@gobing-ai/spur 0.4.5 → 0.4.6
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.example.yaml +3 -4
- package/config/config.global.yaml +3 -4
- package/config/rules/quality/coverage-gate.yaml +6 -4
- package/config/transition-shims.json +0 -14
- package/package.json +1 -1
- package/plugins/sp/lib/inline-run.generated.mjs +1 -1
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/commit-guard.mjs +49 -39
- package/plugins/sp/scripts/commit-guard.ts +60 -61
- package/plugins/sp/scripts/persist-out-check.mjs +28 -24
- package/plugins/sp/scripts/persist-out-check.ts +42 -33
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +5 -3
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +4 -2
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +3 -1
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -2
- package/spur.js +155 -48
- package/web/_astro/{BoardApp.hF0RAIrg.js → BoardApp.C5jnkkKP.js} +124 -124
- package/web/_astro/{BoardApp.Niubpe6z.js → BoardApp.CaNvQBD_.js} +1 -1
- package/web/_astro/{TaskDetail.DJpapt38.js → TaskDetail.DGKndhI8.js} +1 -1
- package/web/_astro/{arc.BgKcK0i8.js → arc.DHrwdg3G.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.ChP-Ig0V.js → architectureDiagram-3BPJPVTR.pvKpK1s5.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.BVaCBoeN.js → blockDiagram-GPEHLZMM.BQmRcZsS.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.CHSDpEO5.js → c4Diagram-AAUBKEIU.CCLjHeH1.js} +1 -1
- package/web/_astro/channel.C7wG9RxA.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.MYRcYVRa.js → chunk-2J33WTMH.DL6Yuoma.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.S5j22Zqa.js → chunk-4BX2VUAB.CzlyEI7l.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.gciRPvmZ.js → chunk-55IACEB6.B60EWxp1.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.CdqrSQkX.js → chunk-727SXJPM.c7v_fRLo.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.CyokdHT3.js → chunk-AQP2D5EJ.8BxLWdDc.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Bg9KJhjW.js → chunk-FMBD7UC4.DmKuxGFT.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.1gxxI-vD.js → chunk-ND2GUHAM.1Zwd4mqV.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.DDEYtCKU.js → chunk-QZHKN3VN.BvYLu-A1.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.Ch3zi37T.js → classDiagram-4FO5ZUOK.CEqRt0WU.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.Ch3zi37T.js → classDiagram-v2-Q7XG4LA2.CEqRt0WU.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.D2q5AINI.js → cose-bilkent-S5V4N54A.C7JiUdj2.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.BA9kC2YT.js → cynefin-OW5HDTMX.B6mZ-1be.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.CHO08nRp.js → dagre-BM42HDAG.BuryfDy6.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.BfFQt0LW.js → diagram-2AECGRRQ.dao39YQM.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.CHMwMTe4.js → diagram-5GNKFQAL.lnGZe4At.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.BJ-7gK-w.js → diagram-KO2AKTUF.uWfhzEfT.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.C5xqcc0t.js → diagram-LMA3HP47.ppq8-198.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.C2pQjfrC.js → diagram-OG6HWLK6.CKSCqDt5.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35._FoNyxgC.js → erDiagram-TEJ5UH35.sFSf2yBN.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.B_ENkT2I.js → flowDiagram-I6XJVG4X.BJQjKLbz.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.B3q8h77C.js → ganttDiagram-6RSMTGT7.DAHKNj2N.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.QWZFDboZ.js → gitGraphDiagram-PVQCEYII.BlmzcW_O.js} +1 -1
- package/web/_astro/index.BCY1JJHd.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.Cs6eEc92.js → infoDiagram-5YYISTIA.DGhjaqsi.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.BU9DJMHA.js → ishikawaDiagram-YF4QCWOH.gKIpgt6F.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.D35k6Jpe.js → journeyDiagram-JHISSGLW.DG3tmP0R.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DBqcAT4m.js → kanban-definition-UN3LZRKU.CgHDSxkR.js} +1 -1
- package/web/_astro/{linear.D-LSNK8W.js → linear.BuGEpcuM.js} +1 -1
- package/web/_astro/{mermaid.core.CSruWVOa.js → mermaid.core.DBVqkWBp.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BiFaUsfj.js → mindmap-definition-RKZ34NQL.D-kOcji9.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.D1KTKqb9.js → pieDiagram-4H26LBE5.BvGPwtZt.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.DBHflsMG.js → quadrantDiagram-W4KKPZXB.BKb_9FV6.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.hwX9CNdk.js → requirementDiagram-4Y6WPE33.Cx0fvD8v.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DkI8mJiE.js → sankeyDiagram-5OEKKPKP.fatULi0W.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.DMz7jBDx.js → sequenceDiagram-3UESZ5HK.ChHyKqco.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.B6Ef2xJ1.js → stateDiagram-AJRCARHV.BdUpKWyj.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.CgoaufB9.js → stateDiagram-v2-BHNVJYJU.fmJxqIm5.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.B3c8h1t-.js → timeline-definition-PNZ67QCA.BayDn5-V.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.BdHPAxAb.js → vennDiagram-CIIHVFJN.Bm2Aggwa.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.BrJXPPAj.js → wardleyDiagram-YWT4CUSO.DI75O-uG.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.CMH5yFsV.js → xychartDiagram-2RQKCTM6.DL90E7Q7.js} +1 -1
- package/web/index.html +2 -2
- package/web/_astro/channel.DkpvN2tD.js +0 -1
- 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
|
-
|
|
10
|
-
|
|
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 (
|
|
62
|
-
|
|
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
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
208
|
+
return usageError();
|
|
197
209
|
if (sub === "start")
|
|
198
|
-
return args.length === 0 ? runStart(run, options) :
|
|
210
|
+
return args.length === 0 ? runStart(run, options) : usageError();
|
|
199
211
|
if (sub === "check")
|
|
200
|
-
return args.length === 0 ? runCheck(run, { ...options, base }) :
|
|
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
|
|
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
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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 (
|
|
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"
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
.sort();
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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/` (
|
|
62
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
107
|
-
`[non-core]`, `[non-behavior]
|
|
108
|
-
|
|
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
|
|
|
@@ -377,8 +377,10 @@ rows), there is no checklist label: cite each row by its exact scenario title
|
|
|
377
377
|
matches nothing and the verdict is rejected.
|
|
378
378
|
|
|
379
379
|
**Evidence rule (behavior-bearing AC):** an AC row with `status: MET` on a behavior-bearing id
|
|
380
|
-
(no `[advisory]`/`[non-core]`/`[non-behavior]`/`[docs-only]` marker
|
|
381
|
-
`command` evidence; any other evidence type downgrades the row to
|
|
380
|
+
(no `[advisory]`/`[non-core]`/`[non-behavior]`/`[docs-only]` marker **on the AC item in the task
|
|
381
|
+
file**) MUST carry `test` or `command` evidence; any other evidence type downgrades the row to
|
|
382
|
+
`PARTIAL` and caps the verdict. Tags written only in the answer are ignored and reported by the
|
|
383
|
+
advisory `ac-exemption-not-in-task` check.
|
|
382
384
|
|
|
383
385
|
**Worked example** — a minimal PASS-producing answer file:
|
|
384
386
|
|