@gobing-ai/spur 0.3.95 → 0.3.96

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 (155) 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 +5 -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/workflows/feature-verification.yaml +1 -1
  10. package/config/workflows/history-anatomy.yaml +23 -11
  11. package/config/workflows/idea-pipeline.yaml +80 -85
  12. package/config/workflows/pr-review.yaml +18 -9
  13. package/config/workflows/task-pipeline.yaml +42 -95
  14. package/config/workflows/wrapup-pipeline.yaml +89 -37
  15. package/package.json +1 -1
  16. package/plugins/sp/README.md +8 -10
  17. package/plugins/sp/agents/super-reviewer.md +43 -18
  18. package/plugins/sp/commands/dev-fixgha.md +83 -0
  19. package/plugins/sp/commands/dev-gitmsg.md +8 -6
  20. package/plugins/sp/commands/dev-gtd.md +2 -2
  21. package/plugins/sp/commands/dev-idea.md +22 -12
  22. package/plugins/sp/commands/dev-plan.md +8 -9
  23. package/plugins/sp/commands/dev-review.md +22 -13
  24. package/plugins/sp/commands/dev-verifyall.md +1 -1
  25. package/plugins/sp/commands/spur-init.md +2 -2
  26. package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
  27. package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
  28. package/plugins/sp/lib/idea-handoff.generated.mjs +5 -4
  29. package/plugins/sp/lib/inline-run.generated.d.mts +11 -0
  30. package/plugins/sp/lib/inline-run.generated.mjs +25 -8
  31. package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
  32. package/plugins/sp/lib/quality-gate.generated.mjs +438 -0
  33. package/plugins/sp/lib/residual-scan.generated.d.mts +62 -0
  34. package/plugins/sp/lib/residual-scan.generated.mjs +210 -0
  35. package/plugins/sp/lib/spur-bin.ts +36 -0
  36. package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
  37. package/plugins/sp/lib/step-profile.generated.mjs +174 -0
  38. package/plugins/sp/plugin.json +1 -1
  39. package/plugins/sp/references/roles.md +1 -1
  40. package/plugins/sp/scripts/history-anatomy-cache.mjs +20 -19
  41. package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
  42. package/plugins/sp/scripts/inline-run-setup.mjs +85 -320
  43. package/plugins/sp/scripts/inline-run-setup.ts +104 -667
  44. package/plugins/sp/scripts/quality-gate.mjs +34 -19
  45. package/plugins/sp/scripts/quality-gate.ts +12 -658
  46. package/plugins/sp/scripts/residual-scan.mjs +135 -156
  47. package/plugins/sp/scripts/residual-scan.ts +102 -499
  48. package/plugins/sp/scripts/script-root.mjs +5 -1
  49. package/plugins/sp/scripts/script-root.ts +5 -1
  50. package/plugins/sp/scripts/workflow-step-profile.mjs +25 -17
  51. package/plugins/sp/scripts/workflow-step-profile.ts +21 -315
  52. package/plugins/sp/scripts/wrapup-drift-probe.mjs +8 -2
  53. package/plugins/sp/scripts/wrapup-drift-probe.ts +3 -2
  54. package/plugins/sp/scripts/wrapup-steps.mjs +10 -32
  55. package/plugins/sp/scripts/wrapup-steps.ts +8 -36
  56. package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
  57. package/plugins/sp/skills/code-verification/SKILL.md +34 -9
  58. package/plugins/sp/skills/code-verification/references/verdict-schema.md +3 -3
  59. package/plugins/sp/skills/functional-review/SKILL.md +7 -4
  60. package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
  61. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
  62. package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
  63. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  64. package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
  65. package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
  66. package/plugins/sp/skills/spur-cli/references/features.md +17 -6
  67. package/plugins/sp/skills/spur-cli/references/init.md +17 -16
  68. package/plugins/sp/skills/spur-cli/references/self.md +3 -2
  69. package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
  70. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +21 -6
  71. package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
  72. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
  73. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +11 -9
  74. package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
  75. package/plugins/sp/skills/spur-dev/references/dev-operations.md +86 -70
  76. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -3
  77. package/plugins/sp/skills/spur-dev/references/execution-batch.md +77 -34
  78. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +5 -9
  79. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
  80. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +31 -21
  81. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
  82. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
  83. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -3
  84. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
  85. package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
  86. package/spur.js +1495 -369
  87. package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.BIjMatT1.js} +1 -1
  88. package/web/_astro/{BoardApp.BxJuwD7I.js → BoardApp.GvjIe9Z6.js} +42 -42
  89. package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.BZM3EAFx.js} +1 -1
  90. package/web/_astro/{arc.CyjRvNMY.js → arc.HkRiZnoI.js} +1 -1
  91. package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.BDdK-tgZ.js} +1 -1
  92. package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.CZ9kOBxx.js} +1 -1
  93. package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.DXK9qeRD.js} +1 -1
  94. package/web/_astro/channel.DlhX2MEt.js +1 -0
  95. package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.BTD2WeX5.js} +1 -1
  96. package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.Ci6Qbcvk.js} +1 -1
  97. package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.yl3zsj7p.js} +1 -1
  98. package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ClXZsfyR.js} +1 -1
  99. package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.BmKWZBcP.js} +1 -1
  100. package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.By30fcb7.js} +1 -1
  101. package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.24NmrD-k.js} +1 -1
  102. package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.BN4sKdcS.js} +1 -1
  103. package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.CJpoMPb5.js} +1 -1
  104. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.CJpoMPb5.js} +1 -1
  105. package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.BraxQ2Nt.js} +1 -1
  106. package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.gzoU73oL.js} +1 -1
  107. package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.wyDfWySU.js} +1 -1
  108. package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.h2_EbMxV.js} +1 -1
  109. package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.Db6dcs89.js} +1 -1
  110. package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.NVwzxu_U.js} +1 -1
  111. package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.B-eNgDQk.js} +1 -1
  112. package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.Bsa8ZRbJ.js} +1 -1
  113. package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.BospRE5Q.js} +1 -1
  114. package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.DMzvHwAA.js} +1 -1
  115. package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.Dmdxgd-M.js} +1 -1
  116. package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.CaqdO0rL.js} +1 -1
  117. package/web/_astro/index.EoZlzLK-.css +1 -0
  118. package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DDVD9Wn7.js} +1 -1
  119. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.EKyCUFsL.js} +1 -1
  120. package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.By2-RkYB.js} +1 -1
  121. package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.ar-WmlyR.js} +1 -1
  122. package/web/_astro/{linear.zRsuuDTE.js → linear.BnzMBgo_.js} +1 -1
  123. package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DSqeFu2Y.js} +4 -4
  124. package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.1XcF8Wh2.js} +1 -1
  125. package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DnItHOdK.js} +1 -1
  126. package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.BQQyrnBt.js} +1 -1
  127. package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.Dmzxun82.js} +1 -1
  128. package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.BBIeRKYB.js} +1 -1
  129. package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.CUNVdBBf.js} +1 -1
  130. package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.DlFf2sJm.js} +1 -1
  131. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DXR7I1H5.js} +1 -1
  132. package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.BcO-GyX2.js} +1 -1
  133. package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.ChCECmmW.js} +1 -1
  134. package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRaT12mD.js} +1 -1
  135. package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.B3lsHVpj.js} +1 -1
  136. package/web/index.html +2 -2
  137. package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
  138. package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
  139. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -301
  140. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -481
  141. package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
  142. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
  143. package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
  144. package/plugins/sp/scripts/record-feature-sync.ts +0 -84
  145. package/plugins/sp/scripts/script-contract-check.ts +0 -506
  146. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  147. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
  148. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -189
  149. package/plugins/sp/scripts/task-size-precheck.ts +0 -212
  150. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  151. package/plugins/sp/scripts/validate-commands.ts +0 -689
  152. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -890
  153. package/plugins/sp/scripts/verify-answer-lint.ts +0 -549
  154. package/web/_astro/channel.CI6N_tCg.js +0 -1
  155. package/web/_astro/index.CENnIEqT.css +0 -1
