@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,185 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* persist-out-check — WT-4 pre-removal assertion for E71 persist-out (task 1067 R1).
|
|
4
|
+
*
|
|
5
|
+
* Before `git worktree remove`, verify the worktree's durable evidence would not be
|
|
6
|
+
* abandoned: every file the worktree owns under `.spur/run/` (`<wbs>-*` per forwarded
|
|
7
|
+
* task file, `<runId>-*` per forwarded run id) and the whole `.spur/memory/evidence/`
|
|
8
|
+
* tree must exist in the invoking tree with identical bytes. Missing files BLOCK the
|
|
9
|
+
* removal (persist-out was skipped or stale); divergent files block too (persist-out
|
|
10
|
+
* never overwrites — reconcile by hand per execution-batch.md). No new public CLI verb:
|
|
11
|
+
* this is a driver-side assertion step, not a corpus tool.
|
|
12
|
+
*
|
|
13
|
+
* ponytail: file-level comparison only — DB run rows and nested-run deep walks stay
|
|
14
|
+
* persist-out's job (`inline-run-setup --persist-out`); this checks the planes E71
|
|
15
|
+
* names as files. Listing caps (64/prefix, 256 evidence) refuse runaways by name.
|
|
16
|
+
*
|
|
17
|
+
* Node-builtin imports only; pure helpers are exported for tests. Exit 0 ok, 1
|
|
18
|
+
* blocked/divergent, 2 usage error. Normal output is the finding lines themselves.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
23
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const EVIDENCE_DIR = join('.spur', 'memory', 'evidence');
|
|
26
|
+
const RUN_DIR = join('.spur', 'run');
|
|
27
|
+
const NAMED_CAP = 32;
|
|
28
|
+
const PREFIX_CAP = 64;
|
|
29
|
+
const EVIDENCE_CAP = 256;
|
|
30
|
+
|
|
31
|
+
/** Task files own their `<wbs>-*` evidence: leading digits before `_` in the basename. */
|
|
32
|
+
export function wbsOfTaskFile(path: string): string | null {
|
|
33
|
+
const m = /^(\d+)_/.exec(basename(path));
|
|
34
|
+
return m?.[1] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listFilesUnder(root: string, rel: string, cap: number): string[] | null {
|
|
38
|
+
const dir = join(root, rel);
|
|
39
|
+
if (!existsSync(dir)) return [];
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
const walk = (cur: string): boolean => {
|
|
42
|
+
for (const entry of readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
43
|
+
const p = join(cur, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
if (!walk(p)) return false;
|
|
46
|
+
} else {
|
|
47
|
+
out.push(relative(root, p));
|
|
48
|
+
if (out.length > cap) return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
return walk(dir) ? out : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Evidence files the worktree owns: `.spur/memory/evidence/**` plus `.spur/run/<prefix>-*`
|
|
58
|
+
* direct children per task-file WBS / run id. Returns null on a cap overrun or a listing
|
|
59
|
+
* failure (not a directory, permission denied) — the caller refuses either way.
|
|
60
|
+
*/
|
|
61
|
+
export function listObligations(wtRoot: string, wbsList: string[], runIds: string[]): string[] | null {
|
|
62
|
+
const evidence = listFilesUnder(wtRoot, EVIDENCE_DIR, EVIDENCE_CAP);
|
|
63
|
+
if (evidence === null) return null;
|
|
64
|
+
const out = [...evidence];
|
|
65
|
+
const prefixes = [...wbsList, ...runIds];
|
|
66
|
+
for (const prefix of prefixes) {
|
|
67
|
+
const dir = join(wtRoot, RUN_DIR);
|
|
68
|
+
if (!existsSync(dir)) continue;
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir).sort();
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const owned = entries.filter((n) => n.startsWith(`${prefix}-`));
|
|
76
|
+
if (owned.length > PREFIX_CAP) return null;
|
|
77
|
+
out.push(...owned.map((n) => join(RUN_DIR, n)));
|
|
78
|
+
}
|
|
79
|
+
return [...new Set(out)].sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Classify obligations against the invoking tree: missing, divergent (byte-differs), or ok. */
|
|
83
|
+
export function compareTrees(
|
|
84
|
+
wtRoot: string,
|
|
85
|
+
invokeRoot: string,
|
|
86
|
+
files: string[],
|
|
87
|
+
): { missing: string[]; divergent: string[]; ok: number } {
|
|
88
|
+
const missing: string[] = [];
|
|
89
|
+
const divergent: string[] = [];
|
|
90
|
+
let ok = 0;
|
|
91
|
+
for (const rel of files) {
|
|
92
|
+
const wtPath = join(wtRoot, rel);
|
|
93
|
+
const invPath = join(invokeRoot, rel);
|
|
94
|
+
if (!existsSync(invPath)) {
|
|
95
|
+
missing.push(rel);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const same = readFileSync(wtPath).equals(readFileSync(invPath));
|
|
100
|
+
if (same) ok++;
|
|
101
|
+
else divergent.push(rel);
|
|
102
|
+
} catch {
|
|
103
|
+
divergent.push(rel);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { missing, divergent, ok };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function persistOutCheckUsage(): string {
|
|
110
|
+
return 'usage: persist-out-check --from <worktree> [--task-file <path>]… [--run-id <id>]… [--root <invoke-tree>]';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The invoking tree is the MAIN repo root — never default to cwd: WT-4 runs from the
|
|
115
|
+
* worktree, and comparing the worktree against itself is a vacuous pass (caught live in
|
|
116
|
+
* task 1067 R4). `--git-common-dir` resolves to the shared `.git`, whose parent is the
|
|
117
|
+
* main tree; inside the main tree itself this falls back to cwd, which is correct there.
|
|
118
|
+
*/
|
|
119
|
+
export function defaultInvokeRoot(cwd: string): string {
|
|
120
|
+
try {
|
|
121
|
+
const run = spawnSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' });
|
|
122
|
+
const common = run.stdout.trim();
|
|
123
|
+
if (run.status === 0 && common) return dirname(isAbsolute(common) ? common : join(cwd, common));
|
|
124
|
+
} catch {
|
|
125
|
+
// git unavailable — fall through to cwd
|
|
126
|
+
}
|
|
127
|
+
return cwd;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd())): number {
|
|
131
|
+
let wtRoot = '';
|
|
132
|
+
const wbsList: string[] = [];
|
|
133
|
+
const runIds: string[] = [];
|
|
134
|
+
for (let i = 0; i < argv.length; i++) {
|
|
135
|
+
const arg = argv[i];
|
|
136
|
+
if (arg === '--spur-bin') {
|
|
137
|
+
i++;
|
|
138
|
+
} else if (arg === '--from') {
|
|
139
|
+
wtRoot = argv[++i] ?? '';
|
|
140
|
+
} else if (arg === '--root') {
|
|
141
|
+
invokeRoot = argv[++i] ?? invokeRoot;
|
|
142
|
+
} else if (arg === '--task-file') {
|
|
143
|
+
const wbs = wbsOfTaskFile(argv[++i] ?? '');
|
|
144
|
+
if (wbs) wbsList.push(wbs);
|
|
145
|
+
} else if (arg === '--run-id') {
|
|
146
|
+
const id = argv[++i] ?? '';
|
|
147
|
+
if (id) runIds.push(id);
|
|
148
|
+
} else {
|
|
149
|
+
process.stderr.write(`${persistOutCheckUsage()}\n`);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
`${persistOutCheckUsage()}\npersist-out-check: --from must be an existing worktree directory\n`,
|
|
156
|
+
);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
const files = listObligations(wtRoot, wbsList, runIds);
|
|
160
|
+
if (files === null) {
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
`persist-out-check: BLOCKED — worktree evidence listing failed or exceeded caps (64/prefix, ${EVIDENCE_CAP} evidence files) — inspect by hand\n`,
|
|
163
|
+
);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files);
|
|
167
|
+
const findings = [
|
|
168
|
+
...missing.map((f) => `MISSING ${f}`),
|
|
169
|
+
...divergent.map((f) => `DIVERGENT ${f} — reconcile by hand (persist-out never overwrites)`),
|
|
170
|
+
];
|
|
171
|
+
for (const line of findings.slice(0, NAMED_CAP)) process.stdout.write(`${line}\n`);
|
|
172
|
+
if (findings.length > NAMED_CAP) process.stdout.write(`… +${findings.length - NAMED_CAP} more\n`);
|
|
173
|
+
if (findings.length === 0) {
|
|
174
|
+
process.stdout.write(`persist-out-check: ok — ${ok} evidence file(s) persisted, nothing abandoned\n`);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent — run inline-run-setup --persist-out --from <worktree> before worktree removal\n`,
|
|
179
|
+
);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (import.meta.main) {
|
|
184
|
+
process.exit(main(process.argv.slice(2)));
|
|
185
|
+
}
|
|
@@ -17,7 +17,8 @@ import { join } from "node:path";
|
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
|
|
19
19
|
var PRIORITY_PATTERN = /^P[1-4]/;
|
|
20
|
-
var
|
|
20
|
+
var RANGE_PRIORITY = /^P[1-4]\s*[\u2013\u2014-]\s*P?[1-4]/;
|
|
21
|
+
var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
|
|
21
22
|
var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
|
|
22
23
|
var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
|
|
23
24
|
var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
|
|
@@ -74,7 +75,7 @@ function parseReviewFindings(taskContent) {
|
|
|
74
75
|
const priority = (cells[priorityCol] ?? "").trim();
|
|
75
76
|
const finding = (cells[findingCol] ?? "").trim();
|
|
76
77
|
const disposition = dispositionCol === -1 ? "" : (cells[dispositionCol] ?? "").trim();
|
|
77
|
-
if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
|
|
78
|
+
if (PRIORITY_PATTERN.test(priority) && !RANGE_PRIORITY.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
|
|
78
79
|
const location = locationOf(cells[locationCol] ?? "", finding);
|
|
79
80
|
out.push(DEFERRED_DISPOSITION.test(disposition) ? { priority, location, text: finding, deferral: disposition } : { priority, location, text: finding });
|
|
80
81
|
}
|
|
@@ -84,7 +85,7 @@ function parseReviewFindings(taskContent) {
|
|
|
84
85
|
return out;
|
|
85
86
|
}
|
|
86
87
|
function splitRow(line) {
|
|
87
|
-
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split(
|
|
88
|
+
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split(/(?<!\\)\|/).map((c) => c.trim());
|
|
88
89
|
}
|
|
89
90
|
function parseDiffMarkers(addedLines) {
|
|
90
91
|
return addedLines.filter((l) => !EXCLUDED_PATHS.some((p) => l.file.startsWith(p))).filter((l) => !l.text.includes(ALLOW_PRAGMA)).filter((l) => MARKER_PATTERN.test(l.text)).map((l) => ({ location: `${l.file}:${l.line}`, text: l.text.trim() }));
|
|
@@ -212,7 +213,8 @@ function renderReport(wbs, items, attemptCount) {
|
|
|
212
213
|
function recordedVerdictPath(runDir, wbs, fs) {
|
|
213
214
|
const evidence = join(runDir, "..", "memory", "evidence");
|
|
214
215
|
const durable = join(evidence, `${wbs}-verdict.json`);
|
|
215
|
-
|
|
216
|
+
const run = join(runDir, `${wbs}-verdict.json`);
|
|
217
|
+
for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable, run]) {
|
|
216
218
|
try {
|
|
217
219
|
if (fs.lstatSync(path).isSymbolicLink())
|
|
218
220
|
throw new Error(`residual-scan: symlink evidence path: ${path}`);
|
|
@@ -221,7 +223,44 @@ function recordedVerdictPath(runDir, wbs, fs) {
|
|
|
221
223
|
throw error;
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
|
-
|
|
226
|
+
if (!fs.existsSync(durable))
|
|
227
|
+
return run;
|
|
228
|
+
if (!fs.existsSync(run))
|
|
229
|
+
return durable;
|
|
230
|
+
const newer = (a, b) => mtimeOf(fs, b) > mtimeOf(fs, a) ? b : a;
|
|
231
|
+
return newer(run, durable);
|
|
232
|
+
}
|
|
233
|
+
function mtimeOf(fs, path) {
|
|
234
|
+
try {
|
|
235
|
+
return fs.statSync(path).mtimeMs;
|
|
236
|
+
} catch {
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function verdictDisagreementNote(runDir, wbs, fs) {
|
|
241
|
+
const run = join(runDir, `${wbs}-verdict.json`);
|
|
242
|
+
const durable = join(runDir, "..", "memory", "evidence", `${wbs}-verdict.json`);
|
|
243
|
+
if (!fs.existsSync(run) || !fs.existsSync(durable))
|
|
244
|
+
return null;
|
|
245
|
+
const bytes = (path) => {
|
|
246
|
+
try {
|
|
247
|
+
return fs.statSync(path).isFile() ? fs.readFileSync(path, "utf8") : null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const runBytes = bytes(run);
|
|
253
|
+
if (runBytes === null || runBytes === bytes(durable))
|
|
254
|
+
return null;
|
|
255
|
+
const value = (path) => {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(bytes(path) ?? "{}").verdict ?? "?";
|
|
258
|
+
} catch {
|
|
259
|
+
return "?";
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const winner = mtimeOf(fs, durable) > mtimeOf(fs, run) ? "durable" : "run";
|
|
263
|
+
return `residual-fold: ${wbs} verdict copies disagree — run=${run} (${value(run)}) durable=${durable} (${value(durable)})` + ` → chose ${winner} (newer mtime)`;
|
|
225
264
|
}
|
|
226
265
|
|
|
227
266
|
// plugins/sp/lib/spur-bin.ts
|
|
@@ -337,9 +376,7 @@ function loadTask(env, spurBinFlag, wbs, root) {
|
|
|
337
376
|
const featureId = [parsed.feature_id, parsed.frontmatter?.feature_id].find((v) => typeof v === "string") ?? "";
|
|
338
377
|
return { content: typeof parsed.content === "string" ? parsed.content : "", featureId };
|
|
339
378
|
}
|
|
340
|
-
|
|
341
|
-
return JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
|
|
342
|
-
}
|
|
379
|
+
var loadVerdict = (runDir, wbs) => JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
|
|
343
380
|
function scanMode(opts, env, io) {
|
|
344
381
|
const runDir = join2(opts.root, ".spur", "run");
|
|
345
382
|
fs.mkdirSync(runDir, { recursive: true });
|
|
@@ -355,6 +392,10 @@ function foldMode(opts, _env, io) {
|
|
|
355
392
|
const runDir = join2(opts.root, ".spur", "run");
|
|
356
393
|
const scan = JSON.parse(fs.readFileSync(join2(runDir, `${opts.wbs}-residuals.json`), "utf8"));
|
|
357
394
|
const target = recordedVerdictPath(runDir, opts.wbs, fs);
|
|
395
|
+
const note = verdictDisagreementNote(runDir, opts.wbs, fs);
|
|
396
|
+
if (note !== null)
|
|
397
|
+
io.out(`${note}
|
|
398
|
+
`);
|
|
358
399
|
const verdict = loadVerdict(runDir, opts.wbs);
|
|
359
400
|
const findingsPath = join2(runDir, `${opts.wbs}-test-gate.findings`);
|
|
360
401
|
const fold = foldVerdict(verdict, scan, fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, "utf8") : "");
|
|
@@ -386,12 +427,7 @@ function settleMode(opts, env, io) {
|
|
|
386
427
|
prior = JSON.parse(fs.readFileSync(residualsPath, "utf8"));
|
|
387
428
|
const deferred = scan.items.filter((i) => i.class === "deferrable");
|
|
388
429
|
if (deferred.length > 0 && prior.followUp === undefined) {
|
|
389
|
-
|
|
390
|
-
io.err(`residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}
|
|
391
|
-
`);
|
|
392
|
-
return 0;
|
|
393
|
-
}
|
|
394
|
-
const args = ["task", "create", `Residuals from ${wbs}`, "--feature", task.featureId, "--skip-ready", "--json"];
|
|
430
|
+
const args = ["task", "create", `Residuals from ${wbs}`, "--skip-ready", "--json"];
|
|
395
431
|
const created = spur(env, opts.spurBin, args, opts.root);
|
|
396
432
|
let wbsNew = "";
|
|
397
433
|
try {
|
|
@@ -407,7 +443,7 @@ function settleMode(opts, env, io) {
|
|
|
407
443
|
return 0;
|
|
408
444
|
}
|
|
409
445
|
const rows = deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`);
|
|
410
|
-
const head = `Source task: ${wbs} (feature ${task.featureId}) \u2014 deferred residuals filed by residual-scan settle.`;
|
|
446
|
+
const head = `Source task: ${wbs}${task.featureId === "" ? "" : ` (feature ${task.featureId})`} \u2014 deferred residuals filed by residual-scan settle (unlinked: a deferral must not hold the completing feature open).`;
|
|
411
447
|
const bgFile = join2(runDir, `${wbs}-residual-background.md`);
|
|
412
448
|
fs.writeFileSync(bgFile, `${[head, "", ...rows].join(`
|
|
413
449
|
`)}
|
|
@@ -469,6 +505,7 @@ function main(argv, env = getEnvVars(), options = {}) {
|
|
|
469
505
|
}
|
|
470
506
|
process.exit(main(process.argv.slice(2)));
|
|
471
507
|
export {
|
|
508
|
+
verdictDisagreementNote,
|
|
472
509
|
scanResiduals2 as scanResiduals,
|
|
473
510
|
renderReport,
|
|
474
511
|
recordedVerdictPath,
|
|
@@ -115,9 +115,8 @@ function loadTask(env: ScanEnv, spurBinFlag: string | undefined, wbs: string, ro
|
|
|
115
115
|
[parsed.feature_id, parsed.frontmatter?.feature_id].find((v): v is string => typeof v === 'string') ?? '';
|
|
116
116
|
return { content: typeof parsed.content === 'string' ? parsed.content : '', featureId };
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
118
|
+
const loadVerdict = (runDir: string, wbs: string): VerdictFile =>
|
|
119
|
+
JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
|
|
121
120
|
function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
122
121
|
const runDir = join(opts.root, '.spur', 'run');
|
|
123
122
|
fs.mkdirSync(runDir, { recursive: true });
|
|
@@ -135,6 +134,8 @@ function foldMode(opts: ParsedArgs, _env: ScanEnv, io: ScanIo): number {
|
|
|
135
134
|
fs.readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8'),
|
|
136
135
|
) as core.ResidualArtifact;
|
|
137
136
|
const target = core.recordedVerdictPath(runDir, opts.wbs, fs);
|
|
137
|
+
const note = core.verdictDisagreementNote(runDir, opts.wbs, fs);
|
|
138
|
+
if (note !== null) io.out(`${note}\n`);
|
|
138
139
|
const verdict = loadVerdict(runDir, opts.wbs);
|
|
139
140
|
const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
|
|
140
141
|
const fold = core.foldVerdict(
|
|
@@ -169,13 +170,12 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
|
169
170
|
if (fs.existsSync(residualsPath)) prior = JSON.parse(fs.readFileSync(residualsPath, 'utf8'));
|
|
170
171
|
const deferred = scan.items.filter((i) => i.class === 'deferrable');
|
|
171
172
|
if (deferred.length > 0 && prior.followUp === undefined) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
const args = ['task', 'create', `Residuals from ${wbs}`, '--feature', task.featureId, '--skip-ready', '--json'];
|
|
173
|
+
// The follow-up is filed UNLINKED: carrying a feature edge to the feature whose run produced
|
|
174
|
+
// it made that feature's done-gate fail with `L4.verifying-incomplete-tasks` the moment a
|
|
175
|
+
// residual was deferred (2026-10-04 session, E72/1086→1087), so a deferral must not be able
|
|
176
|
+
// to block the completing feature. The source task and its feature stay named in the
|
|
177
|
+
// Background, which keeps the traceability without the lifecycle coupling.
|
|
178
|
+
const args = ['task', 'create', `Residuals from ${wbs}`, '--skip-ready', '--json'];
|
|
179
179
|
const created = spur(env, opts.spurBin, args, opts.root);
|
|
180
180
|
let wbsNew = '';
|
|
181
181
|
try {
|
|
@@ -190,7 +190,7 @@ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
|
190
190
|
return 0;
|
|
191
191
|
}
|
|
192
192
|
const rows = deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`);
|
|
193
|
-
const head = `Source task: ${wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`;
|
|
193
|
+
const head = `Source task: ${wbs}${task.featureId === '' ? '' : ` (feature ${task.featureId})`} — deferred residuals filed by residual-scan settle (unlinked: a deferral must not hold the completing feature open).`;
|
|
194
194
|
const bgFile = join(runDir, `${wbs}-residual-background.md`);
|
|
195
195
|
fs.writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
|
|
196
196
|
const updArgs = ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile];
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/run-summary.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/run-summary.ts
|
|
129
|
+
function attemptWindow(attempts) {
|
|
130
|
+
const starts = attempts.map((a) => Date.parse(a.startedAt ?? "")).filter((n) => !Number.isNaN(n));
|
|
131
|
+
const ends = attempts.map((a) => Date.parse(a.completedAt ?? "")).filter((n) => !Number.isNaN(n));
|
|
132
|
+
return starts.length && ends.length ? [Math.min(...starts), Math.max(...ends)] : undefined;
|
|
133
|
+
}
|
|
134
|
+
function summaryRow(stage, status, span, measured) {
|
|
135
|
+
return {
|
|
136
|
+
stage,
|
|
137
|
+
status,
|
|
138
|
+
workMs: span.workMs,
|
|
139
|
+
waitMs: span.waitMs,
|
|
140
|
+
toolCalls: measured ? span.toolCalls : null,
|
|
141
|
+
tokens: measured ? span.tokens : null,
|
|
142
|
+
time: formatDuration(span.workMs),
|
|
143
|
+
wait: formatDuration(span.waitMs),
|
|
144
|
+
token: formatTokenSplit(measured ? span.tokens : null)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function buildRunSummary(lines, inputs, since) {
|
|
148
|
+
const windows = [];
|
|
149
|
+
for (const { label, progress } of inputs) {
|
|
150
|
+
const states = (progress.states ?? []).flatMap((s) => {
|
|
151
|
+
const w = attemptWindow((s.actions ?? []).flatMap((a) => a.attempts ?? []));
|
|
152
|
+
return w ? [{ s, w }] : [];
|
|
153
|
+
});
|
|
154
|
+
if (!states.length)
|
|
155
|
+
throw new Error(`progress for run ${progress.runId} has no timed attempts`);
|
|
156
|
+
if (inputs.length === 1) {
|
|
157
|
+
for (const { s, w } of states) {
|
|
158
|
+
const stage = (s.visit ?? 1) > 1 ? `${s.state} (visit ${s.visit})` : s.state;
|
|
159
|
+
windows.push({ stage, status: s.status ?? "", start: w[0], end: w[1] });
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
const stage = label ?? `${progress.workflow ?? "run"} ${progress.runId.slice(0, 8)}`;
|
|
163
|
+
const start = Math.min(...states.map(({ w }) => w[0]));
|
|
164
|
+
windows.push({
|
|
165
|
+
stage,
|
|
166
|
+
status: progress.status ?? "",
|
|
167
|
+
start,
|
|
168
|
+
end: Math.max(...states.map(({ w }) => w[1]))
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
windows.sort((a, b) => a.start - b.start);
|
|
173
|
+
const { rows, skippedLines } = parseRows(lines);
|
|
174
|
+
const measure = (start, end) => {
|
|
175
|
+
const acc = newAcc(start);
|
|
176
|
+
for (const [row, ts] of rows)
|
|
177
|
+
if (ts >= start && ts <= end && promptText(row) === undefined)
|
|
178
|
+
accumulate(acc, row, ts);
|
|
179
|
+
const span = {
|
|
180
|
+
workMs: end - start - acc.askMs,
|
|
181
|
+
waitMs: acc.askMs,
|
|
182
|
+
toolCalls: acc.toolIds.size,
|
|
183
|
+
tokens: sumTokens([...acc.messages.values()])
|
|
184
|
+
};
|
|
185
|
+
return { span, measured: acc.toolIds.size > 0 || acc.messages.size > 0 };
|
|
186
|
+
};
|
|
187
|
+
const sinceMs = since ? Date.parse(since) : Number.NaN;
|
|
188
|
+
const totalStart = Number.isNaN(sinceMs) ? Math.min(...windows.map((w) => w.start)) : sinceMs;
|
|
189
|
+
let totalEnd = Math.max(...windows.map((w) => w.end));
|
|
190
|
+
if (!Number.isNaN(sinceMs)) {
|
|
191
|
+
for (const [, ts] of rows)
|
|
192
|
+
if (ts >= sinceMs)
|
|
193
|
+
totalEnd = Math.max(totalEnd, ts);
|
|
194
|
+
}
|
|
195
|
+
const stageRows = windows.map((w) => {
|
|
196
|
+
const { span, measured } = measure(w.start, w.end);
|
|
197
|
+
return summaryRow(w.stage, w.status, span, measured);
|
|
198
|
+
});
|
|
199
|
+
const totalMeasure = measure(totalStart, totalEnd);
|
|
200
|
+
const statuses = [...new Set(inputs.map((i) => i.progress.status ?? ""))].join("/");
|
|
201
|
+
const total = summaryRow("Total", statuses, totalMeasure.span, totalMeasure.measured);
|
|
202
|
+
const overlaps = windows.some((w, i) => i > 0 && w.start < (windows[i - 1]?.end ?? 0));
|
|
203
|
+
const sum = sumSpans(stageRows.map((r) => ({ ...r, toolCalls: r.toolCalls ?? 0, tokens: r.tokens ?? zeroTokens() })));
|
|
204
|
+
const overhead = {
|
|
205
|
+
workMs: total.workMs - sum.workMs,
|
|
206
|
+
waitMs: total.waitMs - sum.waitMs,
|
|
207
|
+
toolCalls: (total.toolCalls ?? 0) - sum.toolCalls,
|
|
208
|
+
tokens: Object.fromEntries(Object.keys(sum.tokens).map((k) => [k, (total.tokens?.[k] ?? 0) - sum.tokens[k]]))
|
|
209
|
+
};
|
|
210
|
+
if (!overlaps && overhead.workMs > 0)
|
|
211
|
+
stageRows.push(summaryRow("driver overhead", "", overhead, totalMeasure.measured));
|
|
212
|
+
return { available: true, rows: stageRows, total, skippedLines };
|
|
213
|
+
}
|
|
214
|
+
function renderSummaryMarkdown(summary) {
|
|
215
|
+
const line = (r, bold = false) => {
|
|
216
|
+
const stage = bold ? `**${r.stage}**` : r.stage;
|
|
217
|
+
return `| ${stage} | ${r.status} | ${r.time} | ${r.wait} | ${r.toolCalls ?? "n/a"} | ${r.token} |`;
|
|
218
|
+
};
|
|
219
|
+
return [
|
|
220
|
+
"| Stage | Status | Time | Wait | Tool calls | Token (total / non-cached) |",
|
|
221
|
+
"| --- | --- | ---: | ---: | ---: | ---: |",
|
|
222
|
+
...summary.rows.map((r) => line(r)),
|
|
223
|
+
line(summary.total, true)
|
|
224
|
+
].join(`
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
227
|
+
var RUN_SUMMARY_USAGE = "usage: run-summary --progress [<label>=]<file> [--progress ...] [--transcript <path>] [--since <iso>] [--markdown]";
|
|
228
|
+
function main(argv, env = getEnvVars(), write = (s) => process.stdout.write(s), projectsRoot) {
|
|
229
|
+
let transcript;
|
|
230
|
+
let since;
|
|
231
|
+
let markdown = false;
|
|
232
|
+
const progressArgs = [];
|
|
233
|
+
for (let i = 0;i < argv.length; i++) {
|
|
234
|
+
const arg = argv[i];
|
|
235
|
+
if (arg === "--transcript" && argv[i + 1])
|
|
236
|
+
transcript = argv[++i];
|
|
237
|
+
else if (arg === "--progress" && argv[i + 1])
|
|
238
|
+
progressArgs.push(argv[++i]);
|
|
239
|
+
else if (arg === "--since" && argv[i + 1])
|
|
240
|
+
since = argv[++i];
|
|
241
|
+
else if (arg === "--markdown")
|
|
242
|
+
markdown = true;
|
|
243
|
+
else if (arg === "--spur-bin" && argv[i + 1])
|
|
244
|
+
i++;
|
|
245
|
+
else {
|
|
246
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}
|
|
247
|
+
`);
|
|
248
|
+
return 2;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!progressArgs.length) {
|
|
252
|
+
process.stderr.write(`${RUN_SUMMARY_USAGE}
|
|
253
|
+
`);
|
|
254
|
+
return 2;
|
|
255
|
+
}
|
|
256
|
+
const resolved = resolveTranscript(env, projectsRoot, transcript);
|
|
257
|
+
if (!resolved.ok) {
|
|
258
|
+
const out = markdown ? `Execution summary: n/a (${resolved.reason})` : JSON.stringify({ available: false, reason: resolved.reason });
|
|
259
|
+
write(`${out}
|
|
260
|
+
`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const inputs = progressArgs.map((arg) => {
|
|
265
|
+
const eq = arg.indexOf("=");
|
|
266
|
+
const [label, file] = eq > 0 ? [arg.slice(0, eq), arg.slice(eq + 1)] : [undefined, arg];
|
|
267
|
+
return { label, progress: JSON.parse(readFileSync(file, "utf8")) };
|
|
268
|
+
});
|
|
269
|
+
const summary = buildRunSummary(readFileSync(resolved.path, "utf8").split(`
|
|
270
|
+
`), inputs, since);
|
|
271
|
+
write(`${markdown ? renderSummaryMarkdown(summary) : JSON.stringify({ ...summary, transcript: resolved.path })}
|
|
272
|
+
`);
|
|
273
|
+
return 0;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
process.stderr.write(`run-summary: ${error.message}
|
|
276
|
+
`);
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
process.exit(main(process.argv.slice(2)));
|
|
281
|
+
export {
|
|
282
|
+
renderSummaryMarkdown,
|
|
283
|
+
main,
|
|
284
|
+
buildRunSummary,
|
|
285
|
+
RUN_SUMMARY_USAGE
|
|
286
|
+
};
|