@gobing-ai/spur 0.3.92 → 0.3.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +0 -7
  3. package/config/plugin-scripts.json +10 -0
  4. package/config/rules/boundary/test-subpath-boundary.yaml +48 -0
  5. package/config/rules/strict/runtime-boundaries.yaml +2 -1
  6. package/config/rules/structure/protected-files.yaml +4 -0
  7. package/config/templates/AGENTS.md +4 -0
  8. package/config/templates/docs/02_ROADMAP.md +2 -0
  9. package/config/templates/docs/03_ARCHITECTURE.md +3 -1
  10. package/config/templates/docs/04_DESIGN.md +2 -0
  11. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +10 -2
  12. package/config/workflow-candidates.json +72 -1
  13. package/config/workflows/feature-verification.yaml +1 -0
  14. package/config/workflows/history-anatomy.yaml +2 -0
  15. package/config/workflows/idea-pipeline.yaml +67 -18
  16. package/config/workflows/pr-review.yaml +8 -0
  17. package/config/workflows/task-pipeline.yaml +171 -39
  18. package/config/workflows/wayfinder-resolution.yaml +5 -0
  19. package/config/workflows/wrapup-pipeline.yaml +55 -14
  20. package/package.json +9 -9
  21. package/plugins/sp/README.md +8 -3
  22. package/plugins/sp/commands/dev-review-session.md +4 -4
  23. package/plugins/sp/commands/dev-review.md +16 -5
  24. package/plugins/sp/commands/dev-run.md +2 -2
  25. package/plugins/sp/commands/dev-runall.md +2 -2
  26. package/plugins/sp/hooks/pi/guard-extension.ts +17 -36
  27. package/plugins/sp/lib/idea-handoff.generated.mjs +8 -4
  28. package/plugins/sp/lib/inline-run.generated.d.mts +3 -0
  29. package/plugins/sp/lib/inline-run.generated.mjs +48 -19
  30. package/plugins/sp/plugin.json +1 -1
  31. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +1 -1
  32. package/plugins/sp/scripts/inline-run-setup.mjs +117 -7
  33. package/plugins/sp/scripts/inline-run-setup.ts +211 -7
  34. package/plugins/sp/scripts/quality-gate.mjs +255 -6
  35. package/plugins/sp/scripts/quality-gate.ts +425 -9
  36. package/plugins/sp/scripts/residual-scan.mjs +13 -5
  37. package/plugins/sp/scripts/residual-scan.ts +34 -7
  38. package/plugins/sp/scripts/task-diffstat.mjs +156 -0
  39. package/plugins/sp/scripts/task-diffstat.ts +229 -0
  40. package/plugins/sp/scripts/wrapup-drift-probe.mjs +181 -0
  41. package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
  42. package/plugins/sp/scripts/wrapup-steps.mjs +60 -1
  43. package/plugins/sp/scripts/wrapup-steps.ts +89 -4
  44. package/plugins/sp/skills/brainstorm/SKILL.md +2 -0
  45. package/plugins/sp/skills/brainstorm/references/workflows.md +17 -2
  46. package/plugins/sp/skills/code-verification/SKILL.md +2 -2
  47. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -2
  48. package/plugins/sp/skills/session-review/SKILL.md +11 -9
  49. package/plugins/sp/skills/spur-check/SKILL.md +112 -0
  50. package/plugins/sp/skills/spur-dev/SKILL.md +2 -1
  51. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -2
  52. package/plugins/sp/skills/spur-dev/references/dev-operations.md +8 -2
  53. package/plugins/sp/skills/spur-dev/references/document-authoring.md +85 -0
  54. package/plugins/sp/skills/spur-dev/references/execution-batch.md +46 -18
  55. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +24 -3
  56. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +3 -2
  57. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +34 -2
  58. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +5 -5
  59. package/plugins/sp/skills/spur-dev/templates/design.md +31 -0
  60. package/plugins/sp/skills/spur-dev/templates/plan.md +32 -0
  61. package/plugins/sp/skills/spur-doctor/SKILL.md +60 -14
  62. package/schemas/state-machine-workflow.schema.json +4 -0
  63. package/spur.js +19577 -19822
  64. package/web/_astro/BoardApp.C02hAHPO.js +1 -0
  65. package/web/_astro/{BoardApp.CerSBgis.js → BoardApp.FTEs3-N8.js} +107 -105
  66. package/web/_astro/{TaskDetail.DCqiC-OZ.js → TaskDetail.C-GdsS-t.js} +1 -1
  67. package/web/_astro/arc.uuAf51IT.js +1 -0
  68. package/web/_astro/{architectureDiagram-3BPJPVTR.DM_vp_hO.js → architectureDiagram-3BPJPVTR.CGe629A1.js} +1 -1
  69. package/web/_astro/{blockDiagram-GPEHLZMM.DXVIiv0p.js → blockDiagram-GPEHLZMM.D1mGCq3p.js} +1 -1
  70. package/web/_astro/{c4Diagram-AAUBKEIU.BbF_zCxW.js → c4Diagram-AAUBKEIU.CMsolcde.js} +1 -1
  71. package/web/_astro/channel.fsgl7o5j.js +1 -0
  72. package/web/_astro/{chunk-2J33WTMH.CAgQHpPC.js → chunk-2J33WTMH.CrGA3fik.js} +1 -1
  73. package/web/_astro/{chunk-4BX2VUAB.BN-5tpw4.js → chunk-4BX2VUAB.DsLVVla0.js} +1 -1
  74. package/web/_astro/{chunk-55IACEB6.CnPkEEr0.js → chunk-55IACEB6.Dxc59Tfi.js} +1 -1
  75. package/web/_astro/{chunk-727SXJPM.BQzQeMVm.js → chunk-727SXJPM.CaEVE2Wy.js} +4 -4
  76. package/web/_astro/{chunk-AQP2D5EJ.B6xNyDnL.js → chunk-AQP2D5EJ.BiJ4HXeI.js} +1 -1
  77. package/web/_astro/{chunk-FMBD7UC4.C7f9Ih78.js → chunk-FMBD7UC4.Mxf1fru5.js} +1 -1
  78. package/web/_astro/{chunk-ND2GUHAM.CNV1dFXT.js → chunk-ND2GUHAM.pyOWQixH.js} +1 -1
  79. package/web/_astro/{chunk-QZHKN3VN.Cudn2TkJ.js → chunk-QZHKN3VN.BmEsg4vr.js} +1 -1
  80. package/web/_astro/{classDiagram-4FO5ZUOK.D1NwP50q.js → classDiagram-4FO5ZUOK.BHhhMFTO.js} +1 -1
  81. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D1NwP50q.js → classDiagram-v2-Q7XG4LA2.BHhhMFTO.js} +1 -1
  82. package/web/_astro/{cose-bilkent-S5V4N54A.B1wSL-Xb.js → cose-bilkent-S5V4N54A.2fH4YOlp.js} +1 -1
  83. package/web/_astro/{cynefin-OW5HDTMX.BmK52w8G.js → cynefin-OW5HDTMX.C2j1_lKL.js} +1 -1
  84. package/web/_astro/{dagre-BM42HDAG.Bfy5CTDT.js → dagre-BM42HDAG.hZ2NCTdT.js} +2 -2
  85. package/web/_astro/diagram-2AECGRRQ.Bfw5_EzK.js +43 -0
  86. package/web/_astro/diagram-5GNKFQAL.Bt1V_Tmk.js +10 -0
  87. package/web/_astro/{diagram-KO2AKTUF.CW_vMJ4z.js → diagram-KO2AKTUF.DEk-YFwp.js} +3 -3
  88. package/web/_astro/{diagram-LMA3HP47.B_8ZGF67.js → diagram-LMA3HP47.CDjndtQm.js} +1 -1
  89. package/web/_astro/{diagram-OG6HWLK6.BppnHsdS.js → diagram-OG6HWLK6.cFnUHScG.js} +1 -1
  90. package/web/_astro/{erDiagram-TEJ5UH35.BEuHXcjJ.js → erDiagram-TEJ5UH35.DlhYp7NV.js} +5 -5
  91. package/web/_astro/{flowDiagram-I6XJVG4X.CH-UlnGr.js → flowDiagram-I6XJVG4X.DNTpsxfx.js} +4 -4
  92. package/web/_astro/{ganttDiagram-6RSMTGT7.BO81S85v.js → ganttDiagram-6RSMTGT7.DC_p36PI.js} +1 -1
  93. package/web/_astro/{gitGraphDiagram-PVQCEYII.XnPxPPZN.js → gitGraphDiagram-PVQCEYII.DkEqNI0P.js} +1 -1
  94. package/web/_astro/{infoDiagram-5YYISTIA.JyjYRu_T.js → infoDiagram-5YYISTIA.CrjioCTG.js} +1 -1
  95. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BBRBF-Fo.js → ishikawaDiagram-YF4QCWOH.BcK0CN8n.js} +5 -5
  96. package/web/_astro/{journeyDiagram-JHISSGLW.C_iymSyp.js → journeyDiagram-JHISSGLW.p0CbBDX1.js} +1 -1
  97. package/web/_astro/{kanban-definition-UN3LZRKU.DdfW-Oqt.js → kanban-definition-UN3LZRKU.puHIFt6J.js} +7 -7
  98. package/web/_astro/{linear.C2_IkbZT.js → linear.Bb-3a1d7.js} +1 -1
  99. package/web/_astro/mermaid.core.CJDgXOJs.js +301 -0
  100. package/web/_astro/{mindmap-definition-RKZ34NQL.DAZIxQSK.js → mindmap-definition-RKZ34NQL.BoAZEI9t.js} +2 -2
  101. package/web/_astro/{pieDiagram-4H26LBE5.CN8sIhKM.js → pieDiagram-4H26LBE5.CJNSdqY7.js} +3 -3
  102. package/web/_astro/{quadrantDiagram-W4KKPZXB.3dGcX5GP.js → quadrantDiagram-W4KKPZXB.DtTy7_0Z.js} +1 -1
  103. package/web/_astro/{requirementDiagram-4Y6WPE33.BV2y4dd6.js → requirementDiagram-4Y6WPE33.BNYV98Xg.js} +3 -3
  104. package/web/_astro/{sankeyDiagram-5OEKKPKP.Cqo15Tvo.js → sankeyDiagram-5OEKKPKP.DRq9DGHp.js} +4 -4
  105. package/web/_astro/{sequenceDiagram-3UESZ5HK.CROCPMJB.js → sequenceDiagram-3UESZ5HK.B7KdBFCm.js} +1 -1
  106. package/web/_astro/{stateDiagram-AJRCARHV.RfXZrkFE.js → stateDiagram-AJRCARHV.CD62ZJ2H.js} +1 -1
  107. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CPXmbBs9.js → stateDiagram-v2-BHNVJYJU.CeVk1TAj.js} +1 -1
  108. package/web/_astro/{timeline-definition-PNZ67QCA.DdgKTiO8.js → timeline-definition-PNZ67QCA.C_ZPA5tT.js} +3 -3
  109. package/web/_astro/{vennDiagram-CIIHVFJN.CPNVSHF1.js → vennDiagram-CIIHVFJN.CrThO_FQ.js} +5 -5
  110. package/web/_astro/{wardleyDiagram-YWT4CUSO.CQhA0Jyr.js → wardleyDiagram-YWT4CUSO.DSZCA5nl.js} +3 -3
  111. package/web/_astro/{xychartDiagram-2RQKCTM6.n61BWyy4.js → xychartDiagram-2RQKCTM6.KI7baTuk.js} +1 -1
  112. package/web/index.html +1 -1
  113. package/config/workflows/decision-routing-example.yaml +0 -134
  114. package/web/_astro/BoardApp.eoTz0pZs.js +0 -1
  115. package/web/_astro/arc.CPwg6Rw0.js +0 -1
  116. package/web/_astro/channel.MYZLKNwy.js +0 -1
  117. package/web/_astro/diagram-2AECGRRQ.DhNnvUvX.js +0 -43
  118. package/web/_astro/diagram-5GNKFQAL.lTX5KwnS.js +0 -10
  119. package/web/_astro/mermaid.core.GAOYeSR0.js +0 -303
