@gobing-ai/spur 0.4.8 → 0.4.9

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +2 -2
  3. package/config/rules/boundary/config-loading-ownership.yaml +1 -0
  4. package/config/rules/boundary/sp-plugin-standalone.yaml +1 -1
  5. package/config/rules/boundary/sp-script-placement.yaml +1 -1
  6. package/config/rules/docs/sourced-api-claims.yaml +45 -7
  7. package/config/rules/strict/runtime-boundaries.yaml +40 -1
  8. package/config/rules/structure/protected-files.yaml +1 -1
  9. package/config/rules/surface/check-cli-surface.yaml +33 -0
  10. package/config/rules/typescript/no-debugger.yaml +3 -0
  11. package/config/workflow-candidates.json +2 -2
  12. package/config/workflows/feature-verification.yaml +6 -11
  13. package/config/workflows/history-anatomy.yaml +14 -28
  14. package/config/workflows/idea-pipeline.yaml +6 -19
  15. package/config/workflows/pr-review.yaml +9 -16
  16. package/config/workflows/task-pipeline.yaml +16 -23
  17. package/config/workflows/wrapup-pipeline.yaml +21 -63
  18. package/package.json +3 -3
  19. package/plugins/sp/README.md +70 -37
  20. package/plugins/sp/agents/super-planner.md +5 -5
  21. package/plugins/sp/agents/super-reviewer.md +2 -2
  22. package/plugins/sp/commands/dev-changelog.md +1 -1
  23. package/plugins/sp/commands/dev-daily.md +1 -1
  24. package/plugins/sp/commands/dev-pr-review.md +1 -1
  25. package/plugins/sp/commands/dev-review-session.md +1 -1
  26. package/plugins/sp/commands/dev-runall.md +4 -4
  27. package/plugins/sp/commands/dev-verify.md +4 -6
  28. package/plugins/sp/commands/dev-verifyall.md +4 -5
  29. package/plugins/sp/hooks/hooks.json +11 -1
  30. package/plugins/sp/lib/context-post-tool.ts +1 -1
  31. package/plugins/sp/lib/context-session-start.ts +1 -1
  32. package/plugins/sp/lib/context-session-stop.ts +1 -1
  33. package/plugins/sp/lib/destructive-policy.ts +65 -1
  34. package/plugins/sp/plugin.json +2 -5
  35. package/plugins/sp/{hooks → scripts/hooks}/agent-lifecycle.ts +1 -1
  36. package/plugins/sp/scripts/hooks/careful-guard.ts +94 -0
  37. package/plugins/sp/{hooks → scripts/hooks}/context-post-tool.ts +2 -2
  38. package/plugins/sp/{hooks → scripts/hooks}/context-session-start.ts +1 -1
  39. package/plugins/sp/{hooks → scripts/hooks}/context-session-stop.ts +1 -1
  40. package/plugins/sp/{hooks → scripts/hooks}/fleet-guest-stop.ts +6 -4
  41. package/plugins/sp/{hooks → scripts/hooks}/task-write-guard.ts +38 -21
  42. package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
  43. package/plugins/sp/skills/code-verification/SKILL.md +8 -8
  44. package/plugins/sp/skills/code-verification/references/secu-review.md +4 -3
  45. package/plugins/sp/skills/code-verification/references/verdict-schema.md +9 -1
  46. package/plugins/sp/skills/daily-summary/SKILL.md +4 -5
  47. package/plugins/sp/skills/dogfood-testing/SKILL.md +9 -9
  48. package/plugins/sp/skills/dogfood-testing/references/report-template.md +1 -1
  49. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -2
  50. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  51. package/plugins/sp/skills/parallel-execution/SKILL.md +1 -1
  52. package/plugins/sp/skills/pr-reviewing/SKILL.md +14 -15
  53. package/plugins/sp/skills/session-review/SKILL.md +3 -3
  54. package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
  55. package/plugins/sp/skills/spur-check/SKILL.md +14 -13
  56. package/plugins/sp/skills/spur-cli/SKILL.md +1 -1
  57. package/plugins/sp/skills/spur-cli/references/agent.md +12 -0
  58. package/plugins/sp/skills/spur-cli/references/builder.md +27 -6
  59. package/plugins/sp/skills/spur-cli/references/features.md +6 -1
  60. package/plugins/sp/skills/spur-cli/references/history.md +3 -0
  61. package/plugins/sp/skills/spur-cli/references/self.md +5 -3
  62. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +3 -0
  63. package/plugins/sp/skills/spur-cli/references/tasks.md +13 -3
  64. package/plugins/sp/skills/spur-cli/references/workflows.md +36 -1
  65. package/plugins/sp/skills/spur-dev/SKILL.md +1 -1
  66. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +1 -1
  67. package/plugins/sp/skills/spur-dev/references/dev-operations.md +12 -12
  68. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +10 -11
  69. package/plugins/sp/skills/spur-dev/references/execution-batch.md +16 -17
  70. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +1 -1
  71. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +8 -11
  72. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +6 -8
  73. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -1
  74. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +33 -40
  75. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +24 -24
  76. package/plugins/sp/skills/spur-doctor/SKILL.md +5 -4
  77. package/spur.js +41901 -30598
  78. package/config/plugin-scripts.json +0 -116
  79. package/config/script-placement-baseline.json +0 -33
  80. package/plugins/sp/hooks/careful-guard.ts +0 -74
  81. package/plugins/sp/hooks/pi/guard-extension.ts +0 -304
  82. package/plugins/sp/lib/dogfood-pipeline.ts +0 -265
  83. package/plugins/sp/lib/dogfood-report.ts +0 -278
  84. package/plugins/sp/lib/history-anatomy.generated.d.mts +0 -112
  85. package/plugins/sp/lib/history-anatomy.generated.mjs +0 -691
  86. package/plugins/sp/lib/idea-handoff.generated.d.mts +0 -17
  87. package/plugins/sp/lib/idea-handoff.generated.mjs +0 -1446
  88. package/plugins/sp/lib/inline-run-app.ts +0 -68
  89. package/plugins/sp/lib/inline-run.generated.d.mts +0 -37
  90. package/plugins/sp/lib/inline-run.generated.mjs +0 -3103
  91. package/plugins/sp/lib/plan-projection.generated.d.mts +0 -197
  92. package/plugins/sp/lib/plan-projection.generated.mjs +0 -982
  93. package/plugins/sp/lib/quality-gate.generated.d.mts +0 -139
  94. package/plugins/sp/lib/quality-gate.generated.mjs +0 -30510
  95. package/plugins/sp/lib/residual-scan.generated.d.mts +0 -93
  96. package/plugins/sp/lib/residual-scan.generated.mjs +0 -383
  97. package/plugins/sp/lib/run-summary-core.ts +0 -225
  98. package/plugins/sp/lib/spur-bin.ts +0 -36
  99. package/plugins/sp/lib/step-profile.generated.d.mts +0 -71
  100. package/plugins/sp/lib/step-profile.generated.mjs +0 -174
  101. package/plugins/sp/lib/transcript.ts +0 -112
  102. package/plugins/sp/scripts/batch-plan.mjs +0 -1096
  103. package/plugins/sp/scripts/batch-plan.ts +0 -145
  104. package/plugins/sp/scripts/batch-preflight.mjs +0 -423
  105. package/plugins/sp/scripts/batch-preflight.ts +0 -270
  106. package/plugins/sp/scripts/commit-guard.mjs +0 -226
  107. package/plugins/sp/scripts/commit-guard.ts +0 -248
  108. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -626
  109. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -855
  110. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -211
  111. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -110
  112. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -282
  113. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -98
  114. package/plugins/sp/scripts/feature-verification-steps.mjs +0 -82
  115. package/plugins/sp/scripts/feature-verification-steps.ts +0 -164
  116. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -931
  117. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -247
  118. package/plugins/sp/scripts/idea-handoff.mjs +0 -32
  119. package/plugins/sp/scripts/idea-handoff.ts +0 -47
  120. package/plugins/sp/scripts/inline-run-dispatch.mjs +0 -131
  121. package/plugins/sp/scripts/inline-run-dispatch.ts +0 -115
  122. package/plugins/sp/scripts/inline-run-setup.mjs +0 -746
  123. package/plugins/sp/scripts/inline-run-setup.ts +0 -198
  124. package/plugins/sp/scripts/persist-out-check.mjs +0 -217
  125. package/plugins/sp/scripts/persist-out-check.ts +0 -249
  126. package/plugins/sp/scripts/pr-reviewing.mjs +0 -712
  127. package/plugins/sp/scripts/pr-reviewing.ts +0 -952
  128. package/plugins/sp/scripts/quality-gate.mjs +0 -30491
  129. package/plugins/sp/scripts/quality-gate.ts +0 -80
  130. package/plugins/sp/scripts/residual-scan.mjs +0 -634
  131. package/plugins/sp/scripts/residual-scan.ts +0 -248
  132. package/plugins/sp/scripts/run-summary.mjs +0 -758
  133. package/plugins/sp/scripts/run-summary.ts +0 -107
  134. package/plugins/sp/scripts/script-root.mjs +0 -129
  135. package/plugins/sp/scripts/script-root.ts +0 -199
  136. package/plugins/sp/scripts/session-timeline.mjs +0 -359
  137. package/plugins/sp/scripts/session-timeline.ts +0 -190
  138. package/plugins/sp/scripts/task-diffstat.mjs +0 -181
  139. package/plugins/sp/scripts/task-diffstat.ts +0 -219
  140. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -338
  141. package/plugins/sp/scripts/workflow-step-profile.ts +0 -165
  142. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  143. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -261
  144. package/plugins/sp/scripts/wrapup-steps.mjs +0 -627
  145. package/plugins/sp/scripts/wrapup-steps.ts +0 -891
