@gobing-ai/spur 0.3.98 → 0.3.99

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 (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/plugin-scripts.json +5 -0
  3. package/config/templates/task/brainstorm.md +1 -1
  4. package/config/templates/task/feature-impl.md +1 -1
  5. package/config/templates/task/issue.md +1 -1
  6. package/config/templates/task/meta.md +1 -1
  7. package/config/templates/task/review.md +1 -1
  8. package/config/templates/task/standard.md +1 -1
  9. package/package.json +9 -9
  10. package/plugins/sp/lib/idea-handoff.generated.mjs +37 -4
  11. package/plugins/sp/lib/inline-run.generated.mjs +38 -5
  12. package/plugins/sp/lib/residual-scan.generated.d.mts +5 -0
  13. package/plugins/sp/lib/residual-scan.generated.mjs +45 -5
  14. package/plugins/sp/plugin.json +1 -1
  15. package/plugins/sp/scripts/persist-out-check.mjs +164 -0
  16. package/plugins/sp/scripts/persist-out-check.ts +185 -0
  17. package/plugins/sp/scripts/residual-scan.mjs +50 -8
  18. package/plugins/sp/scripts/residual-scan.ts +4 -3
  19. package/plugins/sp/scripts/task-diffstat.mjs +17 -5
  20. package/plugins/sp/scripts/task-diffstat.ts +25 -4
  21. package/plugins/sp/skills/code-verification/SKILL.md +3 -0
  22. package/plugins/sp/skills/code-verification/references/verdict-schema.md +17 -1
  23. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +40 -7
  24. package/plugins/sp/skills/spur-dev/references/execution-batch.md +216 -37
  25. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +3 -1
  26. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +107 -3
  27. package/spur.js +1662 -386
  28. package/web/_astro/{BoardApp.CYbe-OTb.js → BoardApp.BVp-LYi6.js} +101 -101
  29. package/web/_astro/{BoardApp.Dflq5a8x.js → BoardApp.i4eh_fHA.js} +1 -1
  30. package/web/_astro/{TaskDetail.CK7oXddP.js → TaskDetail.C5Cu8LdB.js} +1 -1
  31. package/web/_astro/{arc.BIqkDATc.js → arc.Bu5XpBEz.js} +1 -1
  32. package/web/_astro/{architectureDiagram-3BPJPVTR.D0B3SvWv.js → architectureDiagram-3BPJPVTR.9vHQTvhF.js} +1 -1
  33. package/web/_astro/{blockDiagram-GPEHLZMM.C2YyKQmL.js → blockDiagram-GPEHLZMM.Cn2Xl7D6.js} +1 -1
  34. package/web/_astro/{c4Diagram-AAUBKEIU.DmtTYrt_.js → c4Diagram-AAUBKEIU.7o0P_QDo.js} +1 -1
  35. package/web/_astro/channel.6IFIKBhu.js +1 -0
  36. package/web/_astro/{chunk-2J33WTMH.HsRXbh7l.js → chunk-2J33WTMH.C3zZFsNt.js} +1 -1
  37. package/web/_astro/{chunk-4BX2VUAB.obE_P-R3.js → chunk-4BX2VUAB.CNy_M7fO.js} +1 -1
  38. package/web/_astro/{chunk-55IACEB6.CjWTF5mz.js → chunk-55IACEB6.2TiPnLBL.js} +1 -1
  39. package/web/_astro/{chunk-727SXJPM.DMo799Ax.js → chunk-727SXJPM.5CGSyKAp.js} +1 -1
  40. package/web/_astro/{chunk-AQP2D5EJ.-cE6JOKZ.js → chunk-AQP2D5EJ.w-ZIgfsK.js} +1 -1
  41. package/web/_astro/{chunk-FMBD7UC4.D6Rw6HIk.js → chunk-FMBD7UC4.BkcEyhKa.js} +1 -1
  42. package/web/_astro/{chunk-ND2GUHAM.d5rBD73Z.js → chunk-ND2GUHAM.mxKKbBG4.js} +1 -1
  43. package/web/_astro/{chunk-QZHKN3VN.r3ox40Q3.js → chunk-QZHKN3VN.cyA0z9aT.js} +1 -1
  44. package/web/_astro/{classDiagram-4FO5ZUOK.CGGbvjjJ.js → classDiagram-4FO5ZUOK.CqtUzwhX.js} +1 -1
  45. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js → classDiagram-v2-Q7XG4LA2.CqtUzwhX.js} +1 -1
  46. package/web/_astro/{cose-bilkent-S5V4N54A.CmKtPd4l.js → cose-bilkent-S5V4N54A.BExPTopI.js} +1 -1
  47. package/web/_astro/{cynefin-OW5HDTMX.QNg5GXs8.js → cynefin-OW5HDTMX.Bq2i10Cn.js} +1 -1
  48. package/web/_astro/{dagre-BM42HDAG.B1SabsjC.js → dagre-BM42HDAG.By_HsOuv.js} +1 -1
  49. package/web/_astro/{diagram-2AECGRRQ.RtDbNj89.js → diagram-2AECGRRQ.BtU0b7hz.js} +1 -1
  50. package/web/_astro/{diagram-5GNKFQAL.IzZpuM-Y.js → diagram-5GNKFQAL.D00ztMdT.js} +1 -1
  51. package/web/_astro/{diagram-KO2AKTUF.DPTF_fgv.js → diagram-KO2AKTUF.DMVJWaUN.js} +1 -1
  52. package/web/_astro/{diagram-LMA3HP47.CCyza39P.js → diagram-LMA3HP47.Cxqrjg5-.js} +1 -1
  53. package/web/_astro/{diagram-OG6HWLK6.BXAi8zcZ.js → diagram-OG6HWLK6.2lz_1B5h.js} +1 -1
  54. package/web/_astro/{erDiagram-TEJ5UH35.DUDX7_MR.js → erDiagram-TEJ5UH35.BVQEuJ3T.js} +1 -1
  55. package/web/_astro/{flowDiagram-I6XJVG4X.C63QTh3N.js → flowDiagram-I6XJVG4X.Qlyo90ky.js} +1 -1
  56. package/web/_astro/{ganttDiagram-6RSMTGT7.CnlZJSwG.js → ganttDiagram-6RSMTGT7.DRwb8HHZ.js} +1 -1
  57. package/web/_astro/{gitGraphDiagram-PVQCEYII.CTqIrOPC.js → gitGraphDiagram-PVQCEYII.YfoPthfS.js} +1 -1
  58. package/web/_astro/{infoDiagram-5YYISTIA.BYMiDFh9.js → infoDiagram-5YYISTIA.DvFREck1.js} +1 -1
  59. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CRiQmI-z.js → ishikawaDiagram-YF4QCWOH.BzCj9Q0-.js} +1 -1
  60. package/web/_astro/{journeyDiagram-JHISSGLW.YQgWBsEU.js → journeyDiagram-JHISSGLW.BDMT_AFb.js} +1 -1
  61. package/web/_astro/{kanban-definition-UN3LZRKU.1ECnrrK1.js → kanban-definition-UN3LZRKU.D6Fh3gj9.js} +1 -1
  62. package/web/_astro/{linear.DzVA71yT.js → linear.Dxw00fGQ.js} +1 -1
  63. package/web/_astro/{mermaid.core.BK7P2Sr_.js → mermaid.core.CsoxUWa0.js} +4 -4
  64. package/web/_astro/{mindmap-definition-RKZ34NQL.BvXyWZK7.js → mindmap-definition-RKZ34NQL.CN8oeHLu.js} +1 -1
  65. package/web/_astro/{pieDiagram-4H26LBE5.BiXk1TW3.js → pieDiagram-4H26LBE5.C7ADqQ_7.js} +1 -1
  66. package/web/_astro/{quadrantDiagram-W4KKPZXB.krvBH8lh.js → quadrantDiagram-W4KKPZXB.Cw9lKyHP.js} +1 -1
  67. package/web/_astro/{requirementDiagram-4Y6WPE33.ih1gL25c.js → requirementDiagram-4Y6WPE33.B1ni-z4q.js} +1 -1
  68. package/web/_astro/{sankeyDiagram-5OEKKPKP.CpUxkv4u.js → sankeyDiagram-5OEKKPKP.BhexdjVr.js} +1 -1
  69. package/web/_astro/{sequenceDiagram-3UESZ5HK.clAfeN7y.js → sequenceDiagram-3UESZ5HK.C3Rm7dpQ.js} +1 -1
  70. package/web/_astro/{stateDiagram-AJRCARHV.DpXWb8-m.js → stateDiagram-AJRCARHV.CkuMt27u.js} +1 -1
  71. package/web/_astro/{stateDiagram-v2-BHNVJYJU.ClZb9GIH.js → stateDiagram-v2-BHNVJYJU.Dc_aazUA.js} +1 -1
  72. package/web/_astro/{timeline-definition-PNZ67QCA.BLcX5dh7.js → timeline-definition-PNZ67QCA.ja7sUFhH.js} +1 -1
  73. package/web/_astro/{vennDiagram-CIIHVFJN.oGgpKMpx.js → vennDiagram-CIIHVFJN.BxIshX_K.js} +1 -1
  74. package/web/_astro/{wardleyDiagram-YWT4CUSO.BPHtMtt-.js → wardleyDiagram-YWT4CUSO.DfHlC0sh.js} +1 -1
  75. package/web/_astro/{xychartDiagram-2RQKCTM6.BNZOPYSk.js → xychartDiagram-2RQKCTM6.DSnhHwCh.js} +1 -1
  76. package/web/index.html +1 -1
  77. package/web/_astro/channel.JuKVyuhZ.js +0 -1
