@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.92",
3
+ "version": "0.3.94",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -45,11 +45,11 @@ const DOCUMENTED = {
45
45
  'file.read.into-var',
46
46
  'hitl.confirm',
47
47
  'hitl.input',
48
- 'hitl.select',
49
48
  'agent.run',
50
49
  'proof.fingerprint',
51
50
  'run.artifact',
52
51
  'command.gate',
52
+ 'decide',
53
53
  ]),
54
54
  guards: new Set(['always', 'shell', 'action-ok', 'contract-violation']),
55
55
  } as const;
@@ -29,7 +29,10 @@ function usage() {
29
29
  console.error("Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]");
30
30
  console.error(" bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file <path> [--feature-file <path>] [--spur-bin <path>]");
31
31
  console.error(" bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> " + "--status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]");
32
- console.error(" bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--spur-bin <path>]");
32
+ console.error(" bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]");
33
+ console.error(" bun plugins/sp/scripts/inline-run-setup.ts --persist-out --from <worktree-path> [--spur-bin <path>]");
34
+ console.error(" terminal-reason is a closed enum (0937 R2): done, paused-operator, failed-check, failed-agent, " + "failed-timeout, failed-guard, cancelled, interrupted, retry-exhausted");
35
+ console.error(" bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]");
33
36
  process.exit(2);
34
37
  }
35
38
  var SAFE_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -151,6 +154,17 @@ async function printFingerprint(taskFile, featureFile, spurBin) {
151
154
  return 0;
152
155
  }
153
156
  var CLOSE_STATUSES = new Set(["done", "failed", "paused"]);
157
+ var TERMINAL_REASONS = new Set([
158
+ "done",
159
+ "paused-operator",
160
+ "failed-check",
161
+ "failed-agent",
162
+ "failed-timeout",
163
+ "failed-guard",
164
+ "cancelled",
165
+ "interrupted",
166
+ "retry-exhausted"
167
+ ]);
154
168
  var ACTION_STATUSES = new Set(["done", "failed"]);
155
169
  function runRecordLogPath(runDir, runId) {
156
170
  const markdownPath = join(runDir, `${runId}.md`);
@@ -159,7 +173,7 @@ function runRecordLogPath(runDir, runId) {
159
173
  return legacyLogPath;
160
174
  return markdownPath;
161
175
  }
162
- function appendTraceFailureLine(runId, detail) {
176
+ function appendRunLogLine(runId, detail) {
163
177
  try {
164
178
  const runDir = join(process.cwd(), ".spur", "run");
165
179
  if (!existsSync(runDir))
@@ -173,7 +187,7 @@ function appendTraceFailureLine(runId, detail) {
173
187
  async function runTraceMode(input) {
174
188
  const operation = input.close ? "run.close" : "action.finish";
175
189
  const fail = (error) => {
176
- appendTraceFailureLine(input.runId, `trace-emission-failed operation=${operation} run=${input.runId}${input.node === "" ? "" : ` node=${input.node}`}${input.kind === "" ? "" : ` kind=${input.kind}`}: ${error}`);
190
+ appendRunLogLine(input.runId, `trace-emission-failed operation=${operation} run=${input.runId}${input.node === "" ? "" : ` node=${input.node}`}${input.kind === "" ? "" : ` kind=${input.kind}`}: ${error}`);
177
191
  process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error })}
178
192
  `);
179
193
  return input.close ? 1 : 0;
@@ -185,9 +199,9 @@ async function runTraceMode(input) {
185
199
  projectDb = await app.openInlineRunProjectDb(process.cwd());
186
200
  const writer = app.createWorkflowActionTraceWriter(projectDb.adapter, (failure) => {
187
201
  const detail = failure;
188
- appendTraceFailureLine(input.runId, `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? "unknown error"}`);
202
+ appendRunLogLine(input.runId, `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? "unknown error"}`);
189
203
  });
190
- const result = input.close ? await writer.closeRun(input.runId, input.status) : await writer.recordAction({
204
+ const result = input.close ? await writer.closeRun(input.runId, input.status, undefined, input.reason) : await writer.recordAction({
191
205
  runId: input.runId,
192
206
  node: input.node,
193
207
  kind: input.kind,
@@ -199,13 +213,20 @@ async function runTraceMode(input) {
199
213
  const failure = result.failure;
200
214
  return fail(failure.error ?? "unknown trace emission failure");
201
215
  }
216
+ if (input.close && input.status === "done" && result.actionRows === 0) {
217
+ const error = `run ${input.runId} closed done with zero action_runs rows`;
218
+ appendRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${error}`);
219
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error, code: "NO_ACTION_ROWS", actionRows: 0 })}
220
+ `);
221
+ return 1;
222
+ }
202
223
  process.stdout.write(`${JSON.stringify({ ...result, runId: input.runId })}
