@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
@@ -4,10 +4,69 @@ import { readFile, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { z } from "zod";
7
- import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
7
+ import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
8
8
  import { findPackageRoot } from "../../shared/package-metadata.js";
9
9
  import { pathMatchesPattern } from "../../shared/git-progress.js";
10
10
  import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
11
+ export const frontendNormalizationActionSchema = z.enum([
12
+ "remove-trailing-commas",
13
+ "strip-comments",
14
+ "canonicalize-requirement-ids",
15
+ "canonicalize-verification-alias",
16
+ "inject-source-binding",
17
+ ]);
18
+ export function sha256Hex(input) {
19
+ return createHash("sha256").update(input, "utf8").digest("hex");
20
+ }
21
+ function sortKeysDeep(value) {
22
+ if (Array.isArray(value))
23
+ return value.map(sortKeysDeep);
24
+ if (value && typeof value === "object") {
25
+ const record = value;
26
+ const sorted = {};
27
+ for (const key of Object.keys(record).sort()) {
28
+ sorted[key] = sortKeysDeep(record[key]);
29
+ }
30
+ return sorted;
31
+ }
32
+ return value;
33
+ }
34
+ /** Deterministic JSON serialization: fixed key order, 2-space indent, no
35
+ * trailing newline. Shared by candidate/canonical/digest hashes so the same
36
+ * input always reproduces the same digest. */
37
+ export function serializeDeterministicJson(value) {
38
+ return JSON.stringify(sortKeysDeep(value), null, 2);
39
+ }
40
+ export function deterministicSha256(value) {
41
+ return sha256Hex(serializeDeterministicJson(value));
42
+ }
43
+ /** A contract materialization failure carries its classification plus the
44
+ * audit context collected up to the failure point, so the prewrite gate can
45
+ * persist a complete frontend-prewrite-result-v1 even when the canonical
46
+ * contract is never materialized. */
47
+ export class FrontendContractFailure extends Error {
48
+ kind;
49
+ candidateRawSha256;
50
+ candidateJsonSha256;
51
+ normalizationActions;
52
+ constructor(options) {
53
+ super(options.message);
54
+ this.name = "FrontendContractFailure";
55
+ this.kind = options.kind;
56
+ this.candidateRawSha256 = options.candidateRawSha256;
57
+ this.candidateJsonSha256 = options.candidateJsonSha256 ?? null;
58
+ this.normalizationActions = options.normalizationActions ?? [];
59
+ }
60
+ }
61
+ /** Write a JSON artifact with deterministic key order and return its sha256
62
+ * over the exact file bytes, so the canonical contract identity matches what
63
+ * downstream verify/repair/review re-read from disk. */
64
+ export async function writeDeterministicJsonArtifact(runDir, relativePath, value) {
65
+ const json = `${serializeDeterministicJson(value)}\n`;
66
+ const targetPath = path.join(runDir, relativePath);
67
+ await writeTextArtifactFile(targetPath, json);
68
+ return { path: targetPath, sha256: sha256Hex(json) };
69
+ }
11
70
  /**
12
71
  * Extract frozen command labels from the DAG run spec (run.json).
13
72
  * The run spec is written before any node executes, so it is always available
@@ -132,7 +191,32 @@ const gap = z
132
191
  blocking: z.boolean(),
133
192
  })
134
193
  .strict();
194
+ /**
195
+ * Normalize unstable model emissions before the strict schema sees them:
196
+ * recursively drop null values (object entries and array elements). A null for
197
+ * an optional field becomes "absent" (accepted); a null for a required field
198
+ * still fails, but with a clean Required issue instead of a confusing type
199
+ * mismatch. Unknown keys and misspellings remain strict rejections.
200
+ */
201
+ function stripNullValuesDeep(value) {
202
+ if (Array.isArray(value)) {
203
+ return value
204
+ .filter((item) => item !== null)
205
+ .map((item) => stripNullValuesDeep(item));
206
+ }
207
+ if (value && typeof value === "object") {
208
+ const record = value;
209
+ const stripped = {};
210
+ for (const [key, item] of Object.entries(record)) {
211
+ if (item !== null)
212
+ stripped[key] = stripNullValuesDeep(item);
213
+ }
214
+ return stripped;
215
+ }
216
+ return value;
217
+ }
135
218
  export const frontendImplementationContractSchema = z
219
+ .preprocess(stripNullValuesDeep, z
136
220
  .object({
137
221
  schemaVersion: z.literal(1),
138
222
  sourceBinding: z
@@ -189,7 +273,9 @@ export const frontendImplementationContractSchema = z
189
273
  "not-needed",
190
274
  ]),
191
275
  productionDefaultOff: z.literal(true),
192
- activation: z.preprocess((value) => (value === "" || value === null ? "explicit activation boundary" : value), z.string().min(1)),
276
+ activation: z.preprocess((value) => value === "" || value === null || value === undefined
277
+ ? "explicit activation boundary"
278
+ : value, z.string().min(1)),
193
279
  endpoints: z.array(z
194
280
  .object({
195
281
  method: z.enum([
@@ -339,7 +425,7 @@ export const frontendImplementationContractSchema = z
339
425
  }
340
426
  });
341
427
  }
342
- });
428
+ }));
343
429
  export async function assertFrontendSourceBindingFresh(input) {
344
430
  for (const source of input.binding.sources) {
345
431
  const absolute = resolveDagTaskSourcePath({
@@ -378,8 +464,13 @@ function secretIssues(value, at = "$", issues = []) {
378
464
  }
379
465
  return issues;
380
466
  }
381
- export function extractFrontendImplementationJson(text) {
467
+ function extractFrontendImplementationJsonWithAudit(text) {
382
468
  const trimmed = text.trim();
469
+ const actions = [];
470
+ const recordAction = (action) => {
471
+ if (!actions.includes(action))
472
+ actions.push(action);
473
+ };
383
474
  const parse = (source) => {
384
475
  try {
385
476
  return JSON.parse(source);
@@ -392,6 +483,10 @@ export function extractFrontendImplementationJson(text) {
392
483
  .replace(/\/\*[\s\S]*?\*\//g, "")
393
484
  .replace(/^\s*\/\/.*$/gm, "");
394
485
  const withoutTrailingCommas = withoutComments.replace(/,\s*([}\]])/g, "$1");
486
+ if (withoutComments !== source)
487
+ recordAction("strip-comments");
488
+ if (withoutTrailingCommas !== withoutComments)
489
+ recordAction("remove-trailing-commas");
395
490
  try {
396
491
  return JSON.parse(withoutTrailingCommas);
397
492
  }
@@ -450,7 +545,7 @@ export function extractFrontendImplementationJson(text) {
450
545
  Array.isArray(record.verificationTargets));
451
546
  };
452
547
  if (trimmed.startsWith("{") && trimmed.endsWith("}"))
453
- return parse(trimmed);
548
+ return { value: parse(trimmed), actions };
454
549
  const balancedObjects = [];
455
550
  for (let start = 0; start < trimmed.length; start += 1) {
456
551
  if (trimmed[start] !== "{")
@@ -488,9 +583,9 @@ export function extractFrontendImplementationJson(text) {
488
583
  }
489
584
  const balancedContracts = balancedObjects.filter(isContract);
490
585
  if (balancedContracts.length === 1)
491
- return balancedContracts[0];
586
+ return { value: balancedContracts[0], actions };
492
587
  if (balancedObjects.length === 1)
493
- return balancedObjects[0];
588
+ return { value: balancedObjects[0], actions };
494
589
  const blocks = [...trimmed.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)];
495
590
  if (blocks.length === 0)
496
591
  throw new Error("output must contain exactly one fenced json object");
@@ -508,13 +603,16 @@ export function extractFrontendImplementationJson(text) {
508
603
  }
509
604
  const contractCandidates = candidates.filter(isContract);
510
605
  if (contractCandidates.length === 1)
511
- return contractCandidates[0];
606
+ return { value: contractCandidates[0], actions };
512
607
  if (candidates.length === 1)
513
- return candidates[0];
608
+ return { value: candidates[0], actions };
514
609
  if (candidates.length === 0)
515
610
  throw new Error("output must contain exactly one valid fenced json object (found 0)");
516
611
  throw new Error(`output must contain exactly one valid frontend contract json object (found ${contractCandidates.length || candidates.length})`);
517
612
  }
613
+ export function extractFrontendImplementationJson(text) {
614
+ return extractFrontendImplementationJsonWithAudit(text).value;
615
+ }
518
616
  /**
519
617
  * Build the authoritative frontend-implementation-contract sourceBinding from
520
618
  * the DAG-owned binding. Model JSON must not invent taskId/path/sha256; the
@@ -752,7 +850,14 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
752
850
  const requirementId = canonicalizeRequirementId(asString(gap.requirementId));
753
851
  // Model gaps are advisory. Blocking status is reconstructed below
754
852
  // from the source binding and executable verification targets.
755
- return { ...gap, blocking: false };
853
+ // Drop a null/empty optional requirementId so the strict schema
854
+ // accepts model emissions (null is not a valid string).
855
+ const { requirementId: _rawRequirementId, ...rest } = gap;
856
+ return {
857
+ ...rest,
858
+ ...(requirementId ? { requirementId } : {}),
859
+ blocking: false,
860
+ };
756
861
  })
757
862
  : [];
758
863
  const derivedBlockingGaps = canonicalBinding.requirementIds
@@ -1228,40 +1333,87 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1228
1333
  evidenceGaps,
1229
1334
  };
1230
1335
  }
1231
- export async function materializeFrontendImplementationContract(input) {
1336
+ /**
1337
+ * Deterministic contract validation shared by the prewrite gate and the plan
1338
+ * node output self-check. Extracts, security-checks, coerces, and schema-parses
1339
+ * a model-emitted frontend contract, so schema/typo/null violations surface as
1340
+ * invalid-output at the producing node (where they can retry) instead of only
1341
+ * failing later at the prewrite gate.
1342
+ */
1343
+ export async function analyzeFrontendImplementationContract(input) {
1232
1344
  if (!input.sourceBinding)
1233
1345
  throw new Error("frontend implementation contract gate requires DAG sourceBinding");
1234
- const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
1235
- let parsed;
1236
- const rawContractText = record.assistantText?.trim() || record.stdout?.trim() || "";
1346
+ let rawOutput;
1347
+ if (input.rawContractText !== undefined) {
1348
+ rawOutput = input.rawContractText;
1349
+ }
1350
+ else if (input.fromNodeId !== undefined) {
1351
+ const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
1352
+ rawOutput = record.assistantText ?? record.stdout ?? "";
1353
+ }
1354
+ else {
1355
+ throw new Error("analyzeFrontendImplementationContract requires fromNodeId or rawContractText");
1356
+ }
1357
+ const rawContractText = rawOutput.trim();
1358
+ const candidateRawSha256 = sha256Hex(rawOutput);
1359
+ const normalizationActions = [];
1360
+ const pushAction = (action) => {
1361
+ if (!normalizationActions.includes(action))
1362
+ normalizationActions.push(action);
1363
+ };
1364
+ function fail(kind, message, candidateJsonSha256) {
1365
+ throw new FrontendContractFailure({
1366
+ kind,
1367
+ message,
1368
+ candidateRawSha256,
1369
+ candidateJsonSha256,
1370
+ normalizationActions,
1371
+ });
1372
+ }
1237
1373
  if (rawContractText.includes('"files":["/'))
1238
- throw new Error("invalid-output: absolute frontend contract path is forbidden");
1374
+ fail("blocked", "invalid-output: absolute frontend contract path is forbidden", null);
1239
1375
  if (/(?:"(?:files|file|implementationTargets|fixture|consumer)"\s*:\s*(?:\[\s*)?)"\//.test(rawContractText))
1240
- throw new Error("invalid-output: absolute frontend contract path is forbidden");
1376
+ fail("blocked", "invalid-output: absolute frontend contract path is forbidden", null);
1241
1377
  if (/(?:"strategy"\s*:\s*")(?!native\b|browser-intercept\b|request-adapter\b|not-needed\b)[^"]+"/.test(rawContractText))
1242
- throw new Error("invalid-output: unsupported mock strategy");
1378
+ fail("blocked", "invalid-output: unsupported mock strategy", null);
1379
+ let parsed;
1243
1380
  try {
1244
- parsed = extractFrontendImplementationJson(rawContractText);
1381
+ const extracted = extractFrontendImplementationJsonWithAudit(rawContractText);
1382
+ parsed = extracted.value;
1383
+ for (const action of extracted.actions)
1384
+ pushAction(action);
1245
1385
  }
1246
1386
  catch (error) {
1247
- throw new Error(`invalid-output: ${error instanceof Error ? error.message : String(error)}`);
1387
+ fail("retryable-invalid", `invalid-output: ${error instanceof Error ? error.message : String(error)}`, null);
1248
1388
  }
1389
+ const candidateJsonSha256 = deterministicSha256(parsed);
1249
1390
  const secrets = secretIssues(parsed);
1250
1391
  if (secrets.length)
1251
- throw new Error(`invalid-output: ${secrets.join("; ")}`);
1392
+ fail("blocked", `invalid-output: ${secrets.join("; ")}`, candidateJsonSha256);
1252
1393
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1253
- throw new Error("invalid-output: frontend contract must be a JSON object");
1394
+ fail("retryable-invalid", "invalid-output: frontend contract must be a JSON object", candidateJsonSha256);
1254
1395
  const parsedTargetObject = parsed.targets;
1255
1396
  const parsedFiles = parsedTargetObject && typeof parsedTargetObject === "object"
1256
1397
  ? parsedTargetObject.files
1257
1398
  : undefined;
1258
1399
  if (Array.isArray(parsedFiles) && parsedFiles.some((file) => typeof file === "string" && file.startsWith("/")))
1259
- throw new Error("invalid-output: absolute frontend contract path is forbidden");
1400
+ fail("blocked", "invalid-output: absolute frontend contract path is forbidden", candidateJsonSha256);
1260
1401
  // Normalize model-emitted compact requirement IDs before deriving the
1261
1402
  // canonical binding or invoking the strict zod schema.
1403
+ const beforeIds = JSON.stringify(parsed);
1262
1404
  parsed = canonicalizeRequirementIdsInPayload(parsed);
1405
+ if (JSON.stringify(parsed) !== beforeIds)
1406
+ pushAction("canonicalize-requirement-ids");
1407
+ const beforeAliases = JSON.stringify(parsed);
1263
1408
  parsed = canonicalizeVerificationTargetAliases(parsed);
1264
- assertFrontendContractPathsSafe(parsed);
1409
+ if (JSON.stringify(parsed) !== beforeAliases)
1410
+ pushAction("canonicalize-verification-alias");
1411
+ try {
1412
+ assertFrontendContractPathsSafe(parsed);
1413
+ }
1414
+ catch (error) {
1415
+ fail("blocked", error instanceof Error ? error.message : String(error), candidateJsonSha256);
1416
+ }
1265
1417
  // Validate verificationTarget commandLabels against frozen command set.
1266
1418
  // The frozen set is derived from DAG verification shell task verifyEvidence.
1267
1419
  const frozenLabels = await deriveFrozenCommandLabelsFromRun(input.runDir);
@@ -1272,7 +1424,7 @@ export async function materializeFrontendImplementationContract(input) {
1272
1424
  for (const vt of parsedVt) {
1273
1425
  const label = asString(asRecord(vt)?.commandLabel);
1274
1426
  if (label && !frozen.has(label)) {
1275
- throw new Error(`invalid-output: verificationTarget commandLabel "${label}" is not in the frozen command set [${[...frozen].join(", ")}]`);
1427
+ fail("blocked", `invalid-output: verificationTarget commandLabel "${label}" is not in the frozen command set [${[...frozen].join(", ")}]`, candidateJsonSha256);
1276
1428
  }
1277
1429
  }
1278
1430
  }
@@ -1280,52 +1432,94 @@ export async function materializeFrontendImplementationContract(input) {
1280
1432
  const parsedTargets = asRecord(parsed)?.targets;
1281
1433
  const parsedTargetFiles = asStringArray(asRecord(parsedTargets)?.files);
1282
1434
  if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
1283
- throw new Error("invalid-output: frontend contract target paths must be relative POSIX paths");
1435
+ fail("blocked", "invalid-output: frontend contract target paths must be relative POSIX paths", candidateJsonSha256);
1284
1436
  const parsedStates = asRecord(parsed)?.uiStates;
1285
1437
  if (Array.isArray(parsedStates) && parsedStates.some((item) => {
1286
1438
  const state = asRecord(item);
1287
1439
  return state?.applicable === true &&
1288
1440
  (!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
1289
1441
  }))
1290
- throw new Error("invalid-output: applicable UI state requires behavior, implementation, and verification");
1442
+ fail("retryable-invalid", "invalid-output: applicable UI state requires behavior, implementation, and verification", candidateJsonSha256);
1291
1443
  const parsedMockApi = asRecord(parsed)?.mockApi;
1292
1444
  if (asRecord(parsedMockApi) &&
1293
1445
  typeof asRecord(parsedMockApi)?.strategy === "string" &&
1294
1446
  !["native", "browser-intercept", "request-adapter", "not-needed"].includes(String(asRecord(parsedMockApi)?.strategy)))
1295
- throw new Error(`invalid-output: unsupported mock strategy ${String(asRecord(parsedMockApi)?.strategy)}`);
1447
+ fail("blocked", `invalid-output: unsupported mock strategy ${String(asRecord(parsedMockApi)?.strategy)}`, candidateJsonSha256);
1296
1448
  const baseCanonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
1297
1449
  const canonicalBinding = withDerivedRequirementIdsWhenUnscoped(baseCanonicalBinding, parsed);
1298
1450
  // Always inject DAG-owned identity. Model-provided sourceBinding is advisory
1299
- // only and must not fail a otherwise-valid contract (common live failure:
1300
- // wrong requirementPath/sha, extra referencePaths, or omitted binding).
1301
- const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
1451
+ // only and must not fail a otherwise-valid contract. Record the action only
1452
+ // when the model's emitted binding actually differs from canonical identity.
1453
+ const modelBinding = asRecord(asRecord(parsed)?.sourceBinding);
1454
+ if (serializeDeterministicJson(modelBinding ?? null) !== serializeDeterministicJson(canonicalBinding))
1455
+ pushAction("inject-source-binding");
1302
1456
  // There is exactly one post-security candidate. A fallback candidate would
1303
1457
  // allow malformed raw fields to bypass the boundary checks above.
1458
+ const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
1304
1459
  const candidate = deriveFrontendVerificationCoverage({
1305
1460
  ...(asRecord(normalizedContract) ?? parsed),
1306
1461
  sourceBinding: canonicalBinding,
1307
1462
  }, canonicalBinding);
1308
1463
  const result = frontendImplementationContractSchema.safeParse(candidate);
1309
1464
  if (!result.success)
1310
- throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
1465
+ fail("retryable-invalid", `invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, candidateJsonSha256);
1311
1466
  const blockingGaps = [
1312
1467
  ...result.data.evidenceGaps,
1313
1468
  ...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
1314
1469
  ].filter((item) => item.blocking);
1315
1470
  if (blockingGaps.length > 0)
1316
- throw new Error(`frontend contract has blocking evidence gap: ${blockingGaps
1471
+ fail("blocked", `frontend contract has blocking evidence gap: ${blockingGaps
1317
1472
  .map((item) => item.requirementId ?? item.description)
1318
- .join(", ")}`);
1473
+ .join(", ")}`, candidateJsonSha256);
1319
1474
  for (const requirementId of canonicalBinding.requirementIds)
1320
1475
  if (!result.data.requirements.some((item) => item.id === requirementId) &&
1321
1476
  !result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
1322
- throw new Error(`frontend contract does not cover ${requirementId}`);
1323
- const artifactPath = await writeDagRunJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), result.data);
1477
+ fail("blocked", `frontend contract does not cover ${requirementId}`, candidateJsonSha256);
1478
+ return {
1479
+ canonical: result.data,
1480
+ candidateRawSha256,
1481
+ candidateJsonSha256,
1482
+ normalizationActions,
1483
+ };
1484
+ }
1485
+ export async function writeFrontendImplementationContractArtifact(input) {
1486
+ const written = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), input.canonical);
1324
1487
  return {
1325
- path: artifactPath,
1326
- sha256: createHash("sha256")
1327
- .update(`${JSON.stringify(result.data, null, 2)}\n`)
1328
- .digest("hex"),
1488
+ path: written.path,
1489
+ sha256: written.sha256,
1329
1490
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1330
1491
  };
1331
1492
  }
1493
+ /**
1494
+ * Node-output self-check for plan nodes that produce the implementation
1495
+ * contract. Mirrors the prewrite gate validation so schema/typo/null violations
1496
+ * are caught at the producing node and retried instead of failing the run later.
1497
+ * Skipped when no sourceBinding is available (the gate remains the authority).
1498
+ */
1499
+ export async function validateFrontendContractNodeOutput(input) {
1500
+ if (!input.sourceBinding)
1501
+ return { ok: true };
1502
+ try {
1503
+ const analysis = await analyzeFrontendImplementationContract({
1504
+ runDir: input.runDir,
1505
+ rawContractText: input.text,
1506
+ sourceBinding: input.sourceBinding,
1507
+ });
1508
+ return { ok: true, contract: analysis.canonical };
1509
+ }
1510
+ catch (error) {
1511
+ return {
1512
+ ok: false,
1513
+ reason: error instanceof Error ? error.message : String(error),
1514
+ };
1515
+ }
1516
+ }
1517
+ export async function materializeFrontendImplementationContract(input) {
1518
+ const analysis = await analyzeFrontendImplementationContract(input);
1519
+ return writeFrontendImplementationContractArtifact({
1520
+ runDir: input.runDir,
1521
+ outputDir: input.outputDir,
1522
+ artifactName: input.artifactName,
1523
+ canonical: analysis.canonical,
1524
+ });
1525
+ }