@gobing-ai/spur 0.3.99 → 0.4.0

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 (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +17 -0
  3. package/config/decisions/task-pipeline.yaml +55 -0
  4. package/config/plugin-scripts.json +10 -0
  5. package/config/rules/boundary/sp-runtime-path.yaml +2 -0
  6. package/config/rules/quality/coverage-gate.yaml +5 -3
  7. package/config/rules/strict/runtime-boundaries.yaml +11 -0
  8. package/config/rules/structure/protected-files.yaml +3 -0
  9. package/config/workflows/task-pipeline.yaml +178 -18
  10. package/package.json +9 -9
  11. package/plugins/sp/README.md +14 -1
  12. package/plugins/sp/agents/super-reviewer.md +25 -11
  13. package/plugins/sp/commands/dev-changelog.md +5 -4
  14. package/plugins/sp/commands/dev-review-session.md +12 -7
  15. package/plugins/sp/commands/dev-run.md +5 -2
  16. package/plugins/sp/commands/dev-runall.md +5 -2
  17. package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
  18. package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
  19. package/plugins/sp/hooks/hooks.json +45 -1
  20. package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
  21. package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
  22. package/plugins/sp/lib/idea-handoff.generated.mjs +34 -9
  23. package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
  24. package/plugins/sp/lib/inline-run.generated.mjs +49 -19
  25. package/plugins/sp/lib/transcript.ts +160 -0
  26. package/plugins/sp/plugin.json +1 -1
  27. package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
  28. package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
  29. package/plugins/sp/scripts/inline-run-setup.ts +25 -31
  30. package/plugins/sp/scripts/residual-scan.mjs +2 -7
  31. package/plugins/sp/scripts/residual-scan.ts +7 -8
  32. package/plugins/sp/scripts/run-summary.mjs +286 -0
  33. package/plugins/sp/scripts/run-summary.ts +241 -0
  34. package/plugins/sp/scripts/session-timeline.mjs +234 -0
  35. package/plugins/sp/scripts/session-timeline.ts +152 -0
  36. package/plugins/sp/skills/code-verification/SKILL.md +5 -0
  37. package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
  38. package/plugins/sp/skills/session-review/SKILL.md +46 -24
  39. package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
  40. package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
  41. package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
  42. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
  43. package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
  44. package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
  45. package/plugins/sp/skills/spur-dev/references/execution-batch.md +64 -7
  46. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +14 -0
  47. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
  48. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +32 -7
  49. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
  50. package/schemas/decision-catalog.schema.json +226 -0
  51. package/schemas/spur-config.schema.json +25 -0
  52. package/spur.js +7440 -2424
  53. package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
  54. package/web/_astro/{BoardApp.i4eh_fHA.js → BoardApp.VeoxxLBy.js} +1 -1
  55. package/web/_astro/{TaskDetail.C5Cu8LdB.js → TaskDetail.LAyR_Pvn.js} +1 -1
  56. package/web/_astro/{arc.Bu5XpBEz.js → arc.f4e0OYLg.js} +1 -1
  57. package/web/_astro/{architectureDiagram-3BPJPVTR.9vHQTvhF.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
  58. package/web/_astro/{blockDiagram-GPEHLZMM.Cn2Xl7D6.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
  59. package/web/_astro/{c4Diagram-AAUBKEIU.7o0P_QDo.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
  60. package/web/_astro/channel.CUFdtWhm.js +1 -0
  61. package/web/_astro/{chunk-2J33WTMH.C3zZFsNt.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
  62. package/web/_astro/{chunk-4BX2VUAB.CNy_M7fO.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
  63. package/web/_astro/{chunk-55IACEB6.2TiPnLBL.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
  64. package/web/_astro/{chunk-727SXJPM.5CGSyKAp.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
  65. package/web/_astro/{chunk-AQP2D5EJ.w-ZIgfsK.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
  66. package/web/_astro/{chunk-FMBD7UC4.BkcEyhKa.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
  67. package/web/_astro/{chunk-ND2GUHAM.mxKKbBG4.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
  68. package/web/_astro/{chunk-QZHKN3VN.cyA0z9aT.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
  69. package/web/_astro/{classDiagram-4FO5ZUOK.CqtUzwhX.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
  70. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CqtUzwhX.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
  71. package/web/_astro/{cose-bilkent-S5V4N54A.BExPTopI.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
  72. package/web/_astro/{cynefin-OW5HDTMX.Bq2i10Cn.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
  73. package/web/_astro/{dagre-BM42HDAG.By_HsOuv.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
  74. package/web/_astro/{diagram-2AECGRRQ.BtU0b7hz.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
  75. package/web/_astro/{diagram-5GNKFQAL.D00ztMdT.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
  76. package/web/_astro/{diagram-KO2AKTUF.DMVJWaUN.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
  77. package/web/_astro/{diagram-LMA3HP47.Cxqrjg5-.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
  78. package/web/_astro/{diagram-OG6HWLK6.2lz_1B5h.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
  79. package/web/_astro/{erDiagram-TEJ5UH35.BVQEuJ3T.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
  80. package/web/_astro/{flowDiagram-I6XJVG4X.Qlyo90ky.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
  81. package/web/_astro/{ganttDiagram-6RSMTGT7.DRwb8HHZ.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
  82. package/web/_astro/{gitGraphDiagram-PVQCEYII.YfoPthfS.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
  83. package/web/_astro/index.Dg_3bOaJ.css +1 -0
  84. package/web/_astro/{infoDiagram-5YYISTIA.DvFREck1.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
  85. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BzCj9Q0-.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
  86. package/web/_astro/{journeyDiagram-JHISSGLW.BDMT_AFb.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
  87. package/web/_astro/{kanban-definition-UN3LZRKU.D6Fh3gj9.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
  88. package/web/_astro/{linear.Dxw00fGQ.js → linear.DguUUe6V.js} +1 -1
  89. package/web/_astro/{mermaid.core.CsoxUWa0.js → mermaid.core.CvgfmqVr.js} +4 -4
  90. package/web/_astro/{mindmap-definition-RKZ34NQL.CN8oeHLu.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
  91. package/web/_astro/{pieDiagram-4H26LBE5.C7ADqQ_7.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
  92. package/web/_astro/{quadrantDiagram-W4KKPZXB.Cw9lKyHP.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
  93. package/web/_astro/{requirementDiagram-4Y6WPE33.B1ni-z4q.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
  94. package/web/_astro/{sankeyDiagram-5OEKKPKP.BhexdjVr.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
  95. package/web/_astro/{sequenceDiagram-3UESZ5HK.C3Rm7dpQ.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
  96. package/web/_astro/{stateDiagram-AJRCARHV.CkuMt27u.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
  97. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Dc_aazUA.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
  98. package/web/_astro/{timeline-definition-PNZ67QCA.ja7sUFhH.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
  99. package/web/_astro/{vennDiagram-CIIHVFJN.BxIshX_K.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
  100. package/web/_astro/{wardleyDiagram-YWT4CUSO.DfHlC0sh.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
  101. package/web/_astro/{xychartDiagram-2RQKCTM6.DSnhHwCh.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
  102. package/web/board-runtime.json +1 -1
  103. package/web/index.html +2 -2
  104. package/web/_astro/BoardApp.BVp-LYi6.js +0 -322
  105. package/web/_astro/channel.6IFIKBhu.js +0 -1
  106. package/web/_astro/index.CbNz17Sx.css +0 -1
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/run-summary.ts
5
+ import { readFileSync } from "fs";
6
+
7
+ // plugins/sp/lib/env.ts
8
+ function getEnvVars() {
9
+ return process.env;
10
+ }
11
+
12
+ // plugins/sp/lib/transcript.ts
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ var SESSION_ID = /^[A-Za-z0-9_-]+$/;
17
+ var OPERATOR_TOOLS = new Set(["AskUserQuestion"]);
18
+ var zeroTokens = () => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
19
+ function formatDuration(ms) {
20
+ const s = Math.round(ms / 1000);
21
+ const h = Math.floor(s / 3600);
22
+ const m = Math.floor(s % 3600 / 60);
23
+ const ss = String(s % 60).padStart(2, "0");
24
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
25
+ }
26
+ function formatTokens(n) {
27
+ if (n >= 999500)
28
+ return `${(n / 1e6).toFixed(1)}M`;
29
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
30
+ }
31
+ function formatTokenSplit(t) {
32
+ if (!t)
33
+ return "n/a";
34
+ const nonCached = t.input + t.cacheCreate + t.output;
35
+ return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
36
+ }
37
+ function promptText(row) {
38
+ if (row.type !== "user" || row.isMeta || row.isCompactSummary)
39
+ return;
40
+ const content = row.message?.content;
41
+ if (typeof content === "string")
42
+ return content;
43
+ if (!Array.isArray(content) || content.some((b) => b.type === "tool_result"))
44
+ return;
45
+ return content.find((b) => b.type === "text")?.text;
46
+ }
47
+ function sumTokens(all) {
48
+ const total = zeroTokens();
49
+ for (const t of all)
50
+ for (const k of Object.keys(total))
51
+ total[k] += t[k];
52
+ return total;
53
+ }
54
+ function sumSpans(spans) {
55
+ return {
56
+ workMs: spans.reduce((n, s) => n + s.workMs, 0),
57
+ waitMs: spans.reduce((n, s) => n + s.waitMs, 0),
58
+ toolCalls: spans.reduce((n, s) => n + s.toolCalls, 0),
59
+ tokens: sumTokens(spans.map((s) => s.tokens))
60
+ };
61
+ }
62
+ var newAcc = (ts) => ({
63
+ last: ts,
64
+ askMs: 0,
65
+ asks: new Map,
66
+ toolIds: new Set,
67
+ messages: new Map
68
+ });
69
+ function accumulate(acc, row, ts) {
70
+ acc.last = Math.max(acc.last, ts);
71
+ const blocks = Array.isArray(row.message?.content) ? row.message.content : [];
72
+ for (const b of blocks) {
73
+ if (b.type === "tool_use" && b.id) {
74
+ acc.toolIds.add(b.id);
75
+ if (b.name && OPERATOR_TOOLS.has(b.name))
76
+ acc.asks.set(b.id, ts);
77
+ } else if (b.type === "tool_result" && b.tool_use_id && acc.asks.has(b.tool_use_id)) {
78
+ acc.askMs += ts - (acc.asks.get(b.tool_use_id) ?? ts);
79
+ }
80
+ }
81
+ const u = row.message?.usage;
82
+ if (row.type === "assistant" && u && row.message?.id) {
83
+ acc.messages.set(row.message.id, {
84
+ input: u.input_tokens ?? 0,
85
+ cacheCreate: u.cache_creation_input_tokens ?? 0,
86
+ cacheRead: u.cache_read_input_tokens ?? 0,
87
+ output: u.output_tokens ?? 0
88
+ });
89
+ }
90
+ }
91
+ function parseRows(lines) {
92
+ const rows = [];
93
+ let skippedLines = 0;
94
+ for (const line of lines) {
95
+ if (!line.trim())
96
+ continue;
97
+ let row;
98
+ try {
99
+ row = JSON.parse(line);
100
+ } catch {
101
+ skippedLines++;
102
+ continue;
103
+ }
104
+ const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
105
+ if (!Number.isNaN(ts))
106
+ rows.push([row, ts]);
107
+ }
108
+ return { rows, skippedLines };
109
+ }
110
+ function resolveTranscript(env, projectsRoot = join(homedir(), ".claude", "projects"), override) {
111
+ if (override)
112
+ return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: "no transcript" };
113
+ const id = env.CLAUDE_CODE_SESSION_ID;
114
+ if (!id)
115
+ return { ok: false, reason: "no host session id (CLAUDE_CODE_SESSION_ID); pass --transcript <path>" };
116
+ if (!SESSION_ID.test(id))
117
+ return { ok: false, reason: "refusing a session id with path characters" };
118
+ if (!existsSync(projectsRoot))
119
+ return { ok: false, reason: `no transcript root ${projectsRoot}` };
120
+ for (const dir of readdirSync(projectsRoot)) {
121
+ const path = join(projectsRoot, dir, `${id}.jsonl`);
122
+ if (existsSync(path))
123
+ return { ok: true, path };
124
+ }
125
+ return { ok: false, reason: `no transcript for session ${id}` };
126
+ }
127
+
128
+ // plugins/sp/scripts/run-summary.ts
129
+ function attemptWindow(attempts) {
130
+ const starts = attempts.map((a) => Date.parse(a.startedAt ?? "")).filter((n) => !Number.isNaN(n));
131
+ const ends = attempts.map((a) => Date.parse(a.completedAt ?? "")).filter((n) => !Number.isNaN(n));
132
+ return starts.length && ends.length ? [Math.min(...starts), Math.max(...ends)] : undefined;
133
+ }
134
+ function summaryRow(stage, status, span, measured) {
135
+ return {
136
+ stage,
137
+ status,
138
+ workMs: span.workMs,
139
+ waitMs: span.waitMs,
140
+ toolCalls: measured ? span.toolCalls : null,
141
+ tokens: measured ? span.tokens : null,
142
+ time: formatDuration(span.workMs),
143
+ wait: formatDuration(span.waitMs),
144
+ token: formatTokenSplit(measured ? span.tokens : null)
145
+ };
146
+ }
147
+ function buildRunSummary(lines, inputs, since) {
148
+ const windows = [];
149
+ for (const { label, progress } of inputs) {
150
+ const states = (progress.states ?? []).flatMap((s) => {
151
+ const w = attemptWindow((s.actions ?? []).flatMap((a) => a.attempts ?? []));
152
+ return w ? [{ s, w }] : [];
153
+ });
154
+ if (!states.length)
155
+ throw new Error(`progress for run ${progress.runId} has no timed attempts`);
156
+ if (inputs.length === 1) {
157
+ for (const { s, w } of states) {
158
+ const stage = (s.visit ?? 1) > 1 ? `${s.state} (visit ${s.visit})` : s.state;
159
+ windows.push({ stage, status: s.status ?? "", start: w[0], end: w[1] });
160
+ }
161
+ } else {
162
+ const stage = label ?? `${progress.workflow ?? "run"} ${progress.runId.slice(0, 8)}`;
163
+ const start = Math.min(...states.map(({ w }) => w[0]));
164
+ windows.push({
165
+ stage,
166
+ status: progress.status ?? "",
167
+ start,
168
+ end: Math.max(...states.map(({ w }) => w[1]))
169
+ });
170
+ }
171
+ }
172
+ windows.sort((a, b) => a.start - b.start);
173
+ const { rows, skippedLines } = parseRows(lines);
174
+ const measure = (start, end) => {
175
+ const acc = newAcc(start);
176
+ for (const [row, ts] of rows)
177
+ if (ts >= start && ts <= end && promptText(row) === undefined)
178
+ accumulate(acc, row, ts);
179
+ const span = {
180
+ workMs: end - start - acc.askMs,
181
+ waitMs: acc.askMs,
182
+ toolCalls: acc.toolIds.size,
183
+ tokens: sumTokens([...acc.messages.values()])
184
+ };
185
+ return { span, measured: acc.toolIds.size > 0 || acc.messages.size > 0 };
186
+ };
187
+ const sinceMs = since ? Date.parse(since) : Number.NaN;
188
+ const totalStart = Number.isNaN(sinceMs) ? Math.min(...windows.map((w) => w.start)) : sinceMs;
189
+ let totalEnd = Math.max(...windows.map((w) => w.end));
190
+ if (!Number.isNaN(sinceMs)) {
191
+ for (const [, ts] of rows)
192
+ if (ts >= sinceMs)
193
+ totalEnd = Math.max(totalEnd, ts);
194
+ }
195
+ const stageRows = windows.map((w) => {
196
+ const { span, measured } = measure(w.start, w.end);
197
+ return summaryRow(w.stage, w.status, span, measured);
198
+ });
199
+ const totalMeasure = measure(totalStart, totalEnd);
200
+ const statuses = [...new Set(inputs.map((i) => i.progress.status ?? ""))].join("/");
201
+ const total = summaryRow("Total", statuses, totalMeasure.span, totalMeasure.measured);
202
+ const overlaps = windows.some((w, i) => i > 0 && w.start < (windows[i - 1]?.end ?? 0));
203
+ const sum = sumSpans(stageRows.map((r) => ({ ...r, toolCalls: r.toolCalls ?? 0, tokens: r.tokens ?? zeroTokens() })));
204
+ const overhead = {
205
+ workMs: total.workMs - sum.workMs,
206
+ waitMs: total.waitMs - sum.waitMs,
207
+ toolCalls: (total.toolCalls ?? 0) - sum.toolCalls,
208
+ tokens: Object.fromEntries(Object.keys(sum.tokens).map((k) => [k, (total.tokens?.[k] ?? 0) - sum.tokens[k]]))
209
+ };
210
+ if (!overlaps && overhead.workMs > 0)
211
+ stageRows.push(summaryRow("driver overhead", "", overhead, totalMeasure.measured));
212
+ return { available: true, rows: stageRows, total, skippedLines };
213
+ }
214
+ function renderSummaryMarkdown(summary) {
215
+ const line = (r, bold = false) => {
216
+ const stage = bold ? `**${r.stage}**` : r.stage;
217
+ return `| ${stage} | ${r.status} | ${r.time} | ${r.wait} | ${r.toolCalls ?? "n/a"} | ${r.token} |`;
218
+ };
219
+ return [
220
+ "| Stage | Status | Time | Wait | Tool calls | Token (total / non-cached) |",
221
+ "| --- | --- | ---: | ---: | ---: | ---: |",
222
+ ...summary.rows.map((r) => line(r)),
223
+ line(summary.total, true)
224
+ ].join(`
225
+ `);
226
+ }
227
+ var RUN_SUMMARY_USAGE = "usage: run-summary --progress [<label>=]<file> [--progress ...] [--transcript <path>] [--since <iso>] [--markdown]";
228
+ function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot) {
229
+ let transcript;
230
+ let since;
231
+ let markdown = false;
232
+ const progressArgs = [];
233
+ for (let i = 0;i < argv.length; i++) {
234
+ const arg = argv[i];
235
+ if (arg === "--transcript" && argv[i + 1])
236
+ transcript = argv[++i];
237
+ else if (arg === "--progress" && argv[i + 1])
238
+ progressArgs.push(argv[++i]);
239
+ else if (arg === "--since" && argv[i + 1])
240
+ since = argv[++i];
241
+ else if (arg === "--markdown")
242
+ markdown = true;
243
+ else if (arg === "--spur-bin" && argv[i + 1])
244
+ i++;
245
+ else {
246
+ process.stderr.write(`${RUN_SUMMARY_USAGE}
247
+ `);
248
+ return 2;
249
+ }
250
+ }
251
+ if (!progressArgs.length) {
252
+ process.stderr.write(`${RUN_SUMMARY_USAGE}
253
+ `);
254
+ return 2;
255
+ }
256
+ const resolved = resolveTranscript(env, projectsRoot, transcript);
257
+ if (!resolved.ok) {
258
+ const out = markdown ? `Execution summary: n/a (${resolved.reason})` : JSON.stringify({ available: false, reason: resolved.reason });
259
+ write(`${out}
260
+ `);
261
+ return 0;
262
+ }
263
+ try {
264
+ const inputs = progressArgs.map((arg) => {
265
+ const eq = arg.indexOf("=");
266
+ const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
267
+ return { label, progress: JSON.parse(readFileSync(file, "utf8")) };
268
+ });
269
+ const summary = buildRunSummary(readFileSync(resolved.path, "utf8").split(`
270
+ `), inputs, since);
271
+ write(`${markdown ? renderSummaryMarkdown(summary) : JSON.stringify({ ...summary, transcript: resolved.path })}
272
+ `);
273
+ return 0;
274
+ } catch (error) {
275
+ process.stderr.write(`run-summary: ${error.message}
276
+ `);
277
+ return 2;
278
+ }
279
+ }
280
+ process.exit(main(process.argv.slice(2)));
281
+ export {
282
+ renderSummaryMarkdown,
283
+ main,
284
+ buildRunSummary,
285
+ RUN_SUMMARY_USAGE
286
+ };
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * run-summary — the measured execution summary `/sp:dev-run` and `/sp:dev-runall` print by default
4
+ * (`--no-summary` skips it). Inputs: one or more `spur workflow progress <run-id> --json` files
5
+ * (`--progress [<label>=]<file>`) and the host Claude Code transcript (resolved like session-timeline,
6
+ * or `--transcript`). One file → one row per state visit; several → one row per run. Each row is
7
+ * windowed by its attempt timestamps and slices the transcript: work, operator wait, tool calls and
8
+ * tokens rendered `<total> / <non-cached>`. A driver-overhead row makes rows sum to the total;
9
+ * `--since <iso>` starts the total at the command's invocation. A window with no host transcript
10
+ * record (a subprocess stage) reads `n/a`, never 0. `--markdown` prints the table. Reporting only:
11
+ * no transcript → `{available:false}` (or one `n/a` line), exit 0. Node builtins only.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { getEnvVars } from '../lib/env';
15
+ import {
16
+ accumulate,
17
+ formatDuration,
18
+ formatTokenSplit,
19
+ newAcc,
20
+ parseRows,
21
+ promptText,
22
+ resolveTranscript,
23
+ type Span,
24
+ sumSpans,
25
+ sumTokens,
26
+ type Tokens,
27
+ zeroTokens,
28
+ } from '../lib/transcript';
29
+
30
+ interface ProgressAttempt {
31
+ startedAt?: string;
32
+ completedAt?: string;
33
+ }
34
+
35
+ /** The subset of `spur workflow progress <run-id> --json` the summary reads. */
36
+ export interface Progress {
37
+ runId: string;
38
+ workflow?: string;
39
+ status?: string;
40
+ states?: { state: string; visit?: number; status?: string; actions?: { attempts?: ProgressAttempt[] }[] }[];
41
+ }
42
+
43
+ /** A stage row; `toolCalls`/`tokens` are null when the window holds no host transcript record. */
44
+ export interface SummaryRow {
45
+ stage: string;
46
+ status: string;
47
+ workMs: number;
48
+ waitMs: number;
49
+ toolCalls: number | null;
50
+ tokens: Tokens | null;
51
+ time: string;
52
+ wait: string;
53
+ token: string;
54
+ }
55
+
56
+ export interface RunSummary {
57
+ available: true;
58
+ transcript?: string;
59
+ rows: SummaryRow[];
60
+ total: SummaryRow;
61
+ skippedLines: number;
62
+ }
63
+
64
+ interface Window {
65
+ stage: string;
66
+ status: string;
67
+ start: number;
68
+ end: number;
69
+ }
70
+
71
+ function attemptWindow(attempts: ProgressAttempt[]): [number, number] | undefined {
72
+ const starts = attempts.map((a) => Date.parse(a.startedAt ?? '')).filter((n) => !Number.isNaN(n));
73
+ const ends = attempts.map((a) => Date.parse(a.completedAt ?? '')).filter((n) => !Number.isNaN(n));
74
+ return starts.length && ends.length ? [Math.min(...starts), Math.max(...ends)] : undefined;
75
+ }
76
+
77
+ function summaryRow(stage: string, status: string, span: Span, measured: boolean): SummaryRow {
78
+ return {
79
+ stage,
80
+ status,
81
+ workMs: span.workMs,
82
+ waitMs: span.waitMs,
83
+ toolCalls: measured ? span.toolCalls : null,
84
+ tokens: measured ? span.tokens : null,
85
+ time: formatDuration(span.workMs),
86
+ wait: formatDuration(span.waitMs),
87
+ token: formatTokenSplit(measured ? span.tokens : null),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * dev-run / dev-runall execution summary. One progress file → one row per state visit; several →
93
+ * one row per run. Each window spans its first attempt start → last completion (inclusive) and
94
+ * slices the host transcript; work = window − AskUserQuestion answer time.
95
+ */
96
+ export function buildRunSummary(
97
+ lines: string[],
98
+ inputs: { label?: string; progress: Progress }[],
99
+ since?: string,
100
+ ): RunSummary {
101
+ const windows: Window[] = [];
102
+ for (const { label, progress } of inputs) {
103
+ const states = (progress.states ?? []).flatMap((s) => {
104
+ const w = attemptWindow((s.actions ?? []).flatMap((a) => a.attempts ?? []));
105
+ return w ? [{ s, w }] : [];
106
+ });
107
+ if (!states.length) throw new Error(`progress for run ${progress.runId} has no timed attempts`);
108
+ if (inputs.length === 1) {
109
+ for (const { s, w } of states) {
110
+ const stage = (s.visit ?? 1) > 1 ? `${s.state} (visit ${s.visit})` : s.state;
111
+ windows.push({ stage, status: s.status ?? '', start: w[0], end: w[1] });
112
+ }
113
+ } else {
114
+ const stage = label ?? `${progress.workflow ?? 'run'} ${progress.runId.slice(0, 8)}`;
115
+ const start = Math.min(...states.map(({ w }) => w[0]));
116
+ windows.push({
117
+ stage,
118
+ status: progress.status ?? '',
119
+ start,
120
+ end: Math.max(...states.map(({ w }) => w[1])),
121
+ });
122
+ }
123
+ }
124
+ windows.sort((a, b) => a.start - b.start);
125
+
126
+ const { rows, skippedLines } = parseRows(lines);
127
+ const measure = (start: number, end: number): { span: Span; measured: boolean } => {
128
+ const acc = newAcc(start);
129
+ for (const [row, ts] of rows)
130
+ if (ts >= start && ts <= end && promptText(row) === undefined) accumulate(acc, row, ts);
131
+ const span = {
132
+ workMs: end - start - acc.askMs,
133
+ waitMs: acc.askMs,
134
+ toolCalls: acc.toolIds.size,
135
+ tokens: sumTokens([...acc.messages.values()]),
136
+ };
137
+ return { span, measured: acc.toolIds.size > 0 || acc.messages.size > 0 };
138
+ };
139
+
140
+ const sinceMs = since ? Date.parse(since) : Number.NaN;
141
+ const totalStart = Number.isNaN(sinceMs) ? Math.min(...windows.map((w) => w.start)) : sinceMs;
142
+ let totalEnd = Math.max(...windows.map((w) => w.end));
143
+ // With --since the total runs to the latest host activity, so setup/close work lands in overhead.
144
+ if (!Number.isNaN(sinceMs)) for (const [, ts] of rows) if (ts >= sinceMs) totalEnd = Math.max(totalEnd, ts);
145
+
146
+ const stageRows = windows.map((w) => {
147
+ const { span, measured } = measure(w.start, w.end);
148
+ return summaryRow(w.stage, w.status, span, measured);
149
+ });
150
+ const totalMeasure = measure(totalStart, totalEnd);
151
+ const statuses = [...new Set(inputs.map((i) => i.progress.status ?? ''))].join('/');
152
+ const total = summaryRow('Total', statuses, totalMeasure.span, totalMeasure.measured);
153
+
154
+ // Parallel runs overlap, so their rows cannot partition the total: no overhead row then.
155
+ const overlaps = windows.some((w, i) => i > 0 && w.start < (windows[i - 1]?.end ?? 0));
156
+ const sum = sumSpans(
157
+ stageRows.map((r) => ({ ...r, toolCalls: r.toolCalls ?? 0, tokens: r.tokens ?? zeroTokens() })),
158
+ );
159
+ const overhead: Span = {
160
+ workMs: total.workMs - sum.workMs,
161
+ waitMs: total.waitMs - sum.waitMs,
162
+ toolCalls: (total.toolCalls ?? 0) - sum.toolCalls,
163
+ tokens: Object.fromEntries(
164
+ (Object.keys(sum.tokens) as (keyof Tokens)[]).map((k) => [k, (total.tokens?.[k] ?? 0) - sum.tokens[k]]),
165
+ ) as Tokens,
166
+ };
167
+ if (!overlaps && overhead.workMs > 0)
168
+ stageRows.push(summaryRow('driver overhead', '', overhead, totalMeasure.measured));
169
+
170
+ return { available: true, rows: stageRows, total, skippedLines };
171
+ }
172
+
173
+ export function renderSummaryMarkdown(summary: RunSummary): string {
174
+ const line = (r: SummaryRow, bold = false) => {
175
+ const stage = bold ? `**${r.stage}**` : r.stage;
176
+ return `| ${stage} | ${r.status} | ${r.time} | ${r.wait} | ${r.toolCalls ?? 'n/a'} | ${r.token} |`;
177
+ };
178
+ return [
179
+ '| Stage | Status | Time | Wait | Tool calls | Token (total / non-cached) |',
180
+ '| --- | --- | ---: | ---: | ---: | ---: |',
181
+ ...summary.rows.map((r) => line(r)),
182
+ line(summary.total, true),
183
+ ].join('\n');
184
+ }
185
+
186
+ export const RUN_SUMMARY_USAGE =
187
+ 'usage: run-summary --progress [<label>=]<file> [--progress ...] [--transcript <path>] [--since <iso>] [--markdown]';
188
+
189
+ export function main(
190
+ argv: string[],
191
+ env: Record<string, string | undefined> = getEnvVars(),
192
+ write: (s: string) => void = (s) => process.stdout.write(s),
193
+ projectsRoot?: string,
194
+ ): number {
195
+ let transcript: string | undefined;
196
+ let since: string | undefined;
197
+ let markdown = false;
198
+ const progressArgs: string[] = [];
199
+ for (let i = 0; i < argv.length; i++) {
200
+ const arg = argv[i];
201
+ if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
202
+ else if (arg === '--progress' && argv[i + 1]) progressArgs.push(argv[++i] as string);
203
+ else if (arg === '--since' && argv[i + 1]) since = argv[++i];
204
+ else if (arg === '--markdown') markdown = true;
205
+ else if (arg === '--spur-bin' && argv[i + 1])
206
+ i++; // 0482 R2: accepted, unused (no spur calls)
207
+ else {
208
+ process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
209
+ return 2;
210
+ }
211
+ }
212
+ if (!progressArgs.length) {
213
+ process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
214
+ return 2;
215
+ }
216
+ const resolved = resolveTranscript(env, projectsRoot, transcript);
217
+ if (!resolved.ok) {
218
+ const out = markdown
219
+ ? `Execution summary: n/a (${resolved.reason})`
220
+ : JSON.stringify({ available: false, reason: resolved.reason });
221
+ write(`${out}\n`);
222
+ return 0;
223
+ }
224
+ try {
225
+ const inputs = progressArgs.map((arg) => {
226
+ const eq = arg.indexOf('=');
227
+ const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
228
+ return { label, progress: JSON.parse(readFileSync(file, 'utf8')) as Progress };
229
+ });
230
+ const summary = buildRunSummary(readFileSync(resolved.path, 'utf8').split('\n'), inputs, since);
231
+ write(
232
+ `${markdown ? renderSummaryMarkdown(summary) : JSON.stringify({ ...summary, transcript: resolved.path })}\n`,
233
+ );
234
+ return 0;
235
+ } catch (error) {
236
+ process.stderr.write(`run-summary: ${(error as Error).message}\n`);
237
+ return 2;
238
+ }
239
+ }
240
+
241
+ if (import.meta.main) process.exit(main(process.argv.slice(2)));