@gobing-ai/spur 0.3.95 → 0.3.97

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/plugin-scripts.json +0 -54
  3. package/config/rules/boundary/sp-script-placement.yaml +19 -0
  4. package/config/rules/strict/runtime-boundaries.yaml +6 -0
  5. package/config/rules/structure/test-location.yaml +2 -0
  6. package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
  7. package/config/rules/typescript/output-boundaries.yaml +15 -2
  8. package/config/script-placement-baseline.json +51 -0
  9. package/config/templates/AGENTS.md +3 -3
  10. package/config/workflows/feature-verification.yaml +1 -1
  11. package/config/workflows/history-anatomy.yaml +23 -11
  12. package/config/workflows/idea-pipeline.yaml +80 -85
  13. package/config/workflows/pr-review.yaml +18 -9
  14. package/config/workflows/task-pipeline.yaml +42 -95
  15. package/config/workflows/wrapup-pipeline.yaml +91 -38
  16. package/package.json +2 -2
  17. package/plugins/sp/README.md +14 -10
  18. package/plugins/sp/agents/super-reviewer.md +43 -18
  19. package/plugins/sp/commands/dev-fixgha.md +83 -0
  20. package/plugins/sp/commands/dev-gitmsg.md +8 -6
  21. package/plugins/sp/commands/dev-gtd.md +2 -2
  22. package/plugins/sp/commands/dev-idea.md +22 -12
  23. package/plugins/sp/commands/dev-job-dump.md +29 -0
  24. package/plugins/sp/commands/dev-job-resume.md +29 -0
  25. package/plugins/sp/commands/dev-plan.md +8 -9
  26. package/plugins/sp/commands/dev-review.md +22 -13
  27. package/plugins/sp/commands/dev-verifyall.md +1 -1
  28. package/plugins/sp/commands/spur-init.md +2 -2
  29. package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
  30. package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
  31. package/plugins/sp/lib/idea-handoff.generated.mjs +5 -4
  32. package/plugins/sp/lib/inline-run.generated.d.mts +11 -0
  33. package/plugins/sp/lib/inline-run.generated.mjs +39 -13
  34. package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
  35. package/plugins/sp/lib/quality-gate.generated.mjs +445 -0
  36. package/plugins/sp/lib/residual-scan.generated.d.mts +63 -0
  37. package/plugins/sp/lib/residual-scan.generated.mjs +226 -0
  38. package/plugins/sp/lib/spur-bin.ts +36 -0
  39. package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
  40. package/plugins/sp/lib/step-profile.generated.mjs +174 -0
  41. package/plugins/sp/plugin.json +1 -1
  42. package/plugins/sp/references/roles.md +3 -3
  43. package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
  44. package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
  45. package/plugins/sp/scripts/history-anatomy-cache.mjs +20 -19
  46. package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
  47. package/plugins/sp/scripts/inline-run-setup.mjs +85 -320
  48. package/plugins/sp/scripts/inline-run-setup.ts +104 -667
  49. package/plugins/sp/scripts/quality-gate.mjs +46 -24
  50. package/plugins/sp/scripts/quality-gate.ts +13 -659
  51. package/plugins/sp/scripts/residual-scan.mjs +179 -174
  52. package/plugins/sp/scripts/residual-scan.ts +125 -517
  53. package/plugins/sp/scripts/script-root.mjs +5 -1
  54. package/plugins/sp/scripts/script-root.ts +5 -1
  55. package/plugins/sp/scripts/workflow-step-profile.mjs +25 -17
  56. package/plugins/sp/scripts/workflow-step-profile.ts +21 -315
  57. package/plugins/sp/scripts/wrapup-drift-probe.mjs +8 -2
  58. package/plugins/sp/scripts/wrapup-drift-probe.ts +3 -2
  59. package/plugins/sp/scripts/wrapup-steps.mjs +72 -33
  60. package/plugins/sp/scripts/wrapup-steps.ts +128 -37
  61. package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
  62. package/plugins/sp/skills/code-verification/SKILL.md +34 -9
  63. package/plugins/sp/skills/code-verification/references/verdict-schema.md +3 -3
  64. package/plugins/sp/skills/functional-review/SKILL.md +7 -4
  65. package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
  66. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
  67. package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
  68. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  69. package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
  70. package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
  71. package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
  72. package/plugins/sp/skills/spur-cli/references/features.md +17 -6
  73. package/plugins/sp/skills/spur-cli/references/init.md +17 -16
  74. package/plugins/sp/skills/spur-cli/references/self.md +3 -2
  75. package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
  76. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
  77. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +21 -6
  78. package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
  79. package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
  80. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  81. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
  82. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -10
  83. package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
  84. package/plugins/sp/skills/spur-dev/references/dev-operations.md +165 -75
  85. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +7 -5
  86. package/plugins/sp/skills/spur-dev/references/execution-batch.md +84 -39
  87. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +8 -12
  88. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
  89. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +40 -21
  90. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
  91. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
  92. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +72 -15
  93. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
  94. package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
  95. package/spur.js +4461 -2085
  96. package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.Bmv5WkJ9.js} +1 -1
  97. package/web/_astro/BoardApp.vveLOdDq.js +322 -0
  98. package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.IKc3uDYV.js} +1 -1
  99. package/web/_astro/{arc.CyjRvNMY.js → arc.C63Ufg35.js} +1 -1
  100. package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
  101. package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
  102. package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
  103. package/web/_astro/channel.BKCmJpWM.js +1 -0
  104. package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
  105. package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
  106. package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
  107. package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
  108. package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
  109. package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
  110. package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
  111. package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
  112. package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
  113. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
  114. package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
  115. package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
  116. package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
  117. package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
  118. package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
  119. package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
  120. package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
  121. package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
  122. package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
  123. package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
  124. package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
  125. package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
  126. package/web/_astro/index.CbNz17Sx.css +1 -0
  127. package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
  128. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
  129. package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
  130. package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
  131. package/web/_astro/{linear.zRsuuDTE.js → linear.BATV4RXu.js} +1 -1
  132. package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DzkwM3VX.js} +4 -4
  133. package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
  134. package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
  135. package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
  136. package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
  137. package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
  138. package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
  139. package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
  140. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
  141. package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
  142. package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
  143. package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
  144. package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
  145. package/web/index.html +2 -2
  146. package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
  147. package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
  148. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -301
  149. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -481
  150. package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
  151. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
  152. package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
  153. package/plugins/sp/scripts/record-feature-sync.ts +0 -84
  154. package/plugins/sp/scripts/script-contract-check.ts +0 -506
  155. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  156. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
  157. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -189
  158. package/plugins/sp/scripts/task-size-precheck.ts +0 -212
  159. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  160. package/plugins/sp/scripts/validate-commands.ts +0 -689
  161. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -890
  162. package/plugins/sp/scripts/verify-answer-lint.ts +0 -549
  163. package/web/_astro/BoardApp.BxJuwD7I.js +0 -191
  164. package/web/_astro/channel.CI6N_tCg.js +0 -1
  165. package/web/_astro/index.CENnIEqT.css +0 -1
