@gobing-ai/spur 0.4.0 → 0.4.2

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 (172) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +4 -2
  3. package/config/decisions/gates.yaml +36 -0
  4. package/config/decisions/history-anatomy.yaml +35 -0
  5. package/config/decisions/idea-pipeline.yaml +38 -0
  6. package/config/plugin-scripts.json +15 -0
  7. package/config/rules/docs/sourced-api-claims.yaml +60 -0
  8. package/config/rules/recommended-pre-check.yaml +8 -3
  9. package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
  10. package/config/script-placement-baseline.json +2 -2
  11. package/config/templates/AGENTS.md +1 -1
  12. package/config/workflows/feature-lifecycle.yaml +9 -2
  13. package/config/workflows/history-anatomy.yaml +22 -0
  14. package/config/workflows/idea-pipeline.yaml +75 -0
  15. package/config/workflows/task-pipeline.yaml +216 -57
  16. package/config/workflows/wrapup-pipeline.yaml +107 -14
  17. package/package.json +10 -10
  18. package/plugins/sp/README.md +12 -3
  19. package/plugins/sp/agents/super-planner.md +16 -6
  20. package/plugins/sp/agents/super-reviewer.md +14 -0
  21. package/plugins/sp/bunfig.toml +8 -0
  22. package/plugins/sp/commands/dev-brainstorm.md +29 -16
  23. package/plugins/sp/commands/dev-find-way.md +80 -0
  24. package/plugins/sp/commands/dev-idea.md +6 -0
  25. package/plugins/sp/commands/dev-plan.md +6 -0
  26. package/plugins/sp/commands/dev-review-session.md +4 -1
  27. package/plugins/sp/commands/dev-run.md +12 -2
  28. package/plugins/sp/commands/dev-runall.md +32 -15
  29. package/plugins/sp/hooks/context-owner.ts +56 -0
  30. package/plugins/sp/hooks/context-post-tool.ts +23 -0
  31. package/plugins/sp/hooks/context-session-start.ts +49 -27
  32. package/plugins/sp/hooks/context-session-stop.ts +131 -30
  33. package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
  34. package/plugins/sp/lib/idea-handoff.generated.mjs +43 -5
  35. package/plugins/sp/lib/inline-run-app.ts +28 -0
  36. package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
  37. package/plugins/sp/lib/inline-run.generated.mjs +351 -46
  38. package/plugins/sp/lib/plan-projection.generated.d.mts +47 -0
  39. package/plugins/sp/lib/plan-projection.generated.mjs +368 -0
  40. package/plugins/sp/lib/quality-gate.generated.d.mts +35 -1
  41. package/plugins/sp/lib/quality-gate.generated.mjs +30296 -71
  42. package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
  43. package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
  44. package/plugins/sp/lib/run-summary-core.ts +561 -0
  45. package/plugins/sp/lib/transcript.ts +150 -7
  46. package/plugins/sp/plugin.json +1 -1
  47. package/plugins/sp/references/roles.md +1 -1
  48. package/plugins/sp/scripts/batch-plan.mjs +488 -0
  49. package/plugins/sp/scripts/batch-plan.ts +145 -0
  50. package/plugins/sp/scripts/batch-preflight.mjs +51 -3
  51. package/plugins/sp/scripts/batch-preflight.ts +72 -1
  52. package/plugins/sp/scripts/commit-guard.mjs +216 -0
  53. package/plugins/sp/scripts/commit-guard.ts +249 -0
  54. package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
  55. package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
  56. package/plugins/sp/scripts/inline-run-setup.mjs +562 -80
  57. package/plugins/sp/scripts/inline-run-setup.ts +52 -78
  58. package/plugins/sp/scripts/persist-out-check.mjs +90 -41
  59. package/plugins/sp/scripts/persist-out-check.ts +112 -57
  60. package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
  61. package/plugins/sp/scripts/pr-reviewing.ts +34 -9
  62. package/plugins/sp/scripts/quality-gate.mjs +30213 -61
  63. package/plugins/sp/scripts/quality-gate.ts +21 -5
  64. package/plugins/sp/scripts/residual-scan.mjs +86 -31
  65. package/plugins/sp/scripts/residual-scan.ts +28 -30
  66. package/plugins/sp/scripts/run-summary.mjs +432 -14
  67. package/plugins/sp/scripts/run-summary.ts +41 -177
  68. package/plugins/sp/scripts/session-timeline.mjs +109 -11
  69. package/plugins/sp/scripts/session-timeline.ts +57 -19
  70. package/plugins/sp/scripts/task-diffstat.mjs +32 -29
  71. package/plugins/sp/scripts/task-diffstat.ts +34 -34
  72. package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
  73. package/plugins/sp/scripts/wrapup-steps.ts +203 -1
  74. package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
  75. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
  78. package/plugins/sp/skills/code-verification/SKILL.md +3 -4
  79. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
  80. package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
  81. package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
  82. package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
  83. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  84. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  85. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
  86. package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
  87. package/plugins/sp/skills/session-review/SKILL.md +40 -7
  88. package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
  89. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
  90. package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
  91. package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
  92. package/plugins/sp/skills/spur-cli/references/decision.md +16 -8
  93. package/plugins/sp/skills/spur-cli/references/message.md +7 -7
  94. package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
  95. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
  96. package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
  97. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
  98. package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
  99. package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
  100. package/plugins/sp/skills/spur-dev/SKILL.md +20 -14
  101. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +33 -3
  102. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
  103. package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -17
  104. package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
  105. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +199 -0
  106. package/plugins/sp/skills/spur-dev/references/execution-batch.md +156 -1024
  107. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +133 -0
  108. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
  109. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
  110. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
  111. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +44 -12
  112. package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
  113. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +340 -165
  114. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
  115. package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
  116. package/schemas/state-machine-workflow.schema.json +23 -0
  117. package/spur.js +42402 -26889
  118. package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.B3GQZvj8.js} +1 -1
  119. package/web/_astro/BoardApp.BT7L2kSb.js +324 -0
  120. package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
  121. package/web/_astro/{arc.f4e0OYLg.js → arc.vaT7tPOB.js} +1 -1
  122. package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
  123. package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
  124. package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
  125. package/web/_astro/channel.D4nG9YLf.js +1 -0
  126. package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
  127. package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
  128. package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
  129. package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
  130. package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
  131. package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
  132. package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
  133. package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
  134. package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
  135. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
  136. package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
  137. package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
  138. package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
  139. package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
  140. package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
  141. package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
  142. package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
  143. package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
  144. package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
  145. package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
  146. package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
  147. package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
  148. package/web/_astro/index.BhSwN8yY.css +1 -0
  149. package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
  150. package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
  151. package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
  152. package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
  153. package/web/_astro/{linear.DguUUe6V.js → linear.BzGnBwNL.js} +1 -1
  154. package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.CSbTpIVz.js} +4 -4
  155. package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
  156. package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
  157. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
  158. package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
  159. package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
  160. package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
  161. package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
  162. package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
  163. package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
  164. package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
  165. package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
  166. package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
  167. package/web/index.html +2 -2
  168. package/plugins/sp/commands/dev-parallel.md +0 -31
  169. package/web/_astro/BoardApp.Bzr4OXrW.js +0 -324
  170. package/web/_astro/TaskDetail.LAyR_Pvn.js +0 -1
  171. package/web/_astro/channel.CUFdtWhm.js +0 -1
  172. package/web/_astro/index.Dg_3bOaJ.css +0 -1
