@gobing-ai/spur 0.3.91 → 0.3.92

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/pipeline-budgets.json +2 -2
  3. package/config/plugin-scripts.json +17 -1
  4. package/config/workflows/feature-lifecycle.yaml +14 -5
  5. package/config/workflows/feature-verification.yaml +35 -27
  6. package/config/workflows/history-anatomy.yaml +14 -25
  7. package/config/workflows/idea-pipeline.yaml +9 -5
  8. package/config/workflows/task-pipeline.yaml +185 -4
  9. package/config/workflows/wrapup-pipeline.yaml +47 -5
  10. package/package.json +9 -9
  11. package/plugins/sp/agents/super-planner.md +14 -5
  12. package/plugins/sp/commands/dev-dogfood.md +4 -4
  13. package/plugins/sp/commands/dev-fixall.md +8 -5
  14. package/plugins/sp/commands/dev-run.md +6 -0
  15. package/plugins/sp/commands/dev-runall.md +12 -6
  16. package/plugins/sp/commands/dev-verify.md +9 -0
  17. package/plugins/sp/commands/dev-verifyall.md +5 -0
  18. package/plugins/sp/lib/idea-handoff.generated.mjs +301 -300
  19. package/plugins/sp/lib/inline-run.generated.d.mts +17 -0
  20. package/plugins/sp/lib/inline-run.generated.mjs +1460 -0
  21. package/plugins/sp/plugin.json +1 -1
  22. package/plugins/sp/references/environment-lens.md +1 -1
  23. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +136 -0
  24. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +196 -2
  25. package/plugins/sp/scripts/feature-verification-steps.mjs +174 -0
  26. package/plugins/sp/scripts/feature-verification-steps.ts +275 -0
  27. package/plugins/sp/scripts/history-anatomy-cache.mjs +104 -4
  28. package/plugins/sp/scripts/history-anatomy-cache.ts +137 -13
  29. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +1 -0
  30. package/plugins/sp/scripts/inline-run-setup.mjs +349 -0
  31. package/plugins/sp/scripts/inline-run-setup.ts +192 -75
  32. package/plugins/sp/scripts/record-feature-sync.mjs +63 -0
  33. package/plugins/sp/scripts/record-feature-sync.ts +84 -0
  34. package/plugins/sp/scripts/residual-scan.mjs +476 -0
  35. package/plugins/sp/scripts/residual-scan.ts +614 -0
  36. package/plugins/sp/scripts/task-evidence-precheck.ts +8 -3
  37. package/plugins/sp/scripts/task-size-precheck.ts +8 -3
  38. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  39. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +2 -0
  40. package/plugins/sp/skills/code-implementation/SKILL.md +17 -0
  41. package/plugins/sp/skills/code-verification/SKILL.md +21 -0
  42. package/plugins/sp/skills/code-verification/references/verdict-schema.md +1 -0
  43. package/plugins/sp/skills/dogfood-testing/SKILL.md +5 -3
  44. package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +63 -26
  45. package/plugins/sp/skills/dogfood-testing/references/report-template.md +33 -10
  46. package/plugins/sp/skills/history-anatomy/references/modes.md +5 -3
  47. package/plugins/sp/skills/next-feature/references/ranking-rubric.md +1 -1
  48. package/plugins/sp/skills/next-router/references/routing-table.md +7 -0
  49. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  50. package/plugins/sp/skills/session-review/SKILL.md +12 -2
  51. package/plugins/sp/skills/spur-cli/references/features.md +1 -1
  52. package/plugins/sp/skills/spur-cli/references/projects.md +3 -1
  53. package/plugins/sp/skills/spur-cli/references/workflows.md +39 -19
  54. package/plugins/sp/skills/spur-dev/SKILL.md +11 -4
  55. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +4 -1
  56. package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -2
  57. package/plugins/sp/skills/spur-dev/references/execution-batch.md +285 -57
  58. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +1 -1
  59. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +19 -4
  60. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +43 -16
  61. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +5 -0
  62. package/spur.js +21827 -19615
  63. package/web/_astro/BoardApp.CerSBgis.js +192 -0
  64. package/web/_astro/BoardApp.eoTz0pZs.js +1 -0
  65. package/web/_astro/{TaskDetail.CXGltuT_.js → TaskDetail.DCqiC-OZ.js} +1 -1
  66. package/web/_astro/arc.CPwg6Rw0.js +1 -0
  67. package/web/_astro/{architectureDiagram-3BPJPVTR.DJ8DHkWE.js → architectureDiagram-3BPJPVTR.DM_vp_hO.js} +1 -1
  68. package/web/_astro/{blockDiagram-GPEHLZMM.D8DHK3Jl.js → blockDiagram-GPEHLZMM.DXVIiv0p.js} +1 -1
  69. package/web/_astro/{c4Diagram-AAUBKEIU.BugQbX9u.js → c4Diagram-AAUBKEIU.BbF_zCxW.js} +1 -1
  70. package/web/_astro/channel.MYZLKNwy.js +1 -0
  71. package/web/_astro/{chunk-2J33WTMH.Mv26KlVn.js → chunk-2J33WTMH.CAgQHpPC.js} +1 -1
  72. package/web/_astro/{chunk-4BX2VUAB.CD51JoT_.js → chunk-4BX2VUAB.BN-5tpw4.js} +1 -1
  73. package/web/_astro/{chunk-55IACEB6.D_PFaIEe.js → chunk-55IACEB6.CnPkEEr0.js} +1 -1
  74. package/web/_astro/{chunk-727SXJPM.DemMW1ao.js → chunk-727SXJPM.BQzQeMVm.js} +4 -4
  75. package/web/_astro/{chunk-AQP2D5EJ.Iu2V5-ex.js → chunk-AQP2D5EJ.B6xNyDnL.js} +1 -1
  76. package/web/_astro/{chunk-FMBD7UC4.MsNgSP-E.js → chunk-FMBD7UC4.C7f9Ih78.js} +1 -1
  77. package/web/_astro/{chunk-ND2GUHAM.DfbRaAlm.js → chunk-ND2GUHAM.CNV1dFXT.js} +1 -1
  78. package/web/_astro/{chunk-QZHKN3VN.BbJAQ4h-.js → chunk-QZHKN3VN.Cudn2TkJ.js} +1 -1
  79. package/web/_astro/{classDiagram-4FO5ZUOK.CPurtiC2.js → classDiagram-4FO5ZUOK.D1NwP50q.js} +1 -1
  80. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CPurtiC2.js → classDiagram-v2-Q7XG4LA2.D1NwP50q.js} +1 -1
  81. package/web/_astro/{cose-bilkent-S5V4N54A.CKKdx1bM.js → cose-bilkent-S5V4N54A.B1wSL-Xb.js} +1 -1
  82. package/web/_astro/{cynefin-OW5HDTMX.CoKMTg-R.js → cynefin-OW5HDTMX.BmK52w8G.js} +1 -1
  83. package/web/_astro/{dagre-BM42HDAG.D4h4_k56.js → dagre-BM42HDAG.Bfy5CTDT.js} +2 -2
  84. package/web/_astro/diagram-2AECGRRQ.DhNnvUvX.js +43 -0
  85. package/web/_astro/diagram-5GNKFQAL.lTX5KwnS.js +10 -0
  86. package/web/_astro/{diagram-KO2AKTUF.BFoCkiCr.js → diagram-KO2AKTUF.CW_vMJ4z.js} +3 -3
  87. package/web/_astro/{diagram-LMA3HP47.exHn9OVx.js → diagram-LMA3HP47.B_8ZGF67.js} +1 -1
  88. package/web/_astro/{diagram-OG6HWLK6.CeqO34nN.js → diagram-OG6HWLK6.BppnHsdS.js} +1 -1
  89. package/web/_astro/{erDiagram-TEJ5UH35.D_v7HqxR.js → erDiagram-TEJ5UH35.BEuHXcjJ.js} +5 -5
  90. package/web/_astro/{flowDiagram-I6XJVG4X.EUmrpbwh.js → flowDiagram-I6XJVG4X.CH-UlnGr.js} +4 -4
  91. package/web/_astro/{ganttDiagram-6RSMTGT7.BOCF5lII.js → ganttDiagram-6RSMTGT7.BO81S85v.js} +1 -1
  92. package/web/_astro/{gitGraphDiagram-PVQCEYII.Di7otYZD.js → gitGraphDiagram-PVQCEYII.XnPxPPZN.js} +1 -1
  93. package/web/_astro/index.Hjbr15fG.css +1 -0
  94. package/web/_astro/{infoDiagram-5YYISTIA.TcBkCAJk.js → infoDiagram-5YYISTIA.JyjYRu_T.js} +1 -1
  95. package/web/_astro/{ishikawaDiagram-YF4QCWOH.D-2y4M0c.js → ishikawaDiagram-YF4QCWOH.BBRBF-Fo.js} +5 -5
  96. package/web/_astro/{journeyDiagram-JHISSGLW.DPbJI_n2.js → journeyDiagram-JHISSGLW.C_iymSyp.js} +1 -1
  97. package/web/_astro/{kanban-definition-UN3LZRKU.EFxhQ9Fj.js → kanban-definition-UN3LZRKU.DdfW-Oqt.js} +7 -7
  98. package/web/_astro/{linear.DSAsQLzs.js → linear.C2_IkbZT.js} +1 -1
  99. package/web/_astro/mermaid.core.GAOYeSR0.js +303 -0
  100. package/web/_astro/{mindmap-definition-RKZ34NQL.CJY1N_7V.js → mindmap-definition-RKZ34NQL.DAZIxQSK.js} +2 -2
  101. package/web/_astro/{pieDiagram-4H26LBE5.567ZNoL2.js → pieDiagram-4H26LBE5.CN8sIhKM.js} +3 -3
  102. package/web/_astro/{quadrantDiagram-W4KKPZXB.mqfz9-MY.js → quadrantDiagram-W4KKPZXB.3dGcX5GP.js} +1 -1
  103. package/web/_astro/{requirementDiagram-4Y6WPE33.Bv1Gv9In.js → requirementDiagram-4Y6WPE33.BV2y4dd6.js} +3 -3
  104. package/web/_astro/{sankeyDiagram-5OEKKPKP.B6Gs4X4r.js → sankeyDiagram-5OEKKPKP.Cqo15Tvo.js} +4 -4
  105. package/web/_astro/{sequenceDiagram-3UESZ5HK.BhYj4v-m.js → sequenceDiagram-3UESZ5HK.CROCPMJB.js} +1 -1
  106. package/web/_astro/{stateDiagram-AJRCARHV.BPbBnkpw.js → stateDiagram-AJRCARHV.RfXZrkFE.js} +1 -1
  107. package/web/_astro/{stateDiagram-v2-BHNVJYJU.C4squMNK.js → stateDiagram-v2-BHNVJYJU.CPXmbBs9.js} +1 -1
  108. package/web/_astro/{timeline-definition-PNZ67QCA.C_SwIHgl.js → timeline-definition-PNZ67QCA.DdgKTiO8.js} +3 -3
  109. package/web/_astro/{vennDiagram-CIIHVFJN.Bz4NZGpQ.js → vennDiagram-CIIHVFJN.CPNVSHF1.js} +5 -5
  110. package/web/_astro/{wardleyDiagram-YWT4CUSO.CozMVZ3i.js → wardleyDiagram-YWT4CUSO.CQhA0Jyr.js} +3 -3
  111. package/web/_astro/{xychartDiagram-2RQKCTM6.BwMGBwjB.js → xychartDiagram-2RQKCTM6.n61BWyy4.js} +1 -1
  112. package/web/index.html +2 -2
  113. package/web/_astro/BoardApp.BEDWpzsr.js +0 -188
  114. package/web/_astro/BoardApp.DQG2xfEz.js +0 -1
  115. package/web/_astro/arc.C0rrflm_.js +0 -1
  116. package/web/_astro/channel.SRrg1P-w.js +0 -1
  117. package/web/_astro/diagram-2AECGRRQ.DJ0h9zgw.js +0 -43
  118. package/web/_astro/diagram-5GNKFQAL.DvPk1jYd.js +0 -10
  119. package/web/_astro/index.Bx6GY4RH.css +0 -1
  120. package/web/_astro/mermaid.core.kAZjgJHG.js +0 -301
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * residual-scan — deterministic task-leftover scanner behind feature F96 (ADR-071).
4
+ *
5
+ * Contract owner: docs/design/task-residual-sweep.md. Model-free detection; writes only
6
+ * under `.spur/run/` (observe-only). Four modes:
7
+ * scan <wbs> write `.spur/run/<wbs>-residuals.json`
8
+ * fold <wbs> fold blocking residuals into `<wbs>-verdict.json` (residual-sweep check,
9
+ * PASS→PARTIAL downgrade) and `<wbs>-test-gate.findings`
10
+ * settle <wbs> file one follow-up task for deferrables; delete `/tmp/<wbs>-*` residue
11
+ * report <wbs> write `.spur/run/<wbs>-residual-report.md` when the verdict's
12
+ * residual-sweep check failed; print the recovery line
13
+ *
14
+ * Environment: `spurBin` (CLI resolution, mirrors wrapup-steps `spurCommand`).
15
+ * Pure helpers (parsers, classify, foldVerdict, renderReport) are exported for unit
16
+ * testing; node/bun builtin imports only (plugin standalone contract).
17
+ */
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import { createHash } from 'node:crypto';
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { getEnvVars } from '../lib/env';
25
+
26
+ export const RESIDUAL_SCAN_USAGE =
27
+ 'usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]';
28
+
29
+ export type ResidualCategory = 'review-finding' | 'diff-marker' | 'unchecked-box' | 'staging-residue';
30
+ export type ResidualClass = 'blocking' | 'deferrable' | 'advisory' | 'housekeeping';
31
+
32
+ export interface ResidualItem {
33
+ id: string;
34
+ category: ResidualCategory;
35
+ class: ResidualClass;
36
+ priority?: string;
37
+ location: string;
38
+ text: string;
39
+ }
40
+
41
+ export interface ResidualArtifact {
42
+ wbs: string;
43
+ base: string | null;
44
+ scanned: Record<ResidualCategory, boolean>;
45
+ items: ResidualItem[];
46
+ counts: { blocking: number; deferrable: number; advisory: number; housekeeping: number };
47
+ }
48
+
49
+ export interface ScanEnv {
50
+ spurBin?: string;
51
+ [key: string]: string | undefined;
52
+ }
53
+
54
+ export interface ScanIo {
55
+ out: (line: string) => void;
56
+ err: (line: string) => void;
57
+ }
58
+
59
+ export interface ScanOptions {
60
+ cwd?: string;
61
+ /** Injectable output sinks; defaults to process streams (tests inject no-ops to keep reporter output clean). */
62
+ io?: ScanIo;
63
+ }
64
+
65
+ const MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
66
+ const PRIORITY_PATTERN = /^P[1-4]/;
67
+ const NONE_FINDING = /^(none|—)$/i;
68
+ const ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
69
+ /** `path:12-18` range anchor → single-line `path:12`. */
70
+ const RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
71
+ const EXCLUDED_PATHS = ['docs/tasks', 'docs/features/', '.spur/'];
72
+ export const ALLOW_PRAGMA = 'residual-scan:allow';
73
+
74
+ /** `spurBin` splits on whitespace into command + prefix args (same as wrapup-steps). */
75
+ export function spurCommand(spurBin: string | undefined): { cmd: string; prefix: string[] } {
76
+ const parts = (spurBin ?? 'spur')
77
+ .trim()
78
+ .split(/\s+/)
79
+ .filter((p) => p.length > 0);
80
+ return { cmd: parts[0] ?? 'spur', prefix: parts.slice(1) };
81
+ }
82
+
83
+ function run(cmd: string, args: string[], cwd: string): { status: number; stdout: string } {
84
+ const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
85
+ if (result.error !== undefined) return { status: result.status ?? 1, stdout: '' };
86
+ return { status: result.status ?? 1, stdout: result.stdout ?? '' };
87
+ }
88
+
89
+ export function makeItemId(category: ResidualCategory, location: string, text: string): string {
90
+ const normalized = text.trim().replace(/\s+/g, ' ');
91
+ const hex = createHash('sha256').update(`${location}${normalized}`).digest('hex');
92
+ return `${category}:${hex.slice(0, 8)}`;
93
+ }
94
+
95
+ /** `path:12-18` → `path:12` (anchor normalization from the design doc). */
96
+ export function normalizeAnchor(location: string): string {
97
+ return location.replace(RANGE_ANCHOR, '$1:$2');
98
+ }
99
+
100
+ /** Normalize a finding cell to an anchor: Location column, else first backticked `path:line`. */
101
+ export function locationOf(locationCell: string, finding: string): string {
102
+ const cell = locationCell.trim().replace(/`/g, '');
103
+ if (cell.length > 0 && cell !== '—') return normalizeAnchor(cell);
104
+ const backtick = finding.match(/`([^`]+)`/);
105
+ return backtick === null ? '' : normalizeAnchor(backtick[1]);
106
+ }
107
+
108
+ /**
109
+ * Extract review-finding rows: any `### Review` section table whose header carries a
110
+ * Priority column. Rows need `^P[1-4]` priority and a finding other than `none`/`—`.
111
+ */
112
+ export function parseReviewFindings(taskContent: string): Array<{ priority: string; location: string; text: string }> {
113
+ const section = taskContent.split(/^### Review\b/m)[1];
114
+ if (section === undefined) return [];
115
+ const body = section.split(/^### /m)[0];
116
+ const out: Array<{ priority: string; location: string; text: string }> = [];
117
+ const lines = body.split('\n');
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const line = lines[i];
120
+ if (line === undefined || !line.trimStart().startsWith('|')) continue;
121
+ const header = splitRow(line);
122
+ const priorityCol = header.findIndex((h) => h.trim() === 'Priority');
123
+ if (priorityCol === -1) {
124
+ // Not a Priority table; skip its separator + body rows.
125
+ while (i + 1 < lines.length && lines[i + 1]?.trimStart().startsWith('|')) i++;
126
+ continue;
127
+ }
128
+ const findingCol = header.findIndex((h) => h.trim() === 'Finding');
129
+ const locationCol = header.findIndex((h) => h.trim() === 'Location');
130
+ i++; // skip header
131
+ const sep = lines[i];
132
+ if (sep !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(sep)) i++; // skip separator
133
+ while (i < lines.length) {
134
+ const row = lines[i];
135
+ if (row === undefined || !row.trimStart().startsWith('|')) break;
136
+ const cells = splitRow(row);
137
+ const priority = (cells[priorityCol] ?? '').trim();
138
+ const finding = (cells[findingCol] ?? '').trim();
139
+ if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0) {
140
+ out.push({ priority, location: locationOf(cells[locationCol] ?? '', finding), text: finding });
141
+ }
142
+ i++;
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function splitRow(line: string): string[] {
149
+ return line
150
+ .trim()
151
+ .replace(/^\|/, '')
152
+ .replace(/\|$/, '')
153
+ .split('|')
154
+ .map((c) => c.trim());
155
+ }
156
+
157
+ /** Added lines (`<file>:<line>:<text>`) from `git diff --unified=0 <base>` plus untracked files. */
158
+ export function collectAddedLines(root: string, base: string): Array<{ file: string; line: number; text: string }> {
159
+ const out: Array<{ file: string; line: number; text: string }> = [];
160
+ const diff = run('git', ['diff', '--unified=0', base], root);
161
+ let file = '';
162
+ let newLine = 0;
163
+ for (const line of diff.stdout.split('\n')) {
164
+ if (line.startsWith('+++ b/')) file = line.slice(6);
165
+ else if (line.startsWith('@@')) {
166
+ const m = line.match(/\+[0-9]+/);
167
+ newLine = m === null ? newLine : Number.parseInt(m[0].slice(1), 10);
168
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
169
+ out.push({ file, line: newLine, text: line.slice(1) });
170
+ newLine++;
171
+ }
172
+ }
173
+ const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], root);
174
+ for (const f of untracked.stdout.split('\n')) {
175
+ if (f.length === 0) continue;
176
+ try {
177
+ if (!statSync(join(root, f)).isFile()) continue;
178
+ } catch {
179
+ continue;
180
+ }
181
+ const content = readFileSync(join(root, f), 'utf8').split('\n');
182
+ content.forEach((text, idx) => {
183
+ out.push({ file: f, line: idx + 1, text });
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /** TODO/FIXME/XXX/HACK markers on added lines, honoring path exclusions + allow pragma. */
190
+ export function parseDiffMarkers(
191
+ addedLines: Array<{ file: string; line: number; text: string }>,
192
+ ): Array<{ location: string; text: string }> {
193
+ return addedLines
194
+ .filter((l) => !EXCLUDED_PATHS.some((p) => l.file.startsWith(p)))
195
+ .filter((l) => !l.text.includes(ALLOW_PRAGMA))
196
+ .filter((l) => MARKER_PATTERN.test(l.text))
197
+ .map((l) => ({ location: `${l.file}:${l.line}`, text: l.text.trim() }));
198
+ }
199
+
200
+ /** `- [ ]` lines in the task file. */
201
+ export function findUncheckedBoxes(taskContent: string): Array<{ location: string; text: string }> {
202
+ const path = 'task-file';
203
+ return taskContent
204
+ .split('\n')
205
+ .map((text, idx) => ({ text: text.trim(), line: idx + 1 }))
206
+ .filter((l) => l.text.startsWith('- [ ]'))
207
+ .map((l) => ({ location: `${path}:${l.line}`, text: l.text }));
208
+ }
209
+
210
+ /** Regular files matching `<tmpDir>/<wbs>-*`. */
211
+ export function listStagingResidue(tmpDir: string, wbs: string): string[] {
212
+ let names: string[];
213
+ try {
214
+ names = readdirSync(tmpDir);
215
+ } catch {
216
+ return [];
217
+ }
218
+ return names
219
+ .filter((n) => n.startsWith(`${wbs}-`))
220
+ .filter((n) => {
221
+ try {
222
+ return statSync(join(tmpDir, n)).isFile();
223
+ } catch {
224
+ return false;
225
+ }
226
+ })
227
+ .map((n) => join(tmpDir, n));
228
+ }
229
+
230
+ interface Deferral {
231
+ id: string;
232
+ reason: string;
233
+ }
234
+
235
+ function readDeferrals(runDir: string, wbs: string): Deferral[] {
236
+ const path = join(runDir, `${wbs}-residual-deferrals.json`);
237
+ if (!existsSync(path)) return [];
238
+ try {
239
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
240
+ if (!Array.isArray(parsed)) return [];
241
+ return parsed.filter(
242
+ (e): e is Deferral =>
243
+ typeof e === 'object' &&
244
+ e !== null &&
245
+ typeof (e as Deferral).id === 'string' &&
246
+ typeof (e as Deferral).reason === 'string' &&
247
+ (e as Deferral).reason.trim().length > 0,
248
+ );
249
+ } catch {
250
+ return [];
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Classification: P1–P3 findings, diff markers and unchecked boxes are blocking; P4 is
256
+ * advisory; staging residue is housekeeping. A deferral entry with a non-empty reason
257
+ * reclassifies a P3 finding or diff marker as deferrable — never P1/P2 or unchecked boxes.
258
+ */
259
+ export function classify(
260
+ items: Array<Omit<ResidualItem, 'id' | 'class'> & { category: ResidualCategory }>,
261
+ deferrals: Deferral[],
262
+ ): ResidualItem[] {
263
+ const deferred = new Map(deferrals.map((d) => [d.id, d.reason]));
264
+ return items.map((item) => {
265
+ const id = makeItemId(item.category, item.location, item.text);
266
+ let klass: ResidualClass;
267
+ if (item.category === 'review-finding') klass = item.priority?.startsWith('P4') ? 'advisory' : 'blocking';
268
+ else if (item.category === 'staging-residue') klass = 'housekeeping';
269
+ else klass = 'blocking';
270
+ if (klass === 'blocking' && item.category !== 'unchecked-box') {
271
+ const p3Like =
272
+ item.category === 'diff-marker' ||
273
+ (item.category === 'review-finding' && (item.priority ?? '').startsWith('P3'));
274
+ const reason = deferred.get(id);
275
+ if (p3Like && reason !== undefined && reason.trim().length > 0) klass = 'deferrable';
276
+ }
277
+ return {
278
+ id,
279
+ category: item.category,
280
+ class: klass,
281
+ priority: item.priority,
282
+ location: item.location,
283
+ text: item.text,
284
+ };
285
+ });
286
+ }
287
+
288
+ export function scanResiduals(
289
+ root: string,
290
+ wbs: string,
291
+ tmpDir: string,
292
+ taskContent: string,
293
+ _env: ScanEnv,
294
+ ): ResidualArtifact {
295
+ const runDir = join(root, '.spur', 'run');
296
+ const basePath = join(runDir, `${wbs}-base.sha`);
297
+ const base = existsSync(basePath) ? readFileSync(basePath, 'utf8').trim() : null;
298
+ const review = parseReviewFindings(taskContent).map((r) => ({
299
+ category: 'review-finding' as const,
300
+ priority: r.priority,
301
+ location: r.location,
302
+ text: r.text,
303
+ }));
304
+ const markers =
305
+ base === null
306
+ ? []
307
+ : parseDiffMarkers(collectAddedLines(root, base)).map((m) => ({
308
+ category: 'diff-marker' as const,
309
+ location: m.location,
310
+ text: m.text,
311
+ }));
312
+ const boxes = findUncheckedBoxes(taskContent).map((b) => ({
313
+ category: 'unchecked-box' as const,
314
+ location: b.location,
315
+ text: b.text,
316
+ }));
317
+ const residue = listStagingResidue(tmpDir, wbs).map((p) => ({
318
+ category: 'staging-residue' as const,
319
+ location: p,
320
+ text: p,
321
+ }));
322
+ const items = classify([...review, ...markers, ...boxes, ...residue], readDeferrals(runDir, wbs));
323
+ const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
324
+ for (const item of items) counts[item.class]++;
325
+ return {
326
+ wbs,
327
+ base,
328
+ scanned: {
329
+ 'review-finding': true,
330
+ 'diff-marker': base !== null,
331
+ 'unchecked-box': true,
332
+ 'staging-residue': true,
333
+ },
334
+ items,
335
+ counts,
336
+ };
337
+ }
338
+
339
+ /** Blocking locations that match the `file.ext:line` findings-anchor shape. */
340
+ export function blockingAnchors(items: ResidualItem[]): string[] {
341
+ const anchors = new Set<string>();
342
+ for (const item of items) {
343
+ if (item.class !== 'blocking') continue;
344
+ for (const m of normalizeAnchor(item.location).matchAll(ANCHOR_PATTERN)) anchors.add(m[0]);
345
+ }
346
+ return [...anchors];
347
+ }
348
+
349
+ export interface FoldResult {
350
+ verdict: 'PASS' | 'PARTIAL' | 'FAIL';
351
+ checks: Array<{ name: string; status: string; evidence: string }>;
352
+ findings: string;
353
+ }
354
+
355
+ /**
356
+ * Fold the scan into a verdict: replace the `residual-sweep` check (fail when blocking > 0),
357
+ * downgrade PASS→PARTIAL when blocking > 0 (PARTIAL/FAIL unchanged), and merge blocking
358
+ * anchors into the gate findings (unique, sorted, cap 20). Idempotent.
359
+ */
360
+ export function foldVerdict(
361
+ verdict: { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> },
362
+ scan: ResidualArtifact,
363
+ existingFindings: string,
364
+ maxFindings = 20,
365
+ ): FoldResult {
366
+ const blocking = scan.items.filter((i) => i.class === 'blocking');
367
+ const deferrable = scan.items.filter((i) => i.class === 'deferrable');
368
+ const evidence =
369
+ `blocking=${blocking.length} deferrable=${deferrable.length} advisory=${scan.counts.advisory} housekeeping=${scan.counts.housekeeping}` +
370
+ (blocking.length > 0 ? `; blocking ids: ${blocking.map((i) => i.id).join(', ')}` : '') +
371
+ (deferrable.length > 0 ? `; deferrable ids: ${deferrable.map((i) => i.id).join(', ')}` : '');
372
+ const checks = verdict.checks.filter((c) => c.name !== 'residual-sweep');
373
+ checks.push({ name: 'residual-sweep', status: blocking.length > 0 ? 'fail' : 'pass', evidence });
374
+ const merged = new Set([
375
+ ...existingFindings.split(/\s+/).filter((a) => a.length > 0),
376
+ ...blockingAnchors(scan.items),
377
+ ]);
378
+ const findings = [...merged]
379
+ .sort()
380
+ .slice(0, maxFindings)
381
+ .map((a) => `${a} `)
382
+ .join('');
383
+ let verdictStatus: FoldResult['verdict'] = verdict.verdict === 'PASS' ? 'PASS' : 'FAIL';
384
+ if (verdict.verdict === 'PARTIAL') verdictStatus = 'PARTIAL';
385
+ else if (verdict.verdict === 'FAIL') verdictStatus = 'FAIL';
386
+ else if (blocking.length > 0) verdictStatus = 'PARTIAL';
387
+ return { verdict: verdictStatus, checks, findings };
388
+ }
389
+
390
+ /** Render the recovery report for blocking items. */
391
+ export function renderReport(wbs: string, items: ResidualItem[], attemptCount: number): string {
392
+ const lines = [
393
+ `# Residual report — ${wbs}`,
394
+ '',
395
+ `Attempt: ${attemptCount}`,
396
+ '',
397
+ '| Category | Class | Location | Text |',
398
+ '| --- | --- | --- | --- |',
399
+ ];
400
+ for (const item of items) {
401
+ lines.push(`| ${item.category} | ${item.class} | ${item.location} | ${item.text.replace(/\|/g, '\\|')} |`);
402
+ }
403
+ return `${lines.join('\n')}\n`;
404
+ }
405
+
406
+ // ── impure modes ─────────────────────────────────────────────────────────────
407
+
408
+ function parseArgs(
409
+ argv: string[],
410
+ ): { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string } | null {
411
+ let mode = '';
412
+ let wbs = '';
413
+ let spurBin: string | undefined;
414
+ let root = process.cwd();
415
+ let tmpDir = tmpdir();
416
+ for (let i = 0; i < argv.length; i++) {
417
+ const a = argv[i];
418
+ if (a === undefined) break;
419
+ if (a === '--spur-bin') spurBin = argv[++i];
420
+ else if (a === '--root') root = argv[++i] ?? root;
421
+ else if (a === '--tmp-dir') tmpDir = argv[++i] ?? tmpDir;
422
+ else if (a === '--help' || a === '-h') return null;
423
+ else if (mode === '') mode = a;
424
+ else if (wbs === '') wbs = a;
425
+ }
426
+ if (mode === '' || wbs === '') return null;
427
+ return { mode, wbs, spurBin, root, tmpDir };
428
+ }
429
+
430
+ function spur(
431
+ env: ScanEnv,
432
+ spurBinFlag: string | undefined,
433
+ args: string[],
434
+ cwd: string,
435
+ ): { status: number; stdout: string } {
436
+ return run(
437
+ spurCommand(spurBinFlag ?? env.spurBin).cmd,
438
+ [...spurCommand(spurBinFlag ?? env.spurBin).prefix, ...args],
439
+ cwd,
440
+ );
441
+ }
442
+
443
+ function loadTask(
444
+ env: ScanEnv,
445
+ spurBinFlag: string | undefined,
446
+ wbs: string,
447
+ root: string,
448
+ ): { content: string; featureId: string } {
449
+ const res = spur(env, spurBinFlag, ['task', 'show', wbs, '--json'], root);
450
+ if (res.status !== 0) throw new Error(`task show ${wbs} failed`);
451
+ const parsed = JSON.parse(res.stdout) as Record<string, unknown>;
452
+ const content = typeof parsed.content === 'string' ? parsed.content : '';
453
+ const fm = parsed.frontmatter;
454
+ const featureId =
455
+ typeof parsed.feature_id === 'string'
456
+ ? parsed.feature_id
457
+ : fm !== null && typeof fm === 'object' && typeof (fm as Record<string, unknown>).feature_id === 'string'
458
+ ? ((fm as Record<string, unknown>).feature_id as string)
459
+ : '';
460
+ return { content, featureId };
461
+ }
462
+
463
+ function loadVerdict(
464
+ runDir: string,
465
+ wbs: string,
466
+ ): { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> } {
467
+ return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), 'utf8'));
468
+ }
469
+
470
+ function scanMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
471
+ const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
472
+ const runDir = join(opts.root, '.spur', 'run');
473
+ mkdirSync(runDir, { recursive: true });
474
+ const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
475
+ writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
476
+ io.out(
477
+ `residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}\n`,
478
+ );
479
+ return 0;
480
+ }
481
+
482
+ function foldMode(opts: NonNullable<ReturnType<typeof parseArgs>>, _env: ScanEnv, io: ScanIo): number {
483
+ const runDir = join(opts.root, '.spur', 'run');
484
+ const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8')) as ResidualArtifact;
485
+ const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
486
+ const verdict = loadVerdict(runDir, opts.wbs);
487
+ const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
488
+ const existing = existsSync(findingsPath) ? readFileSync(findingsPath, 'utf8') : '';
489
+ const folded = foldVerdict(verdict, scan, existing);
490
+ writeFileSync(
491
+ verdictPath,
492
+ `${JSON.stringify({ ...verdict, verdict: folded.verdict, checks: folded.checks }, null, 2)}\n`,
493
+ );
494
+ writeFileSync(findingsPath, folded.findings);
495
+ io.out(
496
+ `residual-fold: ${opts.wbs} verdict=${folded.verdict} residual-sweep=${folded.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
497
+ );
498
+ return 0;
499
+ }
500
+
501
+ function settleMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
502
+ const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
503
+ const runDir = join(opts.root, '.spur', 'run');
504
+ const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
505
+ const residualsPath = join(runDir, `${opts.wbs}-residuals.json`);
506
+ const deferred = scan.items.filter((i) => i.class === 'deferrable');
507
+ const prior = existsSync(residualsPath)
508
+ ? (JSON.parse(readFileSync(residualsPath, 'utf8')) as Partial<ResidualArtifact & { followUp?: string }>)
509
+ : {};
510
+ if (deferred.length > 0 && prior.followUp === undefined) {
511
+ if (task.featureId === '') {
512
+ io.err(
513
+ `residual-settle: ${opts.wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${opts.wbs}\n`,
514
+ );
515
+ return 0;
516
+ }
517
+ const created = spur(
518
+ env,
519
+ opts.spurBin,
520
+ ['task', 'create', `Residuals from ${opts.wbs}`, '--feature', task.featureId, '--skip-ready', '--json'],
521
+ opts.root,
522
+ );
523
+ if (created.status !== 0) {
524
+ io.err(`residual-settle: task create failed; re-run: residual-scan settle ${opts.wbs}\n`);
525
+ return 0;
526
+ }
527
+ let wbsNew = '';
528
+ try {
529
+ const parsed = JSON.parse(created.stdout) as Record<string, unknown>;
530
+ const pick = (o: Record<string, unknown>): string => (typeof o.wbs === 'string' ? o.wbs : '');
531
+ wbsNew =
532
+ pick(parsed) ||
533
+ (parsed.data !== null && typeof parsed.data === 'object'
534
+ ? pick(parsed.data as Record<string, unknown>)
535
+ : '');
536
+ } catch {
537
+ wbsNew = '';
538
+ }
539
+ if (wbsNew === '') {
540
+ io.err(`residual-settle: could not read created task wbs; re-run: residual-scan settle ${opts.wbs}\n`);
541
+ return 0;
542
+ }
543
+ const bg = [
544
+ `Source task: ${opts.wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`,
545
+ '',
546
+ ...deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`),
547
+ ].join('\n');
548
+ const bgFile = join(runDir, `${opts.wbs}-residual-background.md`);
549
+ writeFileSync(bgFile, `${bg}\n`);
550
+ const upd = spur(
551
+ env,
552
+ opts.spurBin,
553
+ ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile],
554
+ opts.root,
555
+ );
556
+ if (upd.status !== 0) {
557
+ io.err(`residual-settle: background write failed; re-run: residual-scan settle ${opts.wbs}\n`);
558
+ return 0;
559
+ }
560
+ prior.followUp = wbsNew;
561
+ io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)\n`);
562
+ }
563
+ // Cleanup: only regular files `<tmpDir>/<wbs>-*`. Never directories, never other prefixes.
564
+ for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
565
+ try {
566
+ rmSync(path, { force: true });
567
+ } catch {
568
+ io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${opts.wbs}\n`);
569
+ return 0;
570
+ }
571
+ }
572
+ writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
573
+ return 0;
574
+ }
575
+
576
+ function reportMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
577
+ const runDir = join(opts.root, '.spur', 'run');
578
+ const verdict = loadVerdict(runDir, opts.wbs);
579
+ const sweep = verdict.checks.find((c) => c.name === 'residual-sweep');
580
+ if (sweep === undefined || sweep.status !== 'fail') return 0;
581
+ const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
582
+ const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
583
+ const blocking = scan.items.filter((i) => i.class === 'blocking');
584
+ const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
585
+ const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, 'utf8').trim() || '0', 10) : 0;
586
+ const reportPath = join(runDir, `${opts.wbs}-residual-report.md`);
587
+ writeFileSync(reportPath, renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
588
+ io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}\n`);
589
+ return 0;
590
+ }
591
+
592
+ export function main(argv: string[], env: ScanEnv = getEnvVars(), options: ScanOptions = {}): number {
593
+ const io: ScanIo = options.io ?? {
594
+ out: (line) => process.stdout.write(line),
595
+ err: (line) => process.stderr.write(line),
596
+ };
597
+ const opts = parseArgs(argv);
598
+ if (opts === null) {
599
+ io.err(`${RESIDUAL_SCAN_USAGE}\n`);
600
+ return 2;
601
+ }
602
+ const cwd = options.cwd ?? process.cwd();
603
+ const resolved = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
604
+ if (resolved.mode === 'scan') return scanMode(resolved, env, io);
605
+ if (resolved.mode === 'fold') return foldMode(resolved, env, io);
606
+ if (resolved.mode === 'settle') return settleMode(resolved, env, io);
607
+ if (resolved.mode === 'report') return reportMode(resolved, env, io);
608
+ io.err(`${RESIDUAL_SCAN_USAGE}\n`);
609
+ return 2;
610
+ }
611
+
612
+ if (import.meta.main) {
613
+ process.exit(main(process.argv.slice(2)));
614
+ }
@@ -74,10 +74,15 @@ function parseArgs(argv: string[]): { wbs: string; spurBin: string } {
74
74
  if (arg === '--spur-bin') {
75
75
  spurBin = argv[i + 1] ?? defaultSpurBin();
76
76
  i += 2;
77
- } else if (!arg.startsWith('--')) {
78
- wbs = arg;
79
- i++;
77
+ } else if (arg.startsWith('-')) {
78
+ // 0948 R4: an unknown flag is a mis-invocation, not something to swallow.
79
+ // The old `else { i++; }` let `script 0926 --task-file x.md` run against x.md.
80
+ console.error(`task-evidence-precheck: unknown flag: ${arg}`);
81
+ usage();
80
82
  } else {
83
+ // 0948 R4: first positional wins — a later positional must never overwrite
84
+ // `wbs` (it used to write a garbage-named status file and mask the exit code).
85
+ if (wbs === '') wbs = arg;
81
86
  i++;
82
87
  }
83
88
  }
@@ -124,10 +124,15 @@ function parseArgs(argv: string[]): {
124
124
  } else if (arg === '--max-plan-items') {
125
125
  maxPlanItems = Number(argv[i + 1]) || 16;
126
126
  i += 2;
127
- } else if (!arg.startsWith('--')) {
128
- wbs = arg;
129
- i++;
127
+ } else if (arg.startsWith('-')) {
128
+ // 0948 R4: an unknown flag is a mis-invocation, not something to swallow.
129
+ // The old `else { i++; }` let `script 0926 --task-file x.md` run against x.md.
130
+ console.error(`task-size-precheck: unknown flag: ${arg}`);
131
+ usage();
130
132
  } else {
133
+ // 0948 R4: first positional wins — a later positional must never overwrite
134
+ // `wbs` (it used to write a garbage-named status file and mask the exit code).
135
+ if (wbs === '') wbs = arg;
131
136
  i++;
132
137
  }
133
138
  }
@@ -81,6 +81,7 @@ git push
81
81
  ```bash
82
82
  git branch -d feature/<slug> # Delete merged branch
83
83
  git worktree remove ../<project>-feature-<slug> # Remove worktree if used
84
+ spur projects remove ../<project>-feature-<slug> 2>/dev/null || true # Deregister from projects.json if registered
84
85
  git worktree prune # Clean up stale worktree references
85
86
  ```
86
87
 
@@ -46,6 +46,7 @@ Shows all worktrees with their branches and paths.
46
46
 
47
47
  ```bash
48
48
  git worktree remove ../project-hotfix
49
+ spur projects remove ../project-hotfix 2>/dev/null || true
49
50
  ```
50
51
 
51
52
  ### Prune (clean up stale references)
@@ -83,6 +84,7 @@ After removing a worktree, run `git worktree prune` and `git gc` to reclaim spac
83
84
 
84
85
  ```bash
85
86
  git worktree remove ../old-worktree
87
+ spur projects remove ../old-worktree 2>/dev/null || true
86
88
  git worktree prune
87
89
  git gc --aggressive
88
90
  ```