@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
@@ -3,21 +3,21 @@
3
3
 
4
4
  // plugins/sp/scripts/residual-scan.ts
5
5
  import { spawnSync } from "child_process";
6
- import { createHash } from "crypto";
7
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
6
+ import * as fs from "fs";
8
7
  import { tmpdir } from "os";
9
- import { join } from "path";
8
+ import { join as join2 } from "path";
10
9
 
11
10
  // plugins/sp/lib/env.ts
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 { join } from "node:path";
17
+ import { createHash } from "node:crypto";
18
18
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
19
19
  var PRIORITY_PATTERN = /^P[1-4]/;
20
- var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
20
+ var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
21
21
  var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
22
22
  var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
23
23
  var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -25,16 +25,6 @@ var ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
25
25
  var RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
26
26
  var EXCLUDED_PATHS = ["docs/tasks", "docs/features/", ".spur/"];
27
27
  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
28
  function makeItemId(category, location, text) {
39
29
  const normalized = text.trim().replace(/\s+/g, " ");
40
30
  const hex = createHash("sha256").update(`${location}${normalized}`).digest("hex");
@@ -45,7 +35,7 @@ function normalizeAnchor(location) {
45
35
  }
46
36
  function locationOf(locationCell, finding) {
47
37
  const cell = locationCell.trim().replace(/`/g, "");
48
- if (cell.length > 0 && cell !== "\u2014")
38
+ if (cell.length > 0 && cell !== "—")
49
39
  return normalizeAnchor(cell);
50
40
  const backtick = finding.match(/`([^`]+)`/)?.[1];
51
41
  return backtick === undefined ? "" : normalizeAnchor(backtick);
@@ -96,42 +86,6 @@ function parseReviewFindings(taskContent) {
96
86
  function splitRow(line) {
97
87
  return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
98
88
  }
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
89
  function parseDiffMarkers(addedLines) {
136
90
  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
91
  }
@@ -140,34 +94,6 @@ function findUncheckedBoxes(taskContent) {
140
94
  return taskContent.split(`
141
95
  `).map((text, idx) => ({ text: text.trim(), line: idx + 1 })).filter((l) => l.text.startsWith("- [ ]")).map((l) => ({ location: `${path}:${l.line}`, text: l.text }));
142
96
  }
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
97
  function classify(items, deferrals) {
172
98
  const deferred = new Map(deferrals.map((d) => [d.id, d.reason]));
173
99
  return items.map((item) => {
@@ -195,10 +121,8 @@ function classify(items, deferrals) {
195
121
  };
196
122
  });
197
123
  }
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;
124
+ function scanResiduals(inputs) {
125
+ const { wbs, base, taskContent, addedLines, stagingResidue, deferrals } = inputs;
202
126
  const reviewRows = parseReviewFindings(taskContent);
203
127
  const tableDeferrals = reviewRows.flatMap((r) => r.deferral === undefined ? [] : [{ id: makeItemId("review-finding", r.location, r.text), reason: r.deferral }]);
204
128
  const review = reviewRows.map((r) => ({
@@ -207,7 +131,7 @@ function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
207
131
  location: r.location,
208
132
  text: r.text
209
133
  }));
210
- const markers = base === null ? [] : parseDiffMarkers(collectAddedLines(root, base)).map((m) => ({
134
+ const markers = base === null ? [] : parseDiffMarkers(addedLines).map((m) => ({
211
135
  category: "diff-marker",
212
136
  location: m.location,
213
137
  text: m.text
@@ -217,12 +141,12 @@ function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
217
141
  location: b.location,
218
142
  text: b.text
219
143
  }));
220
- const residue = listStagingResidue(tmpDir, wbs).map((p) => ({
144
+ const residue = stagingResidue.map((p) => ({
221
145
  category: "staging-residue",
222
146
  location: p,
223
147
  text: p
224
148
  }));
225
- const items = classify([...review, ...markers, ...boxes, ...residue], [...tableDeferrals, ...readDeferrals(runDir, wbs)]);
149
+ const items = classify([...review, ...markers, ...boxes, ...residue], [...tableDeferrals, ...deferrals]);
226
150
  const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
227
151
  for (const item of items)
228
152
  counts[item.class]++;
@@ -271,7 +195,7 @@ function foldVerdict(verdict, scan, existingFindings, maxFindings = 20) {
271
195
  }
272
196
  function renderReport(wbs, items, attemptCount) {
273
197
  const lines = [
274
- `# Residual report \u2014 ${wbs}`,
198
+ `# Residual report — ${wbs}`,
275
199
  "",
276
200
  `Attempt: ${attemptCount}`,
277
201
  "",
@@ -285,6 +209,101 @@ function renderReport(wbs, items, attemptCount) {
285
209
  `)}
286
210
  `;
287
211
  }
212
+ function recordedVerdictPath(runDir, wbs, fs) {
213
+ const evidence = join(runDir, "..", "memory", "evidence");
214
+ const durable = join(evidence, `${wbs}-verdict.json`);
215
+ for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
216
+ try {
217
+ if (fs.lstatSync(path).isSymbolicLink())
218
+ throw new Error(`residual-scan: symlink evidence path: ${path}`);
219
+ } catch (error) {
220
+ if (error.code !== "ENOENT")
221
+ throw error;
222
+ }
223
+ }
224
+ return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
225
+ }
226
+
227
+ // plugins/sp/lib/spur-bin.ts
228
+ function spurCommand(spurBin) {
229
+ const parts = (spurBin ?? "spur").trim().split(/\s+/).filter((p) => p.length > 0);
230
+ return { cmd: parts[0] ?? "spur", prefix: parts.slice(1) };
231
+ }
232
+
233
+ // plugins/sp/scripts/residual-scan.ts
234
+ var RESIDUAL_SCAN_USAGE = "usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]";
235
+ function run(cmd, args, cwd) {
236
+ const result = spawnSync(cmd, args, { cwd, encoding: "utf8" });
237
+ if (result.error !== undefined)
238
+ return { status: result.status ?? 1, stdout: "" };
239
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
240
+ }
241
+ function spur(env, spurBinFlag, args, cwd) {
242
+ const { cmd, prefix } = spurCommand(spurBinFlag ?? env.spurBin);
243
+ return run(cmd, [...prefix, ...args], cwd);
244
+ }
245
+ function isRegularFile(path) {
246
+ try {
247
+ return fs.statSync(path).isFile();
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+ function collectAddedLines(root, base) {
253
+ const out = [];
254
+ const diff = run("git", ["diff", "--unified=0", base], root);
255
+ let file = "";
256
+ let newLine = 0;
257
+ for (const line of diff.stdout.split(`
258
+ `)) {
259
+ if (line.startsWith("+++ b/"))
260
+ file = line.slice(6);
261
+ else if (line.startsWith("@@"))
262
+ newLine = Number.parseInt((line.match(/\+[0-9]+/) ?? ["+0"])[0].slice(1), 10);
263
+ else if (line.startsWith("+") && !line.startsWith("+++"))
264
+ out.push({ file, line: newLine++, text: line.slice(1) });
265
+ }
266
+ const untracked = run("git", ["ls-files", "--others", "--exclude-standard"], root);
267
+ for (const f of untracked.stdout.split(`
268
+ `)) {
269
+ if (f.length === 0 || !isRegularFile(join2(root, f)))
270
+ continue;
271
+ for (const [idx, text] of fs.readFileSync(join2(root, f), "utf8").split(`
272
+ `).entries())
273
+ out.push({ file: f, line: idx + 1, text });
274
+ }
275
+ return out;
276
+ }
277
+ function listStagingResidue(tmpDir, wbs) {
278
+ let names;
279
+ try {
280
+ names = fs.readdirSync(tmpDir);
281
+ } catch {
282
+ return [];
283
+ }
284
+ return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join2(tmpDir, n))).map((n) => join2(tmpDir, n));
285
+ }
286
+ function readDeferrals(runDir, wbs) {
287
+ const path = join2(runDir, `${wbs}-residual-deferrals.json`);
288
+ if (!fs.existsSync(path))
289
+ return [];
290
+ const isDeferral = (e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.reason === "string" && e.reason.trim().length > 0;
291
+ try {
292
+ const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
293
+ return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
298
+ function scanResiduals2(root, wbs, tmpDir, taskContent, _env) {
299
+ const runDir = join2(root, ".spur", "run");
300
+ const basePath = join2(runDir, `${wbs}-base.sha`);
301
+ const base = fs.existsSync(basePath) ? fs.readFileSync(basePath, "utf8").trim() : null;
302
+ const stagingResidue = listStagingResidue(tmpDir, wbs);
303
+ const deferrals = readDeferrals(runDir, wbs);
304
+ const addedLines = base === null ? [] : collectAddedLines(root, base);
305
+ return scanResiduals({ wbs, base, taskContent, addedLines, stagingResidue, deferrals });
306
+ }
288
307
  function parseArgs(argv) {
289
308
  let mode = "";
290
309
  let wbs = "";
@@ -308,96 +327,94 @@ function parseArgs(argv) {
308
327
  else if (wbs === "")
309
328
  wbs = a;
310
329
  }
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);
330
+ return mode === "" || wbs === "" ? null : { mode, wbs, spurBin, root, tmpDir };
317
331
  }
318
332
  function loadTask(env, spurBinFlag, wbs, root) {
319
333
  const res = spur(env, spurBinFlag, ["task", "show", wbs, "--json"], root);
320
334
  if (res.status !== 0)
321
335
  throw new Error(`task show ${wbs} failed`);
322
336
  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 };
337
+ const featureId = [parsed.feature_id, parsed.frontmatter?.feature_id].find((v) => typeof v === "string") ?? "";
338
+ return { content: typeof parsed.content === "string" ? parsed.content : "", featureId };
327
339
  }
328
340
  function loadVerdict(runDir, wbs) {
329
- return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), "utf8"));
341
+ return JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
330
342
  }
331
343
  function scanMode(opts, env, io) {
332
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
333
- const runDir = join(opts.root, ".spur", "run");
334
- mkdirSync(runDir, { recursive: true });
335
- const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
336
- writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}
344
+ const runDir = join2(opts.root, ".spur", "run");
345
+ fs.mkdirSync(runDir, { recursive: true });
346
+ const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
347
+ const artifact = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
348
+ fs.writeFileSync(join2(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}
337
349
  `);
338
350
  io.out(`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}
339
351
  `);
340
352
  return 0;
341
353
  }
342
354
  function foldMode(opts, _env, io) {
343
- const runDir = join(opts.root, ".spur", "run");
344
- const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), "utf8"));
345
- const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
355
+ const runDir = join2(opts.root, ".spur", "run");
356
+ const scan = JSON.parse(fs.readFileSync(join2(runDir, `${opts.wbs}-residuals.json`), "utf8"));
357
+ const target = recordedVerdictPath(runDir, opts.wbs, fs);
346
358
  const verdict = loadVerdict(runDir, opts.wbs);
347
- 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)}
351
- `);
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}
359
+ const findingsPath = join2(runDir, `${opts.wbs}-test-gate.findings`);
360
+ const fold = foldVerdict(verdict, scan, fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, "utf8") : "");
361
+ const bytes = `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}
362
+ `;
363
+ const temporary = `${target}.${process.pid}.tmp`;
364
+ try {
365
+ fs.writeFileSync(temporary, bytes, { flag: "wx" });
366
+ fs.renameSync(temporary, target);
367
+ } finally {
368
+ fs.rmSync(temporary, { force: true });
369
+ }
370
+ const scratch = join2(runDir, `${opts.wbs}-verdict.json`);
371
+ if (target !== scratch)
372
+ fs.writeFileSync(scratch, bytes);
373
+ fs.writeFileSync(findingsPath, fold.findings);
374
+ io.out(`residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === "residual-sweep")?.status}
354
375
  `);
355
376
  return 0;
356
377
  }
357
378
  function settleMode(opts, env, io) {
358
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
359
- 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`);
379
+ const wbs = opts.wbs;
380
+ const task = loadTask(env, opts.spurBin, wbs, opts.root);
381
+ const runDir = join2(opts.root, ".spur", "run");
382
+ const scan = scanResiduals2(opts.root, wbs, opts.tmpDir, task.content, env);
383
+ const residualsPath = join2(runDir, `${wbs}-residuals.json`);
384
+ let prior = {};
385
+ if (fs.existsSync(residualsPath))
386
+ prior = JSON.parse(fs.readFileSync(residualsPath, "utf8"));
362
387
  const deferred = scan.items.filter((i) => i.class === "deferrable");
363
- const prior = existsSync(residualsPath) ? JSON.parse(readFileSync(residualsPath, "utf8")) : {};
364
388
  if (deferred.length > 0 && prior.followUp === undefined) {
365
389
  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}
390
+ io.err(`residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}
373
391
  `);
374
392
  return 0;
375
393
  }
394
+ const args = ["task", "create", `Residuals from ${wbs}`, "--feature", task.featureId, "--skip-ready", "--json"];
395
+ const created = spur(env, opts.spurBin, args, opts.root);
376
396
  let wbsNew = "";
377
397
  try {
378
398
  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) : "");
399
+ const data = typeof parsed.data === "object" && parsed.data !== null ? parsed.data : {};
400
+ wbsNew = [parsed.wbs, data.wbs].find((v) => typeof v === "string") ?? "";
381
401
  } catch {
382
402
  wbsNew = "";
383
403
  }
384
- if (wbsNew === "") {
385
- io.err(`residual-settle: could not read created task wbs; re-run: residual-scan settle ${opts.wbs}
404
+ if (created.status !== 0 || wbsNew === "") {
405
+ io.err(`residual-settle: task create failed or unreadable wbs; re-run: residual-scan settle ${wbs}
386
406
  `);
387
407
  return 0;
388
408
  }
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}
409
+ const rows = deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`);
410
+ const head = `Source task: ${wbs} (feature ${task.featureId}) \u2014 deferred residuals filed by residual-scan settle.`;
411
+ const bgFile = join2(runDir, `${wbs}-residual-background.md`);
412
+ fs.writeFileSync(bgFile, `${[head, "", ...rows].join(`
413
+ `)}
397
414
  `);
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}
415
+ const updArgs = ["task", "update", wbsNew, "--section", "Background", "--from-file", bgFile];
416
+ if (spur(env, opts.spurBin, updArgs, opts.root).status !== 0) {
417
+ io.err(`residual-settle: background write failed; re-run: residual-scan settle ${wbs}
401
418
  `);
402
419
  return 0;
403
420
  }
@@ -405,32 +422,30 @@ function settleMode(opts, env, io) {
405
422
  io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)
406
423
  `);
407
424
  }
408
- for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
425
+ for (const path of listStagingResidue(opts.tmpDir, wbs)) {
409
426
  try {
410
- rmSync(path, { force: true });
427
+ fs.rmSync(path, { force: true });
411
428
  } catch {
412
- io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${opts.wbs}
429
+ io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}
413
430
  `);
414
431
  return 0;
415
432
  }
416
433
  }
417
- writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}
434
+ fs.writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}
418
435
  `);
419
436
  return 0;
420
437
  }
421
438
  function reportMode(opts, env, io) {
422
- 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");
439
+ const runDir = join2(opts.root, ".spur", "run");
440
+ const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === "residual-sweep");
425
441
  if (sweep === undefined || sweep.status !== "fail")
426
442
  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);
443
+ const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
444
+ const scan = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
429
445
  const blocking = scan.items.filter((i) => i.class === "blocking");
430
- const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
431
- 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));
446
+ const attemptFile = join2(runDir, `${opts.wbs}-test-fix-attempt`);
447
+ const attempts = fs.existsSync(attemptFile) ? Number.parseInt(fs.readFileSync(attemptFile, "utf8").trim() || "0", 10) : 0;
448
+ fs.writeFileSync(join2(runDir, `${opts.wbs}-residual-report.md`), renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
434
449
  io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}
