@tea-agent/loop-agent 0.38.2 → 0.38.4

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 (192) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +171 -96
  3. package/README.md +11 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/command-definitions.js +16 -5
  8. package/dist/cli/program.js +16 -2
  9. package/dist/cli/update/notifier.js +2 -2
  10. package/dist/cli.js +2 -1
  11. package/dist/commands/dag-rerun.js +55 -1
  12. package/dist/commands/init-model-catalog.js +464 -0
  13. package/dist/commands/init-upgrade.js +265 -97
  14. package/dist/commands/init.js +428 -27
  15. package/dist/commands/inspect-next.js +8 -0
  16. package/dist/commands/task-advance.js +31 -0
  17. package/dist/executors/dag-pi-executor.js +76 -10
  18. package/dist/executors/pi-event-serializer.js +42 -1
  19. package/dist/executors/pi-executor.js +51 -0
  20. package/dist/executors/pi-extension-resolver.js +233 -0
  21. package/dist/executors/pi-playwright-cli-tool.js +51 -12
  22. package/dist/executors/pi-sdk-executor.js +187 -59
  23. package/dist/executors/shell-executor.js +265 -15
  24. package/dist/executors/shell-write-guard.js +7 -0
  25. package/dist/shared/dag-prompt-override.js +28 -0
  26. package/dist/shared/operator/capabilities.js +32 -0
  27. package/dist/shared/playwright-cli-command-policy.js +15 -0
  28. package/dist/shared/update/console-notifier.js +100 -0
  29. package/dist/{cli → shared}/update/npm-client.js +41 -8
  30. package/dist/{cli → shared}/update/state.js +41 -13
  31. package/dist/task/contract/constants.js +1 -0
  32. package/dist/task/contract/diff.js +26 -0
  33. package/dist/task/contract/project.js +5 -0
  34. package/dist/task/contract/schema.js +6 -1
  35. package/dist/task/source-prepare/build-draft.js +15 -0
  36. package/dist/task/source-prepare/parse-intent.js +24 -4
  37. package/dist/worker/cli.js +31 -1
  38. package/dist/worker/console/chat/routes.js +1 -0
  39. package/dist/worker/console/dag-execution-receipt.js +33 -0
  40. package/dist/worker/console/operator-actions.js +116 -0
  41. package/dist/worker/console/operator-user-error.js +2 -2
  42. package/dist/worker/console/pi-readiness.js +4 -4
  43. package/dist/worker/console/routes.js +43 -2
  44. package/dist/worker/console/security.js +63 -9
  45. package/dist/worker/console/server.js +36 -5
  46. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4bmo3iY.js → abnfDiagram-N423BO3Z-gauhD9kD.js} +1 -1
  47. package/dist/worker/console/static/assets/{arc-SAD7McOS.js → arc-pm3c2Rsl.js} +1 -1
  48. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DAVLUdK1.js → architectureDiagram-T3A2C74G-D_JAg3wH.js} +1 -1
  49. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CPPdv4vj.js → blockDiagram-VBNYF7ZC-Dk7-ex52.js} +1 -1
  50. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D1GRGZax.js → c4Diagram-5PPSVZJV-Uval9oRI.js} +1 -1
  51. package/dist/worker/console/static/assets/channel-CVrnUdZl.js +1 -0
  52. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D_Bq2ZqK.js → chunk-2GRJ4B5K-BSYjHs-v.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CPLVtamK.js → chunk-2Q5K7J3B-KGUuBXI8.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DnaZWcL3.js → chunk-5RXB4S5H-JqAtxD9N.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BRTmFgnq.js → chunk-5VM5RSS4-D6EJOVQK.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BRlfYZoH.js → chunk-6Q2QTUOP-B1JriSjm.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CjNybD4u.js → chunk-GF5L2VYU-D62b96Jd.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-D3e-74fG.js → chunk-JWPE2WC7-BsNKEC_r.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Br3ir6f2.js → chunk-KBJHAD2P-B56v6-ZP.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-1NgVuRbh.js → chunk-RYQCIY6F-D2KPnd8K.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BTYuvuWN.js → chunk-XXDRQBXY-kzKSCaaL.js} +1 -1
  62. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +1 -0
  63. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +1 -0
  64. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BC6Bgosf.js → cose-bilkent-JH36ORCC-C6N6KNJd.js} +1 -1
  65. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DV-HypfL.js → cynefin-VYW2F7L2-DiyymT2D.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DhTaddO-.js → cynefinDiagram-MW4NZA55-BeKAXvf3.js} +1 -1
  67. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BnEcOmiI.js → dagre-VZM6K2ZE-TkIdXfPF.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-xphGRVKp.js → diagram-7IWD3JNH-BFmSG3ks.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CrdD1mRG.js → diagram-B4RE2ZJO-DqpJT2Fs.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-D4YdLsaF.js → diagram-LBJQPF4R-r5ATN43t.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-LCKyGZ5U.js → diagram-Q27KOJAE-DEcSCMuR.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BeiTe7t0.js → diagram-UB23O5K3-VOvYRLVc.js} +1 -1
  73. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BO2cq8GS.js → ebnfDiagram-BXEA7PRR-D7f4Cb1u.js} +1 -1
  74. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CmWww3Nt.js → erDiagram-JOGREHBK-ziUf3U0G.js} +1 -1
  75. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C2kkUYg5.js → flowDiagram-UKHOOZJN-BoRdQ-1y.js} +1 -1
  76. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DY_JZ2iQ.js → ganttDiagram-PKOTCBZU-B1-GncNm.js} +1 -1
  77. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BizhCSw9.js → gitGraphDiagram-DS77QQ5N-CTW-ph8W.js} +1 -1
  78. package/dist/worker/console/static/assets/index-C8rOIAJr.css +1 -0
  79. package/dist/worker/console/static/assets/{index-BXN89VPX.js → index-DEbyrEfW.js} +147 -68
  80. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Dlo2eK_d.js → infoDiagram-6WML65LV-BFA337tZ.js} +1 -1
  81. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js → ishikawaDiagram-WSZJBQD7-BtEujgRR.js} +1 -1
  82. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DiMDe5Yh.js → journeyDiagram-NVQOT4AX-C8ZwD2Jp.js} +1 -1
  83. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-sN553oIN.js → kanban-definition-27J2QSJJ-Ck-z8B11.js} +1 -1
  84. package/dist/worker/console/static/assets/{linear-D9Py31Ld.js → linear-CAyXjQjN.js} +1 -1
  85. package/dist/worker/console/static/assets/{mermaid.core-D7Occzk_.js → mermaid.core-CcWKn6tS.js} +5 -5
  86. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DjDRboiR.js → mindmap-definition-FAOFIHXS-C2NuDO7E.js} +1 -1
  87. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Cgc4qlzp.js → pegDiagram-VL7TDLO6-BFub3PDz.js} +1 -1
  88. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DxZj8pKd.js → pieDiagram-7S7Q4E2Y-BJx5THZ5.js} +1 -1
  89. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CJ50MngR.js → quadrantDiagram-CIZ2JOQS-DhalkDWV.js} +1 -1
  90. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BY-OD8Tr.js → railroadDiagram-AXF67PYL-CVcBM36o.js} +1 -1
  91. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CQFButfK.js → requirementDiagram-LRYGKXZP-D_vy4n0w.js} +1 -1
  92. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BB_QUK_b.js → sankeyDiagram-W5VNT64P-DGYdJL1D.js} +1 -1
  93. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CqhXZjyx.js → sequenceDiagram-SI44F4Z6-C1xJFg3k.js} +1 -1
  94. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-2l1U4rgJ.js → sizeCapture-X5ZJPWSS-D_Fc43df.js} +1 -1
  95. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BX1FbrhJ.js → stateDiagram-OKZ733FA-BhGbIjbu.js} +1 -1
  96. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +1 -0
  97. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DOSnT7hb.js → swimlanes-SLNWSIFB-DVkNcO-V.js} +2 -2
  98. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +8 -0
  99. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DVJQA7AN.js → timeline-definition-Z64GVDOM-BQU9ufV0.js} +1 -1
  100. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-74uxl9gx.js → vennDiagram-T6HMQDX7-BCQhRmYB.js} +1 -1
  101. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DsBxlcg_.js → wardleyDiagram-T6FBY63Y-DWerOyHO.js} +1 -1
  102. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-snuW9Mjw.js → xychartDiagram-ELKLHX3M-DxLeebQU.js} +1 -1
  103. package/dist/worker/console/static/index.html +2 -2
  104. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  105. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  106. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  107. package/dist/worker/observe/node-input.js +74 -3
  108. package/dist/worker/observe/node-process.js +377 -0
  109. package/dist/worker/observe/routes.js +36 -3
  110. package/dist/worker/observe/shell-handler-keys.js +34 -0
  111. package/dist/worker/observe/static/api.js +66 -0
  112. package/dist/worker/observe/static/constants.js +8 -1
  113. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  114. package/dist/worker/observe/static/format.js +23 -0
  115. package/dist/worker/observe/static/markdown-render.js +20 -1
  116. package/dist/worker/observe/static/operator-chrome.js +4 -0
  117. package/dist/worker/observe/static/state.js +13 -2
  118. package/dist/worker/observe/static/styles.css +73 -0
  119. package/dist/worker/observe/static/views/dag-inspector.js +612 -8
  120. package/dist/worker/observe/static/views/dag.js +8 -1
  121. package/dist/worker/observe/static/views/failures.js +5 -2
  122. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  123. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +76 -6
  124. package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
  125. package/dist/workflows/dag/contract-output-registry.js +15 -0
  126. package/dist/workflows/dag/failure-category.js +12 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +145 -40
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +87 -135
  129. package/dist/workflows/dag/frontend-repair.js +14 -0
  130. package/dist/workflows/dag/frontend-risk.js +15 -2
  131. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  132. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  133. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  134. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  135. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  136. package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
  137. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  138. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  139. package/dist/workflows/dag/init-hybrid.js +196 -117
  140. package/dist/workflows/dag/node-execution.js +139 -10
  141. package/dist/workflows/dag/prompt.js +15 -1
  142. package/dist/workflows/dag/rerun-plan.js +134 -3
  143. package/dist/workflows/dag/rerun-run.js +58 -6
  144. package/dist/workflows/dag/retry-policy.js +91 -0
  145. package/dist/workflows/dag/structured-output-repair.js +712 -0
  146. package/dist/workflows/dag/types.js +32 -1
  147. package/dist/workflows/dag/validate.js +41 -1
  148. package/docs/init-surface.manifest.json +5 -0
  149. package/docs/operations/README.md +1 -1
  150. package/docs/operations/local-development-environment.md +1 -5
  151. package/docs/skills/vetted-skill-registry.md +14 -0
  152. package/docs/templates/README.md +1 -1
  153. package/docs/templates/agent-dag.schema.json +44 -0
  154. package/docs/templates/backend-test-dag.json +2 -2
  155. package/docs/templates/frontend-test-dag.json +8 -10
  156. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  157. package/docs/templates/init-managed-agents.md +13 -3
  158. package/harness.json +2 -2
  159. package/package.json +12 -4
  160. package/scripts/next-info.mjs +356 -0
  161. package/scripts/next-publish-gate.mjs +238 -0
  162. package/scripts/release-source-binding.mjs +251 -0
  163. package/skills/analyze-product-requirements/SKILL.md +8 -5
  164. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  165. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  166. package/skills/analyze-product-requirements/references/example.md +50 -0
  167. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  168. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  169. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  170. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  171. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  172. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  173. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  174. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  175. package/skills/codebase-scout/SKILL.md +1 -1
  176. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  177. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  178. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  179. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  180. package/skills/improve-codebase-architecture/deepening.md +37 -0
  181. package/skills/improve-codebase-architecture/html-report.md +123 -0
  182. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  183. package/skills/improve-codebase-architecture/language.md +53 -0
  184. package/skills/loop-agent/references/command-reference.md +11 -4
  185. package/skills/playwright-cli/SKILL.md +22 -0
  186. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  187. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +0 -1
  188. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +0 -1
  189. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +0 -1
  190. package/dist/worker/console/static/assets/index-CuH5CNFu.css +0 -1
  191. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +0 -1
  192. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +0 -8
