@tea-agent/loop-agent 0.26.0 → 0.26.2

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 (197) hide show
  1. package/CHANGELOG.md +1056 -1020
  2. package/README.md +8 -3
  3. package/bin/loop-agent.js +21 -21
  4. package/dist/application/dag/generate-task-dag.js +33 -0
  5. package/dist/cli/command-definitions.js +25 -10
  6. package/dist/cli/help.js +4 -3
  7. package/dist/cli/program.js +43 -17
  8. package/dist/commands/cursor-prompt.js +6 -6
  9. package/dist/commands/import-prd.js +7 -2
  10. package/dist/commands/init.js +7 -5
  11. package/dist/commands/loop-benchmark.js +11 -11
  12. package/dist/commands/pi-reuse-benchmark.js +16 -16
  13. package/dist/commands/task-source-prepare.js +474 -0
  14. package/dist/executors/dag-pi-executor.js +40 -5
  15. package/dist/executors/shell-executor.js +111 -0
  16. package/dist/executors/shell-presets.js +12 -4
  17. package/dist/executors/shell-write-guard.js +161 -25
  18. package/dist/sidecars/cursor-prompt/executor.js +1 -1
  19. package/dist/task/config-types.js +6 -0
  20. package/dist/task/contract/constants.js +1 -0
  21. package/dist/task/contract/project.js +8 -0
  22. package/dist/task/contract/schema.js +1 -0
  23. package/dist/task/frontend-preflight.js +131 -0
  24. package/dist/task/runtime.js +2 -4
  25. package/dist/task/source-prepare/build-draft.js +224 -0
  26. package/dist/task/source-prepare/completeness.js +195 -0
  27. package/dist/task/source-prepare/index.js +7 -0
  28. package/dist/task/source-prepare/parse-intent.js +373 -0
  29. package/dist/task/source-prepare/path-policy.js +197 -0
  30. package/dist/task/source-prepare/prepare.js +506 -0
  31. package/dist/task/source-prepare/reference-integrity.js +274 -0
  32. package/dist/task/source-prepare/types.js +7 -0
  33. package/dist/worker/observability/read-model.js +134 -0
  34. package/dist/worker/observe/static/copy.js +67 -67
  35. package/dist/worker/observe/static/dag-layout.d.ts +31 -31
  36. package/dist/worker/observe/static/dag-layout.js +83 -83
  37. package/dist/worker/observe/static/dom.js +220 -220
  38. package/dist/worker/observe/static/relations.js +133 -133
  39. package/dist/worker/observe/static/router.js +93 -93
  40. package/dist/worker/observe/static/run-processing.js +148 -148
  41. package/dist/worker/observe/static/state.js +61 -0
  42. package/dist/worker/observe/static/styles.css +8 -0
  43. package/dist/worker/observe/static/views/batch.js +227 -227
  44. package/dist/worker/observe/static/views/dag-graph.js +248 -172
  45. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  46. package/dist/worker/observe/static/views/dag.js +4 -11
  47. package/dist/worker/observe/static/views/failures.js +143 -143
  48. package/dist/worker/observe/static/views/feature.js +492 -492
  49. package/dist/worker/observe/static/views/run.js +453 -453
  50. package/dist/worker/observe/static/views/shell.js +7 -7
  51. package/dist/worker/observe/static/views/timeline.js +163 -163
  52. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  53. package/dist/workflows/dag/canvas-observer.js +275 -275
  54. package/dist/workflows/dag/convergence/controller.js +110 -21
  55. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  56. package/dist/workflows/dag/frontend-review-context.js +7 -1
  57. package/dist/workflows/dag/frontend-verification-trace.js +14 -3
  58. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  59. package/dist/workflows/dag/init-hybrid.js +96 -34
  60. package/dist/workflows/dag/output-protocol.js +180 -7
  61. package/dist/workflows/dag/runner.js +141 -52
  62. package/dist/workflows/dag/types.js +4 -0
  63. package/dist/workflows/dag/validate.js +3 -2
  64. package/docs/skills/README.md +7 -7
  65. package/docs/templates/adr.md +60 -60
  66. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  67. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  68. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  69. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  70. package/docs/templates/agent-dag-report.schema.json +473 -473
  71. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  72. package/docs/templates/backend-test-dag.json +100 -8
  73. package/docs/templates/backend-test-result.schema.json +99 -99
  74. package/docs/templates/feature-spec.md +53 -53
  75. package/docs/templates/frontend-design-contract.md +42 -42
  76. package/docs/templates/frontend-eval/fixtures/failures/01-type-build-error.md +17 -17
  77. package/docs/templates/frontend-eval/fixtures/failures/02-unit-component-test-fail.md +16 -16
  78. package/docs/templates/frontend-eval/fixtures/failures/03-fixture-schema-drift.md +16 -16
  79. package/docs/templates/frontend-eval/fixtures/failures/04-missing-loading-empty-error-state.md +16 -16
  80. package/docs/templates/frontend-eval/fixtures/failures/05-forbidden-write-writeset-expansion.md +16 -16
  81. package/docs/templates/frontend-eval/fixtures/failures/06-unapproved-dependency-add.md +16 -16
  82. package/docs/templates/frontend-eval/fixtures/failures/07-mock-production-on.md +21 -21
  83. package/docs/templates/frontend-eval/fixtures/functional/01-simple-component-style.md +29 -29
  84. package/docs/templates/frontend-eval/fixtures/functional/02-form-validation.md +28 -28
  85. package/docs/templates/frontend-eval/fixtures/functional/03-list-detail-page.md +28 -28
  86. package/docs/templates/frontend-eval/fixtures/functional/04-api-mock.md +29 -29
  87. package/docs/templates/frontend-eval/fixtures/functional/05-permission-auth-gated-ui.md +27 -27
  88. package/docs/templates/frontend-eval/fixtures/functional/06-ssr-server-client-boundary.md +28 -28
  89. package/docs/templates/frontend-eval/fixtures/functional/07-shared-public-component-api.md +28 -28
  90. package/docs/templates/frontend-eval/fixtures/functional/08-pure-local-no-remote.md +27 -27
  91. package/docs/templates/frontend-eval/metrics.md +138 -138
  92. package/docs/templates/frontend-eval/smoke-targets.md +53 -53
  93. package/docs/templates/frontend-task-constraints.md +35 -35
  94. package/docs/templates/frontend-task-requirement.md +70 -70
  95. package/docs/templates/init-evolution-review.md +35 -35
  96. package/docs/templates/init-managed-agents.md +10 -5
  97. package/docs/templates/interactive-ui-round2-experiment.md +66 -66
  98. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -118
  99. package/docs/templates/knowledge-sync-dag.json +178 -178
  100. package/docs/templates/knowledge-sync-draft.schema.json +71 -71
  101. package/docs/templates/product-line/closeout.yaml +9 -9
  102. package/docs/templates/product-line/design.md +13 -13
  103. package/docs/templates/product-line/links.md +10 -10
  104. package/docs/templates/product-line/requirement.md +17 -17
  105. package/docs/templates/product-line/test-plan.md +7 -7
  106. package/docs/templates/production-readiness-checklist.md +57 -57
  107. package/docs/templates/project-start-checklist.md +9 -9
  108. package/docs/templates/qa-report.md +48 -48
  109. package/docs/templates/sprint-contract.md +29 -29
  110. package/docs/templates/worker-dogfood-evidence.md +80 -80
  111. package/docs/templates/worker-dogfood-setup.md +68 -68
  112. package/package.json +1 -1
  113. package/scripts/kb-bootstrap-init-skeleton.sh +0 -0
  114. package/scripts/kb-graph-incremental-prepare.mjs +386 -386
  115. package/scripts/kb-graph-materialize.mjs +105 -105
  116. package/scripts/kb-graph-promote.mjs +164 -164
  117. package/scripts/kb-query.mjs +554 -554
  118. package/skills/ai-engineering-context/SKILL.md +48 -48
  119. package/skills/analyze-product-dependencies/SKILL.md +67 -67
  120. package/skills/analyze-product-dependencies/agents/openai.yaml +4 -4
  121. package/skills/analyze-product-dependencies/references/api-documentation-schema.md +30 -30
  122. package/skills/analyze-product-dependencies/references/dependency-analysis-schema.md +28 -28
  123. package/skills/analyze-product-dependencies/references/example.md +76 -76
  124. package/skills/analyze-product-dependencies/references/forward-test-cases.md +35 -35
  125. package/skills/analyze-product-dependencies/references/input-contract.md +11 -11
  126. package/skills/analyze-product-dependencies/references/scouting-rules.md +61 -61
  127. package/skills/analyze-product-dependencies/scripts/test-validators.mjs +267 -267
  128. package/skills/analyze-product-dependencies/scripts/validate-api-documentation.mjs +101 -101
  129. package/skills/analyze-product-dependencies/scripts/validate-dependency-analysis.mjs +142 -142
  130. package/skills/analyze-product-dependencies/scripts/validate-product-requirement-input.mjs +76 -76
  131. package/skills/analyze-product-dependencies/scripts/validation-helpers.mjs +146 -146
  132. package/skills/analyze-product-requirements/SKILL.md +90 -90
  133. package/skills/analyze-product-requirements/agents/openai.yaml +4 -4
  134. package/skills/analyze-product-requirements/references/acceptance-criteria.md +91 -91
  135. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +56 -56
  136. package/skills/analyze-product-requirements/references/example.md +86 -86
  137. package/skills/analyze-product-requirements/references/forward-test-cases.md +66 -66
  138. package/skills/analyze-product-requirements/references/product-analysis-schema.md +32 -32
  139. package/skills/analyze-product-requirements/references/product-requirement-schema.md +33 -33
  140. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +35 -35
  141. package/skills/analyze-product-requirements/scripts/test-validators.mjs +193 -193
  142. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +69 -69
  143. package/skills/analyze-product-requirements/scripts/validate-product-requirement.mjs +97 -97
  144. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +98 -98
  145. package/skills/analyze-product-requirements/scripts/validation-helpers.mjs +156 -156
  146. package/skills/browser-tools/browser-content.js +103 -103
  147. package/skills/browser-tools/browser-cookies.js +35 -35
  148. package/skills/browser-tools/browser-eval.js +53 -53
  149. package/skills/browser-tools/browser-hn-scraper.js +108 -108
  150. package/skills/browser-tools/browser-nav.js +44 -44
  151. package/skills/browser-tools/browser-pick.js +162 -162
  152. package/skills/browser-tools/browser-screenshot.js +34 -34
  153. package/skills/browser-tools/browser-start.js +86 -86
  154. package/skills/browser-tools/package-lock.json +2556 -2556
  155. package/skills/browser-tools/package.json +19 -19
  156. package/skills/code-review-core/SKILL.md +20 -20
  157. package/skills/codebase-scout/SKILL.md +19 -19
  158. package/skills/grill-me/SKILL.md +10 -10
  159. package/skills/loop-agent/SKILL.md +5 -2
  160. package/skills/loop-agent/references/README.md +67 -67
  161. package/skills/loop-agent/references/command-reference.md +17 -15
  162. package/skills/loop-agent/references/docs-converge.md +126 -126
  163. package/skills/loop-agent/references/harness-policy.md +3 -4
  164. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  165. package/skills/loop-agent/references/learned/README.md +21 -21
  166. package/skills/loop-agent/references/long-running-loop.md +57 -57
  167. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  168. package/skills/loop-agent/references/pi-prompt.md +23 -23
  169. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +84 -84
  170. package/skills/loop-agent/references/post-implementation-and-patterns.md +1 -1
  171. package/skills/loop-agent/references/source-and-plan-practice.md +3 -2
  172. package/skills/loop-agent/references/task-workflow.md +7 -5
  173. package/skills/playwright-cli/SKILL.md +420 -420
  174. package/skills/playwright-cli/references/element-attributes.md +23 -23
  175. package/skills/playwright-cli/references/playwright-tests.md +39 -39
  176. package/skills/playwright-cli/references/request-mocking.md +87 -87
  177. package/skills/playwright-cli/references/running-code.md +241 -241
  178. package/skills/playwright-cli/references/session-management.md +225 -225
  179. package/skills/playwright-cli/references/storage-state.md +275 -275
  180. package/skills/playwright-cli/references/test-generation.md +433 -433
  181. package/skills/playwright-cli/references/tracing.md +139 -139
  182. package/skills/playwright-cli/references/video-recording.md +143 -143
  183. package/skills/requesting-code-review/SKILL.md +101 -101
  184. package/skills/requesting-code-review/code-reviewer.md +168 -168
  185. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  186. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  187. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  188. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  189. package/skills/systematic-debugging/find-polluter.sh +63 -63
  190. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  191. package/skills/systematic-debugging/test-academic.md +14 -14
  192. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  193. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  194. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  195. package/skills/using-git-worktrees/SKILL.md +215 -215
  196. package/skills/verification-before-completion/SKILL.md +154 -154
  197. package/skills/webapp-testing/SKILL.md +19 -19
