@gobing-ai/spur 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +4 -2
  3. package/config/decisions/gates.yaml +36 -0
  4. package/config/decisions/history-anatomy.yaml +35 -0
  5. package/config/decisions/idea-pipeline.yaml +38 -0
  6. package/config/plugin-scripts.json +15 -0
  7. package/config/rules/docs/sourced-api-claims.yaml +60 -0
  8. package/config/rules/recommended-pre-check.yaml +8 -3
  9. package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
  10. package/config/script-placement-baseline.json +2 -2
  11. package/config/templates/AGENTS.md +1 -1
  12. package/config/workflows/feature-lifecycle.yaml +9 -2
  13. package/config/workflows/history-anatomy.yaml +22 -0
  14. package/config/workflows/idea-pipeline.yaml +75 -0
  15. package/config/workflows/task-pipeline.yaml +216 -57
  16. package/config/workflows/wrapup-pipeline.yaml +107 -14
  17. package/package.json +10 -10
  18. package/plugins/sp/README.md +12 -3
  19. package/plugins/sp/agents/super-planner.md +16 -6
  20. package/plugins/sp/agents/super-reviewer.md +14 -0
  21. package/plugins/sp/bunfig.toml +8 -0
  22. package/plugins/sp/commands/dev-brainstorm.md +29 -16
  23. package/plugins/sp/commands/dev-find-way.md +80 -0
  24. package/plugins/sp/commands/dev-idea.md +6 -0
  25. package/plugins/sp/commands/dev-plan.md +6 -0
  26. package/plugins/sp/commands/dev-review-session.md +4 -1
  27. package/plugins/sp/commands/dev-run.md +12 -2
  28. package/plugins/sp/commands/dev-runall.md +32 -15
  29. package/plugins/sp/hooks/context-owner.ts +56 -0
  30. package/plugins/sp/hooks/context-post-tool.ts +23 -0
  31. package/plugins/sp/hooks/context-session-start.ts +49 -27
  32. package/plugins/sp/hooks/context-session-stop.ts +131 -30
  33. package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
  34. package/plugins/sp/lib/idea-handoff.generated.mjs +43 -5
  35. package/plugins/sp/lib/inline-run-app.ts +28 -0
  36. package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
  37. package/plugins/sp/lib/inline-run.generated.mjs +351 -46
  38. package/plugins/sp/lib/plan-projection.generated.d.mts +47 -0
  39. package/plugins/sp/lib/plan-projection.generated.mjs +368 -0
  40. package/plugins/sp/lib/quality-gate.generated.d.mts +35 -1
  41. package/plugins/sp/lib/quality-gate.generated.mjs +30296 -71
  42. package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
  43. package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
  44. package/plugins/sp/lib/run-summary-core.ts +561 -0
  45. package/plugins/sp/lib/transcript.ts +150 -7
  46. package/plugins/sp/plugin.json +1 -1
  47. package/plugins/sp/references/roles.md +1 -1
  48. package/plugins/sp/scripts/batch-plan.mjs +488 -0
  49. package/plugins/sp/scripts/batch-plan.ts +145 -0
  50. package/plugins/sp/scripts/batch-preflight.mjs +51 -3
  51. package/plugins/sp/scripts/batch-preflight.ts +72 -1
  52. package/plugins/sp/scripts/commit-guard.mjs +216 -0
  53. package/plugins/sp/scripts/commit-guard.ts +249 -0
  54. package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
  55. package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
  56. package/plugins/sp/scripts/inline-run-setup.mjs +562 -80
  57. package/plugins/sp/scripts/inline-run-setup.ts +52 -78
  58. package/plugins/sp/scripts/persist-out-check.mjs +90 -41
  59. package/plugins/sp/scripts/persist-out-check.ts +112 -57
  60. package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
  61. package/plugins/sp/scripts/pr-reviewing.ts +34 -9
  62. package/plugins/sp/scripts/quality-gate.mjs +30213 -61
  63. package/plugins/sp/scripts/quality-gate.ts +21 -5
  64. package/plugins/sp/scripts/residual-scan.mjs +86 -31
  65. package/plugins/sp/scripts/residual-scan.ts +28 -30
  66. package/plugins/sp/scripts/run-summary.mjs +432 -14
  67. package/plugins/sp/scripts/run-summary.ts +41 -177
  68. package/plugins/sp/scripts/session-timeline.mjs +109 -11
  69. package/plugins/sp/scripts/session-timeline.ts +57 -19
  70. package/plugins/sp/scripts/task-diffstat.mjs +32 -29
  71. package/plugins/sp/scripts/task-diffstat.ts +34 -34
  72. package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
  73. package/plugins/sp/scripts/wrapup-steps.ts +203 -1
  74. package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
  75. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
  78. package/plugins/sp/skills/code-verification/SKILL.md +3 -4
  79. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
  80. package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
  81. package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
  82. package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
  83. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  84. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  85. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
  86. package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
  87. package/plugins/sp/skills/session-review/SKILL.md +40 -7
  88. package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
  89. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
  90. package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
  91. package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
  92. package/plugins/sp/skills/spur-cli/references/decision.md +16 -8
  93. package/plugins/sp/skills/spur-cli/references/message.md +7 -7
  94. package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
  95. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
  96. package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
  97. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
  98. package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
  99. package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
  100. package/plugins/sp/skills/spur-dev/SKILL.md +20 -14
  101. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +33 -3
  102. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
  103. package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -17
  104. package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
  105. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +199 -0
  106. package/plugins/sp/skills/spur-dev/references/execution-batch.md +156 -1024
  107. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +133 -0
  108. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
  109. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
  110. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
  111. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +44 -12
  112. package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
  113. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +340 -165
  114. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
  115. package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
  116. package/schemas/state-machine-workflow.schema.json +23 -0
  117. package/spur.js +42402 -26889
  118. package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.B3GQZvj8.js} +1 -1
  119. package/web/_astro/BoardApp.BT7L2kSb.js +324 -0
  120. package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
  121. package/web/_astro/{arc.f4e0OYLg.js → arc.vaT7tPOB.js} +1 -1
  122. package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
  123. package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
  124. package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
  125. package/web/_astro/channel.D4nG9YLf.js +1 -0
  126. package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
  127. package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
  128. package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
  129. package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
  130. package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
  131. package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
  132. package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
  133. package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
  134. package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
  135. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
  136. package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
  137. package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
  138. package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
  139. package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
  140. package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
  141. package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
  142. package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
  143. package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
  144. package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
  145. package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
  146. package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
  147. package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
  148. package/web/_astro/index.BhSwN8yY.css +1 -0
  149. package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
  150. package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
  151. package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
  152. package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
  153. package/web/_astro/{linear.DguUUe6V.js → linear.BzGnBwNL.js} +1 -1
  154. package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.CSbTpIVz.js} +4 -4
  155. package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
  156. package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
  157. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
  158. package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
  159. package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
  160. package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
  161. package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
  162. package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
  163. package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
  164. package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
  165. package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
  166. package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
  167. package/web/index.html +2 -2
  168. package/plugins/sp/commands/dev-parallel.md +0 -31
  169. package/web/_astro/BoardApp.Bzr4OXrW.js +0 -324
  170. package/web/_astro/TaskDetail.LAyR_Pvn.js +0 -1
  171. package/web/_astro/channel.CUFdtWhm.js +0 -1
  172. package/web/_astro/index.Dg_3bOaJ.css +0 -1
