@tea-agent/loop-agent 0.35.2 → 0.35.3

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 (45) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +1 -1
  4. package/bin/loop-agent.js +37 -1
  5. package/dist/build-stamp.json +6 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/executors/dag-pi-executor.js +44 -0
  8. package/dist/shared/package-metadata.js +42 -0
  9. package/dist/worker/console/chat/assistant-content.js +11 -0
  10. package/dist/worker/console/chat/pi-runtime.js +6 -2
  11. package/dist/worker/console/chat/turn-process.js +17 -9
  12. package/dist/worker/console/chat/workspace-landing.js +1 -1
  13. package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
  14. package/dist/worker/console/static/index.html +1 -1
  15. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
  16. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +15 -3
  17. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  18. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  19. package/dist/worker/console/static-src/operator-chat/useChatThread.js +1 -0
  20. package/dist/worker/loop-agent/loop-agent-client.js +17 -3
  21. package/dist/worker/observability/read-model.js +20 -0
  22. package/dist/worker/observe/spec-evidence.js +3 -8
  23. package/dist/worker/observe/static/views/dag-inspector.js +6 -71
  24. package/dist/worker/preflight.js +2 -1
  25. package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
  26. package/dist/workflows/dag/contract-output-registry.js +14 -0
  27. package/dist/workflows/dag/contract-validator-registrations.js +8 -0
  28. package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
  29. package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
  30. package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
  31. package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
  32. package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
  33. package/dist/workflows/dag/frontend-recovery-run.js +539 -0
  34. package/dist/workflows/dag/frontend-repair.js +219 -18
  35. package/dist/workflows/dag/frontend-verification-trace.js +47 -32
  36. package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
  37. package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
  38. package/dist/workflows/dag/init-hybrid.js +49 -24
  39. package/dist/workflows/dag/node-execution.js +89 -0
  40. package/dist/workflows/dag/recovery-recommendation.js +58 -0
  41. package/dist/workflows/dag/runner.js +245 -11
  42. package/dist/workflows/dag/scheduler.js +257 -3
  43. package/dist/workflows/dag/types.js +130 -2
  44. package/package.json +4 -3
  45. package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