@@ -0,0 +1,712 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath, stat, open } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
+ function sha256Hex(input) {
7
+ return createHash("sha256").update(input, "utf8").digest("hex");
8
+ }
9
+ function sortKeysDeep(value) {
10
+ if (Array.isArray(value))
11
+ return value.map(sortKeysDeep);
12
+ if (value && typeof value === "object") {
13
+ const record = value;
14
+ const sorted = {};
15
+ for (const key of Object.keys(record).sort()) {
16
+ sorted[key] = sortKeysDeep(record[key]);
17
+ }
18
+ return sorted;
19
+ }
20
+ return value;
21
+ }
22
+ function serializeDeterministicJson(value) {
23
+ return JSON.stringify(sortKeysDeep(value), null, 2);
24
+ }
25
+ async function writeDeterministicJsonArtifact(runDir, relativePath, value) {
26
+ const json = `${serializeDeterministicJson(value)}\n`;
27
+ const targetPath = path.join(runDir, relativePath);
28
+ await writeTextArtifactFile(targetPath, json);
29
+ return { path: targetPath, sha256: sha256Hex(json) };
30
+ }
31
+ export const STRUCTURED_OUTPUT_REPAIR_CONTEXT_SCHEMA_ID = "frontend-structured-output-repair-context-v1";
32
+ export const FRONTEND_STRUCTURED_REPAIR_SCHEMA_IDS = [
33
+ "frontend-implementation-contract-plan-patch-v1",
34
+ "frontend-implementation-contract-revision-patch-v1",
35
+ ];
36
+ export const STRUCTURED_REPAIR_EXHAUSTED_CATEGORY = "structured-repair-exhausted";
37
+ export const GOVERNANCE_BLOCKED_CATEGORY = "governance-blocked";
38
+ export const STRUCTURED_REPAIR_MAX_INLINE_BYTES = 65_536;
39
+ export const STRUCTURED_REPAIR_MAX_CANDIDATE_BYTES = 65_536;
40
+ const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
41
+ export const structuredOutputRepairContextSchema = z
42
+ .object({
43
+ schemaVersion: z.literal(1),
44
+ schemaId: z.literal(STRUCTURED_OUTPUT_REPAIR_CONTEXT_SCHEMA_ID),
45
+ taskId: z.string().min(1),
46
+ runId: z.string().min(1),
47
+ nodeId: z.string().min(1),
48
+ nodeAttempt: z.number().int().positive(),
49
+ targetSchemaId: z.string().min(1),
50
+ originalPrompt: z
51
+ .object({
52
+ path: z.string().min(1),
53
+ sha256: sha256Schema,
54
+ })
55
+ .strict()
56
+ .optional(),
57
+ runtimeSkeleton: z
58
+ .object({
59
+ path: z.string().min(1),
60
+ sha256: sha256Schema,
61
+ })
62
+ .strict(),
63
+ previousOutputs: z.array(z
64
+ .object({
65
+ attempt: z.number().int().positive(),
66
+ path: z.string().min(1),
67
+ sha256: sha256Schema,
68
+ stopReason: z.string().min(1).optional(),
69
+ inlinePolicy: z.enum(["inline", "head-tail", "digest"]),
70
+ })
71
+ .strict()),
72
+ validationHistory: z.array(z
73
+ .object({
74
+ attempt: z.number().int().positive(),
75
+ classification: z.string().min(1),
76
+ errors: z.array(z
77
+ .object({
78
+ path: z.string().min(1),
79
+ code: z.string().min(1),
80
+ message: z.string().min(1),
81
+ })
82
+ .strict()),
83
+ })
84
+ .strict()),
85
+ conversationEvidence: z
86
+ .object({
87
+ successfulReadPaths: z.array(z.string()),
88
+ appliedCitations: z.array(z.string()),
89
+ })
90
+ .strict(),
91
+ budget: z
92
+ .object({
93
+ maxInlineBytes: z.number().int().positive(),
94
+ maxCandidateBytes: z.number().int().positive(),
95
+ })
96
+ .strict(),
97
+ instructions: z
98
+ .object({
99
+ repairOnly: z.literal(true),
100
+ reemitCompleteOutput: z.literal(true),
101
+ preserveValidDecisions: z.literal(true),
102
+ doNotRepeatRepositoryResearch: z.literal(true),
103
+ })
104
+ .strict(),
105
+ })
106
+ .strict();
107
+ export function isFrontendStructuredRepairSchemaId(schemaId) {
108
+ return (schemaId !== undefined &&
109
+ FRONTEND_STRUCTURED_REPAIR_SCHEMA_IDS.includes(schemaId));
110
+ }
111
+ export function isStructuredRepairableFailureCategory(category) {
112
+ return (category === "invalid-output" ||
113
+ category === "structured-output-truncated" ||
114
+ category === "output-too-large" ||
115
+ category === "empty-output");
116
+ }
117
+ export function formatStructuredArtifactPointer(input) {
118
+ return [
119
+ "Frontend structured artifact (canonical contract). Read the JSON file with the read tool; do not parse this pointer as the contract body.",
120
+ `path: ${input.path}`,
121
+ `schema: ${input.schemaId}`,
122
+ `sha256: ${input.sha256}`,
123
+ ].join("\n");
124
+ }
125
+ export function stripStructuredOutputBom(text) {
126
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
127
+ }
128
+ export function classifyStructuredContractFailure(input) {
129
+ const reason = input.reason;
130
+ const errors = [
131
+ { path: "$", code: "invalid-output", message: reason },
132
+ ];
133
+ if (input.kind === "blocked") {
134
+ return { classification: "governance-blocked", errors };
135
+ }
136
+ if (input.stopReason === "length") {
137
+ return { classification: "truncated", errors };
138
+ }
139
+ const lower = reason.toLowerCase();
140
+ if (lower.includes("runtime-protected paths")) {
141
+ return { classification: "schema", errors };
142
+ }
143
+ if (lower.includes("sourcebinding") ||
144
+ lower.includes("runtime skeleton") ||
145
+ lower.includes("blocking evidence gap") ||
146
+ lower.includes("does not cover") ||
147
+ lower.includes("absolute frontend contract path") ||
148
+ lower.includes("escapes") ||
149
+ lower.includes("secret")) {
150
+ return { classification: "governance-blocked", errors };
151
+ }
152
+ if (lower.includes("unterminated") ||
153
+ lower.includes("unexpected end") ||
154
+ lower.includes("end of data") ||
155
+ lower.includes("truncated")) {
156
+ return { classification: "truncated", errors };
157
+ }
158
+ if (lower.includes("openspec-citations") ||
159
+ lower.includes("fenced json") ||
160
+ lower.includes("exactly one") ||
161
+ lower.includes("must extract") ||
162
+ lower.includes("must contain exactly one")) {
163
+ return { classification: "format", errors };
164
+ }
165
+ if (lower.includes("contradict") ||
166
+ lower.includes("incompatible") ||
167
+ lower.includes("mutually")) {
168
+ return { classification: "semantic-repair", errors };
169
+ }
170
+ return { classification: "schema", errors };
171
+ }
172
+ export function candidateDirRel(nodeId) {
173
+ return path.posix.join("contracts", "candidates", nodeId);
174
+ }
175
+ export function attemptRawRel(nodeId, attempt) {
176
+ return path.posix.join(candidateDirRel(nodeId), `attempt-${attempt}.raw.md`);
177
+ }
178
+ export function repairContextRel(nodeId, attempt) {
179
+ return path.posix.join(candidateDirRel(nodeId), `attempt-${attempt}.repair-context.json`);
180
+ }
181
+ export function runtimeSkeletonRel(nodeId) {
182
+ return path.posix.join(candidateDirRel(nodeId), "runtime-skeleton.json");
183
+ }
184
+ export function canonicalContractRel(nodeId) {
185
+ return path.posix.join(candidateDirRel(nodeId), "canonical-contract.json");
186
+ }
187
+ export function openspecCitationsRel(nodeId) {
188
+ return path.posix.join(candidateDirRel(nodeId), "openspec-citations.json");
189
+ }
190
+ export async function persistRuntimeSkeleton(input) {
191
+ const relativePath = runtimeSkeletonRel(input.nodeId);
192
+ const written = await writeDeterministicJsonArtifact(input.runDir, relativePath, input.skeleton);
193
+ return { path: written.path, sha256: written.sha256, relativePath };
194
+ }
195
+ export async function persistStructuredAttemptRaw(input) {
196
+ const relativePath = attemptRawRel(input.nodeId, input.attempt);
197
+ await writeTextArtifactFile(path.join(input.runDir, relativePath), input.text.endsWith("\n") ? input.text : `${input.text}\n`);
198
+ return relativePath;
199
+ }
200
+ export async function hasNonEmptyStructuredCandidate(input) {
201
+ for (let attempt = 1; attempt < input.beforeAttempt; attempt += 1) {
202
+ const rawPath = path.join(input.runDir, attemptRawRel(input.nodeId, attempt));
203
+ try {
204
+ const text = await readFile(rawPath, "utf8");
205
+ if (text.trim().length > 0)
206
+ return true;
207
+ }
208
+ catch (error) {
209
+ if (error.code !== "ENOENT")
210
+ throw error;
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+ export async function assertRunOwnedPath(runDir, candidate) {
216
+ const resolvedRun = await realpath(runDir).catch(async () => path.resolve(runDir));
217
+ const resolved = path.resolve(resolvedRun, candidate);
218
+ const relative = path.relative(resolvedRun, resolved);
219
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
220
+ throw new Error(`repair context path escapes runDir: ${candidate}`);
221
+ }
222
+ try {
223
+ const real = await realpath(resolved);
224
+ const realRel = path.relative(resolvedRun, real);
225
+ if (!realRel || realRel.startsWith("..") || path.isAbsolute(realRel)) {
226
+ throw new Error(`repair context path escapes runDir via symlink: ${candidate}`);
227
+ }
228
+ return real;
229
+ }
230
+ catch (error) {
231
+ if (error.code === "ENOENT")
232
+ return resolved;
233
+ throw error;
234
+ }
235
+ }
236
+ function headTailExcerpt(text, budget) {
237
+ if (text.length <= budget)
238
+ return text;
239
+ const half = Math.max(256, Math.floor(budget / 2) - 20);
240
+ return `${text.slice(0, half)}\n…[truncated]…\n${text.slice(-half)}`;
241
+ }
242
+ async function sha256File(absPath) {
243
+ const bytes = await readFile(absPath);
244
+ return createHash("sha256").update(bytes).digest("hex");
245
+ }
246
+ async function readOptionalText(absPath) {
247
+ try {
248
+ return await readFile(absPath, "utf8");
249
+ }
250
+ catch (error) {
251
+ if (error.code === "ENOENT")
252
+ return undefined;
253
+ throw error;
254
+ }
255
+ }
256
+ function eventArgs(event) {
257
+ return event.args ?? event.toolInput ?? event.input ?? {};
258
+ }
259
+ /**
260
+ * Current byte length of a node's session-events.jsonl (0 when missing).
261
+ * Captured before an executor attempt appends its events so audits can be
262
+ * scoped to that attempt's segment only.
263
+ */
264
+ export async function sessionEventsByteLength(runDir, nodeId) {
265
+ try {
266
+ return (await stat(path.join(runDir, nodeId, "session-events.jsonl")))
267
+ .size;
268
+ }
269
+ catch {
270
+ return 0;
271
+ }
272
+ }
273
+ export async function collectSuccessfulReadPaths(input) {
274
+ const eventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
275
+ const raw = await readOptionalText(eventsPath);
276
+ if (!raw)
277
+ return [];
278
+ const matched = new Set();
279
+ const started = new Map();
280
+ for (const line of raw.split(/\r?\n/)) {
281
+ const trimmed = line.trim();
282
+ if (!trimmed)
283
+ continue;
284
+ let event;
285
+ try {
286
+ event = JSON.parse(trimmed);
287
+ }
288
+ catch {
289
+ continue;
290
+ }
291
+ if (event.type === "tool_execution_start" && event.toolName === "read") {
292
+ const readPath = eventArgs(event).path;
293
+ if (typeof readPath === "string" && event.toolCallId) {
294
+ started.set(event.toolCallId, readPath);
295
+ }
296
+ }
297
+ else if (event.type === "tool_execution_end" &&
298
+ event.toolName === "read" &&
299
+ event.toolCallId) {
300
+ const readPath = started.get(event.toolCallId);
301
+ if (readPath)
302
+ matched.add(readPath);
303
+ started.delete(event.toolCallId);
304
+ }
305
+ }
306
+ return [...matched];
307
+ }
308
+ export async function auditRepairAttemptToolUse(input) {
309
+ const eventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
310
+ let raw;
311
+ if (input.eventText !== undefined) {
312
+ raw = input.eventText;
313
+ }
314
+ else {
315
+ raw = await readSessionEventsFromOffset(eventsPath, input.fromByteOffset);
316
+ }
317
+ if (!raw)
318
+ return { ok: true };
319
+ const allowed = new Set(input.allowedPaths.map((entry) => path.resolve(input.runDir, entry)));
320
+ const isAllowed = (candidate) => {
321
+ const resolved = path.resolve(input.runDir, candidate);
322
+ if (allowed.has(resolved))
323
+ return true;
324
+ for (const entry of allowed) {
325
+ const rel = path.relative(entry, resolved);
326
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
327
+ return true;
328
+ }
329
+ }
330
+ return false;
331
+ };
332
+ for (const line of raw.split(/\r?\n/)) {
333
+ const trimmed = line.trim();
334
+ if (!trimmed)
335
+ continue;
336
+ let event;
337
+ try {
338
+ event = JSON.parse(trimmed);
339
+ }
340
+ catch {
341
+ continue;
342
+ }
343
+ if (event.type !== "tool_execution_start" || !event.toolName)
344
+ continue;
345
+ if (event.toolName !== "read") {
346
+ return {
347
+ ok: false,
348
+ reason: `repair attempt used disallowed tool ${event.toolName}`,
349
+ };
350
+ }
351
+ const readPath = eventArgs(event).path;
352
+ if (typeof readPath === "string" && !isAllowed(readPath)) {
353
+ return {
354
+ ok: false,
355
+ reason: `repair attempt read path is outside the frozen allowlist: ${readPath}`,
356
+ };
357
+ }
358
+ }
359
+ return { ok: true };
360
+ }
361
+ /**
362
+ * Read session-events.jsonl from a byte offset, tolerating a mid-character
363
+ * cut by aligning to the next newline boundary. Returns undefined when the
364
+ * file is missing or the offset is at/after EOF (nothing new to audit).
365
+ */
366
+ async function readSessionEventsFromOffset(eventsPath, fromByteOffset) {
367
+ if (!fromByteOffset || fromByteOffset <= 0) {
368
+ return readOptionalText(eventsPath);
369
+ }
370
+ let handle;
371
+ try {
372
+ handle = await open(eventsPath, "r");
373
+ }
374
+ catch {
375
+ return undefined;
376
+ }
377
+ try {
378
+ const size = (await handle.stat()).size;
379
+ if (fromByteOffset >= size)
380
+ return undefined;
381
+ // The offset is captured after the previous attempt's fully drained
382
+ // appends, so it normally lands at a line start. Verify by reading one
383
+ // byte before the offset: when it is not a newline the offset lands
384
+ // mid-line (or mid-UTF-8) and the partial first line must be dropped by
385
+ // aligning to the next newline; otherwise audit from the offset itself.
386
+ const before = Buffer.alloc(1);
387
+ await handle.read(before, 0, 1, fromByteOffset - 1);
388
+ const aligned = before[0] === 0x0a ? fromByteOffset : -1;
389
+ const start = aligned >= 0 ? aligned : await findNextNewlineOffset(handle, fromByteOffset, size);
390
+ if (start < 0 || start >= size)
391
+ return undefined;
392
+ const length = size - start;
393
+ const buffer = Buffer.alloc(length);
394
+ await handle.read(buffer, 0, length, start);
395
+ return buffer.toString("utf8");
396
+ }
397
+ finally {
398
+ await handle.close();
399
+ }
400
+ }
401
+ /**
402
+ * Scan forward from an arbitrary offset for the next newline boundary when
403
+ * the captured offset lands mid-line. Bounded by file size.
404
+ */
405
+ async function findNextNewlineOffset(handle, fromByteOffset, size) {
406
+ const chunk = Buffer.alloc(4096);
407
+ let position = fromByteOffset;
408
+ while (position < size) {
409
+ const readLength = Math.min(chunk.byteLength, size - position);
410
+ const { bytesRead } = await handle.read(chunk, 0, readLength, position);
411
+ if (bytesRead <= 0)
412
+ return -1;
413
+ const newlineIndex = chunk.subarray(0, bytesRead).indexOf(0x0a);
414
+ if (newlineIndex !== -1)
415
+ return position + newlineIndex + 1;
416
+ position += bytesRead;
417
+ }
418
+ return -1;
419
+ }
420
+ async function loadValidationHistory(input) {
421
+ const history = [];
422
+ for (let attempt = 1; attempt < input.beforeAttempt; attempt += 1) {
423
+ const reportPath = path.join(input.runDir, candidateDirRel(input.nodeId), `attempt-${attempt}.validation.json`);
424
+ const raw = await readOptionalText(reportPath);
425
+ if (!raw)
426
+ continue;
427
+ try {
428
+ const parsed = JSON.parse(raw);
429
+ history.push({
430
+ attempt,
431
+ classification: parsed.classification ?? "schema",
432
+ errors: (parsed.errors ?? []).map((error) => ({
433
+ path: error.path ?? "$",
434
+ code: error.code ?? "invalid-output",
435
+ message: error.message ?? "invalid structured output",
436
+ })),
437
+ });
438
+ }
439
+ catch {
440
+ history.push({
441
+ attempt,
442
+ classification: "schema",
443
+ errors: [{ path: "$", code: "invalid-output", message: "unreadable validation report" }],
444
+ });
445
+ }
446
+ }
447
+ return history;
448
+ }
449
+ export async function freezeStructuredOutputRepairContext(input) {
450
+ if (input.nodeAttempt <= 1) {
451
+ throw new Error("repair context is only frozen for attempt>1");
452
+ }
453
+ const skeletonSource = input.skeleton ??
454
+ input.originalCanonical ??
455
+ undefined;
456
+ if (!skeletonSource) {
457
+ throw new Error("repair context requires a runtime skeleton or original canonical contract");
458
+ }
459
+ const skeleton = await persistRuntimeSkeleton({
460
+ runDir: input.runDir,
461
+ nodeId: input.nodeId,
462
+ skeleton: skeletonSource,
463
+ });
464
+ await assertRunOwnedPath(input.runDir, skeleton.relativePath);
465
+ const previousOutputs = [];
466
+ for (let attempt = 1; attempt < input.nodeAttempt; attempt += 1) {
467
+ const relativePath = attemptRawRel(input.nodeId, attempt);
468
+ const absPath = path.join(input.runDir, relativePath);
469
+ try {
470
+ await stat(absPath);
471
+ }
472
+ catch (error) {
473
+ if (error.code === "ENOENT")
474
+ continue;
475
+ throw error;
476
+ }
477
+ await assertRunOwnedPath(input.runDir, relativePath);
478
+ const digest = await sha256File(absPath);
479
+ const isLatest = attempt === input.nodeAttempt - 1;
480
+ previousOutputs.push({
481
+ attempt,
482
+ path: relativePath,
483
+ sha256: digest,
484
+ ...(isLatest && input.latestStopReason
485
+ ? { stopReason: input.latestStopReason }
486
+ : {}),
487
+ inlinePolicy: isLatest ? "inline" : "digest",
488
+ });
489
+ }
490
+ const originalPromptPath = path.posix.join(input.nodeId, "prompt.redacted.md");
491
+ let originalPrompt;
492
+ const originalAbs = path.join(input.runDir, originalPromptPath);
493
+ if (await readOptionalText(originalAbs)) {
494
+ await assertRunOwnedPath(input.runDir, originalPromptPath);
495
+ originalPrompt = {
496
+ path: originalPromptPath,
497
+ sha256: await sha256File(originalAbs),
498
+ };
499
+ }
500
+ const successfulReadPaths = await collectSuccessfulReadPaths({
501
+ runDir: input.runDir,
502
+ nodeId: input.nodeId,
503
+ });
504
+ const citationsPath = path.join(input.runDir, openspecCitationsRel(input.nodeId));
505
+ const citationsRaw = await readOptionalText(citationsPath);
506
+ const appliedCitations = [];
507
+ if (citationsRaw) {
508
+ try {
509
+ const parsed = JSON.parse(citationsRaw);
510
+ for (const citation of parsed.citations ?? []) {
511
+ if (typeof citation.path === "string" && citation.path.trim()) {
512
+ appliedCitations.push(citation.path);
513
+ }
514
+ }
515
+ }
516
+ catch {
517
+ // Citations are evidence, not a blocker for repair.
518
+ }
519
+ }
520
+ const context = {
521
+ schemaVersion: 1,
522
+ schemaId: STRUCTURED_OUTPUT_REPAIR_CONTEXT_SCHEMA_ID,
523
+ taskId: input.taskId,
524
+ runId: input.runId,
525
+ nodeId: input.nodeId,
526
+ nodeAttempt: input.nodeAttempt,
527
+ targetSchemaId: input.targetSchemaId,
528
+ ...(originalPrompt ? { originalPrompt } : {}),
529
+ runtimeSkeleton: {
530
+ path: skeleton.relativePath,
531
+ sha256: skeleton.sha256,
532
+ },
533
+ previousOutputs,
534
+ validationHistory: await loadValidationHistory({
535
+ runDir: input.runDir,
536
+ nodeId: input.nodeId,
537
+ beforeAttempt: input.nodeAttempt,
538
+ }),
539
+ conversationEvidence: {
540
+ successfulReadPaths,
541
+ appliedCitations,
542
+ },
543
+ budget: {
544
+ maxInlineBytes: STRUCTURED_REPAIR_MAX_INLINE_BYTES,
545
+ maxCandidateBytes: STRUCTURED_REPAIR_MAX_CANDIDATE_BYTES,
546
+ },
547
+ instructions: {
548
+ repairOnly: true,
549
+ reemitCompleteOutput: true,
550
+ preserveValidDecisions: true,
551
+ doNotRepeatRepositoryResearch: true,
552
+ },
553
+ };
554
+ const parsed = structuredOutputRepairContextSchema.parse(context);
555
+ const relativePath = repairContextRel(input.nodeId, input.nodeAttempt);
556
+ const written = await writeDeterministicJsonArtifact(input.runDir, relativePath, parsed);
557
+ await assertRunOwnedPath(input.runDir, relativePath);
558
+ return {
559
+ context: parsed,
560
+ relativePath,
561
+ sha256: written.sha256,
562
+ absolutePath: written.path,
563
+ };
564
+ }
565
+ async function loadInlineCandidate(input) {
566
+ const absPath = await assertRunOwnedPath(input.runDir, input.entry.path);
567
+ const digest = await sha256File(absPath);
568
+ if (digest !== input.entry.sha256) {
569
+ throw new Error(`repair context previous output hash mismatch: ${input.entry.path}`);
570
+ }
571
+ const text = await readFile(absPath, "utf8");
572
+ if (input.entry.inlinePolicy === "digest") {
573
+ return `(attempt ${input.entry.attempt} digest ${digest})`;
574
+ }
575
+ if (input.entry.inlinePolicy === "head-tail" || text.length > input.budget) {
576
+ return headTailExcerpt(text, Math.min(input.budget, STRUCTURED_REPAIR_MAX_CANDIDATE_BYTES));
577
+ }
578
+ return text;
579
+ }
580
+ export async function buildStructuredOutputRepairPrompt(input) {
581
+ const skeletonAbs = await assertRunOwnedPath(input.runDir, input.context.runtimeSkeleton.path);
582
+ const skeletonDigest = await sha256File(skeletonAbs);
583
+ if (skeletonDigest !== input.context.runtimeSkeleton.sha256) {
584
+ throw new Error("repair context runtime skeleton hash mismatch");
585
+ }
586
+ const skeletonText = await readFile(skeletonAbs, "utf8");
587
+ const latest = [...input.context.previousOutputs].sort((left, right) => right.attempt - left.attempt)[0];
588
+ const excerpts = [];
589
+ let remaining = input.context.budget.maxInlineBytes -
590
+ Buffer.byteLength(skeletonText, "utf8");
591
+ if (latest) {
592
+ const latestText = await loadInlineCandidate({
593
+ runDir: input.runDir,
594
+ entry: latest,
595
+ budget: Math.min(remaining, input.context.budget.maxCandidateBytes),
596
+ });
597
+ excerpts.push(`### Latest failed candidate (attempt ${latest.attempt}${latest.stopReason ? `, stopReason=${latest.stopReason}` : ""})\n${latestText}`);
598
+ remaining -= Buffer.byteLength(latestText, "utf8");
599
+ }
600
+ for (const entry of input.context.previousOutputs) {
601
+ if (latest && entry.attempt === latest.attempt)
602
+ continue;
603
+ if (remaining <= 0)
604
+ break;
605
+ const digestLine = `attempt ${entry.attempt} ${entry.path} sha256=${entry.sha256}`;
606
+ excerpts.push(`### Earlier candidate\n${digestLine}`);
607
+ remaining -= Buffer.byteLength(digestLine, "utf8");
608
+ }
609
+ const errorLines = input.context.validationHistory.flatMap((entry) => entry.errors.map((error) => `- attempt ${entry.attempt} [${entry.classification}] ${error.path}: ${error.message}`));
610
+ const clippedErrors = Buffer.byteLength(errorLines.join("\n"), "utf8") > 8_192
611
+ ? errorLines.slice(0, 24)
612
+ : errorLines;
613
+ return [
614
+ `STRUCTURED_OUTPUT_REPAIR attempt=${input.context.nodeAttempt}/${input.maxAttempts}`,
615
+ "",
616
+ "Repair the existing structured result using the frozen repair context attached",
617
+ "to this session. Do not re-plan, repeat repository research, or add unsupported",
618
+ "requirements. Preserve all valid decisions from previous attempts.",
619
+ "",
620
+ "Review the latest candidate and the cumulative validation history. When an",
621
+ "earlier output was truncated, recover missing content from the runtime",
622
+ "skeleton, successful read/citation evidence, and compact previous-output",
623
+ "excerpts. Do not reread the full original planner prompt.",
624
+ "",
625
+ "Return one complete standalone replacement from the beginning. Never return",
626
+ "only a suffix and never rely on concatenation with a previous output.",
627
+ "",
628
+ "## Tool contract (hard requirement)",
629
+ "This repair attempt allows ONLY the `read` tool, and only on paths listed",
630
+ "in the frozen repair context (the context file itself, the runtime skeleton,",
631
+ "the original prompt reference, previous output candidates, and the",
632
+ "successful read / citation evidence below). Every other tool — grep, find,",
633
+ "ls, bash, or any write — is forbidden this attempt and fails the node as",
634
+ "governance-blocked. Reading any path outside the allowlist fails the same",
635
+ "way. Do not open repository files, templates, or docs to answer format",
636
+ "questions: use the schema guidance in this prompt instead.",
637
+ "",
638
+ "## Frozen repair context",
639
+ `- path: ${input.contextPath}`,
640
+ `- sha256: ${input.contextSha256}`,
641
+ `- targetSchemaId: ${input.context.targetSchemaId}`,
642
+ "",
643
+ "## Runtime skeleton (never trimmed)",
644
+ "```json",
645
+ skeletonText.trimEnd(),
646
+ "```",
647
+ "",
648
+ "## Previous outputs",
649
+ excerpts.join("\n\n") || "(none)",
650
+ "",
651
+ "## Validation history",
652
+ clippedErrors.join("\n") || "(none)",
653
+ "",
654
+ "## Successful read / citation evidence",
655
+ input.context.conversationEvidence.successfulReadPaths
656
+ .map((entry) => `- read: ${entry}`)
657
+ .join("\n") || "(none)",
658
+ input.context.conversationEvidence.appliedCitations
659
+ .map((entry) => `- citation: ${entry}`)
660
+ .join("\n"),
661
+ "",
662
+ "## Schema-specific guidance",
663
+ ...input.schemaGuidance,
664
+ ...(input.previousReason
665
+ ? ["", "## Previous validator reason", input.previousReason]
666
+ : []),
667
+ "",
668
+ "Fix every reported field violation: do not emit null for optional fields, do not misspell field names, and match the required types exactly.",
669
+ ]
670
+ .filter((line) => line !== undefined)
671
+ .join("\n");
672
+ }
673
+ export function allowedRepairReadPaths(input) {
674
+ return [
675
+ input.contextPath,
676
+ input.context.runtimeSkeleton.path,
677
+ input.context.originalPrompt?.path,
678
+ ...input.context.previousOutputs.map((entry) => entry.path),
679
+ ...input.context.conversationEvidence.successfulReadPaths,
680
+ ...input.context.conversationEvidence.appliedCitations,
681
+ ]
682
+ .filter((entry) => Boolean(entry))
683
+ .map((entry) => path.resolve(input.runDir, entry));
684
+ }
685
+ export function parseOpenspecCitationsBlock(text) {
686
+ const match = text.match(/```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/);
687
+ if (!match) {
688
+ return { block: "", citations: [] };
689
+ }
690
+ const citations = [];
691
+ for (const rawLine of (match[1] ?? "").split(/\r?\n/)) {
692
+ const line = rawLine.trim();
693
+ if (!line)
694
+ continue;
695
+ try {
696
+ const parsed = JSON.parse(line);
697
+ if (typeof parsed.path !== "string" || !parsed.path.trim())
698
+ continue;
699
+ citations.push({
700
+ path: parsed.path,
701
+ section: typeof parsed.section === "string" ? parsed.section : "",
702
+ line: typeof parsed.line === "number" && Number.isInteger(parsed.line)
703
+ ? parsed.line
704
+ : null,
705
+ });
706
+ }
707
+ catch {
708
+ // Keep the raw block even when a row is unparseable.
709
+ }
710
+ }
711
+ return { block: match[0], citations };
712
+ }