@gobing-ai/spur 0.4.4 → 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/docs/sourced-api-claims.yaml +3 -2
- package/config/rules/quality/coverage-gate.yaml +6 -4
- package/config/rules/recommended-pre-check.yaml +11 -9
- package/config/transition-shims.json +0 -14
- package/config/workflows/task-pipeline.yaml +31 -71
- package/package.json +1 -1
- package/plugins/sp/agents/super-reviewer.md +11 -27
- package/plugins/sp/commands/dev-review.md +1 -1
- package/plugins/sp/lib/inline-run.generated.mjs +3 -3
- package/plugins/sp/lib/residual-scan.generated.d.mts +0 -13
- package/plugins/sp/lib/residual-scan.generated.mjs +3 -47
- 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/scripts/residual-scan.mjs +5 -50
- package/plugins/sp/scripts/residual-scan.ts +4 -6
- package/plugins/sp/skills/code-verification/SKILL.md +1 -2
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +16 -35
- 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/plugins/sp/skills/spur-dev/references/execution-workflow.md +7 -11
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +7 -6
- package/spur.js +422 -431
- 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/config/rules/docs/verdict-confidence.yaml +0 -68
- package/web/_astro/channel.DkpvN2tD.js +0 -1
- package/web/_astro/index.BFyW13JJ.css +0 -1
|
@@ -47,8 +47,6 @@ export declare interface ParsedScanArgs {
|
|
|
47
47
|
spurBin?: string;
|
|
48
48
|
root: string;
|
|
49
49
|
tmpDir: string;
|
|
50
|
-
/** The --review-answer <path> flag: the review answer the review-gate mode grades (task 1204). */
|
|
51
|
-
reviewAnswer?: string;
|
|
52
50
|
}
|
|
53
51
|
export declare const ALLOW_PRAGMA: 'residual-scan:allow';
|
|
54
52
|
export declare function recordedVerdictPath(runDir: string, wbs: string, fs: Pick<typeof import('node:fs'), 'lstatSync' | 'existsSync'>): string;
|
|
@@ -70,21 +68,10 @@ export declare function scanResiduals(inputs: ResidualScanInputs): ResidualArtif
|
|
|
70
68
|
export declare function parseDeferralEntries(raw: unknown): Deferral[];
|
|
71
69
|
export declare function blockingReviewFindings(taskContent: string, deferrals: Deferral[]): ResidualItem[];
|
|
72
70
|
export declare function reviewContractViolations(taskContent: string): Array<{ location: string; text: string }>;
|
|
73
|
-
export declare function reviewConfidenceViolation(
|
|
74
|
-
reviewAnswer: string,
|
|
75
|
-
ackLowConfidence: string | undefined,
|
|
76
|
-
): { location: string; text: string } | null;
|
|
77
|
-
export declare function reviewGradingInput(
|
|
78
|
-
root: string,
|
|
79
|
-
reviewAnswer: string | undefined,
|
|
80
|
-
ackLowConfidence: string | undefined,
|
|
81
|
-
fs: Pick<typeof import('node:fs'), 'readFileSync' | 'statSync'>,
|
|
82
|
-
): { answer: string; ackLowConfidence: string | undefined } | undefined;
|
|
83
71
|
export declare function buildReviewGateArtifact(
|
|
84
72
|
wbs: string,
|
|
85
73
|
taskContent: string,
|
|
86
74
|
deferrals: Deferral[],
|
|
87
|
-
review?: { answer: string; ackLowConfidence: string | undefined },
|
|
88
75
|
): { artifact: ResidualArtifact; note: string };
|
|
89
76
|
export declare function parseScanArgs(
|
|
90
77
|
argv: readonly (string | undefined)[],
|
|
@@ -21,14 +21,6 @@ function hasClaimMarker(evidence) {
|
|
|
21
21
|
function hasCitationForm(evidence) {
|
|
22
22
|
return CITATION_FORMS.some((re) => re.test(evidence));
|
|
23
23
|
}
|
|
24
|
-
var CONFIDENCE_LEVELS = ["HIGH", "MEDIUM", "LOW"];
|
|
25
|
-
function extractAnswerConfidence(text) {
|
|
26
|
-
const matches = [...text.matchAll(/^\s*Confidence:\s*(\S+)\s*$/gim)];
|
|
27
|
-
if (matches.length !== 1)
|
|
28
|
-
return;
|
|
29
|
-
const value = (matches[0]?.[1] ?? "").toUpperCase();
|
|
30
|
-
return CONFIDENCE_LEVELS.includes(value) ? value : undefined;
|
|
31
|
-
}
|
|
32
24
|
|
|
33
25
|
// packages/app/src/services/residual-scan.ts
|
|
34
26
|
var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
|
|
@@ -202,13 +194,6 @@ function blockingReviewFindings(taskContent, deferrals) {
|
|
|
202
194
|
}
|
|
203
195
|
function reviewContractViolations(taskContent) {
|
|
204
196
|
const violations = [];
|
|
205
|
-
const body = reviewBody(taskContent);
|
|
206
|
-
if (body !== null && extractAnswerConfidence(body.replace(/\*\*/g, "")) === undefined) {
|
|
207
|
-
violations.push({
|
|
208
|
-
location: "task-file:Review",
|
|
209
|
-
text: "Review report carries no single valid `**Confidence:** HIGH | MEDIUM | LOW` line — " + "state the level the report can defend (HIGH: every requirement row MET, every finding " + "dispositioned, evidence cited; MEDIUM/LOW: name the gap)."
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
197
|
for (const row of parseReviewFindings(taskContent)) {
|
|
213
198
|
if (hasClaimMarker(row.text) && !hasCitationForm(row.text)) {
|
|
214
199
|
violations.push({
|
|
@@ -219,32 +204,8 @@ function reviewContractViolations(taskContent) {
|
|
|
219
204
|
}
|
|
220
205
|
return violations;
|
|
221
206
|
}
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
return null;
|
|
225
|
-
const level = extractAnswerConfidence(reviewAnswer.replace(/\*\*/g, ""));
|
|
226
|
-
if (level === "HIGH" || level === "MEDIUM")
|
|
227
|
-
return null;
|
|
228
|
-
return {
|
|
229
|
-
location: "review-answer:Confidence",
|
|
230
|
-
text: level === undefined ? "The review answer carries no valid `**Confidence:** HIGH | MEDIUM | LOW` line — a " + "PASS must be graded, or the operator must set ackLowConfidence" : `The review answer graded its PASS ${level} — HIGH/MEDIUM cross unadorned, ` + "LOW/absent needs the operator’s ackLowConfidence"
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
function reviewGradingInput(root, reviewAnswer, ackLowConfidence, fs) {
|
|
234
|
-
if (reviewAnswer === undefined)
|
|
235
|
-
return;
|
|
236
|
-
const path = join(root, reviewAnswer);
|
|
237
|
-
let isFile = false;
|
|
238
|
-
try {
|
|
239
|
-
isFile = fs.statSync(path).isFile();
|
|
240
|
-
} catch {
|
|
241
|
-
isFile = false;
|
|
242
|
-
}
|
|
243
|
-
return { answer: isFile ? fs.readFileSync(path, "utf8") : "", ackLowConfidence };
|
|
244
|
-
}
|
|
245
|
-
function buildReviewGateArtifact(wbs, taskContent, deferrals, review) {
|
|
246
|
-
const confidence = review === undefined ? null : reviewConfidenceViolation(review.answer, review.ackLowConfidence);
|
|
247
|
-
const contractViolations = [...reviewContractViolations(taskContent), ...confidence === null ? [] : [confidence]];
|
|
207
|
+
function buildReviewGateArtifact(wbs, taskContent, deferrals) {
|
|
208
|
+
const contractViolations = reviewContractViolations(taskContent);
|
|
248
209
|
const items = [
|
|
249
210
|
...blockingReviewFindings(taskContent, deferrals),
|
|
250
211
|
...contractViolations.map((v) => ({
|
|
@@ -380,7 +341,6 @@ function parseScanArgs(argv, cwd, defaultTmpDir) {
|
|
|
380
341
|
let spurBin;
|
|
381
342
|
let root = cwd;
|
|
382
343
|
let tmpDir = defaultTmpDir;
|
|
383
|
-
let reviewAnswer;
|
|
384
344
|
for (let i = 0;i < argv.length; i++) {
|
|
385
345
|
const a = argv[i];
|
|
386
346
|
if (a === undefined)
|
|
@@ -391,8 +351,6 @@ function parseScanArgs(argv, cwd, defaultTmpDir) {
|
|
|
391
351
|
root = argv[++i] ?? root;
|
|
392
352
|
else if (a === "--tmp-dir")
|
|
393
353
|
tmpDir = argv[++i] ?? tmpDir;
|
|
394
|
-
else if (a === "--review-answer")
|
|
395
|
-
reviewAnswer = argv[++i];
|
|
396
354
|
else if (a === "--help" || a === "-h")
|
|
397
355
|
return null;
|
|
398
356
|
else if (mode === "")
|
|
@@ -400,7 +358,7 @@ function parseScanArgs(argv, cwd, defaultTmpDir) {
|
|
|
400
358
|
else if (wbs === "")
|
|
401
359
|
wbs = a;
|
|
402
360
|
}
|
|
403
|
-
return mode === "" || wbs === "" ? null : { mode, wbs, spurBin, root, tmpDir
|
|
361
|
+
return mode === "" || wbs === "" ? null : { mode, wbs, spurBin, root, tmpDir };
|
|
404
362
|
}
|
|
405
363
|
export {
|
|
406
364
|
ALLOW_PRAGMA,
|
|
@@ -419,9 +377,7 @@ export {
|
|
|
419
377
|
parseScanArgs,
|
|
420
378
|
recordedVerdictPath,
|
|
421
379
|
renderReport,
|
|
422
|
-
reviewConfidenceViolation,
|
|
423
380
|
reviewContractViolations,
|
|
424
|
-
reviewGradingInput,
|
|
425
381
|
scanResiduals,
|
|
426
382
|
verdictDisagreementNote
|
|
427
383
|
};
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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"]
|
|
@@ -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
|
}
|