@@ -61,3 +61,8 @@ export declare function foldVerdict(
61
61
  maxFindings?: number,
62
62
  ): FoldResult;
63
63
  export declare function renderReport(wbs: string, items: ResidualItem[], attemptCount: number): string;
64
+ export declare function verdictDisagreementNote(
65
+ runDir: string,
66
+ wbs: string,
67
+ fs: Pick<typeof import('node:fs'), 'existsSync' | 'readFileSync' | 'statSync'>,
68
+ ): string | null;
@@ -3,7 +3,8 @@ import { join } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
5
5
  var PRIORITY_PATTERN = /^P[1-4]/;
6
- var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
6
+ var RANGE_PRIORITY = /^P[1-4]\s*[\u2013\u2014-]\s*P?[1-4]/;
7
+ var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
7
8
  var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
8
9
  var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
9
10
  var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -60,7 +61,7 @@ function parseReviewFindings(taskContent) {
60
61
  const priority = (cells[priorityCol] ?? "").trim();
61
62
  const finding = (cells[findingCol] ?? "").trim();
62
63
  const disposition = dispositionCol === -1 ? "" : (cells[dispositionCol] ?? "").trim();
63
- if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
64
+ if (PRIORITY_PATTERN.test(priority) && !RANGE_PRIORITY.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
64
65
  const location = locationOf(cells[locationCol] ?? "", finding);
65
66
  out.push(DEFERRED_DISPOSITION.test(disposition) ? { priority, location, text: finding, deferral: disposition } : { priority, location, text: finding });
66
67
  }
@@ -70,7 +71,7 @@ function parseReviewFindings(taskContent) {
70
71
  return out;
71
72
  }
72
73
  function splitRow(line) {
73
- return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
74
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split(/(?<!\\)\|/).map((c) => c.trim());
74
75
  }
75
76
  function parseDiffMarkers(addedLines) {
76
77
  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() }));
