@gobing-ai/spur 0.3.93 → 0.3.95

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 (153) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +42 -0
  3. package/board/index.d.ts +38 -0
  4. package/config/plugin-scripts.json +5 -0
  5. package/config/rules/boundary/test-subpath-boundary.yaml +48 -0
  6. package/config/rules/strict/runtime-boundaries.yaml +2 -1
  7. package/config/rules/structure/protected-files.yaml +4 -0
  8. package/config/workflows/feature-verification.yaml +7 -4
  9. package/config/workflows/idea-pipeline.yaml +10 -4
  10. package/config/workflows/task-pipeline.yaml +71 -52
  11. package/config/workflows/wrapup-pipeline.yaml +92 -17
  12. package/package.json +15 -11
  13. package/plugins/sp/README.md +1 -1
  14. package/plugins/sp/commands/dev-review-session.md +4 -4
  15. package/plugins/sp/commands/dev-review.md +16 -5
  16. package/plugins/sp/commands/dev-verify.md +7 -4
  17. package/plugins/sp/commands/dev-verifyall.md +4 -3
  18. package/plugins/sp/commands/dev-wrap.md +1 -1
  19. package/plugins/sp/hooks/context-post-tool.ts +2 -2
  20. package/plugins/sp/hooks/context-session-start.ts +2 -1
  21. package/plugins/sp/hooks/context-session-stop.ts +22 -14
  22. package/plugins/sp/hooks/pi/guard-extension.ts +112 -172
  23. package/plugins/sp/lib/idea-handoff.generated.mjs +2 -2
  24. package/plugins/sp/lib/inline-run.generated.d.mts +4 -2
  25. package/plugins/sp/lib/inline-run.generated.mjs +35 -14
  26. package/plugins/sp/plugin.json +1 -1
  27. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
  28. package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
  29. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
  30. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
  31. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
  32. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
  33. package/plugins/sp/scripts/feature-sync-bounded.mjs +5 -2
  34. package/plugins/sp/scripts/feature-sync-bounded.ts +3 -1
  35. package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
  36. package/plugins/sp/scripts/history-anatomy-cache.mjs +3 -3
  37. package/plugins/sp/scripts/history-anatomy-cache.ts +4 -4
  38. package/plugins/sp/scripts/inline-run-setup.mjs +60 -12
  39. package/plugins/sp/scripts/inline-run-setup.ts +122 -15
  40. package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
  41. package/plugins/sp/scripts/pr-reviewing.ts +1 -0
  42. package/plugins/sp/scripts/quality-gate.mjs +15 -6
  43. package/plugins/sp/scripts/quality-gate.ts +30 -9
  44. package/plugins/sp/scripts/residual-scan.mjs +4 -4
  45. package/plugins/sp/scripts/residual-scan.ts +5 -4
  46. package/plugins/sp/scripts/script-contract-check.ts +175 -46
  47. package/plugins/sp/scripts/script-root.mjs +125 -0
  48. package/plugins/sp/scripts/script-root.ts +195 -0
  49. package/plugins/sp/scripts/stage-registry-adapter.ts +1 -1
  50. package/plugins/sp/scripts/task-evidence-precheck.ts +4 -2
  51. package/plugins/sp/scripts/task-size-precheck.ts +3 -1
  52. package/plugins/sp/scripts/validate-commands.ts +12 -12
  53. package/plugins/sp/scripts/validate-flag-contracts.ts +42 -30
  54. package/plugins/sp/scripts/verify-answer-lint.ts +3 -1
  55. package/plugins/sp/scripts/workflow-step-profile.mjs +5 -2
  56. package/plugins/sp/scripts/workflow-step-profile.ts +3 -1
  57. package/plugins/sp/scripts/wrapup-drift-probe.mjs +5 -2
  58. package/plugins/sp/scripts/wrapup-drift-probe.ts +4 -2
  59. package/plugins/sp/scripts/wrapup-steps.mjs +32 -8
  60. package/plugins/sp/scripts/wrapup-steps.ts +56 -10
  61. package/plugins/sp/skills/code-verification/SKILL.md +12 -7
  62. package/plugins/sp/skills/code-verification/references/verdict-schema.md +11 -0
  63. package/plugins/sp/skills/functional-review/SKILL.md +1 -1
  64. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  65. package/plugins/sp/skills/session-review/SKILL.md +11 -9
  66. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +9 -1
  67. package/plugins/sp/skills/spur-dev/references/dev-operations.md +8 -2
  68. package/plugins/sp/skills/spur-dev/references/execution-batch.md +65 -17
  69. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +16 -3
  70. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +22 -5
  71. package/plugins/sp/tsconfig.json +8 -0
  72. package/spur.js +20934 -21087
  73. package/web/_astro/{BoardApp.CerSBgis.js → BoardApp.BxJuwD7I.js} +151 -152
  74. package/web/_astro/BoardApp.Cpxntzad.js +1 -0
  75. package/web/_astro/TaskDetail.C5bW4WGV.js +1 -0
  76. package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
  77. package/web/_astro/arc.CyjRvNMY.js +1 -0
  78. package/web/_astro/architectureDiagram-3BPJPVTR.B4lWRzJA.js +36 -0
  79. package/web/_astro/{blockDiagram-GPEHLZMM.DXVIiv0p.js → blockDiagram-GPEHLZMM.Be38USQ2.js} +1 -1
  80. package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
  81. package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
  82. package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
  83. package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
  84. package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
  85. package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
  86. package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
  87. package/web/_astro/{c4Diagram-AAUBKEIU.BbF_zCxW.js → c4Diagram-AAUBKEIU.DT9Fj5Qx.js} +1 -1
  88. package/web/_astro/channel.CI6N_tCg.js +1 -0
  89. package/web/_astro/{chunk-2J33WTMH.CAgQHpPC.js → chunk-2J33WTMH.SeSBWLg5.js} +1 -1
  90. package/web/_astro/{chunk-4BX2VUAB.BN-5tpw4.js → chunk-4BX2VUAB.DoO4VE14.js} +1 -1
  91. package/web/_astro/{chunk-55IACEB6.CnPkEEr0.js → chunk-55IACEB6.DrmTFA53.js} +1 -1
  92. package/web/_astro/{chunk-727SXJPM.BQzQeMVm.js → chunk-727SXJPM.Bi-TUb_V.js} +4 -4
  93. package/web/_astro/{chunk-AQP2D5EJ.B6xNyDnL.js → chunk-AQP2D5EJ.B-xp8Uhi.js} +1 -1
  94. package/web/_astro/{chunk-FMBD7UC4.C7f9Ih78.js → chunk-FMBD7UC4.DGWrDnUU.js} +1 -1
  95. package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
  96. package/web/_astro/{chunk-ND2GUHAM.CNV1dFXT.js → chunk-ND2GUHAM._BagvPDy.js} +1 -1
  97. package/web/_astro/{chunk-QZHKN3VN.Cudn2TkJ.js → chunk-QZHKN3VN.VOboYWQ0.js} +1 -1
  98. package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
  99. package/web/_astro/{classDiagram-4FO5ZUOK.D1NwP50q.js → classDiagram-4FO5ZUOK.D_apfHS7.js} +1 -1
  100. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D1NwP50q.js → classDiagram-v2-Q7XG4LA2.D_apfHS7.js} +1 -1
  101. package/web/_astro/client.BhluLQqe.js +1 -0
  102. package/web/_astro/client.DBpSRLnx.js +9 -0
  103. package/web/_astro/cose-bilkent-S5V4N54A.DNLU_L-x.js +1 -0
  104. package/web/_astro/{cynefin-OW5HDTMX.BmK52w8G.js → cynefin-OW5HDTMX.D8borhV-.js} +1 -1
  105. package/web/_astro/{dagre-BM42HDAG.Bfy5CTDT.js → dagre-BM42HDAG.Uc0lvjcV.js} +2 -2
  106. package/web/_astro/diagram-2AECGRRQ.DDIxtDBo.js +43 -0
  107. package/web/_astro/diagram-5GNKFQAL.DVoDnD3H.js +10 -0
  108. package/web/_astro/{diagram-KO2AKTUF.CW_vMJ4z.js → diagram-KO2AKTUF.CRubTJ-0.js} +3 -3
  109. package/web/_astro/{diagram-LMA3HP47.B_8ZGF67.js → diagram-LMA3HP47.m9SeYcXQ.js} +1 -1
  110. package/web/_astro/{diagram-OG6HWLK6.BppnHsdS.js → diagram-OG6HWLK6.C6s2ZjV5.js} +1 -1
  111. package/web/_astro/{erDiagram-TEJ5UH35.BEuHXcjJ.js → erDiagram-TEJ5UH35.nSzr5KTj.js} +5 -5
  112. package/web/_astro/{flowDiagram-I6XJVG4X.CH-UlnGr.js → flowDiagram-I6XJVG4X.DrZcys16.js} +4 -4
  113. package/web/_astro/ganttDiagram-6RSMTGT7.Do4M1b-K.js +292 -0
  114. package/web/_astro/{gitGraphDiagram-PVQCEYII.XnPxPPZN.js → gitGraphDiagram-PVQCEYII.BWc9oJ4l.js} +1 -1
  115. package/web/_astro/index.5dn-Zm-2.js +1 -0
  116. package/web/_astro/index.C-IqPpW2.js +1 -0
  117. package/web/_astro/index.CENnIEqT.css +1 -0
  118. package/web/_astro/index.Cyc_GA4f.js +1 -0
  119. package/web/_astro/index.tZfqwSut.js +1 -0
  120. package/web/_astro/{infoDiagram-5YYISTIA.JyjYRu_T.js → infoDiagram-5YYISTIA.BIdOctFk.js} +1 -1
  121. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BBRBF-Fo.js → ishikawaDiagram-YF4QCWOH.CV5SuG2t.js} +5 -5
  122. package/web/_astro/{journeyDiagram-JHISSGLW.C_iymSyp.js → journeyDiagram-JHISSGLW.CxE-rEJ-.js} +1 -1
  123. package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
  124. package/web/_astro/{kanban-definition-UN3LZRKU.DdfW-Oqt.js → kanban-definition-UN3LZRKU.BPRdKWs_.js} +7 -7
  125. package/web/_astro/{linear.C2_IkbZT.js → linear.zRsuuDTE.js} +1 -1
  126. package/web/_astro/mermaid.core.jAJTcMKc.js +301 -0
  127. package/web/_astro/{mindmap-definition-RKZ34NQL.DAZIxQSK.js → mindmap-definition-RKZ34NQL.BffJxfCr.js} +2 -2
  128. package/web/_astro/{pieDiagram-4H26LBE5.CN8sIhKM.js → pieDiagram-4H26LBE5.CacwWaE3.js} +3 -3
  129. package/web/_astro/{quadrantDiagram-W4KKPZXB.3dGcX5GP.js → quadrantDiagram-W4KKPZXB.hXELD06-.js} +1 -1
  130. package/web/_astro/{requirementDiagram-4Y6WPE33.BV2y4dd6.js → requirementDiagram-4Y6WPE33.DdEbPcqT.js} +3 -3
  131. package/web/_astro/{sankeyDiagram-5OEKKPKP.Cqo15Tvo.js → sankeyDiagram-5OEKKPKP.DgaDocKv.js} +4 -4
  132. package/web/_astro/{sequenceDiagram-3UESZ5HK.CROCPMJB.js → sequenceDiagram-3UESZ5HK.B0KScnAu.js} +1 -1
  133. package/web/_astro/{stateDiagram-AJRCARHV.RfXZrkFE.js → stateDiagram-AJRCARHV.CV9M_WNc.js} +1 -1
  134. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CPXmbBs9.js → stateDiagram-v2-BHNVJYJU.BOAo74Et.js} +1 -1
  135. package/web/_astro/{timeline-definition-PNZ67QCA.DdgKTiO8.js → timeline-definition-PNZ67QCA.CqqepUMe.js} +3 -3
  136. package/web/_astro/{vennDiagram-CIIHVFJN.CPNVSHF1.js → vennDiagram-CIIHVFJN.DDqe4dOG.js} +5 -5
  137. package/web/_astro/{wardleyDiagram-YWT4CUSO.CQhA0Jyr.js → wardleyDiagram-YWT4CUSO.B3R9OPdO.js} +3 -3
  138. package/web/_astro/{xychartDiagram-2RQKCTM6.n61BWyy4.js → xychartDiagram-2RQKCTM6.B3SvgXpQ.js} +1 -1
  139. package/web/board-runtime.json +69 -0
  140. package/web/index.html +2 -2
  141. package/web/_astro/BoardApp.eoTz0pZs.js +0 -1
  142. package/web/_astro/TaskDetail.DCqiC-OZ.js +0 -1
  143. package/web/_astro/arc.CPwg6Rw0.js +0 -1
  144. package/web/_astro/architectureDiagram-3BPJPVTR.DM_vp_hO.js +0 -36
  145. package/web/_astro/channel.MYZLKNwy.js +0 -1
  146. package/web/_astro/client.yhYJvxCU.js +0 -9
  147. package/web/_astro/cose-bilkent-S5V4N54A.B1wSL-Xb.js +0 -1
  148. package/web/_astro/diagram-2AECGRRQ.DhNnvUvX.js +0 -43
  149. package/web/_astro/diagram-5GNKFQAL.lTX5KwnS.js +0 -10
  150. package/web/_astro/ganttDiagram-6RSMTGT7.BO81S85v.js +0 -292
  151. package/web/_astro/index.De90oHcH.js +0 -1
  152. package/web/_astro/index.Hjbr15fG.css +0 -1
  153. package/web/_astro/mermaid.core.GAOYeSR0.js +0 -303
