@gobing-ai/spur 0.4.0 → 0.4.1

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 (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/decisions/gates.yaml +36 -0
  3. package/config/decisions/history-anatomy.yaml +35 -0
  4. package/config/decisions/idea-pipeline.yaml +38 -0
  5. package/config/plugin-scripts.json +15 -0
  6. package/config/rules/docs/sourced-api-claims.yaml +60 -0
  7. package/config/rules/recommended-pre-check.yaml +8 -3
  8. package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
  9. package/config/script-placement-baseline.json +2 -2
  10. package/config/workflows/feature-lifecycle.yaml +9 -2
  11. package/config/workflows/history-anatomy.yaml +22 -0
  12. package/config/workflows/idea-pipeline.yaml +75 -0
  13. package/config/workflows/task-pipeline.yaml +216 -57
  14. package/config/workflows/wrapup-pipeline.yaml +106 -13
  15. package/package.json +9 -9
  16. package/plugins/sp/README.md +11 -1
  17. package/plugins/sp/agents/super-planner.md +11 -0
  18. package/plugins/sp/agents/super-reviewer.md +14 -0
  19. package/plugins/sp/bunfig.toml +8 -0
  20. package/plugins/sp/commands/dev-brainstorm.md +29 -16
  21. package/plugins/sp/commands/dev-find-way.md +80 -0
  22. package/plugins/sp/commands/dev-idea.md +6 -0
  23. package/plugins/sp/commands/dev-parallel.md +8 -2
  24. package/plugins/sp/commands/dev-plan.md +6 -0
  25. package/plugins/sp/commands/dev-review-session.md +3 -1
  26. package/plugins/sp/commands/dev-run.md +12 -2
  27. package/plugins/sp/commands/dev-runall.md +18 -10
  28. package/plugins/sp/hooks/context-session-start.ts +1 -1
  29. package/plugins/sp/lib/idea-handoff.generated.mjs +41 -5
  30. package/plugins/sp/lib/inline-run-app.ts +28 -0
  31. package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
  32. package/plugins/sp/lib/inline-run.generated.mjs +333 -45
  33. package/plugins/sp/lib/plan-projection.generated.d.mts +28 -0
  34. package/plugins/sp/lib/plan-projection.generated.mjs +255 -0
  35. package/plugins/sp/lib/quality-gate.generated.d.mts +47 -1
  36. package/plugins/sp/lib/quality-gate.generated.mjs +30489 -82
  37. package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
  38. package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
  39. package/plugins/sp/lib/run-summary-core.ts +561 -0
  40. package/plugins/sp/lib/transcript.ts +121 -6
  41. package/plugins/sp/plugin.json +1 -1
  42. package/plugins/sp/references/roles.md +1 -1
  43. package/plugins/sp/scripts/batch-plan.mjs +335 -0
  44. package/plugins/sp/scripts/batch-plan.ts +100 -0
  45. package/plugins/sp/scripts/batch-preflight.mjs +51 -3
  46. package/plugins/sp/scripts/batch-preflight.ts +72 -1
  47. package/plugins/sp/scripts/commit-guard.mjs +216 -0
  48. package/plugins/sp/scripts/commit-guard.ts +249 -0
  49. package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
  50. package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
  51. package/plugins/sp/scripts/inline-run-setup.mjs +551 -80
  52. package/plugins/sp/scripts/inline-run-setup.ts +52 -78
  53. package/plugins/sp/scripts/persist-out-check.mjs +90 -41
  54. package/plugins/sp/scripts/persist-out-check.ts +112 -57
  55. package/plugins/sp/scripts/quality-gate.mjs +30407 -73
  56. package/plugins/sp/scripts/quality-gate.ts +21 -5
  57. package/plugins/sp/scripts/residual-scan.mjs +86 -31
  58. package/plugins/sp/scripts/residual-scan.ts +28 -30
  59. package/plugins/sp/scripts/run-summary.mjs +421 -14
  60. package/plugins/sp/scripts/run-summary.ts +41 -177
  61. package/plugins/sp/scripts/session-timeline.mjs +109 -12
  62. package/plugins/sp/scripts/session-timeline.ts +71 -19
  63. package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
  64. package/plugins/sp/scripts/wrapup-steps.ts +203 -1
  65. package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
  66. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  67. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
  68. package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
  69. package/plugins/sp/skills/code-verification/SKILL.md +3 -4
  70. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
  71. package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
  72. package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
  73. package/plugins/sp/skills/session-review/SKILL.md +38 -7
  74. package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
  75. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
  76. package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
  77. package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
  78. package/plugins/sp/skills/spur-cli/references/decision.md +7 -6
  79. package/plugins/sp/skills/spur-cli/references/message.md +7 -7
  80. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
  81. package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
  82. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
  83. package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
  84. package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
  85. package/plugins/sp/skills/spur-dev/SKILL.md +18 -14
  86. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +6 -3
  87. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
  88. package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -16
  89. package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
  90. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +194 -0
  91. package/plugins/sp/skills/spur-dev/references/execution-batch.md +113 -1020
  92. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +130 -0
  93. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
  94. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
  95. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
  96. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +35 -5
  97. package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
  98. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +343 -163
  99. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
  100. package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
  101. package/schemas/state-machine-workflow.schema.json +23 -0
  102. package/spur.js +20728 -16630
  103. package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.D66k_s_T.js} +1 -1
  104. package/web/_astro/{BoardApp.Bzr4OXrW.js → BoardApp.D6sjAJr_.js} +173 -173
  105. package/web/_astro/{TaskDetail.LAyR_Pvn.js → TaskDetail.BynLnMNc.js} +1 -1
  106. package/web/_astro/{arc.f4e0OYLg.js → arc._BUadrdH.js} +1 -1
  107. package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.Dh7xS1Pp.js} +1 -1
  108. package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.DFDVpfXm.js} +1 -1
  109. package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.V7_3_glh.js} +1 -1
  110. package/web/_astro/channel.DZVfto-r.js +1 -0
  111. package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.De1yx7Jz.js} +1 -1
  112. package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.BDhHFuxx.js} +1 -1
  113. package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.DDLkWnmC.js} +1 -1
  114. package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.CwfiLnA7.js} +1 -1
  115. package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.CtGDmUnM.js} +1 -1
  116. package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.BOLjpw73.js} +1 -1
  117. package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BDhQls6O.js} +1 -1
  118. package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.76qBynDF.js} +1 -1
  119. package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.N0mpPHT0.js} +1 -1
  120. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.N0mpPHT0.js} +1 -1
  121. package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.CKknFfjB.js} +1 -1
  122. package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.Cw3ZWBAh.js} +1 -1
  123. package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.D3yIKCRD.js} +1 -1
  124. package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.D51dO83Z.js} +1 -1
  125. package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.Bb-FViDj.js} +1 -1
  126. package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.D3mN8S6x.js} +1 -1
  127. package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.dMMyPcbJ.js} +1 -1
  128. package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.AzbvzY4C.js} +1 -1
  129. package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.BR5I4OZb.js} +1 -1
  130. package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.DFqvhGkn.js} +1 -1
  131. package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.ByJbhH6J.js} +1 -1
  132. package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.CInHjQR3.js} +1 -1
  133. package/web/_astro/index.COh_kBxN.css +1 -0
  134. package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.Cm0dGVjJ.js} +1 -1
  135. package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js} +1 -1
  136. package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.4IvLa2MW.js} +1 -1
  137. package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DS87Cl-k.js} +1 -1
  138. package/web/_astro/{linear.DguUUe6V.js → linear.MzmRrNER.js} +1 -1
  139. package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.fK-WNswS.js} +4 -4
  140. package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.BqipOHhY.js} +1 -1
  141. package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.0nBiPqFG.js} +1 -1
  142. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.Szj7Zpx5.js} +1 -1
  143. package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.D4UR6TWo.js} +1 -1
  144. package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.DAE7BdOs.js} +1 -1
  145. package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.BP9ZVq25.js} +1 -1
  146. package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.BQW7mIxk.js} +1 -1
  147. package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.PbK4nDih.js} +1 -1
  148. package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.Ci_GvUpo.js} +1 -1
  149. package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.DNw1Y5qx.js} +1 -1
  150. package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.CwlkaaNe.js} +1 -1
  151. package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.MI6ZuiH_.js} +1 -1
  152. package/web/index.html +2 -2
  153. package/web/_astro/channel.CUFdtWhm.js +0 -1
  154. package/web/_astro/index.Dg_3bOaJ.css +0 -1