@@ -198,7 +199,8 @@ function renderReport(wbs, items, attemptCount) {
198
199
  function recordedVerdictPath(runDir, wbs, fs) {
199
200
  const evidence = join(runDir, "..", "memory", "evidence");
200
201
  const durable = join(evidence, `${wbs}-verdict.json`);
201
- for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
202
+ const run = join(runDir, `${wbs}-verdict.json`);
203
+ for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable, run]) {
202
204
  try {
203
205
  if (fs.lstatSync(path).isSymbolicLink())
204
206
  throw new Error(`residual-scan: symlink evidence path: ${path}`);
@@ -207,9 +209,47 @@ function recordedVerdictPath(runDir, wbs, fs) {
207
209
  throw error;
208
210
  }
209
211
  }
210
- return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
212
+ if (!fs.existsSync(durable))
213
+ return run;
214
+ if (!fs.existsSync(run))
215
+ return durable;
216
+ const newer = (a, b) => mtimeOf(fs, b) > mtimeOf(fs, a) ? b : a;
217
+ return newer(run, durable);
218
+ }
219
+ function mtimeOf(fs, path) {
220
+ try {
221
+ return fs.statSync(path).mtimeMs;
222
+ } catch {
223
+ return 0;
224
+ }
225
+ }
226
+ function verdictDisagreementNote(runDir, wbs, fs) {
227
+ const run = join(runDir, `${wbs}-verdict.json`);
228
+ const durable = join(runDir, "..", "memory", "evidence", `${wbs}-verdict.json`);
229
+ if (!fs.existsSync(run) || !fs.existsSync(durable))
230
+ return null;
231
+ const bytes = (path) => {
232
+ try {
233
+ return fs.statSync(path).isFile() ? fs.readFileSync(path, "utf8") : null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ };
238
+ const runBytes = bytes(run);
239
+ if (runBytes === null || runBytes === bytes(durable))
240
+ return null;
241
+ const value = (path) => {
242
+ try {
243
+ return JSON.parse(bytes(path) ?? "{}").verdict ?? "?";
244
+ } catch {
245
+ return "?";
246
+ }
247
+ };
248
+ const winner = mtimeOf(fs, durable) > mtimeOf(fs, run) ? "durable" : "run";
249
+ return `residual-fold: ${wbs} verdict copies disagree — run=${run} (${value(run)}) durable=${durable} (${value(durable)})` + ` → chose ${winner} (newer mtime)`;
211
250
  }
212
251
  export {
252
+ verdictDisagreementNote,
213
253
  scanResiduals,
214
254
  renderReport,
215
255
  recordedVerdictPath,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.98",
3
+ "version": "0.3.99",
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"]
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/persist-out-check.ts
5
+ import { spawnSync } from "child_process";
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
7
+ import { basename, dirname, isAbsolute, join, relative } from "path";
8
+ var EVIDENCE_DIR = join(".spur", "memory", "evidence");
9
+ var RUN_DIR = join(".spur", "run");
10
+ var NAMED_CAP = 32;
11
+ var PREFIX_CAP = 64;
12
+ var EVIDENCE_CAP = 256;
13
+ function wbsOfTaskFile(path) {
14
+ const m = /^(\d+)_/.exec(basename(path));
15
+ return m?.[1] ?? null;
16
+ }
17
+ function listFilesUnder(root, rel, cap) {
18
+ const dir = join(root, rel);
19
+ if (!existsSync(dir))
20
+ return [];
21
+ const out = [];
22
+ const walk = (cur) => {
23
+ for (const entry of readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
24
+ const p = join(cur, entry.name);
25
+ if (entry.isDirectory()) {
26
+ if (!walk(p))
27
+ return false;
28
+ } else {
29
+ out.push(relative(root, p));
30
+ if (out.length > cap)
31
+ return false;
32
+ }
33
+ }
34
+ return true;
35
+ };
36
+ return walk(dir) ? out : null;
37
+ }
38
+ function listObligations(wtRoot, wbsList, runIds) {
39
+ const evidence = listFilesUnder(wtRoot, EVIDENCE_DIR, EVIDENCE_CAP);
40
+ if (evidence === null)
41
+ return null;
42
+ const out = [...evidence];
43
+ const prefixes = [...wbsList, ...runIds];
44
+ for (const prefix of prefixes) {
45
+ const dir = join(wtRoot, RUN_DIR);
46
+ if (!existsSync(dir))
47
+ continue;
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(dir).sort();
51
+ } catch {
52
+ return null;
53
+ }
54
+ const owned = entries.filter((n) => n.startsWith(`${prefix}-`));
55
+ if (owned.length > PREFIX_CAP)
56
+ return null;
57
+ out.push(...owned.map((n) => join(RUN_DIR, n)));
58
+ }
59
+ return [...new Set(out)].sort();
60
+ }
61
+ function compareTrees(wtRoot, invokeRoot, files) {
62
+ const missing = [];
63
+ const divergent = [];
64
+ let ok = 0;
65
+ for (const rel of files) {
66
+ const wtPath = join(wtRoot, rel);
67
+ const invPath = join(invokeRoot, rel);
68
+ if (!existsSync(invPath)) {
69
+ missing.push(rel);
70
+ continue;
71
+ }
72
+ try {
73
+ const same = readFileSync(wtPath).equals(readFileSync(invPath));
74
+ if (same)
75
+ ok++;
76
+ else
77
+ divergent.push(rel);
78
+ } catch {
79
+ divergent.push(rel);
80
+ }
81
+ }
82
+ return { missing, divergent, ok };
83
+ }
84
+ function persistOutCheckUsage() {
85
+ return "usage: persist-out-check --from <worktree> [--task-file <path>]\u2026 [--run-id <id>]\u2026 [--root <invoke-tree>]";
86
+ }
87
+ function defaultInvokeRoot(cwd) {
88
+ try {
89
+ const run = spawnSync("git", ["-C", cwd, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
90
+ const common = run.stdout.trim();
91
+ if (run.status === 0 && common)
92
+ return dirname(isAbsolute(common) ? common : join(cwd, common));
93
+ } catch {}
94
+ return cwd;
95
+ }
96
+ function main(argv, invokeRoot = defaultInvokeRoot(process.cwd())) {
97
+ let wtRoot = "";
98
+ const wbsList = [];
99
+ const runIds = [];
100
+ for (let i = 0;i < argv.length; i++) {
101
+ const arg = argv[i];
102
+ if (arg === "--spur-bin") {
103
+ i++;
104
+ } else if (arg === "--from") {
105
+ wtRoot = argv[++i] ?? "";
106
+ } else if (arg === "--root") {
107
+ invokeRoot = argv[++i] ?? invokeRoot;
108
+ } else if (arg === "--task-file") {
109
+ const wbs = wbsOfTaskFile(argv[++i] ?? "");
110
+ if (wbs)
111
+ wbsList.push(wbs);
112
+ } else if (arg === "--run-id") {
113
+ const id = argv[++i] ?? "";
114
+ if (id)
115
+ runIds.push(id);
116
+ } else {
117
+ process.stderr.write(`${persistOutCheckUsage()}
118
+ `);
119
+ return 2;
120
+ }
121
+ }
122
+ if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
123
+ process.stderr.write(`${persistOutCheckUsage()}
124
+ persist-out-check: --from must be an existing worktree directory
125
+ `);
126
+ return 2;
127
+ }
128
+ const files = listObligations(wtRoot, wbsList, runIds);
129
+ if (files === null) {
130
+ process.stderr.write(`persist-out-check: BLOCKED \u2014 worktree evidence listing failed or exceeded caps (64/prefix, ${EVIDENCE_CAP} evidence files) \u2014 inspect by hand
131
+ `);
132
+ return 1;
133
+ }
134
+ const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files);
135
+ const findings = [
136
+ ...missing.map((f) => `MISSING ${f}`),
137
+ ...divergent.map((f) => `DIVERGENT ${f} \u2014 reconcile by hand (persist-out never overwrites)`)
138
+ ];
139
+ for (const line of findings.slice(0, NAMED_CAP))
140
+ process.stdout.write(`${line}
141
+ `);
142
+ if (findings.length > NAMED_CAP)
143
+ process.stdout.write(`\u2026 +${findings.length - NAMED_CAP} more
144
+ `);
145
+ if (findings.length === 0) {
146
+ process.stdout.write(`persist-out-check: ok \u2014 ${ok} evidence file(s) persisted, nothing abandoned
147
+ `);
148
+ return 0;
149
+ }
150
+ process.stderr.write(`persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent \u2014 run inline-run-setup --persist-out --from <worktree> before worktree removal
151
+ `);
152
+ return 1;
153
+ }
154
+ {
155
+ process.exit(main(process.argv.slice(2)));
156
+ }
157
+ export {
158
+ wbsOfTaskFile,
159
+ persistOutCheckUsage,
160
+ main,
161
+ listObligations,
162
+ defaultInvokeRoot,
163
+ compareTrees
164
+ };
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * persist-out-check — WT-4 pre-removal assertion for E71 persist-out (task 1067 R1).
4
+ *
5
+ * Before `git worktree remove`, verify the worktree's durable evidence would not be
6
+ * abandoned: every file the worktree owns under `.spur/run/` (`<wbs>-*` per forwarded
7
+ * task file, `<runId>-*` per forwarded run id) and the whole `.spur/memory/evidence/`
8
+ * tree must exist in the invoking tree with identical bytes. Missing files BLOCK the
9
+ * removal (persist-out was skipped or stale); divergent files block too (persist-out
10
+ * never overwrites — reconcile by hand per execution-batch.md). No new public CLI verb:
11
+ * this is a driver-side assertion step, not a corpus tool.
12
+ *
13
+ * ponytail: file-level comparison only — DB run rows and nested-run deep walks stay
14
+ * persist-out's job (`inline-run-setup --persist-out`); this checks the planes E71
15
+ * names as files. Listing caps (64/prefix, 256 evidence) refuse runaways by name.
16
+ *
17
+ * Node-builtin imports only; pure helpers are exported for tests. Exit 0 ok, 1
18
+ * blocked/divergent, 2 usage error. Normal output is the finding lines themselves.
19
+ */
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
23
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
24
+
25
+ const EVIDENCE_DIR = join('.spur', 'memory', 'evidence');
26
+ const RUN_DIR = join('.spur', 'run');
27
+ const NAMED_CAP = 32;
28
+ const PREFIX_CAP = 64;
29
+ const EVIDENCE_CAP = 256;
30
+
31
+ /** Task files own their `<wbs>-*` evidence: leading digits before `_` in the basename. */
32
+ export function wbsOfTaskFile(path: string): string | null {
33
+ const m = /^(\d+)_/.exec(basename(path));
34
+ return m?.[1] ?? null;
35
+ }
36
+
37
+ function listFilesUnder(root: string, rel: string, cap: number): string[] | null {
38
+ const dir = join(root, rel);
39
+ if (!existsSync(dir)) return [];
40
+ const out: string[] = [];
41
+ const walk = (cur: string): boolean => {
42
+ for (const entry of readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
43
+ const p = join(cur, entry.name);
44
+ if (entry.isDirectory()) {
45
+ if (!walk(p)) return false;
46
+ } else {
47
+ out.push(relative(root, p));
48
+ if (out.length > cap) return false;
49
+ }
50
+ }
51
+ return true;
52
+ };
53
+ return walk(dir) ? out : null;
54
+ }
55
+
56
+ /**
57
+ * Evidence files the worktree owns: `.spur/memory/evidence/**` plus `.spur/run/<prefix>-*`
58
+ * direct children per task-file WBS / run id. Returns null on a cap overrun or a listing
59
+ * failure (not a directory, permission denied) — the caller refuses either way.
60
+ */
61
+ export function listObligations(wtRoot: string, wbsList: string[], runIds: string[]): string[] | null {
62
+ const evidence = listFilesUnder(wtRoot, EVIDENCE_DIR, EVIDENCE_CAP);
63
+ if (evidence === null) return null;
64
+ const out = [...evidence];
65
+ const prefixes = [...wbsList, ...runIds];
66
+ for (const prefix of prefixes) {
67
+ const dir = join(wtRoot, RUN_DIR);
68
+ if (!existsSync(dir)) continue;
69
+ let entries: string[];
70
+ try {
71
+ entries = readdirSync(dir).sort();
72
+ } catch {
73
+ return null;
74
+ }
75
+ const owned = entries.filter((n) => n.startsWith(`${prefix}-`));
76
+ if (owned.length > PREFIX_CAP) return null;
77
+ out.push(...owned.map((n) => join(RUN_DIR, n)));
78
+ }
79
+ return [...new Set(out)].sort();
80
+ }
81
+
82
+ /** Classify obligations against the invoking tree: missing, divergent (byte-differs), or ok. */
83
+ export function compareTrees(
84
+ wtRoot: string,
85
+ invokeRoot: string,
86
+ files: string[],
87
+ ): { missing: string[]; divergent: string[]; ok: number } {
88
+ const missing: string[] = [];
89
+ const divergent: string[] = [];
90
+ let ok = 0;
91
+ for (const rel of files) {
92
+ const wtPath = join(wtRoot, rel);
93
+ const invPath = join(invokeRoot, rel);
94
+ if (!existsSync(invPath)) {
95
+ missing.push(rel);
96
+ continue;
97
+ }
98
+ try {
99
+ const same = readFileSync(wtPath).equals(readFileSync(invPath));
100
+ if (same) ok++;
101
+ else divergent.push(rel);
102
+ } catch {
103
+ divergent.push(rel);
104
+ }
105
+ }
106
+ return { missing, divergent, ok };
107
+ }
108
+
109
+ export function persistOutCheckUsage(): string {
110
+ return 'usage: persist-out-check --from <worktree> [--task-file <path>]… [--run-id <id>]… [--root <invoke-tree>]';
111
+ }
112
+
113
+ /**
114
+ * The invoking tree is the MAIN repo root — never default to cwd: WT-4 runs from the
115
+ * worktree, and comparing the worktree against itself is a vacuous pass (caught live in
116
+ * task 1067 R4). `--git-common-dir` resolves to the shared `.git`, whose parent is the
117
+ * main tree; inside the main tree itself this falls back to cwd, which is correct there.
118
+ */
119
+ export function defaultInvokeRoot(cwd: string): string {
120
+ try {
121
+ const run = spawnSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' });
122
+ const common = run.stdout.trim();
123
+ if (run.status === 0 && common) return dirname(isAbsolute(common) ? common : join(cwd, common));
124
+ } catch {
125
+ // git unavailable — fall through to cwd
126
+ }
127
+ return cwd;
128
+ }
129
+
130
+ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd())): number {
131
+ let wtRoot = '';
132
+ const wbsList: string[] = [];
133
+ const runIds: string[] = [];
134
+ for (let i = 0; i < argv.length; i++) {
135
+ const arg = argv[i];
136
+ if (arg === '--spur-bin') {
137
+ i++;
138
+ } else if (arg === '--from') {
139
+ wtRoot = argv[++i] ?? '';
140
+ } else if (arg === '--root') {
141
+ invokeRoot = argv[++i] ?? invokeRoot;
142
+ } else if (arg === '--task-file') {
143
+ const wbs = wbsOfTaskFile(argv[++i] ?? '');
144
+ if (wbs) wbsList.push(wbs);
145
+ } else if (arg === '--run-id') {
146
+ const id = argv[++i] ?? '';
147
+ if (id) runIds.push(id);
148
+ } else {
149
+ process.stderr.write(`${persistOutCheckUsage()}\n`);
150
+ return 2;
151
+ }
152
+ }
153
+ if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
154
+ process.stderr.write(
155
+ `${persistOutCheckUsage()}\npersist-out-check: --from must be an existing worktree directory\n`,
156
+ );
157
+ return 2;
158
+ }
159
+ const files = listObligations(wtRoot, wbsList, runIds);
160
+ if (files === null) {
161
+ process.stderr.write(
162
+ `persist-out-check: BLOCKED — worktree evidence listing failed or exceeded caps (64/prefix, ${EVIDENCE_CAP} evidence files) — inspect by hand\n`,
163
+ );
164
+ return 1;
165
+ }
166
+ const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files);
167
+ const findings = [
168
+ ...missing.map((f) => `MISSING ${f}`),
169
+ ...divergent.map((f) => `DIVERGENT ${f} — reconcile by hand (persist-out never overwrites)`),
170
+ ];
171
+ for (const line of findings.slice(0, NAMED_CAP)) process.stdout.write(`${line}\n`);
172
+ if (findings.length > NAMED_CAP) process.stdout.write(`… +${findings.length - NAMED_CAP} more\n`);
173
+ if (findings.length === 0) {
174
+ process.stdout.write(`persist-out-check: ok — ${ok} evidence file(s) persisted, nothing abandoned\n`);
175
+ return 0;
176
+ }
177
+ process.stderr.write(
178
+ `persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent — run inline-run-setup --persist-out --from <worktree> before worktree removal\n`,
179
+ );
180
+ return 1;
181
+ }
182
+
183
+ if (import.meta.main) {
184
+ process.exit(main(process.argv.slice(2)));
185
+ }
@@ -17,7 +17,8 @@ import { join } from "node:path";
17
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)?|—)\s*(\(.*\))?\.?$/i;
20
+ var RANGE_PRIORITY = /^P[1-4]\s*[\u2013\u2014-]\s*P?[1-4]/;
21
+ var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
21
22
  var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
