@tea-agent/loop-agent 0.35.0 → 0.35.1-beta.1

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.
@@ -20,6 +20,14 @@ export const frontendRepairFailureClassSchema = z.enum([
20
20
  "spec-unclear",
21
21
  "unknown",
22
22
  ]);
23
+ export const frontendCommandCategorySchema = z.enum([
24
+ "typecheck",
25
+ "build",
26
+ "lint",
27
+ "component-test",
28
+ "unit-test",
29
+ "trace",
30
+ ]);
23
31
  const REPAIRABLE = new Set([
24
32
  "typecheck",
25
33
  "build",
@@ -27,12 +35,72 @@ const REPAIRABLE = new Set([
27
35
  "component-test",
28
36
  "unit-test",
29
37
  "trace",
30
- "review",
31
38
  ]);
32
39
  export function isFrontendRepairable(failureClass) {
33
40
  return REPAIRABLE.has(failureClass);
34
41
  }
42
+ /**
43
+ * Deterministic commandLabel → commandCategory mapping (AC-3).
44
+ * `targetType` comes from the contract verificationTargets[].type
45
+ * (static | unit | component | integration | mock); the literal commandLabel
46
+ * wins over type so a `static` typecheck target is never misread as a test.
47
+ */
48
+ export function mapCommandLabelToCategory(commandLabel, targetType) {
49
+ const label = commandLabel.toLowerCase();
50
+ if (/(?:typecheck|check-types|type-check|\btsc\b)/.test(label))
51
+ return "typecheck";
52
+ if (/(?:vite\s+build|next\s+build|webpack|rollup|\bbuild\b)/.test(label))
53
+ return "build";
54
+ if (/(?:eslint|\blint\b)/.test(label))
55
+ return "lint";
56
+ if (targetType === "component")
57
+ return "component-test";
58
+ if (targetType === "unit")
59
+ return "unit-test";
60
+ if (/(?:component|testing-library|render\()/.test(label))
61
+ return "component-test";
62
+ return "unit-test";
63
+ }
64
+ export function isReviewRepairable(finding, implementWriteSet) {
65
+ const hasFile = Boolean(finding.file && finding.file.trim().length > 0);
66
+ const hasLocation = Boolean((finding.line !== undefined && finding.line > 0) ||
67
+ (finding.symbol && finding.symbol.trim().length > 0));
68
+ const hasRequirement = Boolean((finding.requirementId && finding.requirementId.trim().length > 0) ||
69
+ (finding.acceptanceCriteriaId &&
70
+ finding.acceptanceCriteriaId.trim().length > 0));
71
+ const fixScope = finding.fixScope ?? (hasFile ? [finding.file] : []);
72
+ const fixScopeSubset = fixScope.length > 0 &&
73
+ fixScope.every((entry) => isPathWithinWriteSet(entry, implementWriteSet));
74
+ return (hasFile &&
75
+ hasLocation &&
76
+ hasRequirement &&
77
+ fixScopeSubset &&
78
+ finding.requiresContractReplan !== true);
79
+ }
35
80
  export function classifyFrontendFailure(input) {
81
+ // AC-2: structured evidence takes priority over string-blob heuristics.
82
+ if (input.failureCategory === "write-guard") {
83
+ return "path";
84
+ }
85
+ if (input.traceTarget || input.commandCategory === "trace") {
86
+ return "trace";
87
+ }
88
+ if (input.commandCategory) {
89
+ switch (input.commandCategory) {
90
+ case "typecheck":
91
+ return "typecheck";
92
+ case "build":
93
+ return "build";
94
+ case "lint":
95
+ return "lint";
96
+ case "component-test":
97
+ return "component-test";
98
+ case "unit-test":
99
+ return "unit-test";
100
+ }
101
+ }
102
+ // Fallback: string-blob heuristic, preserved in its original branch order so
103
+ // legacy assertions (error TS2304 → typecheck, write-guard → path, etc.) stay green.
36
104
  const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
37
105
  if (input.nodeId.includes("contract") ||
38
106
  blob.includes("contract mismatch") ||
@@ -120,6 +188,23 @@ export const frontendRepairAssessmentSchema = z
120
188
  allowedRepairWriteSet: z.array(z.string().min(1)),
121
189
  evidenceRefs: z.array(z.string().min(1)),
122
190
  browserStatus: z.literal("not-run"),
191
+ // AC-5 structured evidence fields (all optional so existing minimal
192
+ // fixtures and frontend-review-context safeParse stay compatible).
193
+ verificationTargetId: z.string().min(1).optional(),
194
+ commandLabel: z.string().min(1).optional(),
195
+ exitCode: z.number().int().optional(),
196
+ commandCategory: frontendCommandCategorySchema.optional(),
197
+ requirementIds: z.array(z.string().min(1)).optional(),
198
+ acceptanceCriteriaIds: z.array(z.string().min(1)).optional(),
199
+ fixScope: z.array(z.string().min(1)).optional(),
200
+ changedPaths: z.array(z.string().min(1)).optional(),
201
+ traceRef: z
202
+ .object({
203
+ relativePath: z.string().min(1),
204
+ schemaId: z.string().min(1),
205
+ })
206
+ .strict()
207
+ .optional(),
123
208
  })
124
209
  .strict();
125
210
  function nodeHadFailure(record) {
@@ -135,19 +220,24 @@ function nodeHadFailure(record) {
135
220
  function normalize(value) {
136
221
  return value.replace(/\\/g, "/").replace(/^\.\//, "");
137
222
  }
223
+ /**
224
+ * Whether a concrete path or glob entry is contained by the implement writeSet.
225
+ * Exact glob equality is the norm (repair writeSet === implement writeSet);
226
+ * concrete (non-glob) paths may also be covered by an implement glob.
227
+ */
228
+ export function isPathWithinWriteSet(entry, implementWriteSet) {
229
+ const normalizedEntry = normalize(entry);
230
+ const normalizedImplement = implementWriteSet.map(normalize);
231
+ if (normalizedImplement.includes(normalizedEntry))
232
+ return true;
233
+ if (!normalizedEntry.includes("*")) {
234
+ return implementWriteSet.some((pattern) => pathMatchesPattern(normalizedEntry, pattern));
235
+ }
236
+ return false;
237
+ }
138
238
  export function assertWriteSetSubset(repairWriteSet, implementWriteSet) {
139
239
  for (const entry of repairWriteSet) {
140
- const ok = implementWriteSet.some((pattern) => normalize(entry) === normalize(pattern) ||
141
- pathMatchesPattern(normalize(entry), pattern) ||
142
- pathMatchesPattern(normalize(pattern), entry));
143
- // exact equality preferred for generation-time copy
144
- if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
145
- // allow identical globs only
146
- if (!ok || normalize(entry) !== normalize(entry)) {
147
- // require exact membership for exclusive writeSet entries
148
- }
149
- }
150
- if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
240
+ if (!isPathWithinWriteSet(entry, implementWriteSet)) {
151
241
  throw new Error(`repair writeSet entry "${entry}" is not ⊆ implement writeSet`);
152
242
  }
153
243
  }
@@ -170,6 +260,63 @@ async function loadImplementWriteSet(runDir, fallback) {
170
260
  }
171
261
  return fallback;
172
262
  }
263
+ function matchVerificationTarget(contract, commandLabel, command) {
264
+ for (const target of contract.verificationTargets) {
265
+ if (commandLabel && target.commandLabel === commandLabel)
266
+ return target;
267
+ if (command &&
268
+ (command.includes(target.commandLabel) ||
269
+ target.commandLabel.includes(command))) {
270
+ return target;
271
+ }
272
+ }
273
+ return undefined;
274
+ }
275
+ function deriveFailureEvidence(input) {
276
+ const evidence = {};
277
+ for (const { record } of input.failed) {
278
+ const results = record.commandResults ?? [];
279
+ const failedResult = results.find((item) => item.ok === false) ?? results[0];
280
+ if (failedResult &&
281
+ evidence.exitCode === undefined &&
282
+ typeof failedResult.exitCode === "number") {
283
+ evidence.exitCode = failedResult.exitCode;
284
+ }
285
+ if (failedResult?.commandLabel && !evidence.commandLabel) {
286
+ evidence.commandLabel = failedResult.commandLabel;
287
+ }
288
+ // Structured trace facts passed by the verification bundle / trace gate.
289
+ const failedTraceTarget = (record.traceTargets ?? []).find((target) => target.status === "failed");
290
+ if (failedTraceTarget && !evidence.verificationTargetId) {
291
+ evidence.verificationTargetId = failedTraceTarget.id;
292
+ if (!evidence.commandLabel)
293
+ evidence.commandLabel = failedTraceTarget.commandLabel;
294
+ evidence.traceTarget = failedTraceTarget;
295
+ evidence.traceRef = {
296
+ relativePath: "contracts/frontend-verification-trace.json",
297
+ schemaId: "frontend-verification-trace-v1",
298
+ };
299
+ }
300
+ if (record.changedPaths?.length && !evidence.changedPaths) {
301
+ evidence.changedPaths = [...record.changedPaths];
302
+ }
303
+ }
304
+ // Resolve commandLabel → contract verification target → category + scope.
305
+ const target = matchVerificationTarget(input.contract, evidence.commandLabel);
306
+ if (target) {
307
+ if (!evidence.verificationTargetId)
308
+ evidence.verificationTargetId = target.id;
309
+ if (!evidence.commandLabel)
310
+ evidence.commandLabel = target.commandLabel;
311
+ evidence.requirementIds ??= [...target.requirementIds];
312
+ evidence.acceptanceCriteriaIds ??= [...target.requirementIds];
313
+ evidence.fixScope ??= [target.file];
314
+ }
315
+ if (evidence.commandLabel) {
316
+ evidence.commandCategory ??= mapCommandLabelToCategory(evidence.commandLabel, target?.type);
317
+ }
318
+ return evidence;
319
+ }
173
320
  export async function runFrontendFailureAssessGate(input) {
174
321
  const attempt = input.attempt ?? 1;
175
322
  const implementWriteSet = await loadImplementWriteSet(input.runDir, input.implementWriteSet ?? []);
@@ -179,12 +326,14 @@ export async function runFrontendFailureAssessGate(input) {
179
326
  const contractRel = "contracts/frontend-implementation-contract.json";
180
327
  const contractPath = path.join(input.runDir, contractRel);
181
328
  let contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
329
+ let contract;
182
330
  try {
183
331
  const raw = JSON.parse(await readFile(contractPath, "utf8"));
184
332
  const parsed = frontendImplementationContractSchema.safeParse(raw);
185
333
  if (!parsed.success) {
186
334
  throw new Error("invalid frontend implementation contract");
187
335
  }
336
+ contract = parsed.data;
188
337
  contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
189
338
  }
190
339
  catch {
@@ -218,7 +367,6 @@ export async function runFrontendFailureAssessGate(input) {
218
367
  // missing optional nodes ok
219
368
  }
220
369
  }
221
- assertWriteSetSubset(implementWriteSet, implementWriteSet);
222
370
  if (failed.length === 0) {
223
371
  const assessment = {
224
372
  schemaVersion: 1,
@@ -241,16 +389,49 @@ export async function runFrontendFailureAssessGate(input) {
241
389
  return assessment;
242
390
  }
243
391
  const primary = failed[0];
392
+ const evidence = deriveFailureEvidence({ contract, failed });
244
393
  const failureClass = classifyFrontendFailure({
245
394
  nodeId: primary.nodeId,
246
395
  failureCategory: primary.record.failureCategory,
247
396
  stdout: primary.record.stdout,
248
397
  stderr: primary.record.stderr,
398
+ commandLabel: evidence.commandLabel,
399
+ commandCategory: evidence.commandCategory,
400
+ exitCode: evidence.exitCode,
401
+ traceTarget: evidence.traceTarget ?? null,
402
+ changedPaths: evidence.changedPaths,
249
403
  });
250
- const eligible = isFrontendRepairable(failureClass) && attempt <= 1;
251
- const reason = eligible
252
- ? `repairable failureClass=${failureClass} on ${primary.nodeId}`
253
- : `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`;
404
+ // AC-5: repairable=true requires structured evidence (a resolved exitCode,
405
+ // commandCategory, or trace-bound verification target). review has its own
406
+ // stricter gate (AC-4) and is never unconditionally repairable.
407
+ const baseEligible = isFrontendRepairable(failureClass) && attempt <= 1;
408
+ let eligible;
409
+ let reason;
410
+ if (failureClass === "review") {
411
+ const finding = primary.record.reviewFinding ?? {};
412
+ eligible = attempt <= 1 && isReviewRepairable(finding, implementWriteSet);
413
+ reason = eligible
414
+ ? `repairable review finding on ${primary.nodeId}`
415
+ : `review finding lacks file/line-symbol/requirement evidence or is outside writeSet on ${primary.nodeId}`;
416
+ }
417
+ else {
418
+ const hasStructuredEvidence = evidence.exitCode !== undefined ||
419
+ evidence.commandCategory !== undefined ||
420
+ Boolean(evidence.verificationTargetId);
421
+ eligible = baseEligible && hasStructuredEvidence;
422
+ reason = eligible
423
+ ? `repairable failureClass=${failureClass} on ${primary.nodeId}`
424
+ : !baseEligible
425
+ ? `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`
426
+ : `missing structured evidence for failureClass=${failureClass} on ${primary.nodeId}`;
427
+ }
428
+ // fixScope and changedPaths must stay inside the implement writeSet.
429
+ if (evidence.fixScope?.length) {
430
+ assertWriteSetSubset(evidence.fixScope, implementWriteSet);
431
+ }
432
+ if (evidence.changedPaths?.length) {
433
+ assertWriteSetSubset(evidence.changedPaths, implementWriteSet);
434
+ }
254
435
  const assessment = {
255
436
  schemaVersion: 1,
256
437
  schemaId: FRONTEND_REPAIR_ASSESSMENT_SCHEMA_ID,
@@ -267,6 +448,25 @@ export async function runFrontendFailureAssessGate(input) {
267
448
  allowedRepairWriteSet: [...implementWriteSet],
268
449
  evidenceRefs: [contractRel, ...failed.map((item) => `${item.nodeId}.json`)],
269
450
  browserStatus: "not-run",
451
+ ...(evidence.verificationTargetId
452
+ ? { verificationTargetId: evidence.verificationTargetId }
453
+ : {}),
454
+ ...(evidence.commandLabel ? { commandLabel: evidence.commandLabel } : {}),
455
+ ...(evidence.exitCode !== undefined ? { exitCode: evidence.exitCode } : {}),
456
+ ...(evidence.commandCategory
457
+ ? { commandCategory: evidence.commandCategory }
458
+ : {}),
459
+ ...(evidence.requirementIds?.length
460
+ ? { requirementIds: evidence.requirementIds }
461
+ : {}),
462
+ ...(evidence.acceptanceCriteriaIds?.length
463
+ ? { acceptanceCriteriaIds: evidence.acceptanceCriteriaIds }
464
+ : {}),
465
+ ...(evidence.fixScope?.length ? { fixScope: evidence.fixScope } : {}),
466
+ ...(evidence.changedPaths?.length
467
+ ? { changedPaths: evidence.changedPaths }
468
+ : {}),
469
+ ...(evidence.traceRef ? { traceRef: evidence.traceRef } : {}),
270
470
  };
271
471
  await writeAssessment(input.runDir, assessment);
272
472
  return assessment;
@@ -322,7 +522,8 @@ export async function runFrontendRepairContractGate(input) {
322
522
  if (!assessment.eligible) {
323
523
  throw new Error(`repair-contract: non-repairable failure (${assessment.failureClass}): ${assessment.reason}`);
324
524
  }
325
- if (!isFrontendRepairable(assessment.failureClass)) {
525
+ if (!isFrontendRepairable(assessment.failureClass) &&
526
+ assessment.failureClass !== "review") {
326
527
  throw new Error(`repair-contract: failureClass ${assessment.failureClass} is not repairable`);
327
528
  }
328
529
  return { ok: true, assessment };
@@ -40,6 +40,48 @@ function symbolEvidenceCandidates(symbol) {
40
40
  function isConfigurationVerificationFile(file) {
41
41
  return /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|(?:vite|webpack|rollup|docusaurus)\.config\.[cm]?[jt]s)$/.test(file.replace(/\\/g, "/"));
42
42
  }
43
+ /**
44
+ * Pure verification-target evaluation shared by the trace gate and the repair
45
+ * assess gate. It derives, for each contract verification target, whether the
46
+ * target bound to frozen evidence (commandLabel owners), and whether the file
47
+ * and optional symbol exist in the workspace. It performs no writes and throws
48
+ * nothing: failures are returned as `hardIssues` plus per-target `status`.
49
+ */
50
+ export async function evaluateVerificationTargets(input) {
51
+ const targets = [];
52
+ const hardIssues = [];
53
+ for (const target of input.contract.verificationTargets) {
54
+ const issues = [];
55
+ const owners = input.labelOwners.get(target.commandLabel);
56
+ if (!owners?.length) {
57
+ issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
58
+ }
59
+ const fileIssues = await assertFileAndSymbol({
60
+ workspaceRoot: input.workspaceRoot,
61
+ file: target.file,
62
+ // Configuration files prove that the command's entrypoint exists;
63
+ // they do not expose source symbols. LLMs sometimes derive a
64
+ // filename fragment such as "build" from tsconfig.build.json.
65
+ symbol: isConfigurationVerificationFile(target.file)
66
+ ? undefined
67
+ : target.symbol,
68
+ });
69
+ issues.push(...fileIssues);
70
+ const status = issues.length ? "failed" : "ok";
71
+ if (issues.length)
72
+ hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
73
+ targets.push({
74
+ id: target.id,
75
+ commandLabel: target.commandLabel,
76
+ file: target.file,
77
+ symbol: target.symbol,
78
+ status,
79
+ matchedNodeIds: owners ?? [],
80
+ issues,
81
+ });
82
+ }
83
+ return { targets, hardIssues };
84
+ }
43
85
  async function assertFileAndSymbol(input) {
44
86
  const issues = [];
45
87
  const absolute = path.resolve(input.workspaceRoot, input.file);
@@ -175,38 +217,11 @@ export async function runFrontendVerificationTraceGate(input) {
175
217
  }
176
218
  }
177
219
  }
178
- const targets = [];
179
- const hardIssues = [];
180
- for (const target of contract.verificationTargets) {
181
- const issues = [];
182
- const owners = labelOwners.get(target.commandLabel);
183
- if (!owners?.length) {
184
- issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
185
- }
186
- const fileIssues = await assertFileAndSymbol({
187
- workspaceRoot: input.workspaceRoot,
188
- file: target.file,
189
- // Configuration files prove that the command's entrypoint exists;
190
- // they do not expose source symbols. LLMs sometimes derive a
191
- // filename fragment such as "build" from tsconfig.build.json.
192
- symbol: isConfigurationVerificationFile(target.file)
193
- ? undefined
194
- : target.symbol,
195
- });
196
- issues.push(...fileIssues);
197
- const status = issues.length ? "failed" : "ok";
198
- if (issues.length)
199
- hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
200
- targets.push({
201
- id: target.id,
202
- commandLabel: target.commandLabel,
203
- file: target.file,
204
- symbol: target.symbol,
205
- status,
206
- matchedNodeIds: owners ?? [],
207
- issues,
208
- });
209
- }
220
+ const { targets, hardIssues } = await evaluateVerificationTargets({
221
+ contract,
222
+ labelOwners,
223
+ workspaceRoot: input.workspaceRoot,
224
+ });
210
225
  if (hardIssues.length) {
211
226
  throw new Error(`trace: ${hardIssues.join("; ")}`);
212
227
  }
@@ -2199,6 +2199,26 @@ function pruneFrontendTasksForRisk(tasks, risk) {
2199
2199
  return { ...task, depends_on };
2200
2200
  });
2201
2201
  }
2202
+ /**
2203
+ * AC-1: shared frontend writer node defaults. frontend-implement-pi and
2204
+ * frontend-repair-pi share one prompt/contract/writeSet-guard surface and an
2205
+ * identical writeSet; only id, depends_on, runIf, outputContract, and
2206
+ * subtask_prompt differ per phase.
2207
+ */
2208
+ function buildFrontendWriterNodeDefaults(input) {
2209
+ return {
2210
+ role: "implementer",
2211
+ executor: "pi",
2212
+ toolProfile: "write",
2213
+ complexity: input.complexity,
2214
+ writePolicy: "exclusive",
2215
+ writeSet: input.writeSet,
2216
+ allowedPaths: input.allowedPaths,
2217
+ forbiddenPaths: input.forbiddenPaths,
2218
+ skills: FRONTEND_BOUNDED_IMPLEMENT_SKILLS,
2219
+ writerOutcomePolicy: { type: "implementation-outcome-v1" },
2220
+ };
2221
+ }
2202
2222
  async function buildFrontendHybridDagFromTask(sources) {
2203
2223
  const { taskConfig } = sources;
2204
2224
  const mockCapability = sources.frontendMockCapability ?? {
@@ -2532,6 +2552,10 @@ async function buildFrontendHybridDagFromTask(sources) {
2532
2552
  writePolicy: "read-only",
2533
2553
  outputMode: "structured-required",
2534
2554
  retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
2555
+ structuredContractOutput: {
2556
+ schemaId: "frontend-implementation-contract-v1",
2557
+ retryOnInvalid: true,
2558
+ },
2535
2559
  allowedPaths: readOnlyPaths,
2536
2560
  forbiddenPaths,
2537
2561
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
@@ -2545,6 +2569,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2545
2569
  "End with exactly one fenced json object conforming to frontend-implementation-contract-v1. This node is the single contract JSON producer: the fenced block is the authoritative contract the prewrite gate materializes. Do not emit any raw JSON, JSON in prose, or a second fenced block anywhere in the response; the plan text must not contain other balanced JSON objects.",
2546
2570
  "Each requirement must state its user-observable or logic-observable expectedOutcome. Each interaction must state its trigger and expectedBehavior. IDs plus file paths are not sufficient behavior semantics.",
2547
2571
  requirementCoverageInstruction,
2572
+ "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2548
2573
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2549
2574
  fixedVerificationContext,
2550
2575
  sourceContext,
@@ -2584,6 +2609,10 @@ async function buildFrontendHybridDagFromTask(sources) {
2584
2609
  writePolicy: "read-only",
2585
2610
  outputMode: "structured-required",
2586
2611
  retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
2612
+ structuredContractOutput: {
2613
+ schemaId: "frontend-implementation-contract-v1",
2614
+ retryOnInvalid: true,
2615
+ },
2587
2616
  allowedPaths: readOnlyPaths,
2588
2617
  forbiddenPaths,
2589
2618
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
@@ -2716,18 +2745,12 @@ async function buildFrontendHybridDagFromTask(sources) {
2716
2745
  ? ["frontend-lint-baseline-shell"]
2717
2746
  : []),
2718
2747
  ],
2719
- role: "implementer",
2720
- executor: "pi",
2721
- toolProfile: "write",
2722
- complexity: resolveWriterComplexity(taskConfig),
2723
- writePolicy: "exclusive",
2724
- writeSet: implementPaths.writeSet,
2725
- allowedPaths: implementPaths.allowedPaths,
2726
- forbiddenPaths,
2727
- skills: FRONTEND_BOUNDED_IMPLEMENT_SKILLS,
2728
- writerOutcomePolicy: {
2729
- type: "implementation-outcome-v1",
2730
- },
2748
+ ...buildFrontendWriterNodeDefaults({
2749
+ complexity: resolveWriterComplexity(taskConfig),
2750
+ writeSet: implementPaths.writeSet,
2751
+ allowedPaths: implementPaths.allowedPaths,
2752
+ forbiddenPaths,
2753
+ }),
2731
2754
  outputContract: "First non-empty line must be exactly one of: IMPLEMENTATION_OUTCOME: changed; IMPLEMENTATION_OUTCOME: already-satisfied; IMPLEMENTATION_OUTCOME: blocked. Then a Markdown delivery summary with Contract Ref (path/schema/hash), Changed Files, Requirements Implemented, UI States, Tests Changed, Verification Attempts, Deviations, and Residual Risks. Follow fixed stages: contract confirm → tests → component/state → API/Mock → focused checks → diff cleanup.",
2732
2755
  subtask_prompt: [
2733
2756
  "Implement against the validated run-owned Frontend Implementation Contract from frontend-prewrite-gate-shell (path/schema/hash). Do not rebuild the contract from Markdown alone.",
@@ -2781,18 +2804,12 @@ async function buildFrontendHybridDagFromTask(sources) {
2781
2804
  id: "frontend-repair-pi",
2782
2805
  depends_on: ["frontend-verify-assess-shell", implementId],
2783
2806
  runIf: "$.nodes['frontend-verify-assess-shell'].json.eligible == true",
2784
- role: "implementer",
2785
- executor: "pi",
2786
- toolProfile: "write",
2787
- complexity: resolveWriterComplexity(taskConfig),
2788
- writePolicy: "exclusive",
2789
- writeSet: implementPaths.writeSet,
2790
- allowedPaths: implementPaths.allowedPaths,
2791
- forbiddenPaths,
2792
- skills: FRONTEND_BOUNDED_IMPLEMENT_SKILLS,
2793
- writerOutcomePolicy: {
2794
- type: "implementation-outcome-v1",
2795
- },
2807
+ ...buildFrontendWriterNodeDefaults({
2808
+ complexity: resolveWriterComplexity(taskConfig),
2809
+ writeSet: implementPaths.writeSet,
2810
+ allowedPaths: implementPaths.allowedPaths,
2811
+ forbiddenPaths,
2812
+ }),
2796
2813
  outputContract: "First non-empty line must be exactly one of: IMPLEMENTATION_OUTCOME: changed; IMPLEMENTATION_OUTCOME: already-satisfied; IMPLEMENTATION_OUTCOME: blocked. Then a repair summary for an eligible repairable assessment. Must not expand writeSet, re-interpret requirements, skip tests, or enable Mock by default.",
2797
2814
  subtask_prompt: [
2798
2815
  "Read contracts/frontend-repair-assessment.json and the validated frontend implementation contract.",
@@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { pathMatchesPattern } from "../../shared/git-progress.js";
6
6
  import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
7
+ import { FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isFrontendWriterAuthorized, readFrontendPrewriteResult, } from "./scheduler.js";
7
8
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
8
9
  import { resolveContextPolicy } from "./context-policy.js";
9
10
  import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./prompt.js";
@@ -12,6 +13,8 @@ import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry,
12
13
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
13
14
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
14
15
  import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
16
+ import { getStructuredContractValidator } from "./contract-output-registry.js";
17
+ import "./contract-validator-registrations.js";
15
18
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
16
19
  import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
17
20
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
@@ -138,6 +141,20 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
138
141
  buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
139
142
  ].join("\n");
140
143
  }
144
+ if (previousFailureCategory === "invalid-output" &&
145
+ task.structuredContractOutput &&
146
+ previousProtocolReason) {
147
+ return [
148
+ basePrompt,
149
+ "",
150
+ "<retry_instruction>",
151
+ "Previous attempt produced an invalid frontend implementation contract:",
152
+ previousProtocolReason,
153
+ "Return exactly one fenced json block conforming to the frontend-implementation-contract-v1 schema.",
154
+ "Fix every reported field violation: do not emit null for optional fields, do not misspell field names, and match the required types exactly.",
155
+ "</retry_instruction>",
156
+ ].join("\n");
157
+ }
141
158
  if (previousFailureCategory === "writer-empty-diff") {
142
159
  const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
143
160
  // When a completeness progress exists for this writer, fold the concrete
@@ -395,6 +412,21 @@ export async function executeDagNode(input) {
395
412
  await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
396
413
  };
397
414
  const failSkillSnapshot = (error) => failBeforePrompt(error, "skill-snapshot-integrity");
415
+ const skipFrontendWriter = async (admission) => {
416
+ const skippedAt = new Date().toISOString();
417
+ node.startedAt ??= skippedAt;
418
+ node.frontendWriterAdmission = admission;
419
+ node.status = "SKIPPED";
420
+ node.skippedReason = "frontend-prewrite-not-authorized";
421
+ node.finishedAt = skippedAt;
422
+ node.lastActivityAt = skippedAt;
423
+ node.durationMs = durationBetween(node.startedAt, node.finishedAt);
424
+ node.timing = { retryBackoffMs: 0, settlementCleanupMs: 0 };
425
+ state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
426
+ await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
427
+ await input.persistState();
428
+ await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
429
+ };
398
430
  if (task.finalWriteSetApproval) {
399
431
  const authorization = parseAndValidateFinalWriteSetApproval({ task, spec, state });
400
432
  if (!authorization.ok) {
@@ -423,6 +455,30 @@ export async function executeDagNode(input) {
423
455
  effectiveWriteSet: [...authorization.effectiveWriteSet],
424
456
  };
425
457
  }
458
+ if (FRONTEND_WRITER_NODE_IDS.includes(nodeId)) {
459
+ const admission = await readFrontendPrewriteResult(runDir);
460
+ if (!admission.ok) {
461
+ await skipFrontendWriter(undefined);
462
+ return;
463
+ }
464
+ const decision = isFrontendWriterAuthorized(admission.result);
465
+ const record = {
466
+ schemaVersion: 1,
467
+ writerNodeId: nodeId,
468
+ decision,
469
+ sourceArtifact: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
470
+ artifactHash: admission.artifactHash,
471
+ checkedAt: new Date().toISOString(),
472
+ reason: decision === "denied"
473
+ ? `classification: ${admission.result.classification}`
474
+ : null,
475
+ };
476
+ if (decision === "denied") {
477
+ await skipFrontendWriter(record);
478
+ return;
479
+ }
480
+ node.frontendWriterAdmission = record;
481
+ }
426
482
  let projectGovernanceContext;
427
483
  if (task.governanceStandardReview) {
428
484
  try {
@@ -689,6 +745,39 @@ export async function executeDagNode(input) {
689
745
  previousProtocolReason = undefined;
690
746
  }
691
747
  }
748
+ // R1: structured contract nodes self-validate their output so schema,
749
+ // typo, and null violations surface as retryable invalid-output at the
750
+ // producing node instead of failing the whole run at the prewrite gate.
751
+ if (result.ok && task.structuredContractOutput) {
752
+ const validator = getStructuredContractValidator(task.structuredContractOutput.schemaId);
753
+ if (validator) {
754
+ const contractText = canonicalNodeOutput(result);
755
+ const contractCheck = await validator({
756
+ runDir,
757
+ text: contractText,
758
+ sourceBinding: spec.sourceBinding,
759
+ });
760
+ if (!contractCheck.ok) {
761
+ result = {
762
+ ...result,
763
+ ok: false,
764
+ failureCategory: "invalid-output",
765
+ stderr: [result.stderr, contractCheck.reason]
766
+ .filter(Boolean)
767
+ .join("\n"),
768
+ };
769
+ previousProtocolReason = contractCheck.reason;
770
+ }
771
+ else {
772
+ previousProtocolReason = undefined;
773
+ }
774
+ }
775
+ else {
776
+ // DAG spec validation rejects unknown schemaIds; this is a
777
+ // defensive fallback for a registry that has not been populated.
778
+ console.warn(`[run-dag] warning: no structured contract validator registered for schemaId ${task.structuredContractOutput.schemaId}; skipping node self-check`);
779
+ }
780
+ }
692
781
  const attemptFinishedAt = new Date().toISOString();
693
782
  const attemptWallDurationMs = durationBetween(attemptStartedAt, attemptFinishedAt);
694
783
  totalAttemptWallDurationMs += attemptWallDurationMs;