@@ -34,6 +34,7 @@
34
34
  * bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]
35
35
  * --status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]
36
36
  * bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]
37
+ * bun plugins/sp/scripts/inline-run-setup.ts --persist-out --from <worktree-path> [--task-file <path>]... [--spur-bin <path>]
37
38
  *
38
39
  * The `--fingerprint` mode prints the engine's proof-input digest for the given spec files and
39
40
  * creates nothing (task 0862 R5).
@@ -51,6 +52,18 @@
51
52
  * decision is still `ok: true` — the run continues — so exit 0 covers every model-level
52
53
  * outcome; only an invalid options schema exits 1 (fail closed).
53
54
  *
55
+ * The `--persist-out --from <worktree-path>` mode (task 0975 R1) is the WT-4a step a
56
+ * `--worktree` run executes BEFORE teardown: it copies the worktree's run provenance — the
57
+ * `runs`/`action_runs`/`phase_runs`/`transition_runs`/`workflow_states` rows and the two-file
58
+ * run records — into THIS tree's project DB and `.spur/run/` (cwd = the invoking tree).
59
+ * Repeatable `--task-file <path>` (0984 R2) forwards each merged task file so the app service
60
+ * also copies/verifies the `.spur/run/<file>` evidence that file cites; citation selection,
61
+ * safe-path validation and conflict behavior live entirely in the app service. Idempotent;
62
+ * collisions are reported as `skipped[{id,reason}]`, never overwrites. Exit 0 = persisted
63
+ * (possibly with skips); exit 1 = fail closed — an unreadable/unsafe/over-cap/unresolved
64
+ * citation or a divergent cited file fails the pass too — the driver routes to WT-5 and
65
+ * retains the worktree; usage errors exit 2.
66
+ *
54
67
  * Exit 2 is reserved for usage errors.