@@ -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,20 @@ function formatTokenSplit(t) {
35
36
  return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
36
37
  }
37
38
  function promptText(row) {
39
+ if (row.type === "custom_message") {
40
+ if (row.display === false)
41
+ return;
42
+ const text = typeof row.content === "string" ? row.content.trim() : "";
43
+ return text === "" ? undefined : text;
44
+ }
45
+ if (row.type === "message") {
46
+ if (row.message?.role !== "user")
47
+ return;
48
+ const text = firstTextContent(row);
49
+ if (text === undefined)
50
+ return;
51
+ return skillInvocationPrompt(text) ?? text;
52
+ }
38
53
  if (row.type !== "user" || row.isMeta || row.isCompactSummary)
39
54
  return;
40
55
  const content = row.message?.content;
@@ -44,6 +59,22 @@ function promptText(row) {
44
59
  return;
45
60
  return content.find((b) => b.type === "text")?.text;
46
61
  }
62
+ function firstTextContent(row) {
63
+ const content = row.message?.content;
64
+ if (typeof content === "string")
65
+ return content;
66
+ if (!Array.isArray(content))
67
+ return;
68
+ return content.find((b) => b.type === "text")?.text;
69
+ }
70
+ function skillInvocationPrompt(text) {
71
+ const name = /^\s*<skill name="([^"]+)"/.exec(text)?.[1];
72
+ if (name === undefined)
73
+ return;
74
+ const close = text.lastIndexOf("</skill>");
75
+ const args = close < 0 ? "" : text.slice(close + "</skill>".length).trim();
76
+ return args === "" ? `/${name}` : `/${name} ${args}`;
77
+ }
47
78
  function sumTokens(all) {
48
79
  const total = zeroTokens();
49
80
  for (const t of all)
@@ -64,10 +95,37 @@ var newAcc = (ts) => ({
64
95
  askMs: 0,
65
96
  asks: new Map,
66
97
  toolIds: new Set,
67
- messages: new Map
98
+ messages: new Map,
99
+ compactions: 0
68
100
  });
69
101
  function accumulate(acc, row, ts) {
70
102
  acc.last = Math.max(acc.last, ts);
103
+ if (row.type === "message") {
104
+ const m = row.message;
105
+ if (m?.role === "assistant") {
106
+ const blocks2 = Array.isArray(m.content) ? m.content : [];
107
+ for (const b of blocks2) {
108
+ if (b.type === "toolCall" && b.id) {
109
+ acc.toolIds.add(b.id);
110
+ if (b.name && OPERATOR_TOOLS.has(b.name))
111
+ acc.asks.set(b.id, ts);
112
+ }
113
+ }
114
+ const u2 = m.usage;
115
+ if (u2 && row.id)
116
+ acc.messages.set(row.id, {
117
+ input: u2.input ?? 0,
118
+ cacheCreate: u2.cacheWrite ?? 0,
119
+ cacheRead: u2.cacheRead ?? 0,
120
+ output: u2.output ?? 0
121
+ });
122
+ } else if (m?.role === "toolResult" && m.toolCallId && acc.asks.has(m.toolCallId)) {
123
+ acc.askMs += ts - (acc.asks.get(m.toolCallId) ?? ts);
124
+ }
125
+ return;
126
+ }
127
+ if (row.type === "compaction")
128
+ acc.compactions++;
71
129
  const blocks = Array.isArray(row.message?.content) ? row.message.content : [];
72
130
  for (const b of blocks) {
73
131
  if (b.type === "tool_use" && b.id) {
@@ -88,6 +146,15 @@ function accumulate(acc, row, ts) {
88
146
  });
89
147
  }
90
148
  }
149
+ function sniffFormat(rows) {
150
+ for (const row of rows) {
151
+ if (row.type === "message" && PI_ROLES.has(row.message?.role ?? ""))
152
+ return "pi";
153
+ if (row.type === "user" || row.type === "assistant")
154
+ return "claude";
155
+ }
156
+ return "unknown";
157
+ }
91
158
  function parseRows(lines) {
92
159
  const rows = [];
93
160
  let skippedLines = 0;
@@ -105,14 +172,22 @@ function parseRows(lines) {
105
172
  if (!Number.isNaN(ts))
106
173
  rows.push([row, ts]);
107
174
  }
108
- return { rows, skippedLines };
175
+ return { rows, skippedLines, format: sniffFormat(rows.map(([r]) => r)) };
109
176
  }
110
177
  function resolveTranscript(env, projectsRoot = join(homedir(), ".claude", "projects"), override) {
111
178
  if (override)
112
179
  return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: "no transcript" };
113
180
  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>" };
181
+ if (!id) {
182
+ const piFile = env.PI_SESSION_FILE;
183
+ if (piFile !== undefined && piFile !== "") {
184
+ return existsSync(piFile) ? { ok: true, path: piFile } : { ok: false, reason: `PI_SESSION_FILE ${piFile} does not exist` };
185
+ }
186
+ return {
187
+ ok: false,
188
+ reason: "no host session id; pass --transcript <path> (pi: ~/.pi/agent/sessions/<cwd-slug>/<file>.jsonl, " + "or set PI_SESSION_FILE)"
189
+ };
190
+ }
116
191
  if (!SESSION_ID.test(id))
117
192
  return { ok: false, reason: "refusing a session id with path characters" };
118
193
  if (!existsSync(projectsRoot))
@@ -146,7 +221,8 @@ function parseGroups(spec, count) {
146
221
  }
147
222
  function buildTimeline(lines, group) {
148
223
  const open = [];
149
- const { rows, skippedLines } = parseRows(lines);
224
+ const { rows, skippedLines, format } = parseRows(lines);
225
+ const isPi = format === "pi";
150
226
  for (const [row, ts] of rows) {
151
227
  const prompt = promptText(row);
152
228
  if (prompt !== undefined) {
@@ -161,7 +237,7 @@ function buildTimeline(lines, group) {
161
237
  const next = open[i + 1]?.start;
162
238
  const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
163
239
  const oneLine = seg.prompt.replace(/\s+/g, " ").trim();
164
- return render({
240
+ const span = {
165
241
  index: i + 1,
166
242
  start: new Date(seg.start).toISOString(),
167
243
  prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}\u2026` : oneLine,
@@ -169,15 +245,17 @@ function buildTimeline(lines, group) {
169
245
  waitMs: idle + seg.askMs,
170
246
  toolCalls: seg.toolIds.size,
171
247
  tokens: sumTokens([...seg.messages.values()])
172
- });
248
+ };
249
+ return render(isPi ? { ...span, compactions: seg.compactions } : span);
173
250
  });
174
251
  const first = open[0];
175
252
  const last = open.at(-1);
176
253
  const elapsedMs = first && last ? last.last - first.start : 0;
254
+ const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
177
255
  const timeline = {
178
256
  available: true,
179
257
  segments,
180
- totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
258
+ totals: render(isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals),
181
259
  skippedLines
182
260
  };
183
261
  if (group) {
@@ -186,6 +264,19 @@ function buildTimeline(lines, group) {
186
264
  return timeline;
187
265
  }
188
266
  var SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
267
+ function zeroSegmentReason(rows) {
268
+ const format = sniffFormat(rows.map(([r]) => r));
269
+ if (format === "unknown") {
270
+ const counts = new Map;
271
+ for (const [r] of rows) {
272
+ const kind = typeof r.type === "string" && r.type !== "" ? r.type : "(untyped)";
273
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
274
+ }
275
+ 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(", ");
276
+ return `unrecognized transcript format (row types: ${census})`;
277
+ }
278
+ return `${format} transcript with no operator prompts (${rows.length} rows)`;
279
+ }
189
280
  function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot, writeErr = (s) => process.stderr.write(s)) {
190
281
  let transcript;
191
282
  let group;
@@ -210,8 +301,14 @@ function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s),
210
301
  return 0;
211
302
  }
212
303
  try {
213
- const timeline = buildTimeline(readFileSync(resolved.path, "utf8").split(`
214
- `), group);
304
+ const lines = readFileSync(resolved.path, "utf8").split(`
305
+ `);
306
+ const timeline = buildTimeline(lines, group);
307
+ if (timeline.segments.length === 0) {
308
+ write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}
309
+ `);
310
+ return 0;
311
+ }
215
312
  write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}
216
313
  `);
217
314
  return 0;
@@ -223,6 +320,7 @@ function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s),
223
320
  }
224
321
  process.exit(main(process.argv.slice(2)));
225
322
  export {
323
+ zeroSegmentReason,
226
324
  resolveTranscript,
227
325
  parseGroups,
228
326
  main,
@@ -19,8 +19,10 @@ import {
19
19
  newAcc,
20
20
  parseRows,
21
21
  promptText,
22
+ type Row,
22
23
  resolveTranscript,
23
24
  type Span,
25
+ sniffFormat,
24
26
  sumSpans,
25
27
  sumTokens,
26
28
  } from '../lib/transcript';
@@ -33,9 +35,9 @@ export type Rendered<T> = T & { work: string; wait: string; token: string };
33
35
  export interface Timeline {
34
36
  available: true;
35
37
  transcript?: string;
36
- segments: Rendered<Span & { index: number; start: string; prompt: string }>[];
38
+ segments: Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }>[];
37
39
  stages?: Rendered<Span & { segments: string }>[];
38
- totals: Rendered<Span & { elapsedMs: number; elapsed: string }>;
40
+ totals: Rendered<Span & { elapsedMs: number; elapsed: string; compactions?: number }>;
39
41
  skippedLines: number;
40
42
  }
41
43
 
@@ -68,7 +70,8 @@ interface Open extends Acc {
68
70
 
69
71
  export function buildTimeline(lines: string[], group?: string): Timeline {
70
72
  const open: Open[] = [];
71
- const { rows, skippedLines } = parseRows(lines);
73
+ const { rows, skippedLines, format } = parseRows(lines);
74
+ const isPi = format === 'pi';
72
75
 
73
76
  for (const [row, ts] of rows) {
74
77
  const prompt = promptText(row);
@@ -80,28 +83,34 @@ export function buildTimeline(lines: string[], group?: string): Timeline {
80
83
  if (seg) accumulate(seg, row, ts); // rows before the first prompt are session preamble
81
84
  }
82
85
 
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
- });
86
+ const segments = open.map(
87
+ (seg, i): Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }> => {
88
+ const next = open[i + 1]?.start;
89
+ const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
90
+ const oneLine = seg.prompt.replace(/\s+/g, ' ').trim();
91
+ const span = {
92
+ index: i + 1,
93
+ start: new Date(seg.start).toISOString(),
94
+ prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}…` : oneLine,
95
+ workMs: seg.last - seg.start - seg.askMs,
96
+ waitMs: idle + seg.askMs,
97
+ toolCalls: seg.toolIds.size,
98
+ tokens: sumTokens([...seg.messages.values()]),
99
+ };
100
+ return render(isPi ? { ...span, compactions: seg.compactions } : span);
101
+ },
102
+ );
97
103
 
98
104
  const first = open[0];
99
105
  const last = open.at(-1);
100
106
  const elapsedMs = first && last ? last.last - first.start : 0;
107
+ const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
101
108
  const timeline: Timeline = {
102
109
  available: true,
103
110
  segments,
104
- totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
111
+ totals: render(
112
+ isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals,
113
+ ),
105
114
  skippedLines,
106
115
  };
107
116
  if (group) {
@@ -114,6 +123,30 @@ export function buildTimeline(lines: string[], group?: string): Timeline {
114
123
 
115
124
  export const SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
116
125
 
126
+ /**
127
+ * 1138 R3: a zero-segment result must say WHAT was detected, so an unparseable-but-recognised
128
+ * transcript can never read as an authoritative all-zero timeline. A known format names the row
129
+ * count; an unknown one lists the top-5 row-type census. (1151 R2 removed the pi-only injected
130
+ * count: pi injects no skill bodies, so a wrapper row is an operator prompt and the class is gone.)
131
+ */
132
+ export function zeroSegmentReason(rows: [Row, number][]): string {
133
+ const format = sniffFormat(rows.map(([r]) => r));
134
+ if (format === 'unknown') {
135
+ const counts = new Map<string, number>();
136
+ for (const [r] of rows) {
137
+ const kind = typeof r.type === 'string' && r.type !== '' ? r.type : '(untyped)';
138
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
139
+ }
140
+ const census = [...counts.entries()]
141
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
142
+ .slice(0, 5)
143
+ .map(([kind, n]) => `${kind} ${n}`)
144
+ .join(', ');
145
+ return `unrecognized transcript format (row types: ${census})`;
146
+ }
147
+ return `${format} transcript with no operator prompts (${rows.length} rows)`;
148
+ }
149
+
117
150
  export function main(
118
151
  argv: string[],
119
152
  env: Record<string, string | undefined> = getEnvVars(),
@@ -140,7 +173,12 @@ export function main(
140
173
  return 0;
141
174
  }
142
175
  try {
143
- const timeline = buildTimeline(readFileSync(resolved.path, 'utf8').split('\n'), group);
176
+ const lines = readFileSync(resolved.path, 'utf8').split('\n');
177
+ const timeline = buildTimeline(lines, group);
178
+ if (timeline.segments.length === 0) {
179
+ write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}\n`);
180
+ return 0;
181
+ }
144
182
  write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}\n`);
145
183
  return 0;
146
184
  } catch (error) {