@@ -13,14 +13,36 @@
13
13
  *
14
14
  * Modes:
15
15
  * run reset the attempt counter, truncate the log, execute the gate loop
16
- * recheck truncate the log, execute the probe first (probe failure is the gate
17
- * failure, the gate loop is skipped), then execute the gate loop
16
+ * recheck truncate the log; a full-tier FAIL receipt at the current digest is a
17
+ * no-progress skip straight to the FAIL write (0940 R2), otherwise the probe
18
+ * runs first (probe failure is the gate failure, the gate loop is skipped),
19
+ * then the gate loop
20
+ * light changed-scope tier (task 0939, ADR-124): biome on the changed files,
21
+ * per-workspace typecheck, and filename-mapped related tests run inside
22
+ * their workspace; merges rows under `tier: light`, soft-fails
23
+ * status print `{reuse, reason}` for the task's receipt and exit 0
18
24
  *
19
25
  * Retry contract: a failed attempt is retried (up to 5 total attempts) only when its
20
26
  * output matches a SQLite busy/locked error; the delay defaults to 10 seconds and is
21
27
  * overridable via `SPUR_QUALITY_GATE_RETRY_DELAY_MS` (tests).
22
28
  *
23
- * Environment: `wbs` (required), `qualityGateCmd`, `gateProbeCmd` (recheck), `proofDigest`.
29
+ * Check receipts (task 0939, ADR-124): `run` and `recheck` write `.spur/run/<wbs>-check-receipt.json`
30
+ * (`check-receipt/v1`) when `proofDigest` is set (a no-progress skip keeps the receipt it matched); without a digest no receipt is written and the
31
+ * log says why. The gate executes `qualityGateCmd` as one unit, so the full-tier receipt carries
32
+ * a single `test` row for the whole `bun run spur-check` chain (lint | typecheck | test-pre-check
33
+ * | test | test-post-check). `light` accumulates: a sub-check already PASS in a light receipt at
34
+ * the same `{id, inputDigest}` is skipped, and light rows never make a receipt reusable for
35
+ * review — `status` reuses only `PASS` + `tier: full` + matching digest. Reuse observability
36
+ * (0940 R3): `status` reuse and light accumulation emit `check.reused`, and the no-progress
37
+ * recheck emits `check.skipped-no-progress`, in the gate log and on stdout (the action result
38
+ * `data`); both surfaces run this same script, so there is no surface branch.
39
+ *
40
+ * Standalone contract: the digest is consumed, never computed — the pipeline captures it with
41
+ * `proof.fingerprint` into `env.proofDigest`; standalone callers use
42
+ * `inline-run-setup.ts --fingerprint`. No `@gobing-ai/*` value imports.
43
+ *
44
+ * Environment: `wbs` (required), `qualityGateCmd`, `gateProbeCmd` (recheck), `proofDigest`,
45
+ * `runId` (receipt identity; falls back to `pipeline-<wbs>`).
24
46
  * Soft-fail contract: the process always exits 0; the verdict lives in the status file.
