@tea-agent/loop-agent 0.42.0-next.15 → 0.42.0-next.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/application/task-lifecycle/advance.js +9 -4
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/task-source-prepare.js +3 -1
  5. package/dist/executors/dag-pi-executor.js +825 -65
  6. package/dist/executors/shell-executor.js +103 -35
  7. package/dist/shared/dag-failure-category.js +6 -0
  8. package/dist/task/contract/apply.js +36 -2
  9. package/dist/task/source-prepare/parse-intent.js +7 -0
  10. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  11. package/dist/workflows/dag/frontend-design-policy.js +1 -1
  12. package/dist/workflows/dag/frontend-risk.js +2 -0
  13. package/dist/workflows/dag/frontend-shadow-dual-write.js +16 -1
  14. package/dist/workflows/dag/frontend-shape.js +16 -6
  15. package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
  16. package/dist/workflows/dag/frontend-verification-trace.js +32 -0
  17. package/dist/workflows/dag/init-hybrid.js +9 -10
  18. package/dist/workflows/dag/node-execution.js +124 -72
  19. package/dist/workflows/dag/rerun-feedback.js +1 -0
  20. package/dist/workflows/dag/rerun-plan.js +90 -4
  21. package/dist/workflows/dag/rerun-run.js +7 -0
  22. package/dist/workflows/dag/retry-policy.js +16 -10
  23. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  24. package/dist/workflows/dag/runner.js +67 -7
  25. package/dist/workflows/dag/structured-output-repair.js +4 -1
  26. package/dist/workflows/dag/validate.js +10 -8
  27. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  28. package/docs/templates/agent-dag.schema.json +2 -2
  29. package/package.json +1 -1
  30. package/skills/frontend-plan/SKILL.md +6 -1
  31. package/skills/frontend-plan/references/decision-contract.md +69 -18
  32. package/skills/frontend-plan/references/design-decisions.md +32 -0
@@ -32,11 +32,13 @@ import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFro
32
32
  import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
33
33
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
34
34
  import { computeActionPayloadHash, } from "../../infrastructure/console/operation-store.js";
35
+ import { classifyFailureOwner, failureOwnerSchema } from "./frontend-recovery-capsule.js";
35
36
  import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, claimDagRecoveryLease, } from "./recovery-lease.js";
36
37
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
37
38
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
38
39
  import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
39
40
  import { executeDynamicReduction } from "./dynamic-runtime/reduction.js";
41
+ import { installRunnerExitDiagnostics } from "./runner-exit-diagnostics.js";
40
42
  export { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions };
