@gobing-ai/spur 0.4.5 → 0.4.7

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 (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +3 -4
  3. package/config/config.global.yaml +3 -4
  4. package/config/rules/quality/coverage-gate.yaml +6 -4
  5. package/config/rules/strict/runtime-boundaries.yaml +8 -3
  6. package/config/rules/surface/check-cli-surface.yaml +20 -0
  7. package/config/transition-shims.json +1 -15
  8. package/config/workflows/task-lifecycle.yaml +1 -1
  9. package/package.json +1 -1
  10. package/plugins/sp/commands/dev-changelog.md +1 -1
  11. package/plugins/sp/lib/inline-run.generated.mjs +1 -1
  12. package/plugins/sp/plugin.json +1 -1
  13. package/plugins/sp/scripts/commit-guard.mjs +49 -39
  14. package/plugins/sp/scripts/commit-guard.ts +60 -61
  15. package/plugins/sp/scripts/persist-out-check.mjs +28 -24
  16. package/plugins/sp/scripts/persist-out-check.ts +42 -33
  17. package/plugins/sp/skills/code-verification/SKILL.md +1 -1
  18. package/plugins/sp/skills/code-verification/references/verdict-schema.md +6 -4
  19. package/plugins/sp/skills/functional-review/SKILL.md +1 -1
  20. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +4 -2
  21. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +3 -1
  22. package/plugins/sp/skills/spur-dev/references/dev-operations.md +7 -3
  23. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -2
  24. package/plugins/sp/skills/spur-dev/references/execution-batch.md +1 -1
  25. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +1 -1
  26. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -1
  27. package/spur.js +43097 -42239
  28. package/web/_astro/{BoardApp.hF0RAIrg.js → BoardApp.C5jnkkKP.js} +124 -124
  29. package/web/_astro/{BoardApp.Niubpe6z.js → BoardApp.CaNvQBD_.js} +1 -1
  30. package/web/_astro/{TaskDetail.DJpapt38.js → TaskDetail.DGKndhI8.js} +1 -1
  31. package/web/_astro/{arc.BgKcK0i8.js → arc.DHrwdg3G.js} +1 -1
  32. package/web/_astro/{architectureDiagram-3BPJPVTR.ChP-Ig0V.js → architectureDiagram-3BPJPVTR.pvKpK1s5.js} +1 -1
  33. package/web/_astro/{blockDiagram-GPEHLZMM.BVaCBoeN.js → blockDiagram-GPEHLZMM.BQmRcZsS.js} +1 -1
  34. package/web/_astro/{c4Diagram-AAUBKEIU.CHSDpEO5.js → c4Diagram-AAUBKEIU.CCLjHeH1.js} +1 -1
  35. package/web/_astro/channel.C7wG9RxA.js +1 -0
  36. package/web/_astro/{chunk-2J33WTMH.MYRcYVRa.js → chunk-2J33WTMH.DL6Yuoma.js} +1 -1
  37. package/web/_astro/{chunk-4BX2VUAB.S5j22Zqa.js → chunk-4BX2VUAB.CzlyEI7l.js} +1 -1
  38. package/web/_astro/{chunk-55IACEB6.gciRPvmZ.js → chunk-55IACEB6.B60EWxp1.js} +1 -1
  39. package/web/_astro/{chunk-727SXJPM.CdqrSQkX.js → chunk-727SXJPM.c7v_fRLo.js} +1 -1
  40. package/web/_astro/{chunk-AQP2D5EJ.CyokdHT3.js → chunk-AQP2D5EJ.8BxLWdDc.js} +1 -1
  41. package/web/_astro/{chunk-FMBD7UC4.Bg9KJhjW.js → chunk-FMBD7UC4.DmKuxGFT.js} +1 -1
  42. package/web/_astro/{chunk-ND2GUHAM.1gxxI-vD.js → chunk-ND2GUHAM.1Zwd4mqV.js} +1 -1
  43. package/web/_astro/{chunk-QZHKN3VN.DDEYtCKU.js → chunk-QZHKN3VN.BvYLu-A1.js} +1 -1
  44. package/web/_astro/{classDiagram-4FO5ZUOK.Ch3zi37T.js → classDiagram-4FO5ZUOK.CEqRt0WU.js} +1 -1
  45. package/web/_astro/{classDiagram-v2-Q7XG4LA2.Ch3zi37T.js → classDiagram-v2-Q7XG4LA2.CEqRt0WU.js} +1 -1
  46. package/web/_astro/{cose-bilkent-S5V4N54A.D2q5AINI.js → cose-bilkent-S5V4N54A.C7JiUdj2.js} +1 -1
  47. package/web/_astro/{cynefin-OW5HDTMX.BA9kC2YT.js → cynefin-OW5HDTMX.B6mZ-1be.js} +1 -1
  48. package/web/_astro/{dagre-BM42HDAG.CHO08nRp.js → dagre-BM42HDAG.BuryfDy6.js} +1 -1
  49. package/web/_astro/{diagram-2AECGRRQ.BfFQt0LW.js → diagram-2AECGRRQ.dao39YQM.js} +1 -1
  50. package/web/_astro/{diagram-5GNKFQAL.CHMwMTe4.js → diagram-5GNKFQAL.lnGZe4At.js} +1 -1
  51. package/web/_astro/{diagram-KO2AKTUF.BJ-7gK-w.js → diagram-KO2AKTUF.uWfhzEfT.js} +1 -1
  52. package/web/_astro/{diagram-LMA3HP47.C5xqcc0t.js → diagram-LMA3HP47.ppq8-198.js} +1 -1
  53. package/web/_astro/{diagram-OG6HWLK6.C2pQjfrC.js → diagram-OG6HWLK6.CKSCqDt5.js} +1 -1
  54. package/web/_astro/{erDiagram-TEJ5UH35._FoNyxgC.js → erDiagram-TEJ5UH35.sFSf2yBN.js} +1 -1
  55. package/web/_astro/{flowDiagram-I6XJVG4X.B_ENkT2I.js → flowDiagram-I6XJVG4X.BJQjKLbz.js} +1 -1
  56. package/web/_astro/{ganttDiagram-6RSMTGT7.B3q8h77C.js → ganttDiagram-6RSMTGT7.DAHKNj2N.js} +1 -1
  57. package/web/_astro/{gitGraphDiagram-PVQCEYII.QWZFDboZ.js → gitGraphDiagram-PVQCEYII.BlmzcW_O.js} +1 -1
  58. package/web/_astro/index.BCY1JJHd.css +1 -0
  59. package/web/_astro/{infoDiagram-5YYISTIA.Cs6eEc92.js → infoDiagram-5YYISTIA.DGhjaqsi.js} +1 -1
  60. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BU9DJMHA.js → ishikawaDiagram-YF4QCWOH.gKIpgt6F.js} +1 -1
  61. package/web/_astro/{journeyDiagram-JHISSGLW.D35k6Jpe.js → journeyDiagram-JHISSGLW.DG3tmP0R.js} +1 -1
  62. package/web/_astro/{kanban-definition-UN3LZRKU.DBqcAT4m.js → kanban-definition-UN3LZRKU.CgHDSxkR.js} +1 -1
  63. package/web/_astro/{linear.D-LSNK8W.js → linear.BuGEpcuM.js} +1 -1
  64. package/web/_astro/{mermaid.core.CSruWVOa.js → mermaid.core.DBVqkWBp.js} +4 -4
  65. package/web/_astro/{mindmap-definition-RKZ34NQL.BiFaUsfj.js → mindmap-definition-RKZ34NQL.D-kOcji9.js} +1 -1
  66. package/web/_astro/{pieDiagram-4H26LBE5.D1KTKqb9.js → pieDiagram-4H26LBE5.BvGPwtZt.js} +1 -1
  67. package/web/_astro/{quadrantDiagram-W4KKPZXB.DBHflsMG.js → quadrantDiagram-W4KKPZXB.BKb_9FV6.js} +1 -1
  68. package/web/_astro/{requirementDiagram-4Y6WPE33.hwX9CNdk.js → requirementDiagram-4Y6WPE33.Cx0fvD8v.js} +1 -1
  69. package/web/_astro/{sankeyDiagram-5OEKKPKP.DkI8mJiE.js → sankeyDiagram-5OEKKPKP.fatULi0W.js} +1 -1
  70. package/web/_astro/{sequenceDiagram-3UESZ5HK.DMz7jBDx.js → sequenceDiagram-3UESZ5HK.ChHyKqco.js} +1 -1
  71. package/web/_astro/{stateDiagram-AJRCARHV.B6Ef2xJ1.js → stateDiagram-AJRCARHV.BdUpKWyj.js} +1 -1
  72. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CgoaufB9.js → stateDiagram-v2-BHNVJYJU.fmJxqIm5.js} +1 -1
  73. package/web/_astro/{timeline-definition-PNZ67QCA.B3c8h1t-.js → timeline-definition-PNZ67QCA.BayDn5-V.js} +1 -1
  74. package/web/_astro/{vennDiagram-CIIHVFJN.BdHPAxAb.js → vennDiagram-CIIHVFJN.Bm2Aggwa.js} +1 -1
  75. package/web/_astro/{wardleyDiagram-YWT4CUSO.BrJXPPAj.js → wardleyDiagram-YWT4CUSO.DI75O-uG.js} +1 -1
  76. package/web/_astro/{xychartDiagram-2RQKCTM6.CMH5yFsV.js → xychartDiagram-2RQKCTM6.DL90E7Q7.js} +1 -1
  77. package/web/index.html +2 -2
  78. package/web/_astro/channel.DkpvN2tD.js +0 -1
  79. package/web/_astro/index.BFyW13JJ.css +0 -1