25
47
  *
26
48
  * Node-builtin imports only; pure helpers are exported for unit testing (ADR-065).
@@ -65,6 +87,8 @@ export interface QualityGateEnv {
65
87
  qualityGateCmd?: string;
66
88
  gateProbeCmd?: string;
67
89
  proofDigest?: string;
90
+ /** Receipt run identity; `pipeline-<wbs>` when absent (task-pipeline convention). */
91
+ runId?: string;
68
92
  [key: string]: string | undefined;
69
93
  }
70
94
 
@@ -80,6 +104,8 @@ export interface QualityGateResult {
80
104
  findingsFile: string;
81
105
  statusFile: string;
82
106
  attemptFile: string;
107
+ /** Written only by `run` with `proofDigest` set (0939 R2). */
108
+ receiptFile?: string;
83
109
  }
84
110
 
85
111
  export function isTransientLock(attemptOutput: string): boolean {
@@ -156,6 +182,335 @@ export function scanCoverageShortfalls(logText: string, threshold: CoverageThres
156
182
  return [...shortfalls.values()];
157
183
  }
158
184
 
185
+ // ─── Check receipts + light tier (task 0939, ADR-124) ───
186
+
187
+ export const RECEIPT_SCHEMA_VERSION = 'check-receipt/v1';
188
+
189
+ export type ReceiptTier = 'full' | 'light';
190
+ export type CheckStatus = 'PASS' | 'FAIL';
191
+ /** Frozen non-reuse reasons of `status` mode (0939 R3). */
192
+ export type ReceiptReuseReason = 'missing' | 'failed' | 'stale' | 'light-only';
193
+
194
+ export interface CheckReceiptRow {
195
+ id: string;
196
+ cmd: string;
197
+ status: CheckStatus;
198
+ durationMs: number;
199
+ logPath: string;
200
+ }
201
+
202
+ export interface CheckReceipt {
203
+ schemaVersion: 'check-receipt/v1';
204
+ wbs: string;
205
+ runId: string;
206
+ tier: ReceiptTier;
207
+ inputDigest: string;
208
+ checks: CheckReceiptRow[];
209
+ status: CheckStatus;
210
+ completedAt: string;
211
+ }
212
+
213
+ export interface ReceiptReadStatus {
214
+ reuse: boolean;
215
+ /** `'ok'` only when reuse holds; otherwise one of the frozen reasons. */
216
+ reason: ReceiptReuseReason | 'ok';
217
+ }
218
+
219
+ export interface BuildReceiptInput {
220
+ wbs: string;
221
+ runId: string;
222
+ tier: ReceiptTier;
223
+ inputDigest: string;
224
+ checks: CheckReceiptRow[];
225
+ /** ISO-8601 completion timestamp; injected so receipts stay reproducible in tests. */
226
+ completedAt: string;
227
+ }
228
+
229
+ /** Stamp `check-receipt/v1`; the overall status derives from the rows (no rows → PASS). */
230
+ export function buildReceipt(input: BuildReceiptInput): CheckReceipt {
231
+ return {
232
+ schemaVersion: RECEIPT_SCHEMA_VERSION,
233
+ wbs: input.wbs,
234
+ runId: input.runId,
235
+ tier: input.tier,
236
+ inputDigest: input.inputDigest,
237
+ checks: input.checks,
238
+ status: input.checks.every((row) => row.status === 'PASS') ? 'PASS' : 'FAIL',
239
+ completedAt: input.completedAt,
240
+ };
241
+ }
242
+
243
+ /** Parse a check receipt; `null` when absent, unreadable, or corrupt. */
244
+ function readReceipt(receiptPath: string): CheckReceipt | null {
245
+ try {
246
+ return JSON.parse(readFileSync(receiptPath, 'utf8')) as CheckReceipt;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * 0940 R2 — no-progress recheck shape: a full-tier FAIL receipt bound to the current proof-input
254
+ * digest, i.e. the fix pass changed nothing tracked and the full chain can only reproduce the
255
+ * failure. `readReceiptStatus` cannot express this decision (a FAIL receipt is `reason: 'failed'`
256
+ * at every digest), so the two fields are compared directly. Light-tier receipts and any digest
257
+ * mismatch never skip — the probe and full gate run unchanged (anti-pattern: reusing a light
258
+ * receipt, or comparing against a digest captured before `test-fix`).
259
+ */
260
+ export function receiptFailsAtDigest(receipt: CheckReceipt | null, currentDigest: string): boolean {
261
+ return (
262
+ receipt !== null &&
263
+ receipt.schemaVersion === RECEIPT_SCHEMA_VERSION &&
264
+ receipt.tier === 'full' &&
265
+ receipt.status === 'FAIL' &&
266
+ currentDigest.length > 0 &&
267
+ receipt.inputDigest === currentDigest
268
+ );
269
+ }
270
+
271
+ /**
272
+ * Reuse verdict for `.spur/run/<wbs>-check-receipt.json` against the current proof-input digest.
273
+ * Evaluated missing → failed → stale → light-only; reuse requires `PASS` + `tier: full` + digest
274
+ * match, so light rows can never flip a receipt reusable (0939 invariant).
275
+ */
276
+ export function readReceiptStatus(receiptPath: string, currentDigest: string): ReceiptReadStatus {
277
+ const receipt = readReceipt(receiptPath);
278
+ if (receipt === null || receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) {
279
+ return { reuse: false, reason: 'missing' };
280
+ }
281
+ if (receipt.status !== 'PASS') return { reuse: false, reason: 'failed' };
282
+ if (currentDigest.length === 0 || receipt.inputDigest !== currentDigest) {
283
+ return { reuse: false, reason: 'stale' };
284
+ }
285
+ if (receipt.tier !== 'full') return { reuse: false, reason: 'light-only' };
286
+ return { reuse: true, reason: 'ok' };
287
+ }
288
+
289
+ const TEST_FILE_PATTERN = /\.test\.tsx?$/;
290
+
291
+ export interface LightScope {
292
+ files: string[];
293
+ workspaces: string[];
294
+ tests: string[];
295
+ }
296
+
297
+ /**
298
+ * The bun workspace a changed file belongs to: the shortest directory prefix below the repo root
299
+ * that holds a package.json (`apps/*`, `packages/*`). Null for root-level files, which no
300
+ * workspace owns. Pure via the injected `exists` predicate.
301
+ */
302
+ function workspaceOf(file: string, exists: (p: string) => boolean): string | null {
303
+ const segments = file.split('/');
304
+ for (let depth = 1; depth < segments.length; depth++) {
305
+ const prefix = segments.slice(0, depth).join('/');
306
+ if (exists(`${prefix}/package.json`)) return prefix;
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /**
312
+ * Light-tier scope from changed repo-relative paths: every touched workspace plus the related
313
+ * tests. `<ws>/src/**x.ts` maps to `<ws>/tests/**x.test.ts` by filename (refine decision —
314
+ * deterministic and cheap; the import graph is not consulted), and a changed `*.test.ts` under a
315
+ * workspace includes itself. The full tier stays the safety net for everything the mapping
316
+ * misses. Unmapped-but-existing candidates are dropped by the `exists` check.
317
+ */
318
+ export function lightScope(changedFiles: string[], exists: (p: string) => boolean = existsSync): LightScope {
319
+ const files: string[] = [];
320
+ const workspaces = new Set<string>();
321
+ const tests = new Set<string>();
322
+ for (const file of changedFiles) {
323
+ files.push(file);
324
+ const workspace = workspaceOf(file, exists);
325
+ if (workspace === null) continue;
326
+ workspaces.add(workspace);
327
+ const rest = file.slice(workspace.length + 1);
328
+ if (rest.startsWith('src/')) {
329
+ const candidate = `${workspace}/tests/${rest.slice('src/'.length).replace(/\.tsx?$/, (ext) => `.test${ext}`)}`;
330
+ if (exists(candidate)) tests.add(candidate);
331
+ } else if (rest.startsWith('tests/') && TEST_FILE_PATTERN.test(rest)) {
332
+ tests.add(file);
333
+ }
334
+ }
335
+ return { files, workspaces: [...workspaces].sort(), tests: [...tests].sort() };
336
+ }
337
+
338
+ /**
339
+ * Quote one argument for `sh -c`. Changed-file names come from the working tree (untracked files
340
+ * included), so an unquoted `x$(cmd).ts` would run `cmd` and a space would split the argument.
341
+ * Plain paths pass through unchanged to keep receipts readable.
342
+ */
343
+ export function shQuote(arg: string): string {
344
+ return /^[\w./@+-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
345
+ }
346
+
347
+ export interface LightCheckPlan {
348
+ id: string;
349
+ cmd: string;
350
+ }
351
+
352
+ /** Does the workspace package.json declare a `typecheck` script? (fs default; tests inject.) */
353
+ export function workspaceHasTypecheck(workspace: string): boolean {
354
+ try {
355
+ const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')) as {
356
+ scripts?: { typecheck?: string };
357
+ };
358
+ return typeof pkg.scripts?.typecheck === 'string';
359
+ } catch {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * The light sub-checks for a scope, in run order: `format-lint:changed` (biome, repo root),
366
+ * `typecheck:<ws>` per workspace declaring the script, `test:<ws>` per workspace with related
367
+ * tests. Tests always run through `cd <ws> &&` — never from the repo root, whose bunfig preload
368
+ * does not apply inside the workspace.
369
+ */
370
+ export function planLightChecks(
371
+ scope: LightScope,
372
+ hasTypecheck: (workspace: string) => boolean = workspaceHasTypecheck,
373
+ ): LightCheckPlan[] {
374
+ const plans: LightCheckPlan[] = [];
375
+ if (scope.files.length > 0) {
376
+ plans.push({ id: 'format-lint:changed', cmd: `bunx biome check ${scope.files.map(shQuote).join(' ')}` });
377
+ }
378
+ for (const workspace of scope.workspaces) {
379
+ if (hasTypecheck(workspace)) {
380
+ plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${shQuote(workspace)} && bun run typecheck` });
381
+ }
382
+ }
383
+ const testsByWorkspace = new Map<string, string[]>();
384
+ for (const test of scope.tests) {
385
+ const workspace = test.slice(0, test.indexOf('/tests/'));
386
+ const paths = testsByWorkspace.get(workspace) ?? [];
387
+ paths.push(test.slice(workspace.length + 1));
388
+ testsByWorkspace.set(workspace, paths);
389
+ }
390
+ for (const [workspace, paths] of testsByWorkspace) {
391
+ plans.push({
392
+ id: `test:${workspace}`,
393
+ cmd: `cd ${shQuote(workspace)} && bun test ${paths.map(shQuote).join(' ')}`,
394
+ });
395
+ }
396
+ return plans;
397
+ }
398
+
399
+ /** Changed paths for the light tier: `git diff --name-only HEAD` plus untracked; existing only. */
400
+ function gitChangedFiles(cwd: string | undefined): string[] {
401
+ const abs = (p: string): string => (cwd ? join(cwd, p) : p);
402
+ const changed = new Set<string>();
403
+ for (const cmd of ['git diff --name-only HEAD', 'git ls-files --others --exclude-standard']) {
404
+ const result = runShellCommand(cmd, cwd);
405
+ for (const line of result.output.split('\n')) {
406
+ const file = line.trim();
407
+ if (file.length > 0 && existsSync(abs(file))) changed.add(file);
408
+ }
409
+ }
410
+ return [...changed].sort();
411
+ }
412
+
413
+ function receiptRunId(env: QualityGateEnv): string {
414
+ return (env.runId ?? '').length > 0 ? (env.runId as string) : `pipeline-${env.wbs}`;
415
+ }
416
+
417
+ export interface LightGateResult {
418
+ status: CheckStatus;
419
+ scope: LightScope;
420
+ checks: CheckReceiptRow[];
421
+ logFile: string;
422
+ receiptFile: string;
423
+ }
424
+
425
+ /**
426
+ * Light tier (0939 R1): run the planned sub-checks, merge their rows into the receipt under
427
+ * `tier: light`, exit soft. A sub-check already PASS in a light receipt at the same
428
+ * `{id, inputDigest}` is skipped (accumulation, AC1); prior rows whose id left the plan are
429
+ * dropped with the rest of the old receipt.
430
+ */
431
+ export function runLightGate(env: QualityGateEnv, options: QualityGateOptions = {}): LightGateResult {
432
+ const cwd = options.cwd;
433
+ const abs = (p: string): string => (cwd ? join(cwd, p) : p);
434
+ const runDir = join('.spur', 'run');
435
+ mkdirSync(abs(runDir), { recursive: true });
436
+ const logFile = join(runDir, `${env.wbs}-light-gate.log`);
437
+ const receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
438
+ writeFileSync(abs(logFile), '');
439
+
440
+ const digest = env.proofDigest ?? '';
441
+ const scope = lightScope(gitChangedFiles(cwd), (p) => existsSync(abs(p)));
442
+ // Bind the fs predicates to the gate cwd, not the process cwd (tests run gates in fixtures).
443
+ const plans = planLightChecks(scope, (workspace) => workspaceHasTypecheck(abs(workspace)));
444
+
445
+ const reusable = new Map<string, CheckReceiptRow>();
446
+ let preserveFullReceipt = false;
447
+ if (digest.length > 0) {
448
+ try {
449
+ const prior = JSON.parse(readFileSync(abs(receiptFile), 'utf8')) as CheckReceipt;
450
+ if (prior?.schemaVersion === RECEIPT_SCHEMA_VERSION && prior.inputDigest === digest) {
451
+ if (prior.tier === 'full') {
452
+ // A full-tier receipt at the same digest is boundary evidence read by status
453
+ // mode (0940/0943); light must never demote it to a tier-light receipt.
454
+ preserveFullReceipt = true;
455
+ } else {
456
+ for (const row of prior.checks ?? []) if (row.status === 'PASS') reusable.set(row.id, row);
457
+ }
458
+ }
459
+ } catch {
460
+ // No prior receipt (or unreadable) — every planned sub-check runs.
461
+ }
462
+ }
463
+
464
+ const checks: CheckReceiptRow[] = [];
465
+ let skipped = 0;
466
+ for (const plan of plans) {
467
+ const priorRow = reusable.get(plan.id);
468
+ if (priorRow !== undefined) {
469
+ checks.push(priorRow);
470
+ skipped++;
471
+ // 0940 R3: accumulation reuse is observable in the gate log and on stdout (the
472
+ // action result `data`); both surfaces run this same script.
473
+ const line = `--- light ${plan.id}: check.reused — skipped (PASS at the same input digest)\n`;
474
+ process.stdout.write(line); // tee: stdout and the log
475
+ appendFileSync(abs(logFile), line);
476
+ continue;
477
+ }
478
+ const startedAtMs = Date.now();
479
+ const result = runShellCommand(plan.cmd, cwd);
480
+ const durationMs = Date.now() - startedAtMs;
481
+ const status: CheckStatus = result.code === 0 ? 'PASS' : 'FAIL';
482
+ appendFileSync(abs(logFile), `--- light ${plan.id}: ${status} (${durationMs}ms)\n${result.output}`);
483
+ checks.push({ id: plan.id, cmd: plan.cmd, status, durationMs, logPath: logFile });
484
+ }
485
+
486
+ const receipt = buildReceipt({
487
+ wbs: env.wbs,
488
+ runId: receiptRunId(env),
489
+ tier: 'light',
490
+ inputDigest: digest,
491
+ checks,
492
+ completedAt: new Date().toISOString(),
493
+ });
494
+ if (preserveFullReceipt) {
495
+ // Light ran for its log; the file keeps the full-tier receipt untouched.
496
+ appendFileSync(
497
+ abs(logFile),
498
+ '--- light receipt: not written — the full-tier receipt at the same input digest is preserved\n',
499
+ );
500
+ process.stdout.write(
501
+ `light gate ${receipt.status} (${scope.files.length} changed files; checks: ${checks.length},` +
502
+ ` skipped: ${skipped}; receipt: ${receiptFile} preserved (full tier); log: ${logFile})\n`,
503
+ );
504
+ } else {
505
+ writeFileSync(abs(receiptFile), `${JSON.stringify(receipt, null, 2)}\n`);
506
+ process.stdout.write(
507
+ `light gate ${receipt.status} (${scope.files.length} changed files; checks: ${checks.length},` +
508
+ ` skipped: ${skipped}; receipt: ${receiptFile}; log: ${logFile})\n`,
509
+ );
510
+ }
511
+ return { status: receipt.status, scope, checks, logFile, receiptFile };
512
+ }
513
+
159
514
  function retryDelayMs(env: QualityGateEnv): number {
160
515
  const raw = Number.parseInt(env[RETRY_DELAY_MS_ENV] ?? '', 10);
161
516
  return Number.isFinite(raw) && raw >= 0 ? raw : RETRY_DELAY_MS_DEFAULT;
@@ -201,12 +556,28 @@ export function runQualityGate(
201
556
  // run: `echo 0 > "$ATTEMPT_FILE" && : > "$LOG_FILE"`; recheck: truncate only.
202
557
  writeFileSync(abs(logFile), '');
203
558
  if (mode === 'run') writeFileSync(abs(attemptFile), '0\n');
559
+ const gateStartedAtMs = Date.now();
204
560
 
205
561
  let gateRc = 0;
206
562
  let gateAttempt = 0;
207
563
 
564
+ // 0940 R2 — no-progress skip, before the probe: a full-tier FAIL receipt at the current
565
+ // proof-input digest means the fix pass changed nothing tracked, so the full chain can only
566
+ // reproduce the failure. The marker tees to stdout (the action result `data`) and the log,
567
+ // then the shared FAIL path below writes findings/status/verdict exactly as a red gate does.
568
+ // The attempt counter is pipeline-owned (only the test-fix hop increments it; `recheck` never
569
+ // touches it), so the existing cap still bounds the loop.
570
+ const noProgressSkip =
571
+ mode === 'recheck' && receiptFailsAtDigest(readReceipt(abs(rel('-check-receipt.json'))), env.proofDigest ?? '');
572
+ if (noProgressSkip) {
573
+ const line = `check.skipped-no-progress — full-tier FAIL receipt at input digest ${env.proofDigest ?? ''}; recheck skipped\n`;
574
+ process.stdout.write(line); // tee: stdout and the log
575
+ appendFileSync(abs(logFile), line);
576
+ gateRc = 1;
577
+ }
578
+
208
579
  // recheck probe: a probe failure is the gate failure; the gate loop is skipped.
209
- if (mode === 'recheck' && (env.gateProbeCmd ?? '').length > 0) {
580
+ if (mode === 'recheck' && !noProgressSkip && (env.gateProbeCmd ?? '').length > 0) {
210
581
  const probe = runShellCommand(env.gateProbeCmd ?? '', cwd);
211
582
  writeFileSync(abs(`${logFile}.probe`), probe.output);
212
583
  gateRc = probe.code;
@@ -263,15 +634,45 @@ export function runQualityGate(
263
634
  writeFileSync(abs(statusFile), `${status}\n`);
264
635
  appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ''}\n`);
265
636
 
266
- return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile };
637
+ // 0939 R2: the gate writes the full-tier receipt only with a digest to bind it to. 0976 R1:
638
+ // `recheck` persists the receipt it evaluated too, so a second recheck at the same digest can
639
+ // take the no-progress skip. A skip leaves the FAIL receipt it matched untouched — a skip never
640
+ // rewrites a receipt, so it can never launder FAIL into PASS.
641
+ let receiptFile: string | undefined;
642
+ if (!noProgressSkip) {
643
+ if ((env.proofDigest ?? '').length > 0) {
644
+ receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
645
+ const receipt = buildReceipt({
646
+ wbs: env.wbs,
647
+ runId: receiptRunId(env),
648
+ tier: 'full',
649
+ inputDigest: env.proofDigest ?? '',
650
+ checks: [
651
+ {
652
+ id: 'test',
653
+ cmd: env.qualityGateCmd ?? '',
654
+ status,
655
+ durationMs: Date.now() - gateStartedAtMs,
656
+ logPath: logFile,
657
+ },
658
+ ],
659
+ completedAt: new Date().toISOString(),
660
+ });
661
+ writeFileSync(abs(receiptFile), `${JSON.stringify(receipt, null, 2)}\n`);
662
+ } else {
663
+ appendFileSync(abs(logFile), 'check-receipt: not written — env `proofDigest` is not set\n');
664
+ }
665
+ }
666
+
667
+ return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile, receiptFile };
267
668
  }
268
669
 
269
670
  export const QUALITY_GATE_USAGE =
270
- 'usage: quality-gate.ts <run|recheck> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest)';
671
+ 'usage: quality-gate.ts <run|recheck|light|status> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest, runId)';
271
672
 
272
- export function main(argv: string[], env: QualityGateEnv = getEnvVars()): number {
673
+ export function main(argv: string[], env: QualityGateEnv = getEnvVars(), options: QualityGateOptions = {}): number {
273
674
  const mode = argv[0];
274
- if (mode !== 'run' && mode !== 'recheck') {
675
+ if (mode !== 'run' && mode !== 'recheck' && mode !== 'light' && mode !== 'status') {
275
676
  process.stderr.write(`${QUALITY_GATE_USAGE}\n`);
276
677
  return 2;
277
678
  }
@@ -279,7 +680,22 @@ export function main(argv: string[], env: QualityGateEnv = getEnvVars()): number
279
680
  process.stderr.write('quality-gate: env `wbs` is required\n');
280
681
  return 2;
281
682
  }
282
- runQualityGate(mode, env);
683
+ if (mode === 'light') {
684
+ runLightGate(env);
685
+ } else if (mode === 'status') {
686
+ const runDir = join(options.cwd ?? '.', '.spur', 'run');
687
+ const verdict = readReceiptStatus(join(runDir, `${env.wbs}-check-receipt.json`), env.proofDigest ?? '');
688
+ if (verdict.reuse) {
689
+ // 0940 R3: reuse is observable in the gate log and on stdout (the action result
690
+ // `data`); the `{reuse, reason}` JSON stays the last stdout line for machine readers.
691
+ const line = `check.reused — full-tier receipt reused for input digest ${env.proofDigest ?? ''}\n`;
692
+ process.stdout.write(line); // tee: stdout and the log
693
+ appendFileSync(join(runDir, `${env.wbs}-test-gate.log`), line);
694
+ }
695
+ process.stdout.write(`${JSON.stringify(verdict)}\n`);
696
+ } else {
697
+ runQualityGate(mode, env);
698
+ }
283
699
  return 0;
284
700
  }
285
701
 
@@ -17,7 +17,10 @@ function getEnvVars() {
17
17
  var RESIDUAL_SCAN_USAGE = "usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]";
18
18
  var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
19
19
  var PRIORITY_PATTERN = /^P[1-4]/;
20
- var NONE_FINDING = /^(none|\u2014)$/i;
20
+ var NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|\u2014)\s*(\(.*\))?\.?$/i;
21
+ var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
22
+ var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
23
+ var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
21
24
  var ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
22
25
  var RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
23
26
  var EXCLUDED_PATHS = ["docs/tasks", "docs/features/", ".spur/"];
@@ -68,6 +71,7 @@ function parseReviewFindings(taskContent) {
68
71
  }
69
72
  const findingCol = header.findIndex((h) => h.trim() === "Finding");
70
73
  const locationCol = header.findIndex((h) => h.trim() === "Location");
74
+ const dispositionCol = header.findIndex((h) => DISPOSITION_HEADER.test(h.trim()));
71
75
  i++;
72
76
  const sep = lines[i];
73
77
  if (sep !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(sep))
@@ -79,8 +83,10 @@ function parseReviewFindings(taskContent) {
79
83
  const cells = splitRow(row);
80
84
  const priority = (cells[priorityCol] ?? "").trim();
81
85
  const finding = (cells[findingCol] ?? "").trim();
82
- if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0) {
83
- out.push({ priority, location: locationOf(cells[locationCol] ?? "", finding), text: finding });
86
+ const disposition = dispositionCol === -1 ? "" : (cells[dispositionCol] ?? "").trim();
87
+ if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0 && !RESOLVED_DISPOSITION.test(disposition)) {
88
+ const location = locationOf(cells[locationCol] ?? "", finding);
89
+ out.push(DEFERRED_DISPOSITION.test(disposition) ? { priority, location, text: finding, deferral: disposition } : { priority, location, text: finding });
84
90
  }
85
91
  i++;
86
92
  }
@@ -193,7 +199,9 @@ function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
193
199
  const runDir = join(root, ".spur", "run");
194
200
  const basePath = join(runDir, `${wbs}-base.sha`);
195
201
  const base = existsSync(basePath) ? readFileSync(basePath, "utf8").trim() : null;
196
- const review = parseReviewFindings(taskContent).map((r) => ({
202
+ const reviewRows = parseReviewFindings(taskContent);
203
+ const tableDeferrals = reviewRows.flatMap((r) => r.deferral === undefined ? [] : [{ id: makeItemId("review-finding", r.location, r.text), reason: r.deferral }]);
204
+ const review = reviewRows.map((r) => ({
197
205
  category: "review-finding",
198
206
  priority: r.priority,
199
207
  location: r.location,
@@ -214,7 +222,7 @@ function scanResiduals(root, wbs, tmpDir, taskContent, _env) {
214
222
  location: p,
215
223
  text: p
216
224
  }));
217
- const items = classify([...review, ...markers, ...boxes, ...residue], readDeferrals(runDir, wbs));
225
+ const items = classify([...review, ...markers, ...boxes, ...residue], [...tableDeferrals, ...readDeferrals(runDir, wbs)]);
218
226
  const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
219
227
  for (const item of items)
220
228
  counts[item.class]++;
@@ -64,7 +64,11 @@ export interface ScanOptions {
64
64
 
65
65
  const MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
66
66
  const PRIORITY_PATTERN = /^P[1-4]/;
67
- const NONE_FINDING = /^(none|—)$/i;
67
+ // Placeholder "no finding" cells, optionally with a trailing "(…)" note; "None of X…" is a real finding.
68
+ const NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
69
+ const DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
70
+ const RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
71
+ const DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
68
72
  const ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
69
73
  /** `path:12-18` range anchor → single-line `path:12`. */
70
74
  const RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
@@ -108,12 +112,16 @@ export function locationOf(locationCell: string, finding: string): string {
108
112
  /**
109
113
  * Extract review-finding rows: any `### Review` section table whose header carries a
110
114
  * Priority column. Rows need `^P[1-4]` priority and a finding other than `none`/`—`.
115
+ * A disposition column (Disposition/Action/Status/Resolution/Fixed) is honored: `FIXED`/
116
+ * `RESOLVED`/`DONE` rows are dropped; `DEFER` rows carry the cell as an in-table deferral reason.
111
117
  */
112
- export function parseReviewFindings(taskContent: string): Array<{ priority: string; location: string; text: string }> {
118
+ export function parseReviewFindings(
119
+ taskContent: string,
120
+ ): Array<{ priority: string; location: string; text: string; deferral?: string }> {
113
121
  const section = taskContent.split(/^### Review\b/m)[1];
114
122
  if (section === undefined) return [];
115
123
  const body = section.split(/^### /m)[0];
116
- const out: Array<{ priority: string; location: string; text: string }> = [];
124
+ const out: Array<{ priority: string; location: string; text: string; deferral?: string }> = [];
117
125
  const lines = body.split('\n');
118
126
  for (let i = 0; i < lines.length; i++) {
119
127
  const line = lines[i];
@@ -127,6 +135,7 @@ export function parseReviewFindings(taskContent: string): Array<{ priority: stri
127
135
  }
128
136
  const findingCol = header.findIndex((h) => h.trim() === 'Finding');
129
137
  const locationCol = header.findIndex((h) => h.trim() === 'Location');
138
+ const dispositionCol = header.findIndex((h) => DISPOSITION_HEADER.test(h.trim()));
130
139
  i++; // skip header
131
140
  const sep = lines[i];
132
141
  if (sep !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(sep)) i++; // skip separator
@@ -136,8 +145,19 @@ export function parseReviewFindings(taskContent: string): Array<{ priority: stri
136
145
  const cells = splitRow(row);
137
146
  const priority = (cells[priorityCol] ?? '').trim();
138
147
  const finding = (cells[findingCol] ?? '').trim();
139
- if (PRIORITY_PATTERN.test(priority) && !NONE_FINDING.test(finding) && finding.length > 0) {
140
- out.push({ priority, location: locationOf(cells[locationCol] ?? '', finding), text: finding });
148
+ const disposition = dispositionCol === -1 ? '' : (cells[dispositionCol] ?? '').trim();
149
+ if (
150
+ PRIORITY_PATTERN.test(priority) &&
151
+ !NONE_FINDING.test(finding) &&
152
+ finding.length > 0 &&
153
+ !RESOLVED_DISPOSITION.test(disposition)
154
+ ) {
155
+ const location = locationOf(cells[locationCol] ?? '', finding);
156
+ out.push(
157
+ DEFERRED_DISPOSITION.test(disposition)
158
+ ? { priority, location, text: finding, deferral: disposition }
159
+ : { priority, location, text: finding },
160
+ );
141
161
  }
142
162
  i++;
143
163
  }
@@ -295,7 +315,11 @@ export function scanResiduals(
295
315
  const runDir = join(root, '.spur', 'run');
296
316
  const basePath = join(runDir, `${wbs}-base.sha`);
297
317
  const base = existsSync(basePath) ? readFileSync(basePath, 'utf8').trim() : null;
298
- const review = parseReviewFindings(taskContent).map((r) => ({
318
+ const reviewRows = parseReviewFindings(taskContent);
319
+ const tableDeferrals = reviewRows.flatMap((r) =>
320
+ r.deferral === undefined ? [] : [{ id: makeItemId('review-finding', r.location, r.text), reason: r.deferral }],
321
+ );
322
+ const review = reviewRows.map((r) => ({
299
323
  category: 'review-finding' as const,
300
324
  priority: r.priority,
301
325
  location: r.location,
@@ -319,7 +343,10 @@ export function scanResiduals(
319
343
  location: p,
320
344
  text: p,
321
345
  }));
322
- const items = classify([...review, ...markers, ...boxes, ...residue], readDeferrals(runDir, wbs));
346
+ const items = classify(
347
+ [...review, ...markers, ...boxes, ...residue],
348
+ [...tableDeferrals, ...readDeferrals(runDir, wbs)],
349
+ );
323
350
  const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
324
351
  for (const item of items) counts[item.class]++;
325
352
  return {