feat(ui): add activity indicator governance
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
|
|||||||
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
||||||
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
AdminNavigationPanel,
|
AdminNavigationPanel,
|
||||||
AppHeader,
|
AppHeader,
|
||||||
ApplicationPanel,
|
ApplicationPanel,
|
||||||
@@ -1372,6 +1373,20 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
|
||||||
|
<div className="catalog-inline catalog-inline--wrap">
|
||||||
|
<span className="catalog-inline">
|
||||||
|
<ActivityIndicator label="Загружаем данные" />
|
||||||
|
<span>Загружаем данные</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
aria-busy="true"
|
||||||
|
disabled
|
||||||
|
icon={<ActivityIndicator size="compact" />}
|
||||||
|
>Подключаем…</Button>
|
||||||
|
</div>
|
||||||
|
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||||
|
</Preview>
|
||||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||||
<div className="catalog-window-actions-demo">
|
<div className="catalog-window-actions-demo">
|
||||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ Button используется для всех текстовых действ
|
|||||||
|
|
||||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||||
|
|
||||||
|
## ActivityIndicator
|
||||||
|
|
||||||
|
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||||
|
|
||||||
|
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||||
|
|
||||||
## Field
|
## Field
|
||||||
|
|
||||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
@@ -31,6 +31,8 @@
|
|||||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
|
"test:spark-governance": "node --test scripts/spark-governance-contract.test.mjs",
|
||||||
|
"test:activity-indicator": "node --test scripts/activity-indicator-contract.test.mjs",
|
||||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||||
|
|||||||
@@ -211,6 +211,30 @@
|
|||||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-activity-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-inline-end-color: transparent;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.78;
|
||||||
|
animation: nodedc-activity-indicator-spin 780ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-activity-indicator[data-size="compact"] {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nodedc-activity-indicator-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-icon-button {
|
.nodedc-icon-button {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: var(--nodedc-icon-button-size);
|
width: var(--nodedc-icon-button-size);
|
||||||
@@ -3585,6 +3609,7 @@ textarea.nodedc-field__control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nodedc-activity-indicator,
|
||||||
.nodedc-dropdown-surface,
|
.nodedc-dropdown-surface,
|
||||||
.nodedc-overlay,
|
.nodedc-overlay,
|
||||||
.nodedc-window,
|
.nodedc-window,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
|
export type ActivityIndicatorSize = "default" | "compact";
|
||||||
|
|
||||||
|
export interface ActivityIndicatorProps extends Omit<
|
||||||
|
HTMLAttributes<HTMLSpanElement>,
|
||||||
|
"aria-hidden" | "aria-label" | "children" | "role"
|
||||||
|
> {
|
||||||
|
size?: ActivityIndicatorSize;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityIndicator({
|
||||||
|
size = "default",
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ActivityIndicatorProps) {
|
||||||
|
const accessibleLabel = label?.trim() || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={cn("nodedc-activity-indicator", className)}
|
||||||
|
data-size={size === "default" ? undefined : size}
|
||||||
|
role={accessibleLabel ? "status" : undefined}
|
||||||
|
aria-label={accessibleLabel}
|
||||||
|
aria-hidden={accessibleLabel ? undefined : "true"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./AppHeader.js";
|
export * from "./AppHeader.js";
|
||||||
|
export * from "./ActivityIndicator.js";
|
||||||
export * from "./AdminNavigationPanel.js";
|
export * from "./AdminNavigationPanel.js";
|
||||||
export * from "./ApplicationShell.js";
|
export * from "./ApplicationShell.js";
|
||||||
export * from "./ApplicationSidePanel.js";
|
export * from "./ApplicationSidePanel.js";
|
||||||
|
|||||||
@@ -33,6 +33,22 @@
|
|||||||
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-indicator",
|
||||||
|
"status": "baseline",
|
||||||
|
"package": "@nodedc/ui-react",
|
||||||
|
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
|
||||||
|
"domContract": ["nodedc-activity-indicator"],
|
||||||
|
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
|
||||||
|
"variants": ["default", "compact"],
|
||||||
|
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
|
||||||
|
"rules": [
|
||||||
|
"Use compact inside a Button icon slot and default for standalone inline progress.",
|
||||||
|
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
||||||
|
"The component never owns operation state, timing or completion.",
|
||||||
|
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "field",
|
"id": "field",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
|
||||||
|
|
||||||
|
test("ActivityIndicator separates decorative and announced progress", () => {
|
||||||
|
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
|
||||||
|
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
|
||||||
|
|
||||||
|
assert.match(decorative, /class="nodedc-activity-indicator"/);
|
||||||
|
assert.match(decorative, /data-size="compact"/);
|
||||||
|
assert.match(decorative, /aria-hidden="true"/);
|
||||||
|
assert.doesNotMatch(decorative, /role="status"/);
|
||||||
|
|
||||||
|
assert.match(announced, /role="status"/);
|
||||||
|
assert.match(announced, /aria-label="Подключаем устройство"/);
|
||||||
|
assert.doesNotMatch(announced, /aria-hidden/);
|
||||||
|
assert.doesNotMatch(announced, /data-size=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
|
||||||
|
const [styles, registrySource, docs, catalog] = await Promise.all([
|
||||||
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
const registry = JSON.parse(registrySource);
|
||||||
|
const entry = registry.components.find((component) => component.id === "activity-indicator");
|
||||||
|
|
||||||
|
assert.deepEqual(entry?.variants, ["default", "compact"]);
|
||||||
|
assert.ok(entry?.exports.includes("ActivityIndicator"));
|
||||||
|
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
|
||||||
|
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
|
||||||
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
|
||||||
|
assert.match(docs, /## ActivityIndicator/);
|
||||||
|
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
|
||||||
|
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
function lineNumber(lines, value) {
|
||||||
|
return lines.findIndex((line) => line.includes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLine(lines, value, message) {
|
||||||
|
const index = lineNumber(lines, value);
|
||||||
|
assert.ok(index >= 0, `${message} (expected near line 1+, missing: ${value})`);
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBlock(lines, key) {
|
||||||
|
const start = lines.findIndex((line) => line.trim() === `${key} = """`);
|
||||||
|
assert.ok(start >= 0, `missing ${key} block start`);
|
||||||
|
let end = start + 1;
|
||||||
|
while (end < lines.length && lines[end] !== "\"\"\"") {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
assert.ok(end < lines.length, `unterminated ${key} block`);
|
||||||
|
return lines.slice(start + 1, end).join("\n").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("global spark agent configuration matches governance contract", async () => {
|
||||||
|
const configLines = (await readFile(new URL("../.codex/config.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
||||||
|
const agentsLine = assertLine(configLines, "[agents]", "global config should declare [agents]");
|
||||||
|
const enabledLine = assertLine(configLines, 'enabled = true', "global config should enable agents");
|
||||||
|
const threadLine = assertLine(configLines, "max_concurrent_threads_per_session = 2", "global config should cap threads at 2");
|
||||||
|
const defaultModelLine = assertLine(configLines, 'default_subagent_model = "gpt-5.6-terra"', "global config should set default subagent model");
|
||||||
|
const effortLine = assertLine(configLines, 'default_subagent_reasoning_effort = "low"', "global config should set default low reasoning for unpinned subagents");
|
||||||
|
const interruptLine = assertLine(configLines, "interrupt_message = true", "global config should keep interruption messages enabled");
|
||||||
|
|
||||||
|
assert.equal(configLines[agentsLine - 1].trim(), "[agents]", "[agents] block should be present");
|
||||||
|
assert.equal(configLines[enabledLine - 1].trim(), 'enabled = true');
|
||||||
|
assert.equal(configLines[threadLine - 1].trim(), "max_concurrent_threads_per_session = 2");
|
||||||
|
assert.equal(configLines[defaultModelLine - 1].trim(), 'default_subagent_model = "gpt-5.6-terra"');
|
||||||
|
assert.equal(configLines[effortLine - 1].trim(), 'default_subagent_reasoning_effort = "low"');
|
||||||
|
assert.equal(configLines[interruptLine - 1].trim(), "interrupt_message = true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spark_explorer contract", async () => {
|
||||||
|
const explorerLines = (await readFile(new URL("../.codex/agents/spark-explorer.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
||||||
|
const instructions = extractBlock(explorerLines, "developer_instructions");
|
||||||
|
|
||||||
|
const nameLine = assertLine(explorerLines, 'name = "spark_explorer"', "explorer name should be spark_explorer");
|
||||||
|
const modelLine = assertLine(explorerLines, 'model = "gpt-5.3-codex-spark"', "explorer should pin model gpt-5.3-codex-spark");
|
||||||
|
const effortLine = assertLine(explorerLines, 'model_reasoning_effort = "medium"', "explorer should use medium reasoning");
|
||||||
|
const sandboxLine = assertLine(explorerLines, 'sandbox_mode = "read-only"', "explorer should use read-only sandbox mode");
|
||||||
|
|
||||||
|
assert.equal(explorerLines[nameLine - 1].trim(), 'name = "spark_explorer"');
|
||||||
|
assert.equal(explorerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
|
||||||
|
assert.equal(explorerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
|
||||||
|
assert.equal(explorerLines[sandboxLine - 1].trim(), 'sandbox_mode = "read-only"');
|
||||||
|
assert.ok(
|
||||||
|
instructions.includes("Never edit, create, move, or delete files."),
|
||||||
|
"explorer instructions must prohibit edits",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
instructions.includes("Never spawn another agent."),
|
||||||
|
"explorer instructions must prohibit nested agents",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spark_worker contract", async () => {
|
||||||
|
const workerLines = (await readFile(new URL("../.codex/agents/spark-worker.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
||||||
|
const instructions = extractBlock(workerLines, "developer_instructions");
|
||||||
|
|
||||||
|
const nameLine = assertLine(workerLines, 'name = "spark_worker"', "worker name should be spark_worker");
|
||||||
|
const modelLine = assertLine(workerLines, 'model = "gpt-5.3-codex-spark"', "worker should pin model gpt-5.3-codex-spark");
|
||||||
|
const effortLine = assertLine(workerLines, 'model_reasoning_effort = "medium"', "worker should use medium reasoning");
|
||||||
|
const sandboxLine = assertLine(workerLines, 'sandbox_mode = "workspace-write"', "worker should use workspace-write sandbox mode");
|
||||||
|
|
||||||
|
assert.equal(workerLines[nameLine - 1].trim(), 'name = "spark_worker"');
|
||||||
|
assert.equal(workerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
|
||||||
|
assert.equal(workerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
|
||||||
|
assert.equal(workerLines[sandboxLine - 1].trim(), 'sandbox_mode = "workspace-write"');
|
||||||
|
assert.ok(
|
||||||
|
instructions.includes("exact allowed file"),
|
||||||
|
"worker instructions must require exact allowed path allowlist",
|
||||||
|
);
|
||||||
|
assert.ok(instructions.includes("Never commit, push, deploy, install dependencies, or change external systems."), "worker must not commit, push, deploy, or install");
|
||||||
|
assert.ok(instructions.includes("Never spawn another agent."), "worker must prohibit nested agents");
|
||||||
|
assert.ok(instructions.includes("One corrective retry is allowed after a failed check; then stop and return the failure evidence."), "worker must limit corrective retries");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AGENTS and governance contract documents", async () => {
|
||||||
|
const agentsLines = (await readFile(new URL("../AGENTS.md", import.meta.url), "utf8")).split(/\r?\n/);
|
||||||
|
const governanceLines = (await readFile(new URL("../docs/CODEX_SUBAGENT_GOVERNANCE.md", import.meta.url), "utf8")).split(/\r?\n/);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
agentsLines.some((line) => line.includes("`docs/CODEX_SUBAGENT_GOVERNANCE.md`")),
|
||||||
|
"AGENTS.md must point to governance document",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
agentsLines.some((line) => line.toLowerCase().includes("at most two subagents")),
|
||||||
|
"AGENTS.md should state max two subagents",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
agentsLines.some((line) => line.toLowerCase().includes("one write-capable")),
|
||||||
|
"AGENTS.md should state max one writer subagent",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("## Mandatory task packet")),
|
||||||
|
"governance should define mandatory task packet",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("1. Objective: one concrete outcome.")),
|
||||||
|
"governance should include mandatory objective field",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("2. Allowed scope: exact files, directories, or read-only tools.")),
|
||||||
|
"governance should include allowed scope field",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("3. Forbidden actions: especially external writes, commits, pushes, and deploys.")),
|
||||||
|
"governance should include forbidden actions field",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("4. Acceptance criteria: observable evidence of completion.")),
|
||||||
|
"governance should include acceptance criteria field",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("5. Verification: exact checks the worker may run.")),
|
||||||
|
"governance should include verification field",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("6. Output: a short structured summary, not raw logs.")),
|
||||||
|
"governance should include output field",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
governanceLines.some((line) => line.includes("Reviews the diff and performs final verification itself.")),
|
||||||
|
"governance should retain final primary review and verification ownership",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user