@@ -6,15 +6,9 @@ import { spawnSync } from "child_process";
6
6
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
7
7
  import { join } from "path";
8
8
  var COMMIT_GUARD_USAGE = "usage: commit-guard start --run <id> | stage --run <id> [--base <sha>] -- <paths\u2026> | check --run <id>";
9
- function isBookkeeping(path) {
10
- return path === ".spur" || path.startsWith(".spur/");
11
- }
12
- function at(options, rel) {
13
- return options.cwd ? join(options.cwd, rel) : rel;
14
- }
15
- function artifactPath(run) {
16
- return join(".spur", "run", `${run}-tree-start.json`);
17
- }
9
+ var isBookkeeping = (path) => path === ".spur" || path.startsWith(".spur/");
10
+ var at = (options, rel) => options.cwd ? join(options.cwd, rel) : rel;
11
+ var artifactPath = (run) => join(".spur", "run", `${run}-tree-start.json`);
18
12
  function gitOf(options) {
19
13
  if (options.git)
20
14
  return options.git;
@@ -57,9 +51,11 @@ function conflictMarkerLine(content) {
57
51
  }
58
52
  function changedSinceBase(base, git) {
59
53
  const tracked = git(["diff", "--name-only", base]);
54
+ if (tracked.status !== 0)
55
+ throw new Error(`git diff --name-only failed (status ${tracked.status})`);
60
56
  const others = git(["ls-files", "--others", "--exclude-standard", "-z"]);
61
- if (tracked.status !== 0 || others.status !== 0)
62
- return new Set;
57
+ if (others.status !== 0)
58
+ throw new Error(`git ls-files --others failed (status ${others.status})`);
63
59
  const clean = (out, sep) => out.split(sep).map((p) => p.trim()).filter((p) => p.length > 0 && !isBookkeeping(p));
64
60
  return new Set([...clean(tracked.stdout, `
65
61
  `), ...clean(others.stdout, "\x00")]);
@@ -119,7 +115,10 @@ function runStart(run, options = {}) {
119
115
  return 0;
120
116
  }
121
117
  function conflictingTarget(paths, options, git) {
122
- const unmerged = new Set(git(["diff", "--name-only", "--diff-filter=U"]).stdout.split(`
118
+ const probe = git(["diff", "--name-only", "--diff-filter=U"]);
119
+ if (probe.status !== 0)
120
+ throw new Error(`git diff --name-only --diff-filter=U failed (status ${probe.status})`);
121
+ const unmerged = new Set(probe.stdout.split(`
123
122
  `).map((p) => p.trim()));
124
123
  for (const path of paths) {
125
124
  if (unmerged.has(path))
@@ -131,16 +130,29 @@ function conflictingTarget(paths, options, git) {
131
130
  }
132
131
  return null;
133
132
  }
134
- function runStage(run, paths, options = {}) {
135
- if (paths.length === 0) {
136
- process.stderr.write(`${COMMIT_GUARD_USAGE}
133
+ function usageError() {
134
+ process.stderr.write(`${COMMIT_GUARD_USAGE}
137
135
  `);
136
+ return 1;
137
+ }
138
+ function probeDiagnostic(err) {
139
+ const detail = err instanceof Error ? err.message : String(err);
140
+ return `commit-guard: git probe failed (${detail}) \u2014 tree state unproven, nothing staged or reported
141
+ `;
142
+ }
143
+ function runStage(run, paths, options = {}) {
144
+ if (paths.length === 0)
145
+ return { staged: [], foreign: [], exitCode: usageError() };
146
+ let context, conflict;
147
+ try {
148
+ context = runContext(run, options);
149
+ if (!context)
150
+ return { staged: [], foreign: [], exitCode: missingStart(run) };
151
+ conflict = conflictingTarget(paths, options, context.git);
152
+ } catch (err) {
153
+ process.stderr.write(probeDiagnostic(err));
138
154
  return { staged: [], foreign: [], exitCode: 1 };
139
155
  }
140
- const context = runContext(run, options);
141
- if (!context)
142
- return { staged: [], foreign: [], exitCode: missingStart(run) };
143
- const conflict = conflictingTarget(paths, options, context.git);
144
156
  if (conflict) {
145
157
  process.stderr.write(`commit-guard: refusing to stage ${conflict}; nothing staged
146
158
  `);
@@ -165,13 +177,19 @@ function runStage(run, paths, options = {}) {
165
177
  return { staged, foreign, exitCode: refused.length > 0 ? 2 : 0 };
166
178
  }
167
179
  function runCheck(run, options = {}) {
168
- const context = runContext(run, options);
169
- if (!context)
170
- return missingStart(run);
171
- const foreign = [...changedSinceBase(context.base, context.git)].filter((path) => !context.written.has(path)).sort();
172
- process.stdout.write(`${JSON.stringify({ foreign })}
180
+ try {
181
+ const context = runContext(run, options);
182
+ if (!context)
183
+ return missingStart(run);
184
+ const { base, git, written } = context;
185
+ const foreign = [...changedSinceBase(base, git)].filter((path) => !written.has(path)).sort();
186
+ process.stdout.write(`${JSON.stringify({ foreign })}
173
187
  `);
174
- return 0;
188
+ return 0;
189
+ } catch (err) {
190
+ process.stderr.write(probeDiagnostic(err));
191
+ return 1;
192
+ }
175
193
  }
176
194
  function takeFlag(args, name) {
177
195
  const index = args.indexOf(name);
@@ -185,26 +203,18 @@ function main(argv, options = {}) {
185
203
  const args = [...argv];
186
204
  takeFlag(args, "--spur-bin");
187
205
  const sub = args.shift();
188
- const run = takeFlag(args, "--run");
189
- const base = takeFlag(args, "--base");
190
- const usage = () => {
191
- process.stderr.write(`${COMMIT_GUARD_USAGE}
192
- `);
193
- return 1;
194
- };
206
+ const run = takeFlag(args, "--run"), base = takeFlag(args, "--base");
195
207
  if (!run)
196
- return usage();
208
+ return usageError();
197
209
  if (sub === "start")
198
- return args.length === 0 ? runStart(run, options) : usage();
210
+ return args.length === 0 ? runStart(run, options) : usageError();
199
211
  if (sub === "check")
200
- return args.length === 0 ? runCheck(run, { ...options, base }) : usage();
212
+ return args.length === 0 ? runCheck(run, { ...options, base }) : usageError();
201
213
  if (sub === "stage")
202
214
  return runStage(run, args.filter((arg) => arg !== "--"), { ...options, base }).exitCode;
203
- return usage();
204
- }
205
- {
206
- process.exit(main(process.argv.slice(2)));
215
+ return usageError();
207
216
  }
217
+ process.exit(main(process.argv.slice(2)));
208
218
  export {
209
219
  COMMIT_GUARD_USAGE,
210
220
  conflictMarkerLine,
@@ -1,23 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * commit-guard — refuse a driver commit that would sweep a *foreign* writer's working-tree changes
4
- * (task 1129, feature H1). Incidents: `a94f9f431` (a batch pipeline commit swept another session's
5
- * uncommitted files) and `36f274590` (a close-out commit swept two other tasks' files through
6
- * `git add -A`). The runbooks already said "stage only what the batch wrote"; nothing enforced it.
7
- *
4
+ * (task 1129, feature H1; incidents `a94f9f431`, `36f274590` — commits swept other sessions' files).
8
5
  * Read/write `.spur/run/<id>-tree-start.json`; stage only the listed paths this run wrote; report
9
- * the ones it did not. "Written by this run" = "changed since base" (task-diffstat semantics) minus
10
- * start-dirty paths whose content still matches the start snapshot — a path dirty at start and
11
- * untouched by the run belongs to another writer. Exit codes: 0 = staged all requested · 2 = some
12
- * refused as foreign · 3 = conflict or unmerged target (nothing staged) · 1 = mis-invocation, or a
13
- * missing start snapshot (`start` first).
14
- *
6
+ * the rest. "Written by this run" = "changed since base" (task-diffstat semantics) minus start-dirty
7
+ * paths whose content still matches the snapshot — a path dirty at start and untouched by the run
8
+ * belongs to another writer. Exit codes: 0 = staged all requested · 2 = some refused as foreign ·
9
+ * 3 = conflict or unmerged target (nothing staged) · 1 = mis-invocation, a missing start snapshot
10
+ * (`start` first), or a failed Git safety probe (nothing staged/reported).
15
11
  * `node:*` builtins and relative imports only (sp-plugin-standalone); `options.git` is the test
16
12
  * seam; `.spur/` is excluded from both sets (run bookkeeping is not product work — task-diffstat).
17
- *
18
- * shortcut: a foreign edit to a path that was CLEAN at `start` is indistinguishable from this run's
19
- * own write — git records content, not authorship — so it is caught only once it is dirty in the
20
- * start snapshot. Upgrade path: a per-run write journal if that case ever recurs.
13
+ * shortcut: a foreign edit to a path CLEAN at `start` is indistinguishable from this run's own
14
+ * write — git records content, not authorship — caught only once dirty at start. Upgrade: a
15
+ * per-run write journal if that case recurs.
21
16
  */
22
17
 
23
18
  import { spawnSync } from 'node:child_process';
@@ -41,23 +36,17 @@ export interface TreeStart {
41
36
  /** Working-file blob hashes at start; `sha` is null when the path did not exist. */
42
37
  dirty: Array<{ path: string; sha: string | null }>;
43
38
  }
44
- /** `exitCode` is what `main` returns: 0 all staged · 2 some refused · 3 conflict · 1 usage. */
39
+ /** `exitCode` is what `main` returns: 0 all staged · 2 some refused · 3 conflict · 1 usage or a failed Git probe. */
45
40
  export type StageResult = { staged: string[]; foreign: string[]; exitCode: number };
46
41
 
47
42
  export const COMMIT_GUARD_USAGE =
48
43
  'usage: commit-guard start --run <id> | stage --run <id> [--base <sha>] -- <paths…> | check --run <id>';
49
44
 
50
45
  /** Run bookkeeping is not product work — neither set may contain it (task-diffstat's rule). */
51
- function isBookkeeping(path: string): boolean {
52
- return path === '.spur' || path.startsWith('.spur/');
53
- }
46
+ const isBookkeeping = (path: string): boolean => path === '.spur' || path.startsWith('.spur/');
54
47
  /** Probe/artifact paths are repo-relative; `cwd` only anchors them for tests and callers. */
55
- function at(options: GuardOptions, rel: string): string {
56
- return options.cwd ? join(options.cwd, rel) : rel;
57
- }
58
- function artifactPath(run: string): string {
59
- return join('.spur', 'run', `${run}-tree-start.json`);
60
- }
48
+ const at = (options: GuardOptions, rel: string): string => (options.cwd ? join(options.cwd, rel) : rel);
49
+ const artifactPath = (run: string): string => join('.spur', 'run', `${run}-tree-start.json`);
61
50
  function gitOf(options: GuardOptions): GitRunner {
62
51
  if (options.git) return options.git;
63
52
  return (args: string[]): GitRunResult => {
@@ -70,8 +59,7 @@ function hashPath(path: string, git: GitRunner): string | null {
70
59
  const run = git(['hash-object', '--', path]);
71
60
  return run.status === 0 ? run.stdout.trim() : null;
72
61
  }
73
- /** Paths from `git status --porcelain=v1 -z` (`XY <path>` entries; a rename's source token is
74
- * consumed, not registered). */
62
+ /** Paths from `git status --porcelain=v1 -z` (`XY <path>` entries; a rename's source token is consumed, not registered). */
75
63
  export function parsePorcelainZ(stdout: string): string[] {
76
64
  const tokens = stdout.split('\u0000');
77
65
  const paths: string[] = [];
@@ -95,11 +83,13 @@ export function conflictMarkerLine(content: string): boolean {
95
83
  }
96
84
  return opener;
97
85
  }
98
- /** "Changed since base" (task-diffstat semantics): tracked changes + untracked, minus bookkeeping. */
86
+ /** "Changed since base" (task-diffstat semantics): tracked changes + untracked, minus bookkeeping.
87
+ * A failed probe throws (1209): an empty set would certify a tree the guard never actually saw. */
99
88
  function changedSinceBase(base: string, git: GitRunner): Set<string> {
100
89
  const tracked = git(['diff', '--name-only', base]);
90
+ if (tracked.status !== 0) throw new Error(`git diff --name-only failed (status ${tracked.status})`);
101
91
  const others = git(['ls-files', '--others', '--exclude-standard', '-z']);
102
- if (tracked.status !== 0 || others.status !== 0) return new Set();
92
+ if (others.status !== 0) throw new Error(`git ls-files --others failed (status ${others.status})`);
103
93
  const clean = (out: string, sep: string): string[] =>
104
94
  out
105
95
  .split(sep)
@@ -116,8 +106,7 @@ function readTreeStart(run: string, options: GuardOptions): TreeStart | null {
116
106
  }
117
107
  return null;
118
108
  }
119
- /** Start snapshot + resolved base + the paths this run wrote: "changed since base", keeping only
120
- * start-dirty paths whose content its own edits changed. */
109
+ /** Start snapshot + resolved base + the paths this run wrote: "changed since base" minus start-dirty paths unchanged since. */
121
110
  function runContext(run: string, options: GuardOptions) {
122
111
  const git = gitOf(options);
123
112
  const start = readTreeStart(run, options);
@@ -159,13 +148,12 @@ export function runStart(run: string, options: GuardOptions = {}): number {
159
148
  renameSync(`${abs}.tmp`, abs); // atomic: a concurrent reader never sees a half-written snapshot
160
149
  return 0;
161
150
  }
162
- /** The first path that is unmerged or carries conflict markers, or null (R4, fail closed). */
151
+ /** The first path that is unmerged or carries conflict markers, or null (R4, fail closed).
152
+ * The unmerged probe is a safety gate too: a failed one throws instead of staging blind (1209 R2). */
163
153
  function conflictingTarget(paths: string[], options: GuardOptions, git: GitRunner): string | null {
164
- const unmerged = new Set(
165
- git(['diff', '--name-only', '--diff-filter=U'])
166
- .stdout.split('\n')
167
- .map((p) => p.trim()),
168
- );
154
+ const probe = git(['diff', '--name-only', '--diff-filter=U']);
155
+ if (probe.status !== 0) throw new Error(`git diff --name-only --diff-filter=U failed (status ${probe.status})`);
156
+ const unmerged = new Set(probe.stdout.split('\n').map((p) => p.trim()));
169
157
  for (const path of paths) {
170
158
  if (unmerged.has(path)) return `${path} (unmerged)`;
171
159
  try {
@@ -176,15 +164,28 @@ function conflictingTarget(paths: string[], options: GuardOptions, git: GitRunne
176
164
  }
177
165
  return null;
178
166
  }
167
+ /** Print the usage line and report exit 1 — shared by runStage's empty-path guard and `main`. */
168
+ function usageError(): number {
169
+ process.stderr.write(`${COMMIT_GUARD_USAGE}\n`);
170
+ return 1;
171
+ }
172
+ /** One diagnostic for any failed safety probe: the tree state is unproven, so nothing else happens. */
173
+ function probeDiagnostic(err: unknown): string {
174
+ const detail = err instanceof Error ? err.message : String(err);
175
+ return `commit-guard: git probe failed (${detail}) — tree state unproven, nothing staged or reported\n`;
176
+ }
179
177
  /** Stage only the listed paths this run wrote (R2) and print `{staged, foreign}` (AC1). */
180
178
  export function runStage(run: string, paths: string[], options: GuardOptions = {}): StageResult {
181
- if (paths.length === 0) {
182
- process.stderr.write(`${COMMIT_GUARD_USAGE}\n`);
179
+ if (paths.length === 0) return { staged: [], foreign: [], exitCode: usageError() };
180
+ let context: ReturnType<typeof runContext>, conflict: string | null;
181
+ try {
182
+ context = runContext(run, options);
183
+ if (!context) return { staged: [], foreign: [], exitCode: missingStart(run) };
184
+ conflict = conflictingTarget(paths, options, context.git);
185
+ } catch (err) {
186
+ process.stderr.write(probeDiagnostic(err)); // 1209 R2: git add is never reached after a failed probe
183
187
  return { staged: [], foreign: [], exitCode: 1 };
184
188
  }
185
- const context = runContext(run, options);
186
- if (!context) return { staged: [], foreign: [], exitCode: missingStart(run) };
187
- const conflict = conflictingTarget(paths, options, context.git);
188
189
  if (conflict) {
189
190
  process.stderr.write(`commit-guard: refusing to stage ${conflict}; nothing staged\n`);
190
191
  return { staged: [], foreign: [], exitCode: 3 };
@@ -206,13 +207,17 @@ export function runStage(run: string, paths: string[], options: GuardOptions = {
206
207
  }
207
208
  /** Report the working-tree changes this run did not write, staging nothing (R3). */
208
209
  export function runCheck(run: string, options: GuardOptions = {}): number {
209
- const context = runContext(run, options);
210
- if (!context) return missingStart(run);
211
- const foreign = [...changedSinceBase(context.base, context.git)]
212
- .filter((path) => !context.written.has(path))
213
- .sort();
214
- process.stdout.write(`${JSON.stringify({ foreign })}\n`);
215
- return 0;
210
+ try {
211
+ const context = runContext(run, options); // first changed-path scan
212
+ if (!context) return missingStart(run);
213
+ const { base, git, written } = context; // second changed-path scan — guarded too (1209 R1)
214
+ const foreign = [...changedSinceBase(base, git)].filter((path) => !written.has(path)).sort();
215
+ process.stdout.write(`${JSON.stringify({ foreign })}\n`);
216
+ return 0;
217
+ } catch (err) {
218
+ process.stderr.write(probeDiagnostic(err)); // a failed probe emits no success JSON (R1)
219
+ return 1;
220
+ }
216
221
  }
217
222
  /** Pull one `--flag value` pair out of argv, or undefined when absent (a missing value is usage). */
218
223
  function takeFlag(args: string[], name: string): string | undefined {
@@ -226,24 +231,18 @@ export function main(argv: string[], options: GuardOptions = {}): number {
226
231
  const args = [...argv];
227
232
  takeFlag(args, '--spur-bin'); // 0482 R2: pipelines hand every plugin script this flag
228
233
  const sub = args.shift();
229
- const run = takeFlag(args, '--run');
230
- const base = takeFlag(args, '--base');
231
- const usage = (): number => {
232
- process.stderr.write(`${COMMIT_GUARD_USAGE}\n`);
233
- return 1;
234
- };
235
- if (!run) return usage();
236
- if (sub === 'start') return args.length === 0 ? runStart(run, options) : usage();
237
- if (sub === 'check') return args.length === 0 ? runCheck(run, { ...options, base }) : usage();
234
+ const run = takeFlag(args, '--run'),
235
+ base = takeFlag(args, '--base');
236
+ if (!run) return usageError();
237
+ if (sub === 'start') return args.length === 0 ? runStart(run, options) : usageError();
238
+ if (sub === 'check') return args.length === 0 ? runCheck(run, { ...options, base }) : usageError();
238
239
  if (sub === 'stage')
239
240
  return runStage(
240
241
  run,
241
242
  args.filter((arg) => arg !== '--'),
242
243
  { ...options, base },
243
244
  ).exitCode;
244
- return usage();
245
+ return usageError();
245
246
  }
246
247
 
247
- if (import.meta.main) {
248
- process.exit(main(process.argv.slice(2)));
249
- }
248
+ if (import.meta.main) process.exit(main(process.argv.slice(2)));
@@ -9,6 +9,7 @@ var EVIDENCE_DIR = join(".spur", "memory", "evidence");
9
9
  var RUN_DIR = join(".spur", "run");
10
10
  var NAMED_CAP = 32;
11
11
  var PREFIX_CAP = 64;
12
+ var DURABLE_FAMILY_RE = /^\d{4}-(?:verdict|feature-verification)\.json$/;
12
13
  function wbsOfTaskFile(path) {
13
14
  const m = /^(\d+)_/.exec(basename(path));
14
15
  return m?.[1] ?? null;
@@ -49,7 +50,7 @@ function listObligations(wtRoot, wbsList, runIds) {
49
50
  const evDir = join(wtRoot, EVIDENCE_DIR);
50
51
  const runDir = join(wtRoot, RUN_DIR);
51
52
  const evFiles = walkFiles(evDir, wtRoot);
52
- const runFiles = existsSync(runDir) ? readdirSync(runDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => join(RUN_DIR, e.name)).sort() : [];
53
+ const runFiles = walkFiles(runDir, wtRoot);
53
54
  const files = [];
54
55
  const counts = new Map;
55
56
  if (!collectOwned(evFiles, prefixes, counts, files))
@@ -57,7 +58,7 @@ function listObligations(wtRoot, wbsList, runIds) {
57
58
  if (!collectOwned(runFiles, prefixes, counts, files))
58
59
  return null;
59
60
  const unowned = evFiles.length - files.filter((f) => f.startsWith(EVIDENCE_DIR)).length;
60
- return { files: [...new Set(files)].sort(), unowned };
61
+ return { files: [...new Set(files)].sort(), unowned, planeFiles: [...evFiles, ...runFiles] };
61
62
  }
62
63
  function compareTrees(wtRoot, invokeRoot, files, foreignDivergent = new Set) {
63
64
  const missing = [];
@@ -128,13 +129,13 @@ function main(argv, invokeRoot = defaultInvokeRoot(process.cwd())) {
128
129
  }
129
130
  if (wbsList.length === 0 && runIds.length === 0) {
130
131
  process.stderr.write(`${persistOutCheckUsage()}
131
- persist-out-check: at least one --task-file or --run-id prefix required
132
+ persist-out-check: at least one --task-file or --run-id required
132
133
  `);
133
134
  return 2;
134
135
  }
135
136
  if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
136
137
  process.stderr.write(`${persistOutCheckUsage()}
137
- persist-out-check: --from must be an existing worktree directory
138
+ persist-out-check: --from must be an existing directory
138
139
  `);
139
140
  return 2;
140
141
  }
@@ -146,27 +147,27 @@ persist-out-check: --from must be an existing worktree directory
146
147
  join(wtRoot, ".spur", "run", "persist-out.json")
147
148
  ].filter(Boolean);
148
149
  for (const cand of candidates) {
149
- if (existsSync(cand)) {
150
- try {
151
- const parsed = JSON.parse(readFileSync(cand, "utf8"));
152
- if (Array.isArray(parsed.evidenceSkipped)) {
153
- for (const item of parsed.evidenceSkipped) {
154
- if (item.reason === "foreign-divergent" && typeof item.name === "string") {
155
- foreignDivergent.add(item.name);
156
- foreignDivergent.add(join(EVIDENCE_DIR, item.name));
157
- }
150
+ if (!existsSync(cand))
151
+ continue;
152
+ try {
153
+ const parsed = JSON.parse(readFileSync(cand, "utf8"));
154
+ if (Array.isArray(parsed.evidenceSkipped)) {
155
+ for (const item of parsed.evidenceSkipped) {
156
+ if (item.reason === "foreign-divergent" && typeof item.name === "string") {
157
+ foreignDivergent.add(item.name);
158
+ foreignDivergent.add(join(EVIDENCE_DIR, item.name));
158
159
  }
159
160
  }
160
- if (Array.isArray(parsed.skipped)) {
161
- for (const item of parsed.skipped) {
162
- if (typeof item.id === "string" && typeof item.reason === "string") {
163
- skips.push({ id: item.id, reason: item.reason });
164
- }
161
+ }
162
+ if (Array.isArray(parsed.skipped)) {
163
+ for (const item of parsed.skipped) {
164
+ if (typeof item.id === "string" && typeof item.reason === "string") {
165
+ skips.push({ id: item.id, reason: item.reason });
165
166
  }
166
167
  }
167
- break;
168
- } catch {}
169
- }
168
+ }
169
+ break;
170
+ } catch {}
170
171
  }
171
172
  const obligations = listObligations(wtRoot, wbsList, runIds);
172
173
  if (obligations === null) {
@@ -174,11 +175,14 @@ persist-out-check: --from must be an existing worktree directory
174
175
  `);
175
176
  return 1;
176
177
  }
177
- const { files, unowned } = obligations;
178
+ const { files, unowned, planeFiles } = obligations;
179
+ const unforwarded = planeFiles.filter((rel) => DURABLE_FAMILY_RE.test(basename(rel))).filter((rel) => !files.includes(rel) && !foreignDivergent.has(rel) && !foreignDivergent.has(basename(rel)));
180
+ const unforwardedFindings = unforwarded.map((f) => `UNFORWARDED ${f} \u2014 durable verdict/receipt outside the forwarded prefixes`);
178
181
  const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files, foreignDivergent);
179
182
  const findings = [
180
183
  ...missing.map((f) => `MISSING ${f}`),
181
- ...divergent.map((f) => `DIVERGENT ${f} \u2014 reconcile by hand (persist-out never overwrites)`)
184
+ ...divergent.map((f) => `DIVERGENT ${f} \u2014 reconcile by hand (persist-out never overwrites)`),
185
+ ...unforwardedFindings
182
186
  ];
183
187
  for (const line of findings.slice(0, NAMED_CAP))
184
188
  process.stdout.write(`${line}
@@ -196,7 +200,7 @@ persist-out-check: --from must be an existing worktree directory
196
200
  `);
197
201
  return 0;
198
202
  }
199
- 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
203
+ process.stderr.write(`persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent` + `${unforwarded.length > 0 ? `, ${unforwarded.length} unforwarded durable verdict/receipt` : ""}` + ` \u2014 run inline-run-setup --persist-out --from <worktree> (--task-file <wbs>_<slug>.md) before worktree removal
200
204
  `);
201
205
  return 1;
202
206
  }
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * persist-out-check — WT-4 pre-removal assertion for E71 persist-out (task 1067 R1, 1148 R1).
4
- *
5
4
  * Before `git worktree remove`, verify the worktree's OWNED durable evidence would not be abandoned:
6
5
  * every `.spur/run/` and `.spur/memory/evidence/` file starting with a forwarded `<wbs>-`/`<runId>-`
7
6
  * prefix must exist in the invoking tree with identical bytes. Unowned evidence is counted
@@ -17,6 +16,9 @@ const EVIDENCE_DIR = join('.spur', 'memory', 'evidence');
17
16
  const RUN_DIR = join('.spur', 'run');
18
17
  const NAMED_CAP = 32;
19
18
  const PREFIX_CAP = 64;
19
+ /** The durable-verdict family persist-out itself transfers (1026/1043) — the one artifact class whose
20
+ * silent abandonment WT-4 exists to prevent, even when the driver forwarded no matching prefix. */
21
+ const DURABLE_FAMILY_RE = /^\d{4}-(?:verdict|feature-verification)\.json$/;
20
22
 
21
23
  /** Task files own their `<wbs>-*` evidence: leading digits before `_` in the basename. */
22
24
  export function wbsOfTaskFile(path: string): string | null {
@@ -27,6 +29,8 @@ export function wbsOfTaskFile(path: string): string | null {
27
29
  export interface ListObligationsResult {
28
30
  files: string[];
29
31
  unowned: number;
32
+ /** Every file seen in both planes (recursive) — lets callers audit outside the forwarded prefixes. */
33
+ planeFiles: string[];
30
34
  }
31
35
 
32
36
  /** Recursively list files under `dir`, relative to `root`. */
@@ -58,8 +62,9 @@ function collectOwned(rels: string[], prefixes: string[], counts: Map<string, nu
58
62
  }
59
63
 
60
64
  /**
61
- * Evidence the worktree owns: `.spur/memory/evidence/` (recursive) and `.spur/run/` (flat) files
62
- * whose basename starts with a forwarded `<wbs>-`/`<runId>-` prefix. Evidence matching no prefix is
65
+ * Evidence the worktree owns: `.spur/memory/evidence/` (recursive) and `.spur/run/` (recursive — the
66
+ * driver keeps run scratch under `.spur/run/<run-id>/`, which a flat scan silently missed) files whose
67
+ * basename starts with a forwarded `<wbs>-`/`<runId>-` prefix. Evidence matching no prefix is
63
68
  * counted as `unowned` (1148 R1). Null when no prefix is forwarded or a prefix cap is exceeded.
64
69
  */
65
70
  export function listObligations(wtRoot: string, wbsList: string[], runIds: string[]): ListObligationsResult | null {
@@ -69,19 +74,14 @@ export function listObligations(wtRoot: string, wbsList: string[], runIds: strin
69
74
  const evDir = join(wtRoot, EVIDENCE_DIR);
70
75
  const runDir = join(wtRoot, RUN_DIR);
71
76
  const evFiles = walkFiles(evDir, wtRoot);
72
- const runFiles = existsSync(runDir)
73
- ? readdirSync(runDir, { withFileTypes: true })
74
- .filter((e) => e.isFile())
75
- .map((e) => join(RUN_DIR, e.name))
76
- .sort()
77
- : [];
77
+ const runFiles = walkFiles(runDir, wtRoot);
78
78
 
79
79
  const files: string[] = [];
80
80
  const counts = new Map<string, number>();
81
81
  if (!collectOwned(evFiles, prefixes, counts, files)) return null;
82
82
  if (!collectOwned(runFiles, prefixes, counts, files)) return null;
83
83
  const unowned = evFiles.length - files.filter((f) => f.startsWith(EVIDENCE_DIR)).length;
84
- return { files: [...new Set(files)].sort(), unowned };
84
+ return { files: [...new Set(files)].sort(), unowned, planeFiles: [...evFiles, ...runFiles] };
85
85
  }
86
86
 
87
87
  /** Classify obligations against the invoking tree: missing, divergent (byte-differs), or ok. */
@@ -164,14 +164,12 @@ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd(
164
164
  }
165
165
  if (wbsList.length === 0 && runIds.length === 0) {
166
166
  process.stderr.write(
167
- `${persistOutCheckUsage()}\npersist-out-check: at least one --task-file or --run-id prefix required\n`,
167
+ `${persistOutCheckUsage()}\npersist-out-check: at least one --task-file or --run-id required\n`,
168
168
  );
169
169
  return 2;
170
170
  }
171
171
  if (!wtRoot || !existsSync(wtRoot) || !statSync(wtRoot).isDirectory()) {
172
- process.stderr.write(
173
- `${persistOutCheckUsage()}\npersist-out-check: --from must be an existing worktree directory\n`,
174
- );
172
+ process.stderr.write(`${persistOutCheckUsage()}\npersist-out-check: --from must be an existing directory\n`);
175
173
  return 2;
176
174
  }
177
175
  const foreignDivergent = new Set<string>();
@@ -182,27 +180,26 @@ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd(
182
180
  join(wtRoot, '.spur', 'run', 'persist-out.json'),
183
181
  ].filter(Boolean);
184
182
  for (const cand of candidates) {
185
- if (existsSync(cand)) {
186
- try {
187
- const parsed = JSON.parse(readFileSync(cand, 'utf8'));
188
- if (Array.isArray(parsed.evidenceSkipped)) {
189
- for (const item of parsed.evidenceSkipped) {
190
- if (item.reason === 'foreign-divergent' && typeof item.name === 'string') {
191
- foreignDivergent.add(item.name);
192
- foreignDivergent.add(join(EVIDENCE_DIR, item.name));
193
- }
183
+ if (!existsSync(cand)) continue;
184
+ try {
185
+ const parsed = JSON.parse(readFileSync(cand, 'utf8'));
186
+ if (Array.isArray(parsed.evidenceSkipped)) {
187
+ for (const item of parsed.evidenceSkipped) {
188
+ if (item.reason === 'foreign-divergent' && typeof item.name === 'string') {
189
+ foreignDivergent.add(item.name);
190
+ foreignDivergent.add(join(EVIDENCE_DIR, item.name));
194
191
  }
195
192
  }
196
- if (Array.isArray(parsed.skipped)) {
197
- for (const item of parsed.skipped) {
198
- if (typeof item.id === 'string' && typeof item.reason === 'string') {
199
- skips.push({ id: item.id, reason: item.reason });
200
- }
193
+ }
194
+ if (Array.isArray(parsed.skipped)) {
195
+ for (const item of parsed.skipped) {
196
+ if (typeof item.id === 'string' && typeof item.reason === 'string') {
197
+ skips.push({ id: item.id, reason: item.reason });
201
198
  }
202
199
  }
203
- break;
204
- } catch {}
205
- }
200
+ }
201
+ break;
202
+ } catch {}
206
203
  }
207
204
  const obligations = listObligations(wtRoot, wbsList, runIds);
208
205
  if (obligations === null) {
@@ -211,11 +208,21 @@ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd(
211
208
  );
212
209
  return 1;
213
210
  }
214
- const { files, unowned } = obligations;
211
+ const { files, unowned, planeFiles } = obligations;
212
+ // Session-incident class (runall-1208-1209-bdcd): the driver forwarded only the batch run id, no
213
+ // prefix matched, and the check passed at 0 obligations while the worktree held its verdicts. Family
214
+ // files outside the prefixes (not foreign-divergent) block teardown; non-family scratch never does.
215
+ const unforwarded = planeFiles
216
+ .filter((rel) => DURABLE_FAMILY_RE.test(basename(rel)))
217
+ .filter((rel) => !files.includes(rel) && !foreignDivergent.has(rel) && !foreignDivergent.has(basename(rel)));
218
+ const unforwardedFindings = unforwarded.map(
219
+ (f) => `UNFORWARDED ${f} — durable verdict/receipt outside the forwarded prefixes`,
220
+ );
215
221
  const { missing, divergent, ok } = compareTrees(wtRoot, invokeRoot, files, foreignDivergent);
216
222
  const findings = [
217
223
  ...missing.map((f) => `MISSING ${f}`),
218
224
  ...divergent.map((f) => `DIVERGENT ${f} — reconcile by hand (persist-out never overwrites)`),
225
+ ...unforwardedFindings,
219
226
  ];
220
227
  for (const line of findings.slice(0, NAMED_CAP)) process.stdout.write(`${line}\n`);
221
228
  if (findings.length > NAMED_CAP) process.stdout.write(`… +${findings.length - NAMED_CAP} more\n`);
@@ -230,7 +237,9 @@ export function main(argv: string[], invokeRoot = defaultInvokeRoot(process.cwd(
230
237
  return 0;
231
238
  }
232
239
  process.stderr.write(
233
- `persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent — run inline-run-setup --persist-out --from <worktree> before worktree removal\n`,
240
+ `persist-out-check: BLOCKED ${missing.length} missing, ${divergent.length} divergent` +
241
+ `${unforwarded.length > 0 ? `, ${unforwarded.length} unforwarded durable verdict/receipt` : ''}` +
242
+ ' — run inline-run-setup --persist-out --from <worktree> (--task-file <wbs>_<slug>.md) before worktree removal\n',
234
243
  );
235
244
  return 1;
236
245
  }
@@ -324,7 +324,7 @@ node "$RESIDUAL" fold <wbs>
324
324
  > `review` coordinator (`/sp:dev-review` → `sp:super-reviewer`), which merges
325
325
  > `functional-review` + `code-verification` review mode + `code-improvement` fragments. The
326
326
  > `record` step backfills `## Review` from the verdict artifact **only** if the section is bare
327
- > (`sectionIsBare` guard, `task-service.ts`) — a standalone compatibility fallback, never an
327
+ > (`sectionIsBare` guard, `task-section-bodies.ts`) — a standalone compatibility fallback, never an
328
328
  > overwrite of authored Review. Writing `## Review` here destroys the review step's
329
329
  > three-dimensional findings.
330
330
 
@@ -103,9 +103,11 @@ is part of the contract so weak proof is visible to the pipeline and to reviewer
103
103
  - `n/a`: explicitly justified non-applicability.
104
104
 
105
105
  Every CORE behavior-bearing AC requires executable evidence: at least one `test` or `command` row.
106
- `spur task verdict` treats AC rows as core and behavior-bearing by default; add `[advisory]`,
107
- `[non-core]`, `[non-behavior]`, or `[docs-only]` in the AC id only when that weaker rule is
108
- intentional. A MET behavior AC with only `static-ref`, `manual-review`, or `llm-judge` evidence is
106
+ `spur task verdict` treats AC rows as core and behavior-bearing by default. Only the task author
107
+ can exempt an AC: the `[advisory]`, `[non-core]`, `[non-behavior]` or `[docs-only]` tag must be on
108
+ the AC item in the task file's `## Acceptance Criteria`. The verifier must not add these tags to
109
+ answer rows; a tag that appears only in the answer is ignored and flagged by an advisory
110
+ `ac-exemption-not-in-task` check. A MET behavior AC with only `static-ref`, `manual-review`, or `llm-judge` evidence is
109
111
  downgraded to `PARTIAL` and emits an `evidence-rule-failed` check. CLI-surface changes should also
110
112
  emit a `cli-golden-path-present` check backed by a golden-path `--json` command invocation.
111
113
 
@@ -159,7 +161,7 @@ every Testing/Solution anchor against the repo root. A basename citation — `de
159
161
  `spur task update <wbs> done`, because the gate cannot tell which file the row meant. Write the
160
162
  repo-root-relative form from the first pass:
161
163
 
162
- - Right: `packages/app/src/decision/decision-events.ts:83`, `apps/cli/tests/commands/task.test.ts:42`
164
+ - Right: `packages/app/src/decision/decision-events.ts:83`, `apps/cli/tests/commands/task/verdict.test.ts:42`
163
165
  - Wrong: `decision-events.ts:83`, `../services/foo.ts:12`, `@gobing-ai/ts-x/src/persistence.ts:108`
164
166
  (a scoped package is never repo-relative — use the external named-origin form below)
165
167