@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.
Files changed (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +17 -0
  3. package/config/decisions/task-pipeline.yaml +55 -0
  4. package/config/plugin-scripts.json +15 -0
  5. package/config/rules/boundary/sp-runtime-path.yaml +2 -0
  6. package/config/rules/quality/coverage-gate.yaml +5 -3
  7. package/config/rules/strict/runtime-boundaries.yaml +11 -0
  8. package/config/rules/structure/protected-files.yaml +3 -0
  9. package/config/templates/task/brainstorm.md +1 -1
  10. package/config/templates/task/feature-impl.md +1 -1
  11. package/config/templates/task/issue.md +1 -1
  12. package/config/templates/task/meta.md +1 -1
  13. package/config/templates/task/review.md +1 -1
  14. package/config/templates/task/standard.md +1 -1
  15. package/config/workflows/task-pipeline.yaml +178 -18
  16. package/package.json +9 -9
  17. package/plugins/sp/README.md +14 -1
  18. package/plugins/sp/agents/super-reviewer.md +25 -11
  19. package/plugins/sp/commands/dev-changelog.md +5 -4
  20. package/plugins/sp/commands/dev-review-session.md +12 -7
  21. package/plugins/sp/commands/dev-run.md +5 -2
  22. package/plugins/sp/commands/dev-runall.md +5 -2
  23. package/plugins/sp/hooks/agent-lifecycle.ts +155 -0
  24. package/plugins/sp/hooks/fleet-guest-stop.ts +180 -0
  25. package/plugins/sp/hooks/hooks.json +45 -1
  26. package/plugins/sp/hooks/pi/guard-extension.ts +21 -0
  27. package/plugins/sp/lib/history-anatomy.generated.mjs +5 -0
  28. package/plugins/sp/lib/idea-handoff.generated.mjs +68 -10
  29. package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
  30. package/plugins/sp/lib/inline-run.generated.mjs +83 -20
  31. package/plugins/sp/lib/residual-scan.generated.d.mts +5 -0
  32. package/plugins/sp/lib/residual-scan.generated.mjs +45 -5
  33. package/plugins/sp/lib/transcript.ts +160 -0
  34. package/plugins/sp/plugin.json +1 -1
  35. package/plugins/sp/scripts/history-anatomy-cache.mjs +5 -0
  36. package/plugins/sp/scripts/inline-run-setup.mjs +21 -20
  37. package/plugins/sp/scripts/inline-run-setup.ts +25 -31
  38. package/plugins/sp/scripts/persist-out-check.mjs +164 -0
  39. package/plugins/sp/scripts/persist-out-check.ts +185 -0
  40. package/plugins/sp/scripts/residual-scan.mjs +52 -15
  41. package/plugins/sp/scripts/residual-scan.ts +11 -11
  42. package/plugins/sp/scripts/run-summary.mjs +286 -0
  43. package/plugins/sp/scripts/run-summary.ts +241 -0
  44. package/plugins/sp/scripts/session-timeline.mjs +234 -0
  45. package/plugins/sp/scripts/session-timeline.ts +152 -0
  46. package/plugins/sp/scripts/task-diffstat.mjs +17 -5
  47. package/plugins/sp/scripts/task-diffstat.ts +25 -4
  48. package/plugins/sp/skills/code-verification/SKILL.md +8 -0
  49. package/plugins/sp/skills/code-verification/references/verdict-schema.md +17 -1
  50. package/plugins/sp/skills/fleet-join/SKILL.md +63 -0
  51. package/plugins/sp/skills/session-review/SKILL.md +46 -24
  52. package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
  53. package/plugins/sp/skills/spur-cli/references/agent.md +108 -6
  54. package/plugins/sp/skills/spur-cli/references/decision.md +97 -0
  55. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +22 -0
  56. package/plugins/sp/skills/spur-composer/SKILL.md +27 -1
  57. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +40 -7
  58. package/plugins/sp/skills/spur-dev/references/dev-operations.md +28 -16
  59. package/plugins/sp/skills/spur-dev/references/execution-batch.md +278 -42
  60. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +17 -1
  61. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -4
  62. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +139 -10
  63. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +6 -0
  64. package/schemas/decision-catalog.schema.json +226 -0
  65. package/schemas/spur-config.schema.json +25 -0
  66. package/spur.js +8856 -2564
  67. package/web/_astro/BoardApp.Bzr4OXrW.js +324 -0
  68. package/web/_astro/{BoardApp.Dflq5a8x.js → BoardApp.VeoxxLBy.js} +1 -1
  69. package/web/_astro/{TaskDetail.CK7oXddP.js → TaskDetail.LAyR_Pvn.js} +1 -1
  70. package/web/_astro/{arc.BIqkDATc.js → arc.f4e0OYLg.js} +1 -1
  71. package/web/_astro/{architectureDiagram-3BPJPVTR.D0B3SvWv.js → architectureDiagram-3BPJPVTR.SSIvxi-Z.js} +1 -1
  72. package/web/_astro/{blockDiagram-GPEHLZMM.C2YyKQmL.js → blockDiagram-GPEHLZMM.DSXIHOFf.js} +1 -1
  73. package/web/_astro/{c4Diagram-AAUBKEIU.DmtTYrt_.js → c4Diagram-AAUBKEIU.DG9Ubu85.js} +1 -1
  74. package/web/_astro/channel.CUFdtWhm.js +1 -0
  75. package/web/_astro/{chunk-2J33WTMH.HsRXbh7l.js → chunk-2J33WTMH.C7QU88Qh.js} +1 -1
  76. package/web/_astro/{chunk-4BX2VUAB.obE_P-R3.js → chunk-4BX2VUAB.26Z94xBj.js} +1 -1
  77. package/web/_astro/{chunk-55IACEB6.CjWTF5mz.js → chunk-55IACEB6.DYRiBkqw.js} +1 -1
  78. package/web/_astro/{chunk-727SXJPM.DMo799Ax.js → chunk-727SXJPM.D5Vfv5pe.js} +1 -1
  79. package/web/_astro/{chunk-AQP2D5EJ.-cE6JOKZ.js → chunk-AQP2D5EJ.DXHV47bN.js} +1 -1
  80. package/web/_astro/{chunk-FMBD7UC4.D6Rw6HIk.js → chunk-FMBD7UC4.D1suAfOs.js} +1 -1
  81. package/web/_astro/{chunk-ND2GUHAM.d5rBD73Z.js → chunk-ND2GUHAM.D6BmdHEY.js} +1 -1
  82. package/web/_astro/{chunk-QZHKN3VN.r3ox40Q3.js → chunk-QZHKN3VN.rnPU-Ak-.js} +1 -1
  83. package/web/_astro/{classDiagram-4FO5ZUOK.CGGbvjjJ.js → classDiagram-4FO5ZUOK.CvEfXIo1.js} +1 -1
  84. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js → classDiagram-v2-Q7XG4LA2.CvEfXIo1.js} +1 -1
  85. package/web/_astro/{cose-bilkent-S5V4N54A.CmKtPd4l.js → cose-bilkent-S5V4N54A.DkMisLSm.js} +1 -1
  86. package/web/_astro/{cynefin-OW5HDTMX.QNg5GXs8.js → cynefin-OW5HDTMX.C77Q-pF6.js} +1 -1
  87. package/web/_astro/{dagre-BM42HDAG.B1SabsjC.js → dagre-BM42HDAG.YaV8ce27.js} +1 -1
  88. package/web/_astro/{diagram-2AECGRRQ.RtDbNj89.js → diagram-2AECGRRQ.CUyGEKge.js} +1 -1
  89. package/web/_astro/{diagram-5GNKFQAL.IzZpuM-Y.js → diagram-5GNKFQAL.CRgd8dgj.js} +1 -1
  90. package/web/_astro/{diagram-KO2AKTUF.DPTF_fgv.js → diagram-KO2AKTUF.Bmzys9hq.js} +1 -1
  91. package/web/_astro/{diagram-LMA3HP47.CCyza39P.js → diagram-LMA3HP47.BvmJY4Lw.js} +1 -1
  92. package/web/_astro/{diagram-OG6HWLK6.BXAi8zcZ.js → diagram-OG6HWLK6.0hEhD30d.js} +1 -1
  93. package/web/_astro/{erDiagram-TEJ5UH35.DUDX7_MR.js → erDiagram-TEJ5UH35.Bjt8lrnm.js} +1 -1
  94. package/web/_astro/{flowDiagram-I6XJVG4X.C63QTh3N.js → flowDiagram-I6XJVG4X.CJZTjRDV.js} +1 -1
  95. package/web/_astro/{ganttDiagram-6RSMTGT7.CnlZJSwG.js → ganttDiagram-6RSMTGT7._VqoVafc.js} +1 -1
  96. package/web/_astro/{gitGraphDiagram-PVQCEYII.CTqIrOPC.js → gitGraphDiagram-PVQCEYII.CEDb7tVo.js} +1 -1
  97. package/web/_astro/index.Dg_3bOaJ.css +1 -0
  98. package/web/_astro/{infoDiagram-5YYISTIA.BYMiDFh9.js → infoDiagram-5YYISTIA.CgwifPpK.js} +1 -1
  99. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CRiQmI-z.js → ishikawaDiagram-YF4QCWOH.B99wx2J1.js} +1 -1
  100. package/web/_astro/{journeyDiagram-JHISSGLW.YQgWBsEU.js → journeyDiagram-JHISSGLW.2hRwJbEo.js} +1 -1
  101. package/web/_astro/{kanban-definition-UN3LZRKU.1ECnrrK1.js → kanban-definition-UN3LZRKU.BahJkKNC.js} +1 -1
  102. package/web/_astro/{linear.DzVA71yT.js → linear.DguUUe6V.js} +1 -1
  103. package/web/_astro/{mermaid.core.BK7P2Sr_.js → mermaid.core.CvgfmqVr.js} +4 -4
  104. package/web/_astro/{mindmap-definition-RKZ34NQL.BvXyWZK7.js → mindmap-definition-RKZ34NQL.B3qEA3x9.js} +1 -1
  105. package/web/_astro/{pieDiagram-4H26LBE5.BiXk1TW3.js → pieDiagram-4H26LBE5.FlWA-_ru.js} +1 -1
  106. package/web/_astro/{quadrantDiagram-W4KKPZXB.krvBH8lh.js → quadrantDiagram-W4KKPZXB.9bVn35X7.js} +1 -1
  107. package/web/_astro/{requirementDiagram-4Y6WPE33.ih1gL25c.js → requirementDiagram-4Y6WPE33.ChykIhct.js} +1 -1
  108. package/web/_astro/{sankeyDiagram-5OEKKPKP.CpUxkv4u.js → sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js} +1 -1
  109. package/web/_astro/{sequenceDiagram-3UESZ5HK.clAfeN7y.js → sequenceDiagram-3UESZ5HK.J7JsHWDt.js} +1 -1
  110. package/web/_astro/{stateDiagram-AJRCARHV.DpXWb8-m.js → stateDiagram-AJRCARHV.DAPbiCSS.js} +1 -1
  111. package/web/_astro/{stateDiagram-v2-BHNVJYJU.ClZb9GIH.js → stateDiagram-v2-BHNVJYJU.D2jvSi0q.js} +1 -1
  112. package/web/_astro/{timeline-definition-PNZ67QCA.BLcX5dh7.js → timeline-definition-PNZ67QCA.psW2ci29.js} +1 -1
  113. package/web/_astro/{vennDiagram-CIIHVFJN.oGgpKMpx.js → vennDiagram-CIIHVFJN.DlILYVmg.js} +1 -1
  114. package/web/_astro/{wardleyDiagram-YWT4CUSO.BPHtMtt-.js → wardleyDiagram-YWT4CUSO.xNWwLJnn.js} +1 -1
  115. package/web/_astro/{xychartDiagram-2RQKCTM6.BNZOPYSk.js → xychartDiagram-2RQKCTM6.EU_BuH7V.js} +1 -1
  116. package/web/board-runtime.json +1 -1
  117. package/web/index.html +2 -2
  118. package/web/_astro/BoardApp.CYbe-OTb.js +0 -322
  119. package/web/_astro/channel.JuKVyuhZ.js +0 -1
  120. 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 NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
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("|").map((c) => c.trim());
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
- for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
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
- return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
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
- function loadVerdict(runDir, wbs) {
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
- if (task.featureId === "") {
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
- function loadVerdict(runDir: string, wbs: string): VerdictFile {
119
- return JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
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
- if (task.featureId === '') {
173
- io.err(
174
- `residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}\n`,
175
- );
176
- return 0;
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
+ };