435
450
  `);
436
451
  return 0;
@@ -440,33 +455,23 @@ function main(argv, env = getEnvVars(), options = {}) {
440
455
  out: (line) => process.stdout.write(line),
441
456
  err: (line) => process.stderr.write(line)
442
457
  };
458
+ const cwd = options.cwd ?? process.cwd();
443
459
  const opts = parseArgs(argv);
444
- if (opts === null) {
460
+ const modes = { scan: scanMode, fold: foldMode, settle: settleMode, report: reportMode };
461
+ const modeFn = opts === null ? undefined : modes[opts.mode];
462
+ if (opts === null || modeFn === undefined) {
445
463
  io.err(`${RESIDUAL_SCAN_USAGE}
446
464
  `);
447
465
  return 2;
448
466
  }
449
- const cwd = options.cwd ?? process.cwd();
450
- 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)));
467
+ const resolved = { ...opts, root: opts.root.startsWith("/") ? opts.root : join2(cwd, opts.root) };
468
+ return modeFn(resolved, env, io);
465
469
  }
470
+ process.exit(main(process.argv.slice(2)));
466
471
  export {
467
- spurCommand,
468
- scanResiduals,
472
+ scanResiduals2 as scanResiduals,
469
473
  renderReport,
474
+ recordedVerdictPath,
470
475
  parseReviewFindings,
471
476
  parseDiffMarkers,
472
477
  normalizeAnchor,