@@ -1,180 +1,40 @@
1
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
-
2
+ /** F96 residual IO glue. Pure logic: residual-scan.generated.mjs; contract: task-residual-sweep.md. */
19
3
  import { spawnSync } from 'node:child_process';
20
- import { createHash } from 'node:crypto';
21
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import * as fs from 'node:fs';
22
5
  import { tmpdir } from 'node:os';
23
6
  import { join } from 'node:path';
24
7
  import { getEnvVars } from '../lib/env';
8
+ import * as core from '../lib/residual-scan.generated.mjs';
9
+ import { spurCommand } from '../lib/spur-bin';
25
10
 
11
+ export * from '../lib/residual-scan.generated.mjs';
26
12
  export const RESIDUAL_SCAN_USAGE =
27
13
  '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
- // Placeholder "no finding" cells, optionally with a trailing "(…)" note; "None of X…" is a real finding.
68
- const NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
69
- const DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
70
- const RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
71
- const DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
72
- const ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
73
- /** `path:12-18` range anchor → single-line `path:12`. */
74
- const RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
75
- const EXCLUDED_PATHS = ['docs/tasks', 'docs/features/', '.spur/'];
76
- export const ALLOW_PRAGMA = 'residual-scan:allow';
77
-
78
- /** `spurBin` splits on whitespace into command + prefix args (same as wrapup-steps). */
79
- export function spurCommand(spurBin: string | undefined): { cmd: string; prefix: string[] } {
80
- const parts = (spurBin ?? 'spur')
81
- .trim()
82
- .split(/\s+/)
83
- .filter((p) => p.length > 0);
84
- return { cmd: parts[0] ?? 'spur', prefix: parts.slice(1) };
85
- }
86
-
87
- function run(cmd: string, args: string[], cwd: string): { status: number; stdout: string } {
14
+ export type ScanEnv = { spurBin?: string } & Record<string, string | undefined>;
15
+ export type ScanIo = { out: (line: string) => void; err: (line: string) => void };
16
+ export type ScanOptions = { cwd?: string; io?: ScanIo };
17
+ type RunResult = { status: number; stdout: string };
18
+ type ParsedArgs = { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string };
19
+ type ModeFn = (o: ParsedArgs, e: ScanEnv, i: ScanIo) => number;
20
+ type ScanTask = { content: string; featureId: string };
21
+ type VerdictFile = { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> };
22
+ function run(cmd: string, args: string[], cwd: string): RunResult {
88
23
  const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
89
24
  if (result.error !== undefined) return { status: result.status ?? 1, stdout: '' };
90
25
  return { status: result.status ?? 1, stdout: result.stdout ?? '' };
91
26
  }
92
-
93
- export function makeItemId(category: ResidualCategory, location: string, text: string): string {
94
- const normalized = text.trim().replace(/\s+/g, ' ');
95
- const hex = createHash('sha256').update(`${location}${normalized}`).digest('hex');
96
- return `${category}:${hex.slice(0, 8)}`;
97
- }
98
-
99
- /** `path:12-18` → `path:12` (anchor normalization from the design doc). */
100
- export function normalizeAnchor(location: string): string {
101
- return location.replace(RANGE_ANCHOR, '$1:$2');
102
- }
103
-
104
- /** Normalize a finding cell to an anchor: Location column, else first backticked `path:line`. */
105
- export function locationOf(locationCell: string, finding: string): string {
106
- const cell = locationCell.trim().replace(/`/g, '');
107
- if (cell.length > 0 && cell !== '—') return normalizeAnchor(cell);
108
- const backtick = finding.match(/`([^`]+)`/)?.[1];
109
- return backtick === undefined ? '' : normalizeAnchor(backtick);
27
+ function spur(env: ScanEnv, spurBinFlag: string | undefined, args: string[], cwd: string): RunResult {
28
+ const { cmd, prefix } = spurCommand(spurBinFlag ?? env.spurBin);
29
+ return run(cmd, [...prefix, ...args], cwd);
110
30
  }
111
-
112
- /**
113
- * Extract review-finding rows: any `### Review` section table whose header carries a
114
- * Priority column. Rows need `^P[1-4]` priority and a finding other than `none`/`—`.
115
- * A disposition column (Disposition/Action/Status/Resolution/Fixed) is honored: `FIXED`/
116
- * `RESOLVED`/`DONE` rows are dropped; `DEFER` rows carry the cell as an in-table deferral reason.
117
- */
118
- export function parseReviewFindings(
119
- taskContent: string,
120
- ): Array<{ priority: string; location: string; text: string; deferral?: string }> {
121
- const section = taskContent.split(/^### Review\b/m)[1];
122
- if (section === undefined) return [];
123
- const body = section.split(/^### /m)[0] ?? '';
124
- const out: Array<{ priority: string; location: string; text: string; deferral?: string }> = [];
125
- const lines = body.split('\n');
126
- for (let i = 0; i < lines.length; i++) {
127
- const line = lines[i];
128
- if (line === undefined || !line.trimStart().startsWith('|')) continue;
129
- const header = splitRow(line);
130
- const priorityCol = header.findIndex((h) => h.trim() === 'Priority');
131
- if (priorityCol === -1) {
132
- // Not a Priority table; skip its separator + body rows.
133
- while (i + 1 < lines.length && lines[i + 1]?.trimStart().startsWith('|')) i++;
134
- continue;
135
- }
136
- const findingCol = header.findIndex((h) => h.trim() === 'Finding');
137
- const locationCol = header.findIndex((h) => h.trim() === 'Location');
138
- const dispositionCol = header.findIndex((h) => DISPOSITION_HEADER.test(h.trim()));
139
- i++; // skip header
140
- const sep = lines[i];
141
- if (sep !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(sep)) i++; // skip separator
142
- while (i < lines.length) {
143
- const row = lines[i];
144
- if (row === undefined || !row.trimStart().startsWith('|')) break;
145
- const cells = splitRow(row);
146
- const priority = (cells[priorityCol] ?? '').trim();
147
- const finding = (cells[findingCol] ?? '').trim();
148
- const disposition = dispositionCol === -1 ? '' : (cells[dispositionCol] ?? '').trim();
149
- if (
150
- PRIORITY_PATTERN.test(priority) &&
151
- !NONE_FINDING.test(finding) &&
152
- finding.length > 0 &&
153
- !RESOLVED_DISPOSITION.test(disposition)
154
- ) {
155
- const location = locationOf(cells[locationCol] ?? '', finding);
156
- out.push(
157
- DEFERRED_DISPOSITION.test(disposition)
158
- ? { priority, location, text: finding, deferral: disposition }
159
- : { priority, location, text: finding },
160
- );
161
- }
162
- i++;
163
- }
31
+ function isRegularFile(path: string): boolean {
32
+ try {
33
+ return fs.statSync(path).isFile();
34
+ } catch {
35
+ return false;
164
36
  }
165
- return out;
166
- }
167
-
168
- function splitRow(line: string): string[] {
169
- return line
170
- .trim()
171
- .replace(/^\|/, '')
172
- .replace(/\|$/, '')
173
- .split('|')
174
- .map((c) => c.trim());
175
37
  }
176
-
177
- /** Added lines (`<file>:<line>:<text>`) from `git diff --unified=0 <base>` plus untracked files. */
178
38
  export function collectAddedLines(root: string, base: string): Array<{ file: string; line: number; text: string }> {
179
39
  const out: Array<{ file: string; line: number; text: string }> = [];
180
40
  const diff = run('git', ['diff', '--unified=0', base], root);
@@ -182,259 +42,53 @@ export function collectAddedLines(root: string, base: string): Array<{ file: str
182
42
  let newLine = 0;
183
43
  for (const line of diff.stdout.split('\n')) {
184
44
  if (line.startsWith('+++ b/')) file = line.slice(6);
185
- else if (line.startsWith('@@')) {
186
- const m = line.match(/\+[0-9]+/);
187
- newLine = m === null ? newLine : Number.parseInt(m[0].slice(1), 10);
188
- } else if (line.startsWith('+') && !line.startsWith('+++')) {
189
- out.push({ file, line: newLine, text: line.slice(1) });
190
- newLine++;
191
- }
45
+ else if (line.startsWith('@@')) newLine = Number.parseInt((line.match(/\+[0-9]+/) ?? ['+0'])[0].slice(1), 10);
46
+ else if (line.startsWith('+') && !line.startsWith('+++'))
47
+ out.push({ file, line: newLine++, text: line.slice(1) });
192
48
  }
193
49
  const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], root);
194
50
  for (const f of untracked.stdout.split('\n')) {
195
- if (f.length === 0) continue;
196
- try {
197
- if (!statSync(join(root, f)).isFile()) continue;
198
- } catch {
199
- continue;
200
- }
201
- const content = readFileSync(join(root, f), 'utf8').split('\n');
202
- content.forEach((text, idx) => {
51
+ if (f.length === 0 || !isRegularFile(join(root, f))) continue;
52
+ for (const [idx, text] of fs.readFileSync(join(root, f), 'utf8').split('\n').entries())
203
53
  out.push({ file: f, line: idx + 1, text });
204
- });
205
54
  }
206
55
  return out;
207
56
  }
208
-
209
- /** TODO/FIXME/XXX/HACK markers on added lines, honoring path exclusions + allow pragma. */
210
- export function parseDiffMarkers(
211
- addedLines: Array<{ file: string; line: number; text: string }>,
212
- ): Array<{ location: string; text: string }> {
213
- return addedLines
214
- .filter((l) => !EXCLUDED_PATHS.some((p) => l.file.startsWith(p)))
215
- .filter((l) => !l.text.includes(ALLOW_PRAGMA))
216
- .filter((l) => MARKER_PATTERN.test(l.text))
217
- .map((l) => ({ location: `${l.file}:${l.line}`, text: l.text.trim() }));
218
- }
219
-
220
- /** `- [ ]` lines in the task file. */
221
- export function findUncheckedBoxes(taskContent: string): Array<{ location: string; text: string }> {
222
- const path = 'task-file';
223
- return taskContent
224
- .split('\n')
225
- .map((text, idx) => ({ text: text.trim(), line: idx + 1 }))
226
- .filter((l) => l.text.startsWith('- [ ]'))
227
- .map((l) => ({ location: `${path}:${l.line}`, text: l.text }));
228
- }
229
-
230
- /** Regular files matching `<tmpDir>/<wbs>-*`. */
231
57
  export function listStagingResidue(tmpDir: string, wbs: string): string[] {
232
58
  let names: string[];
233
59
  try {
234
- names = readdirSync(tmpDir);
60
+ names = fs.readdirSync(tmpDir);
235
61
  } catch {
236
62
  return [];
237
63
  }
238
- return names
239
- .filter((n) => n.startsWith(`${wbs}-`))
240
- .filter((n) => {
241
- try {
242
- return statSync(join(tmpDir, n)).isFile();
243
- } catch {
244
- return false;
245
- }
246
- })
247
- .map((n) => join(tmpDir, n));
248
- }
249
-
250
- interface Deferral {
251
- id: string;
252
- reason: string;
64
+ return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join(tmpDir, n))).map((n) => join(tmpDir, n));
253
65
  }
254
-
255
- function readDeferrals(runDir: string, wbs: string): Deferral[] {
66
+ function readDeferrals(runDir: string, wbs: string): core.Deferral[] {
256
67
  const path = join(runDir, `${wbs}-residual-deferrals.json`);
257
- if (!existsSync(path)) return [];
68
+ if (!fs.existsSync(path)) return [];
69
+ const isDeferral = (e: unknown): e is core.Deferral =>
70
+ typeof e === 'object' &&
71
+ e !== null &&
72
+ typeof (e as core.Deferral).id === 'string' &&
73
+ typeof (e as core.Deferral).reason === 'string' &&
74
+ (e as core.Deferral).reason.trim().length > 0;
258
75
  try {
259
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
260
- if (!Array.isArray(parsed)) return [];
261
- return parsed.filter(
262
- (e): e is Deferral =>
263
- typeof e === 'object' &&
264
- e !== null &&
265
- typeof (e as Deferral).id === 'string' &&
266
- typeof (e as Deferral).reason === 'string' &&
267
- (e as Deferral).reason.trim().length > 0,
268
- );
76
+ const parsed: unknown = JSON.parse(fs.readFileSync(path, 'utf8'));
77
+ return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
269
78
  } catch {
270
79
  return [];
271
80
  }
272
81
  }
273
-
274
- /**
275
- * Classification: P1–P3 findings, diff markers and unchecked boxes are blocking; P4 is
276
- * advisory; staging residue is housekeeping. A deferral entry with a non-empty reason
277
- * reclassifies a P3 finding or diff marker as deferrable — never P1/P2 or unchecked boxes.
278
- */
279
- export function classify(
280
- items: Array<Omit<ResidualItem, 'id' | 'class'> & { category: ResidualCategory }>,
281
- deferrals: Deferral[],
282
- ): ResidualItem[] {
283
- const deferred = new Map(deferrals.map((d) => [d.id, d.reason]));
284
- return items.map((item) => {
285
- const id = makeItemId(item.category, item.location, item.text);
286
- let klass: ResidualClass;
287
- if (item.category === 'review-finding') klass = item.priority?.startsWith('P4') ? 'advisory' : 'blocking';
288
- else if (item.category === 'staging-residue') klass = 'housekeeping';
289
- else klass = 'blocking';
290
- if (klass === 'blocking' && item.category !== 'unchecked-box') {
291
- const p3Like =
292
- item.category === 'diff-marker' ||
293
- (item.category === 'review-finding' && (item.priority ?? '').startsWith('P3'));
294
- const reason = deferred.get(id);
295
- if (p3Like && reason !== undefined && reason.trim().length > 0) klass = 'deferrable';
296
- }
297
- return {
298
- id,
299
- category: item.category,
300
- class: klass,
301
- priority: item.priority,
302
- location: item.location,
303
- text: item.text,
304
- };
305
- });
306
- }
307
-
308
- export function scanResiduals(
309
- root: string,
310
- wbs: string,
311
- tmpDir: string,
312
- taskContent: string,
313
- _env: ScanEnv,
314
- ): ResidualArtifact {
82
+ export function scanResiduals(root: string, wbs: string, tmpDir: string, taskContent: string, _env: ScanEnv) {
315
83
  const runDir = join(root, '.spur', 'run');
316
84
  const basePath = join(runDir, `${wbs}-base.sha`);
317
- const base = existsSync(basePath) ? readFileSync(basePath, 'utf8').trim() : null;
318
- const reviewRows = parseReviewFindings(taskContent);
319
- const tableDeferrals = reviewRows.flatMap((r) =>
320
- r.deferral === undefined ? [] : [{ id: makeItemId('review-finding', r.location, r.text), reason: r.deferral }],
321
- );
322
- const review = reviewRows.map((r) => ({
323
- category: 'review-finding' as const,
324
- priority: r.priority,
325
- location: r.location,
326
- text: r.text,
327
- }));
328
- const markers =
329
- base === null
330
- ? []
331
- : parseDiffMarkers(collectAddedLines(root, base)).map((m) => ({
332
- category: 'diff-marker' as const,
333
- location: m.location,
334
- text: m.text,
335
- }));
336
- const boxes = findUncheckedBoxes(taskContent).map((b) => ({
337
- category: 'unchecked-box' as const,
338
- location: b.location,
339
- text: b.text,
340
- }));
341
- const residue = listStagingResidue(tmpDir, wbs).map((p) => ({
342
- category: 'staging-residue' as const,
343
- location: p,
344
- text: p,
345
- }));
346
- const items = classify(
347
- [...review, ...markers, ...boxes, ...residue],
348
- [...tableDeferrals, ...readDeferrals(runDir, wbs)],
349
- );
350
- const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
351
- for (const item of items) counts[item.class]++;
352
- return {
353
- wbs,
354
- base,
355
- scanned: {
356
- 'review-finding': true,
357
- 'diff-marker': base !== null,
358
- 'unchecked-box': true,
359
- 'staging-residue': true,
360
- },
361
- items,
362
- counts,
363
- };
364
- }
365
-
366
- /** Blocking locations that match the `file.ext:line` findings-anchor shape. */
367
- export function blockingAnchors(items: ResidualItem[]): string[] {
368
- const anchors = new Set<string>();
369
- for (const item of items) {
370
- if (item.class !== 'blocking') continue;
371
- for (const m of normalizeAnchor(item.location).matchAll(ANCHOR_PATTERN)) anchors.add(m[0]);
372
- }
373
- return [...anchors];
85
+ const base = fs.existsSync(basePath) ? fs.readFileSync(basePath, 'utf8').trim() : null;
86
+ const stagingResidue = listStagingResidue(tmpDir, wbs);
87
+ const deferrals = readDeferrals(runDir, wbs);
88
+ const addedLines = base === null ? [] : collectAddedLines(root, base);
89
+ return core.scanResiduals({ wbs, base, taskContent, addedLines, stagingResidue, deferrals });
374
90
  }
375
-
376
- export interface FoldResult {
377
- verdict: 'PASS' | 'PARTIAL' | 'FAIL';
378
- checks: Array<{ name: string; status: string; evidence: string }>;
379
- findings: string;
380
- }
381
-
382
- /**
383
- * Fold the scan into a verdict: replace the `residual-sweep` check (fail when blocking > 0),
384
- * downgrade PASS→PARTIAL when blocking > 0 (PARTIAL/FAIL unchanged), and merge blocking
385
- * anchors into the gate findings (unique, sorted, cap 20). Idempotent.
386
- */
387
- export function foldVerdict(
388
- verdict: { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> },
389
- scan: ResidualArtifact,
390
- existingFindings: string,
391
- maxFindings = 20,
392
- ): FoldResult {
393
- const blocking = scan.items.filter((i) => i.class === 'blocking');
394
- const deferrable = scan.items.filter((i) => i.class === 'deferrable');
395
- const evidence =
396
- `blocking=${blocking.length} deferrable=${deferrable.length} advisory=${scan.counts.advisory} housekeeping=${scan.counts.housekeeping}` +
397
- (blocking.length > 0 ? `; blocking ids: ${blocking.map((i) => i.id).join(', ')}` : '') +
398
- (deferrable.length > 0 ? `; deferrable ids: ${deferrable.map((i) => i.id).join(', ')}` : '');
399
- const checks = verdict.checks.filter((c) => c.name !== 'residual-sweep');
400
- checks.push({ name: 'residual-sweep', status: blocking.length > 0 ? 'fail' : 'pass', evidence });
401
- const merged = new Set([
402
- ...existingFindings.split(/\s+/).filter((a) => a.length > 0),
403
- ...blockingAnchors(scan.items),
404
- ]);
405
- const findings = [...merged]
406
- .sort()
407
- .slice(0, maxFindings)
408
- .map((a) => `${a} `)
409
- .join('');
410
- let verdictStatus: FoldResult['verdict'] = verdict.verdict === 'PASS' ? 'PASS' : 'FAIL';
411
- if (verdict.verdict === 'PARTIAL') verdictStatus = 'PARTIAL';
412
- else if (verdict.verdict === 'FAIL') verdictStatus = 'FAIL';
413
- else if (blocking.length > 0) verdictStatus = 'PARTIAL';
414
- return { verdict: verdictStatus, checks, findings };
415
- }
416
-
417
- /** Render the recovery report for blocking items. */
418
- export function renderReport(wbs: string, items: ResidualItem[], attemptCount: number): string {
419
- const lines = [
420
- `# Residual report — ${wbs}`,
421
- '',
422
- `Attempt: ${attemptCount}`,
423
- '',
424
- '| Category | Class | Location | Text |',
425
- '| --- | --- | --- | --- |',
426
- ];
427
- for (const item of items) {
428
- lines.push(`| ${item.category} | ${item.class} | ${item.location} | ${item.text.replace(/\|/g, '\\|')} |`);
429
- }
430
- return `${lines.join('\n')}\n`;
431
- }
432
-
433
- // ── impure modes ─────────────────────────────────────────────────────────────
434
-
435
- function parseArgs(
436
- argv: string[],
437
- ): { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string } | null {
91
+ function parseArgs(argv: string[]): ParsedArgs | null {
438
92
  let mode = '';
439
93
  let wbs = '';
440
94
  let spurBin: string | undefined;
@@ -450,192 +104,146 @@ function parseArgs(
450
104
  else if (mode === '') mode = a;
451
105
  else if (wbs === '') wbs = a;
452
106
  }
453
- if (mode === '' || wbs === '') return null;
454
- return { mode, wbs, spurBin, root, tmpDir };
107
+ return mode === '' || wbs === '' ? null : { mode, wbs, spurBin, root, tmpDir };
455
108
  }
456
-
457
- function spur(
458
- env: ScanEnv,
459
- spurBinFlag: string | undefined,
460
- args: string[],
461
- cwd: string,
462
- ): { status: number; stdout: string } {
463
- return run(
464
- spurCommand(spurBinFlag ?? env.spurBin).cmd,
465
- [...spurCommand(spurBinFlag ?? env.spurBin).prefix, ...args],
466
- cwd,
467
- );
468
- }
469
-
470
- function loadTask(
471
- env: ScanEnv,
472
- spurBinFlag: string | undefined,
473
- wbs: string,
474
- root: string,
475
- ): { content: string; featureId: string } {
109
+ function loadTask(env: ScanEnv, spurBinFlag: string | undefined, wbs: string, root: string): ScanTask {
476
110
  const res = spur(env, spurBinFlag, ['task', 'show', wbs, '--json'], root);
477
111
  if (res.status !== 0) throw new Error(`task show ${wbs} failed`);
478
- const parsed = JSON.parse(res.stdout) as Record<string, unknown>;
479
- const content = typeof parsed.content === 'string' ? parsed.content : '';
480
- const fm = parsed.frontmatter;
112
+ type TaskJson = { content?: unknown; feature_id?: unknown; frontmatter?: { feature_id?: unknown } | null };
113
+ const parsed = JSON.parse(res.stdout) as TaskJson;
481
114
  const featureId =
482
- typeof parsed.feature_id === 'string'
483
- ? parsed.feature_id
484
- : fm !== null && typeof fm === 'object' && typeof (fm as Record<string, unknown>).feature_id === 'string'
485
- ? ((fm as Record<string, unknown>).feature_id as string)
486
- : '';
487
- return { content, featureId };
115
+ [parsed.feature_id, parsed.frontmatter?.feature_id].find((v): v is string => typeof v === 'string') ?? '';
116
+ return { content: typeof parsed.content === 'string' ? parsed.content : '', featureId };
488
117
  }
489
-
490
- function loadVerdict(
491
- runDir: string,
492
- wbs: string,
493
- ): { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> } {
494
- return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), 'utf8'));
118
+ function loadVerdict(runDir: string, wbs: string): VerdictFile {
119
+ return JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
495
120
  }
496
-
497
- function scanMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
498
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
121
+ function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
499
122
  const runDir = join(opts.root, '.spur', 'run');
500
- mkdirSync(runDir, { recursive: true });
501
- const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
502
- writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
123
+ fs.mkdirSync(runDir, { recursive: true });
124
+ const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
125
+ const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
126
+ fs.writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
503
127
  io.out(
504
128
  `residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}\n`,
505
129
  );
506
130
  return 0;
507
131
  }
508
-
509
- function foldMode(opts: NonNullable<ReturnType<typeof parseArgs>>, _env: ScanEnv, io: ScanIo): number {
132
+ function foldMode(opts: ParsedArgs, _env: ScanEnv, io: ScanIo): number {
510
133
  const runDir = join(opts.root, '.spur', 'run');
511
- const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8')) as ResidualArtifact;
512
- const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
134
+ const scan = JSON.parse(
135
+ fs.readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8'),
136
+ ) as core.ResidualArtifact;
137
+ const target = core.recordedVerdictPath(runDir, opts.wbs, fs);
513
138
  const verdict = loadVerdict(runDir, opts.wbs);
514
139
  const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
515
- const existing = existsSync(findingsPath) ? readFileSync(findingsPath, 'utf8') : '';
516
- const folded = foldVerdict(verdict, scan, existing);
517
- writeFileSync(
518
- verdictPath,
519
- `${JSON.stringify({ ...verdict, verdict: folded.verdict, checks: folded.checks }, null, 2)}\n`,
140
+ const fold = core.foldVerdict(
141
+ verdict,
142
+ scan,
143
+ fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, 'utf8') : '',
520
144
  );
521
- writeFileSync(findingsPath, folded.findings);
145
+ const bytes = `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}\n`;
146
+ const temporary = `${target}.${process.pid}.tmp`;
147
+ try {
148
+ fs.writeFileSync(temporary, bytes, { flag: 'wx' });
149
+ fs.renameSync(temporary, target);
150
+ } finally {
151
+ fs.rmSync(temporary, { force: true });
152
+ }
153
+ // The pipeline still consumes the attempt copy after folding; the recorded copy is authoritative.
154
+ const scratch = join(runDir, `${opts.wbs}-verdict.json`);
155
+ if (target !== scratch) fs.writeFileSync(scratch, bytes);
156
+ fs.writeFileSync(findingsPath, fold.findings);
522
157
  io.out(
523
- `residual-fold: ${opts.wbs} verdict=${folded.verdict} residual-sweep=${folded.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
158
+ `residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
524
159
  );
525
160
  return 0;
526
161
  }
527
-
528
- function settleMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
529
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
162
+ function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
163
+ const wbs = opts.wbs;
164
+ const task = loadTask(env, opts.spurBin, wbs, opts.root);
530
165
  const runDir = join(opts.root, '.spur', 'run');
531
- const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
532
- const residualsPath = join(runDir, `${opts.wbs}-residuals.json`);
166
+ const scan = scanResiduals(opts.root, wbs, opts.tmpDir, task.content, env);
167
+ const residualsPath = join(runDir, `${wbs}-residuals.json`);
168
+ let prior: Partial<core.ResidualArtifact & { followUp?: string }> = {};
169
+ if (fs.existsSync(residualsPath)) prior = JSON.parse(fs.readFileSync(residualsPath, 'utf8'));
533
170
  const deferred = scan.items.filter((i) => i.class === 'deferrable');
534
- const prior = existsSync(residualsPath)
535
- ? (JSON.parse(readFileSync(residualsPath, 'utf8')) as Partial<ResidualArtifact & { followUp?: string }>)
536
- : {};
537
171
  if (deferred.length > 0 && prior.followUp === undefined) {
538
172
  if (task.featureId === '') {
539
173
  io.err(
540
- `residual-settle: ${opts.wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${opts.wbs}\n`,
174
+ `residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}\n`,
541
175
  );
542
176
  return 0;
543
177
  }
544
- const created = spur(
545
- env,
546
- opts.spurBin,
547
- ['task', 'create', `Residuals from ${opts.wbs}`, '--feature', task.featureId, '--skip-ready', '--json'],
548
- opts.root,
549
- );
550
- if (created.status !== 0) {
551
- io.err(`residual-settle: task create failed; re-run: residual-scan settle ${opts.wbs}\n`);
552
- return 0;
553
- }
178
+ const args = ['task', 'create', `Residuals from ${wbs}`, '--feature', task.featureId, '--skip-ready', '--json'];
179
+ const created = spur(env, opts.spurBin, args, opts.root);
554
180
  let wbsNew = '';
555
181
  try {
556
- const parsed = JSON.parse(created.stdout) as Record<string, unknown>;
557
- const pick = (o: Record<string, unknown>): string => (typeof o.wbs === 'string' ? o.wbs : '');
558
- wbsNew =
559
- pick(parsed) ||
560
- (parsed.data !== null && typeof parsed.data === 'object'
561
- ? pick(parsed.data as Record<string, unknown>)
562
- : '');
182
+ const parsed = JSON.parse(created.stdout) as { wbs?: unknown; data?: { wbs?: unknown } | null };
183
+ const data = typeof parsed.data === 'object' && parsed.data !== null ? parsed.data : {};
184
+ wbsNew = [parsed.wbs, data.wbs].find((v): v is string => typeof v === 'string') ?? '';
563
185
  } catch {
564
186
  wbsNew = '';
565
187
  }
566
- if (wbsNew === '') {
567
- io.err(`residual-settle: could not read created task wbs; re-run: residual-scan settle ${opts.wbs}\n`);
188
+ if (created.status !== 0 || wbsNew === '') {
189
+ io.err(`residual-settle: task create failed or unreadable wbs; re-run: residual-scan settle ${wbs}\n`);
568
190
  return 0;
569
191
  }
570
- const bg = [
571
- `Source task: ${opts.wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`,
572
- '',
573
- ...deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`),
574
- ].join('\n');
575
- const bgFile = join(runDir, `${opts.wbs}-residual-background.md`);
576
- writeFileSync(bgFile, `${bg}\n`);
577
- const upd = spur(
578
- env,
579
- opts.spurBin,
580
- ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile],
581
- opts.root,
582
- );
583
- if (upd.status !== 0) {
584
- io.err(`residual-settle: background write failed; re-run: residual-scan settle ${opts.wbs}\n`);
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.`;
194
+ const bgFile = join(runDir, `${wbs}-residual-background.md`);
195
+ fs.writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
196
+ const updArgs = ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile];
197
+ if (spur(env, opts.spurBin, updArgs, opts.root).status !== 0) {
198
+ io.err(`residual-settle: background write failed; re-run: residual-scan settle ${wbs}\n`);
585
199
  return 0;
586
200
  }
587
201
  prior.followUp = wbsNew;
588
202
  io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)\n`);
589
203
  }
590
- // Cleanup: only regular files `<tmpDir>/<wbs>-*`. Never directories, never other prefixes.
591
- for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
204
+ for (const path of listStagingResidue(opts.tmpDir, wbs)) {
592
205
  try {
593
- rmSync(path, { force: true });
206
+ fs.rmSync(path, { force: true });
594
207
  } catch {
595
- io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${opts.wbs}\n`);
208
+ io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}\n`);
596
209
  return 0;
597
210
  }
598
211
  }
599
- writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
212
+ fs.writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
600
213
  return 0;
601
214
  }
602
-
603
- function reportMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
215
+ function reportMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
604
216
  const runDir = join(opts.root, '.spur', 'run');
605
- const verdict = loadVerdict(runDir, opts.wbs);
606
- const sweep = verdict.checks.find((c) => c.name === 'residual-sweep');
217
+ const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === 'residual-sweep');
607
218
  if (sweep === undefined || sweep.status !== 'fail') return 0;
608
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
609
- const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
219
+ const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
220
+ const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
610
221
  const blocking = scan.items.filter((i) => i.class === 'blocking');
611
222
  const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
612
- const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, 'utf8').trim() || '0', 10) : 0;
613
- const reportPath = join(runDir, `${opts.wbs}-residual-report.md`);
614
- writeFileSync(reportPath, renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
223
+ const attempts = fs.existsSync(attemptFile)
224
+ ? Number.parseInt(fs.readFileSync(attemptFile, 'utf8').trim() || '0', 10)
225
+ : 0;
226
+ fs.writeFileSync(
227
+ join(runDir, `${opts.wbs}-residual-report.md`),
228
+ core.renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts),
229
+ );
615
230
  io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}\n`);
616
231
  return 0;
617
232
  }
618
-
619
233
  export function main(argv: string[], env: ScanEnv = getEnvVars(), options: ScanOptions = {}): number {
620
234
  const io: ScanIo = options.io ?? {
621
235
  out: (line) => process.stdout.write(line),
622
236
  err: (line) => process.stderr.write(line),
623
237
  };
238
+ const cwd = options.cwd ?? process.cwd();
624
239
  const opts = parseArgs(argv);
625
- if (opts === null) {
240
+ const modes: Record<string, ModeFn> = { scan: scanMode, fold: foldMode, settle: settleMode, report: reportMode };
241
+ const modeFn = opts === null ? undefined : modes[opts.mode];
242
+ if (opts === null || modeFn === undefined) {
626
243
  io.err(`${RESIDUAL_SCAN_USAGE}\n`);
627
244
  return 2;
628
245
  }
629
- const cwd = options.cwd ?? process.cwd();
630
- const resolved = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
631
- if (resolved.mode === 'scan') return scanMode(resolved, env, io);
632
- if (resolved.mode === 'fold') return foldMode(resolved, env, io);
633
- if (resolved.mode === 'settle') return settleMode(resolved, env, io);
634
- if (resolved.mode === 'report') return reportMode(resolved, env, io);
635
- io.err(`${RESIDUAL_SCAN_USAGE}\n`);
636
- return 2;
637
- }
638
-
639
- if (import.meta.main) {
640
- process.exit(main(process.argv.slice(2)));
246
+ const resolved: ParsedArgs = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
247
+ return modeFn(resolved, env, io);
641
248
  }
249
+ if (import.meta.main) process.exit(main(process.argv.slice(2)));