203
224
  `);
204
225
  return 0;
205
226
  } catch (error) {
206
227
  if (input.close && error.name === "RunRowNotFoundError") {
207
228
  const message = error instanceof Error ? error.message : String(error);
208
- appendTraceFailureLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`);
229
+ appendRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`);
209
230
  process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error: message, code: "RUN_NOT_FOUND" })}
210
231
  `);
211
232
  return 1;
@@ -215,6 +236,57 @@ async function runTraceMode(input) {
215
236
  projectDb?.close();
216
237
  }
217
238
  }
239
+ async function runDecideMode(input) {
240
+ const decideFailed = (error) => {
241
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error })}
242
+ `);
243
+ return 1;
244
+ };
245
+ let outcome;
246
+ try {
247
+ const { entry, portable } = resolveAppEntry(input.spurBin);
248
+ const app = await import(entry);
249
+ const lib = await import(fileURLToPath(new URL("../lib/inline-run.generated.mjs", import.meta.url)));
250
+ const enabled = await lib.resolveDecideDecisionMakerEnabled(process.cwd(), portable ? { embeddedSchemas: lib.EMBEDDED_SPUR_SCHEMAS } : undefined);
251
+ outcome = await app.runDecideForInlineRun({
252
+ workdir: process.cwd(),
253
+ optionsFile: input.optionsFile,
254
+ enabled
255
+ });
256
+ } catch (error) {
257
+ return decideFailed(error instanceof Error ? error.message : String(error));
258
+ }
259
+ if (!outcome.ok)
260
+ return decideFailed(outcome.error ?? "decide failed without an error message");
261
+ process.stdout.write(`${JSON.stringify({ ok: true, runId: input.runId, node: input.node, ...outcome })}
262
+ `);
263
+ appendRunLogLine(input.runId, `decide node=${input.node} value=${outcome.value ?? ""} source=${outcome.source ?? "default"} reason=${outcome.reason ?? ""}`);
264
+ return await runTraceMode({
265
+ runId: input.runId,
266
+ close: false,
267
+ node: input.node,
268
+ kind: "decide",
269
+ status: "done",
270
+ ok: true,
271
+ durationMs: outcome.durationMs ?? 0,
272
+ spurBin: input.spurBin
273
+ });
274
+ }
275
+ async function runPersistOutMode(input) {
276
+ try {
277
+ const { entry } = resolveAppEntry(input.spurBin);
278
+ const app = await import(entry);
279
+ const result = await app.persistWorktreeRuns({ fromWorkdir: input.from, toWorkdir: process.cwd() });
280
+ process.stdout.write(`${JSON.stringify({ ok: true, persisted: result.persisted, skipped: result.skipped })}
281
+ `);
282
+ return 0;
283
+ } catch (error) {
284
+ const message = error instanceof Error ? error.message : String(error);
285
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message })}
286
+ `);
287
+ return 1;
288
+ }
289
+ }
218
290
  async function main() {
219
291
  if (!process.versions.bun) {
220
292
  const child = spawnSync("bun", [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
@@ -231,9 +303,14 @@ async function main() {
231
303
  let featureFile = "";
232
304
  let action = false;
233
305
  let close = false;
306
+ let decide = false;
307
+ let persistOut = false;
308
+ let from = "";
309
+ let optionsJson = "";
234
310
  let node = "";
235
311
  let kind = "";
236
312
  let status = "";
313
+ let reason = "";
237
314
  let okRaw = "";
238
315
  let durationRaw = "";
239
316
  let spurBin = getEnvVar("SPUR_BIN") ?? "";
@@ -253,12 +330,22 @@ async function main() {
253
330
  action = true;
254
331
  else if (argv[i] === "--close")
255
332
  close = true;
333
+ else if (argv[i] === "--decide")
334
+ decide = true;
335
+ else if (argv[i] === "--persist-out")
336
+ persistOut = true;
337
+ else if (argv[i] === "--from")
338
+ from = argv[++i] ?? "";
339
+ else if (argv[i] === "--options-json")
340
+ optionsJson = argv[++i] ?? "";
256
341
  else if (argv[i] === "--node")
257
342
  node = argv[++i] ?? "";
258
343
  else if (argv[i] === "--kind")
259
344
  kind = argv[++i] ?? "";
260
345
  else if (argv[i] === "--status")
261
346
  status = argv[++i] ?? "";
347
+ else if (argv[i] === "--reason")
348
+ reason = argv[++i] ?? "";
262
349
  else if (argv[i] === "--ok")
263
350
  okRaw = argv[++i] ?? "";
264
351
  else if (argv[i] === "--duration-ms")
@@ -271,6 +358,22 @@ async function main() {
271
358
  usage();
272
359
  process.exit(await printFingerprint(taskFile, featureFile, spurBin));
273
360
  }
361
+ if (decide) {
362
+ if (action || close || fingerprint || file !== "" || taskFile !== "")
363
+ usage();
364
+ if (runId.trim() === "" || node.trim() === "" || optionsJson.trim() === "")
365
+ usage();
366
+ if (!SAFE_RUN_ID_RE.test(runId))
367
+ refuseUnsafeRunId(runId);
368
+ process.exit(await runDecideMode({ runId, node, optionsFile: optionsJson, spurBin }));
369
+ }
370
+ if (persistOut) {
371
+ if (fingerprint || decide || action || close || runId !== "" || file !== "" || taskFile !== "")
372
+ usage();
373
+ if (from.trim() === "")
374
+ usage();
375
+ process.exit(await runPersistOutMode({ from, spurBin }));
376
+ }
274
377
  if (action || close) {
275
378
  if (action && close)
276
379
  usage();
@@ -281,6 +384,12 @@ async function main() {
281
384
  if (close) {
282
385
  if (!CLOSE_STATUSES.has(status))
283
386
  usage();
387
+ if (reason.trim() === "") {
388
+ if (status === "failed")
389
+ usage();
390
+ } else if (!TERMINAL_REASONS.has(reason)) {
391
+ usage();
392
+ }
284
393
  process.exit(await runTraceMode({
285
394
  runId,
286
395
  close: true,
@@ -289,7 +398,8 @@ async function main() {
289
398
  status,
290
399
  ok: true,
291
400
  durationMs: 0,
292
- spurBin
401
+ spurBin,
402
+ ...reason.trim() === "" ? {} : { reason }
293
403
  }));
294
404
  }
295
405
  if (node.trim() === "" || kind.trim() === "")
@@ -31,8 +31,10 @@
31
31
  * bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]
32
32
  * bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file <path> [--feature-file <path>] [--spur-bin <path>]
33
33
  * bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> \
34
+ * bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]
34
35
  * --status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]
35
- * bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--spur-bin <path>]
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> [--spur-bin <path>]
36
38
  *
37
39
  * The `--fingerprint` mode prints the engine's proof-input digest for the given spec files and
38
40
  * creates nothing (task 0862 R5).
@@ -44,6 +46,20 @@
44
46
  * `.spur/run/<run-id>.md` and the script still exits 0 with `{"ok":false}` on stdout, so
45
47
  * observation never wedges the run. `--close` is NOT best-effort: the run-row closure is
46
48
  * bookkeeping, so a missing run row or a persistence failure exits 1 with a named error.
49
+ * The `--decide` mode (task 0941 R5) executes the non-pausing decide action through the SAME
50
+ * app runner the engine registers (`runDecideForInlineRun`), writing the same resultFile row
51
+ * and recording the same `action_runs` trace row (`kind=decide`, best-effort). A degraded
52
+ * decision is still `ok: true` — the run continues — so exit 0 covers every model-level
53
+ * outcome; only an invalid options schema exits 1 (fail closed).
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
+ * Idempotent; collisions are reported as `skipped[{id,reason}]`, never overwrites. Exit 0 =
60
+ * persisted (possibly with skips); exit 1 = fail closed — the driver routes to WT-5 and
61
+ * retains the worktree; usage errors exit 2.
62
+ *
47
63
  * Exit 2 is reserved for usage errors.
48
64
  *
49
65
  * Env: SPUR_BIN
@@ -93,7 +109,17 @@ function usage(): never {
93
109
  '--status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]',
94
110
  );
95
111
  console.error(
96
- ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--spur-bin <path>]',
112
+ ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]',
113
+ );
114
+ console.error(
115
+ ' bun plugins/sp/scripts/inline-run-setup.ts --persist-out --from <worktree-path> [--spur-bin <path>]',
116
+ );
117
+ console.error(
118
+ ' terminal-reason is a closed enum (0937 R2): done, paused-operator, failed-check, failed-agent, ' +
119
+ 'failed-timeout, failed-guard, cancelled, interrupted, retry-exhausted',
120
+ );
121
+ console.error(
122
+ ' bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]',
97
123
  );
98
124
  process.exit(2);
99
125
  }
@@ -305,6 +331,24 @@ async function printFingerprint(taskFile: string, featureFile: string, spurBin:
305
331
  /** Terminal statuses the inline driver may declare when closing its run row. */
306
332
  const CLOSE_STATUSES = new Set(['done', 'failed', 'paused']);
307
333
 
334
+ /**
335
+ * 0937 R2: closed terminal-reason vocabulary the driver may declare on `--close`.
336
+ * COPIED from packages/app/src/workflow/terminal-reason.ts — the plugin standalone
337
+ * contract forbids a value import of app code; the parity test asserts the copy
338
+ * equals the app export.
339
+ */
340
+ const TERMINAL_REASONS = new Set([
341
+ 'done',
342
+ 'paused-operator',
343
+ 'failed-check',
344
+ 'failed-agent',
345
+ 'failed-timeout',
346
+ 'failed-guard',
347
+ 'cancelled',
348
+ 'interrupted',
349
+ 'retry-exhausted',
350
+ ]);
351
+
308
352
  /** Finalize statuses — a finish emission is terminal, so only done|failed are valid (0868 #4). */
309
353
  const ACTION_STATUSES = new Set(['done', 'failed']);
310
354
 
@@ -316,6 +360,8 @@ interface TraceModeInput {
316
360
  readonly close: boolean;
317
361
  readonly node: string;
318
362
  readonly kind: string;
363
+ /** Declared terminal reason (0937 R2) — validated against TERMINAL_REASONS before this point. */
364
+ readonly reason?: string;
319
365
  readonly status: string;
320
366
  readonly ok: boolean;
321
367
  readonly durationMs: number;
@@ -341,7 +387,7 @@ function runRecordLogPath(runDir: string, runId: string): string {
341
387
  * exit immediately after), and never throws: an unwritable log must not wedge the run
342
388
  * (ADR-117 R3).
343
389
  */
344
- function appendTraceFailureLine(runId: string, detail: string): void {
390
+ function appendRunLogLine(runId: string, detail: string): void {
345
391
  try {
346
392
  const runDir = join(process.cwd(), '.spur', 'run');
347
393
  if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
@@ -363,7 +409,7 @@ function appendTraceFailureLine(runId: string, detail: string): void {
363
409
  async function runTraceMode(input: TraceModeInput): Promise<number> {
364
410
  const operation = input.close ? 'run.close' : 'action.finish';
365
411
  const fail = (error: string): number => {
366
- appendTraceFailureLine(
412
+ appendRunLogLine(
367
413
  input.runId,
368
414
  `trace-emission-failed operation=${operation} run=${input.runId}` +
369
415
  `${input.node === '' ? '' : ` node=${input.node}`}${input.kind === '' ? '' : ` kind=${input.kind}`}: ${error}`,
@@ -390,14 +436,14 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
390
436
  projectDb = await app.openInlineRunProjectDb(process.cwd());
391
437
  const writer = app.createWorkflowActionTraceWriter(projectDb.adapter, (failure: unknown) => {
392
438
  const detail = failure as { operation?: string; error?: string };
393
- appendTraceFailureLine(
439
+ appendRunLogLine(
394
440
  input.runId,
395
441
  `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? 'unknown error'}`,
396
442
  );
397
443
  });
398
444
  const result = (
399
445
  input.close
400
- ? await writer.closeRun(input.runId, input.status)
446
+ ? await writer.closeRun(input.runId, input.status, undefined, input.reason)
401
447
  : await writer.recordAction({
402
448
  runId: input.runId,
403
449
  node: input.node,
@@ -413,6 +459,18 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
413
459
  const failure = result.failure as { error?: string };
414
460
  return fail(failure.error ?? 'unknown trace emission failure');
415
461
  }
462
+ if (input.close && input.status === 'done' && result.actionRows === 0) {
463
+ // A run finalized `done` with ZERO recorded action rows is a bookkeeping defect
464
+ // (task 0975 R2): the row is already terminal — closeRun ran above — but the
465
+ // driver must surface this instead of reporting a clean close, and must never
466
+ // backfill rows. Exit 1 with the named code; the run record carries the finding.
467
+ const error = `run ${input.runId} closed done with zero action_runs rows`;
468
+ appendRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${error}`);
469
+ process.stdout.write(
470
+ `${JSON.stringify({ ok: false, runId: input.runId, error, code: 'NO_ACTION_ROWS', actionRows: 0 })}\n`,
471
+ );
472
+ return 1;
473
+ }
416
474
  process.stdout.write(`${JSON.stringify({ ...result, runId: input.runId })}\n`);
417
475
  return 0;
418
476
  } catch (error) {
@@ -420,7 +478,7 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
420
478
  // The run row must exist before --close can mark it terminal (R6); a missing row
421
479
  // is a loud correctness failure, not a best-effort emission failure (finding #4).
422
480
  const message = error instanceof Error ? error.message : String(error);
423
- appendTraceFailureLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`);
481
+ appendRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`);
424
482
  process.stdout.write(
425
483
  `${JSON.stringify({ ok: false, runId: input.runId, error: message, code: 'RUN_NOT_FOUND' })}\n`,
426
484
  );
@@ -432,6 +490,116 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
432
490
  }
433
491
  }
434
492
 
493
+ /**
494
+ * `--decide` mode (task 0941 R5): execute the non-pausing decide action through the same app
495
+ * function the engine registers (`runDecideForInlineRun` → `DecideActionRunner`), then record
496
+ * the `action_runs` trace row through the shared writer. The decide-enabled switch
497
+ * (`workflow.decideDecisionMaker`) is resolved HERE at the driver boundary (ADR-082) through
498
+ * the bridged facade derivation and passed into the app runner as an explicit parameter —
499
+ * app services never load Spur config. Degraded outcomes (`disabled`,
500
+ * `no-backend`, `error`, `timeout`, `low-confidence`) are normal — the value is the declared
501
+ * default and the run continues — so they print `ok: true` and exit 0. Only an invalid options
502
+ * schema, an unreadable options file, or a failed config load fails closed with exit 1.
503
+ */
504
+ async function runDecideMode(input: {
505
+ runId: string;
506
+ node: string;
507
+ optionsFile: string;
508
+ spurBin: string;
509
+ }): Promise<number> {
510
+ type DecideOutcome = {
511
+ ok: boolean;
512
+ error?: string;
513
+ value?: string;
514
+ degraded?: boolean;
515
+ reason?: string;
516
+ source?: 'model' | 'default';
517
+ backend?: string | null;
518
+ confidence?: number | null;
519
+ resultFile?: string;
520
+ durationMs?: number;
521
+ };
522
+ const decideFailed = (error: string): number => {
523
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error })}\n`);
524
+ return 1;
525
+ };
526
+ let outcome: DecideOutcome;
527
+ try {
528
+ const { entry, portable } = resolveAppEntry(input.spurBin);
529
+ const app = (await import(entry)) as {
530
+ runDecideForInlineRun: (input2: {
531
+ workdir: string;
532
+ optionsFile: string;
533
+ enabled: boolean;
534
+ }) => Promise<DecideOutcome>;
535
+ };
536
+ // Config switch at the driver boundary (ADR-082 / task 0941 gate fix): the flag comes
537
+ // from the bridged facade derivation in the generated lib — the same committed bundle
538
+ // the inventory flow imports — never a @gobing-ai/* value import. Embedded schemas
539
+ // ride along only in the portable layout, mirroring the setup mode's load posture.
540
+ const lib = (await import(
541
+ fileURLToPath(new URL('../lib/inline-run.generated.mjs', import.meta.url))
542
+ )) as typeof import('../lib/inline-run.generated.mjs');
543
+ const enabled = await lib.resolveDecideDecisionMakerEnabled(
544
+ process.cwd(),
545
+ portable ? { embeddedSchemas: lib.EMBEDDED_SPUR_SCHEMAS } : undefined,
546
+ );
547
+ outcome = await app.runDecideForInlineRun({
548
+ workdir: process.cwd(),
549
+ optionsFile: input.optionsFile,
550
+ enabled,
551
+ });
552
+ } catch (error) {
553
+ return decideFailed(error instanceof Error ? error.message : String(error));
554
+ }
555
+ if (!outcome.ok) return decideFailed(outcome.error ?? 'decide failed without an error message');
556
+ process.stdout.write(`${JSON.stringify({ ok: true, runId: input.runId, node: input.node, ...outcome })}\n`);
557
+ // 0976 R2: the run log names the decision's provenance, so a declared-default fallback is
558
+ // never read as a model decision. Best-effort through the same run-log appender.
559
+ appendRunLogLine(
560
+ input.runId,
561
+ `decide node=${input.node} value=${outcome.value ?? ''} source=${outcome.source ?? 'default'} reason=${outcome.reason ?? ''}`,
562
+ );
563
+ // Trace row is best-effort, exactly like --action: an emission failure never wedges the run.
564
+ return await runTraceMode({
565
+ runId: input.runId,
566
+ close: false,
567
+ node: input.node,
568
+ kind: 'decide',
569
+ status: 'done',
570
+ ok: true,
571
+ durationMs: outcome.durationMs ?? 0,
572
+ spurBin: input.spurBin,
573
+ });
574
+ }
575
+
576
+ /**
577
+ * `--persist-out --from <worktree-path>` (task 0975 R1): resolve the app through the shared
578
+ * chain and copy the worktree's inline-run provenance into THIS tree. No persistence policy
579
+ * lives here — the app's `persistWorktreeRuns` (→ domain `transferRunTables`) owns the DB
580
+ * transfer, the idempotence and the conflict skips. Success prints
581
+ * `{ok:true,persisted:<n>,skipped:[…]}` and exits 0; any failure prints `{ok:false,error}`
582
+ * and exits 1 (the driver routes to WT-5 and retains the worktree).
583
+ */
584
+ async function runPersistOutMode(input: { from: string; spurBin: string }): Promise<number> {
585
+ try {
586
+ const { entry } = resolveAppEntry(input.spurBin);
587
+ const app = (await import(entry)) as {
588
+ persistWorktreeRuns: (i: {
589
+ fromWorkdir: string;
590
+ toWorkdir: string;
591
+ }) => Promise<{ ok: true; persisted: number; skipped: ReadonlyArray<{ id: string; reason: string }> }>;
592
+ };
593
+ const result = await app.persistWorktreeRuns({ fromWorkdir: input.from, toWorkdir: process.cwd() });
594
+ process.stdout.write(`${JSON.stringify({ ok: true, persisted: result.persisted, skipped: result.skipped })}\n`);
595
+ return 0;
596
+ } catch (error) {
597
+ const message = error instanceof Error ? error.message : String(error);
598
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\n`);
599
+ return 1;
600
+ }
601
+ }
602
+
435
603
  async function main(): Promise<void> {
436
604
  // The portable Node twin has no workspace imports; SQLite still uses Spur's existing Bun runtime.
437
605
  if (!process.versions.bun) {
@@ -448,9 +616,14 @@ async function main(): Promise<void> {
448
616
  let featureFile = '';
449
617
  let action = false;
450
618
  let close = false;
619
+ let decide = false;
620
+ let persistOut = false;
621
+ let from = '';
622
+ let optionsJson = '';
451
623
  let node = '';
452
624
  let kind = '';
453
625
  let status = '';
626
+ let reason = '';
454
627
  let okRaw = '';
455
628
  let durationRaw = '';
456
629
  let spurBin = getEnvVar('SPUR_BIN') ?? '';
@@ -463,9 +636,14 @@ async function main(): Promise<void> {
463
636
  else if (argv[i] === '--feature-file') featureFile = argv[++i] ?? '';
464
637
  else if (argv[i] === '--action') action = true;
465
638
  else if (argv[i] === '--close') close = true;
639
+ else if (argv[i] === '--decide') decide = true;
640
+ else if (argv[i] === '--persist-out') persistOut = true;
641
+ else if (argv[i] === '--from') from = argv[++i] ?? '';
642
+ else if (argv[i] === '--options-json') optionsJson = argv[++i] ?? '';
466
643
  else if (argv[i] === '--node') node = argv[++i] ?? '';
467
644
  else if (argv[i] === '--kind') kind = argv[++i] ?? '';
468
645
  else if (argv[i] === '--status') status = argv[++i] ?? '';
646
+ else if (argv[i] === '--reason') reason = argv[++i] ?? '';
469
647
  else if (argv[i] === '--ok') okRaw = argv[++i] ?? '';
470
648
  else if (argv[i] === '--duration-ms') durationRaw = argv[++i] ?? '';
471
649
  else if (argv[i] === '--spur-bin') spurBin = argv[++i] ?? spurBin;
@@ -481,12 +659,37 @@ async function main(): Promise<void> {
481
659
  // ADR-117 emission modes (task 0868): the inline driver reports one completed action
482
660
  // boundary, or closes its run row at the declared terminal state. Both share the run-id
483
661
  // filename guard, the app-entry resolution chain and the best-effort failure contract.
662
+ // Non-pausing decide action (0941 R5): --decide is mutually exclusive with the run-setup,
663
+ // fingerprint, and trace modes; it requires the run id (filename-guarded), the state id
664
+ // for the trace row, and the options JSON file.
665
+ if (decide) {
666
+ if (action || close || fingerprint || file !== '' || taskFile !== '') usage();
667
+ if (runId.trim() === '' || node.trim() === '' || optionsJson.trim() === '') usage();
668
+ if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
669
+ process.exit(await runDecideMode({ runId, node, optionsFile: optionsJson, spurBin }));
670
+ }
671
+
672
+ // Worktree provenance persist-out (task 0975 R1): mutually exclusive with every other
673
+ // mode; requires the source worktree path. Runs with cwd = the invoking tree.
674
+ if (persistOut) {
675
+ if (fingerprint || decide || action || close || runId !== '' || file !== '' || taskFile !== '') usage();
676
+ if (from.trim() === '') usage();
677
+ process.exit(await runPersistOutMode({ from, spurBin }));
678
+ }
679
+
484
680
  if (action || close) {
485
681
  if (action && close) usage();
486
682
  if (runId.trim() === '' || status.trim() === '') usage();
487
683
  if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
488
684
  if (close) {
489
685
  if (!CLOSE_STATUSES.has(status)) usage();
686
+ // 0937 R2: a failed close requires a declared reason, and any declared reason
687
+ // must be a closed-enum value — both fail loudly BEFORE any write happens.
688
+ if (reason.trim() === '') {
689
+ if (status === 'failed') usage();
690
+ } else if (!TERMINAL_REASONS.has(reason)) {
691
+ usage();
692
+ }
490
693
  process.exit(
491
694
  await runTraceMode({
492
695
  runId,
@@ -497,6 +700,7 @@ async function main(): Promise<void> {
497
700
  ok: true,
498
701
  durationMs: 0,
499
702
  spurBin,
703
+ ...(reason.trim() === '' ? {} : { reason }),
500
704
  }),
501
705
  );
502
706
  }