@gobing-ai/spur 0.3.98 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.global.yaml +17 -0
- package/config/decisions/task-pipeline.yaml +55 -0
- package/config/plugin-scripts.json +15 -0
- package/config/rules/boundary/sp-runtime-path.yaml +2 -0
- package/config/rules/quality/coverage-gate.yaml +5 -3
- package/config/rules/strict/runtime-boundaries.yaml +11 -0
- package/config/rules/structure/protected-files.yaml +3 -0
- package/config/templates/task/brainstorm.md +1 -1
- package/config/templates/task/feature-impl.md +1 -1
- package/config/templates/task/issue.md +1 -1
- package/config/templates/task/meta.md +1 -1
- package/config/templates/task/review.md +1 -1
- package/config/templates/task/standard.md +1 -1
- package/config/workflows/task-pipeline.yaml +178 -18
- package/package.json +9 -9
- package/plugins/sp/README.md +14 -1
- package/plugins/sp/agents/super-reviewer.md +25 -11
- package/plugins/sp/commands/dev-changelog.md +5 -4
- package/plugins/sp/commands/dev-review-session.md +12 -7
- package/plugins/sp/commands/dev-run.md +5 -2
- package/plugins/sp/commands/dev-runall.md +5 -2
- package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
- package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
- package/plugins/sp/hooks/hooks.json +45 -1
- package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
- package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +68 -10
- package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
- package/plugins/sp/lib/inline-run.generated.mjs +83 -20
- package/plugins/sp/lib/residual-scan.generated.d.mts +5 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +45 -5
- package/plugins/sp/lib/transcript.ts +160 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
- package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
- package/plugins/sp/scripts/inline-run-setup.ts +25 -31
- package/plugins/sp/scripts/persist-out-check.mjs +164 -0
- package/plugins/sp/scripts/persist-out-check.ts +185 -0
- package/plugins/sp/scripts/residual-scan.mjs +52 -15
- package/plugins/sp/scripts/residual-scan.ts +11 -11
- package/plugins/sp/scripts/run-summary.mjs +286 -0
- package/plugins/sp/scripts/run-summary.ts +241 -0
- package/plugins/sp/scripts/session-timeline.mjs +234 -0
- package/plugins/sp/scripts/session-timeline.ts +152 -0
- package/plugins/sp/scripts/task-diffstat.mjs +17 -5
- package/plugins/sp/scripts/task-diffstat.ts +25 -4
- package/plugins/sp/skills/code-verification/SKILL.md +8 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +17 -1
- package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
- package/plugins/sp/skills/session-review/SKILL.md +46 -24
- package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
- package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
- package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
- package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +40 -7
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +278 -42
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +17 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +139 -10
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
- package/schemas/decision-catalog.schema.json +226 -0
- package/schemas/spur-config.schema.json +25 -0
- package/spur.js +8856 -2564
- package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
- package/web/_astro/{BoardApp.Dflq5a8x.js → BoardApp.VeoxxLBy.js} +1 -1
- package/web/_astro/{TaskDetail.CK7oXddP.js → TaskDetail.LAyR_Pvn.js} +1 -1
- package/web/_astro/{arc.BIqkDATc.js → arc.f4e0OYLg.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.D0B3SvWv.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.C2YyKQmL.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DmtTYrt_.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
- package/web/_astro/channel.CUFdtWhm.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.HsRXbh7l.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.obE_P-R3.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.CjWTF5mz.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DMo799Ax.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.-cE6JOKZ.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.D6Rw6HIk.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.d5rBD73Z.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.r3ox40Q3.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CGGbvjjJ.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CmKtPd4l.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.QNg5GXs8.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.B1SabsjC.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.RtDbNj89.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.IzZpuM-Y.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.DPTF_fgv.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.CCyza39P.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.BXAi8zcZ.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.DUDX7_MR.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.C63QTh3N.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.CnlZJSwG.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CTqIrOPC.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
- package/web/_astro/index.Dg_3bOaJ.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.BYMiDFh9.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.CRiQmI-z.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.YQgWBsEU.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.1ECnrrK1.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
- package/web/_astro/{linear.DzVA71yT.js → linear.DguUUe6V.js} +1 -1
- package/web/_astro/{mermaid.core.BK7P2Sr_.js → mermaid.core.CvgfmqVr.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BvXyWZK7.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.BiXk1TW3.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.krvBH8lh.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.ih1gL25c.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.CpUxkv4u.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.clAfeN7y.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.DpXWb8-m.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.ClZb9GIH.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.BLcX5dh7.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.oGgpKMpx.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.BPHtMtt-.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.BNZOPYSk.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
- package/web/board-runtime.json +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.CYbe-OTb.js +0 -322
- package/web/_astro/channel.JuKVyuhZ.js +0 -1
- package/web/_astro/index.CbNz17Sx.css +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* run-summary — the measured execution summary `/sp:dev-run` and `/sp:dev-runall` print by default
|
|
4
|
+
* (`--no-summary` skips it). Inputs: one or more `spur workflow progress <run-id> --json` files
|
|
5
|
+
* (`--progress [<label>=]<file>`) and the host Claude Code transcript (resolved like session-timeline,
|
|
6
|
+
* or `--transcript`). One file → one row per state visit; several → one row per run. Each row is
|
|
7
|
+
* windowed by its attempt timestamps and slices the transcript: work, operator wait, tool calls and
|
|
8
|
+
* tokens rendered `<total> / <non-cached>`. A driver-overhead row makes rows sum to the total;
|
|
9
|
+
* `--since <iso>` starts the total at the command's invocation. A window with no host transcript
|
|
10
|
+
* record (a subprocess stage) reads `n/a`, never 0. `--markdown` prints the table. Reporting only:
|
|
11
|
+
* no transcript → `{available:false}` (or one `n/a` line), exit 0. Node builtins only.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { getEnvVars } from '../lib/env';
|
|
15
|
+
import {
|
|
16
|
+
accumulate,
|
|
17
|
+
formatDuration,
|
|
18
|
+
formatTokenSplit,
|
|
19
|
+
newAcc,
|
|
20
|
+
parseRows,
|
|
21
|
+
promptText,
|
|
22
|
+
resolveTranscript,
|
|
23
|
+
type Span,
|
|
24
|
+
sumSpans,
|
|
25
|
+
sumTokens,
|
|
26
|
+
type Tokens,
|
|
27
|
+
zeroTokens,
|
|
28
|
+
} from '../lib/transcript';
|
|
29
|
+
|
|
30
|
+
interface ProgressAttempt {
|
|
31
|
+
startedAt?: string;
|
|
32
|
+
completedAt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The subset of `spur workflow progress <run-id> --json` the summary reads. */
|
|
36
|
+
export interface Progress {
|
|
37
|
+
runId: string;
|
|
38
|
+
workflow?: string;
|
|
39
|
+
status?: string;
|
|
40
|
+
states?: { state: string; visit?: number; status?: string; actions?: { attempts?: ProgressAttempt[] }[] }[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A stage row; `toolCalls`/`tokens` are null when the window holds no host transcript record. */
|
|
44
|
+
export interface SummaryRow {
|
|
45
|
+
stage: string;
|
|
46
|
+
status: string;
|
|
47
|
+
workMs: number;
|
|
48
|
+
waitMs: number;
|
|
49
|
+
toolCalls: number | null;
|
|
50
|
+
tokens: Tokens | null;
|
|
51
|
+
time: string;
|
|
52
|
+
wait: string;
|
|
53
|
+
token: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunSummary {
|
|
57
|
+
available: true;
|
|
58
|
+
transcript?: string;
|
|
59
|
+
rows: SummaryRow[];
|
|
60
|
+
total: SummaryRow;
|
|
61
|
+
skippedLines: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Window {
|
|
65
|
+
stage: string;
|
|
66
|
+
status: string;
|
|
67
|
+
start: number;
|
|
68
|
+
end: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function attemptWindow(attempts: ProgressAttempt[]): [number, number] | undefined {
|
|
72
|
+
const starts = attempts.map((a) => Date.parse(a.startedAt ?? '')).filter((n) => !Number.isNaN(n));
|
|
73
|
+
const ends = attempts.map((a) => Date.parse(a.completedAt ?? '')).filter((n) => !Number.isNaN(n));
|
|
74
|
+
return starts.length && ends.length ? [Math.min(...starts), Math.max(...ends)] : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function summaryRow(stage: string, status: string, span: Span, measured: boolean): SummaryRow {
|
|
78
|
+
return {
|
|
79
|
+
stage,
|
|
80
|
+
status,
|
|
81
|
+
workMs: span.workMs,
|
|
82
|
+
waitMs: span.waitMs,
|
|
83
|
+
toolCalls: measured ? span.toolCalls : null,
|
|
84
|
+
tokens: measured ? span.tokens : null,
|
|
85
|
+
time: formatDuration(span.workMs),
|
|
86
|
+
wait: formatDuration(span.waitMs),
|
|
87
|
+
token: formatTokenSplit(measured ? span.tokens : null),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* dev-run / dev-runall execution summary. One progress file → one row per state visit; several →
|
|
93
|
+
* one row per run. Each window spans its first attempt start → last completion (inclusive) and
|
|
94
|
+
* slices the host transcript; work = window − AskUserQuestion answer time.
|
|
95
|
+
*/
|
|
96
|
+
export function buildRunSummary(
|
|
97
|
+
lines: string[],
|
|
98
|
+
inputs: { label?: string; progress: Progress }[],
|
|
99
|
+
since?: string,
|
|
100
|
+
): RunSummary {
|
|
101
|
+
const windows: Window[] = [];
|
|
102
|
+
for (const { label, progress } of inputs) {
|
|
103
|
+
const states = (progress.states ?? []).flatMap((s) => {
|
|
104
|
+
const w = attemptWindow((s.actions ?? []).flatMap((a) => a.attempts ?? []));
|
|
105
|
+
return w ? [{ s, w }] : [];
|
|
106
|
+
});
|
|
107
|
+
if (!states.length) throw new Error(`progress for run ${progress.runId} has no timed attempts`);
|
|
108
|
+
if (inputs.length === 1) {
|
|
109
|
+
for (const { s, w } of states) {
|
|
110
|
+
const stage = (s.visit ?? 1) > 1 ? `${s.state} (visit ${s.visit})` : s.state;
|
|
111
|
+
windows.push({ stage, status: s.status ?? '', start: w[0], end: w[1] });
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const stage = label ?? `${progress.workflow ?? 'run'} ${progress.runId.slice(0, 8)}`;
|
|
115
|
+
const start = Math.min(...states.map(({ w }) => w[0]));
|
|
116
|
+
windows.push({
|
|
117
|
+
stage,
|
|
118
|
+
status: progress.status ?? '',
|
|
119
|
+
start,
|
|
120
|
+
end: Math.max(...states.map(({ w }) => w[1])),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
windows.sort((a, b) => a.start - b.start);
|
|
125
|
+
|
|
126
|
+
const { rows, skippedLines } = parseRows(lines);
|
|
127
|
+
const measure = (start: number, end: number): { span: Span; measured: boolean } => {
|
|
128
|
+
const acc = newAcc(start);
|
|
129
|
+
for (const [row, ts] of rows)
|
|
130
|
+
if (ts >= start && ts <= end && promptText(row) === undefined) accumulate(acc, row, ts);
|
|
131
|
+
const span = {
|
|
132
|
+
workMs: end - start - acc.askMs,
|
|
133
|
+
waitMs: acc.askMs,
|
|
134
|
+
toolCalls: acc.toolIds.size,
|
|
135
|
+
tokens: sumTokens([...acc.messages.values()]),
|
|
136
|
+
};
|
|
137
|
+
return { span, measured: acc.toolIds.size > 0 || acc.messages.size > 0 };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const sinceMs = since ? Date.parse(since) : Number.NaN;
|
|
141
|
+
const totalStart = Number.isNaN(sinceMs) ? Math.min(...windows.map((w) => w.start)) : sinceMs;
|
|
142
|
+
let totalEnd = Math.max(...windows.map((w) => w.end));
|
|
143
|
+
// With --since the total runs to the latest host activity, so setup/close work lands in overhead.
|
|
144
|
+
if (!Number.isNaN(sinceMs)) for (const [, ts] of rows) if (ts >= sinceMs) totalEnd = Math.max(totalEnd, ts);
|
|
145
|
+
|
|
146
|
+
const stageRows = windows.map((w) => {
|
|
147
|
+
const { span, measured } = measure(w.start, w.end);
|
|
148
|
+
return summaryRow(w.stage, w.status, span, measured);
|
|
149
|
+
});
|
|
150
|
+
const totalMeasure = measure(totalStart, totalEnd);
|
|
151
|
+
const statuses = [...new Set(inputs.map((i) => i.progress.status ?? ''))].join('/');
|
|
152
|
+
const total = summaryRow('Total', statuses, totalMeasure.span, totalMeasure.measured);
|
|
153
|
+
|
|
154
|
+
// Parallel runs overlap, so their rows cannot partition the total: no overhead row then.
|
|
155
|
+
const overlaps = windows.some((w, i) => i > 0 && w.start < (windows[i - 1]?.end ?? 0));
|
|
156
|
+
const sum = sumSpans(
|
|
157
|
+
stageRows.map((r) => ({ ...r, toolCalls: r.toolCalls ?? 0, tokens: r.tokens ?? zeroTokens() })),
|
|
158
|
+
);
|
|
159
|
+
const overhead: Span = {
|
|
160
|
+
workMs: total.workMs - sum.workMs,
|
|
161
|
+
waitMs: total.waitMs - sum.waitMs,
|
|
162
|
+
toolCalls: (total.toolCalls ?? 0) - sum.toolCalls,
|
|
163
|
+
tokens: Object.fromEntries(
|
|
164
|
+
(Object.keys(sum.tokens) as (keyof Tokens)[]).map((k) => [k, (total.tokens?.[k] ?? 0) - sum.tokens[k]]),
|
|
165
|
+
) as Tokens,
|
|
166
|
+
};
|
|
167
|
+
if (!overlaps && overhead.workMs > 0)
|
|
168
|
+
stageRows.push(summaryRow('driver overhead', '', overhead, totalMeasure.measured));
|
|
169
|
+
|
|
170
|
+
return { available: true, rows: stageRows, total, skippedLines };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function renderSummaryMarkdown(summary: RunSummary): string {
|
|
174
|
+
const line = (r: SummaryRow, bold = false) => {
|
|
175
|
+
const stage = bold ? `**${r.stage}**` : r.stage;
|
|
176
|
+
return `| ${stage} | ${r.status} | ${r.time} | ${r.wait} | ${r.toolCalls ?? 'n/a'} | ${r.token} |`;
|
|
177
|
+
};
|
|
178
|
+
return [
|
|
179
|
+
'| Stage | Status | Time | Wait | Tool calls | Token (total / non-cached) |',
|
|
180
|
+
'| --- | --- | ---: | ---: | ---: | ---: |',
|
|
181
|
+
...summary.rows.map((r) => line(r)),
|
|
182
|
+
line(summary.total, true),
|
|
183
|
+
].join('\n');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const RUN_SUMMARY_USAGE =
|
|
187
|
+
'usage: run-summary --progress [<label>=]<file> [--progress ...] [--transcript <path>] [--since <iso>] [--markdown]';
|
|
188
|
+
|
|
189
|
+
export function main(
|
|
190
|
+
argv: string[],
|
|
191
|
+
env: Record<string, string | undefined> = getEnvVars(),
|
|
192
|
+
write: (s: string) => void = (s) => process.stdout.write(s),
|
|
193
|
+
projectsRoot?: string,
|
|
194
|
+
): number {
|
|
195
|
+
let transcript: string | undefined;
|
|
196
|
+
let since: string | undefined;
|
|
197
|
+
let markdown = false;
|
|
198
|
+
const progressArgs: string[] = [];
|
|
199
|
+
for (let i = 0; i < argv.length; i++) {
|
|
200
|
+
const arg = argv[i];
|
|
201
|
+
if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
|
|
202
|
+
else if (arg === '--progress' && argv[i + 1]) progressArgs.push(argv[++i] as string);
|
|
203
|
+
else if (arg === '--since' && argv[i + 1]) since = argv[++i];
|
|
204
|
+
else if (arg === '--markdown') markdown = true;
|
|
205
|
+
else if (arg === '--spur-bin' && argv[i + 1])
|
|
206
|
+
i++; // 0482 R2: accepted, unused (no spur calls)
|
|
207
|
+
else {
|
|
208
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!progressArgs.length) {
|
|
213
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}\n`);
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
217
|
+
if (!resolved.ok) {
|
|
218
|
+
const out = markdown
|
|
219
|
+
? `Execution summary: n/a (${resolved.reason})`
|
|
220
|
+
: JSON.stringify({ available: false, reason: resolved.reason });
|
|
221
|
+
write(`${out}\n`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const inputs = progressArgs.map((arg) => {
|
|
226
|
+
const eq = arg.indexOf('=');
|
|
227
|
+
const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
|
|
228
|
+
return { label, progress: JSON.parse(readFileSync(file, 'utf8')) as Progress };
|
|
229
|
+
});
|
|
230
|
+
const summary = buildRunSummary(readFileSync(resolved.path, 'utf8').split('\n'), inputs, since);
|
|
231
|
+
write(
|
|
232
|
+
`${markdown ? renderSummaryMarkdown(summary) : JSON.stringify({ ...summary, transcript: resolved.path })}\n`,
|
|
233
|
+
);
|
|
234
|
+
return 0;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
process.stderr.write(`run-summary: ${(error as Error).message}\n`);
|
|
237
|
+
return 2;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/session-timeline.ts
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
|
|
7
|
+
// plugins/sp/lib/env.ts
|
|
8
|
+
function getEnvVars() {
|
|
9
|
+
return process.env;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// plugins/sp/lib/transcript.ts
|
|
13
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
var SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
17
|
+
var OPERATOR_TOOLS = new Set(["AskUserQuestion"]);
|
|
18
|
+
var zeroTokens = () => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
|
|
19
|
+
function formatDuration(ms) {
|
|
20
|
+
const s = Math.round(ms / 1000);
|
|
21
|
+
const h = Math.floor(s / 3600);
|
|
22
|
+
const m = Math.floor(s % 3600 / 60);
|
|
23
|
+
const ss = String(s % 60).padStart(2, "0");
|
|
24
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
|
|
25
|
+
}
|
|
26
|
+
function formatTokens(n) {
|
|
27
|
+
if (n >= 999500)
|
|
28
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
29
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
30
|
+
}
|
|
31
|
+
function formatTokenSplit(t) {
|
|
32
|
+
if (!t)
|
|
33
|
+
return "n/a";
|
|
34
|
+
const nonCached = t.input + t.cacheCreate + t.output;
|
|
35
|
+
return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
|
|
36
|
+
}
|
|
37
|
+
function promptText(row) {
|
|
38
|
+
if (row.type !== "user" || row.isMeta || row.isCompactSummary)
|
|
39
|
+
return;
|
|
40
|
+
const content = row.message?.content;
|
|
41
|
+
if (typeof content === "string")
|
|
42
|
+
return content;
|
|
43
|
+
if (!Array.isArray(content) || content.some((b) => b.type === "tool_result"))
|
|
44
|
+
return;
|
|
45
|
+
return content.find((b) => b.type === "text")?.text;
|
|
46
|
+
}
|
|
47
|
+
function sumTokens(all) {
|
|
48
|
+
const total = zeroTokens();
|
|
49
|
+
for (const t of all)
|
|
50
|
+
for (const k of Object.keys(total))
|
|
51
|
+
total[k] += t[k];
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
function sumSpans(spans) {
|
|
55
|
+
return {
|
|
56
|
+
workMs: spans.reduce((n, s) => n + s.workMs, 0),
|
|
57
|
+
waitMs: spans.reduce((n, s) => n + s.waitMs, 0),
|
|
58
|
+
toolCalls: spans.reduce((n, s) => n + s.toolCalls, 0),
|
|
59
|
+
tokens: sumTokens(spans.map((s) => s.tokens))
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
var newAcc = (ts) => ({
|
|
63
|
+
last: ts,
|
|
64
|
+
askMs: 0,
|
|
65
|
+
asks: new Map,
|
|
66
|
+
toolIds: new Set,
|
|
67
|
+
messages: new Map
|
|
68
|
+
});
|
|
69
|
+
function accumulate(acc, row, ts) {
|
|
70
|
+
acc.last = Math.max(acc.last, ts);
|
|
71
|
+
const blocks = Array.isArray(row.message?.content) ? row.message.content : [];
|
|
72
|
+
for (const b of blocks) {
|
|
73
|
+
if (b.type === "tool_use" && b.id) {
|
|
74
|
+
acc.toolIds.add(b.id);
|
|
75
|
+
if (b.name && OPERATOR_TOOLS.has(b.name))
|
|
76
|
+
acc.asks.set(b.id, ts);
|
|
77
|
+
} else if (b.type === "tool_result" && b.tool_use_id && acc.asks.has(b.tool_use_id)) {
|
|
78
|
+
acc.askMs += ts - (acc.asks.get(b.tool_use_id) ?? ts);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const u = row.message?.usage;
|
|
82
|
+
if (row.type === "assistant" && u && row.message?.id) {
|
|
83
|
+
acc.messages.set(row.message.id, {
|
|
84
|
+
input: u.input_tokens ?? 0,
|
|
85
|
+
cacheCreate: u.cache_creation_input_tokens ?? 0,
|
|
86
|
+
cacheRead: u.cache_read_input_tokens ?? 0,
|
|
87
|
+
output: u.output_tokens ?? 0
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function parseRows(lines) {
|
|
92
|
+
const rows = [];
|
|
93
|
+
let skippedLines = 0;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line.trim())
|
|
96
|
+
continue;
|
|
97
|
+
let row;
|
|
98
|
+
try {
|
|
99
|
+
row = JSON.parse(line);
|
|
100
|
+
} catch {
|
|
101
|
+
skippedLines++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
|
|
105
|
+
if (!Number.isNaN(ts))
|
|
106
|
+
rows.push([row, ts]);
|
|
107
|
+
}
|
|
108
|
+
return { rows, skippedLines };
|
|
109
|
+
}
|
|
110
|
+
function resolveTranscript(env, projectsRoot = join(homedir(), ".claude", "projects"), override) {
|
|
111
|
+
if (override)
|
|
112
|
+
return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: "no transcript" };
|
|
113
|
+
const id = env.CLAUDE_CODE_SESSION_ID;
|
|
114
|
+
if (!id)
|
|
115
|
+
return { ok: false, reason: "no host session id (CLAUDE_CODE_SESSION_ID); pass --transcript <path>" };
|
|
116
|
+
if (!SESSION_ID.test(id))
|
|
117
|
+
return { ok: false, reason: "refusing a session id with path characters" };
|
|
118
|
+
if (!existsSync(projectsRoot))
|
|
119
|
+
return { ok: false, reason: `no transcript root ${projectsRoot}` };
|
|
120
|
+
for (const dir of readdirSync(projectsRoot)) {
|
|
121
|
+
const path = join(projectsRoot, dir, `${id}.jsonl`);
|
|
122
|
+
if (existsSync(path))
|
|
123
|
+
return { ok: true, path };
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, reason: `no transcript for session ${id}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// plugins/sp/scripts/session-timeline.ts
|
|
129
|
+
var PROMPT_EXCERPT = 80;
|
|
130
|
+
var render = (span) => ({
|
|
131
|
+
...span,
|
|
132
|
+
work: formatDuration(span.workMs),
|
|
133
|
+
wait: formatDuration(span.waitMs),
|
|
134
|
+
token: formatTokenSplit(span.tokens)
|
|
135
|
+
});
|
|
136
|
+
function parseGroups(spec, count) {
|
|
137
|
+
let next = 1;
|
|
138
|
+
return spec.split(",").map((part) => {
|
|
139
|
+
const [a = Number.NaN, b = a] = part.trim().split("-").map(Number);
|
|
140
|
+
if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
|
|
141
|
+
throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
|
|
142
|
+
}
|
|
143
|
+
next = b + 1;
|
|
144
|
+
return [a, b];
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function buildTimeline(lines, group) {
|
|
148
|
+
const open = [];
|
|
149
|
+
const { rows, skippedLines } = parseRows(lines);
|
|
150
|
+
for (const [row, ts] of rows) {
|
|
151
|
+
const prompt = promptText(row);
|
|
152
|
+
if (prompt !== undefined) {
|
|
153
|
+
open.push({ start: ts, prompt, ...newAcc(ts) });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const seg = open.at(-1);
|
|
157
|
+
if (seg)
|
|
158
|
+
accumulate(seg, row, ts);
|
|
159
|
+
}
|
|
160
|
+
const segments = open.map((seg, i) => {
|
|
161
|
+
const next = open[i + 1]?.start;
|
|
162
|
+
const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
|
|
163
|
+
const oneLine = seg.prompt.replace(/\s+/g, " ").trim();
|
|
164
|
+
return render({
|
|
165
|
+
index: i + 1,
|
|
166
|
+
start: new Date(seg.start).toISOString(),
|
|
167
|
+
prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}\u2026` : oneLine,
|
|
168
|
+
workMs: seg.last - seg.start - seg.askMs,
|
|
169
|
+
waitMs: idle + seg.askMs,
|
|
170
|
+
toolCalls: seg.toolIds.size,
|
|
171
|
+
tokens: sumTokens([...seg.messages.values()])
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
const first = open[0];
|
|
175
|
+
const last = open.at(-1);
|
|
176
|
+
const elapsedMs = first && last ? last.last - first.start : 0;
|
|
177
|
+
const timeline = {
|
|
178
|
+
available: true,
|
|
179
|
+
segments,
|
|
180
|
+
totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
|
|
181
|
+
skippedLines
|
|
182
|
+
};
|
|
183
|
+
if (group) {
|
|
184
|
+
timeline.stages = parseGroups(group, segments.length).map(([a, b]) => render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }));
|
|
185
|
+
}
|
|
186
|
+
return timeline;
|
|
187
|
+
}
|
|
188
|
+
var SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
|
|
189
|
+
function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot, writeErr = (s) => process.stderr.write(s)) {
|
|
190
|
+
let transcript;
|
|
191
|
+
let group;
|
|
192
|
+
for (let i = 0;i < argv.length; i++) {
|
|
193
|
+
const arg = argv[i];
|
|
194
|
+
if (arg === "--transcript" && argv[i + 1])
|
|
195
|
+
transcript = argv[++i];
|
|
196
|
+
else if (arg === "--group" && argv[i + 1])
|
|
197
|
+
group = argv[++i];
|
|
198
|
+
else if (arg === "--spur-bin" && argv[i + 1])
|
|
199
|
+
i++;
|
|
200
|
+
else {
|
|
201
|
+
writeErr(`${SESSION_TIMELINE_USAGE}
|
|
202
|
+
`);
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
207
|
+
if (!resolved.ok) {
|
|
208
|
+
write(`${JSON.stringify({ available: false, reason: resolved.reason })}
|
|
209
|
+
`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const timeline = buildTimeline(readFileSync(resolved.path, "utf8").split(`
|
|
214
|
+
`), group);
|
|
215
|
+
write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}
|
|
216
|
+
`);
|
|
217
|
+
return 0;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
writeErr(`session-timeline: ${error.message}
|
|
220
|
+
`);
|
|
221
|
+
return 2;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
process.exit(main(process.argv.slice(2)));
|
|
225
|
+
export {
|
|
226
|
+
resolveTranscript,
|
|
227
|
+
parseGroups,
|
|
228
|
+
main,
|
|
229
|
+
formatTokens,
|
|
230
|
+
formatTokenSplit,
|
|
231
|
+
formatDuration,
|
|
232
|
+
buildTimeline,
|
|
233
|
+
SESSION_TIMELINE_USAGE
|
|
234
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* session-timeline — measured time and token evidence for the sp:session-review Time breakdown.
|
|
4
|
+
* The visible conversation carries no timestamps or usage (so the table read `n/a`); the host
|
|
5
|
+
* transcript carries both. Reads the active Claude Code JSONL read-only
|
|
6
|
+
* (`~/.claude/projects/<slug>/<CLAUDE_CODE_SESSION_ID>.jsonl`, or `--transcript`), one segment per
|
|
7
|
+
* operator prompt: work = prompt → last activity; wait = idle until the next prompt plus time spent
|
|
8
|
+
* answering an AskUserQuestion; tokens counted once per message id and rendered `<total> /
|
|
9
|
+
* <non-cached>`. `--group "1-3,4"` sums segments into stages. No transcript → `{available:false}`,
|
|
10
|
+
* exit 0, and the review renders `n/a` instead of guessing. Node builtins only (standalone plugin).
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { getEnvVars } from '../lib/env';
|
|
14
|
+
import {
|
|
15
|
+
type Acc,
|
|
16
|
+
accumulate,
|
|
17
|
+
formatDuration,
|
|
18
|
+
formatTokenSplit,
|
|
19
|
+
newAcc,
|
|
20
|
+
parseRows,
|
|
21
|
+
promptText,
|
|
22
|
+
resolveTranscript,
|
|
23
|
+
type Span,
|
|
24
|
+
sumSpans,
|
|
25
|
+
sumTokens,
|
|
26
|
+
} from '../lib/transcript';
|
|
27
|
+
|
|
28
|
+
export { formatDuration, formatTokenSplit, formatTokens, resolveTranscript } from '../lib/transcript';
|
|
29
|
+
|
|
30
|
+
/** A span plus its `M:SS` / `H:MM:SS` and token renderings, so the review table needs no arithmetic. */
|
|
31
|
+
export type Rendered<T> = T & { work: string; wait: string; token: string };
|
|
32
|
+
|
|
33
|
+
export interface Timeline {
|
|
34
|
+
available: true;
|
|
35
|
+
transcript?: string;
|
|
36
|
+
segments: Rendered<Span & { index: number; start: string; prompt: string }>[];
|
|
37
|
+
stages?: Rendered<Span & { segments: string }>[];
|
|
38
|
+
totals: Rendered<Span & { elapsedMs: number; elapsed: string }>;
|
|
39
|
+
skippedLines: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PROMPT_EXCERPT = 80;
|
|
43
|
+
|
|
44
|
+
const render = <T extends Span>(span: T): Rendered<T> => ({
|
|
45
|
+
...span,
|
|
46
|
+
work: formatDuration(span.workMs),
|
|
47
|
+
wait: formatDuration(span.waitMs),
|
|
48
|
+
token: formatTokenSplit(span.tokens),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** "1-3,4" → [[1,3],[4,4]]; 1-based, ascending, in range, non-overlapping. */
|
|
52
|
+
export function parseGroups(spec: string, count: number): [number, number][] {
|
|
53
|
+
let next = 1;
|
|
54
|
+
return spec.split(',').map((part) => {
|
|
55
|
+
const [a = Number.NaN, b = a] = part.trim().split('-').map(Number);
|
|
56
|
+
if (!Number.isInteger(a) || !Number.isInteger(b) || a < next || b < a || b > count) {
|
|
57
|
+
throw new Error(`invalid --group "${part}" (segments 1-${count}, ascending, no overlap)`);
|
|
58
|
+
}
|
|
59
|
+
next = b + 1;
|
|
60
|
+
return [a, b] as [number, number];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Open extends Acc {
|
|
65
|
+
start: number;
|
|
66
|
+
prompt: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildTimeline(lines: string[], group?: string): Timeline {
|
|
70
|
+
const open: Open[] = [];
|
|
71
|
+
const { rows, skippedLines } = parseRows(lines);
|
|
72
|
+
|
|
73
|
+
for (const [row, ts] of rows) {
|
|
74
|
+
const prompt = promptText(row);
|
|
75
|
+
if (prompt !== undefined) {
|
|
76
|
+
open.push({ start: ts, prompt, ...newAcc(ts) });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const seg = open.at(-1);
|
|
80
|
+
if (seg) accumulate(seg, row, ts); // rows before the first prompt are session preamble
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const segments = open.map((seg, i) => {
|
|
84
|
+
const next = open[i + 1]?.start;
|
|
85
|
+
const idle = next === undefined ? 0 : Math.max(0, next - seg.last);
|
|
86
|
+
const oneLine = seg.prompt.replace(/\s+/g, ' ').trim();
|
|
87
|
+
return render({
|
|
88
|
+
index: i + 1,
|
|
89
|
+
start: new Date(seg.start).toISOString(),
|
|
90
|
+
prompt: oneLine.length > PROMPT_EXCERPT ? `${oneLine.slice(0, PROMPT_EXCERPT)}…` : oneLine,
|
|
91
|
+
workMs: seg.last - seg.start - seg.askMs,
|
|
92
|
+
waitMs: idle + seg.askMs,
|
|
93
|
+
toolCalls: seg.toolIds.size,
|
|
94
|
+
tokens: sumTokens([...seg.messages.values()]),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const first = open[0];
|
|
99
|
+
const last = open.at(-1);
|
|
100
|
+
const elapsedMs = first && last ? last.last - first.start : 0;
|
|
101
|
+
const timeline: Timeline = {
|
|
102
|
+
available: true,
|
|
103
|
+
segments,
|
|
104
|
+
totals: render({ ...sumSpans(segments), elapsedMs, elapsed: formatDuration(elapsedMs) }),
|
|
105
|
+
skippedLines,
|
|
106
|
+
};
|
|
107
|
+
if (group) {
|
|
108
|
+
timeline.stages = parseGroups(group, segments.length).map(([a, b]) =>
|
|
109
|
+
render({ ...sumSpans(segments.slice(a - 1, b)), segments: a === b ? `${a}` : `${a}-${b}` }),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return timeline;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const SESSION_TIMELINE_USAGE = 'usage: session-timeline [--transcript <path>] [--group "1-3,4,..."]';
|
|
116
|
+
|
|
117
|
+
export function main(
|
|
118
|
+
argv: string[],
|
|
119
|
+
env: Record<string, string | undefined> = getEnvVars(),
|
|
120
|
+
write: (s: string) => void = (s) => process.stdout.write(s),
|
|
121
|
+
projectsRoot?: string,
|
|
122
|
+
writeErr: (s: string) => void = (s) => process.stderr.write(s),
|
|
123
|
+
): number {
|
|
124
|
+
let transcript: string | undefined;
|
|
125
|
+
let group: string | undefined;
|
|
126
|
+
for (let i = 0; i < argv.length; i++) {
|
|
127
|
+
const arg = argv[i];
|
|
128
|
+
if (arg === '--transcript' && argv[i + 1]) transcript = argv[++i];
|
|
129
|
+
else if (arg === '--group' && argv[i + 1]) group = argv[++i];
|
|
130
|
+
else if (arg === '--spur-bin' && argv[i + 1])
|
|
131
|
+
i++; // 0482 R2: accepted, unused (no spur calls)
|
|
132
|
+
else {
|
|
133
|
+
writeErr(`${SESSION_TIMELINE_USAGE}\n`);
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
138
|
+
if (!resolved.ok) {
|
|
139
|
+
write(`${JSON.stringify({ available: false, reason: resolved.reason })}\n`);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const timeline = buildTimeline(readFileSync(resolved.path, 'utf8').split('\n'), group);
|
|
144
|
+
write(`${JSON.stringify({ ...timeline, transcript: resolved.path })}\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
writeErr(`session-timeline: ${(error as Error).message}\n`);
|
|
148
|
+
return 2;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// plugins/sp/scripts/task-diffstat.ts
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
+
import { fstatSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
|
|
9
9
|
// plugins/sp/lib/env.ts
|
|
@@ -85,10 +85,14 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
|
|
|
85
85
|
process.stderr.write(`task-diffstat: ${reason} \u2014 failing safe (sensitive)
|
|
86
86
|
`);
|
|
87
87
|
const row2 = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
|
|
88
|
-
|
|
89
|
-
`);
|
|
88
|
+
writeArtifact(abs(relResult), row2);
|
|
90
89
|
return { ...row2, resultFile: relResult, exitCode: 0 };
|
|
91
90
|
};
|
|
91
|
+
let stdoutIsArtifact = false;
|
|
92
|
+
try {
|
|
93
|
+
const [out, art] = [fstatSync(1), statSync(abs(relResult))];
|
|
94
|
+
stdoutIsArtifact = out.dev === art.dev && out.ino === art.ino;
|
|
95
|
+
} catch {}
|
|
92
96
|
let base = "";
|
|
93
97
|
try {
|
|
94
98
|
base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), "utf8").trim();
|
|
@@ -125,9 +129,17 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
|
|
|
125
129
|
const all = [...paths].filter((p) => p.length > 0).sort();
|
|
126
130
|
const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
|
|
127
131
|
const row = { files: all.length, insertions, deletions, paths: all, sensitive };
|
|
128
|
-
|
|
132
|
+
writeArtifact(abs(relResult), row);
|
|
133
|
+
if (stdoutIsArtifact)
|
|
134
|
+
process.stderr.write(`task-diffstat: stdout redirected onto the artifact \u2014 kept; redirect stdout elsewhere
|
|
135
|
+
`);
|
|
136
|
+
return { ...row, resultFile: relResult, exitCode: stdoutIsArtifact ? 1 : 0 };
|
|
137
|
+
}
|
|
138
|
+
function writeArtifact(absPath, row) {
|
|
139
|
+
const tmp = `${absPath}.tmp`;
|
|
140
|
+
writeFileSync(tmp, `${JSON.stringify(row)}
|
|
129
141
|
`);
|
|
130
|
-
|
|
142
|
+
renameSync(tmp, absPath);
|
|
131
143
|
}
|
|
132
144
|
var TASK_DIFFSTAT_USAGE = "usage: task-diffstat (env: wbs)";
|
|
133
145
|
function main(argv, env = getEnvVars(), options = {}) {
|