55
68
  *
56
69
  * Env: SPUR_BIN
@@ -102,6 +115,13 @@ function usage(): never {
102
115
  console.error(
103
116
  ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]',
104
117
  );
118
+ console.error(
119
+ ' bun plugins/sp/scripts/inline-run-setup.ts --persist-out --from <worktree-path> [--task-file <path>]... [--spur-bin <path>]',
120
+ );
121
+ console.error(
122
+ ' terminal-reason is a closed enum (0937 R2): done, paused-operator, failed-check, failed-agent, ' +
123
+ 'failed-timeout, failed-guard, cancelled, interrupted, retry-exhausted',
124
+ );
105
125
  console.error(
106
126
  ' bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]',
107
127
  );
@@ -313,7 +333,12 @@ async function printFingerprint(taskFile: string, featureFile: string, spurBin:
313
333
  }
314
334
 
315
335
  /** Terminal statuses the inline driver may declare when closing its run row. */
316
- const CLOSE_STATUSES = new Set(['done', 'failed', 'paused']);
336
+ const CLOSE_STATUSES: ReadonlySet<string> = new Set<'done' | 'failed' | 'paused'>(['done', 'failed', 'paused']);
337
+
338
+ /** Narrow an argv `--status` to the close vocabulary (`CLOSE_STATUSES`). */
339
+ function isCloseStatus(status: string): status is 'done' | 'failed' | 'paused' {
340
+ return CLOSE_STATUSES.has(status);
341
+ }
317
342
 
