@tea-agent/loop-agent 0.39.0-beta.8 → 0.39.0-beta.9

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 (158) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +17 -5
  3. package/README.md +2 -2
  4. package/dist/application/task-lifecycle/advance.js +12 -0
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/cli/update/runtime-activity.js +1 -29
  7. package/dist/commands/dag-rerun.js +1 -1
  8. package/dist/commands/init.js +26 -0
  9. package/dist/executors/dag-pi-executor.js +326 -8
  10. package/dist/executors/pi-read-budget-policy.js +166 -0
  11. package/dist/executors/shell-executor.js +26 -30
  12. package/dist/shared/dag-prompt-override.js +4 -5
  13. package/dist/shared/update/npm-client.js +33 -11
  14. package/dist/task/contract/apply.js +11 -1
  15. package/dist/task/contract/constants.js +2 -0
  16. package/dist/task/contract/hash.js +3 -0
  17. package/dist/task/contract/observe.js +25 -4
  18. package/dist/task/contract/paths.js +2 -1
  19. package/dist/task/contract/project.js +15 -1
  20. package/dist/task/contract/schema.js +2 -0
  21. package/dist/task/contract/transaction.js +12 -0
  22. package/dist/task/frontend-project-capability.js +123 -4
  23. package/dist/task/source-prepare/completeness.js +188 -0
  24. package/dist/task/source-prepare/fragment-inventory.js +477 -0
  25. package/dist/task/source-prepare/index.js +3 -0
  26. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  27. package/dist/task/source-prepare/ledger-review.js +214 -0
  28. package/dist/task/source-prepare/ledger.js +545 -0
  29. package/dist/task/source-prepare/prepare.js +262 -1
  30. package/dist/task/source-prepare/semantic-intake.js +25 -5
  31. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  32. package/dist/task/source-prepare/types.js +22 -0
  33. package/dist/worker/cli.js +12 -26
  34. package/dist/worker/console/console-handoff.js +82 -0
  35. package/dist/worker/console/console-update-and-init.js +115 -0
  36. package/dist/worker/console/console-update-runtime.js +84 -0
  37. package/dist/worker/console/draft-store.js +49 -0
  38. package/dist/worker/console/operation-store.js +1 -1
  39. package/dist/worker/console/operator-actions.js +129 -4
  40. package/dist/worker/console/operator-surface-health.js +1 -0
  41. package/dist/worker/console/prd-intake-bridge.js +13 -0
  42. package/dist/worker/console/routes.js +3 -0
  43. package/dist/worker/console/server.js +73 -5
  44. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-bPer1waP.js → abnfDiagram-N423BO3Z-D1-Z-BBa.js} +1 -1
  45. package/dist/worker/console/static/assets/{arc-BLdkwucW.js → arc-q8qAYm5m.js} +1 -1
  46. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw4wo4I1.js → architectureDiagram-T3A2C74G-CAu4_lve.js} +1 -1
  47. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DUUkc0gr.js → blockDiagram-VBNYF7ZC-D5GhQvHm.js} +1 -1
  48. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BC3O0nob.js → c4Diagram-5PPSVZJV-ClQjeLkO.js} +1 -1
  49. package/dist/worker/console/static/assets/channel-ClAuhSUL.js +1 -0
  50. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Dd4g2nti.js → chunk-2GRJ4B5K-DLqWlFbo.js} +1 -1
  51. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-s2EDm7-n.js → chunk-2Q5K7J3B-DkRT_usL.js} +1 -1
  52. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-GqtHM2yQ.js → chunk-5RXB4S5H-Cqp7Chi3.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BzRFrN8j.js → chunk-5VM5RSS4-CedEdFB9.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bw9nFjCJ.js → chunk-6Q2QTUOP-BHM8vP5x.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B4n-03Qm.js → chunk-GF5L2VYU-D7EFr377.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Dt75ZdBD.js → chunk-JWPE2WC7-DR38mTWV.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B_afMhHB.js → chunk-KBJHAD2P-BFv2LsR3.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Bd8Rqqg9.js → chunk-RYQCIY6F-BAvpJS5z.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-9boOMji4.js → chunk-XXDRQBXY-eKI9TO72.js} +1 -1
  60. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-cBgeCpVU.js +1 -0
  61. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-cBgeCpVU.js +1 -0
  62. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BtCycfCc.js → cose-bilkent-JH36ORCC-BMJXpgo4.js} +1 -1
  63. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-eTOts9L1.js → cynefin-VYW2F7L2-CKJ2ofZx.js} +1 -1
  64. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Cibd2mjg.js → cynefinDiagram-MW4NZA55-CsKnsazm.js} +1 -1
  65. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CDjDZtDV.js → dagre-VZM6K2ZE-DVQ46vIw.js} +1 -1
  66. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Btexn-JJ.js → diagram-7IWD3JNH-D_RuonYS.js} +1 -1
  67. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cyl8sdpM.js → diagram-B4RE2ZJO-D2Mt0nMu.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Ev-83E1t.js → diagram-LBJQPF4R-DjBDuye6.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-41LqE0Mc.js → diagram-Q27KOJAE-8VZkqpE_.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BC39T5R3.js → diagram-UB23O5K3-D0izYxia.js} +1 -1
  71. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DzcNRuri.js → ebnfDiagram-BXEA7PRR-CncG2I95.js} +1 -1
  72. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-B5Frvvnz.js → erDiagram-JOGREHBK-DiBUUXR8.js} +1 -1
  73. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BapxROIM.js → flowDiagram-UKHOOZJN-Dtc0fArb.js} +1 -1
  74. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cd4d4wQ0.js → ganttDiagram-PKOTCBZU-BPNFP5OZ.js} +1 -1
  75. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CrL6buFW.js → gitGraphDiagram-DS77QQ5N-CsgpxEEd.js} +1 -1
  76. package/dist/worker/console/static/assets/{index-DBh_x_Ek.js → index-Bai8Z25q.js} +81 -81
  77. package/dist/worker/console/static/assets/index-CH0Tr5hY.css +1 -0
  78. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BrSiNxyd.js → infoDiagram-6WML65LV-kHGxkJUS.js} +1 -1
  79. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-gvlvY-n9.js → ishikawaDiagram-WSZJBQD7-CbLe9wrr.js} +1 -1
  80. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-YaPzRQ.js → journeyDiagram-NVQOT4AX-BOkwxAcP.js} +1 -1
  81. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BsmDv9E_.js → kanban-definition-27J2QSJJ-BHYQ2rDu.js} +1 -1
  82. package/dist/worker/console/static/assets/{linear-UWaoDN3h.js → linear-CHT4ujob.js} +1 -1
  83. package/dist/worker/console/static/assets/{mermaid.core-BlvpGVG8.js → mermaid.core-yL8sfqnY.js} +5 -5
  84. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CUCIP5CP.js → mindmap-definition-FAOFIHXS-DZx1coV-.js} +1 -1
  85. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CB1aQUAK.js → pegDiagram-VL7TDLO6-DStWRCI8.js} +1 -1
  86. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BwehHxhf.js → pieDiagram-7S7Q4E2Y-DSKZkwzK.js} +1 -1
  87. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CFz30APB.js → quadrantDiagram-CIZ2JOQS-CmoEGOuW.js} +1 -1
  88. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwNmw1i6.js → railroadDiagram-AXF67PYL-BLqzhKyq.js} +1 -1
  89. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-jg-tPXlI.js → requirementDiagram-LRYGKXZP-gbHeUabM.js} +1 -1
  90. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BlyReDKl.js → sankeyDiagram-W5VNT64P-CaKkBCKO.js} +1 -1
  91. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DBUzW1f3.js → sequenceDiagram-SI44F4Z6-PUbi3Upu.js} +1 -1
  92. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BYcI2RAr.js → sizeCapture-X5ZJPWSS-dm7S9Qfe.js} +1 -1
  93. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D7AiyJdo.js → stateDiagram-OKZ733FA-C7WSzv4p.js} +1 -1
  94. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-sQDv5AP3.js +1 -0
  95. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BHGt7Kkv.js → swimlanes-SLNWSIFB-CBKCiQwd.js} +2 -2
  96. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BXwsmcPs.js +8 -0
  97. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-OPjooWcX.js → timeline-definition-Z64GVDOM-CvnUlylk.js} +1 -1
  98. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-ggsWDvrO.js → vennDiagram-T6HMQDX7-xM0StY1O.js} +1 -1
  99. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DjNe-BjZ.js → wardleyDiagram-T6FBY63Y-CWtJMagy.js} +1 -1
  100. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Byp63Hei.js → xychartDiagram-ELKLHX3M-BO_-MeK8.js} +1 -1
  101. package/dist/worker/console/static/index.html +2 -2
  102. package/dist/worker/console/static-src/app/console-types.js +40 -1
  103. package/dist/worker/console/static-src/app/useOperatorActions.js +5 -0
  104. package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
  105. package/dist/worker/observability/init-runtime-activity.js +26 -0
  106. package/dist/worker/observe/node-input.js +88 -2
  107. package/dist/worker/observe/routes.js +188 -0
  108. package/dist/worker/observe/static/api.js +3 -3
  109. package/dist/worker/observe/static/format.js +37 -0
  110. package/dist/worker/observe/static/index.html +5 -0
  111. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  112. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  113. package/dist/worker/observe/static/state.js +2 -2
  114. package/dist/worker/observe/static/styles.css +331 -35
  115. package/dist/worker/observe/static/views/dag-inspector.js +509 -78
  116. package/dist/worker/observe/static/views/task.js +55 -0
  117. package/dist/workflows/dag/frontend-design-policy.js +19 -9
  118. package/dist/workflows/dag/frontend-implementation-contract.js +109 -49
  119. package/dist/workflows/dag/frontend-plan-render.js +27 -0
  120. package/dist/workflows/dag/frontend-prewrite-gate.js +45 -1
  121. package/dist/workflows/dag/frontend-recovery-controller.js +1 -0
  122. package/dist/workflows/dag/frontend-review-context.js +102 -15
  123. package/dist/workflows/dag/frontend-shadow-dual-write.js +36 -2
  124. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  125. package/dist/workflows/dag/frontend-worktree-diff.js +154 -0
  126. package/dist/workflows/dag/frontend-writer-admission.js +12 -0
  127. package/dist/workflows/dag/init-hybrid.js +293 -65
  128. package/dist/workflows/dag/interrupt-request.js +7 -0
  129. package/dist/workflows/dag/node-execution.js +111 -1
  130. package/dist/workflows/dag/prompt.js +34 -1
  131. package/dist/workflows/dag/recovery-lease.js +80 -0
  132. package/dist/workflows/dag/rerun-run.js +23 -0
  133. package/dist/workflows/dag/rerun-task.js +29 -0
  134. package/dist/workflows/dag/retry-policy.js +26 -3
  135. package/dist/workflows/dag/runner.js +38 -8
  136. package/dist/workflows/dag/types.js +51 -11
  137. package/docs/README.md +1 -0
  138. package/docs/architecture/evolution.md +45 -0
  139. package/docs/init-surface.manifest.json +4 -0
  140. package/docs/templates/README.md +2 -1
  141. package/docs/templates/frontend-implementation-contract.schema.json +684 -24
  142. package/docs/templates/spec-registry.schema.json +45 -0
  143. package/harness.json +3 -3
  144. package/package.json +1 -1
  145. package/skills/frontend-bounded-implement/SKILL.md +8 -14
  146. package/skills/frontend-design-review/SKILL.md +20 -41
  147. package/skills/frontend-implementation/SKILL.md +2 -3
  148. package/skills/frontend-implementation/references/design-spec.md +4 -3
  149. package/skills/frontend-implementation/references/node-contracts.md +4 -3
  150. package/skills/frontend-review/SKILL.md +22 -14
  151. package/skills/frontend-review/references/review-findings.md +6 -7
  152. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  153. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +0 -1
  154. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +0 -1
  155. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +0 -1
  156. package/dist/worker/console/static/assets/index-DwOGauxU.css +0 -1
  157. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +0 -1
  158. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +0 -8
