@tea-agent/loop-agent 0.39.0-beta.11 → 0.39.0-beta.13

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 (112) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/application/dag/generate-task-dag.js +6 -2
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/client-recovery.js +8 -36
  5. package/dist/executors/dag-pi-executor.js +343 -40
  6. package/dist/executors/pi-executor.js +8 -4
  7. package/dist/executors/pi-sdk-executor.js +33 -5
  8. package/dist/executors/shell-executor.js +102 -32
  9. package/dist/governance/checks.js +1 -0
  10. package/dist/shared/pi-context-pressure/checkpoint.js +116 -0
  11. package/dist/shared/pi-context-pressure/compaction-policy.js +151 -0
  12. package/dist/shared/pi-context-pressure/env.js +58 -0
  13. package/dist/shared/pi-context-pressure/extension.js +100 -0
  14. package/dist/shared/pi-context-pressure/index.js +7 -0
  15. package/dist/shared/pi-context-pressure/overflow.js +252 -0
  16. package/dist/shared/pi-context-pressure/sift-bridge.js +386 -0
  17. package/dist/shared/pi-context-pressure/telemetry.js +51 -0
  18. package/dist/task/frontend-project-capability.js +3 -1
  19. package/dist/task/source-prepare/fragment-inventory.js +4 -1
  20. package/dist/worker/console/chat/pi-runtime.js +146 -5
  21. package/dist/worker/console/chat/provider-error.js +2 -1
  22. package/dist/worker/console/chat/routes.js +3 -0
  23. package/dist/worker/console/chat/sift-bridge.js +1 -0
  24. package/dist/worker/console/dag-execution-receipt.js +20 -2
  25. package/dist/worker/console/operator-actions.js +4 -3
  26. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CXj_GnSb.js → abnfDiagram-N423BO3Z-DC863mud.js} +1 -1
  27. package/dist/worker/console/static/assets/{arc-BZp6JAp7.js → arc-CftC38G9.js} +1 -1
  28. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DfpcEuYU.js → architectureDiagram-T3A2C74G-B3PAPQCw.js} +1 -1
  29. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-_Gf0xadb.js → blockDiagram-VBNYF7ZC-C9UDJNRv.js} +1 -1
  30. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Ct2QPCmv.js → c4Diagram-5PPSVZJV--BJ76fv_.js} +1 -1
  31. package/dist/worker/console/static/assets/channel-CUz-Bg86.js +1 -0
  32. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BfklkzKl.js → chunk-2GRJ4B5K--hiIqoGp.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-Dy25vZJV.js → chunk-2Q5K7J3B-DgTzpIa3.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BFlCRZep.js → chunk-5RXB4S5H-DIwpJziP.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-op3oVxIE.js → chunk-5VM5RSS4-BJzbdUi0.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-C0R7rzF2.js → chunk-6Q2QTUOP-BbGouI1Z.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bt44TCGy.js → chunk-GF5L2VYU-B9247w69.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-_uEE_XFx.js → chunk-JWPE2WC7-CXob_wYy.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-C3TOYGZ9.js → chunk-KBJHAD2P-C6FUel1g.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Cz60oBPV.js → chunk-RYQCIY6F-DGKRYKHu.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-8Bik0qis.js → chunk-XXDRQBXY-kNBqNPfx.js} +1 -1
  42. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BhlUacmD.js +1 -0
  43. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BhlUacmD.js +1 -0
  44. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C2QIOA4a.js → cose-bilkent-JH36ORCC--xmkDwfD.js} +1 -1
  45. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CSH_yUUd.js → cynefin-VYW2F7L2-Cw5FMMuG.js} +1 -1
  46. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BDLw2XFM.js → cynefinDiagram-MW4NZA55-CuLslt2t.js} +1 -1
  47. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CcD9ZtF4.js → dagre-VZM6K2ZE-D9ngqrs2.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DqwtkBsI.js → diagram-7IWD3JNH-RDRSbHQp.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BWVEcqsC.js → diagram-B4RE2ZJO-BgQ1P9aV.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-M-WAbtQi.js → diagram-LBJQPF4R-D3WWyPtn.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DKW7SixP.js → diagram-Q27KOJAE-L2k28wTR.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-UB23O5K3-PHHPPtrj.js → diagram-UB23O5K3-DDNrA5Qw.js} +1 -1
  53. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BQD-B4RX.js → ebnfDiagram-BXEA7PRR-BaRFCOdM.js} +1 -1
  54. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BFvULb51.js → erDiagram-JOGREHBK-CM33DVVM.js} +1 -1
  55. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-Bw-aXTCb.js → flowDiagram-UKHOOZJN-CVBSjOxe.js} +1 -1
  56. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BGKw04Qx.js → ganttDiagram-PKOTCBZU-BdscewE_.js} +1 -1
  57. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-RqKaPR-I.js → gitGraphDiagram-DS77QQ5N-HRuSZb7g.js} +1 -1
  58. package/dist/worker/console/static/assets/{index-B_D8rbWc.js → index-B9JJQsVK.js} +102 -72
  59. package/dist/worker/console/static/assets/index-rWaGv4jz.css +1 -0
  60. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-YO5dnzrJ.js → infoDiagram-6WML65LV-cYfHvfAR.js} +1 -1
  61. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bi1VZHMq.js → ishikawaDiagram-WSZJBQD7-CZmaoOuy.js} +1 -1
  62. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Bszls1DD.js → journeyDiagram-NVQOT4AX-ntYijh7g.js} +1 -1
  63. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PhzeaZ09.js → kanban-definition-27J2QSJJ-Cz3b0DeD.js} +1 -1
  64. package/dist/worker/console/static/assets/{linear-BsjbDoXi.js → linear-DvGonpsP.js} +1 -1
  65. package/dist/worker/console/static/assets/{mermaid.core-0B7NnWKk.js → mermaid.core-B-3vjfyW.js} +5 -5
  66. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BJr4Fj-q.js → mindmap-definition-FAOFIHXS-5iKxlsX3.js} +1 -1
  67. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-moC4fpGB.js → pegDiagram-VL7TDLO6-C8BUwapW.js} +1 -1
  68. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BEw37-2c.js → pieDiagram-7S7Q4E2Y-B2rPoQQG.js} +1 -1
  69. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Cq6LyasU.js → quadrantDiagram-CIZ2JOQS-jDeVUAy4.js} +1 -1
  70. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DUCMcK0D.js → railroadDiagram-AXF67PYL-DV140Dwm.js} +1 -1
  71. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-C3upTZm7.js → requirementDiagram-LRYGKXZP-CnYgHtYC.js} +1 -1
  72. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BI_gMsCW.js → sankeyDiagram-W5VNT64P-SIK3CdWw.js} +1 -1
  73. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-YFOIRzfN.js → sequenceDiagram-SI44F4Z6-DW_vZix7.js} +1 -1
  74. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-dOnB7UDD.js → sizeCapture-X5ZJPWSS-NBAtkg0C.js} +1 -1
  75. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BXUniaIh.js → stateDiagram-OKZ733FA-Bi1bQxpi.js} +1 -1
  76. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DjWKYjQ1.js +1 -0
  77. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-2tA4wTNu.js → swimlanes-SLNWSIFB-8PT_uP_i.js} +2 -2
  78. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B4cdm7bH.js +8 -0
  79. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DO0HkJXC.js → timeline-definition-Z64GVDOM-CD0ZZPk0.js} +1 -1
  80. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DvIOzixv.js → vennDiagram-T6HMQDX7-BbEgWdhK.js} +1 -1
  81. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DXDZ0cTj.js → wardleyDiagram-T6FBY63Y-DCwPKdLl.js} +1 -1
  82. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-B32Ark0D.js → xychartDiagram-ELKLHX3M-sfC5PcNg.js} +1 -1
  83. package/dist/worker/console/static/index.html +2 -2
  84. package/dist/worker/observe/static/styles.css +9 -0
  85. package/dist/worker/observe/static/views/dag-inspector.js +40 -0
  86. package/dist/worker/observe/static/views/session-timeline.js +135 -0
  87. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +157 -7
  88. package/dist/workflows/dag/backend-test-pytest-collection.js +70 -2
  89. package/dist/workflows/dag/backend-test-result-contract.js +4 -0
  90. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  91. package/dist/workflows/dag/backend-test-writer-completeness.js +11 -0
  92. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  93. package/dist/workflows/dag/frontend-implementation-contract.js +118 -22
  94. package/dist/workflows/dag/frontend-shadow-dual-write.js +1 -1
  95. package/dist/workflows/dag/frontend-writer-admission.js +13 -0
  96. package/dist/workflows/dag/init-hybrid.js +67 -3
  97. package/dist/workflows/dag/node-execution.js +384 -26
  98. package/dist/workflows/dag/rerun-feedback.js +135 -3
  99. package/dist/workflows/dag/retry-policy.js +13 -122
  100. package/dist/workflows/dag/types.js +6 -1
  101. package/docs/architecture/runtime-boundaries.md +2 -1
  102. package/docs/templates/README.md +1 -0
  103. package/docs/templates/backend-test-dag.json +4 -3
  104. package/docs/templates/frontend-implementation-dag.json +89 -0
  105. package/package.json +4 -3
  106. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  107. package/dist/worker/console/static/assets/channel-3TxJgYaH.js +0 -1
  108. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BHIkXpp3.js +0 -1
  109. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BHIkXpp3.js +0 -1
  110. package/dist/worker/console/static/assets/index-BdNx6fj0.css +0 -1
  111. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cdi6UhLa.js +0 -1
  112. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D-RJBbb0.js +0 -8