@@ -2,189 +2,29 @@
2
2
  /**
3
3
  * run-summary — the measured execution summary `/sp:dev-run` and `/sp:dev-runall` print by default
4
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.
5
+ * (`--progress [<label>=]<file>`) and the host transcript (resolved like session-timeline, or
6
+ * `--transcript`), OR the close-written per-run summary files (`--rollup [<label>=]<file>`, 1146 R3).
7
+ * Measurement and rendering live in `../lib/run-summary-core` (ADR-130 glue budget); this script is
8
+ * argv parsing and dispatch. Reporting only: no transcript or no timed attempts → an `n/a` result,
9
+ * exit 0. Node builtins only.
12
10
  */
13
11
  import { readFileSync } from 'node:fs';
14
12
  import { getEnvVars } from '../lib/env';
15
13
  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
- }
14
+ buildRunSummary,
15
+ gateFromReceipt,
16
+ type Progress,
17
+ type RollupInput,
18
+ renderRollupMarkdown,
19
+ renderSummaryMarkdown,
20
+ } from '../lib/run-summary-core';
21
+ import { resolveTranscript } from '../lib/transcript';
22
+
23
+ // Re-exported so the suite and any consumer keep one import path for the whole surface.
24
+ export * from '../lib/run-summary-core';
185
25
 