@@ -8,6 +8,7 @@ import { routeFrontendProviderCapability } from "../workflows/dag/frontend-provi
8
8
  import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
9
9
  import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
10
10
  import { buildPiWriterToolPolicyContext, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
11
+ import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
11
12
  import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
12
13
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
13
14
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
@@ -338,6 +339,90 @@ export function scanReviewTerminalKindsFromSessionEvents(content) {
338
339
  * the plan re-read upstream outputs/sources instead of trusting typed facts,
339
340
  * which blows up the context window (400 request-too-large). */
340
341
  const PLAN_READ_TOOL_BUDGET = 40;
342
+ const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "ls", "find"]);
343
+ function readEventPath(event) {
344
+ const candidates = [event.path, event.readPath, event.input];
345
+ for (const value of candidates) {
346
+ if (typeof value === "string")
347
+ return value;
348
+ if (value && typeof value === "object") {
349
+ const nested = value;
350
+ for (const key of ["path", "readPath", "file"]) {
351
+ if (typeof nested[key] === "string")
352
+ return nested[key];
353
+ }
354
+ }
355
+ }
356
+ return undefined;
357
+ }
358
+ function eventTimestamp(event) {
359
+ for (const key of ["timestamp", "timestampMs", "ts", "createdAt"]) {
360
+ const value = event[key];
361
+ if (typeof value === "number" && Number.isFinite(value))
362
+ return value < 10_000_000_000 ? value * 1000 : value;
363
+ if (typeof value === "string") {
364
+ const parsed = Date.parse(value);
365
+ if (Number.isFinite(parsed))
366
+ return parsed;
367
+ }
368
+ }
369
+ return undefined;
370
+ }
371
+ function eventBytes(event, line) {
372
+ const result = event.result ?? event.toolResult ?? event.output;
373
+ if (typeof result === "string")
374
+ return Buffer.byteLength(result);
375
+ if (result !== undefined)
376
+ return Buffer.byteLength(JSON.stringify(result));
377
+ return Buffer.byteLength(line);
378
+ }
379
+ export async function detectNodeReadBudget(input) {
380
+ if (!input.budget)
381
+ return [];
382
+ const sessionEventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
383
+ let content;
384
+ try {
385
+ content = await readFile(sessionEventsPath, "utf8");
386
+ }
387
+ catch {
388
+ return [];
389
+ }
390
+ const stats = { files: new Set(), bytes: 0, tools: 0 };
391
+ let firstTs;
392
+ let lastTs;
393
+ for (const line of content.split("\n")) {
394
+ if (!line.trim())
395
+ continue;
396
+ try {
397
+ const event = JSON.parse(line);
398
+ if (event.type !== "tool_execution_start" || typeof event.toolName !== "string" || !READ_ONLY_TOOL_NAMES.has(event.toolName))
399
+ continue;
400
+ stats.tools++;
401
+ const readPath = readEventPath(event);
402
+ if (readPath)
403
+ stats.files.add(readPath);
404
+ stats.bytes += eventBytes(event, line);
405
+ const ts = eventTimestamp(event);
406
+ if (ts !== undefined) {
407
+ firstTs ??= ts;
408
+ lastTs = ts;
409
+ }
410
+ }
411
+ catch { /* ignore malformed telemetry */ }
412
+ }
413
+ if (firstTs !== undefined && lastTs !== undefined)
414
+ stats.elapsedMs = Math.max(0, lastTs - firstTs);
415
+ const issues = [];
416
+ if (stats.files.size > input.budget.maxFiles)
417
+ issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.files.size} files (budget ${input.budget.maxFiles})`);
418
+ if (stats.bytes > input.budget.maxBytes)
419
+ issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.bytes} bytes (budget ${input.budget.maxBytes})`);
420
+ if (input.budget.maxMs !== undefined &&
421
+ stats.elapsedMs !== undefined &&
422
+ stats.elapsedMs > input.budget.maxMs)
423
+ issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.elapsedMs}ms (budget ${input.budget.maxMs}ms)`);
424
+ return issues;
425
+ }
341
426
  /** Deterministic read-burst guard for frontend-plan-pi: count read-only
342
427
  * discovery tool calls (read/grep/ls/find) from the session log. Over budget →
343
428
  * read-burst, retried with a reduced-reading instruction. Pure scan; a
@@ -685,6 +770,56 @@ export async function createFrontendDesignTerminalTools(input) {
685
770
  },
686
771
  };
687
772
  }
773
+ /**
774
+ * Source fidelity ledger (AC-005/AC-006): load the contract node's committed
775
+ * requirement facts and build a requirement-id → provenance map. The contract
776
+ * node declared sourceFragmentIds/sourceRefs from the ledger's
777
+ * requirement→fragment mapping; the plan inherits them by id so the compiled
778
+ * canonical contract carries authoritative provenance without the plan
779
+ * re-deriving (or fabricating) it. Best-effort: missing/unreadable contract
780
+ * ledger yields an empty map and the plan compiles as before (the design
781
+ * policy shell will then fail closed on missing provenance).
782
+ */
783
+ async function loadContractRequirementInheritance(runDir) {
784
+ const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
785
+ let records;
786
+ try {
787
+ records = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
788
+ }
789
+ catch {
790
+ return new Map();
791
+ }
792
+ const byId = new Map();
793
+ for (const record of records) {
794
+ const fact = record.fact;
795
+ if (!fact || typeof fact !== "object")
796
+ continue;
797
+ const recordFact = fact;
798
+ if (recordFact.origin !== "contract" ||
799
+ recordFact.kind !== "requirement") {
800
+ continue;
801
+ }
802
+ // Contract requirement facts carry id/sourceFragmentIds/sourceRefs on
803
+ // the fact itself (origin=contract, kind=requirement, id, text, ...),
804
+ // not inside an `entry` wrapper.
805
+ const id = typeof recordFact.id === "string" ? recordFact.id : "";
806
+ if (!id)
807
+ continue;
808
+ const sourceFragmentIds = Array.isArray(recordFact.sourceFragmentIds)
809
+ ? recordFact.sourceFragmentIds.filter((value) => typeof value === "string")
810
+ : undefined;
811
+ const sourceRefs = Array.isArray(recordFact.sourceRefs)
812
+ ? recordFact.sourceRefs.filter((value) => typeof value === "string")
813
+ : undefined;
814
+ if (sourceFragmentIds || sourceRefs) {
815
+ byId.set(id, {
816
+ ...(sourceFragmentIds ? { sourceFragmentIds } : {}),
817
+ ...(sourceRefs ? { sourceRefs } : {}),
818
+ });
819
+ }
820
+ }
821
+ return byId;
822
+ }
688
823
  /**
689
824
  * A+B: `frontend-plan-pi` now records its decision ledger through seven
690
825
  * incremental `record_*` tools (origin=plan) and closes with exactly one
@@ -725,9 +860,19 @@ export async function createFrontendPlanLedgerTools(input) {
725
860
  implementationTargets: optionalStringArray,
726
861
  verificationTargetIds: optionalStringArray,
727
862
  notApplicableReason: optionalString,
863
+ // Compatibility is intentionally limited to this model-facing tool
864
+ // boundary. Canonical contract facts always use notApplicableReason.
865
+ reason: Type.Optional(Type.String({
866
+ description: "Legacy/model alias for notApplicableReason. Use notApplicableReason in new calls.",
867
+ })),
728
868
  }, { additionalProperties: false });
729
869
  const interactionSchema = Type.Object({
730
- name: Type.String({}),
870
+ name: optionalString,
871
+ // Compatibility is intentionally limited to this model-facing tool
872
+ // boundary. Canonical contract facts always use name.
873
+ id: Type.Optional(Type.String({
874
+ description: "Legacy/model alias for name. Use name in new calls.",
875
+ })),
731
876
  trigger: Type.String({}),
732
877
  expectedBehavior: Type.String({}),
733
878
  implementationTargets: stringArray,
@@ -764,7 +909,9 @@ export async function createFrontendPlanLedgerTools(input) {
764
909
  description: "Optional. Real exported identifier or describe/it title in the target file. Omit it to keep the call small — the trace gate then only verifies the file exists and the command ran. Forbidden for type=static (runtime strips it).",
765
910
  })),
766
911
  requirementIds: stringArray,
767
- uiStates: stringArray,
912
+ uiStates: Type.Optional(Type.Array(Type.String({}), {
913
+ description: "Optional only at this tool boundary. An omitted value is deterministically recorded as []. Pass an explicit array for new calls.",
914
+ })),
768
915
  }, { additionalProperties: false });
769
916
  const evidenceGapSchema = Type.Object({
770
917
  requirementId: optionalString,
@@ -791,6 +938,9 @@ export async function createFrontendPlanLedgerTools(input) {
791
938
  const stringList = (value) => Array.isArray(value)
792
939
  ? value.filter((item) => typeof item === "string")
793
940
  : [];
941
+ const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0
942
+ ? value.trim()
943
+ : undefined;
794
944
  const planToolReceipt = (details) => ({
795
945
  content: [
796
946
  {
@@ -863,7 +1013,7 @@ export async function createFrontendPlanLedgerTools(input) {
863
1013
  const recordComponentChoiceTool = defineTool({
864
1014
  name: "record_component_choice",
865
1015
  label: "record_component_choice",
866
- description: "Record ONE component choice (origin=plan component-choice fact). Only declare components you add or replace; reuse of existing components needs no entry. Omit rationale for reuse-existing; it is optional. Call once per component — one tool call per message. Optionally include stylingStrategy (set it once, on the first call).",
1016
+ description: "Record ONE component choice (origin=plan component-choice fact). Declare every UI purpose's component selection. A component the task source/PRD mandates adding (not present in the repo) MUST be decision=new with a specReference pointing at the PRD section that defines it; decision=reuse-existing is only for components that already exist in the repo (e.g. reusing ActiveRunBadge's styling convention). Omit rationale for reuse-existing; it is optional. Call once per component — one tool call per message. Optionally include stylingStrategy (set it once, on the first call).",
867
1017
  promptSnippet: "Record one component choice (one tool call per message).",
868
1018
  parameters: Type.Object({
869
1019
  choice: uiComponentChoiceSchema,
@@ -902,10 +1052,79 @@ export async function createFrontendPlanLedgerTools(input) {
902
1052
  interactions: Type.Array(interactionSchema),
903
1053
  }, { additionalProperties: false }),
904
1054
  async execute(_toolCallId, params) {
905
- const uiStates = Array.isArray(params?.uiStates) ? params.uiStates : [];
906
- const interactions = Array.isArray(params?.interactions)
1055
+ const rawUiStates = Array.isArray(params?.uiStates)
1056
+ ? params.uiStates
1057
+ : [];
1058
+ const rawInteractions = Array.isArray(params?.interactions)
907
1059
  ? params.interactions
908
1060
  : [];
1061
+ const uiStates = [];
1062
+ for (const rawState of rawUiStates) {
1063
+ if (!isRecordObject(rawState)) {
1064
+ return planToolReceipt({
1065
+ ok: false,
1066
+ kind: "state-flow",
1067
+ error: "record_state_flow uiStates entries must be objects",
1068
+ });
1069
+ }
1070
+ const { reason, notApplicableReason, ...state } = rawState;
1071
+ const canonicalReason = nonEmptyString(notApplicableReason);
1072
+ const aliasReason = nonEmptyString(reason);
1073
+ if (canonicalReason !== undefined &&
1074
+ aliasReason !== undefined &&
1075
+ canonicalReason !== aliasReason) {
1076
+ return planToolReceipt({
1077
+ ok: false,
1078
+ kind: "state-flow",
1079
+ error: "record_state_flow uiState has conflicting reason and notApplicableReason values",
1080
+ });
1081
+ }
1082
+ const resolvedReason = canonicalReason ?? aliasReason;
1083
+ if (state.applicable === false && !resolvedReason) {
1084
+ return planToolReceipt({
1085
+ ok: false,
1086
+ kind: "state-flow",
1087
+ error: "record_state_flow requires non-empty notApplicableReason when uiState.applicable is false",
1088
+ });
1089
+ }
1090
+ uiStates.push({
1091
+ ...state,
1092
+ ...(resolvedReason
1093
+ ? { notApplicableReason: resolvedReason }
1094
+ : {}),
1095
+ });
1096
+ }
1097
+ const interactions = [];
1098
+ for (const rawInteraction of rawInteractions) {
1099
+ if (!isRecordObject(rawInteraction)) {
1100
+ return planToolReceipt({
1101
+ ok: false,
1102
+ kind: "state-flow",
1103
+ error: "record_state_flow interactions entries must be objects",
1104
+ });
1105
+ }
1106
+ const { id, name, ...interaction } = rawInteraction;
1107
+ const canonicalName = nonEmptyString(name);
1108
+ const aliasName = nonEmptyString(id);
1109
+ if (canonicalName !== undefined &&
1110
+ aliasName !== undefined &&
1111
+ canonicalName !== aliasName) {
1112
+ return planToolReceipt({
1113
+ ok: false,
1114
+ kind: "state-flow",
1115
+ error: "record_state_flow interaction has conflicting id and name values",
1116
+ });
1117
+ }
1118
+ const resolvedName = canonicalName ?? aliasName;
1119
+ if (!resolvedName) {
1120
+ return planToolReceipt({
1121
+ ok: false,
1122
+ kind: "state-flow",
1123
+ error: "record_state_flow requires non-empty interaction.name (id is accepted only as a legacy alias)",
1124
+ });
1125
+ }
1126
+ interactions.push({ ...interaction, name: resolvedName });
1127
+ }
909
1128
  const states = uiStates
910
1129
  .map((state) => (typeof state?.name === "string" ? state.name : ""))
911
1130
  .filter(Boolean);
@@ -993,7 +1212,7 @@ export async function createFrontendPlanLedgerTools(input) {
993
1212
  const recordPlanRequirementTool = defineTool({
994
1213
  name: "record_plan_requirement",
995
1214
  label: "record_plan_requirement",
996
- description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement; entry carries id, implementationTargets, verificationTargetIds, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
1215
+ description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement; entry carries id, implementationTargets, verificationTargetIds, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). Each requirement id must be recorded EXACTLY once — re-recording the same id is rejected as a duplicate and would compile a duplicated requirements[] entry. IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
997
1216
  promptSnippet: "Commit one plan requirement entry (one tool call per message).",
998
1217
  parameters: Type.Object({
999
1218
  entry: requirementSchema,
@@ -1008,6 +1227,28 @@ export async function createFrontendPlanLedgerTools(input) {
1008
1227
  });
1009
1228
  }
1010
1229
  const entry = rawEntry;
1230
+ // A requirement id is a canonical identity: recording it twice would
1231
+ // compile a duplicate requirements[] entry and fail design review.
1232
+ // Reject duplicates at the tool boundary so the model can fix them
1233
+ // in-node instead of burning the attempt on a later validation error.
1234
+ const id = typeof entry.id === "string" ? entry.id : "";
1235
+ if (id) {
1236
+ const existing = readCommittedEvents(store, attemptId).find((event) => {
1237
+ const fact = event.fact;
1238
+ if (!fact || fact.kind !== "plan-requirement")
1239
+ return false;
1240
+ const entryFact = fact.entry;
1241
+ return (typeof entryFact?.id === "string" &&
1242
+ entryFact.id === id);
1243
+ });
1244
+ if (existing) {
1245
+ return planToolReceipt({
1246
+ ok: false,
1247
+ kind: "plan-requirement",
1248
+ error: `record_plan_requirement duplicate: requirement ${id} is already recorded; do not record the same requirement id twice`,
1249
+ });
1250
+ }
1251
+ }
1011
1252
  const result = await adoptPlanFact("plan-requirement", `${attemptId}:record_plan_requirement:${randomUUID()}`, { kind: "plan-requirement", origin: "plan", entry });
1012
1253
  return planToolReceipt(result);
1013
1254
  },
@@ -1029,7 +1270,13 @@ export async function createFrontendPlanLedgerTools(input) {
1029
1270
  error: "record_plan_verification_target requires a non-empty entry object",
1030
1271
  });
1031
1272
  }
1032
- const entry = rawEntry;
1273
+ // uiStates: [] means this verification target is intentionally not
1274
+ // bound to a named UI state. Keep that canonical representation even
1275
+ // when a model omits the optional tool-boundary field.
1276
+ const entry = {
1277
+ ...rawEntry,
1278
+ uiStates: stringList(rawEntry.uiStates),
1279
+ };
1033
1280
  const result = await adoptPlanFact("plan-verification-target", `${attemptId}:record_plan_verification_target:${randomUUID()}`, { kind: "plan-verification-target", origin: "plan", entry });
1034
1281
  return planToolReceipt(result);
1035
1282
  },
@@ -1068,7 +1315,13 @@ export async function createFrontendPlanLedgerTools(input) {
1068
1315
  async execute(_toolCallId, params) {
1069
1316
  try {
1070
1317
  const committed = readCommittedEvents(store, attemptId);
1071
- const fragment = assemblePlanPatchFromCommittedFacts(committed) ?? {};
1318
+ // Source fidelity ledger (AC-005/AC-006): inherit the contract
1319
+ // node's declared requirement→fragment provenance so the compiled
1320
+ // canonical contract carries sourceFragmentIds/sourceRefs even
1321
+ // when the plan did not re-declare them. The contract node is the
1322
+ // sole synthesis point; the plan inherits by requirement id.
1323
+ const contractInheritance = await loadContractRequirementInheritance(input.runDir);
1324
+ const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
1072
1325
  const patch = {
1073
1326
  ...fragment,
1074
1327
  ...(params?.residualRisks
@@ -1783,6 +2036,11 @@ const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
1783
2036
  * blocks the node.
1784
2037
  */
1785
2038
  async function runFrontendReviewTerminalShadow(input) {
2039
+ // A provider/executor failure (in particular context-overflow) is already
2040
+ // authoritative. Do not rewrite it to review-terminal-missing merely
2041
+ // because no terminal tool could be submitted after the failed call.
2042
+ if (!input.mapped.ok)
2043
+ return input.mapped;
1786
2044
  const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
1787
2045
  const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
1788
2046
  const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
@@ -1863,6 +2121,10 @@ async function runFrontendReviewTerminalShadow(input) {
1863
2121
  * `design-typed-facts.jsonl` as the only authoritative verdict.
1864
2122
  */
1865
2123
  async function runFrontendDesignTerminalShadow(input) {
2124
+ // See the review counterpart above: a failed provider call cannot be
2125
+ // diagnosed as an omitted terminal tool call.
2126
+ if (!input.mapped.ok)
2127
+ return input.mapped;
1866
2128
  const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
1867
2129
  let typedKinds = [];
1868
2130
  try {
@@ -1980,6 +2242,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
1980
2242
  let planLedgerTools;
1981
2243
  let contractTools;
1982
2244
  let scoutEvidenceTools;
2245
+ let readBudgetTools;
1983
2246
  const commandPolicy = resolveDagCommandPolicy(input.task.commandPolicy);
1984
2247
  const allowsPlaywrightCli = dagCommandPolicyAllows(input.task.commandPolicy, "playwright-cli");
1985
2248
  let playwrightToolContext;
@@ -2177,6 +2440,30 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
2177
2440
  };
2178
2441
  }
2179
2442
  }
2443
+ if (input.task.readBudget) {
2444
+ try {
2445
+ readBudgetTools = await createPiReadBudgetCustomTools({
2446
+ repoRoot: input.cwd,
2447
+ budget: input.task.readBudget,
2448
+ });
2449
+ writerToolPolicy = {
2450
+ requireSdk: true,
2451
+ customTools: [
2452
+ ...(writerToolPolicy?.customTools ?? []),
2453
+ ...readBudgetTools.customTools,
2454
+ ],
2455
+ };
2456
+ }
2457
+ catch (error) {
2458
+ return {
2459
+ ok: false,
2460
+ stdout: "",
2461
+ stderr: `pi read budget policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
2462
+ failureCategory: "tool-policy",
2463
+ durationMs: Date.now() - started,
2464
+ };
2465
+ }
2466
+ }
2180
2467
  let result;