@@ -36,6 +36,13 @@ export declare interface FoldResult {
36
36
  checks: Array<{ name: string; status: string; evidence: string }>;
37
37
  findings: string;
38
38
  }
39
+ export declare interface ParsedScanArgs {
40
+ mode: string;
41
+ wbs: string;
42
+ spurBin?: string;
43
+ root: string;
44
+ tmpDir: string;
45
+ }
39
46
  export declare const ALLOW_PRAGMA: 'residual-scan:allow';
40
47
  export declare function recordedVerdictPath(runDir: string, wbs: string, fs: Pick<typeof import('node:fs'), 'lstatSync' | 'existsSync'>): string;
41
48
  export declare function makeItemId(category: ResidualCategory, location: string, text: string): string;
@@ -53,6 +60,18 @@ export declare function classify(
53
60
  deferrals: Deferral[],
54
61
  ): ResidualItem[];
55
62
  export declare function scanResiduals(inputs: ResidualScanInputs): ResidualArtifact;
63
+ export declare function parseDeferralEntries(raw: unknown): Deferral[];
64
+ export declare function blockingReviewFindings(taskContent: string, deferrals: Deferral[]): ResidualItem[];
65
+ export declare function buildReviewGateArtifact(
66
+ wbs: string,
67
+ taskContent: string,
68
+ deferrals: Deferral[],
69
+ ): { artifact: ResidualArtifact; note: string };
70
+ export declare function parseScanArgs(
71
+ argv: readonly (string | undefined)[],
72
+ cwd: string,
73
+ defaultTmpDir: string,
74
+ ): ParsedScanArgs | null;
56
75
  export declare function blockingAnchors(items: ResidualItem[]): string[];
57
76
  export declare function foldVerdict(
58
77
  verdict: { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> },
@@ -150,6 +150,43 @@ function scanResiduals(inputs) {
150
150
  counts
151
151
  };
152
152
  }