@@ -18,7 +18,14 @@ const DEFAULT_CONVERGENCE_CHAIN_NODE_IDS = [
18
18
  */
19
19
  const REVIEW_GATE_NODE_ID = "review-gate-shell";
20
20
  const REVIEW_VERDICT_NODE_ID = "review-verdict-recovery-pi";
21
- const CONVERGENCE_NON_RETRY_FAILURES = new Set([
21
+ const INITIAL_REVIEW_NODE_ID = "initial-review-pi";
22
+ const INITIAL_REVIEW_GATE_NODE_ID = "initial-review-gate-shell";
23
+ const INITIAL_REVIEW_PASS_NODE_ID = "initial-review-pass-shell";
24
+ // Automatic code repair is authorized only for an ordinary command/test
25
+ // failure. Unknown, protocol, validation, provider, safety, governance, and
26
+ // human-decision categories all fail closed instead of reaching a writer.
27
+ const HARD_VERIFY_REPAIR_ELIGIBLE_FAILURES = new Set(["nonzero-exit"]);
28
+ const REVIEW_SOURCE_NON_RETRY_FAILURES = new Set([
22
29
  "timeout",
23
30
  "spawn-error",
24
31
  "write-guard",
@@ -26,9 +33,6 @@ const CONVERGENCE_NON_RETRY_FAILURES = new Set([
26
33
  "auth",
27
34
  "human-rejected",
28
35
  "decision-gate-requires-human",
29
- ]);
30
- const REVIEW_SOURCE_NON_RETRY_FAILURES = new Set([
31
- ...CONVERGENCE_NON_RETRY_FAILURES,
32
36
  "protocol-invalid",
33
37
  "invalid-output",
34
38
  ]);
@@ -38,26 +42,61 @@ export function shouldEnableDagConvergence(spec) {
38
42
  }
39
43
  export async function runConvergencePassController(input) {
40
44
  const convergence = input.state.convergence;
41
- if (!convergence?.enabled)
45
+ if (!convergence)
42
46
  return { retry: false };
43
47
  if (process.env.HARNESS_DAG_CONVERGENCE === "off") {
44
48
  convergence.terminalReason = "feature-flag-off";
45
49
  return { retry: false };
46
50
  }
51
+ if (!convergence.enabled) {
52
+ return resolveNoRepairTerminal(input);
53
+ }
47
54
  if (!hasConvergenceChain(input.tasksById, input.spec)) {
48
55
  convergence.terminalReason = "unsupported-dag-shape";
49
56
  return { retry: false };
50
57
  }
51
58
  const chain = getConvergenceChain(input.spec);
52
59
  const observesReview = chain.includes(REVIEW_GATE_NODE_ID);
60
+ if (input.tasksById.has(INITIAL_REVIEW_NODE_ID)) {
61
+ const initialReview = input.state.nodes[INITIAL_REVIEW_NODE_ID];
62
+ const initialGate = input.state.nodes[INITIAL_REVIEW_GATE_NODE_ID];
63
+ const initialPass = input.state.nodes[INITIAL_REVIEW_PASS_NODE_ID];
64
+ if (initialReview?.status === "ERROR" || initialGate?.status === "ERROR") {
65
+ convergence.terminalReason = "non-retry-failure";
66
+ return { retry: false };
67
+ }
68
+ if (initialPass?.status === "FINISHED" &&
69
+ parseProcessVerdict(initialReview) === "pass") {
70
+ convergence.terminalReason = "review-pass";
71
+ return { retry: false };
72
+ }
73
+ const repair = input.state.nodes["repair-pi"];
74
+ if (convergence.currentPass === 1 &&
75
+ parseProcessVerdict(initialReview) === "request-revision" &&
76
+ (repair?.status === "FINISHED" || repair?.status === "ERROR")) {
77
+ // Pass 1 is the initial review. The first executed repair chain is pass 2,
78
+ // even though the scheduler completes it before the controller first runs.
79
+ convergence.currentPass = 2;
80
+ }
81
+ if (repair?.status === "ERROR") {
82
+ convergence.terminalReason = "non-retry-failure";
83
+ return { retry: false };
84
+ }
85
+ }
53
86
  const hardVerify = input.state.nodes["hard-verify-shell"];
54
87
  if (!hardVerify)
55
88
  return { retry: false };
56
89
  if (hardVerify.status === "ERROR") {
57
90
  return handleHardVerifyFailure(input, hardVerify);
58
91
  }
59
- if (hardVerify.status !== "FINISHED")
92
+ if (hardVerify.status !== "FINISHED") {
93
+ // The scheduler has already settled every ordinary rank before invoking
94
+ // the controller. PENDING/SKIPPED here therefore means an earlier failure
95
+ // blocked the convergence chain; record a terminal reason so the deferred
96
+ // failure-aware closeout remains reachable.
97
+ convergence.terminalReason = "non-retry-failure";
60
98
  return { retry: false };
99
+ }
61
100
  // Hard verification passed. When the chain observes a review gate, success
62
101
  // is gated on the review verdict: a legitimate `request-revision` re-enters
63
102
  // the same bounded repair-reverify-review loop (AC3/AC4). Non-supervised
@@ -74,6 +113,13 @@ export async function runConvergencePassController(input) {
74
113
  const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
75
114
  if (!reviewGate)
76
115
  return { retry: false };
116
+ // Well-formed request-revision is a convergence routing result: the review
117
+ // gate finishes successfully (routingAccept) rather than shell ERROR.
118
+ // Still re-enter the bounded repair chain when revision evidence is valid.
119
+ if (reviewGate.status === "FINISHED" &&
120
+ isLegitimateReviewRequestRevision(input.state)) {
121
+ return handleReviewRequestRevision(input, reviewGate);
122
+ }
77
123
  if (reviewGate.status === "FINISHED") {
78
124
  convergence.terminalReason = "review-pass";
79
125
  await appendConvergenceKnowledgePattern({
@@ -82,10 +128,50 @@ export async function runConvergencePassController(input) {
82
128
  });
83
129
  return { retry: false };
84
130
  }
131
+ // Compatibility: gates without routingAccept still ERROR on revision.
85
132
  if (reviewGate.status === "ERROR") {
86
133
  return handleReviewRequestRevision(input, reviewGate);
87
134
  }
88
- // Review gate still PENDING/SKIPPED mid-rank: wait for the next loop.
135
+ // The scheduler pass is settled; an unexecuted gate is a terminal upstream
136
+ // blockage, not a reason to leave closeout permanently pending.
137
+ convergence.terminalReason = "non-retry-failure";
138
+ return { retry: false };
139
+ }
140
+ /**
141
+ * `maxFixLoops=0` disables writer re-entry, not terminal adjudication. Review
142
+ * and hard-verify evidence still decide whether closeout is a truthful success
143
+ * or a bounded failure. Existing handlers are safe here because maxPasses=1,
144
+ * so they record the terminal pass without resetting any writer nodes.
145
+ */
146
+ async function resolveNoRepairTerminal(input) {
147
+ const convergence = input.state.convergence;
148
+ const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
149
+ if (reviewGate) {
150
+ if (reviewGate.status === "FINISHED" &&
151
+ isLegitimateReviewRequestRevision(input.state)) {
152
+ return handleReviewRequestRevision(input, reviewGate);
153
+ }
154
+ if (reviewGate.status === "FINISHED") {
155
+ convergence.terminalReason = "review-pass";
156
+ return { retry: false };
157
+ }
158
+ if (reviewGate.status === "ERROR") {
159
+ return handleReviewRequestRevision(input, reviewGate);
160
+ }
161
+ convergence.terminalReason = "non-retry-failure";
162
+ return { retry: false };
163
+ }
164
+ const hardVerify = input.state.nodes["hard-verify-shell"];
165
+ if (hardVerify?.status === "ERROR") {
166
+ return handleHardVerifyFailure(input, hardVerify);
167
+ }
168
+ if (hardVerify?.status === "FINISHED") {
169
+ convergence.terminalReason = "hard-verify-pass";
170
+ return { retry: false };
171
+ }
172
+ convergence.terminalReason = hardVerify
173
+ ? "non-retry-failure"
174
+ : "unsupported-dag-shape";
89
175
  return { retry: false };
90
176
  }
91
177
  /**
@@ -104,7 +190,7 @@ async function handleHardVerifyFailure(input, hardVerify) {
104
190
  runDir: input.runDir,
105
191
  spec: input.spec,
106
192
  });
107
- if (CONVERGENCE_NON_RETRY_FAILURES.has(hardFailure)) {
193
+ if (!HARD_VERIFY_REPAIR_ELIGIBLE_FAILURES.has(hardFailure)) {
108
194
  passRecord.status = "terminal";
109
195
  passRecord.reason = "non-retry-failure";
110
196
  convergence.passHistory.push(passRecord);
@@ -145,29 +231,32 @@ async function handleHardVerifyFailure(input, hardVerify) {
145
231
  await input.persistState();
146
232
  return { retry: true };
147
233
  }
234
+ function isLegitimateReviewRequestRevision(state) {
235
+ const reviewNode = state.nodes["review-pi"];
236
+ const reviewVerdictNode = state.nodes[REVIEW_VERDICT_NODE_ID];
237
+ return (reviewNode?.status === "FINISHED" &&
238
+ parseProcessVerdict(reviewNode) === "request-revision" &&
239
+ reviewVerdictNode?.status === "FINISHED" &&
240
+ parseProcessVerdict(reviewVerdictNode) === "request-revision");
241
+ }
148
242
  /**
149
- * Hard verification passed but the review gate blocked closeout with a
150
- * legitimate `request-revision` (review-gate-shell ERRORs when the verdict is
151
- * not `pass`). Re-enter the same bounded recovery chain so repair can address
152
- * the review findings, then re-verify and re-review. Review protocol-invalid
153
- * and invalid-output sources, plus safety failures such as auth/write-guard,
154
- * stay fail-closed and never enter code repair.
243
+ * Hard verification passed but the review path produced a legitimate
244
+ * `request-revision`. With routingAccept the review gate finishes as a routing
245
+ * result (not shell ERROR); older gates may still ERROR. Re-enter the same
246
+ * bounded recovery chain so repair can address the findings, then re-verify
247
+ * and re-review. Protocol/safety failures stay fail-closed.
155
248
  */
156
249
  async function handleReviewRequestRevision(input, reviewGate) {
157
250
  const convergence = input.state.convergence;
158
251
  const currentPass = convergence.currentPass || 1;
159
252
  const reviewNode = input.state.nodes["review-pi"];
160
253
  const reviewVerdictNode = input.state.nodes[REVIEW_VERDICT_NODE_ID];
161
- // The gate commonly reports only nonzero-exit. Inspect the complete review
162
- // chain so recovery output cannot launder provider/safety/protocol failures
163
- // into automatic code repair.
254
+ // Inspect the complete review chain so recovery output cannot launder
255
+ // provider/safety/protocol failures into automatic code repair.
164
256
  const reviewFailure = [reviewGate, reviewVerdictNode, reviewNode]
165
257
  .map((node) => node?.failureCategory)
166
258
  .find((category) => category && REVIEW_SOURCE_NON_RETRY_FAILURES.has(category));
167
- const legitimateRequestRevision = reviewNode?.status === "FINISHED" &&
168
- parseProcessVerdict(reviewNode) === "request-revision" &&
169
- reviewVerdictNode?.status === "FINISHED" &&
170
- parseProcessVerdict(reviewVerdictNode) === "request-revision";
259
+ const legitimateRequestRevision = isLegitimateReviewRequestRevision(input.state);
171
260
  if (reviewFailure || !legitimateRequestRevision) {
172
261
  const passRecord = await buildConvergencePassRecord({
173
262
  pass: currentPass,
@@ -84,6 +84,7 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
84
84
  return JSON.stringify(parsed);
85
85
  }
86
86
  const id = z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/);
87
+ const REQUIREMENT_ID_PATTERN = /^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
87
88
  const safePath = z
88
89
  .string()
89
90
  .min(1)
@@ -366,7 +367,7 @@ export function canonicalFrontendContractSourceBinding(binding) {
366
367
  .filter((source) => source.kind === "reference")
367
368
  .map((source) => source.path)
368
369
  .sort(),
369
- requirementIds: [...binding.requirementIds],
370
+ requirementIds: binding.requirementIds.map(canonicalizeRequirementId),
370
371
  };
371
372
  }
372
373
  function asRecord(value) {
@@ -384,6 +385,137 @@ function asStringArray(value) {
384
385
  .map((item) => asString(item))
385
386
  .filter((item) => item.length > 0);
386
387
  }
388
+ function isRequirementId(value) {
389
+ return REQUIREMENT_ID_PATTERN.test(value);
390
+ }
391
+ /** Normalize the compact IDs models commonly emit (for example AC1) before
392
+ * strict schema validation. This keeps governance strict while making the
393
+ * boundary tolerant of presentation-only formatting differences. */
394
+ function canonicalizeRequirementId(value) {
395
+ const trimmed = value.trim().toUpperCase();
396
+ if (REQUIREMENT_ID_PATTERN.test(trimmed))
397
+ return trimmed;
398
+ const compact = /^(REQ|BR|AC)(\d+)$/.exec(trimmed);
399
+ if (compact)
400
+ return `${compact[1]}-${compact[2]}`;
401
+ return value;
402
+ }
403
+ function canonicalizeRequirementIdsInPayload(value) {
404
+ if (Array.isArray(value))
405
+ return value.map(canonicalizeRequirementIdsInPayload);
406
+ if (!value || typeof value !== "object")
407
+ return value;
408
+ const record = value;
409
+ const out = {};
410
+ for (const [key, child] of Object.entries(record)) {
411
+ if (key === "id" || key === "requirementId") {
412
+ out[key] = typeof child === "string" ? canonicalizeRequirementId(child) : child;
413
+ }
414
+ else if (key === "requirementIds" && Array.isArray(child)) {
415
+ out[key] = child.map((item) => typeof item === "string" ? canonicalizeRequirementId(item) : item);
416
+ }
417
+ else {
418
+ out[key] = canonicalizeRequirementIdsInPayload(child);
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ function canonicalizeVerificationTargetAliases(value) {
424
+ const record = asRecord(value);
425
+ if (!record || !Array.isArray(record.verificationTargets))
426
+ return value;
427
+ const targets = record.verificationTargets
428
+ .map((item) => asRecord(item))
429
+ .filter((item) => Boolean(item));
430
+ const aliases = new Map();
431
+ for (const target of targets) {
432
+ const id = asString(target.id);
433
+ const label = asString(target.commandLabel).toLowerCase();
434
+ if (!id)
435
+ continue;
436
+ aliases.set(id.toLowerCase(), id);
437
+ if (label.includes("typecheck"))
438
+ aliases.set("vt-typecheck", id);
439
+ if (label.includes("unified surface"))
440
+ aliases.set("vt-unified-surface", id);
441
+ }
442
+ const requirements = Array.isArray(record.requirements)
443
+ ? record.requirements.map((item) => {
444
+ const requirement = asRecord(item);
445
+ if (!requirement || !Array.isArray(requirement.verificationTargetIds))
446
+ return item;
447
+ return {
448
+ ...requirement,
449
+ verificationTargetIds: requirement.verificationTargetIds.map((id) => typeof id === "string" ? aliases.get(id.toLowerCase()) ?? id : id),
450
+ };
451
+ })
452
+ : record.requirements;
453
+ return { ...record, requirements };
454
+ }
455
+ function assertFrontendContractPathsSafe(value) {
456
+ const record = asRecord(value);
457
+ if (!record)
458
+ return;
459
+ const pathFields = ["files", "file", "implementationTargets", "fixture", "consumer"];
460
+ for (const [key, child] of Object.entries(record)) {
461
+ if (pathFields.includes(key) && Array.isArray(child)) {
462
+ for (const item of child) {
463
+ if (typeof item === "string" && (item.startsWith("/") || item.includes("\\") || item.split("/").includes("..")))
464
+ throw new Error(`invalid-output: unsafe frontend contract path ${item}`);
465
+ }
466
+ }
467
+ else if (pathFields.includes(key) && typeof child === "string" && (child.startsWith("/") || child.includes("\\") || child.split("/").includes(".."))) {
468
+ throw new Error(`invalid-output: unsafe frontend contract path ${child}`);
469
+ }
470
+ if (Array.isArray(child))
471
+ child.forEach(assertFrontendContractPathsSafe);
472
+ else if (child && typeof child === "object")
473
+ assertFrontendContractPathsSafe(child);
474
+ }
475
+ }
476
+ function deriveContractRequirementIds(value) {
477
+ const record = asRecord(value);
478
+ if (!record)
479
+ return [];
480
+ const ids = [];
481
+ const push = (candidate) => {
482
+ const value = asString(candidate);
483
+ const canonical = canonicalizeRequirementId(value);
484
+ if (!canonical || !isRequirementId(canonical) || ids.includes(canonical))
485
+ return;
486
+ ids.push(canonical);
487
+ };
488
+ if (Array.isArray(record.requirements)) {
489
+ for (const item of record.requirements)
490
+ push(asRecord(item)?.id);
491
+ }
492
+ if (Array.isArray(record.requirementCoverage)) {
493
+ for (const item of record.requirementCoverage)
494
+ push(asRecord(item)?.id);
495
+ }
496
+ const rawVerification = Array.isArray(record.verificationTargets)
497
+ ? record.verificationTargets
498
+ : asRecord(record.verificationTargets)
499
+ ? Object.values(asRecord(record.verificationTargets))
500
+ : [];
501
+ for (const item of rawVerification) {
502
+ for (const requirementId of asStringArray(asRecord(item)?.requirementIds)) {
503
+ push(requirementId);
504
+ }
505
+ }
506
+ for (const item of Array.isArray(record.evidenceGaps) ? record.evidenceGaps : []) {
507
+ push(asRecord(item)?.requirementId);
508
+ }
509
+ return ids;
510
+ }
511
+ function withDerivedRequirementIdsWhenUnscoped(binding, parsed) {
512
+ if (binding.requirementIds.length > 0)
513
+ return binding;
514
+ const derivedIds = deriveContractRequirementIds(parsed);
515
+ return derivedIds.length > 0
516
+ ? { ...binding, requirementIds: derivedIds }
517
+ : binding;
518
+ }
387
519
  function looksLikeStrictFrontendContract(value) {
388
520
  const record = asRecord(value);
389
521
  if (!record)
@@ -401,6 +533,11 @@ function looksLikeStrictFrontendContract(value) {
401
533
  */
402
534
  export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
403
535
  const rawRecord = asRecord(value);
536
+ const rawMockApi = asRecord(rawRecord?.mockApi);
537
+ if (rawMockApi &&
538
+ typeof rawMockApi.strategy === "string" &&
539
+ !["native", "browser-intercept", "request-adapter", "not-needed"].includes(rawMockApi.strategy))
540
+ return value;
404
541
  const verificationTargetIds = rawRecord && Array.isArray(rawRecord.verificationTargets)
405
542
  ? new Set(rawRecord.verificationTargets
406
543
  .map((item) => asString(asRecord(item)?.id))
@@ -414,9 +551,10 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
414
551
  const requirement = asRecord(item);
415
552
  if (!requirement || !Array.isArray(requirement.verificationTargetIds))
416
553
  return item;
554
+ const filtered = requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id));
417
555
  return {
418
556
  ...requirement,
419
- verificationTargetIds: requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id)),
557
+ verificationTargetIds: filtered.length > 0 ? filtered : requirement.verificationTargetIds,
420
558
  };
421
559
  })
422
560
  : rawRecord.requirements,
@@ -444,8 +582,39 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
444
582
  : rawRecord.uiStates,
445
583
  }
446
584
  : value;
447
- if (looksLikeStrictFrontendContract(normalizedValue))
448
- return normalizedValue;
585
+ // A payload can have the strict top-level shape while still containing
586
+ // empty requirement coverage arrays. Do not trust shape alone: route such
587
+ // payloads through the compatibility normalizer so targets and verification
588
+ // references are deterministically filled from the contract context.
589
+ if (looksLikeStrictFrontendContract(normalizedValue)) {
590
+ const strictRecord = asRecord(normalizedValue);
591
+ const strictMockApi = asRecord(strictRecord?.mockApi);
592
+ if (strictMockApi &&
593
+ typeof strictMockApi.strategy === "string" &&
594
+ !["native", "browser-intercept", "request-adapter", "not-needed"].includes(strictMockApi.strategy))
595
+ return normalizedValue;
596
+ const targetFiles = asStringArray(asRecord(strictRecord?.targets)?.files);
597
+ const verificationIds = Array.isArray(strictRecord?.verificationTargets)
598
+ ? strictRecord.verificationTargets.map((item) => asString(asRecord(item)?.id)).filter(Boolean)
599
+ : [];
600
+ if (Array.isArray(strictRecord?.requirements)) {
601
+ const requirements = strictRecord.requirements.map((item) => {
602
+ const requirement = asRecord(item);
603
+ if (!requirement)
604
+ return item;
605
+ return {
606
+ ...requirement,
607
+ implementationTargets: asStringArray(requirement.implementationTargets).length > 0
608
+ ? requirement.implementationTargets
609
+ : targetFiles,
610
+ verificationTargetIds: asStringArray(requirement.verificationTargetIds).length > 0
611
+ ? requirement.verificationTargetIds
612
+ : verificationIds,
613
+ };
614
+ });
615
+ return { ...strictRecord, requirements };
616
+ }
617
+ }
449
618
  const record = asRecord(normalizedValue);
450
619
  if (!record)
451
620
  return value;
@@ -729,8 +898,15 @@ export async function materializeFrontendImplementationContract(input) {
729
898
  throw new Error("frontend implementation contract gate requires DAG sourceBinding");
730
899
  const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
731
900
  let parsed;
901
+ const rawContractText = record.assistantText?.trim() || record.stdout?.trim() || "";
902
+ if (rawContractText.includes('"files":["/'))
903
+ throw new Error("invalid-output: absolute frontend contract path is forbidden");
904
+ if (/(?:"(?:files|file|implementationTargets|fixture|consumer)"\s*:\s*(?:\[\s*)?)"\//.test(rawContractText))
905
+ throw new Error("invalid-output: absolute frontend contract path is forbidden");
906
+ if (/(?:"strategy"\s*:\s*")(?!native\b|browser-intercept\b|request-adapter\b|not-needed\b)[^"]+"/.test(rawContractText))
907
+ throw new Error("invalid-output: unsupported mock strategy");
732
908
  try {
733
- parsed = extractFrontendImplementationJson(record.assistantText?.trim() || record.stdout?.trim() || "");
909
+ parsed = extractFrontendImplementationJson(rawContractText);
734
910
  }
735
911
  catch (error) {
736
912
  throw new Error(`invalid-output: ${error instanceof Error ? error.message : String(error)}`);
@@ -740,21 +916,46 @@ export async function materializeFrontendImplementationContract(input) {
740
916
  throw new Error(`invalid-output: ${secrets.join("; ")}`);
741
917
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
742
918
  throw new Error("invalid-output: frontend contract must be a JSON object");
743
- const canonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
919
+ const parsedTargetObject = parsed.targets;
920
+ const parsedFiles = parsedTargetObject && typeof parsedTargetObject === "object"
921
+ ? parsedTargetObject.files
922
+ : undefined;
923
+ if (Array.isArray(parsedFiles) && parsedFiles.some((file) => typeof file === "string" && file.startsWith("/")))
924
+ throw new Error("invalid-output: absolute frontend contract path is forbidden");
925
+ // Normalize model-emitted compact requirement IDs before deriving the
926
+ // canonical binding or invoking the strict zod schema.
927
+ parsed = canonicalizeRequirementIdsInPayload(parsed);
928
+ parsed = canonicalizeVerificationTargetAliases(parsed);
929
+ assertFrontendContractPathsSafe(parsed);
930
+ const parsedTargets = asRecord(parsed)?.targets;
931
+ const parsedTargetFiles = asStringArray(asRecord(parsedTargets)?.files);
932
+ if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
933
+ throw new Error("invalid-output: frontend contract target paths must be relative POSIX paths");
934
+ const parsedStates = asRecord(parsed)?.uiStates;
935
+ if (Array.isArray(parsedStates) && parsedStates.some((item) => {
936
+ const state = asRecord(item);
937
+ return state?.applicable === true &&
938
+ (!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
939
+ }))
940
+ throw new Error("invalid-output: applicable UI state requires behavior, implementation, and verification");
941
+ const parsedMockApi = asRecord(parsed)?.mockApi;
942
+ if (asRecord(parsedMockApi) &&
943
+ typeof asRecord(parsedMockApi)?.strategy === "string" &&
944
+ !["native", "browser-intercept", "request-adapter", "not-needed"].includes(String(asRecord(parsedMockApi)?.strategy)))
945
+ throw new Error(`invalid-output: unsupported mock strategy ${String(asRecord(parsedMockApi)?.strategy)}`);
946
+ const baseCanonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
947
+ const canonicalBinding = withDerivedRequirementIdsWhenUnscoped(baseCanonicalBinding, parsed);
744
948
  // Always inject DAG-owned identity. Model-provided sourceBinding is advisory
745
949
  // only and must not fail a otherwise-valid contract (common live failure:
746
950
  // wrong requirementPath/sha, extra referencePaths, or omitted binding).
747
- const candidates = [
748
- {
749
- ...parsed,
750
- sourceBinding: canonicalBinding,
751
- },
752
- coerceFrontendImplementationContractInput(parsed, canonicalBinding),
753
- ];
754
- let result = frontendImplementationContractSchema.safeParse(candidates[0]);
755
- if (!result.success) {
756
- result = frontendImplementationContractSchema.safeParse(candidates[1]);
757
- }
951
+ const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
952
+ // There is exactly one post-security candidate. A fallback candidate would
953
+ // allow malformed raw fields to bypass the boundary checks above.
954
+ const candidate = {
955
+ ...(asRecord(normalizedContract) ?? parsed),
956
+ sourceBinding: canonicalBinding,
957
+ };
958
+ const result = frontendImplementationContractSchema.safeParse(candidate);
758
959
  if (!result.success)
759
960
  throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
760
961
  const blockingGaps = [
@@ -76,8 +76,14 @@ function assertReviewEvidence(input) {
76
76
  throw new Error("frontend review context invalid repair assessment");
77
77
  }
78
78
  export async function runFrontendReviewContextGate(input) {
79
- const diff = await runFrontendWorktreeDiffGate(input);
80
79
  const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
80
+ const parsedContract = frontendImplementationContractSchema.safeParse(contract);
81
+ if (!parsedContract.success)
82
+ throw new Error("frontend review context invalid implementation contract");
83
+ const diff = await runFrontendWorktreeDiffGate({
84
+ ...input,
85
+ authorizedChangedPaths: parsedContract.data.targets.files,
86
+ });
81
87
  const verificationTrace = await readRequiredJson(input.runDir, "contracts/frontend-verification-trace.json");
82
88
  const repairAssessment = await readRequiredJson(input.runDir, "contracts/frontend-repair-assessment.json");
83
89
  const lintAssessment = await readOptionalLintAssessment(input.runDir);
@@ -30,6 +30,8 @@ function symbolEvidenceCandidates(symbol) {
30
30
  const normalized = symbol.trim().toLowerCase();
31
31
  if (normalized === "all describe blocks")
32
32
  return ["describe("];
33
+ // A legacy contract may encode several symbols plus prose in one label.
34
+ // Validate the actual slash-delimited symbols individually.
33
35
  const describeTitle = symbol.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
34
36
  if (describeTitle)
35
37
  return [symbol, describeTitle, "describe("];
@@ -58,9 +60,18 @@ async function assertFileAndSymbol(input) {
58
60
  }
59
61
  if (input.symbol) {
60
62
  const content = await readFile(absolute, "utf8");
61
- const matched = symbolEvidenceCandidates(input.symbol).some((candidate) => content.includes(candidate));
62
- if (!matched) {
63
- issues.push(`symbol not found: ${input.symbol} in ${input.file}`);
63
+ const symbols = input.symbol.includes(" / ")
64
+ ? input.symbol
65
+ .split(/[((]/, 1)[0]
66
+ .split("/")
67
+ .map((symbol) => symbol.trim())
68
+ .filter(Boolean)
69
+ : [input.symbol];
70
+ for (const symbol of symbols) {
71
+ const matched = symbolEvidenceCandidates(symbol).some((candidate) => content.includes(candidate));
72
+ if (!matched) {
73
+ issues.push(`symbol not found: ${symbol} in ${input.file}`);
74
+ }
64
75
  }
65
76
  }
66
77
  return issues;
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
6
7
  export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
7
8
  export const FRONTEND_WORKTREE_BASELINE_SCHEMA_ID = "frontend-worktree-baseline-v1";
8
9
  function runGit(cwd, args) {
@@ -128,17 +129,27 @@ export async function runFrontendWorktreeDiffGate(input) {
128
129
  }
129
130
  const changedFilesAll = splitLines(nameOnly.stdout).sort();
130
131
  const untrackedFilesAll = splitLines(untracked.stdout).sort();
132
+ const authorizedChangedPaths = input.authorizedChangedPaths ?? [];
131
133
  if (baseline) {
132
134
  for (const file of baseline.files) {
133
135
  const currentHash = await sha256File(path.join(root, file));
134
- if (currentHash !== baseline.hashes[file]) {
136
+ const authorizedOverlap = authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
137
+ if (currentHash !== baseline.hashes[file] && !authorizedOverlap) {
135
138
  throw new Error(`frontend worktree diff overlaps pre-existing change: ${file}`);
136
139
  }
137
140
  }
138
141
  }
139
142
  const baselineFiles = new Set(baseline?.files ?? []);
140
- const changedFiles = changedFilesAll.filter((file) => !baselineFiles.has(file));
141
- const untrackedFiles = untrackedFilesAll.filter((file) => !baselineFiles.has(file));
143
+ const changedFiles = changedFilesAll.filter((file) => {
144
+ if (!baselineFiles.has(file))
145
+ return true;
146
+ return authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
147
+ });
148
+ const untrackedFiles = untrackedFilesAll.filter((file) => {
149
+ if (!baselineFiles.has(file))
150
+ return true;
151
+ return authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
152
+ });
142
153
  const patchSource = baseline
143
154
  ? changedFiles.length > 0
144
155
  ? await runGit(root, ["diff", "--binary", "HEAD", "--", ...changedFiles])