@@ -17,6 +17,7 @@ import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, pars
17
17
  import { getStructuredContractValidator } from "./contract-output-registry.js";
18
18
  import "./contract-validator-registrations.js";
19
19
  import { computeNormalizedFailureFingerprint } from "./frontend-recovery-lineage.js";
20
+ import { parseLedgerJson } from "../../task/source-prepare/ledger.js";
20
21
  import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
21
22
  import { allowedRepairReadPaths, auditRepairAttemptToolUse, buildStructuredOutputRepairPrompt, freezeStructuredOutputRepairContext, hasNonEmptyStructuredCandidate, isFrontendStructuredRepairSchemaId, isStructuredRepairableFailureCategory, persistStructuredAttemptRaw, sessionEventsByteLength, GOVERNANCE_BLOCKED_CATEGORY, STRUCTURED_REPAIR_EXHAUSTED_CATEGORY, } from "./structured-output-repair.js";
22
23
  import { readFrontendCanonicalCandidate } from "./frontend-implementation-contract.js";
@@ -221,6 +222,62 @@ export function buildContextOverflowRetryPrompt(task, basePrompt) {
221
222
  function isFrontendPlanLadderTask(task) {
222
223
  return task.id === FRONTEND_PLAN_NODE_ID;
223
224
  }
225
+ /** Contract node consumes the compiled ledger input; local predicate avoids a
226
+ * dag-pi-executor import cycle. */
227
+ function isFrontendContractTypedNode(task) {
228
+ return task.id === "frontend-contract-pi";
229
+ }
230
+ /** Resolve the source-fidelity ledger path from a v2 source binding, refusing
231
+ * any path that escapes the workspace root (mirrors dag-pi-executor). */
232
+ function resolveFrontendLedgerPath(sourceBinding, cwd) {
233
+ if (!sourceBinding || sourceBinding.schemaVersion !== 2) {
234
+ throw new Error(`frontend-contract-input-unavailable: sourceBinding v2 ledger required (got ${sourceBinding?.schemaVersion ?? "none"})`);
235
+ }
236
+ const absolutePath = path.resolve(cwd, sourceBinding.ledgerPath);
237
+ const workspaceRoot = path.resolve(cwd);
238
+ if (absolutePath !== workspaceRoot &&
239
+ !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) {
240
+ throw new Error(`frontend-contract-input-unavailable: ledger path escapes workspace root: ${sourceBinding.ledgerPath}`);
241
+ }
242
+ return absolutePath;
243
+ }
244
+ /** record_* submissions observed for the contract node in its session events. */
245
+ const FRONTEND_CONTRACT_RECORD_TOOL_NAMES_LOCAL = new Set([
246
+ "record_requirement",
247
+ "record_constraint",
248
+ "record_evidence_expectation",
249
+ "record_handoff_intent",
250
+ "record_open_question",
251
+ "record_split_proposal",
252
+ ]);
253
+ async function countContractRecordSubmissions(runDir, nodeId) {
254
+ const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
255
+ let count = 0;
256
+ try {
257
+ for (const line of (await readFile(eventsPath, "utf8")).split("\n")) {
258
+ if (!line.trim())
259
+ continue;
260
+ let event;
261
+ try {
262
+ event = JSON.parse(line);
263
+ }
264
+ catch {
265
+ continue;
266
+ }
267
+ if (event !== null &&
268
+ typeof event === "object" &&
269
+ event.type === "tool_execution_start" &&
270
+ typeof event.toolName === "string" &&
271
+ FRONTEND_CONTRACT_RECORD_TOOL_NAMES_LOCAL.has(event.toolName)) {
272
+ count += 1;
273
+ }
274
+ }
275
+ }
276
+ catch {
277
+ // Missing events file = zero submissions.
278
+ }
279
+ return count;
280
+ }
224
281
  /**
225
282
  * Read the committed plan ledger snapshot for the ladder's "no new committed
226
283
  * fact" check. The digest is over the sorted committed payload hashes, and
@@ -254,11 +311,165 @@ function planInputStrings(value) {
254
311
  ? value.filter((item) => typeof item === "string")
255
312
  : [];
256
313
  }
314
+ /** Input bound for the planner evidence block; protects the model input budget. */
315
+ const FRONTEND_PLAN_INPUT_MAX_CHARS = 12_000;
316
+ const FRONTEND_PLAN_INPUT_CAP_LADDER = [
317
+ { text: 240, array: 40 },
318
+ { text: 120, array: 20 },
319
+ { text: 60, array: 10 },
320
+ { text: 24, array: 4 },
321
+ ];
322
+ /**
323
+ * Frozen requirement→PRD citation map for the plan review checklist: lets the
324
+ * model declare sourceRequirementIds whose section/line match the component
325
+ * purpose, so the runtime-derived specReference survives reviewer scrutiny
326
+ * (r12: 4 of 9 decision=new choices cited a mismatched PRD section).
327
+ */
328
+ async function resolveComponentSourceCitations(spec, cwd) {
329
+ const binding = spec.sourceBinding;
330
+ if (!binding || binding.schemaVersion !== 2 || !binding.ledgerPath)
331
+ return new Map();
332
+ const absolutePath = path.resolve(cwd, binding.ledgerPath);
333
+ const workspaceRoot = path.resolve(cwd);
334
+ if (absolutePath !== workspaceRoot &&
335
+ !absolutePath.startsWith(`${workspaceRoot}${path.sep}`))
336
+ return new Map();
337
+ try {
338
+ const ledger = JSON.parse(await readFile(absolutePath, "utf8"));
339
+ const fragmentsById = new Map((ledger.fragments ?? []).map((fragment) => [fragment.id, fragment]));
340
+ const references = new Map();
341
+ for (const requirement of ledger.canonicalRequirements ?? []) {
342
+ const id = typeof requirement.id === "string" ? requirement.id : "";
343
+ if (!id)
344
+ continue;
345
+ const citations = (requirement.sourceFragmentIds ?? [])
346
+ .map((fragmentId) => fragmentsById.get(fragmentId))
347
+ .filter((fragment) => fragment !== undefined)
348
+ .map((fragment) => ({
349
+ fragmentId: fragment.id,
350
+ path: typeof fragment.path === "string" ? fragment.path : "",
351
+ section: typeof fragment.headingPath === "string"
352
+ ? fragment.headingPath
353
+ : "",
354
+ line: typeof fragment.lineRange?.start === "number"
355
+ ? fragment.lineRange.start
356
+ : undefined,
357
+ }));
358
+ if (citations.length > 0)
359
+ references.set(id, citations);
360
+ }
361
+ return references;
362
+ }
363
+ catch {
364
+ return new Map();
365
+ }
366
+ }
367
+ /** Input bound for the contract node's compiled ledger block. */
368
+ const FRONTEND_CONTRACT_INPUT_MAX_CHARS = 12_000;
369
+ /**
370
+ * Render the contract node's complete-but-bounded ledger handoff. The
371
+ * source-fidelity ledger already extracted canonical requirements with source
372
+ * spans; the contract node confirms and commits them incrementally through
373
+ * record_requirement instead of re-reading the raw source (extreme-environment:
374
+ * a small output window cannot absorb a full source re-read).
375
+ *
376
+ * Same shape guarantees as the plan input block: always valid JSON under the
377
+ * char bound, ids never drop, texts degrade through the cap ladder.
378
+ */
379
+ export function renderFrontendContractInputContext(input) {
380
+ // Fragment bindings are ids, not prose: they are the one thing the contract
381
+ // must never lose. r6 regression — the last-resort degradation dropped
382
+ // sourceFragmentIds entirely, the model (correctly refusing to invent ids)
383
+ // committed empty bindings, and the plan compile failed the ledger-binding
384
+ // gate for every requirement. Bindings therefore bypass the cap ladder and
385
+ // every degradation level; only requirement TEXTS and fragment CONTEXT
386
+ // (path/headingPath) may degrade. Fragment context is rendered only for
387
+ // fragments actually referenced by a requirement and shrinks first.
388
+ const referencedFragmentIds = new Set(input.canonicalRequirements.flatMap((requirement) => planInputStrings(requirement.sourceFragmentIds)));
389
+ const referencedFragments = input.fragments.filter((fragment) => referencedFragmentIds.has(fragment.id));
390
+ const serializeAtCap = (cap) => JSON.stringify({
391
+ requirements: input.canonicalRequirements.map((requirement) => ({
392
+ id: requirement.id,
393
+ text: planInputText(requirement.text, cap.text),
394
+ sourceFragmentIds: planInputStrings(requirement.sourceFragmentIds),
395
+ })),
396
+ fragments: referencedFragments.map((fragment) => ({
397
+ id: fragment.id,
398
+ path: planInputText(fragment.path, 200),
399
+ headingPath: planInputText(fragment.headingPath, 120),
400
+ lineRange: fragment.lineRange,
401
+ })),
402
+ });
403
+ let serialized = serializeAtCap(FRONTEND_PLAN_INPUT_CAP_LADDER[0]);
404
+ for (const cap of FRONTEND_PLAN_INPUT_CAP_LADDER.slice(1)) {
405
+ if (serialized.length <= FRONTEND_CONTRACT_INPUT_MAX_CHARS)
406
+ break;
407
+ serialized = serializeAtCap(cap);
408
+ }
409
+ if (serialized.length > FRONTEND_CONTRACT_INPUT_MAX_CHARS) {
410
+ // Last resort: keep every requirement id AND its fragment bindings,
411
+ // degrade texts, and shrink referenced fragment context first (halve,
412
+ // then drop context fields, then drop the fragment list entirely).
413
+ // Requirement ids and sourceFragmentIds are never dropped.
414
+ let fragments = referencedFragments.map((fragment) => ({
415
+ id: fragment.id,
416
+ path: planInputText(fragment.path, 120),
417
+ }));
418
+ let requirements = input.canonicalRequirements.map((requirement) => {
419
+ const sourceFragmentIds = planInputStrings(requirement.sourceFragmentIds);
420
+ return {
421
+ id: requirement.id,
422
+ text: "(truncated)",
423
+ // Empty bindings carry no information; omit them so the payload
424
+ // stays inside the char bound when no requirement is bound.
425
+ ...(sourceFragmentIds.length > 0 ? { sourceFragmentIds } : {}),
426
+ };
427
+ });
428
+ let bounded = JSON.stringify({ degraded: "requirement-texts-truncated", requirements, fragments });
429
+ while (bounded.length > FRONTEND_CONTRACT_INPUT_MAX_CHARS && fragments.length > 0) {
430
+ fragments = fragments.slice(0, Math.floor(fragments.length / 2));
431
+ bounded = JSON.stringify({
432
+ degraded: "requirement-texts-truncated",
433
+ requirements,
434
+ fragments,
435
+ });
436
+ }
437
+ serialized = bounded;
438
+ }
439
+ return [
440
+ "<frontend_contract_input>",
441
+ "Canonical requirements extracted by the source-fidelity ledger, compiled by the runner. Treat them as the authoritative requirement inventory: confirm and commit each requirement through record_requirement (one per tool call); the ledger already binds source fragments, so do NOT re-read the raw source files.",
442
+ serialized,
443
+ "</frontend_contract_input>",
444
+ ].join("\n");
445
+ }
446
+ export async function buildFrontendContractInputContext(input) {
447
+ const ledger = parseLedgerJson(await readFile(input.ledgerPath, "utf8"));
448
+ return renderFrontendContractInputContext({
449
+ canonicalRequirements: ledger.canonicalRequirements.map((requirement) => ({
450
+ id: requirement.id,
451
+ text: requirement.text,
452
+ sourceFragmentIds: requirement.sourceFragmentIds ?? [],
453
+ })),
454
+ fragments: ledger.fragments.map((fragment) => ({
455
+ id: fragment.id,
456
+ path: fragment.path,
457
+ headingPath: fragment.headingPath,
458
+ lineRange: fragment.lineRange,
459
+ })),
460
+ });
461
+ }
257
462
  /**
258
463
  * Render the planner's complete-but-bounded evidence handoff from committed
259
464
  * typed facts. It deliberately excludes upstream response prose and artifact
260
465
  * paths: Contract and Scout have already established these facts, so Plan
261
466
  * should decide and commit rather than spend another model turn reading them.
467
+ *
468
+ * The block is always valid JSON under the char bound: field texts shrink
469
+ * through a cap ladder before any fact is dropped, and the last-resort
470
+ * fallback keeps every requirement id (with `text: "(truncated)"`) while
471
+ * declaring the degradation, so the planner records targeted evidence gaps
472
+ * instead of receiving a silently corrupted tail.
262
473
  */
263
474
  export function renderFrontendPlanInputContext(input) {
264
475
  const committedFacts = (records) => records.flatMap((record) => record.phase === "committed" && planInputRecord(record.fact)
@@ -277,45 +488,143 @@ export function renderFrontendPlanInputContext(input) {
277
488
  const targetSurface = scoutFacts
278
489
  .filter((fact) => fact.kind === "target-surface" && fact.origin === "scout")
279
490
  .map((fact) => ({
280
- completeness: planInputText(fact.completeness, 32),
281
- entrypoint: planInputText(fact.entrypoint, 240),
282
- routeOrMount: planInputText(fact.routeOrMount, 240),
283
- implementationPaths: planInputStrings(fact.implementationPaths),
284
- testPaths: planInputStrings(fact.testPaths),
285
- dataSource: planInputText(fact.dataSource),
286
- allowedPathConflicts: planInputStrings(fact.allowedPathConflicts),
287
- unresolvedPaths: planInputStrings(fact.unresolvedPaths),
491
+ completeness: fact.completeness,
492
+ entrypoint: fact.entrypoint,
493
+ routeOrMount: fact.routeOrMount,
494
+ implementationPaths: fact.implementationPaths,
495
+ testPaths: fact.testPaths,
496
+ dataSource: fact.dataSource,
497
+ allowedPathConflicts: fact.allowedPathConflicts,
498
+ unresolvedPaths: fact.unresolvedPaths,
288
499
  }));
289
500
  const designEvidence = scoutFacts
290
501
  .filter((fact) => fact.kind === "design-evidence" && fact.origin === "scout")
291
502
  .map((fact) => ({
292
- source: planInputText(fact.source),
293
- paths: planInputStrings(fact.paths),
294
- conflicts: planInputStrings(fact.conflicts),
503
+ source: fact.source,
504
+ paths: fact.paths,
505
+ conflicts: fact.conflicts,
295
506
  }));
296
- const serialized = JSON.stringify({ requirements, targetSurface, designEvidence });
297
- const bounded = serialized.length <= 12_000
298
- ? serialized
299
- : `${serialized.slice(0, 11_999)}…`;
507
+ // Reviewer-rubric scaffold: the design reviewer re-runs the design-policy
508
+ // checks on the committed facts, so publish the checklist to the producer.
509
+ // Requirements whose contract evidence expects behavioural verification are
510
+ // enumerated explicitly — those are the slots the reviewer finds missing
511
+ // when the plan models interactions ad hoc (r8/r9 findings).
512
+ const behaviorRequiredIds = requirements
513
+ .filter((requirement) => {
514
+ const fact = contractFacts.find((candidate) => candidate.kind === "requirement" &&
515
+ candidate.origin === "contract" &&
516
+ candidate.id === requirement.id);
517
+ const evidence = fact?.evidence;
518
+ return evidence?.behavior === "required";
519
+ })
520
+ .map((requirement) => requirement.id);
521
+ const serializeAtCap = (cap) => JSON.stringify({
522
+ requirements: requirements.map((requirement) => ({
523
+ id: requirement.id,
524
+ text: planInputText(requirement.text, cap.text),
525
+ sourceFragmentIds: planInputStrings(requirement.sourceFragmentIds).slice(0, cap.array),
526
+ })),
527
+ targetSurface: targetSurface.map((surface) => ({
528
+ completeness: planInputText(surface.completeness, 32),
529
+ entrypoint: planInputText(surface.entrypoint, cap.text),
530
+ routeOrMount: planInputText(surface.routeOrMount, cap.text),
531
+ implementationPaths: planInputStrings(surface.implementationPaths).slice(0, cap.array),
532
+ testPaths: planInputStrings(surface.testPaths).slice(0, cap.array),
533
+ dataSource: planInputText(surface.dataSource, cap.text),
534
+ allowedPathConflicts: planInputStrings(surface.allowedPathConflicts).slice(0, cap.array),
535
+ unresolvedPaths: planInputStrings(surface.unresolvedPaths).slice(0, cap.array),
536
+ })),
537
+ designEvidence: designEvidence.map((evidence) => ({
538
+ source: planInputText(evidence.source, cap.text),
539
+ paths: planInputStrings(evidence.paths).slice(0, cap.array),
540
+ conflicts: planInputStrings(evidence.conflicts).slice(0, cap.array),
541
+ })),
542
+ });
543
+ let serialized = serializeAtCap(FRONTEND_PLAN_INPUT_CAP_LADDER[0]);
544
+ for (const cap of FRONTEND_PLAN_INPUT_CAP_LADDER.slice(1)) {
545
+ if (serialized.length <= FRONTEND_PLAN_INPUT_MAX_CHARS)
546
+ break;
547
+ serialized = serializeAtCap(cap);
548
+ }
549
+ if (serialized.length > FRONTEND_PLAN_INPUT_MAX_CHARS) {
550
+ // Last resort: keep every requirement id (ids are short and the plan
551
+ // prompt separately lists them) but drop their texts, shrink scout facts
552
+ // to the minimum, and declare the degradation instead of corrupting JSON.
553
+ let fallback = {
554
+ degraded: "requirement-texts-truncated",
555
+ requirements: requirements.map((requirement) => ({
556
+ id: requirement.id,
557
+ text: "(truncated)",
558
+ })),
559
+ targetSurface: targetSurface.map((surface) => ({
560
+ completeness: planInputText(surface.completeness, 32),
561
+ implementationPaths: planInputStrings(surface.implementationPaths).slice(0, FRONTEND_PLAN_INPUT_CAP_LADDER[3].array),
562
+ })),
563
+ designEvidence: [],
564
+ };
565
+ let bounded = JSON.stringify(fallback);
566
+ let keep = fallback.requirements.length;
567
+ while (bounded.length > FRONTEND_PLAN_INPUT_MAX_CHARS &&
568
+ keep > 0) {
569
+ keep = Math.max(0, Math.floor(keep / 2));
570
+ fallback = { ...fallback, requirements: fallback.requirements.slice(0, keep) };
571
+ bounded = JSON.stringify({
572
+ ...fallback,
573
+ requirementIdsTruncated: keep < requirements.length,
574
+ });
575
+ }
576
+ serialized = bounded;
577
+ }
578
+ const checklistLines = [
579
+ "1. Every interaction you record needs a uiComponentChoices entry whose purpose equals the interaction name, or one decision=reuse-existing choice covering behavioural interactions.",
580
+ "2. Every applicable UI state needs a purpose-matching component choice or a stylingStrategy.",
581
+ "3. Every requirement marked (behavior) below needs modelled interactions plus at least one verification target that references it.",
582
+ "4. targets.files must name the concrete deliverable files; never leave the scope broader than the frozen requirements state.",
583
+ "5. Verification targets may only reference UI states and requirements you actually recorded (the record_* tools reject unknown references).",
584
+ `Requirements requiring behavioural coverage: ${behaviorRequiredIds.length > 0 ? behaviorRequiredIds.join(", ") : "(none)"}`,
585
+ "6. A decision=new component must declare sourceRequirementIds and pass sourceFragmentId for the frozen PRD fragment whose section matches the component's purpose — the runtime validates that binding and derives specReference (path/section/line); the reviewer checks purpose↔citation consistency.",
586
+ ...[...input.componentSourceCitations ?? []]
587
+ .filter(([id]) => behaviorRequiredIds.includes(id))
588
+ .flatMap(([id, citations]) => citations.map((citation) => ` ${id} + ${citation.fragmentId} → ${citation.section}${citation.line ? ` (line ${citation.line})` : ""}`)),
589
+ ];
300
590
  return [
301
591
  "<frontend_plan_input>",
302
592
  "Committed Contract/Scout facts, compiled by the runner. Treat them as the complete planning evidence.",
303
- bounded,
593
+ serialized,
304
594
  "Do not read upstream artifacts, task sources, or repository files. If this input cannot support a decision, record a genuine evidence gap.",
305
595
  "</frontend_plan_input>",
596
+ "<plan_review_checklist>",
597
+ "The design reviewer re-runs these exact checks on the committed facts; satisfy every line before finalize_plan:",
598
+ ...checklistLines,
599
+ "</plan_review_checklist>",
306
600
  ].join("\n");
307
601
  }
308
- async function buildFrontendPlanInputContext(runDir) {
602
+ export async function buildFrontendPlanInputContext(runDir, componentSourceCitations) {
603
+ const contractFactsPath = path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
604
+ const scoutFactsPath = path.join(runDir, "frontend-scout-pi", "scout-typed-facts.jsonl");
605
+ let contractRecords;
606
+ let scoutRecords;
309
607
  try {
310
- const [contractRecords, scoutRecords] = await Promise.all([
311
- readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl")),
312
- readTypedEventStoreFromJsonl(path.join(runDir, "frontend-scout-pi", "scout-typed-facts.jsonl")),
608
+ [contractRecords, scoutRecords] = await Promise.all([
609
+ readTypedEventStoreFromJsonl(contractFactsPath),
610
+ readTypedEventStoreFromJsonl(scoutFactsPath),
313
611
  ]);
314
- return renderFrontendPlanInputContext({ contractRecords, scoutRecords });
315
612
  }
316
- catch {
317
- return "";
613
+ catch (error) {
614
+ // A missing/unreadable upstream store is a broken pipeline, not an
615
+ // evidence gap; fail before spending a model turn on a prompt that
616
+ // forbids reading anything.
617
+ throw new Error(`frontend-plan-input-unavailable: cannot read committed typed facts (${error instanceof Error ? error.message : String(error)})`);
618
+ }
619
+ const committedCount = [...contractRecords, ...scoutRecords].filter((record) => record.phase === "committed").length;
620
+ if (committedCount === 0) {
621
+ throw new Error(`frontend-plan-input-unavailable: no committed Contract/Scout facts in ${contractFactsPath} / ${scoutFactsPath}`);
318
622
  }
623
+ return renderFrontendPlanInputContext({
624
+ contractRecords,
625
+ scoutRecords,
626
+ componentSourceCitations,
627
+ });
319
628
  }
320
629
  export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics, frontendPlanRetryStep) {
321
630
  if (attemptNumber <= 1)
@@ -1018,9 +1327,36 @@ export async function executeDagNode(input) {
1018
1327
  return;
1019
1328
  }
1020
1329
  if (isFrontendPlanLadderTask(task)) {
1021
- const planInputContext = await buildFrontendPlanInputContext(runDir);
1022
- if (planInputContext)
1023
- prompt = `${prompt}\n\n${planInputContext}`;
1330
+ let planInputContext;
1331
+ try {
1332
+ const componentSourceCitations = await resolveComponentSourceCitations(spec, cwd);
1333
+ planInputContext = await buildFrontendPlanInputContext(runDir, componentSourceCitations);
1334
+ }
1335
+ catch (error) {
1336
+ await failBeforePrompt(error, "frontend-plan-input-unavailable");
1337
+ return;
1338
+ }
1339
+ prompt = `${prompt}\n\n${planInputContext}`;
1340
+ }
1341
+ if (isFrontendContractTypedNode(task)) {
1342
+ // Extreme-environment input handoff: compile the ledger's canonical
1343
+ // requirements into a bounded block so the contract node never re-reads
1344
+ // the raw source (mirrors the plan node's compiled input; its tool set
1345
+ // is record_* + finalize_contract, no read tools).
1346
+ let contractInputContext;
1347
+ try {
1348
+ const ledgerPath = resolveFrontendLedgerPath(spec.sourceBinding, cwd);
1349
+ contractInputContext = await buildFrontendContractInputContext({
1350
+ ledgerPath,
1351
+ });
1352
+ prompt = `${prompt}\n\n${contractInputContext}`;
1353
+ }
1354
+ catch (error) {
1355
+ // Ledger unreadable is a broken pipeline: fail before spending a
1356
+ // model turn on a prompt that forbids reading anything.
1357
+ await failBeforePrompt(error, "frontend-contract-input-unavailable");
1358
+ return;
1359
+ }
1024
1360
  }
1025
1361
  node.resolvedSkills = resolvedSkills;
1026
1362
  await writeNodeSkillArtifacts(runDir, nodeId, resolvedSkills);
@@ -1321,6 +1657,28 @@ export async function executeDagNode(input) {
1321
1657
  prompt: promptRestartCandidate,
1322
1658
  });
1323
1659
  }
1660
+ // C: contract incremental-progress guard. A failed contract attempt that
1661
+ // committed zero record_* submissions is a "no-progress" failure, not an
1662
+ // opaque timeout/error: normalize it to empty-output so the retryPolicy
1663
+ // retries with the incremental-commit discipline instead of burning the
1664
+ // remaining attempts on the same stalled behavior.
1665
+ if (isFrontendContractTypedNode(task) &&
1666
+ !result.ok &&
1667
+ retryPolicy !== undefined) {
1668
+ const submissions = await countContractRecordSubmissions(runDir, nodeId);
1669
+ if (submissions === 0) {
1670
+ result = {
1671
+ ...result,
1672
+ failureCategory: "empty-output",
1673
+ stderr: [
1674
+ result.stderr,
1675
+ "contract attempt failed with zero record_* submissions; retrying with incremental-commit discipline (one record_* call per message, starting from the first tool call)",
1676
+ ]
1677
+ .filter(Boolean)
1678
+ .join("\n"),
1679
+ };
1680
+ }
1681
+ }
1324
1682
  const attemptRecord = {
1325
1683
  attempt: attemptNumber,
1326
1684
  startedAt: attemptStartedAt,
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { parseJsonReviewVerdict } from "./output-protocol.js";
5
5
  import { FRONTEND_DESIGN_REVIEW_NODE_ID, FRONTEND_REVIEW_NODE_ID, readCommittedDesignRequestFact, readCommittedReviewRequestFact, } from "./frontend-review-findings.js";
6
+ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
6
7
  export const MAX_RERUN_FEEDBACK_CHARS = 12_000;
7
8
  const RERUN_FEEDBACK_TRUNCATION_MARKER = "\n\n...[上一轮反馈已按长度上限截断]...\n\n";
8
9
  const MAX_EVIDENCE_REFS = 8;
@@ -201,6 +202,127 @@ async function readTypedDesignRequestFact(runDir) {
201
202
  },
202
203
  };
203
204
  }
205
+ const RERUN_FEEDBACK_LINEAGE_MAX_DEPTH = 5;
206
+ const RERUN_FEEDBACK_LINEAGE_LIFECYCLES = [
207
+ "active",
208
+ "paused",
209
+ "completed",
210
+ ];
211
+ function safeLineageRunId(value) {
212
+ if (typeof value !== "string" || !value || value !== value.trim())
213
+ return undefined;
214
+ if (value === "." ||
215
+ value === ".." ||
216
+ value.includes("/") ||
217
+ value.includes("\\") ||
218
+ value.includes("\0"))
219
+ return undefined;
220
+ return value;
221
+ }
222
+ async function readCommittedTerminalKind(input) {
223
+ try {
224
+ const records = await readTypedEventStoreFromJsonl(path.join(input.runDir, input.nodeId, input.factsFile));
225
+ for (const record of records) {
226
+ if (record.phase !== "committed")
227
+ continue;
228
+ const kind = record.fact.kind;
229
+ if (typeof kind === "string" && input.kinds.includes(kind))
230
+ return kind;
231
+ }
232
+ }
233
+ catch {
234
+ // A missing/unreadable terminal store means this run did not establish a
235
+ // usable resolution barrier; lineage lookup may continue.
236
+ }
237
+ return undefined;
238
+ }
239
+ async function readReviewTerminalKind(runDir) {
240
+ return readCommittedTerminalKind({
241
+ runDir,
242
+ nodeId: FRONTEND_REVIEW_NODE_ID,
243
+ factsFile: "review-typed-facts.jsonl",
244
+ kinds: ["request_review_changes", "approve_review"],
245
+ });
246
+ }
247
+ async function readDesignTerminalKind(runDir) {
248
+ return readCommittedTerminalKind({
249
+ runDir,
250
+ nodeId: FRONTEND_DESIGN_REVIEW_NODE_ID,
251
+ factsFile: "design-typed-facts.jsonl",
252
+ kinds: ["request_design_changes", "approve_design"],
253
+ });
254
+ }
255
+ /**
256
+ * Walk the rerun-task lineage chain (`<runDir>/.runtime/rerun-task-lineage.json`
257
+ * → parentRunId) looking for the nearest ancestor run with a committed review
258
+ * or design request fact. A nearer final-review approval resolves all older
259
+ * findings; a nearer design approval resolves older design findings while
260
+ * still allowing an older final-review request to survive. Bounded depth;
261
+ * unsafe ids, mismatched state identities, cycles, and unreadable lineage
262
+ * files terminate the walk with undefined.
263
+ */
264
+ async function readAncestorTypedRequestFact(runDir) {
265
+ let currentRunDir = runDir;
266
+ let currentRunId;
267
+ let designResolved = false;
268
+ const dagRunsRoot = path.resolve(runDir, "..", "..");
269
+ const visitedRunDirs = new Set([path.resolve(runDir)]);
270
+ for (let depth = 0; depth <= RERUN_FEEDBACK_LINEAGE_MAX_DEPTH; depth += 1) {
271
+ const reviewTerminalKind = await readReviewTerminalKind(currentRunDir);
272
+ if (currentRunId && reviewTerminalKind === "request_review_changes") {
273
+ const fact = await readTypedReviewRequestFact(currentRunDir);
274
+ if (fact)
275
+ return { ...fact, parentRunId: currentRunId };
276
+ }
277
+ if (reviewTerminalKind === "approve_review")
278
+ return undefined;
279
+ const designTerminalKind = await readDesignTerminalKind(currentRunDir);
280
+ if (currentRunId &&
281
+ !designResolved &&
282
+ designTerminalKind === "request_design_changes") {
283
+ const fact = await readTypedDesignRequestFact(currentRunDir);
284
+ if (fact)
285
+ return { ...fact, parentRunId: currentRunId };
286
+ }
287
+ if (designTerminalKind === "approve_design")
288
+ designResolved = true;
289
+ if (depth === RERUN_FEEDBACK_LINEAGE_MAX_DEPTH)
290
+ return undefined;
291
+ let parentRunId;
292
+ try {
293
+ const lineage = JSON.parse(await readFile(path.join(currentRunDir, ".runtime", "rerun-task-lineage.json"), "utf8"));
294
+ parentRunId = safeLineageRunId(lineage.parentRunId);
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ if (!parentRunId)
300
+ return undefined;
301
+ let ancestorRunDir;
302
+ for (const lifecycle of RERUN_FEEDBACK_LINEAGE_LIFECYCLES) {
303
+ const candidate = path.join(dagRunsRoot, lifecycle, parentRunId);
304
+ try {
305
+ const ancestorState = JSON.parse(await readFile(path.join(candidate, "state.json"), "utf8"));
306
+ if (ancestorState.runId === parentRunId) {
307
+ ancestorRunDir = candidate;
308
+ break;
309
+ }
310
+ }
311
+ catch {
312
+ // try next lifecycle
313
+ }
314
+ }
315
+ if (!ancestorRunDir)
316
+ return undefined;
317
+ const resolvedAncestorRunDir = path.resolve(ancestorRunDir);
318
+ if (visitedRunDirs.has(resolvedAncestorRunDir))
319
+ return undefined;
320
+ visitedRunDirs.add(resolvedAncestorRunDir);
321
+ currentRunDir = ancestorRunDir;
322
+ currentRunId = parentRunId;
323
+ }
324
+ return undefined;
325
+ }
204
326
  /**
205
327
  * Derive only the latest unresolved verify/review feedback from canonical
206
328
  * parent-run facts. Provider-only failures intentionally return undefined.
@@ -225,15 +347,25 @@ export async function deriveDagRerunFeedback(input) {
225
347
  // passHistory. Fall back to reading the typed event stores so findings
226
348
  // still flow into the next round as unresolved feedback. Prefer review;
227
349
  // if absent, fall through to the design review (a design rejection also
228
- // carries actionable findings the next plan round must consume).
350
+ // carries actionable findings the next plan round must consume). When
351
+ // the direct parent has neither because it failed before review, walk the
352
+ // rerun-task lineage chain — chained
353
+ // reruns otherwise lose the grandparent's code-review findings and the
354
+ // reviewer re-flags them as "unresolved previous-round feedback" (r18).
355
+ // A committed approval is a resolution barrier and prevents older
356
+ // findings from being resurrected.
229
357
  const typedFact = (await readTypedReviewRequestFact(input.runDir)) ??
230
- (await readTypedDesignRequestFact(input.runDir));
358
+ (await readTypedDesignRequestFact(input.runDir)) ??
359
+ (await readAncestorTypedRequestFact(input.runDir));
231
360
  if (typedFact) {
232
361
  const parentSourceBindingHash = sourceBindingHash(input.spec);
233
362
  return withDigest({
234
363
  schemaVersion: 1,
235
364
  kind: "unresolved-terminal-feedback",
236
- parentRunId: input.parentRunId,
365
+ parentRunId: "parentRunId" in typedFact &&
366
+ typeof typedFact.parentRunId === "string"
367
+ ? typedFact.parentRunId
368
+ : input.parentRunId,
237
369
  taskId: input.taskId,
238
370
  sourceNodeId: typedFact.nodeId,
239
371
  verdict: "request-revision",