@@ -1,359 +0,0 @@
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
-
17
- // plugins/sp/lib/plan-projection.generated.mjs
18
- var NON_ABORTING_PREFLIGHT_CODES = new Set([
19
- "L4.scenario-unverified",
20
- "L4.verifying-incomplete-tasks"
21
- ]);
22
- var zeroTokens = () => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
23
- function formatDuration(ms) {
24
- const s = Math.round(ms / 1000);
25
- const h = Math.floor(s / 3600);
26
- const m = Math.floor(s % 3600 / 60);
27
- const ss = String(s % 60).padStart(2, "0");
28
- return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
29
- }
30
- function formatTokens(n) {
31
- if (n >= 999500)
32
- return `${(n / 1e6).toFixed(1)}M`;
33
- return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
34
- }
35
- function formatTokenSplit(t) {
36
- if (!t)
37
- return "n/a";
38
- const nonCached = t.input + t.cacheCreate + t.output;
39
- return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
40
- }
41
- var OPERATOR_TOOLS = new Set(["AskUserQuestion", "ask_user_question"]);
42
- function firstTextContent(row) {
43
- const content = row.message?.content;
44
- if (typeof content === "string")
45
- return content;
46
- if (!Array.isArray(content))
47
- return;
48
- return content.find((b) => b.type === "text")?.text;
49
- }
50
- function skillInvocationPrompt(text) {
51
- const name = /^\s*<skill name="([^"]+)"/.exec(text)?.[1];
52
- if (name === undefined)
53
- return;
54
- const close = text.lastIndexOf("</skill>");
55
- const args = close < 0 ? "" : text.slice(close + "</skill>".length).trim();
56
- return args === "" ? `/${name}` : `/${name} ${args}`;
57
- }
58
- function promptText(row) {
59
- if (row.type === "custom_message") {
60
- if (row.display === false)
61
- return;
62
- const text = typeof row.content === "string" ? row.content.trim() : "";
63
- return text === "" ? undefined : text;
64
- }
65
- if (row.type === "message") {
66
- if (row.message?.role !== "user")
67
- return;
68
- const text = firstTextContent(row);
69
- if (text === undefined)
70
- return;
71
- return skillInvocationPrompt(text) ?? text;
72
- }
73
- if (row.type !== "user" || row.isMeta || row.isCompactSummary)
74
- return;
75
- const content = row.message?.content;
76
- if (typeof content === "string")
77
- return content;
78
- if (!Array.isArray(content) || content.some((b) => b.type === "tool_result"))
79
- return;
80
- return content.find((b) => b.type === "text")?.text;
81
- }
82
- function sumTokens(all) {
83
- const total = zeroTokens();
84
- for (const t of all)
85
- for (const k of Object.keys(total))
86
- total[k] += t[k];
87
- return total;
88
- }
89
- function sumSpans(spans) {
90
- return {
91
- workMs: spans.reduce((n, s) => n + s.workMs, 0),
92
- waitMs: spans.reduce((n, s) => n + s.waitMs, 0),
93
- toolCalls: spans.reduce((n, s) => n + s.toolCalls, 0),
94
- tokens: sumTokens(spans.map((s) => s.tokens))
95
- };
96
- }
97
- var newAcc = (ts) => ({
98
- last: ts,
99
- askMs: 0,
100
- asks: new Map,
101
- toolIds: new Set,
102
- messages: new Map,
103
- compactions: 0
104
- });
105
- function accumulate(acc, row, ts) {
106
- acc.last = Math.max(acc.last, ts);
107
- if (row.type === "message") {
108
- const m = row.message;
109
- if (m?.role === "assistant") {
110
- const blocks = Array.isArray(m.content) ? m.content : [];
111
- for (const b of blocks) {
112
- if (b.type === "toolCall" && b.id) {
113
- acc.toolIds.add(b.id);
114
- if (b.name && OPERATOR_TOOLS.has(b.name))
115
- acc.asks.set(b.id, ts);
116
- }
117
- }
118
- const u = m.usage;
119
- if (u && row.id)
120
- acc.messages.set(row.id, {
121
- input: u.input ?? 0,
122
- cacheCreate: u.cacheWrite ?? 0,
123
- cacheRead: u.cacheRead ?? 0,
124
- output: u.output ?? 0
125
- });
126
- } else if (m?.role === "toolResult" && m.toolCallId && acc.asks.has(m.toolCallId)) {
127
- acc.askMs += ts - (acc.asks.get(m.toolCallId) ?? ts);
128
- }
129
- return;
130
- }
131
- if (row.type === "compaction")
132
- acc.compactions++;
133
- const blocks = Array.isArray(row.message?.content) ? row.message.content : [];
134
- for (const b of blocks) {
135
- if (b.type === "tool_use" && b.id) {
136
- acc.toolIds.add(b.id);
137
- if (b.name && OPERATOR_TOOLS.has(b.name))
138
- acc.asks.set(b.id, ts);
139
- } else if (b.type === "tool_result" && b.tool_use_id && acc.asks.has(b.tool_use_id)) {
140
- acc.askMs += ts - (acc.asks.get(b.tool_use_id) ?? ts);
141
- }
142
- }
143
- const u = row.message?.usage;
144
- if (row.type === "assistant" && u && row.message?.id) {
145
- acc.messages.set(row.message.id, {
146
- input: u.input_tokens ?? 0,
147
- cacheCreate: u.cache_creation_input_tokens ?? 0,
148
- cacheRead: u.cache_read_input_tokens ?? 0,
149
- output: u.output_tokens ?? 0
150
- });
151
- }
152
- }
153
- var SHELL_TOOLS = new Set(["bash", "shell", "exec", "exec_command", "run_command", "command"]);
154
- var SUBAGENT_TOOLS = new Set(["agent", "task", "invoke_subagent", "subagent", "dispatch_agent"]);
155
- var LOWERCASE_OPERATOR_TOOLS = new Set(["askuserquestion", "ask_user_question"]);
156
- var SENSITIVE_PREFIXES = ["drizzle/", "packages/config/", ".github/", "plugins/sp/hooks/"];
157
- var SENSITIVE_GLOBS = ["apps/server/src/**/auth*", "**/*secret*", "**/*.sql"];
158
- function globToRegex(glob) {
159
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*");
160
- return new RegExp(`^${escaped}$`, "s");
161
- }
162
- var SENSITIVE_MATCHERS = [
163
- ...SENSITIVE_PREFIXES.map((prefix) => ({
164
- label: prefix,
165
- test: (path) => path.startsWith(prefix)
166
- })),
167
- ...SENSITIVE_GLOBS.map((glob) => {
168
- const regex = globToRegex(glob);
169
- return { label: glob, test: (path) => regex.test(path) };
170
- })
171
- ];
172
-
173
- // plugins/sp/lib/transcript.ts
174
- var SESSION_ID = /^[A-Za-z0-9_-]+$/;
175
- var PI_ROLES = new Set(["user", "assistant", "toolResult"]);
176
- function sniffFormat(rows) {
177
- for (const row of rows) {
178
- if (row.type === "message" && PI_ROLES.has(row.message?.role ?? ""))
179
- return "pi";
180
- if (row.type === "user" || row.type === "assistant")
181
- return "claude";
182
- }
183
- return "unknown";
184
- }
185
- function parseRows(lines) {
186
- const rows = [];
187
- let skippedLines = 0;
188
- for (const line of lines) {
189
- if (!line.trim())
190
- continue;
191
- let row;
192
- try {
193
- row = JSON.parse(line);
194
- } catch {
195
- skippedLines++;
196
- continue;
197
- }
198
- const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
199
- if (!Number.isNaN(ts))
200
- rows.push([row, ts]);
201
- }
202
- return { rows, skippedLines, format: sniffFormat(rows.map(([r]) => r)) };
203
- }
204
- function resolveTranscript(env, projectsRoot = join(homedir(), ".claude", "projects"), override) {
205
- if (override)
206
- return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: "no transcript" };
207
- const id = env.CLAUDE_CODE_SESSION_ID;
208
- if (!id) {
209
- const piFile = env.PI_SESSION_FILE;
210
- if (piFile !== undefined && piFile !== "") {
211
- return existsSync(piFile) ? { ok: true, path: piFile } : { ok: false, reason: `PI_SESSION_FILE ${piFile} does not exist` };
212
- }
213
- return {
214
- ok: false,
215
- reason: "no host session id; pass --transcript <path> (pi: ~/.pi/agent/sessions/<cwd-slug>/<file>.jsonl, " + "or set PI_SESSION_FILE)"
216
- };
217
- }
218
- if (!SESSION_ID.test(id))
219
- return { ok: false, reason: "refusing a session id with path characters" };
220
- if (!existsSync(projectsRoot))
221
- return { ok: false, reason: `no transcript root ${projectsRoot}` };
222
- for (const dir of readdirSync(projectsRoot)) {
223
- const path = join(projectsRoot, dir, `${id}.jsonl`);
224
- if (existsSync(path))
225
- return { ok: true, path };
226
- }
227
- return { ok: false, reason: `no transcript for session ${id}` };
228
- }
229
-
230
- // plugins/sp/scripts/session-timeline.ts
231
- var PROMPT_EXCERPT = 80;
232
- var render = (span) => ({
233
- ...span,
234
- work: formatDuration(span.workMs),
235
- wait: formatDuration(span.waitMs),
236
- token: formatTokenSplit(span.tokens)
237
- });
238
- function parseGroups(spec, count) {
239
- let next = 1;
240
- return spec.split(",").map((part) => {
241
- const [a = Number.NaN, b = a] = part.trim().split("-").map(Number);
242
- if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
243
- throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
244
- }
245
- next = b + 1;
246
- return [a, b];
247
- });
248
- }
249
- function buildTimeline(lines, group) {
250
- const open = [];
251
- const { rows, skippedLines, format } = parseRows(lines);
252
- const isPi = format === "pi";
253
- for (const [row, ts] of rows) {
254
- const prompt = promptText(row);
255
- if (prompt !== undefined) {
256
- open.push({ start: ts, prompt, ...newAcc(ts) });
257
- continue;
258
- }
259
- const seg = open.at(-1);
260
- if (seg)
261
- accumulate(seg, row, ts);
262
- }
263
- const segments = open.map((seg, i) => {
264
- const next = open[i + 1]?.start;
265
- const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
266
- const oneLine = seg.prompt.replace(/\s+/g, " ").trim();
267
- const span = {
268
- index: i + 1,
269
- start: new Date(seg.start).toISOString(),
270
- prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}\u2026` : oneLine,
271
- workMs: seg.last - seg.start - seg.askMs,
272
- waitMs: idle + seg.askMs,
273
- toolCalls: seg.toolIds.size,
274
- tokens: sumTokens([...seg.messages.values()])
275
- };
276
- return render(isPi ? { ...span, compactions: seg.compactions } : span);
277
- });
278
- const first = open[0];
279
- const last = open.at(-1);
280
- const elapsedMs = first && last ? last.last - first.start : 0;
281
- const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
282
- const timeline = {
283
- available: true,
284
- segments,
285
- totals: render(isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals),
286
- skippedLines
287
- };
288
- if (group) {
289
- timeline.stages = parseGroups(group, segments.length).map(([a, b]) => render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }));
290
- }
291
- return timeline;
292
- }
293
- var SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
294
- function zeroSegmentReason(rows) {
295
- const format = sniffFormat(rows.map(([r]) => r));
296
- if (format === "unknown") {
297
- const counts = new Map;
298
- for (const [r] of rows) {
299
- const kind = typeof r.type === "string" && r.type !== "" ? r.type : "(untyped)";
300
- counts.set(kind, (counts.get(kind) ?? 0) + 1);
301
- }
302
- 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(", ");
303
- return `unrecognized transcript format (row types: ${census})`;
304
- }
305
- return `${format} transcript with no operator prompts (${rows.length} rows)`;
306
- }
307
- function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot, writeErr = (s) => process.stderr.write(s)) {
308
- let transcript;
309
- let group;
310
- for (let i = 0;i < argv.length; i++) {
311
- const arg = argv[i];
312
- if (arg === "--transcript" && argv[i + 1])
313
- transcript = argv[++i];
314
- else if (arg === "--group" && argv[i + 1])
315
- group = argv[++i];
316
- else if (arg === "--spur-bin" && argv[i + 1])
317
- i++;
318
- else {
319
- writeErr(`${SESSION_TIMELINE_USAGE}
320
- `);
321
- return 2;
322
- }
323
- }
324
- const resolved = resolveTranscript(env, projectsRoot, transcript);
325
- if (!resolved.ok) {
326
- write(`${JSON.stringify({ available: false, reason: resolved.reason })}
327
- `);
328
- return 0;
329
- }
330
- try {
331
- const lines = readFileSync(resolved.path, "utf8").split(`
332
- `);
333
- const timeline = buildTimeline(lines, group);
334
- if (timeline.segments.length === 0) {
335
- write(`${JSON.stringify({ available: false, reason: zeroSegmentReason(parseRows(lines).rows) })}
336
- `);
337
- return 0;
338
- }
339
- write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}
340
- `);
341
- return 0;
342
- } catch (error) {
343
- writeErr(`session-timeline: ${error.message}
344
- `);
345
- return 2;
346
- }
347
- }
348
- process.exit(main(process.argv.slice(2)));
349
- export {
350
- SESSION_TIMELINE_USAGE,
351
- buildTimeline,
352
- formatDuration,
353
- formatTokenSplit,
354
- formatTokens,
355
- main,
356
- parseGroups,
357
- resolveTranscript,
358
- zeroSegmentReason
359
- };
@@ -1,190 +0,0 @@
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
- type Row,
23
- resolveTranscript,
24
- type Span,
25
- sniffFormat,
26
- sumSpans,
27
- sumTokens,
28
- } from '../lib/transcript';
29
-
30
- export { formatDuration, formatTokenSplit, formatTokens, resolveTranscript } from '../lib/transcript';
31
-
32
- /** A span plus its `M:SS` / `H:MM:SS` and token renderings, so the review table needs no arithmetic. */
33
- export type Rendered<T> = T & { work: string; wait: string; token: string };
34
-
35
- export interface Timeline {
36
- available: true;
37
- transcript?: string;
38
- segments: Rendered<Span & { index: number; start: string; prompt: string; compactions?: number }>[];
39
- stages?: Rendered<Span & { segments: string }>[];
40
- totals: Rendered<Span & { elapsedMs: number; elapsed: string; compactions?: number }>;
41
- skippedLines: number;
42
- }
43
-
44
- const PROMPT_EXCERPT = 80;
45
-
46
- const render = <T extends Span>(span: T): Rendered<T> => ({
47
- ...span,
48
- work: formatDuration(span.workMs),
49
- wait: formatDuration(span.waitMs),
50
- token: formatTokenSplit(span.tokens),
51
- });
52
-
53
- /** "1-3,4" → [[1,3],[4,4]]; 1-based, ascending, in range, non-overlapping. */
54
- export function parseGroups(spec: string, count: number): [number, number][] {
55
- let next = 1;
56
- return spec.split(',').map((part) => {
57
- const [a = Number.NaN, b = a] = part.trim().split('-').map(Number);
58
- if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
59
- throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
60
- }
61
- next = b + 1;
62
- return [a, b] as [number, number];
63
- });
64
- }
65
-
66
- interface Open extends Acc {
67
- start: number;
68
- prompt: string;
69
- }
70
-
71
- export function buildTimeline(lines: string[], group?: string): Timeline {
72
- const open: Open[] = [];
73
- const { rows, skippedLines, format } = parseRows(lines);
74
- const isPi = format === 'pi';
75
-
76
- for (const [row, ts] of rows) {
77
- const prompt = promptText(row);
78
- if (prompt !== undefined) {
79
- open.push({ start: ts, prompt, ...newAcc(ts) });
80
- continue;
81
- }
82
- const seg = open.at(-1);
83
- if (seg) accumulate(seg, row, ts); // rows before the first prompt are session preamble
84
- }
85
-
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
- );
103
-
104
- const first = open[0];
105
- const last = open.at(-1);
106
- const elapsedMs = first && last ? last.last - first.start : 0;
107
- const totals = { ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) };
108
- const timeline: Timeline = {
109
- available: true,
110
- segments,
111
- totals: render(
112
- isPi ? { ...totals, compactions: segments.reduce((n, s) => n + (s.compactions ?? 0), 0) } : totals,
113
- ),
114
- skippedLines,
115
- };
116
- if (group) {
117
- timeline.stages = parseGroups(group, segments.length).map(([a, b]) =>
118
- render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }),
119
- );
120
- }
121
- return timeline;
122
- }
123
-
124
- export const SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
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
-
150
- export function main(
151
- argv: string[],
152
- env: Record<string, string | undefined> = getEnvVars(),
153
- write: (s: string) => void = (s) => process.stdout.write(s),
154
- projectsRoot?: string,
155
- writeErr: (s: string) => void = (s) => process.stderr.write(s),
156
- ): number {
157
- let transcript: string | undefined;
158
- let group: string | undefined;
159
- for (let i = 0; i < argv.length; i++) {
160
- const arg = argv[i];
161
- if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
162
- else if (arg === '--group' && argv[i + 1]) group = argv[++i];
163
- else if (arg === '--spur-bin' && argv[i + 1])
164
- i++; // 0482 R2: accepted, unused (no spur calls)
165
- else {
166
- writeErr(`${SESSION_TIMELINE_USAGE}\n`);
167
- return 2;
168
- }
169
- }
170
- const resolved = resolveTranscript(env, projectsRoot, transcript);
171
- if (!resolved.ok) {
172
- write(`${JSON.stringify({ available: false, reason: resolved.reason })}\n`);
173
- return 0;
174
- }
175
- try {
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
- }
182
- write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}\n`);
183
- return 0;
184
- } catch (error) {
185
- writeErr(`session-timeline: ${(error as Error).message}\n`);
186
- return 2;
187
- }
188
- }
189
-
190
- if (import.meta.main) process.exit(main(process.argv.slice(2)));