153
+ function parseDeferralEntries(raw) {
154
+ const isDeferral = (e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.reason === "string" && e.reason.trim().length > 0;
155
+ return (Array.isArray(raw) ? raw : []).filter(isDeferral);
156
+ }
157
+ function blockingReviewFindings(taskContent, deferrals) {
158
+ const rows = parseReviewFindings(taskContent);
159
+ const tableDeferrals = rows.flatMap((r) => r.deferral === undefined ? [] : [{ id: makeItemId("review-finding", r.location, r.text), reason: r.deferral }]);
160
+ const items = rows.map((r) => ({
161
+ category: "review-finding",
162
+ priority: r.priority,
163
+ location: r.location,
164
+ text: r.text
165
+ }));
166
+ return classify(items, [...tableDeferrals, ...deferrals]);
167
+ }
168
+ function buildReviewGateArtifact(wbs, taskContent, deferrals) {
169
+ const items = blockingReviewFindings(taskContent, deferrals);
170
+ const blocking = items.filter((i) => i.class === "blocking");
171
+ const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
172
+ for (const item of items)
173
+ counts[item.class]++;
174
+ const artifact = {
175
+ wbs,
176
+ base: null,
177
+ scanned: {
178
+ "review-finding": true,
179
+ "diff-marker": false,
180
+ "unchecked-box": false,
181
+ "staging-residue": false
182
+ },
183
+ items,
184
+ counts
185
+ };
186
+ const note = `residual-review-gate: ${wbs} blocking=${counts.blocking}` + (blocking.length > 0 ? ` ids=${blocking.map((i) => i.id).join(",")} anchors=${blockingAnchors(blocking).join(",")}` : "") + `
187
+ `;
188
+ return { artifact, note };
189
+ }
153
190
  function blockingAnchors(items) {
154
191
  const anchors = new Set;
155
192
  for (const item of items) {
@@ -248,19 +285,48 @@ function verdictDisagreementNote(runDir, wbs, fs) {
248
285
  const winner = mtimeOf(fs, durable) > mtimeOf(fs, run) ? "durable" : "run";
249
286
  return `residual-fold: ${wbs} verdict copies disagree — run=${run} (${value(run)}) durable=${durable} (${value(durable)})` + ` → chose ${winner} (newer mtime)`;
250
287
  }
288
+ function parseScanArgs(argv, cwd, defaultTmpDir) {
289
+ let mode = "";
290
+ let wbs = "";
291
+ let spurBin;
292
+ let root = cwd;
293
+ let tmpDir = defaultTmpDir;
294
+ for (let i = 0;i < argv.length; i++) {
295
+ const a = argv[i];
296
+ if (a === undefined)
297
+ break;
298
+ if (a === "--spur-bin")
299
+ spurBin = argv[++i];
300
+ else if (a === "--root")
301
+ root = argv[++i] ?? root;
302
+ else if (a === "--tmp-dir")
303
+ tmpDir = argv[++i] ?? tmpDir;
304
+ else if (a === "--help" || a === "-h")
305
+ return null;
306
+ else if (mode === "")
307
+ mode = a;
308
+ else if (wbs === "")
309
+ wbs = a;
310
+ }
311
+ return mode === "" || wbs === "" ? null : { mode, wbs, spurBin, root, tmpDir };
312
+ }
251
313
  export {
252
314
  verdictDisagreementNote,
253
315
  scanResiduals,
254
316
  renderReport,
255
317
  recordedVerdictPath,
318
+ parseScanArgs,
256
319
  parseReviewFindings,
257
320
  parseDiffMarkers,
321
+ parseDeferralEntries,
258
322
  normalizeAnchor,
259
323
  makeItemId,
260
324
  locationOf,
261
325
  foldVerdict,
262
326
  findUncheckedBoxes,
263
327
  classify,
328
+ buildReviewGateArtifact,
329
+ blockingReviewFindings,
264
330
  blockingAnchors,
265
331
  ALLOW_PRAGMA
266
332
  };
@@ -0,0 +1,561 @@
1
+ /**
2
+ * run-summary core — the measurement and rendering behind the `run-summary` CLI (task 1146).
3
+ *
4
+ * Split out of `plugins/sp/scripts/run-summary.ts` when the actor split and the batch roll-up pushed
5
+ * that script past the ADR-130 glue budget: the script keeps argv parsing and dispatch, this module
6
+ * keeps the logic. `plugins/sp/lib/transcript.ts` is the same shape of shared plugin-side module.
7
+ * Node builtins plus relative imports only; `import type` for the app projection it is handed.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import type { WorkflowProgressProjection } from '@gobing-ai/spur-app';
12
+ import {
13
+ accumulate,
14
+ type Block,
15
+ formatDuration,
16
+ formatTokenSplit,
17
+ isCompaction,
18
+ newAcc,
19
+ parseRows,
20
+ promptText,
21
+ resolveTranscript,
22
+ type Row,
23
+ type Span,
24
+ sumSpans,
25
+ sumTokens,
26
+ type Tokens,
27
+ zeroTokens,
28
+ } from './transcript';
29
+ import { getEnvVars } from './env';
30
+
31
+ interface ProgressAttempt {
32
+ startedAt?: string;
33
+ completedAt?: string;
34
+ }
35
+
36
+ /**
37
+ * The subset of the progress projection the summary windows over. The close path hands over the
38
+ * full `WorkflowProgressProjection`; the extra fields are ignored here, so the reader stays a
39
+ * narrow structural view of it rather than a second copy of the contract.
40
+ */
41
+ export interface Progress {
42
+ runId: string;
43
+ workflow?: string;
44
+ status?: string;
45
+ states?: { state: string; visit?: number; status?: string; actions?: { attempts?: ProgressAttempt[] }[] }[];
46
+ }
47
+
48
+ /** A stage row; `toolCalls`/`tokens` are null when the window holds no host transcript record. */
49
+ export interface SummaryRow {
50
+ stage: string;
51
+ status: string;
52
+ workMs: number;
53
+ waitMs: number;
54
+ toolCalls: number | null;
55
+ tokens: Tokens | null;
56
+ time: string;
57
+ wait: string;
58
+ token: string;
59
+ }
60
+
61
+ /** 1146 R4: how the total window is spent, by actor. Rows sum exactly to the window. */
62
+ export interface ActorSplit {
63
+ operatorMs: number;
64
+ /** The longest single operator wait, so a total is not read as one stall. */
65
+ longestOperatorWaitMs: number;
66
+ modelMs: number;
67
+ shellMs: number;
68
+ subagentMs: number;
69
+ otherToolMs: number;
70
+ idleMs: number;
71
+ compactions: number;
72
+ subagentErrors: number;
73
+ }
74
+
75
+ /** A gate-receipt measurement, or the reason there is none (1146 R4: never a fabricated 0). */
76
+ export type GateTime = { ms: number } | { ms: null; reason: string };
77
+
78
+ export interface RunSummary {
79
+ available: true;
80
+ transcript?: string;
81
+ rows: SummaryRow[];
82
+ total: SummaryRow;
83
+ actors: ActorSplit;
84
+ gate: GateTime;
85
+ skippedLines: number;
86
+ }
87
+
88
+ /** A gap at or above this is idle, not work (1146 R4, closed decision: fixed, no flag). */
89
+ const IDLE_GAP_MS = 300_000;
90
+ const SHELL_TOOLS = new Set(['bash', 'shell', 'exec', 'exec_command', 'run_command', 'command']);
91
+ const SUBAGENT_TOOLS = new Set(['agent', 'task', 'invoke_subagent', 'subagent', 'dispatch_agent']);
92
+ const OPERATOR_TOOLS = new Set(['askuserquestion', 'ask_user_question']);
93
+
94
+ type Actor = 'operator' | 'subagent' | 'shell' | 'other';
95
+ /** Overlap is counted once, and subagent outranks shell outranks other tools (1146 AC4). */
96
+ const ACTOR_PRECEDENCE: Actor[] = ['operator', 'subagent', 'shell', 'other'];
97
+
98
+ function actorOf(tool: string): Actor {
99
+ const t = tool.toLowerCase().replace(/[^a-z_]/g, '');
100
+ if (OPERATOR_TOOLS.has(t)) return 'operator';
101
+ if (SUBAGENT_TOOLS.has(t)) return 'subagent';
102
+ if (SHELL_TOOLS.has(t)) return 'shell';
103
+ return 'other';
104
+ }
105
+
106
+ interface Call {
107
+ id: string;
108
+ actor: Actor;
109
+ ts: number;
110
+ }
111
+
112
+ /** One resolved tool span: [start, end] attributed to one actor. */
113
+ interface Resolved extends Call {
114
+ end: number;
115
+ }
116
+
117
+ /** Union length of a set of intervals, clipped to [start, end]. */
118
+ function unionMs(spans: Array<[number, number]>, start: number, end: number): number {
119
+ const clipped = spans
120
+ .map(([a, b]) => [Math.max(a, start), Math.min(b, end)] as [number, number])
121
+ .filter(([a, b]) => b > a)
122
+ .sort((x, y) => x[0] - y[0]);
123
+ let total = 0;
124
+ let cursor = Number.NaN;
125
+ let openUntil = Number.NaN;
126
+ for (const [a, b] of clipped) {
127
+ if (!Number.isFinite(cursor)) {
128
+ cursor = a;
129
+ openUntil = b;
130
+ continue;
131
+ }
132
+ if (a > openUntil) {
133
+ total += openUntil - cursor;
134
+ cursor = a;
135
+ openUntil = b;
136
+ } else {
137
+ openUntil = Math.max(openUntil, b);
138
+ }
139
+ }
140
+ if (Number.isFinite(cursor)) total += openUntil - cursor;
141
+ return total;
142
+ }
143
+
144
+ /** Subtract already-claimed intervals from a candidate set, returning the still-free part. */
145
+ function subtract(spans: Array<[number, number]>, claimed: Array<[number, number]>): Array<[number, number]> {
146
+ let free = spans;
147
+ for (const [ca, cb] of claimed) {
148
+ const next: Array<[number, number]> = [];
149
+ for (const [a, b] of free) {
150
+ if (cb <= a || ca >= b) {
151
+ next.push([a, b]);
152
+ continue;
153
+ }
154
+ if (ca > a) next.push([a, ca]);
155
+ if (cb < b) next.push([cb, b]);
156
+ }
157
+ free = next;
158
+ }
159
+ return free;
160
+ }
161
+
162
+ /**
163
+ * 1146 R4: partition the window [start, end] by actor. Tool calls are paired with their results
164
+ * (either host shape); overlaps resolve by `ACTOR_PRECEDENCE`, counted once; unclaimed gaps at or
165
+ * above IDLE_GAP_MS are idle; the remainder is model time, so the rows always sum to the window.
166
+ */
167
+ export function actorSplit(rows: [Row, number][], start: number, end: number): ActorSplit {
168
+ const empty: ActorSplit = {
169
+ operatorMs: 0,
170
+ longestOperatorWaitMs: 0,
171
+ modelMs: 0,
172
+ shellMs: 0,
173
+ subagentMs: 0,
174
+ otherToolMs: 0,
175
+ idleMs: 0,
176
+ compactions: 0,
177
+ subagentErrors: 0,
178
+ };
179
+ const window = end - start;
180
+ if (window <= 0) return empty;
181
+
182
+ const calls = new Map<string, Call>();
183
+ const results = new Map<string, { ts: number; isError: boolean }>();
184
+ const eventTs: number[] = [];
185
+ let compactions = 0;
186
+
187
+ for (const [row, ts] of rows) {
188
+ if (ts < start || ts > end) continue;
189
+ eventTs.push(ts);
190
+ if (isCompaction(row)) compactions++;
191
+ const blocks = Array.isArray(row.message?.content) ? (row.message?.content as Block[]) : [];
192
+ if (row.message?.role === 'toolResult' && row.message.toolCallId !== undefined) {
193
+ results.set(row.message.toolCallId, { ts, isError: row.message.isError === true });
194
+ }
195
+ for (const b of blocks) {
196
+ if ((b.type === 'tool_use' || b.type === 'toolCall') && b.id !== undefined && b.name !== undefined) {
197
+ calls.set(b.id, { id: b.id, actor: actorOf(b.name), ts });
198
+ } else if (b.type === 'tool_result' && b.tool_use_id !== undefined) {
199
+ results.set(b.tool_use_id, { ts, isError: b.is_error === true });
200
+ }
201
+ }
202
+ }
203
+
204
+ let subagentErrors = 0;
205
+ const resolved: Resolved[] = [];
206
+ for (const call of calls.values()) {
207
+ const result = results.get(call.id);
208
+ if (result?.isError === true && call.actor === 'subagent') subagentErrors++;
209
+ resolved.push({ ...call, end: Math.min(result?.ts ?? end, end) });
210
+ }
211
+
212
+ const claimed: Array<[number, number]> = [];
213
+ const byActor = new Map<Actor, number>();
214
+ for (const actor of ACTOR_PRECEDENCE) {
215
+ const mine = resolved.filter((r) => r.actor === actor).map((r) => [r.ts, r.end] as [number, number]);
216
+ const free = subtract(mine, claimed);
217
+ for (const span of free) claimed.push(span);
218
+ const ms = unionMs(free, start, end);
219
+ byActor.set(actor, ms);
220
+ if (actor === 'operator') {
221
+ empty.longestOperatorWaitMs = Math.max(0, ...mine.map(([a, b]) => b - a));
222
+ }
223
+ }
224
+
225
+ // Idle: unclaimed gaps between consecutive event boundaries, at or above the threshold.
226
+ const boundaries = [...new Set([start, end, ...eventTs, ...claimed.flat()])].sort((a, b) => a - b);
227
+ const claimedSorted = [...claimed].sort((a, b) => a[0] - b[0]);
228
+ let idleMs = 0;
229
+ for (let i = 0; i < boundaries.length - 1; i++) {
230
+ const a = boundaries[i] as number;
231
+ const b = boundaries[i + 1] as number;
232
+ if (b - a < IDLE_GAP_MS) continue;
233
+ if (claimedSorted.some(([ca, cb]) => ca < b && cb > a)) continue;
234
+ idleMs += b - a;
235
+ }
236
+
237
+ const accounted =
238
+ (byActor.get('operator') ?? 0) +
239
+ (byActor.get('subagent') ?? 0) +
240
+ (byActor.get('shell') ?? 0) +
241
+ (byActor.get('other') ?? 0) +
242
+ idleMs;
243
+ return {
244
+ ...empty,
245
+ operatorMs: byActor.get('operator') ?? 0,
246
+ subagentMs: byActor.get('subagent') ?? 0,
247
+ shellMs: byActor.get('shell') ?? 0,
248
+ otherToolMs: byActor.get('other') ?? 0,
249
+ idleMs,
250
+ modelMs: Math.max(0, window - accounted),
251
+ compactions,
252
+ subagentErrors,
253
+ };
254
+ }
255
+
256
+ interface Window {
257
+ stage: string;
258
+ status: string;
259
+ start: number;
260
+ end: number;
261
+ }
262
+
263
+ function attemptWindow(attempts: ProgressAttempt[]): [number, number] | undefined {
264
+ const starts = attempts.map((a) => Date.parse(a.startedAt ?? '')).filter((n) => !Number.isNaN(n));
265
+ const ends = attempts.map((a) => Date.parse(a.completedAt ?? '')).filter((n) => !Number.isNaN(n));
266
+ return starts.length && ends.length ? [Math.min(...starts), Math.max(...ends)] : undefined;
267
+ }
268
+
269
+ function summaryRow(stage: string, status: string, span: Span, measured: boolean): SummaryRow {
270
+ return {
271
+ stage,
272
+ status,
273
+ workMs: span.workMs,
274
+ waitMs: span.waitMs,
275
+ toolCalls: measured ? span.toolCalls : null,
276
+ tokens: measured ? span.tokens : null,
277
+ time: formatDuration(span.workMs),
278
+ wait: formatDuration(span.waitMs),
279
+ token: formatTokenSplit(measured ? span.tokens : null),
280
+ };
281
+ }
282
+
283
+ /**
284
+ * dev-run / dev-runall execution summary. One progress file → one row per state visit; several →
285
+ * one row per run. Each window spans its first attempt start → last completion (inclusive) and
286
+ * slices the host transcript; work = window − AskUserQuestion answer time.
287
+ */
288
+ export function buildRunSummary(
289
+ lines: string[],
290
+ inputs: { label?: string; progress: Progress }[],
291
+ since?: string,
292
+ until?: string,
293
+ ): RunSummary {
294
+ const windows: Window[] = [];
295
+ for (const { label, progress } of inputs) {
296
+ const states = (progress.states ?? []).flatMap((s) => {
297
+ const w = attemptWindow((s.actions ?? []).flatMap((a) => a.attempts ?? []));
298
+ return w ? [{ s, w }] : [];
299
+ });
300
+ if (!states.length) throw new Error(`progress for run ${progress.runId} has no timed attempts`);
301
+ if (inputs.length === 1) {
302
+ for (const { s, w } of states) {
303
+ const stage = (s.visit ?? 1) > 1 ? `${s.state} (visit ${s.visit})` : s.state;
304
+ windows.push({ stage, status: s.status ?? '', start: w[0], end: w[1] });
305
+ }
306
+ } else {
307
+ const stage = label ?? `${progress.workflow ?? 'run'} ${progress.runId.slice(0, 8)}`;
308
+ const start = Math.min(...states.map(({ w }) => w[0]));
309
+ windows.push({
310
+ stage,
311
+ status: progress.status ?? '',
312
+ start,
313
+ end: Math.max(...states.map(({ w }) => w[1])),
314
+ });
315
+ }
316
+ }
317
+ windows.sort((a, b) => a.start - b.start);
318
+
319
+ const { rows, skippedLines } = parseRows(lines);
320
+ const measure = (start: number, end: number): { span: Span; measured: boolean } => {
321
+ const acc = newAcc(start);
322
+ for (const [row, ts] of rows)
323
+ if (ts >= start && ts <= end && promptText(row) === undefined) accumulate(acc, row, ts);
324
+ const span = {
325
+ workMs: end - start - acc.askMs,
326
+ waitMs: acc.askMs,
327
+ toolCalls: acc.toolIds.size,
328
+ tokens: sumTokens([...acc.messages.values()]),
329
+ };
330
+ return { span, measured: acc.toolIds.size > 0 || acc.messages.size > 0 };
331
+ };
332
+
333
+ const sinceMs = since ? Date.parse(since) : Number.NaN;
334
+ const totalStart = Number.isNaN(sinceMs) ? Math.min(...windows.map((w) => w.start)) : sinceMs;
335
+ let totalEnd = Math.max(...windows.map((w) => w.end));
336
+ // With --since the total runs to the latest host activity, so setup/close work lands in overhead.
337
+ if (!Number.isNaN(sinceMs)) for (const [, ts] of rows) if (ts >= sinceMs) totalEnd = Math.max(totalEnd, ts);
338
+ // 1146 R1: the close passes its own completion instant, which caps the window exactly.
339
+ const untilMs = until ? Date.parse(until) : Number.NaN;
340
+ if (!Number.isNaN(untilMs) && untilMs > totalStart) totalEnd = untilMs;
341
+
342
+ const stageRows = windows.map((w) => {
343
+ const { span, measured } = measure(w.start, w.end);
344
+ return summaryRow(w.stage, w.status, span, measured);
345
+ });
346
+ const totalMeasure = measure(totalStart, totalEnd);
347
+ const statuses = [...new Set(inputs.map((i) => i.progress.status ?? ''))].join('/');
348
+ const total = summaryRow('Total', statuses, totalMeasure.span, totalMeasure.measured);
349
+
350
+ // Parallel runs overlap, so their rows cannot partition the total: no overhead row then.
351
+ const overlaps = windows.some((w, i) => i > 0 && w.start < (windows[i - 1]?.end ?? 0));
352
+ const sum = sumSpans(
353
+ stageRows.map((r) => ({ ...r, toolCalls: r.toolCalls ?? 0, tokens: r.tokens ?? zeroTokens() })),
354
+ );
355
+ const overhead: Span = {
356
+ workMs: total.workMs - sum.workMs,
357
+ waitMs: total.waitMs - sum.waitMs,
358
+ toolCalls: (total.toolCalls ?? 0) - sum.toolCalls,
359
+ tokens: Object.fromEntries(
360
+ (Object.keys(sum.tokens) as (keyof Tokens)[]).map((k) => [k, (total.tokens?.[k] ?? 0) - sum.tokens[k]]),
361
+ ) as Tokens,
362
+ };
363
+ if (!overlaps && overhead.workMs > 0)
364
+ stageRows.push(summaryRow('driver overhead', '', overhead, totalMeasure.measured));
365
+
366
+ return {
367
+ available: true,
368
+ rows: stageRows,
369
+ total,
370
+ actors: actorSplit(rows, totalStart, totalEnd),
371
+ gate: { ms: null, reason: 'no matching check receipt' },
372
+ skippedLines,
373
+ };
374
+ }
375
+
376
+ export function renderSummaryMarkdown(summary: RunSummary): string {
377
+ const line = (r: SummaryRow, bold = false) => {
378
+ const stage = bold ? `**${r.stage}**` : r.stage;
379
+ return `| ${stage} | ${r.status} | ${r.time} | ${r.wait} | ${r.toolCalls ?? 'n/a'} | ${r.token} |`;
380
+ };
381
+ const a = summary.actors;
382
+ const window = summary.total.workMs + summary.total.waitMs;
383
+ const share = (ms: number): string => (window > 0 ? `${Math.round((ms / window) * 100)}%` : 'n/a');
384
+ const gate = summary.gate.ms === null ? `n/a (${summary.gate.reason})` : formatDuration(summary.gate.ms);
385
+ return [
386
+ '| Stage | Status | Time | Wait | Tool calls | Token (total / non-cached) |',
387
+ '| --- | --- | ---: | ---: | ---: | ---: |',
388
+ ...summary.rows.map((r) => line(r)),
389
+ line(summary.total, true),
390
+ '',
391
+ '| Actor | Time | Share |',
392
+ '| --- | ---: | ---: |',
393
+ `| operator wait (longest ${formatDuration(a.longestOperatorWaitMs)}) | ${formatDuration(a.operatorMs)} | ${share(a.operatorMs)} |`,
394
+ `| model | ${formatDuration(a.modelMs)} | ${share(a.modelMs)} |`,
395
+ `| shell | ${formatDuration(a.shellMs)} | ${share(a.shellMs)} |`,
396
+ `| of which gate (informational) | ${gate} | — |`,
397
+ `| subagent | ${formatDuration(a.subagentMs)} | ${share(a.subagentMs)} |`,
398
+ `| other tools | ${formatDuration(a.otherToolMs)} | ${share(a.otherToolMs)} |`,
399
+ `| idle / unattributed | ${formatDuration(a.idleMs)} | ${share(a.idleMs)} |`,
400
+ '',
401
+ `Compactions: ${a.compactions} · Subagent errors: ${a.subagentErrors}`,
402
+ ].join('\n');
403
+ }
404
+
405
+ /** One `--rollup` input: a close-written `<runId>-summary.json` plus its label. */
406
+ export interface RollupInput {
407
+ label: string;
408
+ summary: {
409
+ runId?: string;
410
+ startedAt?: string;
411
+ completedAt?: string;
412
+ total: SummaryRow;
413
+ actors: ActorSplit;
414
+ };
415
+ }
416
+
417
+ /** Batch roll-up (1146 R3/AC5): one row per run's OWN window, then the sum, then the wall span. */
418
+ export function renderRollupMarkdown(rows: RollupInput[]): string {
419
+ const line = (label: string, r: SummaryRow, actors: ActorSplit, bold = false): string => {
420
+ const name = bold ? `**${label}**` : label;
421
+ return `| ${name} | ${r.status} | ${r.time} | ${r.wait} | ${r.toolCalls ?? 'n/a'} | ${r.token} | ${formatDuration(actors.operatorMs)} | ${formatDuration(actors.subagentMs)} |`;
422
+ };
423
+ const total = rows.reduce(
424
+ (acc, { summary }) => ({
425
+ workMs: acc.workMs + summary.total.workMs,
426
+ waitMs: acc.waitMs + summary.total.waitMs,
427
+ toolCalls: (acc.toolCalls ?? 0) + (summary.total.toolCalls ?? 0),
428
+ tokens: sumTokens([acc.tokens, summary.total.tokens ?? zeroTokens()]),
429
+ }),
430
+ { workMs: 0, waitMs: 0, toolCalls: 0 as number | null, tokens: zeroTokens() },
431
+ );
432
+ const totalRow: SummaryRow = {
433
+ stage: 'Batch total',
434
+ status: '',
435
+ workMs: total.workMs,
436
+ waitMs: total.waitMs,
437
+ toolCalls: total.toolCalls,
438
+ tokens: total.tokens,
439
+ time: formatDuration(total.workMs),
440
+ wait: formatDuration(total.waitMs),
441
+ token: formatTokenSplit(total.tokens),
442
+ };
443
+ const totalActors: ActorSplit = {
444
+ operatorMs: 0,
445
+ longestOperatorWaitMs: 0,
446
+ modelMs: 0,
447
+ shellMs: 0,
448
+ subagentMs: 0,
449
+ otherToolMs: 0,
450
+ idleMs: 0,
451
+ compactions: 0,
452
+ subagentErrors: 0,
453
+ };
454
+ for (const { summary } of rows) {
455
+ totalActors.operatorMs += summary.actors.operatorMs;
456
+ totalActors.subagentMs += summary.actors.subagentMs;
457
+ totalActors.compactions += summary.actors.compactions;
458
+ totalActors.subagentErrors += summary.actors.subagentErrors;
459
+ }
460
+ const out = [
461
+ '| Run | Status | Time | Wait | Tool calls | Token (total / non-cached) | Operator wait | Subagent |',
462
+ '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
463
+ ...rows.map(({ label, summary }) => line(label, summary.total, summary.actors)),
464
+ line('Batch total', totalRow, totalActors, true),
465
+ ];
466
+ const starts = rows.map((r) => Date.parse(r.summary.startedAt ?? '')).filter((n) => !Number.isNaN(n));
467
+ const ends = rows.map((r) => Date.parse(r.summary.completedAt ?? '')).filter((n) => !Number.isNaN(n));
468
+ const overlaps =
469
+ starts.length === rows.length &&
470
+ rows.some((r, i) =>
471
+ rows.some(
472
+ (o, j) =>
473
+ i !== j &&
474
+ Date.parse(r.summary.startedAt ?? '') < Date.parse(o.summary.completedAt ?? '') &&
475
+ Date.parse(o.summary.startedAt ?? '') < Date.parse(r.summary.completedAt ?? ''),
476
+ ),
477
+ );
478
+ if (overlaps && starts.length > 0 && ends.length > 0) {
479
+ out.push(
480
+ '',
481
+ `Wall span: ${new Date(Math.min(...starts)).toISOString()} → ${new Date(Math.max(...ends)).toISOString()}`,
482
+ );
483
+ }
484
+ return out.join('\n');
485
+ }
486
+
487
+ /** 1146 R1: the close-step summarizer's input — a structural copy of the app's interface. */
488
+ export interface CloseSummaryRun {
489
+ readonly runId: string;
490
+ readonly startedAt: string | undefined;
491
+ readonly completedAt: string;
492
+ /** The app's full projection; the summary reads only the subset above. */
493
+ readonly progress: WorkflowProgressProjection;
494
+ }
495
+
496
+ /**
497
+ * 1146 R1: write `.spur/run/<runId>-summary.{md,json}` and return the `.md` path (relative to cwd).
498
+ * Never throws: any failure (unresolved transcript, no timed attempts, anything else) leaves the
499
+ * `.md` holding exactly `Execution summary: n/a (<reason>)` and returns its path, so the close's
500
+ * exit code and JSON verdict are untouched.
501
+ */
502
+ export function writeCloseSummary(
503
+ run: CloseSummaryRun,
504
+ opts: { env?: Record<string, string | undefined>; projectsRoot?: string; cwd?: string } = {},
505
+ ): string {
506
+ const cwd = opts.cwd ?? process.cwd();
507
+ const runDir = join(cwd, '.spur', 'run');
508
+ const rel = join('.spur', 'run', `${run.runId}-summary.md`);
509
+ const abs = join(cwd, rel);
510
+ const na = (reason: string): string => {
511
+ mkdirSync(runDir, { recursive: true });
512
+ writeFileSync(abs, `Execution summary: n/a (${reason})\n`);
513
+ return rel;
514
+ };
515
+ try {
516
+ if (run.startedAt === undefined) return na('run row has no started_at');
517
+ const resolved = resolveTranscript(opts.env ?? getEnvVars(), opts.projectsRoot);
518
+ if (!resolved.ok) return na(resolved.reason);
519
+ const lines = readFileSync(resolved.path, 'utf8').split('\n');
520
+ const summary = buildRunSummary(
521
+ lines,
522
+ [{ progress: run.progress as unknown as Progress }],
523
+ run.startedAt,
524
+ run.completedAt,
525
+ );
526
+ if (summary.rows.length === 0) return na('no timed attempts');
527
+ mkdirSync(runDir, { recursive: true });
528
+ writeFileSync(abs, `${renderSummaryMarkdown(summary)}\n`);
529
+ writeFileSync(
530
+ join(runDir, `${run.runId}-summary.json`),
531
+ JSON.stringify(
532
+ { ...summary, runId: run.runId, startedAt: run.startedAt, completedAt: run.completedAt },
533
+ null,
534
+ 2,
535
+ ),
536
+ );
537
+ return rel;
538
+ } catch (error) {
539
+ return na(error instanceof Error ? error.message : String(error));
540
+ }
541
+ }
542
+
543
+ /**
544
+ * 1146 R4: gate time comes only from THIS run's check receipt (`receipt.runId === runId`); a
545
+ * receipt belonging to another run is a cross-run scratch read (ADR-131) and renders n/a.
546
+ */
547
+ export function gateFromReceipt(cwd: string, runId: string): GateTime {
548
+ const path = join(cwd, '.spur', 'run', `${runId}-check-receipt.json`);
549
+ if (!existsSync(path)) return { ms: null, reason: 'no matching check receipt' };
550
+ try {
551
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as { runId?: unknown; gateRuntimeMs?: unknown };
552
+ if (parsed.runId !== runId) return { ms: null, reason: 'check receipt belongs to another run' };
553
+ const ms = parsed.gateRuntimeMs;
554
+ if (typeof ms !== 'number' || !Number.isFinite(ms))
555
+ return { ms: null, reason: 'check receipt has no gate time' };
556
+ return { ms };
557
+ } catch {
558
+ return { ms: null, reason: 'check receipt is unreadable' };
559
+ }
560
+ }
561
+