22
23
  var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
23
24
  var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -74,7 +75,7 @@ function parseReviewFindings(taskContent) {
74
75
  const priority = (cells[priorityCol] ?? "").trim();
75
76
  const finding = (cells[findingCol] ?? "").trim();
76
77
  const disposition = dispositionCol === -1 ? "" : (cells[dispositionCol] ?? "").trim();
77
- if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
78
+ if (PRIORITY_PATTERN.test(priority) && !RANGE_PRIORITY.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
78
79
  const location = locationOf(cells[locationCol] ?? "", finding);
79
80
  out.push(DEFERRED_DISPOSITION.test(disposition) ? { priority, location, text: finding, deferral: disposition } : { priority, location, text: finding });
80
81
  }
@@ -84,7 +85,7 @@ function parseReviewFindings(taskContent) {
84
85
  return out;
85
86
  }
86
87
  function splitRow(line) {
87
- return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
88
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split(/(?<!\\)\|/).map((c) => c.trim());
88
89
  }
89
90
  function parseDiffMarkers(addedLines) {
90
91
  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() }));
@@ -212,7 +213,8 @@ function renderReport(wbs, items, attemptCount) {
212
213
  function recordedVerdictPath(runDir, wbs, fs) {
213
214
  const evidence = join(runDir, "..", "memory", "evidence");
214
215
  const durable = join(evidence, `${wbs}-verdict.json`);
215
- for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
216
+ const run = join(runDir, `${wbs}-verdict.json`);
217
+ for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable, run]) {
216
218
  try {
217
219
  if (fs.lstatSync(path).isSymbolicLink())
218
220
  throw new Error(`residual-scan: symlink evidence path: ${path}`);
@@ -221,7 +223,44 @@ function recordedVerdictPath(runDir, wbs, fs) {
221
223
  throw error;
222
224
  }
223
225
  }
224
- return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
226
+ if (!fs.existsSync(durable))
227
+ return run;
228
+ if (!fs.existsSync(run))
229
+ return durable;
230
+ const newer = (a, b) => mtimeOf(fs, b) > mtimeOf(fs, a) ? b : a;
231
+ return newer(run, durable);
232
+ }
233
+ function mtimeOf(fs, path) {
234
+ try {
235
+ return fs.statSync(path).mtimeMs;
236
+ } catch {
237
+ return 0;
238
+ }
239
+ }
240
+ function verdictDisagreementNote(runDir, wbs, fs) {
241
+ const run = join(runDir, `${wbs}-verdict.json`);
242
+ const durable = join(runDir, "..", "memory", "evidence", `${wbs}-verdict.json`);
243
+ if (!fs.existsSync(run) || !fs.existsSync(durable))
244
+ return null;
245
+ const bytes = (path) => {
246
+ try {
247
+ return fs.statSync(path).isFile() ? fs.readFileSync(path, "utf8") : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ };
252
+ const runBytes = bytes(run);
253
+ if (runBytes === null || runBytes === bytes(durable))
254
+ return null;
255
+ const value = (path) => {
256
+ try {
257
+ return JSON.parse(bytes(path) ?? "{}").verdict ?? "?";
258
+ } catch {
259
+ return "?";
260
+ }
261
+ };
262
+ const winner = mtimeOf(fs, durable) > mtimeOf(fs, run) ? "durable" : "run";
263
+ return `residual-fold: ${wbs} verdict copies disagree — run=${run} (${value(run)}) durable=${durable} (${value(durable)})` + ` → chose ${winner} (newer mtime)`;
225
264
  }
226
265
 
227
266
  // plugins/sp/lib/spur-bin.ts
@@ -337,9 +376,7 @@ function loadTask(env, spurBinFlag, wbs, root) {
337
376
  const featureId = [parsed.feature_id, parsed.frontmatter?.feature_id].find((v) => typeof v === "string") ?? "";
338
377
  return { content: typeof parsed.content === "string" ? parsed.content : "", featureId };
339
378
  }
340
- function loadVerdict(runDir, wbs) {
341
- return JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
342
- }
379
+ var loadVerdict = (runDir, wbs) => JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
343
380
  function scanMode(opts, env, io) {
344
381
  const runDir = join2(opts.root, ".spur", "run");
345
382
  fs.mkdirSync(runDir, { recursive: true });
@@ -355,6 +392,10 @@ function foldMode(opts, _env, io) {
355
392
  const runDir = join2(opts.root, ".spur", "run");
356
393
  const scan = JSON.parse(fs.readFileSync(join2(runDir, `${opts.wbs}-residuals.json`), "utf8"));
357
394
  const target = recordedVerdictPath(runDir, opts.wbs, fs);
395
+ const note = verdictDisagreementNote(runDir, opts.wbs, fs);
396
+ if (note !== null)
397
+ io.out(`${note}
398
+ `);
358
399
  const verdict = loadVerdict(runDir, opts.wbs);
359
400
  const findingsPath = join2(runDir, `${opts.wbs}-test-gate.findings`);
360
401
  const fold = foldVerdict(verdict, scan, fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, "utf8") : "");
@@ -469,6 +510,7 @@ function main(argv, env = getEnvVars(), options = {}) {
469
510
  }
470
511
  process.exit(main(process.argv.slice(2)));
471
512
  export {
513
+ verdictDisagreementNote,
472
514
  scanResiduals2 as scanResiduals,
473
515
  renderReport,
474
516
  recordedVerdictPath,
@@ -115,9 +115,8 @@ function loadTask(env: ScanEnv, spurBinFlag: string | undefined, wbs: string, ro
115
115
  [parsed.feature_id, parsed.frontmatter?.feature_id].find((v): v is string => typeof v === 'string') ?? '';
116
116
  return { content: typeof parsed.content === 'string' ? parsed.content : '', featureId };
117
117
  }
118
- function loadVerdict(runDir: string, wbs: string): VerdictFile {
119
- return JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
120
- }
118
+ const loadVerdict = (runDir: string, wbs: string): VerdictFile =>
119
+ JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
121
120
  function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
122
121
  const runDir = join(opts.root, '.spur', 'run');
123
122
  fs.mkdirSync(runDir, { recursive: true });
@@ -135,6 +134,8 @@ function foldMode(opts: ParsedArgs, _env: ScanEnv, io: ScanIo): number {
135
134
  fs.readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8'),
136
135
  ) as core.ResidualArtifact;
137
136
  const target = core.recordedVerdictPath(runDir, opts.wbs, fs);
137
+ const note = core.verdictDisagreementNote(runDir, opts.wbs, fs);
138
+ if (note !== null) io.out(`${note}\n`);
138
139
  const verdict = loadVerdict(runDir, opts.wbs);
139
140
  const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
140
141
  const fold = core.foldVerdict(
@@ -3,7 +3,7 @@
3
3
 
4
4
  // plugins/sp/scripts/task-diffstat.ts
5
5
  import { spawnSync } from "child_process";
6
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
6
+ import { fstatSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
7
7
  import { join } from "path";
8
8
 
9
9
  // plugins/sp/lib/env.ts
@@ -85,10 +85,14 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
85
85
  process.stderr.write(`task-diffstat: ${reason} \u2014 failing safe (sensitive)
86
86
  `);
87
87
  const row2 = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
88
- writeFileSync(abs(relResult), `${JSON.stringify(row2)}
89
- `);
88
+ writeArtifact(abs(relResult), row2);
90
89
  return { ...row2, resultFile: relResult, exitCode: 0 };
91
90
  };
91
+ let stdoutIsArtifact = false;
92
+ try {
93
+ const [out, art] = [fstatSync(1), statSync(abs(relResult))];
94
+ stdoutIsArtifact = out.dev === art.dev && out.ino === art.ino;
95
+ } catch {}
92
96
  let base = "";
93
97
  try {
94
98
  base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), "utf8").trim();
@@ -125,9 +129,17 @@ function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
125
129
  const all = [...paths].filter((p) => p.length > 0).sort();
126
130
  const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
127
131
  const row = { files: all.length, insertions, deletions, paths: all, sensitive };
128
- writeFileSync(abs(relResult), `${JSON.stringify(row)}
132
+ writeArtifact(abs(relResult), row);
133
+ if (stdoutIsArtifact)
134
+ process.stderr.write(`task-diffstat: stdout redirected onto the artifact \u2014 kept; redirect stdout elsewhere
135
+ `);
136
+ return { ...row, resultFile: relResult, exitCode: stdoutIsArtifact ? 1 : 0 };
137
+ }
138
+ function writeArtifact(absPath, row) {
139
+ const tmp = `${absPath}.tmp`;
140
+ writeFileSync(tmp, `${JSON.stringify(row)}
129
141
  `);
130
- return { ...row, resultFile: relResult, exitCode: 0 };
142
+ renameSync(tmp, absPath);
131
143
  }
132
144
  var TASK_DIFFSTAT_USAGE = "usage: task-diffstat (env: wbs)";
133
145
  function main(argv, env = getEnvVars(), options = {}) {