41
43
  export async function loadDagSpecFromFile(dagPath) {
42
44
  const raw = JSON.parse(await readFile(dagPath, "utf-8"));
@@ -602,6 +604,36 @@ async function executeDagCheckpoint(input) {
602
604
  const onSigInt = () => shutdownOnSignal("SIGINT");
603
605
  process.once("SIGTERM", onSigTerm);
604
606
  process.once("SIGINT", onSigInt);
607
+ // Exit-time diagnostics (silent-death forensics): write an `armed` line at
608
+ // checkpoint start and append process-exit / uncaught-exception /
609
+ // unhandled-rejection lines with a live state snapshot so an abnormal death
610
+ // (supervisor hard kill, wedged event loop, crash with lost stderr) leaves
611
+ // a durable trace next to state.json. Removed on clean terminal return.
612
+ const exitDiagnostics = installRunnerExitDiagnostics({
613
+ runDir,
614
+ runId: state.runId,
615
+ title: state.title,
616
+ snapshot: () => {
617
+ const heartbeatAt = state.runner?.heartbeatAt;
618
+ return {
619
+ status: state.status,
620
+ terminalReason: state.terminalReason,
621
+ failureCategory: state.failureCategory,
622
+ heartbeatAt,
623
+ heartbeatAgeMs: heartbeatAt === undefined
624
+ ? undefined
625
+ : Math.max(0, Date.now() - Date.parse(heartbeatAt)),
626
+ running: Object.values(state.nodes)
627
+ .filter((node) => node.status === "RUNNING")
628
+ .map((node) => ({
629
+ id: node.id,
630
+ attempt: node.currentAttempt,
631
+ startedAt: node.startedAt,
632
+ lastMeaningfulProgressAt: node.lastMeaningfulProgressAt,
633
+ })),
634
+ };
635
+ },
636
+ });
605
637
  try {
606
638
  const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
607
639
  const baseExecuteNode = input.executeNode ??
@@ -938,6 +970,7 @@ async function executeDagCheckpoint(input) {
938
970
  finally {
939
971
  clearInterval(heartbeatTimer);
940
972
  interruptWatcher?.stop();
973
+ exitDiagnostics.stop();
941
974
  process.removeListener("SIGTERM", onSigTerm);
942
975
  process.removeListener("SIGINT", onSigInt);
943
976
  }
@@ -1296,13 +1329,40 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1296
1329
  if (verifyFailed) {
1297
1330
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1298
1331
  if (continuationCount < frontendRecoveryQuota) {
1299
- recoveryTrigger = {
1300
- requestId: state.runId,
1301
- failureSource: "frontend-verify-shell",
1302
- failureOwner: "implementation",
1303
- protocolFailureReason: "frontend verification failure",
1304
- recoveryMode: "continuation",
1305
- };
1332
+ let recoveryOwner = classifyFailureOwner({
1333
+ nodeId: "frontend-verify-shell",
1334
+ rawFailureCategory: state.nodes["frontend-verify-shell"]?.failureCategory,
1335
+ });
1336
+ try {
1337
+ const verifyFailure = JSON.parse(await readFile(path.join(runDir, "contracts/frontend-verify-failure.json"), "utf8"));
1338
+ const owner = failureOwnerSchema.safeParse(verifyFailure.failureOwner);
1339
+ if (verifyFailure.schemaVersion === 1 &&
1340
+ verifyFailure.schemaId === "frontend-verify-failure-v1" &&
1341
+ verifyFailure.nodeId === "frontend-verify-shell" &&
1342
+ verifyFailure.failureCategory === state.nodes["frontend-verify-shell"]?.failureCategory &&
1343
+ owner.success) {
1344
+ recoveryOwner = owner.data;
1345
+ }
1346
+ else {
1347
+ recoveryOwner = "unknown";
1348
+ }
1349
+ }
1350
+ catch {
1351
+ // The node's classified category remains the fallback; never turn
1352
+ // unknown/environment/configuration failures into writer work.
1353
+ }
1354
+ if (recoveryOwner === "implementation")
1355
+ recoveryTrigger = {
1356
+ requestId: state.runId,
1357
+ failureSource: "frontend-verify-shell",
1358
+ failureOwner: recoveryOwner,
1359
+ protocolFailureReason: "frontend verification failure",
1360
+ recoveryMode: "continuation",
1361
+ };
1362
+ else
1363
+ state.terminalReason = recoveryOwner === "environment"
1364
+ ? "frontend-verification-wait-external"
1365
+ : `frontend-verification-${recoveryOwner}-requires-contract-review`;
1306
1366
  }
1307
1367
  }
1308
1368
  }
@@ -112,7 +112,10 @@ export function isStructuredRepairableFailureCategory(category) {
112
112
  return (category === "invalid-output" ||
113
113
  category === "structured-output-truncated" ||
114
114
  category === "output-too-large" ||
115
- category === "empty-output");
115
+ category === "empty-output" ||
116
+ // A length-truncated structured turn with a non-empty candidate artifact
117
+ // still repairs best through the frozen artifact repair prompt.
118
+ category === "output-limit");
116
119
  }
117
120
  export function formatStructuredArtifactPointer(input) {
118
121
  return [
@@ -3,7 +3,7 @@ import { resolveShellCommands } from "../../executors/shell-executor.js";
3
3
  import { pathMatchesPattern } from "../../shared/git-progress.js";
4
4
  import { resolveRepairTaskForGate } from "./repair-artifact.js";
5
5
  import { topoSortToRanks } from "./topo.js";
6
- import { isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, CONTEXT_OVERFLOW_RETRY_CATEGORY, } from "./retry-policy.js";
6
+ import { isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, OUTPUT_LIMIT_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, CONTEXT_OVERFLOW_RETRY_CATEGORY, } from "./retry-policy.js";
7
7
  import { isBackendTestCompletenessRetryCandidate } from "./backend-test-writer-completeness.js";
8
8
  import { defaultFrontendTestLayout, frontendTestLayoutFromSpec, } from "./frontend-test-layout.js";
9
9
  const GOVERNANCE_WARNING_TYPES = new Set([
@@ -630,27 +630,28 @@ function validateRetryPolicyTaskConfig(task, issues) {
630
630
  const allowed = new Set([
631
631
  WRITER_EMPTY_DIFF_RETRY_CATEGORY,
632
632
  INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
633
+ OUTPUT_LIMIT_RETRY_CATEGORY,
633
634
  ]);
634
635
  const unsupported = task.retryPolicy.retryCategories.filter((category) => !allowed.has(category));
635
636
  if (task.retryPolicy.maxAttempts < 2 ||
636
- task.retryPolicy.maxAttempts > 3 ||
637
+ task.retryPolicy.maxAttempts > 5 ||
637
638
  unsupported.length > 0 ||
638
639
  !categories.has(WRITER_EMPTY_DIFF_RETRY_CATEGORY)) {
639
640
  issues.push({
640
641
  type: "invalid-retry-policy",
641
- message: `task ${task.id} backend-test writer retryPolicy must use 2..3 total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY} and/or ${INCOMPLETE_WRITE_SET_RETRY_CATEGORY}`,
642
+ message: `task ${task.id} backend-test writer retryPolicy must use 2..5 total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}, ${INCOMPLETE_WRITE_SET_RETRY_CATEGORY}, and/or ${OUTPUT_LIMIT_RETRY_CATEGORY}`,
642
643
  });
643
644
  }
644
645
  return;
645
646
  }
646
647
  if (isWriterEmptyDiffCandidate &&
647
648
  (task.retryPolicy.maxAttempts !== 2 ||
648
- task.retryPolicy.retryCategories.length !== 1 ||
649
- task.retryPolicy.retryCategories[0] !==
650
- WRITER_EMPTY_DIFF_RETRY_CATEGORY)) {
649
+ !task.retryPolicy.retryCategories.includes(WRITER_EMPTY_DIFF_RETRY_CATEGORY) ||
650
+ task.retryPolicy.retryCategories.some((category) => category !== WRITER_EMPTY_DIFF_RETRY_CATEGORY &&
651
+ category !== OUTPUT_LIMIT_RETRY_CATEGORY))) {
651
652
  issues.push({
652
653
  type: "invalid-retry-policy",
653
- message: `task ${task.id} writer retryPolicy must use exactly two total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}`,
654
+ message: `task ${task.id} writer retryPolicy must use exactly two total attempts and ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}, with optional ${OUTPUT_LIMIT_RETRY_CATEGORY}`,
654
655
  });
655
656
  return;
656
657
  }
@@ -659,6 +660,7 @@ function validateRetryPolicyTaskConfig(task, issues) {
659
660
  const allowedWriterTransportCategories = new Set([
660
661
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
661
662
  CONTEXT_OVERFLOW_RETRY_CATEGORY,
663
+ OUTPUT_LIMIT_RETRY_CATEGORY,
662
664
  ]);
663
665
  const allAllowed = [...categories].every((category) => allowedWriterTransportCategories.has(category));
664
666
  if (task.retryPolicy.maxAttempts !== 2 ||
@@ -667,7 +669,7 @@ function validateRetryPolicyTaskConfig(task, issues) {
667
669
  !categories.has(WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY)) {
668
670
  issues.push({
669
671
  type: "invalid-retry-policy",
670
- message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only writer-clean-timeout (plus optional context-overflow)`,
672
+ message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only writer-clean-timeout (plus optional context-overflow/output-limit)`,
671
673
  });
672
674
  }
673
675
  }
@@ -184,3 +184,69 @@ export function workspaceFingerprintsMatch(left, right) {
184
184
  return false;
185
185
  return left.fingerprint === right.fingerprint;
186
186
  }
187
+ /**
188
+ * Path-level drift between two checkpoints: paths whose content hash,
189
+ * porcelain state, or presence differs (changed paths plus stable Task
190
+ * Contract governance inputs). The whole-tree fingerprint also moves on HEAD
191
+ * commits or porcelain churn that leaves every path's content untouched;
192
+ * only content-level path diffs are actionable for scoped rerun eligibility.
193
+ * Returns undefined when either checkpoint lacks a path inventory so callers
194
+ * fail closed to the strict whole-fingerprint comparison.
195
+ */
196
+ export function workspaceDriftedPaths(parent, current) {
197
+ // changedPaths is only the porcelain inventory; it cannot explain files
198
+ // already committed between two different HEADs. Never localize that drift.
199
+ if (!parent.gitHeadSha ||
200
+ !current.gitHeadSha ||
201
+ parent.gitHeadSha !== current.gitHeadSha) {
202
+ return undefined;
203
+ }
204
+ if (!Array.isArray(parent.changedPaths) || !Array.isArray(current.changedPaths)) {
205
+ return undefined;
206
+ }
207
+ // Porcelain v1 records only the rename destination in this checkpoint
208
+ // schema, so the removed source path cannot be proven safe for imported
209
+ // readers. Keep scoped reuse fail-closed until both sides expose complete
210
+ // rename lineage.
211
+ if (parent.changedPaths.some((entry) => entry.state === "renamed") ||
212
+ current.changedPaths.some((entry) => entry.state === "renamed")) {
213
+ return undefined;
214
+ }
215
+ if ((parent.stableGovernanceInputs === undefined) !==
216
+ (current.stableGovernanceInputs === undefined)) {
217
+ return undefined;
218
+ }
219
+ const drifted = new Set();
220
+ const parentByPath = new Map(parent.changedPaths.map((entry) => [entry.path, entry]));
221
+ const currentByPath = new Map(current.changedPaths.map((entry) => [entry.path, entry]));
222
+ for (const [pathKey, parentEntry] of parentByPath) {
223
+ const currentEntry = currentByPath.get(pathKey);
224
+ if (!currentEntry) {
225
+ drifted.add(pathKey);
226
+ continue;
227
+ }
228
+ if (parentEntry.contentSha256 !== currentEntry.contentSha256 ||
229
+ parentEntry.state !== currentEntry.state ||
230
+ parentEntry.mode !== currentEntry.mode) {
231
+ drifted.add(pathKey);
232
+ }
233
+ }
234
+ for (const pathKey of currentByPath.keys()) {
235
+ if (!parentByPath.has(pathKey))
236
+ drifted.add(pathKey);
237
+ }
238
+ if (parent.stableGovernanceInputs && current.stableGovernanceInputs) {
239
+ const parentGov = new Map(parent.stableGovernanceInputs.map((entry) => [entry.path, entry.contentSha256]));
240
+ const currentGov = new Map(current.stableGovernanceInputs.map((entry) => [entry.path, entry.contentSha256]));
241
+ for (const [pathKey, hash] of parentGov) {
242
+ const other = currentGov.get(pathKey);
243
+ if (other === undefined || other !== hash)
244
+ drifted.add(pathKey);
245
+ }
246
+ for (const [pathKey, hash] of currentGov) {
247
+ if (parentGov.get(pathKey) !== hash)
248
+ drifted.add(pathKey);
249
+ }
250
+ }
251
+ return [...drifted];
252
+ }
@@ -466,9 +466,9 @@
466
466
  "retryCategories": {
467
467
  "type": "array",
468
468
  "items": {
469
- "enum": ["timeout", "network", "rate-limit", "unavailable", "empty-output", "output-too-large", "protocol-invalid", "invalid-output", "writer-empty-diff", "incomplete-write-set", "writer-clean-timeout", "read-burst", "context-overflow"]
469
+ "enum": ["timeout", "network", "rate-limit", "unavailable", "empty-output", "output-limit", "output-too-large", "protocol-invalid", "invalid-output", "writer-empty-diff", "incomplete-write-set", "writer-clean-timeout", "read-burst", "context-overflow"]
470
470
  },
471
- "default": ["timeout", "network", "rate-limit", "unavailable", "empty-output"],
471
+ "default": ["timeout", "network", "rate-limit", "unavailable", "empty-output", "output-limit"],
472
472
  "description": "Failure categories eligible for retry. quota/auth/write-guard are never eligible. invalid-output is allowed only on explicit structured/protocol policies; writer-empty-diff and incomplete-write-set are reserved for requireChangedFiles generation/completeness writers; writer-clean-timeout is reserved for standard bounded writers after explicit zero-write-tool-call and empty-diff evidence."
473
473
  }
474
474
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.42.0-next.15",
3
+ "version": "0.42.0-next.16",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -4,7 +4,7 @@ description: Use only for the tool-only frontend decision planning node.
4
4
  references:
5
5
  - path: references/decision-contract.md
6
6
  required: true
7
- maxChars: 5200
7
+ maxChars: 5600
8
8
  - path: references/design-decisions.md
9
9
  required: true
10
10
  maxChars: 3400
@@ -29,6 +29,11 @@ cite `evidencePath` of an existing file; greenfield is `decision=new`.
29
29
  Bind every file in the Contract-owned `requiredDeliverables` inventory. It records
30
30
  explicit source obligations, not every path mentioned by a requirement.
31
31
 
32
+ Cross-slice citations must close in the same session: when a requirement cites a
33
+ verification target committed in another slice's session, re-commit that target
34
+ here with the same id and `replace=true` (union backfill) before finalize. A
35
+ `duplicate` receipt is a correction invitation, not a prohibition.
36
+
32
37
  The runtime owns protected skeleton fields, path/command validation, materialization,
33
38
  and final schema checks. Finish with exactly one `finalize_plan`; return no Markdown
34
39
  narrative. Record a genuine evidence gap when frozen facts are insufficient.
@@ -16,11 +16,9 @@
16
16
 
17
17
  ### Behavior target shape (canonical example)
18
18
 
19
- Behavior targets are machine-traced evidence: each one must name the requirements
20
- it proves and the declared UI states it exercises. Empty `requirementIds` or empty
21
- `uiStates` (when the contract declares any UI state or interaction) is rejected at
22
- plan validation — fix the mapping, do not resubmit unchanged. State names must be
23
- copied verbatim from the declared `uiStates`; never invent synonyms.
19
+ Each behavior target names the requirements it proves and declared UI states it
20
+ exercises. Empty `requirementIds`, or empty `uiStates` when any state/interaction
21
+ is declared, is rejected. Copy state names verbatim; never invent synonyms.
24
22
 
25
23
  ```json
26
24
  {
@@ -36,13 +34,70 @@ the task's declared scope. Capabilities the task source lists as non-goals (e.g.
36
34
  responsive/mobile layout, animations) must not be promised here, and themes or
37
35
  CSS conventions must not be claimed without a Scout-confirmed repository fact.
38
36
 
37
+ ### UI state declaration shape (canonical example)
38
+
39
+ Use the exact state names referenced by behavior targets. Each applicable state
40
+ carries behavior, implementation targets, and verification target ids:
41
+
42
+ ```json
43
+ {
44
+ "uiStates": [
45
+ {
46
+ "name": "disabled-minus",
47
+ "applicable": true,
48
+ "expectedBehavior": "minus button disabled at count 0",
49
+ "implementationTargets": ["src/components/SmokeCounter.tsx"],
50
+ "verificationTargetIds": ["VT-COUNTER-LOCAL-CONSTRAINTS"]
51
+ }
52
+ ],
53
+ "interactions": [
54
+ {
55
+ "name": "refresh",
56
+ "trigger": "User clicks refresh",
57
+ "expectedBehavior": "reloads runs",
58
+ "implementationTargets": ["src/App.tsx"],
59
+ "verificationTargetIds": ["VT-SMOKE-COUNTER-BEHAVIOR"]
60
+ }
61
+ ]
62
+ }
63
+ ```
64
+
65
+ ### Implementation target coverage (canonical example)
66
+
67
+ `targets.files` is the union of every requirement's `implementationTargets`; list
68
+ each required component, integration point, and sibling source. Admission adds
69
+ non-static verification target files to the writer writeSet separately.
70
+
71
+ ```json
72
+ {
73
+ "id": "AC-FE-001",
74
+ "implementationTargets": [
75
+ "src/components/SmokeCounter.tsx",
76
+ "src/App.tsx"
77
+ ],
78
+ "verificationTargetIds": [
79
+ "VT-SMOKE-COUNTER-BEHAVIOR",
80
+ "VT-COUNTER-LOCAL-CONSTRAINTS"
81
+ ]
82
+ }
83
+ ```
84
+
85
+ ### Bidirectional VT cross-reference invariant
86
+
87
+ Every requirement → VT citation must close in both directions: the cited VT's
88
+ `requirementIds` must list the citing requirement back. The checker reports the
89
+ missing direction; fix it in-node by re-submitting the corrected VT entry with
90
+ the same id and `replace=true`. `replace` is a full replacement: the latest
91
+ submission under an id becomes the authoritative entry (a subset re-submission
92
+ is the new rule). A `duplicate` receipt is a correction invitation, not a
93
+ prohibition, and a reverse-reference backfill is not "re-deriving committed
94
+ facts".
95
+
39
96
  ## UX vocabulary (registry first)
40
97
 
41
- - Record `record_state_registry` BEFORE any `record_state_flow`: one compact
42
- global vocabulary of UI-state and interaction names. Coverage may slice by
43
- requirement; UX must not — never one name per requirement number and never a
44
- rename of an already-recorded concept (kebab-case behavior-domain names, e.g.
45
- `planner-task-edit`, `focus-queue-move`).
98
+ - Record `record_state_registry` BEFORE `record_state_flow`: one global vocabulary.
99
+ Coverage may slice by requirement; UX must not. Use stable kebab-case behavior
100
+ names, never AC-number-derived names or renamed committed concepts.
46
101
  - UI states use the contract's declared authoritative ids (`declaredUiStates`
47
102
  in the plan input) when the source declares a state table.
48
103
  - Retry slices see `committedUx` in the plan input: reuse those exact names.
@@ -50,14 +105,10 @@ CSS conventions must not be claimed without a Scout-confirmed repository fact.
50
105
  ## Global decisions
51
106
 
52
107
  Keep route/component placement, Mock/data strategy, and dependency/deviation policy
53
- as explicit global decisions rather than duplicating them across each requirement.
54
- Use only frozen command labels and Scout-confirmed paths.
55
-
56
- Component choices: ONE choice may cover many UI states/interactions explicitly via
57
- `covers` — never one row per interaction. `decision=reuse-existing` REQUIRES
58
- `evidencePath` naming the existing repo file that proves the reuse (the runtime
59
- verifies the file exists); a path that does not exist yet is greenfield and must
60
- use `decision=new`.
108
+ global. Use only frozen command labels and Scout-confirmed paths.
109
+
110
+ One component choice may cover many states/interactions via `covers`.
111
+ `reuse-existing` requires an existing-file `evidencePath`; greenfield uses `new`.
61
112
 
62
113
  For Mock/API, keep the real request path enabled by default. Select only a strategy
63
114
  allowed by the frozen task capability. Active Mock requires deterministic Mock-backed
@@ -15,3 +15,35 @@ Contract. Do not search specification roots or nearby code from Plan.
15
15
  responsive, and accessibility behavior; use not-applicable only with a reason.
16
16
  - Record styling strategy, deviations, conflicts, and unresolved gaps explicitly.
17
17
  Never silently replace a spec-defined component or claim unread evidence.
18
+
19
+ ### Component choice shape (canonical example)
20
+
21
+ `purpose` must exactly match a declared UI state or interaction name — it is the
22
+ coverage key the deterministic policy checks. Do not invent synonyms, and never
23
+ swap a task-mandated component for another name without recording a deviation.
24
+
25
+ ```json
26
+ [
27
+ {
28
+ "purpose": "disabled-minus",
29
+ "component": "SmokeCounter",
30
+ "decision": "new",
31
+ "sourceRequirementIds": ["AC-FE-001"],
32
+ "sourceFragmentId": "REQ-SRC-AC-FE-001",
33
+ "rationale": "task mandates a new SmokeCounter card on the home surface"
34
+ },
35
+ {
36
+ "purpose": "refresh",
37
+ "component": "RunFilterBar",
38
+ "decision": "reuse-existing",
39
+ "rationale": "reuses the Scout-confirmed filter bar for the refresh interaction"
40
+ }
41
+ ]
42
+ ```
43
+
44
+ For `decision: "new"`, pass `sourceRequirementIds` + `sourceFragmentId` and let
45
+ the runtime derive the exact task-source citation — never hand-write a
46
+ `specReference`. A `reuse-existing` choice covers behavioural interactions
47
+ without one row per interaction; each applicable visual UI state still needs its
48
+ own `purpose` match (or an explicitly scoped stylingStrategy covering the visual
49
+ dimension).