318
343
  /**
319
344
  * 0937 R2: closed terminal-reason vocabulary the driver may declare on `--close`.
@@ -334,7 +359,12 @@ const TERMINAL_REASONS = new Set([
334
359
  ]);
335
360
 
336
361
  /** Finalize statuses — a finish emission is terminal, so only done|failed are valid (0868 #4). */
337
- const ACTION_STATUSES = new Set(['done', 'failed']);
362
+ const ACTION_STATUSES: ReadonlySet<string> = new Set<'done' | 'failed'>(['done', 'failed']);
363
+
364
+ /** Narrow an argv `--status` to the finalize vocabulary (`ACTION_STATUSES`). */
365
+ function isActionStatus(status: string): status is 'done' | 'failed' {
366
+ return ACTION_STATUSES.has(status);
367
+ }
338
368
 
339
369
  /** Input for the ADR-117 emission modes (`--action` / `--close`). */
340
370
  import type { WorkflowActionTraceWriter } from '@gobing-ai/spur-app';
@@ -346,7 +376,12 @@ interface TraceModeInput {
346
376
  readonly kind: string;
347
377
  /** Declared terminal reason (0937 R2) — validated against TERMINAL_REASONS before this point. */
348
378
  readonly reason?: string;
349
- readonly status: string;
379
+ /**
380
+ * Trace status: the finalize vocabulary plus the close-only `paused` (`CLOSE_STATUSES`).
381
+ * Both are assignable to the engine's `WorkflowStatus`; the plugin cannot import that
382
+ * type (standalone contract), so the literal union stands in for it.
383
+ */
384
+ readonly status: 'done' | 'failed' | 'paused';
350
385
  readonly ok: boolean;
351
386
  readonly durationMs: number;
352
387
  readonly spurBin: string;
@@ -371,7 +406,7 @@ function runRecordLogPath(runDir: string, runId: string): string {
371
406
  * exit immediately after), and never throws: an unwritable log must not wedge the run
372
407
  * (ADR-117 R3).
373
408
  */
374
- function appendTraceFailureLine(runId: string, detail: string): void {
409
+ function appendRunLogLine(runId: string, detail: string): void {
375
410
  try {
376
411
  const runDir = join(process.cwd(), '.spur', 'run');
377
412
  if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
@@ -393,7 +428,7 @@ function appendTraceFailureLine(runId: string, detail: string): void {
393
428
  async function runTraceMode(input: TraceModeInput): Promise<number> {
394
429
  const operation = input.close ? 'run.close' : 'action.finish';
395
430
  const fail = (error: string): number => {
396
- appendTraceFailureLine(
431
+ appendRunLogLine(
397
432
  input.runId,
398
433
  `trace-emission-failed operation=${operation} run=${input.runId}` +
399
434
  `${input.node === '' ? '' : ` node=${input.node}`}${input.kind === '' ? '' : ` kind=${input.kind}`}: ${error}`,
@@ -420,7 +455,7 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
420
455
  projectDb = await app.openInlineRunProjectDb(process.cwd());
421
456
  const writer = app.createWorkflowActionTraceWriter(projectDb.adapter, (failure: unknown) => {
422
457
  const detail = failure as { operation?: string; error?: string };
423
- appendTraceFailureLine(
458
+ appendRunLogLine(
424
459
  input.runId,
425
460
  `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? 'unknown error'}`,
426
461
  );
@@ -443,6 +478,18 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
443
478
  const failure = result.failure as { error?: string };
444
479
  return fail(failure.error ?? 'unknown trace emission failure');
445
480
  }
481
+ if (input.close && input.status === 'done' && result.actionRows === 0) {
482
+ // A run finalized `done` with ZERO recorded action rows is a bookkeeping defect
483
+ // (task 0975 R2): the row is already terminal — closeRun ran above — but the
484
+ // driver must surface this instead of reporting a clean close, and must never
485
+ // backfill rows. Exit 1 with the named code; the run record carries the finding.
486
+ const error = `run ${input.runId} closed done with zero action_runs rows`;
487
+ appendRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${error}`);
488
+ process.stdout.write(
489
+ `${JSON.stringify({ ok: false, runId: input.runId, error, code: 'NO_ACTION_ROWS', actionRows: 0 })}\n`,
490
+ );
491
+ return 1;
492
+ }
446
493
  process.stdout.write(`${JSON.stringify({ ...result, runId: input.runId })}\n`);
447
494
  return 0;
448
495
  } catch (error) {
@@ -450,7 +497,7 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
450
497
  // The run row must exist before --close can mark it terminal (R6); a missing row
451
498
  // is a loud correctness failure, not a best-effort emission failure (finding #4).
452
499
  const message = error instanceof Error ? error.message : String(error);
453
- appendTraceFailureLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`);
500
+ appendRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`);
454
501
  process.stdout.write(
455
502
  `${JSON.stringify({ ok: false, runId: input.runId, error: message, code: 'RUN_NOT_FOUND' })}\n`,
456
503
  );
@@ -485,6 +532,7 @@ async function runDecideMode(input: {
485
532
  value?: string;
486
533
  degraded?: boolean;
487
534
  reason?: string;
535
+ source?: 'model' | 'default';
488
536
  backend?: string | null;
489
537
  confidence?: number | null;
490
538
  resultFile?: string;
@@ -524,7 +572,13 @@ async function runDecideMode(input: {
524
572
  return decideFailed(error instanceof Error ? error.message : String(error));
525
573
  }
526
574
  if (!outcome.ok) return decideFailed(outcome.error ?? 'decide failed without an error message');
527
- process.stdout.write(`${JSON.stringify({ ok: true, runId: input.runId, node: input.node, ...outcome })}\n`);
575
+ process.stdout.write(`${JSON.stringify({ runId: input.runId, node: input.node, ...outcome, ok: true })}\n`);
576
+ // 0976 R2: the run log names the decision's provenance, so a declared-default fallback is
577
+ // never read as a model decision. Best-effort through the same run-log appender.
578
+ appendRunLogLine(
579
+ input.runId,
580
+ `decide node=${input.node} value=${outcome.value ?? ''} source=${outcome.source ?? 'default'} reason=${outcome.reason ?? ''}`,
581
+ );
528
582
  // Trace row is best-effort, exactly like --action: an emission failure never wedges the run.
529
583
  return await runTraceMode({
530
584
  runId: input.runId,
@@ -538,6 +592,44 @@ async function runDecideMode(input: {
538
592
  });
539
593
  }
540
594
 
595
+ /**
596
+ * `--persist-out --from <worktree-path>` (task 0975 R1; `--task-file` per 0984 R2): resolve
597
+ * the app through the shared chain and copy the worktree's inline-run provenance into THIS
598
+ * tree. No persistence policy lives here — the app's `persistWorktreeRuns` (→ domain
599
+ * `transferRunTables`) owns the DB transfer, the citation selection/copy rules, the
600
+ * idempotence and the conflict skips; this delegate only forwards the merged task file
601
+ * paths. Success prints `{ok:true,persisted:<n>,skipped:[…]}` and exits 0; any failure
602
+ * prints `{ok:false,error}` and exits 1 (the driver routes to WT-5 and retains the
603
+ * worktree).
604
+ */
605
+ async function runPersistOutMode(input: {
606
+ from: string;
607
+ taskFiles: readonly string[];
608
+ spurBin: string;
609
+ }): Promise<number> {
610
+ try {
611
+ const { entry } = resolveAppEntry(input.spurBin);
612
+ const app = (await import(entry)) as {
613
+ persistWorktreeRuns: (i: {
614
+ fromWorkdir: string;
615
+ toWorkdir: string;
616
+ taskFiles?: readonly string[];
617
+ }) => Promise<{ ok: true; persisted: number; skipped: ReadonlyArray<{ id: string; reason: string }> }>;
618
+ };
619
+ const result = await app.persistWorktreeRuns({
620
+ fromWorkdir: input.from,
621
+ toWorkdir: process.cwd(),
622
+ ...(input.taskFiles.length > 0 ? { taskFiles: input.taskFiles } : {}),
623
+ });
624
+ process.stdout.write(`${JSON.stringify({ ok: true, persisted: result.persisted, skipped: result.skipped })}\n`);
625
+ return 0;
626
+ } catch (error) {
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\n`);
629
+ return 1;
630
+ }
631
+ }
632
+
541
633
  async function main(): Promise<void> {
542
634
  // The portable Node twin has no workspace imports; SQLite still uses Spur's existing Bun runtime.
543
635
  if (!process.versions.bun) {
@@ -550,11 +642,13 @@ async function main(): Promise<void> {
550
642
  let runId = '';
551
643
  let file = '';
552
644
  let fingerprint = false;
553
- let taskFile = '';
645
+ const taskFiles: string[] = [];
554
646
  let featureFile = '';
555
647
  let action = false;
556
648
  let close = false;
557
649
  let decide = false;
650
+ let persistOut = false;
651
+ let from = '';
558
652
  let optionsJson = '';
559
653
  let node = '';
560
654
  let kind = '';
@@ -568,11 +662,13 @@ async function main(): Promise<void> {
568
662
  if (argv[i] === '--run-id') runId = argv[++i] ?? '';
569
663
  else if (argv[i] === '--file') file = argv[++i] ?? '';
570
664
  else if (argv[i] === '--fingerprint') fingerprint = true;
571
- else if (argv[i] === '--task-file') taskFile = argv[++i] ?? '';
665
+ else if (argv[i] === '--task-file') taskFiles.push(argv[++i] ?? '');
572
666
  else if (argv[i] === '--feature-file') featureFile = argv[++i] ?? '';
573
667
  else if (argv[i] === '--action') action = true;
574
668
  else if (argv[i] === '--close') close = true;
575
669
  else if (argv[i] === '--decide') decide = true;
670
+ else if (argv[i] === '--persist-out') persistOut = true;
671
+ else if (argv[i] === '--from') from = argv[++i] ?? '';
576
672
  else if (argv[i] === '--options-json') optionsJson = argv[++i] ?? '';
577
673
  else if (argv[i] === '--node') node = argv[++i] ?? '';
578
674
  else if (argv[i] === '--kind') kind = argv[++i] ?? '';
@@ -585,9 +681,10 @@ async function main(): Promise<void> {
585
681
 
586
682
  // Two mutually exclusive modes share this entry point: create/attach a run (run-id + file), or
587
683
  // print the proof digest for the inline driver (task 0862 R5). Mixing them is a usage error.
684
+ // `--task-file` belongs to the fingerprint (exactly one) and persist-out (zero or more) modes.
588
685
  if (fingerprint) {
589
- if (runId !== '' || file !== '' || taskFile.trim() === '') usage();
590
- process.exit(await printFingerprint(taskFile, featureFile, spurBin));
686
+ if (runId !== '' || file !== '' || taskFiles.length !== 1 || (taskFiles[0] ?? '').trim() === '') usage();
687
+ process.exit(await printFingerprint(taskFiles[0] ?? '', featureFile, spurBin));
591
688
  }
592
689
 
593
690
  // ADR-117 emission modes (task 0868): the inline driver reports one completed action
@@ -597,18 +694,28 @@ async function main(): Promise<void> {
597
694
  // fingerprint, and trace modes; it requires the run id (filename-guarded), the state id
598
695
  // for the trace row, and the options JSON file.
599
696
  if (decide) {
600
- if (action || close || fingerprint || file !== '' || taskFile !== '') usage();
697
+ if (action || close || fingerprint || file !== '' || taskFiles.length > 0) usage();
601
698
  if (runId.trim() === '' || node.trim() === '' || optionsJson.trim() === '') usage();
602
699
  if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
603
700
  process.exit(await runDecideMode({ runId, node, optionsFile: optionsJson, spurBin }));
604
701
  }
605
702
 
703
+ // Worktree provenance persist-out (task 0975 R1): mutually exclusive with every other
704
+ // mode; requires the source worktree path and accepts zero or more merged task files
705
+ // (0984 R2) whose cited `.spur/run/` evidence the app service copies/verifies. Runs
706
+ // with cwd = the invoking tree.
707
+ if (persistOut) {
708
+ if (fingerprint || decide || action || close || runId !== '' || file !== '') usage();
709
+ if (from.trim() === '' || taskFiles.some((taskFile) => taskFile.trim() === '')) usage();
710
+ process.exit(await runPersistOutMode({ from, taskFiles, spurBin }));
711
+ }
712
+
606
713
  if (action || close) {
607
714
  if (action && close) usage();
608
715
  if (runId.trim() === '' || status.trim() === '') usage();
609
716
  if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
610
717
  if (close) {
611
- if (!CLOSE_STATUSES.has(status)) usage();
718
+ if (!isCloseStatus(status)) usage();
612
719
  // 0937 R2: a failed close requires a declared reason, and any declared reason
613
720
  // must be a closed-enum value — both fail loudly BEFORE any write happens.
614
721
  if (reason.trim() === '') {
@@ -631,7 +738,7 @@ async function main(): Promise<void> {
631
738
  );
632
739
  }
633
740
  if (node.trim() === '' || kind.trim() === '') usage();
634
- if (!ACTION_STATUSES.has(status)) usage();
741
+ if (!isActionStatus(status)) usage();
635
742
  // `--ok` and `--duration-ms` are required and exact for the action mode
636
743
  // (review finding #2): a miscased `--ok True` or an omitted `--duration-ms`
637
744
  // must be a loud usage error, never a silently-defaulted `ok=0` /
@@ -50,6 +50,8 @@ function parseArgs(argv) {
50
50
  const booleans = new Set;
51
51
  for (let i = 0;i < rest.length; i++) {
52
52
  const tok = rest[i];
53
+ if (tok === undefined)
54
+ continue;
53
55
  if (BOOL_FLAGS.has(tok)) {
54
56
  booleans.add(tok);
55
57
  } else if (VALUE_FLAGS.has(tok)) {
@@ -169,6 +169,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
169
169
  const booleans = new Set<string>();
170
170
  for (let i = 0; i < rest.length; i++) {
171
171
  const tok = rest[i];
172
+ if (tok === undefined) continue;
172
173
  if (BOOL_FLAGS.has(tok)) {
173
174
  booleans.add(tok);
174
175
  } else if (VALUE_FLAGS.has(tok)) {
@@ -154,6 +154,9 @@ function lightScope(changedFiles, exists = existsSync) {
154
154
  }
155
155
  return { files, workspaces: [...workspaces].sort(), tests: [...tests].sort() };
156
156
  }
157
+ function shQuote(arg) {
158
+ return /^[\w./@+-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
159
+ }
157
160
  function workspaceHasTypecheck(workspace) {
158
161
  try {
159
162
  const pkg = JSON.parse(readFileSync(join(workspace, "package.json"), "utf8"));
@@ -165,11 +168,11 @@ function workspaceHasTypecheck(workspace) {
165
168
  function planLightChecks(scope, hasTypecheck = workspaceHasTypecheck) {
166
169
  const plans = [];
167
170
  if (scope.files.length > 0) {
168
- plans.push({ id: "format-lint:changed", cmd: `bunx biome check ${scope.files.join(" ")}` });
171
+ plans.push({ id: "format-lint:changed", cmd: `bunx biome check ${scope.files.map(shQuote).join(" ")}` });
169
172
  }
170
173
  for (const workspace of scope.workspaces) {
171
174
  if (hasTypecheck(workspace)) {
172
- plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${workspace} && bun run typecheck` });
175
+ plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${shQuote(workspace)} && bun run typecheck` });
173
176
  }
174
177
  }
175
178
  const testsByWorkspace = new Map;
@@ -180,7 +183,10 @@ function planLightChecks(scope, hasTypecheck = workspaceHasTypecheck) {
180
183
  testsByWorkspace.set(workspace, paths);
181
184
  }
182
185
  for (const [workspace, paths] of testsByWorkspace) {
183
- plans.push({ id: `test:${workspace}`, cmd: `cd ${workspace} && bun test ${paths.join(" ")}` });
186
+ plans.push({
187
+ id: `test:${workspace}`,
188
+ cmd: `cd ${shQuote(workspace)} && bun test ${paths.map(shQuote).join(" ")}`
189
+ });
184
190
  }
185
191
  return plans;
186
192
  }
@@ -376,7 +382,7 @@ function runQualityGate(mode, env, options = {}) {
376
382
  appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
377
383
  `);
378
384
  let receiptFile;
379
- if (mode === "run") {
385
+ if (!noProgressSkip) {
380
386
  if ((env.proofDigest ?? "").length > 0) {
381
387
  receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
382
388
  const receipt = buildReceipt({
@@ -404,17 +410,19 @@ function runQualityGate(mode, env, options = {}) {
404
410
  return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile, receiptFile };
405
411
  }
406
412
  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 = {}) {
413
+ function main(argv, rawEnv = getEnvVars(), options = {}) {
408
414
  const mode = argv[0];
409
415
  if (mode !== "run" && mode !== "recheck" && mode !== "light" && mode !== "status") {
410
416
  process.stderr.write(`${QUALITY_GATE_USAGE}
411
417
  `);
412
418
  return 2;
413
419
  }
414
- if ((env.wbs ?? "").length === 0) {
420
+ const wbs = rawEnv.wbs ?? "";
421
+ if (wbs.length === 0) {
415
422
  process.stderr.write("quality-gate: env `wbs` is required\n");
416
423
  return 2;
417
424
  }
425
+ const env = { ...rawEnv, wbs };
418
426
  if (mode === "light") {
419
427
  runLightGate(env);
420
428
  } else if (mode === "status") {
@@ -439,6 +447,7 @@ function main(argv, env = getEnvVars(), options = {}) {
439
447
  export {
440
448
  workspaceHasTypecheck,
441
449
  tailLines,
450
+ shQuote,
442
451
  scanCoverageShortfalls,
443
452
  runShellCommand,
444
453
  runQualityGate,
@@ -26,8 +26,8 @@
26
26
  * output matches a SQLite busy/locked error; the delay defaults to 10 seconds and is
27
27
  * overridable via `SPUR_QUALITY_GATE_RETRY_DELAY_MS` (tests).
28
28
  *
29
- * Check receipts (task 0939, ADR-124): `run` writes `.spur/run/<wbs>-check-receipt.json`
30
- * (`check-receipt/v1`) when `proofDigest` is set; without a digest no receipt is written and the
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
31
  * log says why. The gate executes `qualityGateCmd` as one unit, so the full-tier receipt carries
32
32
  * a single `test` row for the whole `bun run spur-check` chain (lint | typecheck | test-pre-check
33
33
  * | test | test-post-check). `light` accumulates: a sub-check already PASS in a light receipt at
@@ -335,6 +335,15 @@ export function lightScope(changedFiles: string[], exists: (p: string) => boolea
335
335
  return { files, workspaces: [...workspaces].sort(), tests: [...tests].sort() };
336
336
  }
337
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
+
338
347
  export interface LightCheckPlan {
339
348
  id: string;
340
349
  cmd: string;
@@ -364,11 +373,11 @@ export function planLightChecks(
364
373
  ): LightCheckPlan[] {
365
374
  const plans: LightCheckPlan[] = [];
366
375
  if (scope.files.length > 0) {
367
- plans.push({ id: 'format-lint:changed', cmd: `bunx biome check ${scope.files.join(' ')}` });
376
+ plans.push({ id: 'format-lint:changed', cmd: `bunx biome check ${scope.files.map(shQuote).join(' ')}` });
368
377
  }
369
378
  for (const workspace of scope.workspaces) {
370
379
  if (hasTypecheck(workspace)) {
371
- plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${workspace} && bun run typecheck` });
380
+ plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${shQuote(workspace)} && bun run typecheck` });
372
381
  }
373
382
  }
374
383
  const testsByWorkspace = new Map<string, string[]>();
@@ -379,7 +388,10 @@ export function planLightChecks(
379
388
  testsByWorkspace.set(workspace, paths);
380
389
  }
381
390
  for (const [workspace, paths] of testsByWorkspace) {
382
- plans.push({ id: `test:${workspace}`, cmd: `cd ${workspace} && bun test ${paths.join(' ')}` });
391
+ plans.push({
392
+ id: `test:${workspace}`,
393
+ cmd: `cd ${shQuote(workspace)} && bun test ${paths.map(shQuote).join(' ')}`,
394
+ });
383
395
  }
384
396
  return plans;
385
397
  }
@@ -622,9 +634,12 @@ export function runQualityGate(
622
634
  writeFileSync(abs(statusFile), `${status}\n`);
623
635
  appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ''}\n`);
624
636
 
625
- // 0939 R2: only `run` writes the full-tier receipt, and only with a digest to bind it to.
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.
626
641
  let receiptFile: string | undefined;
627
- if (mode === 'run') {
642
+ if (!noProgressSkip) {
628
643
  if ((env.proofDigest ?? '').length > 0) {
629
644
  receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
630
645
  const receipt = buildReceipt({
@@ -655,16 +670,22 @@ export function runQualityGate(
655
670
  export const QUALITY_GATE_USAGE =
656
671
  'usage: quality-gate.ts <run|recheck|light|status> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest, runId)';
657
672
 
658
- export function main(argv: string[], env: QualityGateEnv = getEnvVars(), options: QualityGateOptions = {}): number {
673
+ export function main(
674
+ argv: string[],
675
+ rawEnv: Partial<QualityGateEnv> = getEnvVars(),
676
+ options: QualityGateOptions = {},
677
+ ): number {
659
678
  const mode = argv[0];
660
679
  if (mode !== 'run' && mode !== 'recheck' && mode !== 'light' && mode !== 'status') {
661
680
  process.stderr.write(`${QUALITY_GATE_USAGE}\n`);
662
681
  return 2;
663
682
  }
664
- if ((env.wbs ?? '').length === 0) {
683
+ const wbs = rawEnv.wbs ?? '';
684
+ if (wbs.length === 0) {
665
685
  process.stderr.write('quality-gate: env `wbs` is required\n');
666
686
  return 2;
667
687
  }
688
+ const env: QualityGateEnv = { ...rawEnv, wbs };
668
689
  if (mode === 'light') {
669
690
  runLightGate(env);
670
691
  } else if (mode === 'status') {
@@ -17,7 +17,7 @@ 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
21
  var DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
22
22
  var RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
23
23
  var DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -47,14 +47,14 @@ function locationOf(locationCell, finding) {
47
47
  const cell = locationCell.trim().replace(/`/g, "");
48
48
  if (cell.length > 0 && cell !== "\u2014")
49
49
  return normalizeAnchor(cell);
50
- const backtick = finding.match(/`([^`]+)`/);
51
- return backtick === null ? "" : normalizeAnchor(backtick[1]);
50
+ const backtick = finding.match(/`([^`]+)`/)?.[1];
51
+ return backtick === undefined ? "" : normalizeAnchor(backtick);
52
52
  }
53
53
  function parseReviewFindings(taskContent) {
54
54
  const section = taskContent.split(/^### Review\b/m)[1];
55
55
  if (section === undefined)
56
56
  return [];
57
- const body = section.split(/^### /m)[0];
57
+ const body = section.split(/^### /m)[0] ?? "";
58
58
  const out = [];
59
59
  const lines = body.split(`
60
60
  `);
@@ -64,7 +64,8 @@ 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;
68
69
  const DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
69
70
  const RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
70
71
  const DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
@@ -104,8 +105,8 @@ export function normalizeAnchor(location: string): string {
104
105
  export function locationOf(locationCell: string, finding: string): string {
105
106
  const cell = locationCell.trim().replace(/`/g, '');
106
107
  if (cell.length > 0 && cell !== '—') return normalizeAnchor(cell);
107
- const backtick = finding.match(/`([^`]+)`/);
108
- return backtick === null ? '' : normalizeAnchor(backtick[1]);
108
+ const backtick = finding.match(/`([^`]+)`/)?.[1];
109
+ return backtick === undefined ? '' : normalizeAnchor(backtick);
109
110
  }
110
111
 
111
112
  /**
@@ -119,7 +120,7 @@ export function parseReviewFindings(
119
120
  ): Array<{ priority: string; location: string; text: string; deferral?: string }> {
120
121
  const section = taskContent.split(/^### Review\b/m)[1];
121
122
  if (section === undefined) return [];
122
- const body = section.split(/^### /m)[0];
123
+ const body = section.split(/^### /m)[0] ?? '';
123
124
  const out: Array<{ priority: string; location: string; text: string; deferral?: string }> = [];
124
125
  const lines = body.split('\n');
125
126
  for (let i = 0; i < lines.length; i++) {