@gobing-ai/spur 0.3.92 → 0.3.93
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/pipeline-budgets.json +0 -7
- package/config/plugin-scripts.json +10 -0
- package/config/templates/AGENTS.md +4 -0
- package/config/templates/docs/02_ROADMAP.md +2 -0
- package/config/templates/docs/03_ARCHITECTURE.md +3 -1
- package/config/templates/docs/04_DESIGN.md +2 -0
- package/config/templates/docs/99_PROJECT_CONSTITUTION.md +10 -2
- package/config/workflow-candidates.json +72 -1
- package/config/workflows/feature-verification.yaml +1 -0
- package/config/workflows/history-anatomy.yaml +2 -0
- package/config/workflows/idea-pipeline.yaml +67 -18
- package/config/workflows/pr-review.yaml +8 -0
- package/config/workflows/task-pipeline.yaml +180 -39
- package/config/workflows/wayfinder-resolution.yaml +5 -0
- package/config/workflows/wrapup-pipeline.yaml +55 -14
- package/package.json +9 -9
- package/plugins/sp/README.md +7 -2
- package/plugins/sp/commands/dev-run.md +2 -2
- package/plugins/sp/commands/dev-runall.md +2 -2
- package/plugins/sp/lib/idea-handoff.generated.mjs +8 -4
- package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
- package/plugins/sp/lib/inline-run.generated.mjs +24 -16
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +1 -1
- package/plugins/sp/scripts/inline-run-setup.mjs +75 -3
- package/plugins/sp/scripts/inline-run-setup.ts +133 -3
- package/plugins/sp/scripts/quality-gate.mjs +248 -6
- package/plugins/sp/scripts/quality-gate.ts +410 -9
- package/plugins/sp/scripts/residual-scan.mjs +12 -4
- package/plugins/sp/scripts/residual-scan.ts +32 -6
- package/plugins/sp/scripts/task-diffstat.mjs +156 -0
- package/plugins/sp/scripts/task-diffstat.ts +229 -0
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +181 -0
- package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +60 -1
- package/plugins/sp/scripts/wrapup-steps.ts +89 -4
- package/plugins/sp/skills/brainstorm/SKILL.md +2 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +17 -2
- package/plugins/sp/skills/code-verification/SKILL.md +2 -2
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -2
- package/plugins/sp/skills/spur-check/SKILL.md +112 -0
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -1
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -2
- package/plugins/sp/skills/spur-dev/references/document-authoring.md +85 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +1 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +8 -0
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +3 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +19 -1
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +5 -5
- package/plugins/sp/skills/spur-dev/templates/design.md +31 -0
- package/plugins/sp/skills/spur-dev/templates/plan.md +32 -0
- package/plugins/sp/skills/spur-doctor/SKILL.md +60 -14
- package/schemas/state-machine-workflow.schema.json +4 -0
- package/spur.js +1117 -472
- package/config/workflows/decision-routing-example.yaml +0 -134
|
@@ -86,6 +86,190 @@ function scanCoverageShortfalls(logText, threshold) {
|
|
|
86
86
|
}
|
|
87
87
|
return [...shortfalls.values()];
|
|
88
88
|
}
|
|
89
|
+
var RECEIPT_SCHEMA_VERSION = "check-receipt/v1";
|
|
90
|
+
function buildReceipt(input) {
|
|
91
|
+
return {
|
|
92
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
93
|
+
wbs: input.wbs,
|
|
94
|
+
runId: input.runId,
|
|
95
|
+
tier: input.tier,
|
|
96
|
+
inputDigest: input.inputDigest,
|
|
97
|
+
checks: input.checks,
|
|
98
|
+
status: input.checks.every((row) => row.status === "PASS") ? "PASS" : "FAIL",
|
|
99
|
+
completedAt: input.completedAt
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function readReceipt(receiptPath) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(readFileSync(receiptPath, "utf8"));
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function receiptFailsAtDigest(receipt, currentDigest) {
|
|
110
|
+
return receipt !== null && receipt.schemaVersion === RECEIPT_SCHEMA_VERSION && receipt.tier === "full" && receipt.status === "FAIL" && currentDigest.length > 0 && receipt.inputDigest === currentDigest;
|
|
111
|
+
}
|
|
112
|
+
function readReceiptStatus(receiptPath, currentDigest) {
|
|
113
|
+
const receipt = readReceipt(receiptPath);
|
|
114
|
+
if (receipt === null || receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) {
|
|
115
|
+
return { reuse: false, reason: "missing" };
|
|
116
|
+
}
|
|
117
|
+
if (receipt.status !== "PASS")
|
|
118
|
+
return { reuse: false, reason: "failed" };
|
|
119
|
+
if (currentDigest.length === 0 || receipt.inputDigest !== currentDigest) {
|
|
120
|
+
return { reuse: false, reason: "stale" };
|
|
121
|
+
}
|
|
122
|
+
if (receipt.tier !== "full")
|
|
123
|
+
return { reuse: false, reason: "light-only" };
|
|
124
|
+
return { reuse: true, reason: "ok" };
|
|
125
|
+
}
|
|
126
|
+
var TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
127
|
+
function workspaceOf(file, exists) {
|
|
128
|
+
const segments = file.split("/");
|
|
129
|
+
for (let depth = 1;depth < segments.length; depth++) {
|
|
130
|
+
const prefix = segments.slice(0, depth).join("/");
|
|
131
|
+
if (exists(`${prefix}/package.json`))
|
|
132
|
+
return prefix;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function lightScope(changedFiles, exists = existsSync) {
|
|
137
|
+
const files = [];
|
|
138
|
+
const workspaces = new Set;
|
|
139
|
+
const tests = new Set;
|
|
140
|
+
for (const file of changedFiles) {
|
|
141
|
+
files.push(file);
|
|
142
|
+
const workspace = workspaceOf(file, exists);
|
|
143
|
+
if (workspace === null)
|
|
144
|
+
continue;
|
|
145
|
+
workspaces.add(workspace);
|
|
146
|
+
const rest = file.slice(workspace.length + 1);
|
|
147
|
+
if (rest.startsWith("src/")) {
|
|
148
|
+
const candidate = `${workspace}/tests/${rest.slice("src/".length).replace(/\.tsx?$/, (ext) => `.test${ext}`)}`;
|
|
149
|
+
if (exists(candidate))
|
|
150
|
+
tests.add(candidate);
|
|
151
|
+
} else if (rest.startsWith("tests/") && TEST_FILE_PATTERN.test(rest)) {
|
|
152
|
+
tests.add(file);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { files, workspaces: [...workspaces].sort(), tests: [...tests].sort() };
|
|
156
|
+
}
|
|
157
|
+
function workspaceHasTypecheck(workspace) {
|
|
158
|
+
try {
|
|
159
|
+
const pkg = JSON.parse(readFileSync(join(workspace, "package.json"), "utf8"));
|
|
160
|
+
return typeof pkg.scripts?.typecheck === "string";
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function planLightChecks(scope, hasTypecheck = workspaceHasTypecheck) {
|
|
166
|
+
const plans = [];
|
|
167
|
+
if (scope.files.length > 0) {
|
|
168
|
+
plans.push({ id: "format-lint:changed", cmd: `bunx biome check ${scope.files.join(" ")}` });
|
|
169
|
+
}
|
|
170
|
+
for (const workspace of scope.workspaces) {
|
|
171
|
+
if (hasTypecheck(workspace)) {
|
|
172
|
+
plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${workspace} && bun run typecheck` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const testsByWorkspace = new Map;
|
|
176
|
+
for (const test of scope.tests) {
|
|
177
|
+
const workspace = test.slice(0, test.indexOf("/tests/"));
|
|
178
|
+
const paths = testsByWorkspace.get(workspace) ?? [];
|
|
179
|
+
paths.push(test.slice(workspace.length + 1));
|
|
180
|
+
testsByWorkspace.set(workspace, paths);
|
|
181
|
+
}
|
|
182
|
+
for (const [workspace, paths] of testsByWorkspace) {
|
|
183
|
+
plans.push({ id: `test:${workspace}`, cmd: `cd ${workspace} && bun test ${paths.join(" ")}` });
|
|
184
|
+
}
|
|
185
|
+
return plans;
|
|
186
|
+
}
|
|
187
|
+
function gitChangedFiles(cwd) {
|
|
188
|
+
const abs = (p) => cwd ? join(cwd, p) : p;
|
|
189
|
+
const changed = new Set;
|
|
190
|
+
for (const cmd of ["git diff --name-only HEAD", "git ls-files --others --exclude-standard"]) {
|
|
191
|
+
const result = runShellCommand(cmd, cwd);
|
|
192
|
+
for (const line of result.output.split(`
|
|
193
|
+
`)) {
|
|
194
|
+
const file = line.trim();
|
|
195
|
+
if (file.length > 0 && existsSync(abs(file)))
|
|
196
|
+
changed.add(file);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return [...changed].sort();
|
|
200
|
+
}
|
|
201
|
+
function receiptRunId(env) {
|
|
202
|
+
return (env.runId ?? "").length > 0 ? env.runId : `pipeline-${env.wbs}`;
|
|
203
|
+
}
|
|
204
|
+
function runLightGate(env, options = {}) {
|
|
205
|
+
const cwd = options.cwd;
|
|
206
|
+
const abs = (p) => cwd ? join(cwd, p) : p;
|
|
207
|
+
const runDir = join(".spur", "run");
|
|
208
|
+
mkdirSync(abs(runDir), { recursive: true });
|
|
209
|
+
const logFile = join(runDir, `${env.wbs}-light-gate.log`);
|
|
210
|
+
const receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
|
|
211
|
+
writeFileSync(abs(logFile), "");
|
|
212
|
+
const digest = env.proofDigest ?? "";
|
|
213
|
+
const scope = lightScope(gitChangedFiles(cwd), (p) => existsSync(abs(p)));
|
|
214
|
+
const plans = planLightChecks(scope, (workspace) => workspaceHasTypecheck(abs(workspace)));
|
|
215
|
+
const reusable = new Map;
|
|
216
|
+
let preserveFullReceipt = false;
|
|
217
|
+
if (digest.length > 0) {
|
|
218
|
+
try {
|
|
219
|
+
const prior = JSON.parse(readFileSync(abs(receiptFile), "utf8"));
|
|
220
|
+
if (prior?.schemaVersion === RECEIPT_SCHEMA_VERSION && prior.inputDigest === digest) {
|
|
221
|
+
if (prior.tier === "full") {
|
|
222
|
+
preserveFullReceipt = true;
|
|
223
|
+
} else {
|
|
224
|
+
for (const row of prior.checks ?? [])
|
|
225
|
+
if (row.status === "PASS")
|
|
226
|
+
reusable.set(row.id, row);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
const checks = [];
|
|
232
|
+
let skipped = 0;
|
|
233
|
+
for (const plan of plans) {
|
|
234
|
+
const priorRow = reusable.get(plan.id);
|
|
235
|
+
if (priorRow !== undefined) {
|
|
236
|
+
checks.push(priorRow);
|
|
237
|
+
skipped++;
|
|
238
|
+
const line = `--- light ${plan.id}: check.reused \u2014 skipped (PASS at the same input digest)
|
|
239
|
+
`;
|
|
240
|
+
process.stdout.write(line);
|
|
241
|
+
appendFileSync(abs(logFile), line);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const startedAtMs = Date.now();
|
|
245
|
+
const result = runShellCommand(plan.cmd, cwd);
|
|
246
|
+
const durationMs = Date.now() - startedAtMs;
|
|
247
|
+
const status = result.code === 0 ? "PASS" : "FAIL";
|
|
248
|
+
appendFileSync(abs(logFile), `--- light ${plan.id}: ${status} (${durationMs}ms)
|
|
249
|
+
${result.output}`);
|
|
250
|
+
checks.push({ id: plan.id, cmd: plan.cmd, status, durationMs, logPath: logFile });
|
|
251
|
+
}
|
|
252
|
+
const receipt = buildReceipt({
|
|
253
|
+
wbs: env.wbs,
|
|
254
|
+
runId: receiptRunId(env),
|
|
255
|
+
tier: "light",
|
|
256
|
+
inputDigest: digest,
|
|
257
|
+
checks,
|
|
258
|
+
completedAt: new Date().toISOString()
|
|
259
|
+
});
|
|
260
|
+
if (preserveFullReceipt) {
|
|
261
|
+
appendFileSync(abs(logFile), `--- light receipt: not written \u2014 the full-tier receipt at the same input digest is preserved
|
|
262
|
+
`);
|
|
263
|
+
process.stdout.write(`light gate ${receipt.status} (${scope.files.length} changed files; checks: ${checks.length},` + ` skipped: ${skipped}; receipt: ${receiptFile} preserved (full tier); log: ${logFile})
|
|
264
|
+
`);
|
|
265
|
+
} else {
|
|
266
|
+
writeFileSync(abs(receiptFile), `${JSON.stringify(receipt, null, 2)}
|
|
267
|
+
`);
|
|
268
|
+
process.stdout.write(`light gate ${receipt.status} (${scope.files.length} changed files; checks: ${checks.length},` + ` skipped: ${skipped}; receipt: ${receiptFile}; log: ${logFile})
|
|
269
|
+
`);
|
|
270
|
+
}
|
|
271
|
+
return { status: receipt.status, scope, checks, logFile, receiptFile };
|
|
272
|
+
}
|
|
89
273
|
function retryDelayMs(env) {
|
|
90
274
|
const raw = Number.parseInt(env[RETRY_DELAY_MS_ENV] ?? "", 10);
|
|
91
275
|
return Number.isFinite(raw) && raw >= 0 ? raw : RETRY_DELAY_MS_DEFAULT;
|
|
@@ -124,9 +308,18 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
124
308
|
if (mode === "run")
|
|
125
309
|
writeFileSync(abs(attemptFile), `0
|
|
126
310
|
`);
|
|
311
|
+
const gateStartedAtMs = Date.now();
|
|
127
312
|
let gateRc = 0;
|
|
128
313
|
let gateAttempt = 0;
|
|
129
|
-
|
|
314
|
+
const noProgressSkip = mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
|
|
315
|
+
if (noProgressSkip) {
|
|
316
|
+
const line = `check.skipped-no-progress \u2014 full-tier FAIL receipt at input digest ${env.proofDigest ?? ""}; recheck skipped
|
|
317
|
+
`;
|
|
318
|
+
process.stdout.write(line);
|
|
319
|
+
appendFileSync(abs(logFile), line);
|
|
320
|
+
gateRc = 1;
|
|
321
|
+
}
|
|
322
|
+
if (mode === "recheck" && !noProgressSkip && (env.gateProbeCmd ?? "").length > 0) {
|
|
130
323
|
const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
|
|
131
324
|
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
132
325
|
gateRc = probe.code;
|
|
@@ -182,12 +375,38 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
182
375
|
`);
|
|
183
376
|
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
|
|
184
377
|
`);
|
|
185
|
-
|
|
378
|
+
let receiptFile;
|
|
379
|
+
if (mode === "run") {
|
|
380
|
+
if ((env.proofDigest ?? "").length > 0) {
|
|
381
|
+
receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
|
|
382
|
+
const receipt = buildReceipt({
|
|
383
|
+
wbs: env.wbs,
|
|
384
|
+
runId: receiptRunId(env),
|
|
385
|
+
tier: "full",
|
|
386
|
+
inputDigest: env.proofDigest ?? "",
|
|
387
|
+
checks: [
|
|
388
|
+
{
|
|
389
|
+
id: "test",
|
|
390
|
+
cmd: env.qualityGateCmd ?? "",
|
|
391
|
+
status,
|
|
392
|
+
durationMs: Date.now() - gateStartedAtMs,
|
|
393
|
+
logPath: logFile
|
|
394
|
+
}
|
|
395
|
+
],
|
|
396
|
+
completedAt: new Date().toISOString()
|
|
397
|
+
});
|
|
398
|
+
writeFileSync(abs(receiptFile), `${JSON.stringify(receipt, null, 2)}
|
|
399
|
+
`);
|
|
400
|
+
} else {
|
|
401
|
+
appendFileSync(abs(logFile), "check-receipt: not written \u2014 env `proofDigest` is not set\n");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile, receiptFile };
|
|
186
405
|
}
|
|
187
|
-
var QUALITY_GATE_USAGE = "usage: quality-gate.ts <run|recheck> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest)";
|
|
188
|
-
function main(argv, env = getEnvVars()) {
|
|
406
|
+
var QUALITY_GATE_USAGE = "usage: quality-gate.ts <run|recheck|light|status> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest, runId)";
|
|
407
|
+
function main(argv, env = getEnvVars(), options = {}) {
|
|
189
408
|
const mode = argv[0];
|
|
190
|
-
if (mode !== "run" && mode !== "recheck") {
|
|
409
|
+
if (mode !== "run" && mode !== "recheck" && mode !== "light" && mode !== "status") {
|
|
191
410
|
process.stderr.write(`${QUALITY_GATE_USAGE}
|
|
192
411
|
`);
|
|
193
412
|
return 2;
|
|
@@ -196,24 +415,47 @@ function main(argv, env = getEnvVars()) {
|
|
|
196
415
|
process.stderr.write("quality-gate: env `wbs` is required\n");
|
|
197
416
|
return 2;
|
|
198
417
|
}
|
|
199
|
-
|
|
418
|
+
if (mode === "light") {
|
|
419
|
+
runLightGate(env);
|
|
420
|
+
} else if (mode === "status") {
|
|
421
|
+
const runDir = join(options.cwd ?? ".", ".spur", "run");
|
|
422
|
+
const verdict = readReceiptStatus(join(runDir, `${env.wbs}-check-receipt.json`), env.proofDigest ?? "");
|
|
423
|
+
if (verdict.reuse) {
|
|
424
|
+
const line = `check.reused \u2014 full-tier receipt reused for input digest ${env.proofDigest ?? ""}
|
|
425
|
+
`;
|
|
426
|
+
process.stdout.write(line);
|
|
427
|
+
appendFileSync(join(runDir, `${env.wbs}-test-gate.log`), line);
|
|
428
|
+
}
|
|
429
|
+
process.stdout.write(`${JSON.stringify(verdict)}
|
|
430
|
+
`);
|
|
431
|
+
} else {
|
|
432
|
+
runQualityGate(mode, env);
|
|
433
|
+
}
|
|
200
434
|
return 0;
|
|
201
435
|
}
|
|
202
436
|
{
|
|
203
437
|
process.exit(main(process.argv.slice(2)));
|
|
204
438
|
}
|
|
205
439
|
export {
|
|
440
|
+
workspaceHasTypecheck,
|
|
206
441
|
tailLines,
|
|
207
442
|
scanCoverageShortfalls,
|
|
208
443
|
runShellCommand,
|
|
209
444
|
runQualityGate,
|
|
445
|
+
runLightGate,
|
|
210
446
|
retryMessage,
|
|
447
|
+
receiptFailsAtDigest,
|
|
448
|
+
readReceiptStatus,
|
|
449
|
+
planLightChecks,
|
|
211
450
|
parseCoverageThreshold,
|
|
212
451
|
main,
|
|
452
|
+
lightScope,
|
|
213
453
|
isTransientLock,
|
|
214
454
|
extractFindings,
|
|
455
|
+
buildReceipt,
|
|
215
456
|
RETRY_DELAY_MS_ENV,
|
|
216
457
|
RETRY_DELAY_MS_DEFAULT,
|
|
458
|
+
RECEIPT_SCHEMA_VERSION,
|
|
217
459
|
QUALITY_GATE_USAGE,
|
|
218
460
|
MAX_GATE_ATTEMPTS,
|
|
219
461
|
MAX_FINDINGS,
|