2181
2468
  try {
2182
2469
  // Phase 5 (P1-9): capture the writeSet baseline BEFORE the writer provider
@@ -2294,6 +2581,37 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
2294
2581
  : input.task.writerOutcomePolicy
2295
2582
  ? WRITER_OUTCOME_PROTOCOL_LINE
2296
2583
  : input.task.firstProtocolLine);
2584
+ // A node-specific budget is enforced for every frontend reader that opts in,
2585
+ // including design/final review. Earlier code only checked contract, scout,
2586
+ // and plan nodes, leaving the two largest review sessions unbounded.
2587
+ const readBudgetIssues = [
2588
+ ...(readBudgetTools?.issues() ?? []),
2589
+ ...(await detectNodeReadBudget({
2590
+ runDir: meta.runDir,
2591
+ nodeId: input.task.id,
2592
+ budget: input.task.readBudget,
2593
+ })),
2594
+ ];
2595
+ if (readBudgetIssues.length > 0) {
2596
+ // Read-budget telemetry is diagnostic only when the provider/executor has
2597
+ // already failed. In particular, keep context-overflow so node-execution
2598
+ // selects its compact retry envelope instead of treating the attempt as a
2599
+ // generic read-burst retry.
2600
+ if (!mapped.ok) {
2601
+ return {
2602
+ ...mapped,
2603
+ stderr: [mapped.stderr, ...readBudgetIssues]
2604
+ .filter(Boolean)
2605
+ .join("\n\n"),
2606
+ };
2607
+ }
2608
+ return {
2609
+ ...mapped,
2610
+ ok: false,
2611
+ failureCategory: "read-burst",
2612
+ stderr: [mapped.stderr, ...readBudgetIssues].filter(Boolean).join("\n\n"),
2613
+ };
2614
+ }
2297
2615
  if (isFrontendReviewTypedTerminalNode(input.task)) {
2298
2616
  return await runFrontendReviewTerminalShadow({
2299
2617
  task: input.task,
@@ -0,0 +1,166 @@
1
+ import { access, open, readFile, stat, } from "node:fs/promises";
2
+ class PiReadBudgetGuard {
3
+ budget;
4
+ files = new Set();
5
+ violations = [];
6
+ bytes = 0;
7
+ startedAt = Date.now();
8
+ constructor(budget) {
9
+ this.budget = budget;
10
+ }
11
+ issues() {
12
+ return [...this.violations];
13
+ }
14
+ report(message) {
15
+ if (!this.violations.includes(message))
16
+ this.violations.push(message);
17
+ }
18
+ assertElapsed() {
19
+ if (this.budget.maxMs === undefined)
20
+ return;
21
+ const elapsedMs = Date.now() - this.startedAt;
22
+ if (elapsedMs > this.budget.maxMs) {
23
+ const message = `read budget exhausted: ${elapsedMs}ms (budget ${this.budget.maxMs}ms)`;
24
+ this.report(message);
25
+ throw new Error(message);
26
+ }
27
+ }
28
+ reserveFile(absolutePath) {
29
+ this.assertElapsed();
30
+ if (this.files.has(absolutePath))
31
+ return;
32
+ if (this.files.size >= this.budget.maxFiles) {
33
+ const message = `read budget exhausted: file ${absolutePath} would exceed ${this.budget.maxFiles} files`;
34
+ this.report(message);
35
+ throw new Error(message);
36
+ }
37
+ this.files.add(absolutePath);
38
+ }
39
+ reserveBytes(bytes) {
40
+ this.assertElapsed();
41
+ const remaining = Math.max(0, this.budget.maxBytes - this.bytes);
42
+ if (remaining === 0) {
43
+ const message = `read budget exhausted: ${this.bytes} bytes (budget ${this.budget.maxBytes})`;
44
+ this.report(message);
45
+ throw new Error(message);
46
+ }
47
+ const granted = Math.min(bytes, remaining);
48
+ this.bytes += granted;
49
+ if (granted < bytes) {
50
+ this.report(`read budget truncated: ${bytes} requested bytes at ${this.bytes}/${this.budget.maxBytes} bytes`);
51
+ }
52
+ return granted;
53
+ }
54
+ async readFile(absolutePath) {
55
+ this.reserveFile(absolutePath);
56
+ const metadata = await stat(absolutePath);
57
+ const granted = this.reserveBytes(metadata.size);
58
+ if (granted === metadata.size)
59
+ return await readFile(absolutePath);
60
+ const handle = await open(absolutePath, "r");
61
+ try {
62
+ const buffer = Buffer.allocUnsafe(granted);
63
+ const { bytesRead } = await handle.read(buffer, 0, granted, 0);
64
+ return buffer.subarray(0, bytesRead);
65
+ }
66
+ finally {
67
+ await handle.close();
68
+ }
69
+ }
70
+ async access(absolutePath) {
71
+ this.reserveFile(absolutePath);
72
+ await access(absolutePath);
73
+ }
74
+ beforeNonFileRead() {
75
+ this.assertElapsed();
76
+ }
77
+ limitToolResult(result) {
78
+ if (!result || typeof result !== "object" || !Array.isArray(result.content)) {
79
+ return result;
80
+ }
81
+ const content = result.content.map((item) => {
82
+ if (!item || item.type !== "text" || typeof item.text !== "string") {
83
+ this.report("read budget truncated: non-text tool result omitted");
84
+ return {
85
+ type: "text",
86
+ text: "[read budget reached: non-text result omitted]",
87
+ };
88
+ }
89
+ const granted = this.reserveBytes(Buffer.byteLength(item.text));
90
+ return {
91
+ ...item,
92
+ text: truncateUtf8(item.text, granted),
93
+ };
94
+ });
95
+ return { ...result, content };
96
+ }
97
+ }
98
+ function truncateUtf8(text, maxBytes) {
99
+ if (Buffer.byteLength(text) <= maxBytes)
100
+ return text;
101
+ const marker = "\n[read result truncated by node budget]";
102
+ const markerBytes = Buffer.byteLength(marker);
103
+ if (maxBytes <= markerBytes)
104
+ return truncateUtf8Prefix(marker, maxBytes);
105
+ const contentBudget = maxBytes - markerBytes;
106
+ return `${truncateUtf8Prefix(text, contentBudget)}${marker}`;
107
+ }
108
+ function truncateUtf8Prefix(text, maxBytes) {
109
+ let result = "";
110
+ let bytes = 0;
111
+ for (const codePoint of text) {
112
+ const size = Buffer.byteLength(codePoint);
113
+ if (bytes + size > maxBytes)
114
+ break;
115
+ result += codePoint;
116
+ bytes += size;
117
+ }
118
+ return result;
119
+ }
120
+ function wrapTool(definition, guard, consumeResult) {
121
+ return {
122
+ ...definition,
123
+ async execute(...args) {
124
+ guard.beforeNonFileRead();
125
+ const result = await definition.execute(...args);
126
+ return consumeResult ? guard.limitToolResult(result) : result;
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * Override Pi's built-in read-only tools with SDK definitions that meter the
132
+ * content before it reaches the model. The read tool caps filesystem bytes at
133
+ * the operation boundary; grep/find/ls have their returned payload clipped
134
+ * before the agent receives it. This is SDK-only by design: the CLI backend
135
+ * cannot inject same-name built-in overrides safely.
136
+ */
137
+ export async function createPiReadBudgetCustomTools(input) {
138
+ const sdk = (await import("@earendil-works/pi-coding-agent"));
139
+ const createReadToolDefinition = sdk.createReadToolDefinition;
140
+ const createGrepToolDefinition = sdk.createGrepToolDefinition;
141
+ const createFindToolDefinition = sdk.createFindToolDefinition;
142
+ const createLsToolDefinition = sdk.createLsToolDefinition;
143
+ if (typeof createReadToolDefinition !== "function" ||
144
+ typeof createGrepToolDefinition !== "function" ||
145
+ typeof createFindToolDefinition !== "function" ||
146
+ typeof createLsToolDefinition !== "function") {
147
+ throw new Error("pi read budget policy unavailable: SDK read/grep/find/ls tool factories missing");
148
+ }
149
+ const guard = new PiReadBudgetGuard(input.budget);
150
+ const read = createReadToolDefinition(input.repoRoot, {
151
+ operations: {
152
+ readFile: (absolutePath) => guard.readFile(absolutePath),
153
+ access: (absolutePath) => guard.access(absolutePath),
154
+ },
155
+ });
156
+ const grep = wrapTool(createGrepToolDefinition(input.repoRoot), guard, true);
157
+ const find = wrapTool(createFindToolDefinition(input.repoRoot), guard, true);
158
+ const ls = wrapTool(createLsToolDefinition(input.repoRoot), guard, true);
159
+ if (read.name !== "read" ||
160
+ grep.name !== "grep" ||
161
+ find.name !== "find" ||
162
+ ls.name !== "ls") {
163
+ throw new Error("pi read budget policy unavailable: SDK tool name mismatch");
164
+ }
165
+ return { customTools: [read, grep, find, ls], issues: () => guard.issues() };
166
+ }