@@ -1,9 +1,125 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { z } from "zod";
3
4
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
4
- import { frontendImplementationContractSchema, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
5
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
5
6
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
6
7
  import { pathMatchesPattern } from "../../shared/git-progress.js";
8
+ export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
9
+ export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
10
+ /**
11
+ * Machine-readable failure classification for blocked/retryable-invalid
12
+ * prewrite outcomes. Kept alongside the human-readable failureReason so
13
+ * downstream routing (repair prompting, dashboards, retry policies) can act on
14
+ * a stable code instead of parsing prose.
15
+ */
16
+ export const frontendPrewriteFailureCodeSchema = z.enum([
17
+ "plan-text-unreadable",
18
+ "review-text-unreadable",
19
+ "verdict-not-pass",
20
+ "missing-requirement-ids",
21
+ "source-binding-not-fresh",
22
+ "contract-invalid",
23
+ "contract-blocked",
24
+ "mock-strategy-outside-allowed",
25
+ "mock-strategy-no-verification-commands",
26
+ "target-outside-write-set",
27
+ "verification-target-outside-write-set",
28
+ ]);
29
+ export const frontendPrewriteResultV1Schema = z
30
+ .object({
31
+ schemaVersion: z.literal(1),
32
+ schemaId: z.literal(FRONTEND_PREWRITE_RESULT_SCHEMA_ID),
33
+ classification: z.enum([
34
+ "accepted",
35
+ "accepted-normalized",
36
+ "retryable-invalid",
37
+ "blocked",
38
+ ]),
39
+ failureCode: frontendPrewriteFailureCodeSchema.nullable(),
40
+ normalizationActions: z.array(frontendNormalizationActionSchema),
41
+ candidateRawSha256: z.string().regex(/^[a-f0-9]{64}$/),
42
+ candidateJsonSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
43
+ canonicalSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
44
+ selectedPlanNodeId: z.string().regex(/^[a-z][a-z0-9-]*$/),
45
+ sourceBindingSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
46
+ writeSetDigest: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
47
+ retryCount: z.number().int().min(0).max(1),
48
+ maxRetryCount: z.literal(1),
49
+ failureReason: z.string().nullable(),
50
+ digestAlgorithm: z.literal("sha256"),
51
+ })
52
+ .strict()
53
+ .superRefine((value, ctx) => {
54
+ const acceptedLike = value.classification === "accepted" ||
55
+ value.classification === "accepted-normalized";
56
+ const failed = value.classification === "retryable-invalid" ||
57
+ value.classification === "blocked";
58
+ if (acceptedLike) {
59
+ if (value.failureReason !== null)
60
+ ctx.addIssue({
61
+ code: "custom",
62
+ message: "failureReason must be null for accepted/accepted-normalized",
63
+ path: ["failureReason"],
64
+ });
65
+ if (value.failureCode !== null)
66
+ ctx.addIssue({
67
+ code: "custom",
68
+ message: "failureCode must be null for accepted/accepted-normalized",
69
+ path: ["failureCode"],
70
+ });
71
+ if (!value.candidateJsonSha256 || !value.canonicalSha256)
72
+ ctx.addIssue({
73
+ code: "custom",
74
+ message: "candidateJsonSha256 and canonicalSha256 are required for accepted/accepted-normalized",
75
+ path: ["candidateJsonSha256"],
76
+ });
77
+ }
78
+ if (value.classification === "accepted" && value.normalizationActions.length !== 0)
79
+ ctx.addIssue({
80
+ code: "custom",
81
+ message: "accepted must have empty normalizationActions",
82
+ path: ["normalizationActions"],
83
+ });
84
+ if (value.classification === "accepted-normalized" &&
85
+ value.normalizationActions.length === 0)
86
+ ctx.addIssue({
87
+ code: "custom",
88
+ message: "accepted-normalized must have non-empty normalizationActions",
89
+ path: ["normalizationActions"],
90
+ });
91
+ if (failed) {
92
+ if (value.normalizationActions.length !== 0)
93
+ ctx.addIssue({
94
+ code: "custom",
95
+ message: "retryable-invalid/blocked must have empty normalizationActions",
96
+ path: ["normalizationActions"],
97
+ });
98
+ if (!value.failureReason)
99
+ ctx.addIssue({
100
+ code: "custom",
101
+ message: "failureReason must be non-empty for retryable-invalid/blocked",
102
+ path: ["failureReason"],
103
+ });
104
+ if (value.failureCode === null)
105
+ ctx.addIssue({
106
+ code: "custom",
107
+ message: "failureCode must be non-null for retryable-invalid/blocked",
108
+ path: ["failureCode"],
109
+ });
110
+ }
111
+ });
112
+ export const frontendWriterAdmissionSchema = z
113
+ .object({
114
+ schemaVersion: z.literal(1),
115
+ writerNodeId: z.enum(["frontend-implement-pi", "frontend-repair-pi"]),
116
+ decision: z.enum(["authorized", "denied"]),
117
+ sourceArtifact: z.string().min(1),
118
+ artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
119
+ checkedAt: z.string().min(1),
120
+ reason: z.string().nullable(),
121
+ })
122
+ .strict();
7
123
  async function selectNode(runDir, primary, fallbacks) {
8
124
  const candidates = [primary, ...fallbacks.filter((id) => id !== primary)];
9
125
  const rejections = [];
@@ -37,6 +153,10 @@ async function readNodeText(runDir, nodeId) {
37
153
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
38
154
  return text;
39
155
  }
156
+ async function readNodeRawText(runDir, nodeId) {
157
+ const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
158
+ return record.assistantText ?? record.stdout ?? "";
159
+ }
40
160
  function firstNonEmptyVerdictLine(text) {
41
161
  const lines = text
42
162
  .split(/\r?\n/)
@@ -132,16 +252,141 @@ async function checkOpenspecReadEvidence(input) {
132
252
  }
133
253
  return [...matched];
134
254
  }
255
+ async function finalizePrewrite(input, pending) {
256
+ const sourceBindingSha256 = input.sourceBinding
257
+ ? deterministicSha256(input.sourceBinding)
258
+ : null;
259
+ const writeSetDigest = input.config.implementationWriteSet
260
+ ? deterministicSha256([...new Set(input.config.implementationWriteSet)].sort())
261
+ : null;
262
+ const prewriteResult = frontendPrewriteResultV1Schema.parse({
263
+ schemaVersion: 1,
264
+ schemaId: FRONTEND_PREWRITE_RESULT_SCHEMA_ID,
265
+ classification: pending.classification,
266
+ normalizationActions: pending.normalizationActions,
267
+ candidateRawSha256: pending.candidateRawSha256,
268
+ candidateJsonSha256: pending.candidateJsonSha256,
269
+ canonicalSha256: pending.canonicalSha256,
270
+ selectedPlanNodeId: pending.planNodeId,
271
+ sourceBindingSha256,
272
+ writeSetDigest,
273
+ retryCount: 0,
274
+ maxRetryCount: 1,
275
+ failureReason: pending.failureReason,
276
+ failureCode: pending.failureCode,
277
+ digestAlgorithm: "sha256",
278
+ });
279
+ const written = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(input.config.outputDir, FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME), prewriteResult);
280
+ return {
281
+ ok: true,
282
+ classification: pending.classification,
283
+ planNodeId: pending.planNodeId,
284
+ reviewNodeId: pending.reviewNodeId,
285
+ verdict: pending.verdict,
286
+ mockStrategy: pending.mockStrategy,
287
+ artifact: pending.artifact,
288
+ prewriteResult: {
289
+ path: written.path,
290
+ sha256: written.sha256,
291
+ schemaId: FRONTEND_PREWRITE_RESULT_SCHEMA_ID,
292
+ },
293
+ failureReason: pending.failureReason,
294
+ failureCode: pending.failureCode,
295
+ normalizationActions: pending.normalizationActions,
296
+ candidateRawSha256: pending.candidateRawSha256,
297
+ candidateJsonSha256: pending.candidateJsonSha256,
298
+ canonicalSha256: pending.canonicalSha256,
299
+ openspecReadPaths: pending.openspecReadPaths,
300
+ openspecCandidatePaths: pending.openspecCandidatePaths,
301
+ };
302
+ }
135
303
  export async function runFrontendPrewriteGate(input) {
136
304
  const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
305
+ // selectNode throws for a scheduling defect (no FINISHED plan/review). That
306
+ // remains an ERROR and is intentionally not classified into prewrite-result.
307
+ const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
308
+ const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
309
+ const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
310
+ const basePending = {
311
+ planNodeId,
312
+ reviewNodeId,
313
+ verdict: "",
314
+ candidateRawSha256,
315
+ candidateJsonSha256: null,
316
+ canonicalSha256: null,
317
+ normalizationActions: [],
318
+ mockStrategy: undefined,
319
+ artifact: undefined,
320
+ failureReason: null,
321
+ failureCode: null,
322
+ classification: "accepted",
323
+ openspecReadPaths: [],
324
+ openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
325
+ };
326
+ let planText;
327
+ try {
328
+ planText = await readNodeText(input.runDir, planNodeId);
329
+ }
330
+ catch (error) {
331
+ return finalizePrewrite(input, {
332
+ ...basePending,
333
+ classification: "retryable-invalid",
334
+ failureReason: error instanceof Error ? error.message : String(error),
335
+ failureCode: "plan-text-unreadable",
336
+ });
337
+ }
338
+ let reviewText;
339
+ try {
340
+ reviewText = await readNodeText(input.runDir, reviewNodeId);
341
+ }
342
+ catch (error) {
343
+ return finalizePrewrite(input, {
344
+ ...basePending,
345
+ classification: "blocked",
346
+ failureReason: `frontend prewrite gate blocked by ${reviewNodeId}: ${error instanceof Error ? error.message : String(error)}`,
347
+ failureCode: "review-text-unreadable",
348
+ });
349
+ }
350
+ const verdict = firstNonEmptyVerdictLine(reviewText);
351
+ if (verdict !== "VERDICT: pass") {
352
+ return finalizePrewrite(input, {
353
+ ...basePending,
354
+ verdict,
355
+ classification: "blocked",
356
+ failureReason: `frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`,
357
+ failureCode: "verdict-not-pass",
358
+ });
359
+ }
360
+ const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
361
+ if (missingIds.length > 0) {
362
+ return finalizePrewrite(input, {
363
+ ...basePending,
364
+ verdict,
365
+ classification: "blocked",
366
+ failureReason: `frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`,
367
+ failureCode: "missing-requirement-ids",
368
+ });
369
+ }
137
370
  if (workspaceRoot) {
138
371
  if (input.config.requireSourceFreshness) {
139
- if (!input.sourceBinding)
372
+ if (!input.sourceBinding) {
140
373
  throw new Error("frontend prewrite gate requires sourceBinding for freshness check");
141
- await assertFrontendSourceBindingFresh({
142
- workspaceRoot,
143
- binding: input.sourceBinding,
144
- });
374
+ }
375
+ try {
376
+ await assertFrontendSourceBindingFresh({
377
+ workspaceRoot,
378
+ binding: input.sourceBinding,
379
+ });
380
+ }
381
+ catch (error) {
382
+ return finalizePrewrite(input, {
383
+ ...basePending,
384
+ verdict,
385
+ classification: "blocked",
386
+ failureReason: error instanceof Error ? error.message : String(error),
387
+ failureCode: "source-binding-not-fresh",
388
+ });
389
+ }
145
390
  }
146
391
  let hasGitMetadata = true;
147
392
  try {
@@ -159,31 +404,34 @@ export async function runFrontendPrewriteGate(input) {
159
404
  workspaceRoot,
160
405
  });
161
406
  }
162
- const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
163
- const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
164
- const planText = await readNodeText(input.runDir, planNodeId);
165
- const reviewText = await readNodeText(input.runDir, reviewNodeId);
166
- const verdict = firstNonEmptyVerdictLine(reviewText);
167
- if (verdict !== "VERDICT: pass") {
168
- throw new Error(`frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`);
407
+ // Candidate -> canonical conversion. The gate consumes the selected
408
+ // plan/revision assistant output directly and is the only conversion point.
409
+ let analysis;
410
+ try {
411
+ analysis = await analyzeFrontendImplementationContract({
412
+ runDir: input.runDir,
413
+ fromNodeId: planNodeId,
414
+ sourceBinding: input.sourceBinding,
415
+ });
169
416
  }
170
- const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
171
- if (missingIds.length > 0) {
172
- throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
417
+ catch (error) {
418
+ if (error instanceof FrontendContractFailure) {
419
+ return finalizePrewrite(input, {
420
+ ...basePending,
421
+ verdict,
422
+ classification: error.kind,
423
+ failureReason: error.message,
424
+ failureCode: error.kind === "retryable-invalid"
425
+ ? "contract-invalid"
426
+ : "contract-blocked",
427
+ candidateJsonSha256: error.candidateJsonSha256,
428
+ normalizationActions: [],
429
+ });
430
+ }
431
+ throw error;
173
432
  }
174
- // Always rematerialize from the selected plan node. Reusing a previous
175
- // contracts/*.json would accept stale contracts when a higher-priority
176
- // FINISHED primary is present but invalid (see composite-shell invalid-primary).
177
- const artifact = await materializeFrontendImplementationContract({
178
- runDir: input.runDir,
179
- fromNodeId: planNodeId,
180
- artifactName: input.config.artifactName,
181
- outputDir: input.config.outputDir,
182
- sourceBinding: input.sourceBinding,
183
- });
184
- const raw = JSON.parse(await readFile(artifact.path, "utf8"));
185
- const contract = frontendImplementationContractSchema.parse(raw);
186
- if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
433
+ const mockStrategy = analysis.canonical.mockApi.strategy;
434
+ if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
187
435
  const quotedAllowed = input.config.allowedMockStrategies
188
436
  .map((strategy) => JSON.stringify(strategy))
189
437
  .join(", ");
@@ -192,30 +440,71 @@ export async function runFrontendPrewriteGate(input) {
192
440
  const reason = forcedNotNeeded
193
441
  ? "the task is auto/not-required or lacks authorized deterministic Mock verification commands"
194
442
  : `allowed strategies are [${quotedAllowed}]`;
195
- throw new Error(`frontend contract mock strategy "${contract.mockApi.strategy}" is incompatible with the generated allowed strategies [${quotedAllowed}]; ${reason}. Regenerate the DAG or set frontendMock.policy=required with authorized Mock verification commands.`);
443
+ return finalizePrewrite(input, {
444
+ ...basePending,
445
+ verdict,
446
+ candidateJsonSha256: analysis.candidateJsonSha256,
447
+ normalizationActions: [],
448
+ mockStrategy,
449
+ classification: "blocked",
450
+ failureReason: `frontend contract mock strategy "${mockStrategy}" is incompatible with the generated allowed strategies [${quotedAllowed}]; ${reason}. Regenerate the DAG or set frontendMock.policy=required with authorized Mock verification commands.`,
451
+ failureCode: "mock-strategy-outside-allowed",
452
+ });
196
453
  }
197
- // Fail early instead of at the verification trace: a non-not-needed strategy
198
- // can only be proven by executing the DAG's frozen Mock verification
199
- // commands. If none were materialized at generation time the contract would
200
- // fail "trace: selected Mock strategy requires successful Mock verification
201
- // commands" after implementation, so block before any write is authorized.
202
- if (contract.mockApi.strategy !== "not-needed" &&
454
+ // Mock command binding (main): a non-not-needed strategy can only be proven
455
+ // by the DAG's frozen Mock verification commands. Block before any write is
456
+ // authorized when none were materialized at generation time.
457
+ if (mockStrategy !== "not-needed" &&
203
458
  (input.config.mockCommandLabels?.length ?? 0) === 0) {
204
- throw new Error(`frontend prewrite gate blocked: contract selected Mock strategy ${contract.mockApi.strategy} but the DAG has no authorized Mock verification commands; add frontendMock.verifyCommands or a project mock script, or select not-needed`);
459
+ return finalizePrewrite(input, {
460
+ ...basePending,
461
+ verdict,
462
+ candidateJsonSha256: analysis.candidateJsonSha256,
463
+ normalizationActions: [],
464
+ mockStrategy,
465
+ classification: "blocked",
466
+ failureReason: `frontend prewrite gate blocked: contract selected Mock strategy ${mockStrategy} but the DAG has no authorized Mock verification commands; add frontendMock.verifyCommands or a project mock script, or select not-needed`,
467
+ failureCode: "mock-strategy-no-verification-commands",
468
+ });
205
469
  }
206
470
  if (input.config.implementationWriteSet) {
207
471
  const writeSet = new Set(input.config.implementationWriteSet);
208
- const contractTargets = new Set(contract.targets.files);
472
+ const contractTargets = new Set(analysis.canonical.targets.files);
209
473
  const uncoveredTargets = [...contractTargets].filter((target) => ![...writeSet].some((pattern) => pathMatchesPattern(target, pattern) || target === pattern));
210
474
  if (uncoveredTargets.length > 0) {
211
- throw new Error(`frontend prewrite gate contract target is outside implementation writeSet: ${uncoveredTargets.join(", ")}`);
475
+ return finalizePrewrite(input, {
476
+ ...basePending,
477
+ verdict,
478
+ candidateJsonSha256: analysis.candidateJsonSha256,
479
+ normalizationActions: [],
480
+ mockStrategy,
481
+ classification: "blocked",
482
+ failureReason: `frontend prewrite gate contract target is outside implementation writeSet: ${uncoveredTargets.join(", ")}`,
483
+ failureCode: "target-outside-write-set",
484
+ });
212
485
  }
213
- const nonStaticVTs = contract.verificationTargets.filter((vt) => vt.type !== "static");
486
+ const nonStaticVTs = analysis.canonical.verificationTargets.filter((vt) => vt.type !== "static");
214
487
  const uncoveredVTs = nonStaticVTs.filter((vt) => ![...writeSet].some((pattern) => pathMatchesPattern(vt.file, pattern) || vt.file === pattern));
215
488
  if (uncoveredVTs.length > 0) {
216
- throw new Error(`frontend prewrite gate verification target is outside implementation writeSet: ${uncoveredVTs.map((vt) => vt.file).join(", ")}`);
489
+ return finalizePrewrite(input, {
490
+ ...basePending,
491
+ verdict,
492
+ candidateJsonSha256: analysis.candidateJsonSha256,
493
+ normalizationActions: [],
494
+ mockStrategy,
495
+ classification: "blocked",
496
+ failureReason: `frontend prewrite gate verification target is outside implementation writeSet: ${uncoveredVTs.map((vt) => vt.file).join(", ")}`,
497
+ failureCode: "verification-target-outside-write-set",
498
+ });
217
499
  }
218
500
  }
501
+ // Materialize the canonical contract only after every governance check passed.
502
+ const artifact = await writeFrontendImplementationContractArtifact({
503
+ runDir: input.runDir,
504
+ outputDir: input.config.outputDir,
505
+ artifactName: input.config.artifactName,
506
+ canonical: analysis.canonical,
507
+ });
219
508
  const candidatePaths = input.config.openspecCandidatePaths ?? [];
220
509
  const openspecReadPaths = await checkOpenspecReadEvidence({
221
510
  runDir: input.runDir,
@@ -224,32 +513,46 @@ export async function runFrontendPrewriteGate(input) {
224
513
  reviewNodeId,
225
514
  repoRoot: workspaceRoot ?? process.cwd(),
226
515
  });
227
- // OpenSpec is advisory design evidence, not an authorization boundary.
228
- // Model read behavior is nondeterministic, and a missed read must not block
229
- // a contract/schema/write-set-authorized implementation. Preserve any
230
- // successful evidence for review context, while allowing unavailable
231
- // evidence to be reported downstream.
232
- return {
233
- ok: true,
234
- planNodeId,
235
- reviewNodeId,
516
+ const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
517
+ return finalizePrewrite(input, {
518
+ ...basePending,
236
519
  verdict,
237
- mockStrategy: contract.mockApi.strategy,
520
+ classification,
521
+ failureReason: null,
522
+ candidateJsonSha256: analysis.candidateJsonSha256,
523
+ canonicalSha256: artifact.sha256,
524
+ normalizationActions: analysis.normalizationActions,
525
+ mockStrategy,
238
526
  artifact,
239
527
  openspecReadPaths,
240
528
  openspecCandidatePaths: candidatePaths,
241
- };
529
+ });
242
530
  }
243
531
  export function formatFrontendPrewriteGateStdout(result) {
244
- const lines = [
245
- "Frontend prewrite gate: pass",
246
- `Plan: ${result.planNodeId}`,
247
- `Review: ${result.reviewNodeId}`,
248
- `Mock strategy: ${result.mockStrategy}`,
249
- `Structured artifact: ${result.artifact.path}`,
250
- `Schema: ${result.artifact.schemaId}`,
251
- `SHA-256: ${result.artifact.sha256}`,
252
- ];
532
+ const lines = [];
533
+ if (result.classification === "accepted" ||
534
+ result.classification === "accepted-normalized") {
535
+ lines.push("Frontend prewrite gate: pass");
536
+ }
537
+ else if (result.classification === "blocked") {
538
+ lines.push("Frontend prewrite gate: blocked");
539
+ }
540
+ else {
541
+ lines.push("Frontend prewrite gate: retryable-invalid");
542
+ }
543
+ lines.push(`Classification: ${result.classification}`);
544
+ lines.push(`Plan: ${result.planNodeId}`);
545
+ lines.push(`Review: ${result.reviewNodeId}`);
546
+ if (result.mockStrategy)
547
+ lines.push(`Mock strategy: ${result.mockStrategy}`);
548
+ if (result.artifact) {
549
+ lines.push(`Structured artifact: ${result.artifact.path}`);
550
+ lines.push(`Schema: ${result.artifact.schemaId}`);
551
+ lines.push(`SHA-256: ${result.artifact.sha256}`);
552
+ }
553
+ lines.push(`Prewrite result: ${result.prewriteResult.path}`);
554
+ if (result.failureReason)
555
+ lines.push(`Failure reason: ${result.failureReason}`);
253
556
  if (result.openspecReadPaths.length > 0) {
254
557
  lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
255
558
  }
@@ -0,0 +1,73 @@
1
+ import { computeResetClosure } from "./rerun-plan.js";
2
+ /**
3
+ * Frontend-only candidate-continuation recovery planning (AC-5).
4
+ *
5
+ * Distinct from the generic `dag rerun` reset closure: the failure source is
6
+ * derived from the prewrite-result's `selectedPlanNodeId` rather than an
7
+ * operator-selected node, and the closure starts at the specific frontend
8
+ * candidate producer that failed.
9
+ */
10
+ /** Idempotency namespace for requestId-keyed recovery intents (D2). */
11
+ export const FRONTEND_RECOVERY_INTENT_REL_DIR = ".runtime/frontend-recovery";
12
+ /** Import manifest path inside the child run dir; hashed into the activation marker. */
13
+ export const FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH = ".runtime/import-manifest.json";
14
+ /** Staging directory prefix under `active/` for in-flight child materialization. */
15
+ export const FRONTEND_RECOVERY_STAGING_PREFIX = ".frontend-recovery-staging-";
16
+ export function deriveFrontendRecoveryFailureSource(result) {
17
+ if (result.selectedPlanNodeId === "frontend-plan-revision-pi") {
18
+ return "frontend-plan-revision-pi";
19
+ }
20
+ if (result.selectedPlanNodeId === "frontend-plan-pi") {
21
+ return "frontend-plan-pi";
22
+ }
23
+ return "frontend-prewrite-gate-shell";
24
+ }
25
+ /**
26
+ * Compute the frontend candidate-continuation reset partition:
27
+ * - `frontend-plan-pi` failure → reset plan + design-review + prewrite (+ descendants);
28
+ * - `frontend-plan-revision-pi` failure → reset revision + final-review + prewrite (+ descendants);
29
+ * - prewrite-only materialization → reset prewrite (+ descendants), reuse verified plan/review.
30
+ *
31
+ * Everything upstream of the failure source is imported as verified parent facts.
32
+ */
33
+ export function computeFrontendRecoveryPlan(input) {
34
+ const failureSource = deriveFrontendRecoveryFailureSource(input.result);
35
+ return computeFrontendRecoveryPlanForSource({
36
+ spec: input.spec,
37
+ parentRunId: input.parentRunId,
38
+ requestId: input.requestId,
39
+ failureSource,
40
+ });
41
+ }
42
+ /**
43
+ * Phase 5: writer transient partial-write recovery plan. The writer itself
44
+ * failed after some bounded writes, so the child re-runs `frontend-implement-pi`
45
+ * (and its descendants) while importing the verified contract/scout/plan/prewrite
46
+ * facts. Everything upstream of the writer is imported; the writer + descendants
47
+ * are reset.
48
+ */
49
+ export function computeFrontendWriterRecoveryPlan(input) {
50
+ return computeFrontendRecoveryPlanForSource({
51
+ spec: input.spec,
52
+ parentRunId: input.parentRunId,
53
+ requestId: input.requestId,
54
+ failureSource: "frontend-implement-pi",
55
+ });
56
+ }
57
+ function computeFrontendRecoveryPlanForSource(input) {
58
+ const resetNodeIds = computeResetClosure(input.spec, input.failureSource);
59
+ const resetSet = new Set(resetNodeIds);
60
+ const importedNodeIds = input.spec.tasks
61
+ .map((task) => task.id)
62
+ .filter((nodeId) => !resetSet.has(nodeId))
63
+ .sort();
64
+ return {
65
+ schemaVersion: 1,
66
+ parentRunId: input.parentRunId,
67
+ requestId: input.requestId,
68
+ failureSource: input.failureSource,
69
+ resetNodeIds,
70
+ importedNodeIds,
71
+ };
72
+ }
73
+ export { computeFrontendRecoveryPlanForSource };