186
26
  export const RUN_SUMMARY_USAGE =
187
- 'usage: run-summary --progress [<label>=]<file> [--progress ...] [--transcript <path>] [--since <iso>] [--markdown]';
27
+ 'usage: run-summary (--progress [<label>=]<file> [--progress ...] | --rollup [<label>=]<summary.json> [--rollup ...]) [--transcript <path>] [--since <iso>] [--markdown]';
188
28
 
189
29
  export function main(
190
30
  argv: string[],
@@ -196,10 +36,12 @@ export function main(
196
36
  let since: string | undefined;
197
37
  let markdown = false;
198
38
  const progressArgs: string[] = [];
39
+ const rollupArgs: string[] = [];
199
40
  for (let i = 0; i < argv.length; i++) {
200
41
  const arg = argv[i];
201
42
  if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
202
43
  else if (arg === '--progress' && argv[i + 1]) progressArgs.push(argv[++i] as string);
44
+ else if (arg === '--rollup' && argv[i + 1]) rollupArgs.push(argv[++i] as string);
203
45
  else if (arg === '--since' && argv[i + 1]) since = argv[++i];
204
46
  else if (arg === '--markdown') markdown = true;
205
47
  else if (arg === '--spur-bin' && argv[i + 1])
@@ -209,6 +51,27 @@ export function main(
209
51
  return 2;
210
52
  }
211
53
  }
54
+ // 1146 AC5: --rollup is a different mode — it reads close-written summary files and never
55
+ // re-measures, so combining it with a measurement source is a usage error.
56
+ if (rollupArgs.length > 0 && (progressArgs.length > 0 || since !== undefined || transcript !== undefined)) {
57
+ process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
58
+ return 2;
59
+ }
60
+ if (rollupArgs.length > 0) {
61
+ try {
62
+ const inputs: RollupInput[] = rollupArgs.map((arg) => {
63
+ const eq = arg.indexOf('=');
64
+ const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
65
+ const summary = JSON.parse(readFileSync(file as string, 'utf8')) as RollupInput['summary'];
66
+ return { label: label ?? summary.runId ?? (file as string), summary };
67
+ });
68
+ write(`${renderRollupMarkdown(inputs)}\n`);
69
+ return 0;
70
+ } catch (error) {
71
+ process.stderr.write(`run-summary: ${(error as Error).message}\n`);
72
+ return 2;
73
+ }
74
+ }
212
75
  if (!progressArgs.length) {
213
76
  process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
214
77
  return 2;
@@ -228,6 +91,7 @@ export function main(
228
91
  return { label, progress: JSON.parse(readFileSync(file, 'utf8')) as Progress };
229
92
  });
230
93
  const summary = buildRunSummary(readFileSync(resolved.path, 'utf8').split('\n'), inputs, since);
94
+ summary.gate = gateFromReceipt(process.cwd(), inputs[0]?.progress.runId ?? '');
231
95
  write(
232
96
  `${markdown ? renderSummaryMarkdown(summary) : JSON.stringify({ ...summary, transcript: resolved.path })}\n`,
233
97
  );
@@ -14,7 +14,8 @@ import { existsSync, readdirSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  var SESSION_ID = /^[A-Za-z0-9_-]+$/;
17
- var OPERATOR_TOOLS = new Set(["AskUserQuestion"]);
17
+ var OPERATOR_TOOLS = new Set(["AskUserQuestion", "ask_user_question"]);
18
+ var PI_ROLES = new Set(["user", "assistant", "toolResult"]);
18
19
  var zeroTokens = () => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
19
20
  function formatDuration(ms) {
20
21
  const s = Math.round(ms / 1000);
@@ -35,6 +36,18 @@ function formatTokenSplit(t) {
35
36
  return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
36
37
  }
37
38
  function promptText(row) {
39
+ if (row.type === "message") {
40
+ if (row.message?.role !== "user")
41
+ return;
42
+ if (isInjectedPrompt(row))
43
+ return;
44
+ const content2 = row.message.content;
45
+ if (typeof content2 === "string")
46
+ return content2;
47
+ if (!Array.isArray(content2))
48
+ return;
49
+ return content2.find((b) => b.type === "text")?.text;
50
+ }
38
51
  if (row.type !== "user" || row.isMeta || row.isCompactSummary)
39
52
  return;
40
53
  const content = row.message?.content;
@@ -44,6 +57,13 @@ function promptText(row) {
44
57
  return;
45
58
  return content.find((b) => b.type === "text")?.text;
46
59
  }
60
+ function isInjectedPrompt(row) {
61
+ if (row.type !== "message" || row.message?.role !== "user")
62
+ return false;
63
+ const content = row.message.content;
64
+ const text = typeof content === "string" ? content : Array.isArray(content) ? content.find((b) => b.type === "text")?.text : undefined;
65
+ return typeof text === "string" && text.replace(/^\s+/, "").startsWith('<skill name="');
66
+ }
47
67
  function sumTokens(all) {
48
68
  const total = zeroTokens();
49
69
  for (const t of all)
@@ -64,10 +84,37 @@ var newAcc = (ts) => ({
64
84
  askMs: 0,
65
85
  asks: new Map,
66
86
  toolIds: new Set,
67
- messages: new Map
87
+ messages: new Map,
88
+ compactions: 0
68
89
  });
69
90
  function accumulate(acc, row, ts) {
70
91
  acc.last = Math.max(acc.last, ts);
92
+ if (row.type === "message") {
93
+ const m = row.message;
94
+ if (m?.role === "assistant") {
95
+ const blocks2 = Array.isArray(m.content) ? m.content : [];
96
+ for (const b of blocks2) {
97
+ if (b.type === "toolCall" && b.id) {
98
+ acc.toolIds.add(b.id);
99
+ if (b.name && OPERATOR_TOOLS.has(b.name))
100
+ acc.asks.set(b.id, ts);
101
+ }
102
+ }
103
+ const u2 = m.usage;
104
+ if (u2 && row.id)
105
+ acc.messages.set(row.id, {
106
+ input: u2.input ?? 0,
107
+ cacheCreate: u2.cacheWrite ?? 0,
108
+ cacheRead: u2.cacheRead ?? 0,
109
+ output: u2.output ?? 0
110
+ });
111
+ } else if (m?.role === "toolResult" && m.toolCallId && acc.asks.has(m.toolCallId)) {
112
+ acc.askMs += ts - (acc.asks.get(m.toolCallId) ?? ts);
113
+ }
114
+ return;
115
+ }
116
+ if (row.type === "compaction")
117
+ acc.compactions++;
71
118
  const blocks = Array.isArray(row.message?.content) ? row.message.content : [];
72
119
  for (const b of blocks) {
73
120
  if (b.type === "tool_use" && b.id) {
@@ -88,6 +135,15 @@ function accumulate(acc, row, ts) {
88
135
  });
89
136
  }
90
137
  }
138
+ function sniffFormat(rows) {
139
+ for (const row of rows) {
140
+ if (row.type === "message" && PI_ROLES.has(row.message?.role ?? ""))
141
+ return "pi";
142
+ if (row.type === "user" || row.type === "assistant")
143
+ return "claude";
144
+ }
145
+ return "unknown";
146
+ }
91
147
  function parseRows(lines) {
92
148
  const rows = [];
93
149
  let skippedLines = 0;
@@ -105,14 +161,22 @@ function parseRows(lines) {
105
161
  if (!Number.isNaN(ts))
106
162
  rows.push([row, ts]);
107
163
  }
108
- return { rows, skippedLines };
164
+ return { rows, skippedLines, format: sniffFormat(rows.map(([r]) => r)) };
109
165
  }
110
166
  function resolveTranscript(env, projectsRoot = join(homedir(), ".claude", "projects"), override) {
111
167
  if (override)
112
168
  return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: "no transcript" };
113
169
  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>" };
170
+ if (!id) {
171
+ const piFile = env.PI_SESSION_FILE;
172
+ if (piFile !== undefined && piFile !== "") {
173
+ return existsSync(piFile) ? { ok: true, path: piFile } : { ok: false, reason: `PI_SESSION_FILE ${piFile} does not exist` };
174
+ }
175
+ return {
176
+ ok: false,
177
+ reason: "no host session id; pass --transcript <path> (pi: ~/.pi/agent/sessions/<cwd-slug>/<file>.jsonl, " + "or set PI_SESSION_FILE)"
178
+ };
179
+ }
116
180
  if (!SESSION_ID.test(id))
117
181
  return { ok: false, reason: "refusing a session id with path characters" };
118
182
  if (!existsSync(projectsRoot))
@@ -146,8 +210,17 @@ function parseGroups(spec, count) {
146
210
  }
147
211
  function buildTimeline(lines, group) {
148
212
  const open = [];
149
- const { rows, skippedLines } = parseRows(lines);
213
+ const { rows, skippedLines, format } = parseRows(lines);
214
+ const isPi = format === "pi";
215
+ let injectedPrompts = 0;
150
216
  for (const [row, ts] of rows) {
217
+ if (isPi && isInjectedPrompt(row)) {
218
+ injectedPrompts++;
219
+ const injectedSeg = open.at(-1);
220
+ if (injectedSeg)
221
+ accumulate(injectedSeg, row, ts);
222
+ continue;
223
+ }
151
224
  const prompt = promptText(row);
152
225
  if (prompt !== undefined) {
153
226
  open.push({ start: ts, prompt, ...newAcc(ts) });
@@ -161,7 +234,7 @@ function buildTimeline(lines, group) {
161
234
  const next = open[i + 1]?.start;
162
235
  const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
163
236
  const oneLine = seg.prompt.replace(/\s+/g, " ").trim();
164
- return render({
237
+ const span = {
165
238
  index: i + 1,
166
239
  start: new Date(seg.start).toISOString(),
167
240
  prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}\u2026` : oneLine,
@@ -169,16 +242,19 @@ function buildTimeline(lines, group) {
169
242
  waitMs: idle + seg.askMs,
170
243
  toolCalls: seg.toolIds.size,
171
244
  tokens: sumTokens([...seg.messages.values()])
172
- });
245
+ };
246
+ return render(isPi ? { ...span, compactions: seg.compactions } : span);
173
247
  });
174
248
  const first = open[0];
175
249
  const last = open.at(-1);
176
250
  const elapsedMs = first && last ? last.last - first.start : 0;
251
+ const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
177
252
  const timeline = {
178
253
  available: true,
179
254
  segments,
180
- totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
181
- skippedLines
255
+ totals: render(isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals),
256
+ skippedLines,
257
+ ...isPi ? { injectedPrompts } : {}
182
258
  };
183
259
  if (group) {
184
260
  timeline.stages = parseGroups(group, segments.length).map(([a, b]) => render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }));
@@ -186,6 +262,20 @@ function buildTimeline(lines, group) {
186
262
  return timeline;
187
263
  }
188
264
  var SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
265
+ function zeroSegmentReason(rows) {
266
+ const format = sniffFormat(rows.map(([r]) => r));
267
+ if (format === "unknown") {
268
+ const counts = new Map;
269
+ for (const [r] of rows) {
270
+ const kind = typeof r.type === "string" && r.type !== "" ? r.type : "(untyped)";
271
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
272
+ }
273
+ const census = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([kind, n]) => `${kind} ${n}`).join(", ");
274
+ return `unrecognized transcript format (row types: ${census})`;
275
+ }
276
+ const injected = rows.filter(([r]) => isInjectedPrompt(r)).length;
277
+ return `${format} transcript with no operator prompts (${rows.length} rows, ${injected} injected)`;
278
+ }
189
279
  function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot, writeErr = (s) => process.stderr.write(s)) {
190
280
  let transcript;
191
281
  let group;
@@ -210,8 +300,14 @@ function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s),
210
300
  return 0;
211
301
  }
212
302
  try {
213
- const timeline = buildTimeline(readFileSync(resolved.path, "utf8").split(`
214
- `), group);
303
+ const lines = readFileSync(resolved.path, "utf8").split(`
304
+ `);
305
+ const timeline = buildTimeline(lines, group);
306
+ if (timeline.segments.length === 0) {
307
+ write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}
308
+ `);
309
+ return 0;
310
+ }
215
311
  write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}
216
312
  `);
217
313
  return 0;
@@ -223,6 +319,7 @@ function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s),
223
319
  }
224
320
  process.exit(main(process.argv.slice(2)));
225
321
  export {
322
+ zeroSegmentReason,
226
323
  resolveTranscript,
227
324
  parseGroups,
228
325
  main,
@@ -16,11 +16,14 @@ import {
16
16
  accumulate,
17
17
  formatDuration,
18
18
  formatTokenSplit,
19
+ isInjectedPrompt,
19
20
  newAcc,
20
21
  parseRows,
21
22
  promptText,
23
+ type Row,
22
24
  resolveTranscript,
23
25
  type Span,
26
+ sniffFormat,
24
27
  sumSpans,
25
28
  sumTokens,
26
29
  } from '../lib/transcript';
@@ -33,10 +36,12 @@ export type Rendered<T> = T & { work: string; wait: string; token: string };
33
36
  export interface Timeline {
34
37
  available: true;
35
38
  transcript?: string;
36
- segments: Rendered<Span & { index: number; start: string; prompt: string }>[];
39
+ segments: Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }>[];
37
40
  stages?: Rendered<Span & { segments: string }>[];
38
- totals: Rendered<Span & { elapsedMs: number; elapsed: string }>;
41
+ totals: Rendered<Span & { elapsedMs: number; elapsed: string; compactions?: number }>;
39
42
  skippedLines: number;
43
+ /** pi only (1138 R2): skill-injected user bodies counted as activity, never as segments. */
44
+ injectedPrompts?: number;
40
45
  }
41
46
 
42
47
  const PROMPT_EXCERPT = 80;
@@ -68,9 +73,19 @@ interface Open extends Acc {
68
73
 
69
74
  export function buildTimeline(lines: string[], group?: string): Timeline {
70
75
  const open: Open[] = [];
71
- const { rows, skippedLines } = parseRows(lines);
76
+ const { rows, skippedLines, format } = parseRows(lines);
77
+ const isPi = format === 'pi';
78
+ let injectedPrompts = 0;
72
79
 
73
80
  for (const [row, ts] of rows) {
81
+ // 1138 R2: an injected skill body is activity, not a prompt — accumulate it into the open
82
+ // segment (its tools and usage are real work) and count it, never open a segment.
83
+ if (isPi && isInjectedPrompt(row)) {
84
+ injectedPrompts++;
85
+ const injectedSeg = open.at(-1);
86
+ if (injectedSeg) accumulate(injectedSeg, row, ts);
87
+ continue;
88
+ }
74
89
  const prompt = promptText(row);
75
90
  if (prompt !== undefined) {
76
91
  open.push({ start: ts, prompt, ...newAcc(ts) });
@@ -80,29 +95,37 @@ export function buildTimeline(lines: string[], group?: string): Timeline {
80
95
  if (seg) accumulate(seg, row, ts); // rows before the first prompt are session preamble
81
96
  }
82
97
 
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
- });
98
+ const segments = open.map(
99
+ (seg, i): Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }> => {
100
+ const next = open[i + 1]?.start;
101
+ const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
102
+ const oneLine = seg.prompt.replace(/\s+/g, ' ').trim();
103
+ const span = {
104
+ index: i + 1,
105
+ start: new Date(seg.start).toISOString(),
106
+ prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}…` : oneLine,
107
+ workMs: seg.last - seg.start - seg.askMs,
108
+ waitMs: idle + seg.askMs,
109
+ toolCalls: seg.toolIds.size,
110
+ tokens: sumTokens([...seg.messages.values()]),
111
+ };
112
+ return render(isPi ? { ...span, compactions: seg.compactions } : span);
113
+ },
114
+ );
97
115
 
98
116
  const first = open[0];
99
117
  const last = open.at(-1);
100
118
  const elapsedMs = first && last ? last.last - first.start : 0;
119
+ const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
101
120
  const timeline: Timeline = {
102
121
  available: true,
103
122
  segments,
104
- totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
123
+ totals: render(
124
+ isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals,
125
+ ),
105
126
  skippedLines,
127
+ // Emitted only for pi, as `compactions` is, so Claude output is unchanged (1138 R2).
128
+ ...(isPi ? { injectedPrompts } : {}),
106
129
  };
107
130
  if (group) {
108
131
  timeline.stages = parseGroups(group, segments.length).map(([a, b]) =>
@@ -114,6 +137,30 @@ export function buildTimeline(lines: string[], group?: string): Timeline {
114
137
 
115
138
  export const SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
116
139
 
140
+ /**
141
+ * 1138 R3: a zero-segment result must say WHAT was detected, so an unparseable-but-recognised
142
+ * transcript can never read as an authoritative all-zero timeline. A known format names the
143
+ * row count and the injected count; an unknown one lists the top-5 row-type census.
144
+ */
145
+ export function zeroSegmentReason(rows: [Row, number][]): string {
146
+ const format = sniffFormat(rows.map(([r]) => r));
147
+ if (format === 'unknown') {
148
+ const counts = new Map<string, number>();
149
+ for (const [r] of rows) {
150
+ const kind = typeof r.type === 'string' && r.type !== '' ? r.type : '(untyped)';
151
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
152
+ }
153
+ const census = [...counts.entries()]
154
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
155
+ .slice(0, 5)
156
+ .map(([kind, n]) => `${kind} ${n}`)
157
+ .join(', ');
158
+ return `unrecognized transcript format (row types: ${census})`;
159
+ }
160
+ const injected = rows.filter(([r]) => isInjectedPrompt(r)).length;
161
+ return `${format} transcript with no operator prompts (${rows.length} rows, ${injected} injected)`;
162
+ }
163
+
117
164
  export function main(
118
165
  argv: string[],
119
166
  env: Record<string, string | undefined> = getEnvVars(),
@@ -140,7 +187,12 @@ export function main(
140
187
  return 0;
141
188
  }
142
189
  try {
143
- const timeline = buildTimeline(readFileSync(resolved.path, 'utf8').split('\n'), group);
190
+ const lines = readFileSync(resolved.path, 'utf8').split('\n');
191
+ const timeline = buildTimeline(lines, group);
192
+ if (timeline.segments.length === 0) {
193
+ write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}\n`);
194
+ return 0;
195
+ }
144
196
  write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}\n`);
145
197
  return 0;
146
198
  } catch (error) {