@gobing-ai/spur 0.3.96 → 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 (87) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/rules/strict/runtime-boundaries.yaml +1 -0
  3. package/config/templates/AGENTS.md +3 -3
  4. package/config/workflows/wrapup-pipeline.yaml +2 -1
  5. package/package.json +2 -2
  6. package/plugins/sp/README.md +6 -0
  7. package/plugins/sp/commands/dev-job-dump.md +29 -0
  8. package/plugins/sp/commands/dev-job-resume.md +29 -0
  9. package/plugins/sp/lib/inline-run.generated.mjs +25 -16
  10. package/plugins/sp/lib/quality-gate.generated.mjs +12 -5
  11. package/plugins/sp/lib/residual-scan.generated.d.mts +1 -0
  12. package/plugins/sp/lib/residual-scan.generated.mjs +16 -0
  13. package/plugins/sp/plugin.json +1 -1
  14. package/plugins/sp/references/roles.md +2 -2
  15. package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
  16. package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
  17. package/plugins/sp/scripts/quality-gate.mjs +13 -6
  18. package/plugins/sp/scripts/quality-gate.ts +1 -1
  19. package/plugins/sp/scripts/residual-scan.mjs +64 -38
  20. package/plugins/sp/scripts/residual-scan.ts +39 -34
  21. package/plugins/sp/scripts/wrapup-steps.mjs +64 -3
  22. package/plugins/sp/scripts/wrapup-steps.ts +122 -3
  23. package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
  24. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
  25. package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
  26. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  27. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +3 -1
  28. package/plugins/sp/skills/spur-dev/references/dev-operations.md +79 -5
  29. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +4 -2
  30. package/plugins/sp/skills/spur-dev/references/execution-batch.md +8 -6
  31. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +3 -3
  32. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +9 -0
  33. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +43 -12
  34. package/spur.js +4246 -2996
  35. package/web/_astro/{BoardApp.BIjMatT1.js → BoardApp.Bmv5WkJ9.js} +1 -1
  36. package/web/_astro/BoardApp.vveLOdDq.js +322 -0
  37. package/web/_astro/{TaskDetail.BZM3EAFx.js → TaskDetail.IKc3uDYV.js} +1 -1
  38. package/web/_astro/{arc.HkRiZnoI.js → arc.C63Ufg35.js} +1 -1
  39. package/web/_astro/{architectureDiagram-3BPJPVTR.BDdK-tgZ.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
  40. package/web/_astro/{blockDiagram-GPEHLZMM.CZ9kOBxx.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
  41. package/web/_astro/{c4Diagram-AAUBKEIU.DXK9qeRD.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
  42. package/web/_astro/channel.BKCmJpWM.js +1 -0
  43. package/web/_astro/{chunk-2J33WTMH.BTD2WeX5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
  44. package/web/_astro/{chunk-4BX2VUAB.Ci6Qbcvk.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
  45. package/web/_astro/{chunk-55IACEB6.yl3zsj7p.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
  46. package/web/_astro/{chunk-727SXJPM.ClXZsfyR.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
  47. package/web/_astro/{chunk-AQP2D5EJ.BmKWZBcP.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
  48. package/web/_astro/{chunk-FMBD7UC4.By30fcb7.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
  49. package/web/_astro/{chunk-ND2GUHAM.24NmrD-k.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
  50. package/web/_astro/{chunk-QZHKN3VN.BN4sKdcS.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
  51. package/web/_astro/{classDiagram-4FO5ZUOK.CJpoMPb5.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
  52. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CJpoMPb5.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
  53. package/web/_astro/{cose-bilkent-S5V4N54A.BraxQ2Nt.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
  54. package/web/_astro/{cynefin-OW5HDTMX.gzoU73oL.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
  55. package/web/_astro/{dagre-BM42HDAG.wyDfWySU.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
  56. package/web/_astro/{diagram-2AECGRRQ.h2_EbMxV.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
  57. package/web/_astro/{diagram-5GNKFQAL.Db6dcs89.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
  58. package/web/_astro/{diagram-KO2AKTUF.NVwzxu_U.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
  59. package/web/_astro/{diagram-LMA3HP47.B-eNgDQk.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
  60. package/web/_astro/{diagram-OG6HWLK6.Bsa8ZRbJ.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
  61. package/web/_astro/{erDiagram-TEJ5UH35.BospRE5Q.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
  62. package/web/_astro/{flowDiagram-I6XJVG4X.DMzvHwAA.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
  63. package/web/_astro/{ganttDiagram-6RSMTGT7.Dmdxgd-M.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
  64. package/web/_astro/{gitGraphDiagram-PVQCEYII.CaqdO0rL.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
  65. package/web/_astro/index.CbNz17Sx.css +1 -0
  66. package/web/_astro/{infoDiagram-5YYISTIA.DDVD9Wn7.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
  67. package/web/_astro/{ishikawaDiagram-YF4QCWOH.EKyCUFsL.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
  68. package/web/_astro/{journeyDiagram-JHISSGLW.By2-RkYB.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
  69. package/web/_astro/{kanban-definition-UN3LZRKU.ar-WmlyR.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
  70. package/web/_astro/{linear.BnzMBgo_.js → linear.BATV4RXu.js} +1 -1
  71. package/web/_astro/{mermaid.core.DSqeFu2Y.js → mermaid.core.DzkwM3VX.js} +4 -4
  72. package/web/_astro/{mindmap-definition-RKZ34NQL.1XcF8Wh2.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
  73. package/web/_astro/{pieDiagram-4H26LBE5.DnItHOdK.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
  74. package/web/_astro/{quadrantDiagram-W4KKPZXB.BQQyrnBt.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
  75. package/web/_astro/{requirementDiagram-4Y6WPE33.Dmzxun82.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
  76. package/web/_astro/{sankeyDiagram-5OEKKPKP.BBIeRKYB.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
  77. package/web/_astro/{sequenceDiagram-3UESZ5HK.CUNVdBBf.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
  78. package/web/_astro/{stateDiagram-AJRCARHV.DlFf2sJm.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
  79. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DXR7I1H5.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
  80. package/web/_astro/{timeline-definition-PNZ67QCA.BcO-GyX2.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
  81. package/web/_astro/{vennDiagram-CIIHVFJN.ChCECmmW.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
  82. package/web/_astro/{wardleyDiagram-YWT4CUSO.CRaT12mD.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
  83. package/web/_astro/{xychartDiagram-2RQKCTM6.B3lsHVpj.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
  84. package/web/index.html +2 -2
  85. package/web/_astro/BoardApp.GvjIe9Z6.js +0 -191
  86. package/web/_astro/channel.DlhX2MEt.js +0 -1
  87. package/web/_astro/index.EoZlzLK-.css +0 -1
@@ -111,6 +111,9 @@ function readReceiptStatus(receiptPath, currentDigest) {
111
111
  }
112
112
  if (receipt.tier !== "full")
113
113
  return { reuse: false, reason: "light-only" };
114
+ if (!Array.isArray(receipt.checks) || receipt.checks.length === 0 || receipt.checks.some((row) => row === null || typeof row !== "object" || row.status !== "PASS" || typeof row.cmd !== "string" || row.cmd.trim().length === 0)) {
115
+ return { reuse: false, reason: "failed" };
116
+ }
114
117
  return { reuse: true, reason: "ok" };
115
118
  }
116
119
  var TEST_FILE_PATTERN = /\.test\.tsx?$/;
@@ -305,9 +308,13 @@ function runQualityGate(mode, env, options = {}) {
305
308
  writeFileSync(abs(attemptFile), `0
306
309
  `);
307
310
  const gateStartedAtMs = Date.now();
308
- let gateRc = 0;
311
+ const commandPresent = (env.qualityGateCmd ?? "").trim().length > 0;
312
+ let gateRc = commandPresent ? 0 : 1;
309
313
  let gateAttempt = 0;
310
- const reusePass = readReceiptStatus(abs(rel("-check-receipt.json")), env.proofDigest ?? "").reuse;
314
+ if (!commandPresent)
315
+ appendFileSync(abs(logFile), `quality-gate: mode ${mode} requires env \`qualityGateCmd\` to be non-empty
316
+ `);
317
+ const reusePass = commandPresent && readReceiptStatus(abs(rel("-check-receipt.json")), env.proofDigest ?? "").reuse;
311
318
  if (reusePass) {
312
319
  const line = `check.reused — full-tier PASS receipt at input digest ${env.proofDigest ?? ""}; gate skipped
313
320
  `;
@@ -318,7 +325,7 @@ function runQualityGate(mode, env, options = {}) {
318
325
  `);
319
326
  return { status: "PASS", attempts: 0, logFile, findingsFile, statusFile, attemptFile };
320
327
  }
321
- const noProgressSkip = mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
328
+ const noProgressSkip = commandPresent && mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
322
329
  if (noProgressSkip) {
323
330
  const line = `check.skipped-no-progress — full-tier FAIL receipt at input digest ${env.proofDigest ?? ""}; recheck skipped
324
331
  `;
@@ -326,7 +333,7 @@ function runQualityGate(mode, env, options = {}) {
326
333
  appendFileSync(abs(logFile), line);
327
334
  gateRc = 1;
328
335
  }
329
- if (mode === "recheck" && !noProgressSkip && (env.gateProbeCmd ?? "").length > 0) {
336
+ if (mode === "recheck" && gateRc === 0 && (env.gateProbeCmd ?? "").length > 0) {
330
337
  const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
331
338
  writeFileSync(abs(`${logFile}.probe`), probe.output);
332
339
  gateRc = probe.code;
@@ -383,7 +390,7 @@ function runQualityGate(mode, env, options = {}) {
383
390
  appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
384
391
  `);
385
392
  let receiptFile;
386
- if (!noProgressSkip) {
393
+ if (!noProgressSkip && commandPresent) {
387
394
  if ((env.proofDigest ?? "").length > 0) {
388
395
  receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
389
396
  const receipt = buildReceipt({
@@ -37,6 +37,7 @@ export declare interface FoldResult {
37
37
  findings: string;
38
38
  }
39
39
  export declare const ALLOW_PRAGMA: 'residual-scan:allow';
40
+ export declare function recordedVerdictPath(runDir: string, wbs: string, fs: Pick<typeof import('node:fs'), 'lstatSync' | 'existsSync'>): string;
40
41
  export declare function makeItemId(category: ResidualCategory, location: string, text: string): string;
41
42
  export declare function normalizeAnchor(location: string): string;
42
43
  export declare function locationOf(locationCell: string, finding: string): string;
@@ -1,4 +1,5 @@
1
1
  // packages/app/src/services/residual-scan.ts
2
+ import { join } from "node:path";
2
3
  import { createHash } from "node:crypto";
3
4
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
4
5
  var PRIORITY_PATTERN = /^P[1-4]/;
@@ -194,9 +195,24 @@ function renderReport(wbs, items, attemptCount) {
194
195
  `)}
195
196
  `;
196
197
  }
198
+ function recordedVerdictPath(runDir, wbs, fs) {
199
+ const evidence = join(runDir, "..", "memory", "evidence");
200
+ const durable = join(evidence, `${wbs}-verdict.json`);
201
+ for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
202
+ try {
203
+ if (fs.lstatSync(path).isSymbolicLink())
204
+ throw new Error(`residual-scan: symlink evidence path: ${path}`);
205
+ } catch (error) {
206
+ if (error.code !== "ENOENT")
207
+ throw error;
208
+ }
209
+ }
210
+ return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
211
+ }
197
212
  export {
198
213
  scanResiduals,
199
214
  renderReport,
215
+ recordedVerdictPath,
200
216
  parseReviewFindings,
201
217
  parseDiffMarkers,
202
218
  normalizeAnchor,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.96",
3
+ "version": "0.3.97",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -50,7 +50,7 @@ version: 1
50
50
  roles:
51
51
  - id: scribe
52
52
  tier: cheap
53
- commands: [dev-gitmsg, dev-handover, dev-daily, dev-changelog, dev-refresh, rule-add, rule-refine, workflow-add, workflow-refine, spur-init]
53
+ commands: [dev-gitmsg, dev-handover, dev-job-dump, dev-daily, dev-changelog, dev-refresh, rule-add, rule-refine, workflow-add, workflow-refine, spur-init]
54
54
  stages: [changelog]
55
55
  - id: coder
56
56
  tier: standard
@@ -62,7 +62,7 @@ roles:
62
62
  stages: [verify, review, dogfood]
63
63
  - id: planner
64
64
  tier: capable-2
65
- commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
65
+ commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-job-resume, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
66
66
  stages: [plan, refine, brainstorm]
67
67
  ```
68
68
 
@@ -10,7 +10,9 @@ import {
10
10
  openSync,
11
11
  readdirSync,
12
12
  readFileSync,
13
+ realpathSync,
13
14
  renameSync,
15
+ statSync,
14
16
  writeFileSync
15
17
  } from "fs";
16
18
  import { join } from "path";
@@ -74,6 +76,17 @@ function findFeatureFile(featureDir, featureId) {
74
76
  return name === undefined ? undefined : join(featureDir, name);
75
77
  }
76
78
  var nodeFsShim = {
79
+ realPath: realpathSync,
80
+ readDir: readdirSync,
81
+ stat: (path) => {
82
+ try {
83
+ return statSync(path);
84
+ } catch (error) {
85
+ if (error.code === "ENOENT")
86
+ return null;
87
+ throw error;
88
+ }
89
+ },
77
90
  ensureDir: (dir) => {
78
91
  mkdirSync(dir, { recursive: true });
79
92
  },
@@ -123,7 +136,7 @@ async function verify(featureId, runId, cmdOverride, spurBin, mode) {
123
136
  try {
124
137
  await new mod.ArtifactDao(db.adapter).record({
125
138
  runId,
126
- path: join(runDir, `${runId}-feature-verification.json`),
139
+ path: mod.featureReceiptPaths(runDir, featureId, runId).runScoped,
127
140
  kind: "feature-verification"
128
141
  });
129
142
  } finally {
@@ -33,7 +33,9 @@ import {
33
33
  openSync,
34
34
  readdirSync,
35
35
  readFileSync,
36
+ realpathSync,
36
37
  renameSync,
38
+ statSync,
37
39
  writeFileSync,
38
40
  } from 'node:fs';
39
41
  import { join } from 'node:path';
@@ -45,6 +47,7 @@ const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
45
47
 
46
48
  /** Shape of the generated inline bundle / repo app source this script consumes. */
47
49
  interface VerificationModule {
50
+ featureReceiptPaths: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['featureReceiptPaths'];
48
51
  startFeatureVerificationReceipt: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['startFeatureVerificationReceipt'];
49
52
  completeFeatureVerificationReceipt: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['completeFeatureVerificationReceipt'];
50
53
  captureFeatureReceiptDigest: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['captureFeatureReceiptDigest'];
@@ -143,6 +146,16 @@ function findFeatureFile(featureDir: string, featureId: string): string | undefi
143
146
  // FileSystem port; only ensureDir/writeFile/rename are exercised on the
144
147
  // start/complete paths. Swap to createNodeFileSystem if more surface is needed.
145
148
  const nodeFsShim = {
149
+ realPath: realpathSync,
150
+ readDir: readdirSync,
151
+ stat: (path: string) => {
152
+ try {
153
+ return statSync(path);
154
+ } catch (error) {
155
+ if ((error as { code?: string }).code === 'ENOENT') return null;
156
+ throw error;
157
+ }
158
+ },
146
159
  ensureDir: (dir: string) => {
147
160
  mkdirSync(dir, { recursive: true });
148
161
  },
@@ -209,7 +222,7 @@ async function verify(
209
222
  try {
210
223
  await new mod.ArtifactDao(db.adapter as never).record({
211
224
  runId,
212
- path: join(runDir, `${runId}-feature-verification.json`),
225
+ path: mod.featureReceiptPaths(runDir, featureId, runId).runScoped,
213
226
  kind: 'feature-verification',
214
227
  });
215
228
  } finally {
@@ -123,6 +123,9 @@ function readReceiptStatus(receiptPath, currentDigest) {
123
123
  }
124
124
  if (receipt.tier !== "full")
125
125
  return { reuse: false, reason: "light-only" };
126
+ if (!Array.isArray(receipt.checks) || receipt.checks.length === 0 || receipt.checks.some((row) => row === null || typeof row !== "object" || row.status !== "PASS" || typeof row.cmd !== "string" || row.cmd.trim().length === 0)) {
127
+ return { reuse: false, reason: "failed" };
128
+ }
126
129
  return { reuse: true, reason: "ok" };
127
130
  }
128
131
  var TEST_FILE_PATTERN = /\.test\.tsx?$/;
@@ -317,9 +320,13 @@ function runQualityGate(mode, env, options = {}) {
317
320
  writeFileSync(abs(attemptFile), `0
318
321
  `);
319
322
  const gateStartedAtMs = Date.now();
320
- let gateRc = 0;
323
+ const commandPresent = (env.qualityGateCmd ?? "").trim().length > 0;
324
+ let gateRc = commandPresent ? 0 : 1;
321
325
  let gateAttempt = 0;
322
- const reusePass = readReceiptStatus(abs(rel("-check-receipt.json")), env.proofDigest ?? "").reuse;
326
+ if (!commandPresent)
327
+ appendFileSync(abs(logFile), `quality-gate: mode ${mode} requires env \`qualityGateCmd\` to be non-empty
328
+ `);
329
+ const reusePass = commandPresent && readReceiptStatus(abs(rel("-check-receipt.json")), env.proofDigest ?? "").reuse;
323
330
  if (reusePass) {
324
331
  const line = `check.reused — full-tier PASS receipt at input digest ${env.proofDigest ?? ""}; gate skipped
325
332
  `;
@@ -330,7 +337,7 @@ function runQualityGate(mode, env, options = {}) {
330
337
  `);
331
338
  return { status: "PASS", attempts: 0, logFile, findingsFile, statusFile, attemptFile };
332
339
  }
333
- const noProgressSkip = mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
340
+ const noProgressSkip = commandPresent && mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
334
341
  if (noProgressSkip) {
335
342
  const line = `check.skipped-no-progress — full-tier FAIL receipt at input digest ${env.proofDigest ?? ""}; recheck skipped
336
343
  `;
@@ -338,7 +345,7 @@ function runQualityGate(mode, env, options = {}) {
338
345
  appendFileSync(abs(logFile), line);
339
346
  gateRc = 1;
340
347
  }
341
- if (mode === "recheck" && !noProgressSkip && (env.gateProbeCmd ?? "").length > 0) {
348
+ if (mode === "recheck" && gateRc === 0 && (env.gateProbeCmd ?? "").length > 0) {
342
349
  const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
343
350
  writeFileSync(abs(`${logFile}.probe`), probe.output);
344
351
  gateRc = probe.code;
@@ -395,7 +402,7 @@ function runQualityGate(mode, env, options = {}) {
395
402
  appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
396
403
  `);
397
404
  let receiptFile;
398
- if (!noProgressSkip) {
405
+ if (!noProgressSkip && commandPresent) {
399
406
  if ((env.proofDigest ?? "").length > 0) {
400
407
  receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
401
408
  const receipt = buildReceipt({
@@ -452,7 +459,7 @@ function main(argv, rawEnv = getEnvVars(), options = {}) {
452
459
  process.stdout.write(`${JSON.stringify(verdict)}
453
460
  `);
454
461
  } else {
455
- runQualityGate(mode, env);
462
+ return runQualityGate(mode, env, options).status === "PASS" ? 0 : 1;
456
463
  }
457
464
  return 0;
458
465
  }
@@ -54,7 +54,7 @@ export function main(
54
54
  }
55
55
  process.stdout.write(`${JSON.stringify(verdict)}\n`);
56
56
  } else {
57
- runQualityGate(mode, env);
57
+ return runQualityGate(mode, env, options).status === 'PASS' ? 0 : 1;
58
58
  }
59
59
  return 0;
60
60
  }
@@ -3,9 +3,9 @@
3
3
 
4
4
  // plugins/sp/scripts/residual-scan.ts
5
5
  import { spawnSync } from "child_process";
6
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
6
+ import * as fs from "fs";
7
7
  import { tmpdir } from "os";
8
- import { join } from "path";
8
+ import { join as join2 } from "path";
9
9
 
10
10
  // plugins/sp/lib/env.ts
11
11
  function getEnvVars() {
@@ -13,6 +13,7 @@ function getEnvVars() {
13
13
  }
14
14
 
15
15
  // plugins/sp/lib/residual-scan.generated.mjs
16
+ import { join } from "node:path";
16
17
  import { createHash } from "node:crypto";
17
18
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
18
19
  var PRIORITY_PATTERN = /^P[1-4]/;
@@ -208,6 +209,20 @@ function renderReport(wbs, items, attemptCount) {
208
209
  `)}
209
210
  `;
210
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
+ }
211
226
 
212
227
  // plugins/sp/lib/spur-bin.ts
213
228
  function spurCommand(spurBin) {
@@ -229,7 +244,7 @@ function spur(env, spurBinFlag, args, cwd) {
229
244
  }
230
245
  function isRegularFile(path) {
231
246
  try {
232
- return statSync(path).isFile();
247
+ return fs.statSync(path).isFile();
233
248
  } catch {
234
249
  return false;
235
250
  }
@@ -251,9 +266,9 @@ function collectAddedLines(root, base) {
251
266
  const untracked = run("git", ["ls-files", "--others", "--exclude-standard"], root);
252
267
  for (const f of untracked.stdout.split(`
253
268
  `)) {
254
- if (f.length === 0 || !isRegularFile(join(root, f)))
269
+ if (f.length === 0 || !isRegularFile(join2(root, f)))
255
270
  continue;
256
- for (const [idx, text] of readFileSync(join(root, f), "utf8").split(`
271
+ for (const [idx, text] of fs.readFileSync(join2(root, f), "utf8").split(`
257
272
  `).entries())
258
273
  out.push({ file: f, line: idx + 1, text });
259
274
  }
@@ -262,28 +277,28 @@ function collectAddedLines(root, base) {
262
277
  function listStagingResidue(tmpDir, wbs) {
263
278
  let names;
264
279
  try {
265
- names = readdirSync(tmpDir);
280
+ names = fs.readdirSync(tmpDir);
266
281
  } catch {
267
282
  return [];
268
283
  }
269
- return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join(tmpDir, n))).map((n) => join(tmpDir, n));
284
+ return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join2(tmpDir, n))).map((n) => join2(tmpDir, n));
270
285
  }
271
286
  function readDeferrals(runDir, wbs) {
272
- const path = join(runDir, `${wbs}-residual-deferrals.json`);
273
- if (!existsSync(path))
287
+ const path = join2(runDir, `${wbs}-residual-deferrals.json`);
288
+ if (!fs.existsSync(path))
274
289
  return [];
275
290
  const isDeferral = (e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.reason === "string" && e.reason.trim().length > 0;
276
291
  try {
277
- const parsed = JSON.parse(readFileSync(path, "utf8"));
292
+ const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
278
293
  return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
279
294
  } catch {
280
295
  return [];
281
296
  }
282
297
  }
283
298
  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;
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;
287
302
  const stagingResidue = listStagingResidue(tmpDir, wbs);
288
303
  const deferrals = readDeferrals(runDir, wbs);
289
304
  const addedLines = base === null ? [] : collectAddedLines(root, base);
@@ -323,29 +338,39 @@ function loadTask(env, spurBinFlag, wbs, root) {
323
338
  return { content: typeof parsed.content === "string" ? parsed.content : "", featureId };
324
339
  }
325
340
  function loadVerdict(runDir, wbs) {
326
- return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), "utf8"));
341
+ return JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
327
342
  }
328
343
  function scanMode(opts, env, io) {
329
- const runDir = join(opts.root, ".spur", "run");
330
- mkdirSync(runDir, { recursive: true });
344
+ const runDir = join2(opts.root, ".spur", "run");
345
+ fs.mkdirSync(runDir, { recursive: true });
331
346
  const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
332
347
  const artifact = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
333
- writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}
348
+ fs.writeFileSync(join2(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}
334
349
  `);
335
350
  io.out(`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}
336
351
  `);
337
352
  return 0;
338
353
  }
339
354
  function foldMode(opts, _env, io) {
340
- const runDir = join(opts.root, ".spur", "run");
341
- const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), "utf8"));
342
- 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);
343
358
  const verdict = loadVerdict(runDir, opts.wbs);
344
- const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
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)}
347
- `);
348
- writeFileSync(findingsPath, fold.findings);
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);
349
374
  io.out(`residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === "residual-sweep")?.status}
350
375
  `);
351
376
  return 0;
@@ -353,12 +378,12 @@ function foldMode(opts, _env, io) {
353
378
  function settleMode(opts, env, io) {
354
379
  const wbs = opts.wbs;
355
380
  const task = loadTask(env, opts.spurBin, wbs, opts.root);
356
- const runDir = join(opts.root, ".spur", "run");
381
+ const runDir = join2(opts.root, ".spur", "run");
357
382
  const scan = scanResiduals2(opts.root, wbs, opts.tmpDir, task.content, env);
358
- const residualsPath = join(runDir, `${wbs}-residuals.json`);
383
+ const residualsPath = join2(runDir, `${wbs}-residuals.json`);
359
384
  let prior = {};
360
- if (existsSync(residualsPath))
361
- prior = JSON.parse(readFileSync(residualsPath, "utf8"));
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
388
  if (deferred.length > 0 && prior.followUp === undefined) {
364
389
  if (task.featureId === "") {
@@ -383,8 +408,8 @@ function settleMode(opts, env, io) {
383
408
  }
384
409
  const rows = deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`);
385
410
  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(`
411
+ const bgFile = join2(runDir, `${wbs}-residual-background.md`);
412
+ fs.writeFileSync(bgFile, `${[head, "", ...rows].join(`
388
413
  `)}
389
414
  `);
390
415
  const updArgs = ["task", "update", wbsNew, "--section", "Background", "--from-file", bgFile];
@@ -399,28 +424,28 @@ function settleMode(opts, env, io) {
399
424
  }
400
425
  for (const path of listStagingResidue(opts.tmpDir, wbs)) {
401
426
  try {
402
- rmSync(path, { force: true });
427
+ fs.rmSync(path, { force: true });
403
428
  } catch {
404
429
  io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}
405
430
  `);
406
431
  return 0;
407
432
  }
408
433
  }
409
- writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}
434
+ fs.writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}
410
435
  `);
411
436
  return 0;
412
437
  }
413
438
  function reportMode(opts, env, io) {
414
- const runDir = join(opts.root, ".spur", "run");
439
+ const runDir = join2(opts.root, ".spur", "run");
415
440
  const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === "residual-sweep");
416
441
  if (sweep === undefined || sweep.status !== "fail")
417
442
  return 0;
418
443
  const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
419
444
  const scan = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
420
445
  const blocking = scan.items.filter((i) => i.class === "blocking");
421
- const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
422
- const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, "utf8").trim() || "0", 10) : 0;
423
- writeFileSync(join(runDir, `${opts.wbs}-residual-report.md`), 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));
424
449
  io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}
425
450
  `);
426
451
  return 0;
@@ -439,13 +464,14 @@ function main(argv, env = getEnvVars(), options = {}) {
439
464
  `);
440
465
  return 2;
441
466
  }
442
- const resolved = { ...opts, root: opts.root.startsWith("/") ? opts.root : join(cwd, opts.root) };
467
+ const resolved = { ...opts, root: opts.root.startsWith("/") ? opts.root : join2(cwd, opts.root) };
443
468
  return modeFn(resolved, env, io);
444
469
  }
445
470
  process.exit(main(process.argv.slice(2)));
446
471
  export {
447
472
  scanResiduals2 as scanResiduals,
448
473
  renderReport,
474
+ recordedVerdictPath,
449
475
  parseReviewFindings,
450
476
  parseDiffMarkers,
451
477
  normalizeAnchor,