@gobing-ai/spur 0.4.0 → 0.4.2
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.global.yaml +4 -2
- package/config/decisions/gates.yaml +36 -0
- package/config/decisions/history-anatomy.yaml +35 -0
- package/config/decisions/idea-pipeline.yaml +38 -0
- package/config/plugin-scripts.json +15 -0
- package/config/rules/docs/sourced-api-claims.yaml +60 -0
- package/config/rules/recommended-pre-check.yaml +8 -3
- package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
- package/config/script-placement-baseline.json +2 -2
- package/config/templates/AGENTS.md +1 -1
- package/config/workflows/feature-lifecycle.yaml +9 -2
- package/config/workflows/history-anatomy.yaml +22 -0
- package/config/workflows/idea-pipeline.yaml +75 -0
- package/config/workflows/task-pipeline.yaml +216 -57
- package/config/workflows/wrapup-pipeline.yaml +107 -14
- package/package.json +10 -10
- package/plugins/sp/README.md +12 -3
- package/plugins/sp/agents/super-planner.md +16 -6
- package/plugins/sp/agents/super-reviewer.md +14 -0
- package/plugins/sp/bunfig.toml +8 -0
- package/plugins/sp/commands/dev-brainstorm.md +29 -16
- package/plugins/sp/commands/dev-find-way.md +80 -0
- package/plugins/sp/commands/dev-idea.md +6 -0
- package/plugins/sp/commands/dev-plan.md +6 -0
- package/plugins/sp/commands/dev-review-session.md +4 -1
- package/plugins/sp/commands/dev-run.md +12 -2
- package/plugins/sp/commands/dev-runall.md +32 -15
- package/plugins/sp/hooks/context-owner.ts +56 -0
- package/plugins/sp/hooks/context-post-tool.ts +23 -0
- package/plugins/sp/hooks/context-session-start.ts +49 -27
- package/plugins/sp/hooks/context-session-stop.ts +131 -30
- package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
- package/plugins/sp/lib/idea-handoff.generated.mjs +43 -5
- package/plugins/sp/lib/inline-run-app.ts +28 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
- package/plugins/sp/lib/inline-run.generated.mjs +351 -46
- package/plugins/sp/lib/plan-projection.generated.d.mts +47 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +368 -0
- package/plugins/sp/lib/quality-gate.generated.d.mts +35 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +30296 -71
- package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
- package/plugins/sp/lib/run-summary-core.ts +561 -0
- package/plugins/sp/lib/transcript.ts +150 -7
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +488 -0
- package/plugins/sp/scripts/batch-plan.ts +145 -0
- package/plugins/sp/scripts/batch-preflight.mjs +51 -3
- package/plugins/sp/scripts/batch-preflight.ts +72 -1
- package/plugins/sp/scripts/commit-guard.mjs +216 -0
- package/plugins/sp/scripts/commit-guard.ts +249 -0
- package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
- package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
- package/plugins/sp/scripts/inline-run-setup.mjs +562 -80
- package/plugins/sp/scripts/inline-run-setup.ts +52 -78
- package/plugins/sp/scripts/persist-out-check.mjs +90 -41
- package/plugins/sp/scripts/persist-out-check.ts +112 -57
- package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
- package/plugins/sp/scripts/pr-reviewing.ts +34 -9
- package/plugins/sp/scripts/quality-gate.mjs +30213 -61
- package/plugins/sp/scripts/quality-gate.ts +21 -5
- package/plugins/sp/scripts/residual-scan.mjs +86 -31
- package/plugins/sp/scripts/residual-scan.ts +28 -30
- package/plugins/sp/scripts/run-summary.mjs +432 -14
- package/plugins/sp/scripts/run-summary.ts +41 -177
- package/plugins/sp/scripts/session-timeline.mjs +109 -11
- package/plugins/sp/scripts/session-timeline.ts +57 -19
- package/plugins/sp/scripts/task-diffstat.mjs +32 -29
- package/plugins/sp/scripts/task-diffstat.ts +34 -34
- package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
- package/plugins/sp/scripts/wrapup-steps.ts +203 -1
- package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
- package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
- package/plugins/sp/skills/code-verification/SKILL.md +3 -4
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
- package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
- package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
- package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
- package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
- package/plugins/sp/skills/session-review/SKILL.md +40 -7
- package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
- package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
- package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
- package/plugins/sp/skills/spur-cli/references/decision.md +16 -8
- package/plugins/sp/skills/spur-cli/references/message.md +7 -7
- package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
- package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
- package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
- package/plugins/sp/skills/spur-dev/SKILL.md +20 -14
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +33 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -17
- package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +199 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +156 -1024
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +133 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +44 -12
- package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +340 -165
- package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
- package/schemas/state-machine-workflow.schema.json +23 -0
- package/spur.js +42402 -26889
- package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.B3GQZvj8.js} +1 -1
- package/web/_astro/BoardApp.BT7L2kSb.js +324 -0
- package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
- package/web/_astro/{arc.f4e0OYLg.js → arc.vaT7tPOB.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
- package/web/_astro/channel.D4nG9YLf.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
- package/web/_astro/index.BhSwN8yY.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
- package/web/_astro/{linear.DguUUe6V.js → linear.BzGnBwNL.js} +1 -1
- package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.CSbTpIVz.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
- package/web/index.html +2 -2
- package/plugins/sp/commands/dev-parallel.md +0 -31
- package/web/_astro/BoardApp.Bzr4OXrW.js +0 -324
- package/web/_astro/TaskDetail.LAyR_Pvn.js +0 -1
- package/web/_astro/channel.CUFdtWhm.js +0 -1
- package/web/_astro/index.Dg_3bOaJ.css +0 -1
|
@@ -3,6 +3,30 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
4
|
|
|
5
5
|
// plugins/sp/scripts/batch-preflight.ts
|
|
6
|
+
var NON_ABORTING_PREFLIGHT_CODES = new Set([
|
|
7
|
+
"L4.scenario-unverified",
|
|
8
|
+
"L4.verifying-incomplete-tasks"
|
|
9
|
+
]);
|
|
10
|
+
function classifyFeaturePreflightFindings(findings) {
|
|
11
|
+
const aborting = [];
|
|
12
|
+
const nonAborting = [];
|
|
13
|
+
for (const f of findings) {
|
|
14
|
+
if (f.severity === "error") {
|
|
15
|
+
if (NON_ABORTING_PREFLIGHT_CODES.has(f.code)) {
|
|
16
|
+
nonAborting.push(f);
|
|
17
|
+
} else {
|
|
18
|
+
aborting.push(f);
|
|
19
|
+
}
|
|
20
|
+
} else {
|
|
21
|
+
nonAborting.push(f);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
aborting,
|
|
26
|
+
nonAborting,
|
|
27
|
+
shouldAbort: aborting.length > 0
|
|
28
|
+
};
|
|
29
|
+
}
|
|
6
30
|
function quickReadiness(input) {
|
|
7
31
|
const status = (input.status ?? "").toLowerCase();
|
|
8
32
|
const operation = input.operation;
|
|
@@ -190,6 +214,7 @@ function parsePreflightCliArgs(argv) {
|
|
|
190
214
|
let filteredCount = null;
|
|
191
215
|
let requiredSections = [];
|
|
192
216
|
let presentSections = [];
|
|
217
|
+
let classifyFeature = null;
|
|
193
218
|
for (let i = 0;i < argv.length; i++) {
|
|
194
219
|
const a = argv[i];
|
|
195
220
|
if (a === "--help" || a === "-h")
|
|
@@ -214,7 +239,9 @@ function parsePreflightCliArgs(argv) {
|
|
|
214
239
|
presentSections = raw.length === 0 ? [] : raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
215
240
|
} else if (a === "--wbs")
|
|
216
241
|
wbs = argv[++i] ?? wbs;
|
|
217
|
-
else if (a === "--
|
|
242
|
+
else if (a === "--classify-feature") {
|
|
243
|
+
classifyFeature = argv[++i] ?? "[]";
|
|
244
|
+
} else if (a === "--status")
|
|
218
245
|
status = argv[++i] ?? null;
|
|
219
246
|
else if (a === "--deps") {
|
|
220
247
|
const raw = argv[++i] ?? "";
|
|
@@ -240,7 +267,8 @@ function parsePreflightCliArgs(argv) {
|
|
|
240
267
|
force,
|
|
241
268
|
filteredCount,
|
|
242
269
|
requiredSections,
|
|
243
|
-
presentSections
|
|
270
|
+
presentSections,
|
|
271
|
+
classifyFeature
|
|
244
272
|
};
|
|
245
273
|
}
|
|
246
274
|
var PREFLIGHT_CLI_USAGE = `Usage:
|
|
@@ -253,6 +281,24 @@ function runPreflightCli(argv) {
|
|
|
253
281
|
const args = parsePreflightCliArgs(argv);
|
|
254
282
|
if (args.help)
|
|
255
283
|
return { exitCode: 0, stdout: "", stderr: PREFLIGHT_CLI_USAGE };
|
|
284
|
+
if (args.classifyFeature !== null) {
|
|
285
|
+
try {
|
|
286
|
+
const findings = JSON.parse(args.classifyFeature);
|
|
287
|
+
const res = classifyFeaturePreflightFindings(Array.isArray(findings) ? findings : []);
|
|
288
|
+
return {
|
|
289
|
+
exitCode: res.shouldAbort ? 1 : 0,
|
|
290
|
+
stdout: `${JSON.stringify(res, null, 2)}
|
|
291
|
+
`,
|
|
292
|
+
stderr: ""
|
|
293
|
+
};
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return {
|
|
296
|
+
exitCode: 1,
|
|
297
|
+
stdout: "",
|
|
298
|
+
stderr: `Invalid JSON for --classify-feature: ${String(e)}`
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
256
302
|
if (!args.status)
|
|
257
303
|
return { exitCode: 1, stdout: "", stderr: PREFLIGHT_CLI_USAGE };
|
|
258
304
|
if (args.recovery) {
|
|
@@ -327,5 +373,7 @@ export {
|
|
|
327
373
|
quickReadiness,
|
|
328
374
|
preflightTask,
|
|
329
375
|
parsePreflightCliArgs,
|
|
330
|
-
|
|
376
|
+
classifyFeaturePreflightFindings,
|
|
377
|
+
PREFLIGHT_CLI_USAGE,
|
|
378
|
+
NON_ABORTING_PREFLIGHT_CODES
|
|
331
379
|
};
|
|
@@ -25,6 +25,51 @@ export type PreflightResult =
|
|
|
25
25
|
| { action: 'run'; code?: string; reason?: string }
|
|
26
26
|
| { action: 'skip'; code: string; reason: string; unmetDeps?: string[] };
|
|
27
27
|
|
|
28
|
+
// ── Feature preflight classification (1132 R4) ──────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* 1132 R4: Codes that are expected pre-run states under `feature check --strict`
|
|
31
|
+
* and must not abort the batch. Structured classification in code (replaces prose-only).
|
|
32
|
+
*/
|
|
33
|
+
export const NON_ABORTING_PREFLIGHT_CODES = new Set<string>([
|
|
34
|
+
'L4.scenario-unverified',
|
|
35
|
+
'L4.verifying-incomplete-tasks',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export interface FeaturePreflightFinding {
|
|
39
|
+
layer: string;
|
|
40
|
+
code: string;
|
|
41
|
+
severity: string;
|
|
42
|
+
message: string;
|
|
43
|
+
section?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FeaturePreflightClassification {
|
|
47
|
+
aborting: FeaturePreflightFinding[];
|
|
48
|
+
nonAborting: FeaturePreflightFinding[];
|
|
49
|
+
shouldAbort: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function classifyFeaturePreflightFindings(findings: FeaturePreflightFinding[]): FeaturePreflightClassification {
|
|
53
|
+
const aborting: FeaturePreflightFinding[] = [];
|
|
54
|
+
const nonAborting: FeaturePreflightFinding[] = [];
|
|
55
|
+
for (const f of findings) {
|
|
56
|
+
if (f.severity === 'error') {
|
|
57
|
+
if (NON_ABORTING_PREFLIGHT_CODES.has(f.code)) {
|
|
58
|
+
nonAborting.push(f);
|
|
59
|
+
} else {
|
|
60
|
+
aborting.push(f);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
nonAborting.push(f);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
aborting,
|
|
68
|
+
nonAborting,
|
|
69
|
+
shouldAbort: aborting.length > 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
// ── Command-aware quick readiness (task 0814 R2) ──────────────────────────────
|
|
29
74
|
// Read-only admission decision for the requested dev operation. Distinguishes
|
|
30
75
|
// runnable / needs-refinement / blocked / skipped / invalid outcomes without
|
|
@@ -299,6 +344,8 @@ export interface PreflightCliArgs {
|
|
|
299
344
|
requiredSections: string[];
|
|
300
345
|
/** Sections actually present in the task. */
|
|
301
346
|
presentSections: string[];
|
|
347
|
+
/** Feature findings to classify (1132 R4). */
|
|
348
|
+
classifyFeature: string | null;
|
|
302
349
|
}
|
|
303
350
|
|
|
304
351
|
export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
@@ -314,6 +361,7 @@ export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
|
314
361
|
let filteredCount: number | null = null;
|
|
315
362
|
let requiredSections: string[] = [];
|
|
316
363
|
let presentSections: string[] = [];
|
|
364
|
+
let classifyFeature: string | null = null;
|
|
317
365
|
|
|
318
366
|
for (let i = 0; i < argv.length; i++) {
|
|
319
367
|
const a = argv[i];
|
|
@@ -346,7 +394,9 @@ export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
|
346
394
|
.map((s) => s.trim())
|
|
347
395
|
.filter(Boolean);
|
|
348
396
|
} else if (a === '--wbs') wbs = argv[++i] ?? wbs;
|
|
349
|
-
else if (a === '--
|
|
397
|
+
else if (a === '--classify-feature') {
|
|
398
|
+
classifyFeature = argv[++i] ?? '[]';
|
|
399
|
+
} else if (a === '--status') status = argv[++i] ?? null;
|
|
350
400
|
else if (a === '--deps') {
|
|
351
401
|
const raw = argv[++i] ?? '';
|
|
352
402
|
deps =
|
|
@@ -378,6 +428,7 @@ export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
|
378
428
|
filteredCount,
|
|
379
429
|
requiredSections,
|
|
380
430
|
presentSections,
|
|
431
|
+
classifyFeature,
|
|
381
432
|
};
|
|
382
433
|
}
|
|
383
434
|
|
|
@@ -392,6 +443,26 @@ Exit: 0 = run (or recovery hint printed); 2 = skip; 1 = usage.`;
|
|
|
392
443
|
export function runPreflightCli(argv: string[]): { exitCode: number; stdout: string; stderr: string } {
|
|
393
444
|
const args = parsePreflightCliArgs(argv);
|
|
394
445
|
if (args.help) return { exitCode: 0, stdout: '', stderr: PREFLIGHT_CLI_USAGE };
|
|
446
|
+
|
|
447
|
+
// Feature findings classification (1132 R4)
|
|
448
|
+
if (args.classifyFeature !== null) {
|
|
449
|
+
try {
|
|
450
|
+
const findings = JSON.parse(args.classifyFeature);
|
|
451
|
+
const res = classifyFeaturePreflightFindings(Array.isArray(findings) ? findings : []);
|
|
452
|
+
return {
|
|
453
|
+
exitCode: res.shouldAbort ? 1 : 0,
|
|
454
|
+
stdout: `${JSON.stringify(res, null, 2)}\n`,
|
|
455
|
+
stderr: '',
|
|
456
|
+
};
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return {
|
|
459
|
+
exitCode: 1,
|
|
460
|
+
stdout: '',
|
|
461
|
+
stderr: `Invalid JSON for --classify-feature: ${String(e)}`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
395
466
|
if (!args.status) return { exitCode: 1, stdout: '', stderr: PREFLIGHT_CLI_USAGE };
|
|
396
467
|
|
|
397
468
|
if (args.recovery) {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/commit-guard.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
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
|
+
}
|
|
18
|
+
function gitOf(options) {
|
|
19
|
+
if (options.git)
|
|
20
|
+
return options.git;
|
|
21
|
+
return (args) => {
|
|
22
|
+
const run = spawnSync("git", args, { cwd: options.cwd, encoding: "utf8" });
|
|
23
|
+
return { status: run.status ?? 1, stdout: run.stdout ?? "" };
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function hashPath(path, git) {
|
|
27
|
+
const run = git(["hash-object", "--", path]);
|
|
28
|
+
return run.status === 0 ? run.stdout.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
function parsePorcelainZ(stdout) {
|
|
31
|
+
const tokens = stdout.split("\x00");
|
|
32
|
+
const paths = [];
|
|
33
|
+
for (let i = 0;i < tokens.length; i++) {
|
|
34
|
+
const entry = tokens[i] ?? "";
|
|
35
|
+
if (entry.length < 4)
|
|
36
|
+
continue;
|
|
37
|
+
const path = entry.slice(3);
|
|
38
|
+
if (!isBookkeeping(path))
|
|
39
|
+
paths.push(path);
|
|
40
|
+
if (/[RC]/.test(entry.slice(0, 2)))
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return paths;
|
|
44
|
+
}
|
|
45
|
+
function conflictMarkerLine(content) {
|
|
46
|
+
let opener = false;
|
|
47
|
+
for (const line of content.split(`
|
|
48
|
+
`)) {
|
|
49
|
+
if (/^<{7}( |$)/.test(line))
|
|
50
|
+
opener = true;
|
|
51
|
+
else if (/^={7}(\s|$)/.test(line) && opener)
|
|
52
|
+
return true;
|
|
53
|
+
else if (/^>{7}( |$)/.test(line))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return opener;
|
|
57
|
+
}
|
|
58
|
+
function changedSinceBase(base, git) {
|
|
59
|
+
const tracked = git(["diff", "--name-only", base]);
|
|
60
|
+
const others = git(["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
61
|
+
if (tracked.status !== 0 || others.status !== 0)
|
|
62
|
+
return new Set;
|
|
63
|
+
const clean = (out, sep) => out.split(sep).map((p) => p.trim()).filter((p) => p.length > 0 && !isBookkeeping(p));
|
|
64
|
+
return new Set([...clean(tracked.stdout, `
|
|
65
|
+
`), ...clean(others.stdout, "\x00")]);
|
|
66
|
+
}
|
|
67
|
+
function readTreeStart(run, options) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(readFileSync(at(options, artifactPath(run)), "utf8"));
|
|
70
|
+
if (typeof parsed.head === "string" && Array.isArray(parsed.dirty))
|
|
71
|
+
return parsed;
|
|
72
|
+
} catch {}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function runContext(run, options) {
|
|
76
|
+
const git = gitOf(options);
|
|
77
|
+
const start = readTreeStart(run, options);
|
|
78
|
+
if (!start)
|
|
79
|
+
return null;
|
|
80
|
+
let base = options.base ?? "";
|
|
81
|
+
if (!base) {
|
|
82
|
+
try {
|
|
83
|
+
const anchored = readFileSync(at(options, join(".spur", "run", `${run}-base.sha`)), "utf8").trim();
|
|
84
|
+
if (/^[0-9a-f]{7,40}$/i.test(anchored))
|
|
85
|
+
base = anchored;
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
if (!base)
|
|
89
|
+
base = start.head.length > 0 ? start.head : "HEAD";
|
|
90
|
+
const written = changedSinceBase(base, git);
|
|
91
|
+
for (const entry of start.dirty)
|
|
92
|
+
if (hashPath(entry.path, git) === entry.sha)
|
|
93
|
+
written.delete(entry.path);
|
|
94
|
+
return { git, base, start, written };
|
|
95
|
+
}
|
|
96
|
+
function missingStart(run) {
|
|
97
|
+
process.stderr.write(`commit-guard: no ${artifactPath(run)} \u2014 run \`commit-guard start --run ${run}\` first
|
|
98
|
+
`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
function runStart(run, options = {}) {
|
|
102
|
+
const git = gitOf(options);
|
|
103
|
+
const head = git(["rev-parse", "HEAD"]);
|
|
104
|
+
const status = git(["status", "--porcelain=v1", "-z"]);
|
|
105
|
+
if (head.status !== 0 || status.status !== 0) {
|
|
106
|
+
process.stderr.write(`commit-guard: git probe failed (rev-parse HEAD / status) \u2014 is this a work tree?
|
|
107
|
+
`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const snapshot = {
|
|
111
|
+
head: head.stdout.trim(),
|
|
112
|
+
dirty: parsePorcelainZ(status.stdout).map((path) => ({ path, sha: hashPath(path, git) }))
|
|
113
|
+
};
|
|
114
|
+
const abs = at(options, artifactPath(run));
|
|
115
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
116
|
+
writeFileSync(`${abs}.tmp`, `${JSON.stringify(snapshot, null, 2)}
|
|
117
|
+
`);
|
|
118
|
+
renameSync(`${abs}.tmp`, abs);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
function conflictingTarget(paths, options, git) {
|
|
122
|
+
const unmerged = new Set(git(["diff", "--name-only", "--diff-filter=U"]).stdout.split(`
|
|
123
|
+
`).map((p) => p.trim()));
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
if (unmerged.has(path))
|
|
126
|
+
return `${path} (unmerged)`;
|
|
127
|
+
try {
|
|
128
|
+
if (conflictMarkerLine(readFileSync(at(options, path), "utf8")))
|
|
129
|
+
return `${path} (conflict markers)`;
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
function runStage(run, paths, options = {}) {
|
|
135
|
+
if (paths.length === 0) {
|
|
136
|
+
process.stderr.write(`${COMMIT_GUARD_USAGE}
|
|
137
|
+
`);
|
|
138
|
+
return { staged: [], foreign: [], exitCode: 1 };
|
|
139
|
+
}
|
|
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
|
+
if (conflict) {
|
|
145
|
+
process.stderr.write(`commit-guard: refusing to stage ${conflict}; nothing staged
|
|
146
|
+
`);
|
|
147
|
+
return { staged: [], foreign: [], exitCode: 3 };
|
|
148
|
+
}
|
|
149
|
+
const dirtyAtStart = new Set(context.start.dirty.map((entry) => entry.path));
|
|
150
|
+
const staged = paths.filter((path) => context.written.has(path));
|
|
151
|
+
const refused = paths.filter((path) => !context.written.has(path));
|
|
152
|
+
for (const path of refused) {
|
|
153
|
+
const why = dirtyAtStart.has(path) ? "foreign \u2014 dirty at start, untouched by this run" : "unchanged since base";
|
|
154
|
+
process.stderr.write(`commit-guard: refused ${path} (${why})
|
|
155
|
+
`);
|
|
156
|
+
}
|
|
157
|
+
if (staged.length > 0 && context.git(["add", "--", ...staged]).status !== 0) {
|
|
158
|
+
process.stderr.write(`commit-guard: git add failed \u2014 nothing staged
|
|
159
|
+
`);
|
|
160
|
+
return { staged: [], foreign: [], exitCode: 1 };
|
|
161
|
+
}
|
|
162
|
+
const foreign = refused.filter((path) => dirtyAtStart.has(path));
|
|
163
|
+
process.stdout.write(`${JSON.stringify({ staged, foreign })}
|
|
164
|
+
`);
|
|
165
|
+
return { staged, foreign, exitCode: refused.length > 0 ? 2 : 0 };
|
|
166
|
+
}
|
|
167
|
+
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 })}
|
|
173
|
+
`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
function takeFlag(args, name) {
|
|
177
|
+
const index = args.indexOf(name);
|
|
178
|
+
const value = index === -1 ? undefined : args[index + 1];
|
|
179
|
+
const missing = value === undefined || value.startsWith("--");
|
|
180
|
+
if (index !== -1)
|
|
181
|
+
args.splice(index, missing ? 1 : 2);
|
|
182
|
+
return missing ? undefined : value;
|
|
183
|
+
}
|
|
184
|
+
function main(argv, options = {}) {
|
|
185
|
+
const args = [...argv];
|
|
186
|
+
takeFlag(args, "--spur-bin");
|
|
187
|
+
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
|
+
};
|
|
195
|
+
if (!run)
|
|
196
|
+
return usage();
|
|
197
|
+
if (sub === "start")
|
|
198
|
+
return args.length === 0 ? runStart(run, options) : usage();
|
|
199
|
+
if (sub === "check")
|
|
200
|
+
return args.length === 0 ? runCheck(run, { ...options, base }) : usage();
|
|
201
|
+
if (sub === "stage")
|
|
202
|
+
return runStage(run, args.filter((arg) => arg !== "--"), { ...options, base }).exitCode;
|
|
203
|
+
return usage();
|
|
204
|
+
}
|
|
205
|
+
{
|
|
206
|
+
process.exit(main(process.argv.slice(2)));
|
|
207
|
+
}
|
|
208
|
+
export {
|
|
209
|
+
runStart,
|
|
210
|
+
runStage,
|
|
211
|
+
runCheck,
|
|
212
|
+
parsePorcelainZ,
|
|
213
|
+
main,
|
|
214
|
+
conflictMarkerLine,
|
|
215
|
+
COMMIT_GUARD_USAGE
|
|
216
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
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
|
+
*
|
|
8
|
+
* 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
|
+
*
|
|
15
|
+
* `node:*` builtins and relative imports only (sp-plugin-standalone); `options.git` is the test
|
|
16
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/** Base directory for the probes; `git` is the test seam (a fake runner instead of spawning). */
|
|
28
|
+
export interface GuardOptions {
|
|
29
|
+
cwd?: string;
|
|
30
|
+
base?: string;
|
|
31
|
+
git?: GitRunner;
|
|
32
|
+
}
|
|
33
|
+
/** Minimal spawnable git surface — tests fake this to return canned probe output. */
|
|
34
|
+
export interface GitRunResult {
|
|
35
|
+
status: number;
|
|
36
|
+
stdout: string;
|
|
37
|
+
}
|
|
38
|
+
export type GitRunner = (args: string[]) => GitRunResult;
|
|
39
|
+
export interface TreeStart {
|
|
40
|
+
head: string;
|
|
41
|
+
/** Working-file blob hashes at start; `sha` is null when the path did not exist. */
|
|
42
|
+
dirty: Array<{ path: string; sha: string | null }>;
|
|
43
|
+
}
|
|
44
|
+
/** `exitCode` is what `main` returns: 0 all staged · 2 some refused · 3 conflict · 1 usage. */
|
|
45
|
+
export type StageResult = { staged: string[]; foreign: string[]; exitCode: number };
|
|
46
|
+
|
|
47
|
+
export const COMMIT_GUARD_USAGE =
|
|
48
|
+
'usage: commit-guard start --run <id> | stage --run <id> [--base <sha>] -- <paths…> | check --run <id>';
|
|
49
|
+
|
|
50
|
+
/** 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
|
+
}
|
|
54
|
+
/** 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
|
+
}
|
|
61
|
+
function gitOf(options: GuardOptions): GitRunner {
|
|
62
|
+
if (options.git) return options.git;
|
|
63
|
+
return (args: string[]): GitRunResult => {
|
|
64
|
+
const run = spawnSync('git', args, { cwd: options.cwd, encoding: 'utf8' });
|
|
65
|
+
return { status: run.status ?? 1, stdout: run.stdout ?? '' };
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Working-file blob hash, or null when the path is absent (`git hash-object` failed). */
|
|
69
|
+
function hashPath(path: string, git: GitRunner): string | null {
|
|
70
|
+
const run = git(['hash-object', '--', path]);
|
|
71
|
+
return run.status === 0 ? run.stdout.trim() : null;
|
|
72
|
+
}
|
|
73
|
+
/** Paths from `git status --porcelain=v1 -z` (`XY <path>` entries; a rename's source token is
|
|
74
|
+
* consumed, not registered). */
|
|
75
|
+
export function parsePorcelainZ(stdout: string): string[] {
|
|
76
|
+
const tokens = stdout.split('\u0000');
|
|
77
|
+
const paths: string[] = [];
|
|
78
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
79
|
+
const entry = tokens[i] ?? '';
|
|
80
|
+
if (entry.length < 4) continue;
|
|
81
|
+
const path = entry.slice(3);
|
|
82
|
+
if (!isBookkeeping(path)) paths.push(path);
|
|
83
|
+
if (/[RC]/.test(entry.slice(0, 2))) i++;
|
|
84
|
+
}
|
|
85
|
+
return paths;
|
|
86
|
+
}
|
|
87
|
+
/** An unresolved conflict marker: an opener or closer line, or a `=======` between them — a `===`
|
|
88
|
+
* setext underline in prose is not a marker without an opener. */
|
|
89
|
+
export function conflictMarkerLine(content: string): boolean {
|
|
90
|
+
let opener = false;
|
|
91
|
+
for (const line of content.split('\n')) {
|
|
92
|
+
if (/^<{7}( |$)/.test(line)) opener = true;
|
|
93
|
+
else if (/^={7}(\s|$)/.test(line) && opener) return true;
|
|
94
|
+
else if (/^>{7}( |$)/.test(line)) return true;
|
|
95
|
+
}
|
|
96
|
+
return opener;
|
|
97
|
+
}
|
|
98
|
+
/** "Changed since base" (task-diffstat semantics): tracked changes + untracked, minus bookkeeping. */
|
|
99
|
+
function changedSinceBase(base: string, git: GitRunner): Set<string> {
|
|
100
|
+
const tracked = git(['diff', '--name-only', base]);
|
|
101
|
+
const others = git(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
102
|
+
if (tracked.status !== 0 || others.status !== 0) return new Set();
|
|
103
|
+
const clean = (out: string, sep: string): string[] =>
|
|
104
|
+
out
|
|
105
|
+
.split(sep)
|
|
106
|
+
.map((p) => p.trim())
|
|
107
|
+
.filter((p) => p.length > 0 && !isBookkeeping(p));
|
|
108
|
+
return new Set([...clean(tracked.stdout, '\n'), ...clean(others.stdout, '\u0000')]);
|
|
109
|
+
}
|
|
110
|
+
function readTreeStart(run: string, options: GuardOptions): TreeStart | null {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(readFileSync(at(options, artifactPath(run)), 'utf8')) as TreeStart;
|
|
113
|
+
if (typeof parsed.head === 'string' && Array.isArray(parsed.dirty)) return parsed;
|
|
114
|
+
} catch {
|
|
115
|
+
/* absent or malformed — the caller fails closed */
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
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. */
|
|
121
|
+
function runContext(run: string, options: GuardOptions) {
|
|
122
|
+
const git = gitOf(options);
|
|
123
|
+
const start = readTreeStart(run, options);
|
|
124
|
+
if (!start) return null;
|
|
125
|
+
let base = options.base ?? '';
|
|
126
|
+
if (!base) {
|
|
127
|
+
try {
|
|
128
|
+
const anchored = readFileSync(at(options, join('.spur', 'run', `${run}-base.sha`)), 'utf8').trim();
|
|
129
|
+
if (/^[0-9a-f]{7,40}$/i.test(anchored)) base = anchored;
|
|
130
|
+
} catch {
|
|
131
|
+
/* no precheck anchor — the start head is the base */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!base) base = start.head.length > 0 ? start.head : 'HEAD';
|
|
135
|
+
const written = changedSinceBase(base, git);
|
|
136
|
+
for (const entry of start.dirty) if (hashPath(entry.path, git) === entry.sha) written.delete(entry.path);
|
|
137
|
+
return { git, base, start, written };
|
|
138
|
+
}
|
|
139
|
+
function missingStart(run: string): number {
|
|
140
|
+
process.stderr.write(`commit-guard: no ${artifactPath(run)} — run \`commit-guard start --run ${run}\` first\n`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
/** Fingerprint the tree once, before the run writes anything (R1). */
|
|
144
|
+
export function runStart(run: string, options: GuardOptions = {}): number {
|
|
145
|
+
const git = gitOf(options);
|
|
146
|
+
const head = git(['rev-parse', 'HEAD']);
|
|
147
|
+
const status = git(['status', '--porcelain=v1', '-z']);
|
|
148
|
+
if (head.status !== 0 || status.status !== 0) {
|
|
149
|
+
process.stderr.write('commit-guard: git probe failed (rev-parse HEAD / status) — is this a work tree?\n');
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
const snapshot: TreeStart = {
|
|
153
|
+
head: head.stdout.trim(),
|
|
154
|
+
dirty: parsePorcelainZ(status.stdout).map((path) => ({ path, sha: hashPath(path, git) })),
|
|
155
|
+
};
|
|
156
|
+
const abs = at(options, artifactPath(run));
|
|
157
|
+
mkdirSync(join(abs, '..'), { recursive: true });
|
|
158
|
+
writeFileSync(`${abs}.tmp`, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
159
|
+
renameSync(`${abs}.tmp`, abs); // atomic: a concurrent reader never sees a half-written snapshot
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
/** The first path that is unmerged or carries conflict markers, or null (R4, fail closed). */
|
|
163
|
+
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
|
+
);
|
|
169
|
+
for (const path of paths) {
|
|
170
|
+
if (unmerged.has(path)) return `${path} (unmerged)`;
|
|
171
|
+
try {
|
|
172
|
+
if (conflictMarkerLine(readFileSync(at(options, path), 'utf8'))) return `${path} (conflict markers)`;
|
|
173
|
+
} catch {
|
|
174
|
+
/* deleted target — nothing to scan */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** Stage only the listed paths this run wrote (R2) and print `{staged, foreign}` (AC1). */
|
|
180
|
+
export function runStage(run: string, paths: string[], options: GuardOptions = {}): StageResult {
|
|
181
|
+
if (paths.length === 0) {
|
|
182
|
+
process.stderr.write(`${COMMIT_GUARD_USAGE}\n`);
|
|
183
|
+
return { staged: [], foreign: [], exitCode: 1 };
|
|
184
|
+
}
|
|
185
|
+
const context = runContext(run, options);
|
|
186
|
+
if (!context) return { staged: [], foreign: [], exitCode: missingStart(run) };
|
|
187
|
+
const conflict = conflictingTarget(paths, options, context.git);
|
|
188
|
+
if (conflict) {
|
|
189
|
+
process.stderr.write(`commit-guard: refusing to stage ${conflict}; nothing staged\n`);
|
|
190
|
+
return { staged: [], foreign: [], exitCode: 3 };
|
|
191
|
+
}
|
|
192
|
+
const dirtyAtStart = new Set(context.start.dirty.map((entry) => entry.path));
|
|
193
|
+
const staged = paths.filter((path) => context.written.has(path));
|
|
194
|
+
const refused = paths.filter((path) => !context.written.has(path));
|
|
195
|
+
for (const path of refused) {
|
|
196
|
+
const why = dirtyAtStart.has(path) ? 'foreign — dirty at start, untouched by this run' : 'unchanged since base';
|
|
197
|
+
process.stderr.write(`commit-guard: refused ${path} (${why})\n`);
|
|
198
|
+
}
|
|
199
|
+
if (staged.length > 0 && context.git(['add', '--', ...staged]).status !== 0) {
|
|
200
|
+
process.stderr.write('commit-guard: git add failed — nothing staged\n');
|
|
201
|
+
return { staged: [], foreign: [], exitCode: 1 };
|
|
202
|
+
}
|
|
203
|
+
const foreign = refused.filter((path) => dirtyAtStart.has(path));
|
|
204
|
+
process.stdout.write(`${JSON.stringify({ staged, foreign })}\n`);
|
|
205
|
+
return { staged, foreign, exitCode: refused.length > 0 ? 2 : 0 };
|
|
206
|
+
}
|
|
207
|
+
/** Report the working-tree changes this run did not write, staging nothing (R3). */
|
|
208
|
+
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;
|
|
216
|
+
}
|
|
217
|
+
/** Pull one `--flag value` pair out of argv, or undefined when absent (a missing value is usage). */
|
|
218
|
+
function takeFlag(args: string[], name: string): string | undefined {
|
|
219
|
+
const index = args.indexOf(name);
|
|
220
|
+
const value = index === -1 ? undefined : args[index + 1];
|
|
221
|
+
const missing = value === undefined || value.startsWith('--');
|
|
222
|
+
if (index !== -1) args.splice(index, missing ? 1 : 2);
|
|
223
|
+
return missing ? undefined : value;
|
|
224
|
+
}
|
|
225
|
+
export function main(argv: string[], options: GuardOptions = {}): number {
|
|
226
|
+
const args = [...argv];
|
|
227
|
+
takeFlag(args, '--spur-bin'); // 0482 R2: pipelines hand every plugin script this flag
|
|
228
|
+
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();
|
|
238
|
+
if (sub === 'stage')
|
|
239
|
+
return runStage(
|
|
240
|
+
run,
|
|
241
|
+
args.filter((arg) => arg !== '--'),
|
|
242
|
+
{ ...options, base },
|
|
243
|
+
).exitCode;
|
|
244
|
+
return usage();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (import.meta.main) {
|
|
248
|
+
process.exit(main(process.argv.slice(2)));
|
|
249
|
+
}
|