@gobing-ai/spur 0.3.98 → 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 (120) 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 +15 -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/templates/task/brainstorm.md +1 -1
  10. package/config/templates/task/feature-impl.md +1 -1
  11. package/config/templates/task/issue.md +1 -1
  12. package/config/templates/task/meta.md +1 -1
  13. package/config/templates/task/review.md +1 -1
  14. package/config/templates/task/standard.md +1 -1
  15. package/config/workflows/task-pipeline.yaml +178 -18
  16. package/package.json +9 -9
  17. package/plugins/sp/README.md +14 -1
  18. package/plugins/sp/agents/super-reviewer.md +25 -11
  19. package/plugins/sp/commands/dev-changelog.md +5 -4
  20. package/plugins/sp/commands/dev-review-session.md +12 -7
  21. package/plugins/sp/commands/dev-run.md +5 -2
  22. package/plugins/sp/commands/dev-runall.md +5 -2
  23. package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
  24. package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
  25. package/plugins/sp/hooks/hooks.json +45 -1
  26. package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
  27. package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
  28. package/plugins/sp/lib/idea-handoff.generated.mjs +68 -10
  29. package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
  30. package/plugins/sp/lib/inline-run.generated.mjs +83 -20
  31. package/plugins/sp/lib/residual-scan.generated.d.mts +5 -0
  32. package/plugins/sp/lib/residual-scan.generated.mjs +45 -5
  33. package/plugins/sp/lib/transcript.ts +160 -0
  34. package/plugins/sp/plugin.json +1 -1
  35. package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
  36. package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
  37. package/plugins/sp/scripts/inline-run-setup.ts +25 -31
  38. package/plugins/sp/scripts/persist-out-check.mjs +164 -0
  39. package/plugins/sp/scripts/persist-out-check.ts +185 -0
  40. package/plugins/sp/scripts/residual-scan.mjs +52 -15
  41. package/plugins/sp/scripts/residual-scan.ts +11 -11
  42. package/plugins/sp/scripts/run-summary.mjs +286 -0
  43. package/plugins/sp/scripts/run-summary.ts +241 -0
  44. package/plugins/sp/scripts/session-timeline.mjs +234 -0
  45. package/plugins/sp/scripts/session-timeline.ts +152 -0
  46. package/plugins/sp/scripts/task-diffstat.mjs +17 -5
  47. package/plugins/sp/scripts/task-diffstat.ts +25 -4
  48. package/plugins/sp/skills/code-verification/SKILL.md +8 -0
  49. package/plugins/sp/skills/code-verification/references/verdict-schema.md +17 -1
  50. package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
  51. package/plugins/sp/skills/session-review/SKILL.md +46 -24
  52. package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
  53. package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
  54. package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
  55. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
  56. package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
  57. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +40 -7
  58. package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
  59. package/plugins/sp/skills/spur-dev/references/execution-batch.md +278 -42
  60. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +17 -1
  61. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
  62. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +139 -10
  63. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
  64. package/schemas/decision-catalog.schema.json +226 -0
  65. package/schemas/spur-config.schema.json +25 -0
  66. package/spur.js +8856 -2564
  67. package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
  68. package/web/_astro/{BoardApp.Dflq5a8x.js → BoardApp.VeoxxLBy.js} +1 -1
  69. package/web/_astro/{TaskDetail.CK7oXddP.js → TaskDetail.LAyR_Pvn.js} +1 -1
  70. package/web/_astro/{arc.BIqkDATc.js → arc.f4e0OYLg.js} +1 -1
  71. package/web/_astro/{architectureDiagram-3BPJPVTR.D0B3SvWv.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
  72. package/web/_astro/{blockDiagram-GPEHLZMM.C2YyKQmL.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
  73. package/web/_astro/{c4Diagram-AAUBKEIU.DmtTYrt_.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
  74. package/web/_astro/channel.CUFdtWhm.js +1 -0
  75. package/web/_astro/{chunk-2J33WTMH.HsRXbh7l.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
  76. package/web/_astro/{chunk-4BX2VUAB.obE_P-R3.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
  77. package/web/_astro/{chunk-55IACEB6.CjWTF5mz.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
  78. package/web/_astro/{chunk-727SXJPM.DMo799Ax.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
  79. package/web/_astro/{chunk-AQP2D5EJ.-cE6JOKZ.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
  80. package/web/_astro/{chunk-FMBD7UC4.D6Rw6HIk.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
  81. package/web/_astro/{chunk-ND2GUHAM.d5rBD73Z.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
  82. package/web/_astro/{chunk-QZHKN3VN.r3ox40Q3.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
  83. package/web/_astro/{classDiagram-4FO5ZUOK.CGGbvjjJ.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
  84. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
  85. package/web/_astro/{cose-bilkent-S5V4N54A.CmKtPd4l.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
  86. package/web/_astro/{cynefin-OW5HDTMX.QNg5GXs8.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
  87. package/web/_astro/{dagre-BM42HDAG.B1SabsjC.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
  88. package/web/_astro/{diagram-2AECGRRQ.RtDbNj89.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
  89. package/web/_astro/{diagram-5GNKFQAL.IzZpuM-Y.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
  90. package/web/_astro/{diagram-KO2AKTUF.DPTF_fgv.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
  91. package/web/_astro/{diagram-LMA3HP47.CCyza39P.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
  92. package/web/_astro/{diagram-OG6HWLK6.BXAi8zcZ.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
  93. package/web/_astro/{erDiagram-TEJ5UH35.DUDX7_MR.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
  94. package/web/_astro/{flowDiagram-I6XJVG4X.C63QTh3N.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
  95. package/web/_astro/{ganttDiagram-6RSMTGT7.CnlZJSwG.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
  96. package/web/_astro/{gitGraphDiagram-PVQCEYII.CTqIrOPC.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
  97. package/web/_astro/index.Dg_3bOaJ.css +1 -0
  98. package/web/_astro/{infoDiagram-5YYISTIA.BYMiDFh9.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
  99. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CRiQmI-z.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
  100. package/web/_astro/{journeyDiagram-JHISSGLW.YQgWBsEU.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
  101. package/web/_astro/{kanban-definition-UN3LZRKU.1ECnrrK1.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
  102. package/web/_astro/{linear.DzVA71yT.js → linear.DguUUe6V.js} +1 -1
  103. package/web/_astro/{mermaid.core.BK7P2Sr_.js → mermaid.core.CvgfmqVr.js} +4 -4
  104. package/web/_astro/{mindmap-definition-RKZ34NQL.BvXyWZK7.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
  105. package/web/_astro/{pieDiagram-4H26LBE5.BiXk1TW3.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
  106. package/web/_astro/{quadrantDiagram-W4KKPZXB.krvBH8lh.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
  107. package/web/_astro/{requirementDiagram-4Y6WPE33.ih1gL25c.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
  108. package/web/_astro/{sankeyDiagram-5OEKKPKP.CpUxkv4u.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
  109. package/web/_astro/{sequenceDiagram-3UESZ5HK.clAfeN7y.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
  110. package/web/_astro/{stateDiagram-AJRCARHV.DpXWb8-m.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
  111. package/web/_astro/{stateDiagram-v2-BHNVJYJU.ClZb9GIH.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
  112. package/web/_astro/{timeline-definition-PNZ67QCA.BLcX5dh7.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
  113. package/web/_astro/{vennDiagram-CIIHVFJN.oGgpKMpx.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
  114. package/web/_astro/{wardleyDiagram-YWT4CUSO.BPHtMtt-.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
  115. package/web/_astro/{xychartDiagram-2RQKCTM6.BNZOPYSk.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
  116. package/web/board-runtime.json +1 -1
  117. package/web/index.html +2 -2
  118. package/web/_astro/BoardApp.CYbe-OTb.js +0 -322
  119. package/web/_astro/channel.JuKVyuhZ.js +0 -1
  120. package/web/_astro/index.CbNz17Sx.css +0 -1
@@ -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)));
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/session-timeline.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/session-timeline.ts
129
+ var PROMPT_EXCERPT = 80;
130
+ var render = (span) => ({
131
+ ...span,
132
+ work: formatDuration(span.workMs),
133
+ wait: formatDuration(span.waitMs),
134
+ token: formatTokenSplit(span.tokens)
135
+ });
136
+ function parseGroups(spec, count) {
137
+ let next = 1;
138
+ return spec.split(",").map((part) => {
139
+ const [a = Number.NaN, b = a] = part.trim().split("-").map(Number);
140
+ if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
141
+ throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
142
+ }
143
+ next = b + 1;
144
+ return [a, b];
145
+ });
146
+ }
147
+ function buildTimeline(lines, group) {
148
+ const open = [];
149
+ const { rows, skippedLines } = parseRows(lines);
150
+ for (const [row, ts] of rows) {
151
+ const prompt = promptText(row);
152
+ if (prompt !== undefined) {
153
+ open.push({ start: ts, prompt, ...newAcc(ts) });
154
+ continue;
155
+ }
156
+ const seg = open.at(-1);
157
+ if (seg)
158
+ accumulate(seg, row, ts);
159
+ }
160
+ const segments = open.map((seg, i) => {
161
+ const next = open[i + 1]?.start;
162
+ const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
163
+ const oneLine = seg.prompt.replace(/\s+/g, " ").trim();
164
+ return render({
165
+ index: i + 1,
166
+ start: new Date(seg.start).toISOString(),
167
+ prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}\u2026` : oneLine,
168
+ workMs: seg.last - seg.start - seg.askMs,
169
+ waitMs: idle + seg.askMs,
170
+ toolCalls: seg.toolIds.size,
171
+ tokens: sumTokens([...seg.messages.values()])
172
+ });
173
+ });
174
+ const first = open[0];
175
+ const last = open.at(-1);
176
+ const elapsedMs = first && last ? last.last - first.start : 0;
177
+ const timeline = {
178
+ available: true,
179
+ segments,
180
+ totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
181
+ skippedLines
182
+ };
183
+ if (group) {
184
+ timeline.stages = parseGroups(group, segments.length).map(([a, b]) => render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }));
185
+ }
186
+ return timeline;
187
+ }
188
+ var SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
189
+ function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot, writeErr = (s) => process.stderr.write(s)) {
190
+ let transcript;
191
+ let group;
192
+ for (let i = 0;i < argv.length; i++) {
193
+ const arg = argv[i];
194
+ if (arg === "--transcript" && argv[i + 1])
195
+ transcript = argv[++i];
196
+ else if (arg === "--group" && argv[i + 1])
197
+ group = argv[++i];
198
+ else if (arg === "--spur-bin" && argv[i + 1])
199
+ i++;
200
+ else {
201
+ writeErr(`${SESSION_TIMELINE_USAGE}
202
+ `);
203
+ return 2;
204
+ }
205
+ }
206
+ const resolved = resolveTranscript(env, projectsRoot, transcript);
207
+ if (!resolved.ok) {
208
+ write(`${JSON.stringify({ available: false, reason: resolved.reason })}
209
+ `);
210
+ return 0;
211
+ }
212
+ try {
213
+ const timeline = buildTimeline(readFileSync(resolved.path, "utf8").split(`
214
+ `), group);
215
+ write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}
216
+ `);
217
+ return 0;
218
+ } catch (error) {
219
+ writeErr(`session-timeline: ${error.message}
220
+ `);
221
+ return 2;
222
+ }
223
+ }
224
+ process.exit(main(process.argv.slice(2)));
225
+ export {
226
+ resolveTranscript,
227
+ parseGroups,
228
+ main,
229
+ formatTokens,
230
+ formatTokenSplit,
231
+ formatDuration,
232
+ buildTimeline,
233
+ SESSION_TIMELINE_USAGE
234
+ };
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * session-timeline — measured time and token evidence for the sp:session-review Time breakdown.
4
+ * The visible conversation carries no timestamps or usage (so the table read `n/a`); the host
5
+ * transcript carries both. Reads the active Claude Code JSONL read-only
6
+ * (`~/.claude/projects/<slug>/<CLAUDE_CODE_SESSION_ID>.jsonl`, or `--transcript`), one segment per
7
+ * operator prompt: work = prompt → last activity; wait = idle until the next prompt plus time spent
8
+ * answering an AskUserQuestion; tokens counted once per message id and rendered `<total> /
9
+ * <non-cached>`. `--group "1-3,4"` sums segments into stages. No transcript → `{available:false}`,
10
+ * exit 0, and the review renders `n/a` instead of guessing. Node builtins only (standalone plugin).
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { getEnvVars } from '../lib/env';
14
+ import {
15
+ type Acc,
16
+ accumulate,
17
+ formatDuration,
18
+ formatTokenSplit,
19
+ newAcc,
20
+ parseRows,
21
+ promptText,
22
+ resolveTranscript,
23
+ type Span,
24
+ sumSpans,
25
+ sumTokens,
26
+ } from '../lib/transcript';
27
+
28
+ export { formatDuration, formatTokenSplit, formatTokens, resolveTranscript } from '../lib/transcript';
29
+
30
+ /** A span plus its `M:SS` / `H:MM:SS` and token renderings, so the review table needs no arithmetic. */
31
+ export type Rendered<T> = T & { work: string; wait: string; token: string };
32
+
33
+ export interface Timeline {
34
+ available: true;
35
+ transcript?: string;
36
+ segments: Rendered<Span & { index: number; start: string; prompt: string }>[];
37
+ stages?: Rendered<Span & { segments: string }>[];
38
+ totals: Rendered<Span & { elapsedMs: number; elapsed: string }>;
39
+ skippedLines: number;
40
+ }
41
+
42
+ const PROMPT_EXCERPT = 80;
43
+
44
+ const render = <T extends Span>(span: T): Rendered<T> => ({
45
+ ...span,
46
+ work: formatDuration(span.workMs),
47
+ wait: formatDuration(span.waitMs),
48
+ token: formatTokenSplit(span.tokens),
49
+ });
50
+
51
+ /** "1-3,4" → [[1,3],[4,4]]; 1-based, ascending, in range, non-overlapping. */
52
+ export function parseGroups(spec: string, count: number): [number, number][] {
53
+ let next = 1;
54
+ return spec.split(',').map((part) => {
55
+ const [a = Number.NaN, b = a] = part.trim().split('-').map(Number);
56
+ if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
57
+ throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
58
+ }
59
+ next = b + 1;
60
+ return [a, b] as [number, number];
61
+ });
62
+ }
63
+
64
+ interface Open extends Acc {
65
+ start: number;
66
+ prompt: string;
67
+ }
68
+
69
+ export function buildTimeline(lines: string[], group?: string): Timeline {
70
+ const open: Open[] = [];
71
+ const { rows, skippedLines } = parseRows(lines);
72
+
73
+ for (const [row, ts] of rows) {
74
+ const prompt = promptText(row);
75
+ if (prompt !== undefined) {
76
+ open.push({ start: ts, prompt, ...newAcc(ts) });
77
+ continue;
78
+ }
79
+ const seg = open.at(-1);
80
+ if (seg) accumulate(seg, row, ts); // rows before the first prompt are session preamble
81
+ }
82
+
83
+ const segments = open.map((seg, i) => {
84
+ const next = open[i + 1]?.start;
85
+ const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
86
+ const oneLine = seg.prompt.replace(/\s+/g, ' ').trim();
87
+ return render({
88
+ index: i + 1,
89
+ start: new Date(seg.start).toISOString(),
90
+ prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}…` : oneLine,
91
+ workMs: seg.last - seg.start - seg.askMs,
92
+ waitMs: idle + seg.askMs,
93
+ toolCalls: seg.toolIds.size,
94
+ tokens: sumTokens([...seg.messages.values()]),
95
+ });
96
+ });
97
+
98
+ const first = open[0];
99
+ const last = open.at(-1);
100
+ const elapsedMs = first && last ? last.last - first.start : 0;
101
+ const timeline: Timeline = {
102
+ available: true,
103
+ segments,
104
+ totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
105
+ skippedLines,
106
+ };
107
+ if (group) {
108
+ timeline.stages = parseGroups(group, segments.length).map(([a, b]) =>
109
+ render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }),
110
+ );
111
+ }
112
+ return timeline;
113
+ }
114
+
115
+ export const SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
116
+
117
+ export function main(
118
+ argv: string[],
119
+ env: Record<string, string | undefined> = getEnvVars(),
120
+ write: (s: string) => void = (s) => process.stdout.write(s),
121
+ projectsRoot?: string,
122
+ writeErr: (s: string) => void = (s) => process.stderr.write(s),
123
+ ): number {
124
+ let transcript: string | undefined;
125
+ let group: string | undefined;
126
+ for (let i = 0; i < argv.length; i++) {
127
+ const arg = argv[i];
128
+ if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
129
+ else if (arg === '--group' && argv[i + 1]) group = argv[++i];
130
+ else if (arg === '--spur-bin' && argv[i + 1])
131
+ i++; // 0482 R2: accepted, unused (no spur calls)
132
+ else {
133
+ writeErr(`${SESSION_TIMELINE_USAGE}\n`);
134
+ return 2;
135
+ }
136
+ }
137
+ const resolved = resolveTranscript(env, projectsRoot, transcript);
138
+ if (!resolved.ok) {
139
+ write(`${JSON.stringify({ available: false, reason: resolved.reason })}\n`);
140
+ return 0;
141
+ }
142
+ try {
143
+ const timeline = buildTimeline(readFileSync(resolved.path, 'utf8').split('\n'), group);
144
+ write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}\n`);
145
+ return 0;
146
+ } catch (error) {
147
+ writeErr(`session-timeline: ${(error as Error).message}\n`);
148
+ return 2;
149
+ }
150
+ }
151
+
152
+ if (import.meta.main) process.exit(main(process.argv.slice(2)));
@@ -3,7 +3,7 @@
3
3
 
4
4
  // plugins/sp/scripts/task-diffstat.ts
5
5
  import { spawnSync } from "child_process";
6
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
6
+ import { fstatSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
7
7
  import { join } from "path";
8
8
 
9
9
  // plugins/sp/lib/env.ts
@@ -85,10 +85,14 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
85
85
  process.stderr.write(`task-diffstat: ${reason} \u2014 failing safe (sensitive)
86
86
  `);
87
87
  const row2 = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
88
- writeFileSync(abs(relResult), `${JSON.stringify(row2)}
89
- `);
88
+ writeArtifact(abs(relResult), row2);
90
89
  return { ...row2, resultFile: relResult, exitCode: 0 };
91
90
  };
91
+ let stdoutIsArtifact = false;
92
+ try {
93
+ const [out, art] = [fstatSync(1), statSync(abs(relResult))];
94
+ stdoutIsArtifact = out.dev === art.dev && out.ino === art.ino;
95
+ } catch {}
92
96
  let base = "";
93
97
  try {
94
98
  base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), "utf8").trim();
@@ -125,9 +129,17 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
125
129
  const all = [...paths].filter((p) => p.length > 0).sort();
126
130
  const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
127
131
  const row = { files: all.length, insertions, deletions, paths: all, sensitive };
128
- writeFileSync(abs(relResult), `${JSON.stringify(row)}
132
+ writeArtifact(abs(relResult), row);
133
+ if (stdoutIsArtifact)
134
+ process.stderr.write(`task-diffstat: stdout redirected onto the artifact \u2014 kept; redirect stdout elsewhere
135
+ `);
136
+ return { ...row, resultFile: relResult, exitCode: stdoutIsArtifact ? 1 : 0 };
137
+ }
138
+ function writeArtifact(absPath, row) {
139
+ const tmp = `${absPath}.tmp`;
140
+ writeFileSync(tmp, `${JSON.stringify(row)}
129
141
  `);
130
- return { ...row, resultFile: relResult, exitCode: 0 };
142
+ renameSync(tmp, absPath);
131
143
  }
132
144
  var TASK_DIFFSTAT_USAGE = "usage: task-diffstat (env: wbs)";
133
145
  function main(argv, env = getEnvVars(), options = {}) {