@@ -3,7 +3,6 @@
3
3
 
4
4
  // plugins/sp/scripts/residual-scan.ts
5
5
  import { spawnSync } from "child_process";
6
- import { createHash } from "crypto";
7
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
8
7
  import { tmpdir } from "os";
9
8
  import { join } from "path";
@@ -13,11 +12,11 @@ function getEnvVars() {
13
12
  return process.env;
14
13
  }
15
14
 
16
- // plugins/sp/scripts/residual-scan.ts
17
- var RESIDUAL_SCAN_USAGE = "usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]";
15
+ // plugins/sp/lib/residual-scan.generated.mjs
16
+ import { createHash } from "node:crypto";
18
17
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
19
18
  var PRIORITY_PATTERN = /^P[1-4]/;
20
- var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
19
+ var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
21
20
  var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
22
21
  var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
23
22
  var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -25,16 +24,6 @@ var ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
25
24
  var RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
26
25
  var EXCLUDED_PATHS = ["docs/tasks", "docs/features/", ".spur/"];
27
26
  var ALLOW_PRAGMA = "residual-scan:allow";
28
- function spurCommand(spurBin) {
29
- const parts = (spurBin ?? "spur").trim().split(/\s+/).filter((p) => p.length > 0);
30
- return { cmd: parts[0] ?? "spur", prefix: parts.slice(1) };
31
- }
32
- function run(cmd, args, cwd) {
33
- const result = spawnSync(cmd, args, { cwd, encoding: "utf8" });
34
- if (result.error !== undefined)
35
- return { status: result.status ?? 1, stdout: "" };
36
- return { status: result.status ?? 1, stdout: result.stdout ?? "" };
37
- }
38
27
  function makeItemId(category, location, text) {
39
28
  const normalized = text.trim().replace(/\s+/g, " ");
40
29
  const hex = createHash("sha256").update(`${location}${normalized}`).digest("hex");
@@ -45,7 +34,7 @@ function normalizeAnchor(location) {
45
34
  }
46
35
  function locationOf(locationCell, finding) {
47
36
  const cell = locationCell.trim().replace(/`/g, "");
48
- if (cell.length > 0 && cell !== "\u2014")
37
+ if (cell.length > 0 && cell !== "—")
49
38
  return normalizeAnchor(cell);
50
39
  const backtick = finding.match(/`([^`]+)`/)?.[1];
51
40
  return backtick === undefined ? "" : normalizeAnchor(backtick);
@@ -96,42 +85,6 @@ function parseReviewFindings(taskContent) {
96
85
  function splitRow(line) {
97
86
  return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
98
87
  }
99
- function collectAddedLines(root, base) {
100
- const out = [];
101
- const diff = run("git", ["diff", "--unified=0", base], root);
102
- let file = "";
103
- let newLine = 0;
104
- for (const line of diff.stdout.split(`
105
- `)) {
106
- if (line.startsWith("+++ b/"))
107
- file = line.slice(6);
108
- else if (line.startsWith("@@")) {
109
- const m = line.match(/\+[0-9]+/);
110
- newLine = m === null ? newLine : Number.parseInt(m[0].slice(1), 10);
111
- } else if (line.startsWith("+") && !line.startsWith("+++")) {
112
- out.push({ file, line: newLine, text: line.slice(1) });
113
- newLine++;
114
- }
115
- }
116
- const untracked = run("git", ["ls-files", "--others", "--exclude-standard"], root);
117
- for (const f of untracked.stdout.split(`
118
- `)) {
119
- if (f.length === 0)
120
- continue;
121
- try {
122
- if (!statSync(join(root, f)).isFile())
123
- continue;
124
- } catch {
125
- continue;
126
- }
127
- const content = readFileSync(join(root, f), "utf8").split(`
128
- `);
129
- content.forEach((text, idx) => {
130
- out.push({ file: f, line: idx + 1, text });
131
- });
132
- }
133
- return out;
134
- }
135
88
  function parseDiffMarkers(addedLines) {
136
89
  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() }));
137
90
  }
@@ -140,34 +93,6 @@ function findUncheckedBoxes(taskContent) {
140
93
  return taskContent.split(`
141
94
  `).map((text, idx) => ({ text: text.trim(), line: idx + 1 })).filter((l) => l.text.startsWith("- [ ]")).map((l) => ({ location: `${path}:${l.line}`, text: l.text }));
142
95
  }
143
- function listStagingResidue(tmpDir, wbs) {
144
- let names;
145
- try {
146
- names = readdirSync(tmpDir);
147
- } catch {
148
- return [];
149
- }
150
- return names.filter((n) => n.startsWith(`${wbs}-`)).filter((n) => {
151
- try {
152
- return statSync(join(tmpDir, n)).isFile();
153
- } catch {
154
- return false;
155
- }
156
- }).map((n) => join(tmpDir, n));
157
- }
158
- function readDeferrals(runDir, wbs) {
159
- const path = join(runDir, `${wbs}-residual-deferrals.json`);
160
- if (!existsSync(path))
161
- return [];
162
- try {
163
- const parsed = JSON.parse(readFileSync(path, "utf8"));
164
- if (!Array.isArray(parsed))
165
- return [];
166
- return parsed.filter((e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.reason === "string" && e.reason.trim().length > 0);
167
- } catch {
168
- return [];
169
- }
170
- }
171
96
  function classify(items, deferrals) {
172
97
  const deferred = new Map(deferrals.map((d) => [d.id, d.reason]));
173
98
  return items.map((item) => {
@@ -195,10 +120,8 @@ function classify(items, deferrals) {
195
120
  };
196
121
  });
197
122
  }
198
- function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
199
- const runDir = join(root, ".spur", "run");
200
- const basePath = join(runDir, `${wbs}-base.sha`);
201
- const base = existsSync(basePath) ? readFileSync(basePath, "utf8").trim() : null;
123
+ function scanResiduals(inputs) {
124
+ const { wbs, base, taskContent, addedLines, stagingResidue, deferrals } = inputs;
202
125
  const reviewRows = parseReviewFindings(taskContent);
203
126
  const tableDeferrals = reviewRows.flatMap((r) => r.deferral === undefined ? [] : [{ id: makeItemId("review-finding", r.location, r.text), reason: r.deferral }]);
204
127
  const review = reviewRows.map((r) => ({
@@ -207,7 +130,7 @@ function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
207
130
  location: r.location,
208
131
  text: r.text
209
132
  }));
210
- const markers = base === null ? [] : parseDiffMarkers(collectAddedLines(root, base)).map((m) => ({
133
+ const markers = base === null ? [] : parseDiffMarkers(addedLines).map((m) => ({
211
134
  category: "diff-marker",
212
135
  location: m.location,
213
136
  text: m.text
@@ -217,12 +140,12 @@ function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
217
140
  location: b.location,
218
141
  text: b.text
219
142
  }));
220
- const residue = listStagingResidue(tmpDir, wbs).map((p) => ({
143
+ const residue = stagingResidue.map((p) => ({
221
144
  category: "staging-residue",
222
145
  location: p,
223
146
  text: p
224
147
  }));
225
- const items = classify([...review, ...markers, ...boxes, ...residue], [...tableDeferrals, ...readDeferrals(runDir, wbs)]);
148
+ const items = classify([...review, ...markers, ...boxes, ...residue], [...tableDeferrals, ...deferrals]);
226
149
  const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
227
150
  for (const item of items)
228
151
  counts[item.class]++;
@@ -271,7 +194,7 @@ function foldVerdict(verdict, scan, existingFindings, maxFindings = 20) {
271
194
  }
272
195
  function renderReport(wbs, items, attemptCount) {
273
196
  const lines = [
274
- `# Residual report \u2014 ${wbs}`,
197
+ `# Residual report — ${wbs}`,
275
198
  "",
276
199
  `Attempt: ${attemptCount}`,
277
200
  "",
@@ -285,6 +208,87 @@ function renderReport(wbs, items, attemptCount) {
285
208
  `)}
286
209
  `;
287
210
  }
211
+
212
+ // plugins/sp/lib/spur-bin.ts
213
+ function spurCommand(spurBin) {
214
+ const parts = (spurBin ?? "spur").trim().split(/\s+/).filter((p) => p.length > 0);
215
+ return { cmd: parts[0] ?? "spur", prefix: parts.slice(1) };
216
+ }
217
+
218
+ // plugins/sp/scripts/residual-scan.ts
219
+ var RESIDUAL_SCAN_USAGE = "usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]";
220
+ function run(cmd, args, cwd) {
221
+ const result = spawnSync(cmd, args, { cwd, encoding: "utf8" });
222
+ if (result.error !== undefined)
223
+ return { status: result.status ?? 1, stdout: "" };
224
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
225
+ }
226
+ function spur(env, spurBinFlag, args, cwd) {
227
+ const { cmd, prefix } = spurCommand(spurBinFlag ?? env.spurBin);
228
+ return run(cmd, [...prefix, ...args], cwd);
229
+ }
230
+ function isRegularFile(path) {
231
+ try {
232
+ return statSync(path).isFile();
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+ function collectAddedLines(root, base) {
238
+ const out = [];
239
+ const diff = run("git", ["diff", "--unified=0", base], root);
240
+ let file = "";
241
+ let newLine = 0;
242
+ for (const line of diff.stdout.split(`
243
+ `)) {
244
+ if (line.startsWith("+++ b/"))
245
+ file = line.slice(6);
246
+ else if (line.startsWith("@@"))
247
+ newLine = Number.parseInt((line.match(/\+[0-9]+/) ?? ["+0"])[0].slice(1), 10);
248
+ else if (line.startsWith("+") && !line.startsWith("+++"))
249
+ out.push({ file, line: newLine++, text: line.slice(1) });
250
+ }
251
+ const untracked = run("git", ["ls-files", "--others", "--exclude-standard"], root);
252
+ for (const f of untracked.stdout.split(`
253
+ `)) {
254
+ if (f.length === 0 || !isRegularFile(join(root, f)))
255
+ continue;
256
+ for (const [idx, text] of readFileSync(join(root, f), "utf8").split(`
257
+ `).entries())
258
+ out.push({ file: f, line: idx + 1, text });
259
+ }
260
+ return out;
261
+ }
262
+ function listStagingResidue(tmpDir, wbs) {
263
+ let names;
264
+ try {
265
+ names = readdirSync(tmpDir);
266
+ } catch {
267
+ return [];
268
+ }
269
+ return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join(tmpDir, n))).map((n) => join(tmpDir, n));
270
+ }
271
+ function readDeferrals(runDir, wbs) {
272
+ const path = join(runDir, `${wbs}-residual-deferrals.json`);
273
+ if (!existsSync(path))
274
+ return [];
275
+ const isDeferral = (e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.reason === "string" && e.reason.trim().length > 0;
276
+ try {
277
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
278
+ return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
279
+ } catch {
280
+ return [];
281
+ }
282
+ }
283
+ function scanResiduals2(root, wbs, tmpDir, taskContent, _env) {
284
+ const runDir = join(root, ".spur", "run");
285
+ const basePath = join(runDir, `${wbs}-base.sha`);
286
+ const base = existsSync(basePath) ? readFileSync(basePath, "utf8").trim() : null;
287
+ const stagingResidue = listStagingResidue(tmpDir, wbs);
288
+ const deferrals = readDeferrals(runDir, wbs);
289
+ const addedLines = base === null ? [] : collectAddedLines(root, base);
290
+ return scanResiduals({ wbs, base, taskContent, addedLines, stagingResidue, deferrals });
291
+ }
288
292
  function parseArgs(argv) {
289
293
  let mode = "";
290
294
  let wbs = "";
@@ -308,31 +312,24 @@ function parseArgs(argv) {
308
312
  else if (wbs === "")
309
313
  wbs = a;
310
314
  }
311
- if (mode === "" || wbs === "")
312
- return null;
313
- return { mode, wbs, spurBin, root, tmpDir };
314
- }
315
- function spur(env, spurBinFlag, args, cwd) {
316
- return run(spurCommand(spurBinFlag ?? env.spurBin).cmd, [...spurCommand(spurBinFlag ?? env.spurBin).prefix, ...args], cwd);
315
+ return mode === "" || wbs === "" ? null : { mode, wbs, spurBin, root, tmpDir };
317
316
  }
318
317
  function loadTask(env, spurBinFlag, wbs, root) {
319
318
  const res = spur(env, spurBinFlag, ["task", "show", wbs, "--json"], root);
320
319
  if (res.status !== 0)
321
320
  throw new Error(`task show ${wbs} failed`);
322
321
  const parsed = JSON.parse(res.stdout);
323
- const content = typeof parsed.content === "string" ? parsed.content : "";
324
- const fm = parsed.frontmatter;
325
- const featureId = typeof parsed.feature_id === "string" ? parsed.feature_id : fm !== null && typeof fm === "object" && typeof fm.feature_id === "string" ? fm.feature_id : "";
326
- return { content, featureId };
322
+ const featureId = [parsed.feature_id, parsed.frontmatter?.feature_id].find((v) => typeof v === "string") ?? "";
323
+ return { content: typeof parsed.content === "string" ? parsed.content : "", featureId };
327
324
  }
328
325
  function loadVerdict(runDir, wbs) {
329
326
  return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), "utf8"));
330
327
  }
331
328
  function scanMode(opts, env, io) {
332
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
333
329
  const runDir = join(opts.root, ".spur", "run");
334
330
  mkdirSync(runDir, { recursive: true });
335
- const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
331
+ const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
332
+ const artifact = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
336
333
  writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}
337
334
  `);
338
335
  io.out(`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}
@@ -345,59 +342,54 @@ function foldMode(opts, _env, io) {
345
342
  const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
346
343
  const verdict = loadVerdict(runDir, opts.wbs);
347
344
  const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
348
- const existing = existsSync(findingsPath) ? readFileSync(findingsPath, "utf8") : "";
349
- const folded = foldVerdict(verdict, scan, existing);
350
- writeFileSync(verdictPath, `${JSON.stringify({ ...verdict, verdict: folded.verdict, checks: folded.checks }, null, 2)}
345
+ const fold = foldVerdict(verdict, scan, existsSync(findingsPath) ? readFileSync(findingsPath, "utf8") : "");
346
+ writeFileSync(verdictPath, `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}
351
347
  `);
352
- writeFileSync(findingsPath, folded.findings);
353
- io.out(`residual-fold: ${opts.wbs} verdict=${folded.verdict} residual-sweep=${folded.checks.find((c) => c.name === "residual-sweep")?.status}
348
+ writeFileSync(findingsPath, fold.findings);
349
+ io.out(`residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === "residual-sweep")?.status}
354
350
  `);
355
351
  return 0;
356
352
  }
357
353
  function settleMode(opts, env, io) {
358
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
354
+ const wbs = opts.wbs;
355
+ const task = loadTask(env, opts.spurBin, wbs, opts.root);
359
356
  const runDir = join(opts.root, ".spur", "run");
360
- const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
361
- const residualsPath = join(runDir, `${opts.wbs}-residuals.json`);
357
+ const scan = scanResiduals2(opts.root, wbs, opts.tmpDir, task.content, env);
358
+ const residualsPath = join(runDir, `${wbs}-residuals.json`);
359
+ let prior = {};
360
+ if (existsSync(residualsPath))
361
+ prior = JSON.parse(readFileSync(residualsPath, "utf8"));
362
362
  const deferred = scan.items.filter((i) => i.class === "deferrable");
363
- const prior = existsSync(residualsPath) ? JSON.parse(readFileSync(residualsPath, "utf8")) : {};
364
363
  if (deferred.length > 0 && prior.followUp === undefined) {
365
364
  if (task.featureId === "") {
366
- io.err(`residual-settle: ${opts.wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${opts.wbs}
367
- `);
368
- return 0;
369
- }
370
- const created = spur(env, opts.spurBin, ["task", "create", `Residuals from ${opts.wbs}`, "--feature", task.featureId, "--skip-ready", "--json"], opts.root);
371
- if (created.status !== 0) {
372
- io.err(`residual-settle: task create failed; re-run: residual-scan settle ${opts.wbs}
365
+ io.err(`residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}
373
366
  `);
374
367
  return 0;
375
368
  }
369
+ const args = ["task", "create", `Residuals from ${wbs}`, "--feature", task.featureId, "--skip-ready", "--json"];
370
+ const created = spur(env, opts.spurBin, args, opts.root);
376
371
  let wbsNew = "";
377
372
  try {
378
373
  const parsed = JSON.parse(created.stdout);
379
- const pick = (o) => typeof o.wbs === "string" ? o.wbs : "";
380
- wbsNew = pick(parsed) || (parsed.data !== null && typeof parsed.data === "object" ? pick(parsed.data) : "");
374
+ const data = typeof parsed.data === "object" && parsed.data !== null ? parsed.data : {};
375
+ wbsNew = [parsed.wbs, data.wbs].find((v) => typeof v === "string") ?? "";
381
376
  } catch {
382
377
  wbsNew = "";
383
378
  }
384
- if (wbsNew === "") {
385
- io.err(`residual-settle: could not read created task wbs; re-run: residual-scan settle ${opts.wbs}
379
+ if (created.status !== 0 || wbsNew === "") {
380
+ io.err(`residual-settle: task create failed or unreadable wbs; re-run: residual-scan settle ${wbs}
386
381
  `);
387
382
  return 0;
388
383
  }
389
- const bg = [
390
- `Source task: ${opts.wbs} (feature ${task.featureId}) \u2014 deferred residuals filed by residual-scan settle.`,
391
- "",
392
- ...deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`)
393
- ].join(`
394
- `);
395
- const bgFile = join(runDir, `${opts.wbs}-residual-background.md`);
396
- writeFileSync(bgFile, `${bg}
384
+ const rows = deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`);
385
+ const head = `Source task: ${wbs} (feature ${task.featureId}) \u2014 deferred residuals filed by residual-scan settle.`;
386
+ const bgFile = join(runDir, `${wbs}-residual-background.md`);
387
+ writeFileSync(bgFile, `${[head, "", ...rows].join(`
388
+ `)}
397
389
  `);
398
- const upd = spur(env, opts.spurBin, ["task", "update", wbsNew, "--section", "Background", "--from-file", bgFile], opts.root);
399
- if (upd.status !== 0) {
400
- io.err(`residual-settle: background write failed; re-run: residual-scan settle ${opts.wbs}
390
+ const updArgs = ["task", "update", wbsNew, "--section", "Background", "--from-file", bgFile];
391
+ if (spur(env, opts.spurBin, updArgs, opts.root).status !== 0) {
392
+ io.err(`residual-settle: background write failed; re-run: residual-scan settle ${wbs}
401
393
  `);
402
394
  return 0;
403
395
  }
@@ -405,11 +397,11 @@ function settleMode(opts, env, io) {
405
397
  io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)
406
398
  `);
407
399
  }
408
- for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
400
+ for (const path of listStagingResidue(opts.tmpDir, wbs)) {
409
401
  try {
410
402
  rmSync(path, { force: true });
411
403
  } catch {
412
- io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${opts.wbs}
404
+ io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}
413
405
  `);
414
406
  return 0;
415
407
  }
@@ -420,17 +412,15 @@ function settleMode(opts, env, io) {
420
412
  }
421
413
  function reportMode(opts, env, io) {
422
414
  const runDir = join(opts.root, ".spur", "run");
423
- const verdict = loadVerdict(runDir, opts.wbs);
424
- const sweep = verdict.checks.find((c) => c.name === "residual-sweep");
415
+ const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === "residual-sweep");
425
416
  if (sweep === undefined || sweep.status !== "fail")
426
417
  return 0;
427
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
428
- const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
418
+ const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
419
+ const scan = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
429
420
  const blocking = scan.items.filter((i) => i.class === "blocking");
430
421
  const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
431
422
  const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, "utf8").trim() || "0", 10) : 0;
432
- const reportPath = join(runDir, `${opts.wbs}-residual-report.md`);
433
- writeFileSync(reportPath, renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
423
+ writeFileSync(join(runDir, `${opts.wbs}-residual-report.md`), renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
434
424
  io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}
435
425
  `);
436
426
  return 0;
@@ -440,32 +430,21 @@ function main(argv, env = getEnvVars(), options = {}) {
440
430
  out: (line) => process.stdout.write(line),
441
431
  err: (line) => process.stderr.write(line)
442
432
  };
433
+ const cwd = options.cwd ?? process.cwd();
443
434
  const opts = parseArgs(argv);
444
- if (opts === null) {
435
+ const modes = { scan: scanMode, fold: foldMode, settle: settleMode, report: reportMode };
436
+ const modeFn = opts === null ? undefined : modes[opts.mode];
437
+ if (opts === null || modeFn === undefined) {
445
438
  io.err(`${RESIDUAL_SCAN_USAGE}
446
439
  `);
447
440
  return 2;
448
441
  }
449
- const cwd = options.cwd ?? process.cwd();
450
442
  const resolved = { ...opts, root: opts.root.startsWith("/") ? opts.root : join(cwd, opts.root) };
451
- if (resolved.mode === "scan")
452
- return scanMode(resolved, env, io);
453
- if (resolved.mode === "fold")
454
- return foldMode(resolved, env, io);
455
- if (resolved.mode === "settle")
456
- return settleMode(resolved, env, io);
457
- if (resolved.mode === "report")
458
- return reportMode(resolved, env, io);
459
- io.err(`${RESIDUAL_SCAN_USAGE}
460
- `);
461
- return 2;
462
- }
463
- {
464
- process.exit(main(process.argv.slice(2)));
443
+ return modeFn(resolved, env, io);
465
444
  }
445
+ process.exit(main(process.argv.slice(2)));
466
446
  export {
467
- spurCommand,
468
- scanResiduals,
447
+ scanResiduals2 as scanResiduals,
469
448
  renderReport,
470
449
  parseReviewFindings,
471
450
  parseDiffMarkers,