@gobing-ai/spur 0.3.97 → 0.3.98

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 (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/sp/README.md +9 -9
  4. package/plugins/sp/lib/inline-run.generated.mjs +16 -9
  5. package/plugins/sp/plugin.json +1 -1
  6. package/plugins/sp/references/roles.md +3 -3
  7. package/plugins/sp/scripts/inline-run-setup.mjs +4 -2
  8. package/plugins/sp/scripts/inline-run-setup.ts +12 -12
  9. package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
  10. package/plugins/sp/skills/conflict-finding/SKILL.md +1 -1
  11. package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +5 -3
  12. package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +9 -8
  13. package/plugins/sp/skills/doc-evolve/SKILL.md +4 -3
  14. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +7 -4
  15. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +7 -4
  16. package/plugins/sp/skills/spur-dev/references/dev-operations.md +16 -13
  17. package/plugins/sp/skills/spur-dev/references/execution-batch.md +1 -1
  18. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +7 -8
  19. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +3 -0
  20. package/spur.js +483 -254
  21. package/web/_astro/{BoardApp.vveLOdDq.js → BoardApp.CYbe-OTb.js} +5 -5
  22. package/web/_astro/{BoardApp.Bmv5WkJ9.js → BoardApp.Dflq5a8x.js} +1 -1
  23. package/web/_astro/{TaskDetail.IKc3uDYV.js → TaskDetail.CK7oXddP.js} +1 -1
  24. package/web/_astro/{arc.C63Ufg35.js → arc.BIqkDATc.js} +1 -1
  25. package/web/_astro/{architectureDiagram-3BPJPVTR.CP8Hyldk.js → architectureDiagram-3BPJPVTR.D0B3SvWv.js} +1 -1
  26. package/web/_astro/{blockDiagram-GPEHLZMM.Bo_48MRA.js → blockDiagram-GPEHLZMM.C2YyKQmL.js} +1 -1
  27. package/web/_astro/{c4Diagram-AAUBKEIU.Ch61sw-O.js → c4Diagram-AAUBKEIU.DmtTYrt_.js} +1 -1
  28. package/web/_astro/channel.JuKVyuhZ.js +1 -0
  29. package/web/_astro/{chunk-2J33WTMH.CHpzS7xf.js → chunk-2J33WTMH.HsRXbh7l.js} +1 -1
  30. package/web/_astro/{chunk-4BX2VUAB.DzMNNWIw.js → chunk-4BX2VUAB.obE_P-R3.js} +1 -1
  31. package/web/_astro/{chunk-55IACEB6.BqPrDw_Q.js → chunk-55IACEB6.CjWTF5mz.js} +1 -1
  32. package/web/_astro/{chunk-727SXJPM.ZzBHhNw-.js → chunk-727SXJPM.DMo799Ax.js} +1 -1
  33. package/web/_astro/{chunk-AQP2D5EJ.1_O_LLkx.js → chunk-AQP2D5EJ.-cE6JOKZ.js} +1 -1
  34. package/web/_astro/{chunk-FMBD7UC4.GS4UHg7_.js → chunk-FMBD7UC4.D6Rw6HIk.js} +1 -1
  35. package/web/_astro/{chunk-ND2GUHAM.BnqotN4g.js → chunk-ND2GUHAM.d5rBD73Z.js} +1 -1
  36. package/web/_astro/{chunk-QZHKN3VN.Ghuh8zoJ.js → chunk-QZHKN3VN.r3ox40Q3.js} +1 -1
  37. package/web/_astro/{classDiagram-4FO5ZUOK.BW9vC3zg.js → classDiagram-4FO5ZUOK.CGGbvjjJ.js} +1 -1
  38. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BW9vC3zg.js → classDiagram-v2-Q7XG4LA2.CGGbvjjJ.js} +1 -1
  39. package/web/_astro/{cose-bilkent-S5V4N54A.D6jNC0ti.js → cose-bilkent-S5V4N54A.CmKtPd4l.js} +1 -1
  40. package/web/_astro/{cynefin-OW5HDTMX.BOGoCs4L.js → cynefin-OW5HDTMX.QNg5GXs8.js} +1 -1
  41. package/web/_astro/{dagre-BM42HDAG.8iU5VcWi.js → dagre-BM42HDAG.B1SabsjC.js} +1 -1
  42. package/web/_astro/{diagram-2AECGRRQ.CVt88HwH.js → diagram-2AECGRRQ.RtDbNj89.js} +1 -1
  43. package/web/_astro/{diagram-5GNKFQAL.C9HxUUMl.js → diagram-5GNKFQAL.IzZpuM-Y.js} +1 -1
  44. package/web/_astro/{diagram-KO2AKTUF.4ywkQN3t.js → diagram-KO2AKTUF.DPTF_fgv.js} +1 -1
  45. package/web/_astro/{diagram-LMA3HP47.C48A6gpz.js → diagram-LMA3HP47.CCyza39P.js} +1 -1
  46. package/web/_astro/{diagram-OG6HWLK6.DxFieQW4.js → diagram-OG6HWLK6.BXAi8zcZ.js} +1 -1
  47. package/web/_astro/{erDiagram-TEJ5UH35.CtjtQqYs.js → erDiagram-TEJ5UH35.DUDX7_MR.js} +1 -1
  48. package/web/_astro/{flowDiagram-I6XJVG4X.C5zAiahU.js → flowDiagram-I6XJVG4X.C63QTh3N.js} +1 -1
  49. package/web/_astro/{ganttDiagram-6RSMTGT7.D_L6zsny.js → ganttDiagram-6RSMTGT7.CnlZJSwG.js} +1 -1
  50. package/web/_astro/{gitGraphDiagram-PVQCEYII.Ignnrk3V.js → gitGraphDiagram-PVQCEYII.CTqIrOPC.js} +1 -1
  51. package/web/_astro/{infoDiagram-5YYISTIA.DLv8-P5u.js → infoDiagram-5YYISTIA.BYMiDFh9.js} +1 -1
  52. package/web/_astro/{ishikawaDiagram-YF4QCWOH.p0OTpwFO.js → ishikawaDiagram-YF4QCWOH.CRiQmI-z.js} +1 -1
  53. package/web/_astro/{journeyDiagram-JHISSGLW.CIaBt-DD.js → journeyDiagram-JHISSGLW.YQgWBsEU.js} +1 -1
  54. package/web/_astro/{kanban-definition-UN3LZRKU.DGySIntq.js → kanban-definition-UN3LZRKU.1ECnrrK1.js} +1 -1
  55. package/web/_astro/{linear.BATV4RXu.js → linear.DzVA71yT.js} +1 -1
  56. package/web/_astro/{mermaid.core.DzkwM3VX.js → mermaid.core.BK7P2Sr_.js} +4 -4
  57. package/web/_astro/{mindmap-definition-RKZ34NQL.3piBRRzA.js → mindmap-definition-RKZ34NQL.BvXyWZK7.js} +1 -1
  58. package/web/_astro/{pieDiagram-4H26LBE5.DUNueLub.js → pieDiagram-4H26LBE5.BiXk1TW3.js} +1 -1
  59. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bihVbrt.js → quadrantDiagram-W4KKPZXB.krvBH8lh.js} +1 -1
  60. package/web/_astro/{requirementDiagram-4Y6WPE33.CkjsC-YT.js → requirementDiagram-4Y6WPE33.ih1gL25c.js} +1 -1
  61. package/web/_astro/{sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js → sankeyDiagram-5OEKKPKP.CpUxkv4u.js} +1 -1
  62. package/web/_astro/{sequenceDiagram-3UESZ5HK.BivrH99P.js → sequenceDiagram-3UESZ5HK.clAfeN7y.js} +1 -1
  63. package/web/_astro/{stateDiagram-AJRCARHV.CqLQ1Vzz.js → stateDiagram-AJRCARHV.DpXWb8-m.js} +1 -1
  64. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DvvoLD3b.js → stateDiagram-v2-BHNVJYJU.ClZb9GIH.js} +1 -1
  65. package/web/_astro/{timeline-definition-PNZ67QCA.B-KR78gv.js → timeline-definition-PNZ67QCA.BLcX5dh7.js} +1 -1
  66. package/web/_astro/{vennDiagram-CIIHVFJN.Ck0Eieb5.js → vennDiagram-CIIHVFJN.oGgpKMpx.js} +1 -1
  67. package/web/_astro/{wardleyDiagram-YWT4CUSO.CRfdd_do.js → wardleyDiagram-YWT4CUSO.BPHtMtt-.js} +1 -1
  68. package/web/_astro/{xychartDiagram-2RQKCTM6.Ck_Jxk9C.js → xychartDiagram-2RQKCTM6.BNZOPYSk.js} +1 -1
  69. package/web/index.html +1 -1
  70. package/web/_astro/channel.BKCmJpWM.js +0 -1
package/spur.js CHANGED
@@ -62293,6 +62293,14 @@ var init_run_dao = __esm(() => {
62293
62293
  metadata_json = json_set(COALESCE(NULLIF(metadata_json, ''), '{}'), '$.staleReason', ?2)
62294
62294
  WHERE id = ?3 AND status IN ('running', 'pending')`, nowIso, reason, runId);
62295
62295
  }
62296
+ async cancelRun(runId) {
62297
+ const nowIso = new Date().toISOString();
62298
+ await this.adapter.run(`UPDATE runs
62299
+ SET status = 'failed', completed_at = ?1, updated_at = ?1,
62300
+ terminal_reason = 'cancelled',
62301
+ metadata_json = json_set(COALESCE(NULLIF(metadata_json, ''), '{}'), '$.staleReason', ?2)
62302
+ WHERE id = ?3 AND status IN ('running', 'pending', 'paused', 'interrupted')`, nowIso, "cancelled by operator (spur workflow cancel)", runId);
62303
+ }
62296
62304
  async setPid(runId, pid) {
62297
62305
  await this.adapter.run("UPDATE runs SET pid = ? WHERE id = ?", pid, runId);
62298
62306
  }
@@ -79050,15 +79058,6 @@ function parseVerifyVerdict(raw, fallbackWbs) {
79050
79058
  const verdict = { ...result.data, wbs: result.data.wbs !== "" ? result.data.wbs : wbs };
79051
79059
  return { kind: "valid", wbs, verdict };
79052
79060
  }
79053
- async function readVerifyVerdict(fs3, path9, fallbackWbs) {
79054
- let raw;
79055
- try {
79056
- raw = await fs3.readFile(path9);
79057
- } catch {
79058
- return { kind: "missing", wbs: fallbackWbs ?? "" };
79059
- }
79060
- return parseVerifyVerdict(raw, fallbackWbs);
79061
- }
79062
79061
  function checkRowName(row) {
79063
79062
  for (const v of [row.name, row.check, row.id]) {
79064
79063
  if (typeof v === "string" && v.trim() !== "")
@@ -82355,6 +82354,7 @@ function formatNoopMessage(wbs, status) {
82355
82354
  }
82356
82355
  function evaluateDoneTransition(input) {
82357
82356
  const { wbs, taskFilePath, currentStatus, targetStatus, forced, reason, artifact, readError } = input;
82357
+ const verdictPath = input.verdictPath ?? `.spur/run/${wbs}-verdict.json`;
82358
82358
  if (targetStatus === currentStatus) {
82359
82359
  return { kind: "noop", fromStatus: currentStatus, message: formatNoopMessage(wbs, currentStatus) };
82360
82360
  }
@@ -82362,7 +82362,6 @@ function evaluateDoneTransition(input) {
82362
82362
  return { kind: "allow", reason: "forced" };
82363
82363
  }
82364
82364
  if (artifact === undefined) {
82365
- const verdictPath2 = `.spur/run/${wbs}-verdict.json`;
82366
82365
  if (readError) {
82367
82366
  return {
82368
82367
  kind: "deny",
@@ -82370,7 +82369,7 @@ function evaluateDoneTransition(input) {
82370
82369
  message: [
82371
82370
  `Cannot transition task ${wbs} to done: verdict artifact is unusable.`,
82372
82371
  ` task: ${taskFilePath}`,
82373
- ` verdict: ${verdictPath2} (present but rejected)`,
82372
+ ` verdict: ${verdictPath} (present but rejected)`,
82374
82373
  ` reason: ${readError}`,
82375
82374
  ` remediation: fix the artifact (re-run \`/sp:dev-verify ${wbs}\` until PASS), ` + `or override with \`spur task update ${wbs} done --force-done --reason "<why>"\`.`
82376
82375
  ].join(`
@@ -82383,7 +82382,7 @@ function evaluateDoneTransition(input) {
82383
82382
  message: [
82384
82383
  `Cannot transition task ${wbs} to done: missing verify verdict artifact.`,
82385
82384
  ` task: ${taskFilePath}`,
82386
- ` verdict: ${verdictPath2} (not found)`,
82385
+ ` verdict: ${verdictPath} (not found)`,
82387
82386
  ` remediation: re-run \`/sp:dev-verify ${wbs}\` until PASS (writes the artifact), ` + `or override with \`spur task update ${wbs} done --force-done --reason "<why>"\`.`
82388
82387
  ].join(`
82389
82388
  `)
@@ -82397,7 +82396,6 @@ function evaluateDoneTransition(input) {
82397
82396
  if (effective === "PASS") {
82398
82397
  return { kind: "allow", reason: "pass" };
82399
82398
  }
82400
- const verdictPath = `.spur/run/${wbs}-verdict.json`;
82401
82399
  const inconsistency = artifact.verdict !== computed ? { stored: artifact.verdict, computed } : undefined;
82402
82400
  return {
82403
82401
  kind: "deny",
@@ -84930,6 +84928,7 @@ function hasSolutionFileLineCitation(body) {
84930
84928
  var TESTING_VERDICT_STUB_RE, EXTERNAL_EVIDENCE_RE, GATE_LANGUAGE_SECTIONS, TaskCheckService;
84931
84929
  var init_task_check = __esm(() => {
84932
84930
  init_src2();
84931
+ init_done_transition_guard();
84933
84932
  init_feature_check();
84934
84933
  init_planning_check_base();
84935
84934
  init_structural_repair();
@@ -85601,12 +85600,14 @@ var init_task_check = __esm(() => {
85601
85600
  exists2 = false;
85602
85601
  }
85603
85602
  if (!exists2) {
85603
+ const scoped = /^(@[^/\s]+\/[^/\s]+)\/(.+)$/.exec(cite.path);
85604
+ const detail = scoped ? `external package path \u2014 rewrite in the frozen external-evidence form: Evidence: ${scoped[1]} \`${scoped[2]}\` line ${cite.startLine}${cite.endLine ? `-${cite.endLine}` : ""} (origin and line number outside the backticks; task 0584 / ADR-062)` : `file not found at ${cite.path} (from project root). Point the citation at the code that implements this row, or move it to the external-evidence form if the file is not in this repo.`;
85604
85605
  findings.push({
85605
85606
  layer: "L4",
85606
85607
  code: FINDING_CODES.L4_ANCHOR_UNRESOLVED,
85607
85608
  severity: resolutionSeverity,
85608
85609
  section,
85609
- message: `Unresolved line anchor \`${cite.raw}\` \u2014 file not found at ${cite.path} (from project root). Point the citation at the code that implements this row, or move it to the external-evidence form if the file is not in this repo.`
85610
+ message: `Unresolved line anchor \`${cite.raw}\` \u2014 ${detail}`
85610
85611
  });
85611
85612
  reported++;
85612
85613
  continue;
@@ -85773,8 +85774,21 @@ var init_task_check = __esm(() => {
85773
85774
  async checkVerdictArtifact(wbs, tasksDir, findings) {
85774
85775
  const projectRoot = resolveProjectRootFromTasksDir(tasksDir);
85775
85776
  const runDir = join25(projectRoot, ".spur", "run");
85776
- const verdictPath = `${runDir}/${wbs}-verdict.json`;
85777
- const outcome = await readVerifyVerdict(this.fs, verdictPath, wbs);
85777
+ const loaded = await readVerdictArtifact(this.fs, runDir, wbs);
85778
+ const verdictPath = loaded.path;
85779
+ if (loaded.readError) {
85780
+ findings.push({
85781
+ layer: "L4",
85782
+ code: FINDING_CODES.L4_MALFORMED_VERDICT_ARTIFACT,
85783
+ severity: "error",
85784
+ section: "Testing",
85785
+ message: `Verdict artifact at ${verdictPath} is unusable: ${loaded.readError}`
85786
+ });
85787
+ return;
85788
+ }
85789
+ if (loaded.artifact === undefined)
85790
+ return;
85791
+ const outcome = parseVerifyVerdict(JSON.stringify(loaded.artifact), wbs);
85778
85792
  if (outcome.kind === "missing")
85779
85793
  return;
85780
85794
  if (outcome.kind === "malformed") {
@@ -91575,7 +91589,6 @@ async function deriveVerifiedOutcome(deps, window2 = {}) {
91575
91589
  }
91576
91590
  async function deriveTaskInput(deps, wbs, linkedRuns) {
91577
91591
  const runCompleted = (r) => r.status === "done" || r.status === "completed";
91578
- const supersedingFailedRun = linkedRuns.some((r) => r.status === "failed" || r.status === "cancelled");
91579
91592
  let done = false;
91580
91593
  let forcedDone = false;
91581
91594
  let sectionVerdictPresent = false;
@@ -91592,8 +91605,9 @@ async function deriveTaskInput(deps, wbs, linkedRuns) {
91592
91605
  raw = null;
91593
91606
  }
91594
91607
  if (raw !== null) {
91595
- forcedDone = /^done_forced:\s*true\b/m.test(raw);
91596
- done = /^status:\s*done\b/m.test(raw);
91608
+ const frontmatter = MarkdownDocument.parse(raw, "task").frontmatterData ?? {};
91609
+ forcedDone = frontmatter.done_forced === true;
91610
+ let historyStatus;
91597
91611
  sectionVerdictPresent = parseVerdictLine(raw.split(`
91598
91612
  `)) !== null;
91599
91613
  for (const line of raw.split(`
@@ -91601,6 +91615,7 @@ async function deriveTaskInput(deps, wbs, linkedRuns) {
91601
91615
  const entry = parseHistoryLine(line, "task", wbs);
91602
91616
  if (!entry)
91603
91617
  continue;
91618
+ historyStatus = entry.to;
91604
91619
  if (entry.to === "wip" && firstWipAt === null)
91605
91620
  firstWipAt = entry.timestamp;
91606
91621
  if (entry.to === "done") {
@@ -91610,7 +91625,7 @@ async function deriveTaskInput(deps, wbs, linkedRuns) {
91610
91625
  if (reachedDone && entry.from === "done")
91611
91626
  reopened = true;
91612
91627
  }
91613
- done = done || reachedDone;
91628
+ done = String(frontmatter.status ?? historyStatus ?? "").toLowerCase() === "done";
91614
91629
  }
91615
91630
  }
91616
91631
  let verdictPresent = false;
@@ -91623,7 +91638,8 @@ async function deriveTaskInput(deps, wbs, linkedRuns) {
91623
91638
  const verdict = verdictRead.artifact;
91624
91639
  if (verdict) {
91625
91640
  verdictPresent = typeof verdict.verdict === "string";
91626
- passVerdict = verdict.verdict === "PASS";
91641
+ const parsed = parseVerifyVerdict(JSON.stringify(verdict), wbs);
91642
+ passVerdict = parsed.kind === "valid" && parsed.verdict.verdict === "PASS" && parsed.verdict.requirements.length + parsed.verdict.acceptanceCriteria.length > 0 && verdictRead.artifact !== undefined && computeAggregate(verdictRead.artifact) === "PASS";
91627
91643
  const digest = typeof verdict.proof?.digest === "string" ? verdict.proof.digest : verdict.proofDigest;
91628
91644
  proofDigestPresent = typeof digest === "string" && digest.length > 0;
91629
91645
  if (typeof verdict.proof?.runId === "string" && verdict.proof.runId.length > 0) {
@@ -91633,7 +91649,9 @@ async function deriveTaskInput(deps, wbs, linkedRuns) {
91633
91649
  boundDefinitionDigest = verdict.proof.definitionDigest;
91634
91650
  }
91635
91651
  }
91636
- const certifyingRunCompleted = boundRunId !== null ? linkedRuns.some((r) => r.runId === boundRunId && runCompleted(r) && (boundDefinitionDigest === null || r.definitionDigest !== null && r.definitionDigest === boundDefinitionDigest)) : linkedRuns.some(runCompleted);
91652
+ const certifyingRun = boundRunId !== null ? linkedRuns.find((r) => r.runId === boundRunId && runCompleted(r) && (boundDefinitionDigest === null || r.definitionDigest !== null && r.definitionDigest === boundDefinitionDigest)) : linkedRuns.find(runCompleted);
91653
+ const certifyingRunCompleted = certifyingRun !== undefined;
91654
+ const supersedingFailedRun = linkedRuns.some((r) => (r.status === "failed" || r.status === "cancelled") && (certifyingRun === undefined || Date.parse(r.startedAt ?? "") > Date.parse(certifyingRun.completedAt ?? "")));
91637
91655
  if (passVerdict) {
91638
91656
  let tokens = 0;
91639
91657
  let any2 = false;
@@ -91673,6 +91691,7 @@ var init_verified_outcome = __esm(() => {
91673
91691
  init_src2();
91674
91692
  init_done_transition_guard();
91675
91693
  init_task_record();
91694
+ init_verify_verdict();
91676
91695
  });
91677
91696
 
91678
91697
  // ../../packages/app/src/services/history-service.ts
@@ -92595,9 +92614,9 @@ class WorkflowActionTraceWriter {
92595
92614
  }
92596
92615
  await this.inner.finalizeRun(runId, status, completedAt ?? new Date().toISOString(), undefined, reason === undefined ? undefined : classifyTerminalReason({ status, engineReason: reason, errorText: reason }));
92597
92616
  if (this.db === undefined)
92598
- return { ok: true };
92617
+ return { ok: true, startedAt: existing.started_at };
92599
92618
  const rows = await new ActionRunDao(this.db).actionRowsByRunId(runId);
92600
- return { ok: true, actionRows: rows.length };
92619
+ return { ok: true, actionRows: rows.length, startedAt: existing.started_at };
92601
92620
  }
92602
92621
  async guard(operation, context4, emit) {
92603
92622
  try {
@@ -95858,6 +95877,14 @@ class ProofFingerprintActionRunner {
95858
95877
  this.observabilityBus = observabilityBus;
95859
95878
  }
95860
95879
  async execute(options, context4) {
95880
+ const accepted = ACCEPTED_OPTION_KEYS;
95881
+ const unexpected = Object.keys(options).filter((key2) => !accepted.includes(key2)).sort();
95882
+ if (unexpected.length > 0) {
95883
+ return {
95884
+ ok: false,
95885
+ error: `${KIND11}: unexpected option(s): ${unexpected.join(", ")} \u2014 accepted keys: ` + `${[...accepted].sort().join(", ")}`
95886
+ };
95887
+ }
95861
95888
  const varName = options.var;
95862
95889
  if (typeof varName !== "string" || varName === "") {
95863
95890
  return { ok: false, error: `${KIND11}: var is required` };
@@ -95865,6 +95892,12 @@ class ProofFingerprintActionRunner {
95865
95892
  if (!VAR_NAME_RE.test(varName)) {
95866
95893
  return { ok: false, error: `${KIND11}: var name must match ${VAR_NAME_RE}, got "${varName}"` };
95867
95894
  }
95895
+ if (options.expect !== undefined && typeof options.expect !== "string") {
95896
+ return {
95897
+ ok: false,
95898
+ error: `${KIND11}: expect must be a string when supplied (got ${typeof options.expect})`
95899
+ };
95900
+ }
95868
95901
  const inputs = await readProofInputContents(this.fileSystem, context4.workdir ?? ".", options);
95869
95902
  if (!inputs.ok) {
95870
95903
  return { ok: false, error: `${KIND11}: ${inputs.error}` };
@@ -95925,11 +95958,12 @@ class ProofFingerprintActionRunner {
95925
95958
  };
95926
95959
  }
95927
95960
  }
95928
- var KIND11 = "proof.fingerprint", VAR_NAME_RE;
95961
+ var KIND11 = "proof.fingerprint", VAR_NAME_RE, ACCEPTED_OPTION_KEYS;
95929
95962
  var init_proof_fingerprint = __esm(() => {
95930
95963
  init_proof_input_fingerprint();
95931
95964
  init_tripwire();
95932
95965
  VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
95966
+ ACCEPTED_OPTION_KEYS = ["expect", "featureFile", "taskFile", "var"];
95933
95967
  });
95934
95968
 
95935
95969
  // ../../packages/app/src/workflow/actions/response-validate.ts
@@ -98000,12 +98034,12 @@ class WorkflowAppService {
98000
98034
  if (!before) {
98001
98035
  return { runId, finalized: false, status: "not_found", killed: false };
98002
98036
  }
98003
- const isNonTerminal = before.status === "running" || before.status === "pending";
98037
+ const isNonTerminal = before.status === "running" || before.status === "pending" || before.status === "paused" || before.status === "interrupted";
98004
98038
  const pid = isNonTerminal ? await dao2.getPid(runId) : null;
98005
98039
  const killed = pid != null ? signalSubprocess(pid) : false;
98006
- await dao2.finalizeStale(runId, "cancelled by operator (spur workflow cancel)");
98040
+ await dao2.cancelRun(runId);
98007
98041
  const after = await dao2.traceRowById(runId);
98008
- const finalized = after?.status === "failed" && before.status !== "failed";
98042
+ const finalized = after?.status === "failed" && isNonTerminal;
98009
98043
  return { runId, finalized, status: after?.status ?? "not_found", killed };
98010
98044
  }
98011
98045
  async continuePaused(runId, opts) {
@@ -99021,6 +99055,7 @@ async function persistWorktreeRuns(input) {
99021
99055
  evidenceCopies.push({ source: source2, target, bytes });
99022
99056
  }
99023
99057
  const citedNames = new Set;
99058
+ const citedDirSkips = new Set;
99024
99059
  for (const taskFile of input.taskFiles ?? []) {
99025
99060
  let content;
99026
99061
  try {
@@ -99030,7 +99065,13 @@ async function persistWorktreeRuns(input) {
99030
99065
  }
99031
99066
  for (const match of content.matchAll(RUN_CITATION_RE)) {
99032
99067
  const name = asLiteralRunFileName(match[1] ?? "");
99033
- if (name === undefined || citedNames.has(name))
99068
+ if (name === undefined)
99069
+ continue;
99070
+ if (match[2] !== undefined) {
99071
+ citedDirSkips.add(name);
99072
+ continue;
99073
+ }
99074
+ if (citedNames.has(name))
99034
99075
  continue;
99035
99076
  if (citedNames.size >= MAX_CITED_RUN_FILES) {
99036
99077
  throw new Error(`persist-out: merged task files cite more than ${MAX_CITED_RUN_FILES} distinct ` + ".spur/run/ files \u2014 over the fixed citation cap (0984 R3); split the batch or prune the citations");
@@ -99077,6 +99118,8 @@ async function persistWorktreeRuns(input) {
99077
99118
  }
99078
99119
  const citedCopies = [];
99079
99120
  const citedSkips = [];
99121
+ for (const name of citedDirSkips)
99122
+ citedSkips.push({ id: name, reason: `cited-directory:${name}` });
99080
99123
  for (const name of citedNames) {
99081
99124
  const scratchPath = join36(fromRunDir, name);
99082
99125
  const durablePath = join36(fromRecordsDir, name);
@@ -99467,6 +99510,47 @@ function writeInlineRunOutcome(runId, outcome) {
99467
99510
  closeSync4(headerFd);
99468
99511
  }
99469
99512
  }
99513
+ function projectInlineRunClose(runId, status, context4) {
99514
+ try {
99515
+ const runDir = runStoragePaths(process.cwd()).recordsDir;
99516
+ if (!existsSync9(runDir))
99517
+ mkdirSync9(runDir, { recursive: true });
99518
+ const statePath = join36(runDir, `${runId}.state.json`);
99519
+ let prior = {};
99520
+ try {
99521
+ const parsed = JSON.parse(readFileSync13(statePath, "utf8"));
99522
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
99523
+ prior = parsed;
99524
+ }
99525
+ } catch {}
99526
+ const at2 = new Date().toISOString();
99527
+ const state = {
99528
+ schemaVersion: 1,
99529
+ ...prior,
99530
+ runId,
99531
+ status,
99532
+ ok: true,
99533
+ startedAt: typeof prior.startedAt === "string" ? prior.startedAt : context4?.startedAt ?? at2,
99534
+ updatedAt: at2
99535
+ };
99536
+ delete state.error;
99537
+ const temp = `${statePath}.tmp`;
99538
+ try {
99539
+ writeFileSync8(temp, `${JSON.stringify(state, null, 4)}
99540
+ `);
99541
+ renameSync7(temp, statePath);
99542
+ } catch (error51) {
99543
+ try {
99544
+ unlinkSync5(temp);
99545
+ } catch {}
99546
+ throw error51;
99547
+ }
99548
+ return;
99549
+ } catch (error51) {
99550
+ const detail = error51 instanceof Error ? error51.message : String(error51);
99551
+ return `failed to project the committed ${status} status into the run-record state file for run ${runId}: ${detail} (the run row is committed; replay the same close to repair)`;
99552
+ }
99553
+ }
99470
99554
  function isInlineRunCloseStatus(status) {
99471
99555
  return CLOSE_STATUSES.has(status);
99472
99556
  }
@@ -99493,6 +99577,20 @@ function appendInlineRunLogLine(runId, detail) {
99493
99577
  }
99494
99578
  async function runInlineRunTrace(input) {
99495
99579
  const operation = input.close ? "run.close" : "action.finish";
99580
+ if (input.close) {
99581
+ const invalidReason = (error51) => {
99582
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error: error51, code: "INVALID_CLOSE_REASON" })}
99583
+ `);
99584
+ return 1;
99585
+ };
99586
+ if (input.reason !== undefined && !isTerminalReason(input.reason)) {
99587
+ return invalidReason(`close reason "${input.reason}" is not a terminal-reason enum value; pass one of: ${TERMINAL_REASONS.join(", ")}`);
99588
+ }
99589
+ if (input.status === "failed" && input.reason === undefined) {
99590
+ return invalidReason(`close status failed requires an explicit reason from the terminal-reason enum (0937 R2); pass one of: ${TERMINAL_REASONS.join(", ")}`);
99591
+ }
99592
+ }
99593
+ const closeReason = input.close ? input.reason ?? (input.status === "paused" ? "paused-operator" : "done") : undefined;
99496
99594
  const fail = (error51) => {
99497
99595
  appendInlineRunLogLine(input.runId, `trace-emission-failed operation=${operation} run=${input.runId}${input.node === "" ? "" : ` node=${input.node}`}${input.kind === "" ? "" : ` kind=${input.kind}`}: ${error51}`);
99498
99596
  process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error: error51 })}
@@ -99506,7 +99604,7 @@ async function runInlineRunTrace(input) {
99506
99604
  const detail = failure;
99507
99605
  appendInlineRunLogLine(input.runId, `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? "unknown error"}`);
99508
99606
  });
99509
- const result = input.close ? await writer.closeRun(input.runId, input.status, undefined, input.reason) : await writer.recordAction({
99607
+ const result = input.close ? await writer.closeRun(input.runId, input.status, undefined, closeReason) : await writer.recordAction({
99510
99608
  runId: input.runId,
99511
99609
  node: input.node,
99512
99610
  kind: input.kind,
@@ -99518,14 +99616,24 @@ async function runInlineRunTrace(input) {
99518
99616
  const failure = result.failure;
99519
99617
  return fail(failure.error ?? "unknown trace emission failure");
99520
99618
  }
99619
+ const stateError = input.close ? projectInlineRunClose(input.runId, input.status, {
99620
+ startedAt: typeof result.startedAt === "string" ? result.startedAt : undefined
99621
+ }) : undefined;
99521
99622
  if (input.close && input.status === "done" && result.actionRows === 0) {
99522
- const error51 = `run ${input.runId} closed done with zero action_runs rows; emit --action/--actions-file during the run (no backfill); see inline-pipeline-driver.md#structured-trace-emission-adr-117-task-0868`;
99623
+ const error51 = `run ${input.runId} closed done with zero action_runs rows; emit --action/--actions-file during the run (no backfill); see inline-pipeline-driver.md#structured-trace-emission-adr-117-task-0868${stateError !== undefined ? `; run-record state projection also failed: ${stateError}` : ""}`;
99523
99624
  appendInlineRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${error51}`);
99524
99625
  process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error: error51, code: "NO_ACTION_ROWS", actionRows: 0 })}
99525
99626
  `);
99526
99627
  return 1;
99527
99628
  }
99528
- process.stdout.write(`${JSON.stringify({ ...result, runId: input.runId })}
99629
+ if (input.close && stateError !== undefined) {
99630
+ appendInlineRunLogLine(input.runId, `trace-close-failed run=${input.runId}: ${stateError}`);
99631
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error: stateError, code: "RUN_RECORD_STATE_FAILED" })}
99632
+ `);
99633
+ return 1;
99634
+ }
99635
+ const { startedAt: _threaded, ...stdoutResult } = result;
99636
+ process.stdout.write(`${JSON.stringify({ ...stdoutResult, runId: input.runId })}
99529
99637
  `);
99530
99638
  return 0;
99531
99639
  } catch (error51) {
@@ -99680,8 +99788,10 @@ async function runInlineRunPersistOut(input) {
99680
99788
  toWorkdir: process.cwd(),
99681
99789
  ...input.taskFiles.length > 0 ? { taskFiles: input.taskFiles } : {}
99682
99790
  });
99683
- if (result.skipped.some((skip) => skip.reason.startsWith("record-conflict:"))) {
99684
- process.stdout.write(`${JSON.stringify({ ...result, ok: false, error: "persist-out: unresolved retained record conflicts; retain the worktree and reconcile copies before teardown" })}
99791
+ const keyConflicts = result.skipped.filter((skip) => skip.reason === "external-key-conflict");
99792
+ if (keyConflicts.length > 0 || result.skipped.some((skip) => skip.reason.startsWith("record-conflict:"))) {
99793
+ const error51 = keyConflicts.length > 0 ? `persist-out: external-key conflict for source runs ${keyConflicts.map((skip) => skip.id).join(", ")}; retain the source worktree and reconcile provenance before teardown` : "persist-out: unresolved retained record conflicts; retain the worktree and reconcile copies before teardown";
99794
+ process.stdout.write(`${JSON.stringify({ ...result, ok: false, error: error51 })}
99685
99795
  `);
99686
99796
  return 1;
99687
99797
  }
@@ -99751,7 +99861,7 @@ var init_inline_run_setup = __esm(async () => {
99751
99861
  } = await import("fs"));
99752
99862
  ({ lstat: lstat2, mkdir: mkdir3, readdir: readdir2, readFile: readFile4 } = await import("fs/promises"));
99753
99863
  SAFE_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
99754
- RUN_CITATION_RE = /(?<![\w~:-]|[\w~:-]\/)\.spur\/run\/([A-Za-z0-9][A-Za-z0-9._*?<>{}|,\u2026-]*)/g;
99864
+ RUN_CITATION_RE = /(?<![\w~:-]|[\w~:-]\/)\.spur\/run\/([A-Za-z0-9][A-Za-z0-9._*?<>{}|,\u2026-]*)(\/[^\s`]*)?/g;
99755
99865
  CLOSE_STATUSES = new Set(["done", "failed", "paused"]);
99756
99866
  ACTION_STATUSES = new Set(["done", "failed"]);
99757
99867
  });
@@ -100017,11 +100127,13 @@ class PlanningWriteService {
100017
100127
  lifecycle;
100018
100128
  emitter;
100019
100129
  projectName;
100130
+ onTransitionCommitted;
100020
100131
  constructor(opts) {
100021
100132
  this.fs = opts.fs;
100022
100133
  this.lifecycle = opts.lifecycle ?? new SchemaLifecyclePort;
100023
100134
  this.emitter = opts.emitter ?? new NoopEventEmitter;
100024
100135
  this.projectName = opts.projectName ?? "spur";
100136
+ this.onTransitionCommitted = opts.onTransitionCommitted;
100025
100137
  }
100026
100138
  async create(ref, content) {
100027
100139
  return this.executePipeline(ref, { kind: "create", content });
@@ -100035,8 +100147,8 @@ class PlanningWriteService {
100035
100147
  lock.release();
100036
100148
  }
100037
100149
  }
100038
- async updateSection(ref, sectionName, body) {
100039
- return this.executePipeline(ref, { kind: "updateSection", sectionName, sectionBody: body });
100150
+ async updateSection(ref, sectionName, body, append = false) {
100151
+ return this.executePipeline(ref, { kind: "updateSection", sectionName, sectionBody: body, append });
100040
100152
  }
100041
100153
  async updateBody(ref, body) {
100042
100154
  return this.executePipeline(ref, { kind: "updateBody", body });
@@ -100119,6 +100231,15 @@ class PlanningWriteService {
100119
100231
  } : {}
100120
100232
  };
100121
100233
  await this.emitter.emit(event);
100234
+ let bookkeepingError;
100235
+ const committedHook = this.onTransitionCommitted;
100236
+ if (statusChanged && committedHook !== undefined && fromStatus !== undefined && toStatus !== undefined) {
100237
+ try {
100238
+ await committedHook(ref, fromStatus, toStatus);
100239
+ } catch (err) {
100240
+ bookkeepingError = err instanceof Error ? err.message : String(err);
100241
+ }
100242
+ }
100122
100243
  const warnings = doc2.demotedHeadings.map((line) => `Demoted same-level heading one level deeper in section body (would become a phantom section): "${line}". ` + "Use bullet lists, tables, or **bold** labels for sub-structure instead.");
100123
100244
  for (const dup of doc2.duplicateSectionNames) {
100124
100245
  warnings.push(`Dropped duplicate "${dup}" section (appeared more than once in the file \u2014 ` + "likely a copy-paste or double-write error). The first occurrence was kept.");
@@ -100128,7 +100249,8 @@ class PlanningWriteService {
100128
100249
  eventName,
100129
100250
  ...fromStatus !== undefined ? { fromStatus } : {},
100130
100251
  ...toStatus !== undefined ? { toStatus } : {},
100131
- ...warnings.length > 0 ? { warnings } : {}
100252
+ ...warnings.length > 0 ? { warnings } : {},
100253
+ ...bookkeepingError !== undefined ? { bookkeepingError } : {}
100132
100254
  };
100133
100255
  }
100134
100256
  }
@@ -100151,9 +100273,21 @@ function applyMutation(doc2, mutation) {
100151
100273
  if (mutation.sectionName !== undefined && mutation.sectionBody !== undefined) {
100152
100274
  const body = mutation.sectionName.toLowerCase() === "acceptance criteria" ? normalizeAcFence(mutation.sectionBody) : mutation.sectionBody;
100153
100275
  if (mutation.sectionName.toLowerCase() === "q&a") {
100276
+ if (body.trimStart().startsWith(QA_REPLACE_MARKER)) {
100277
+ doc2.replaceSection("Q&A", body.trimStart().slice(QA_REPLACE_MARKER.length).trimStart());
100278
+ break;
100279
+ }
100280
+ if (mutation.append) {
100281
+ appendSectionBody(doc2, "Q&A", body);
100282
+ break;
100283
+ }
100154
100284
  appendQaEntry(doc2, body);
100155
100285
  break;
100156
100286
  }
100287
+ if (mutation.append) {
100288
+ appendSectionBody(doc2, mutation.sectionName, body);
100289
+ break;
100290
+ }
100157
100291
  doc2.replaceSection(mutation.sectionName, body);
100158
100292
  }
100159
100293
  break;
@@ -100185,9 +100319,8 @@ ${line}
100185
100319
  doc2.replaceSection("History", updated);
100186
100320
  }
100187
100321
  function appendQaEntry(doc2, body) {
100188
- const REPLACE_MARKER = "<!-- qa:replace -->";
100189
- if (body.trimStart().startsWith(REPLACE_MARKER)) {
100190
- doc2.replaceSection("Q&A", body.trimStart().slice(REPLACE_MARKER.length).trimStart());
100322
+ if (body.trimStart().startsWith(QA_REPLACE_MARKER)) {
100323
+ doc2.replaceSection("Q&A", body.trimStart().slice(QA_REPLACE_MARKER.length).trimStart());
100191
100324
  return;
100192
100325
  }
100193
100326
  const existing = doc2.getSection("Q&A") ?? "";
@@ -100200,6 +100333,15 @@ ${body.trim()}
100200
100333
  ${entry}` : entry;
100201
100334
  doc2.replaceSection("Q&A", updated);
100202
100335
  }
100336
+ function appendSectionBody(doc2, sectionName, body) {
100337
+ const existing = doc2.getSection(sectionName) ?? "";
100338
+ const updated = existing.trim().length > 0 ? `${existing.trimEnd()}
100339
+
100340
+ ${body.trim()}
100341
+ ` : `${body.trim()}
100342
+ `;
100343
+ doc2.replaceSection(sectionName, updated);
100344
+ }
100203
100345
  function resolveEventName(kind, domain2, statusChanged) {
100204
100346
  if (kind === "create") {
100205
100347
  return domain2 === "task" ? "task.created" : "feature.created";
@@ -100219,6 +100361,7 @@ function normalizeStatusForDomain(status, domain2) {
100219
100361
  return status;
100220
100362
  }
100221
100363
  }
100364
+ var QA_REPLACE_MARKER = "<!-- qa:replace -->";
100222
100365
  var init_planning_write_service = __esm(() => {
100223
100366
  init_src2();
100224
100367
  init_errors8();
@@ -103352,6 +103495,221 @@ var init_task_scaffold = __esm(() => {
103352
103495
  init_task_locator();
103353
103496
  });
103354
103497
 
103498
+ // ../../packages/app/src/workflow/lifecycle-adapter.ts
103499
+ async function reconcileExistingLifecycleRow(getDb, profile, entityId2, toStatus) {
103500
+ if (toStatus !== "done" && toStatus !== "cancelled")
103501
+ return false;
103502
+ const persistence2 = new DbWorkflowPersistenceAdapter(await getDb());
103503
+ const run = await persistence2.findRunByKey(profile.workflowName, `${profile.entityPrefix}:${entityId2}`);
103504
+ if (run === undefined)
103505
+ return false;
103506
+ const engineStatus = toStatus === "cancelled" ? "failed" : "done";
103507
+ if (run.status === engineStatus && run.terminal_reason === toStatus)
103508
+ return false;
103509
+ await persistence2.finalizeRun(run.id, engineStatus, new Date().toISOString(), undefined, toStatus);
103510
+ return true;
103511
+ }
103512
+
103513
+ class LifecycleAdapter {
103514
+ opts;
103515
+ workflowCache;
103516
+ constructor(opts) {
103517
+ this.opts = opts;
103518
+ }
103519
+ async requestTransition(ref, currentStatus, to) {
103520
+ const { profile } = this.opts;
103521
+ const db2 = await this.opts.getDb();
103522
+ const host = createDefaultWorkflowEngineHost();
103523
+ host.registerGuard(new EnvShellGuardRunner(new NodeProcessExecutor3), "builtin");
103524
+ const persistence2 = withActionTrace(new DbWorkflowPersistenceAdapter(db2), createRunLogTraceFailureRecorder(this.opts.cwd));
103525
+ const svc = new WorkflowService(host, persistence2);
103526
+ const workflow = this.bindGuardVar(await this.loadWorkflow(), ref.id);
103527
+ const externalKey = `${profile.entityPrefix}:${ref.id}`;
103528
+ const now2 = new Date().toISOString();
103529
+ if (to === "done" && profile.entityPrefix === "task") {
103530
+ const links = await this.opts.taskRunLinkDao(db2).listByWbs(ref.id, 20);
103531
+ const hasPipelineRun = links.some((l) => l.kind === "pipeline");
103532
+ const hasPriorBypass = links.some((l) => l.kind === "provenance_bypass");
103533
+ if (!hasPipelineRun && !hasPriorBypass) {
103534
+ if (this.opts.provenanceBypass === true) {
103535
+ await this.opts.taskRunLinkDao(db2).insert({
103536
+ id: createId("trl"),
103537
+ wbs: ref.id,
103538
+ run_id: "manual",
103539
+ kind: "provenance_bypass",
103540
+ created_at: now2
103541
+ });
103542
+ } else {
103543
+ return {
103544
+ allowed: false,
103545
+ from: currentStatus,
103546
+ to,
103547
+ report: `No pipeline run recorded for ${ref.id}. Run the pipeline first (` + `spur workflow run task-pipeline.yaml --vars '{"wbs":"${ref.id}"}'), ` + "or pass --provenance-bypass on `spur task update` to bypass (recorded)."
103548
+ };
103549
+ }
103550
+ }
103551
+ const reviewDenial = await this.checkReviewReadyForDone(ref);
103552
+ if (reviewDenial !== null) {
103553
+ return {
103554
+ allowed: false,
103555
+ from: currentStatus,
103556
+ to,
103557
+ report: reviewDenial
103558
+ };
103559
+ }
103560
+ }
103561
+ const existing = await svc.findRunByKey(profile.workflowName, externalKey);
103562
+ const runId = existing?.id ?? createId("run");
103563
+ await svc.createOrAttachRun({
103564
+ id: runId,
103565
+ workflow_name: profile.workflowName,
103566
+ mode: "state-machine",
103567
+ status: "running",
103568
+ started_at: existing?.started_at ?? now2,
103569
+ completed_at: null,
103570
+ metadata_json: "{}",
103571
+ external_key: externalKey
103572
+ });
103573
+ if (existing === undefined) {
103574
+ await this.opts.taskRunLinkDao(db2).insert({
103575
+ id: createId("trl"),
103576
+ wbs: ref.id,
103577
+ run_id: runId,
103578
+ kind: profile.linkKind,
103579
+ created_at: now2
103580
+ });
103581
+ }
103582
+ await svc.reseedRun(workflow, runId, currentStatus);
103583
+ const result = await svc.requestTransition(workflow, runId, to, {
103584
+ workdir: this.opts.cwd,
103585
+ vars: { [profile.varKey]: ref.id, spurBin: this.opts.spurBin }
103586
+ });
103587
+ if (result.allowed) {
103588
+ const enterFailure = await this.runTargetEnterActions(workflow, to, runId);
103589
+ if (enterFailure !== null) {
103590
+ return { allowed: false, from: currentStatus, to, report: enterFailure };
103591
+ }
103592
+ const terminalStatus = to === "cancelled" ? "failed" : to === "done" ? "done" : null;
103593
+ await persistence2.finalizeRun(runId, terminalStatus ?? "running", new Date().toISOString(), undefined, to === "cancelled" ? "cancelled" : to === "done" ? "done" : undefined);
103594
+ return { allowed: true, from: result.fromState, to: result.toState };
103595
+ }
103596
+ return {
103597
+ allowed: false,
103598
+ from: currentStatus,
103599
+ to,
103600
+ report: this.formatDenialWithLegalPaths(result, workflow, currentStatus, ref.id)
103601
+ };
103602
+ }
103603
+ async runTargetEnterActions(workflow, to, runId) {
103604
+ if (this.opts.runEnterActions === false)
103605
+ return null;
103606
+ const actions = workflow.states.find((state) => state.id === to)?.onEnter ?? [];
103607
+ if (actions.length === 0)
103608
+ return null;
103609
+ const runner = new StreamingShellActionRunner(new NodeProcessExecutor3);
103610
+ const vars = workflow.vars ?? {};
103611
+ for (const [index2, action] of actions.entries()) {
103612
+ if (action.kind !== "shell") {
103613
+ return `State "${to}" onEnter[${index2}] is kind "${action.kind}"; the lifecycle caller only runs shell onEnter actions.`;
103614
+ }
103615
+ const command = action.options?.command;
103616
+ if (typeof command !== "string" || command.trim() === "") {
103617
+ return `State "${to}" onEnter[${index2}] has no shell command.`;
103618
+ }
103619
+ const result = await runner.execute({ ...action.options }, {
103620
+ runId,
103621
+ workdir: this.opts.cwd,
103622
+ stateOrNodeId: to,
103623
+ vars,
103624
+ env: {}
103625
+ });
103626
+ if (!result.ok) {
103627
+ const stderr = typeof result.data?.stderr === "string" ? result.data.stderr.trim() : "";
103628
+ const tail2 = stderr.length > 2000 ? stderr.slice(-2000) : stderr;
103629
+ const exitCode = result.data?.exitCode ?? "unknown";
103630
+ return [`State "${to}" onEnter shell failed (exit ${String(exitCode)}).`, result.error ?? "", tail2].filter((part) => part !== "").join(" ");
103631
+ }
103632
+ }
103633
+ return null;
103634
+ }
103635
+ formatDenialWithLegalPaths(result, workflow, currentStatus, id) {
103636
+ const base2 = this.formatDenial(result.detail, result.guardReport);
103637
+ if (result.reason !== "no-such-transition")
103638
+ return base2;
103639
+ const legal = workflow.transitions.filter((t) => t.from === currentStatus).map((t) => t.to);
103640
+ if (legal.length === 0)
103641
+ return base2;
103642
+ const command = this.opts.profile.entityPrefix === "feature" ? `spur feature sync ${id} (derives the legal hop path)` : `spur task update ${id} <legal-status> or \`spur task record ${id} --transition <legal-status>\``;
103643
+ return `${base2}. Legal path(s) from ${currentStatus}: ${legal.join(" \u2192 ")}. Reach them via \`${command}\`.`;
103644
+ }
103645
+ async loadWorkflow() {
103646
+ if (this.workflowCache === undefined) {
103647
+ const def = await loadWorkflowDef(this.opts.workflowPath, { validateSchema: false });
103648
+ if (def.kind !== "state-machine") {
103649
+ throw new Error(`${this.opts.profile.workflowName} workflow must be a state-machine; got "${def.kind}"`);
103650
+ }
103651
+ this.workflowCache = def;
103652
+ }
103653
+ return this.workflowCache;
103654
+ }
103655
+ bindGuardVar(workflow, id) {
103656
+ return {
103657
+ ...workflow,
103658
+ vars: { ...workflow.vars, [this.opts.profile.varKey]: id, spurBin: this.opts.spurBin }
103659
+ };
103660
+ }
103661
+ async checkReviewReadyForDone(ref) {
103662
+ const markdown = await this.loadTaskMarkdown(ref);
103663
+ if (markdown === null) {
103664
+ return null;
103665
+ }
103666
+ const reviewBody = extractReviewSectionBody(markdown);
103667
+ if (reviewBody === null || !hasPopulatedPriorityTable(reviewBody)) {
103668
+ return `Review L3 gate failed for task ${ref.id}: ### Review must contain a populated ` + `P1\u2013P4 priority findings table before testing\u2192done (strict-core). ` + `Author the table via /sp:dev-review, then re-run the done transition.`;
103669
+ }
103670
+ return null;
103671
+ }
103672
+ async loadTaskMarkdown(ref) {
103673
+ if (this.opts.readTaskMarkdown) {
103674
+ return this.opts.readTaskMarkdown(ref);
103675
+ }
103676
+ try {
103677
+ const fs3 = createNodeFileSystem3(this.opts.cwd);
103678
+ return await fs3.readFile(ref.filePath);
103679
+ } catch {
103680
+ return null;
103681
+ }
103682
+ }
103683
+ formatDenial(detail, guardReport) {
103684
+ if (guardReport === undefined || guardReport === null)
103685
+ return detail;
103686
+ const report = typeof guardReport === "string" ? guardReport : JSON.stringify(guardReport);
103687
+ return `${detail} \u2014 ${report}`;
103688
+ }
103689
+ }
103690
+ var TASK_LIFECYCLE_PROFILE, FEATURE_LIFECYCLE_PROFILE;
103691
+ var init_lifecycle_adapter = __esm(() => {
103692
+ init_src2();
103693
+ init_dist8();
103694
+ init_dist5();
103695
+ init_task_check();
103696
+ init_action_trace();
103697
+ init_shell();
103698
+ init_shell2();
103699
+ TASK_LIFECYCLE_PROFILE = {
103700
+ linkKind: "lifecycle",
103701
+ workflowName: "task-lifecycle",
103702
+ entityPrefix: "task",
103703
+ varKey: "wbs"
103704
+ };
103705
+ FEATURE_LIFECYCLE_PROFILE = {
103706
+ linkKind: "feature-lifecycle",
103707
+ workflowName: "feature-lifecycle",
103708
+ entityPrefix: "feature",
103709
+ varKey: "featureId"
103710
+ };
103711
+ });
103712
+
103355
103713
  // ../../packages/app/src/services/task-transition.ts
103356
103714
  async function runTransitionCheckGate(gate, wbs, filePath, target) {
103357
103715
  const result = await gate.service.check(filePath, wbs, {
@@ -103411,6 +103769,7 @@ async function transitionTaskGuarded(deps, input) {
103411
103769
  forced: input.forceDone === true,
103412
103770
  reason: input.reason,
103413
103771
  artifact: loaded.artifact,
103772
+ verdictPath: loaded.path,
103414
103773
  readError: loaded.readError
103415
103774
  });
103416
103775
  if (guardOutcome.kind === "noop") {
@@ -103918,7 +104277,7 @@ ${templateBackground}`;
103918
104277
  });
103919
104278
  return { runId, action, status: "queued" };
103920
104279
  }
103921
- async updateSection(wbs, sectionName, sourceFile) {
104280
+ async updateSection(wbs, sectionName, sourceFile, append = false) {
103922
104281
  const filePath = await this.resolveTaskFile(wbs);
103923
104282
  const raw = await this.ctx.fs.readFile(sourceFile);
103924
104283
  const body = stripLeadingSectionHeader2(raw, sectionName);
@@ -103926,7 +104285,7 @@ ${templateBackground}`;
103926
104285
  throw new SectionMutationError("invalid-solution", "Solution must contain at least one `file:line` citation");
103927
104286
  }
103928
104287
  const ref = { kind: "task", id: wbs, filePath, folder: this.ctx.tasksDir };
103929
- let result = await this.writeService.updateSection(ref, sectionName, body);
104288
+ let result = await this.writeService.updateSection(ref, sectionName, body, append);
103930
104289
  if (sectionName === "Acceptance Criteria") {
103931
104290
  const warnings = await this.checkAcSubsetWarning(filePath, body);
103932
104291
  if (warnings.length > 0) {
@@ -104091,6 +104450,7 @@ ${templateBackground}`;
104091
104450
  }
104092
104451
  if (current === target) {
104093
104452
  result.transitionedTo = current;
104453
+ result.bookkeepingError = await this.reconcileTerminalBookkeeping(wbs, target);
104094
104454
  } else if (target === "done") {
104095
104455
  if (verdict.verdict !== "PASS") {
104096
104456
  throw new GuardDeniedError(`Cannot transition task ${wbs} to done: verify verdict is ${verdict.verdict}, not PASS. ` + `Re-run the pipeline to a PASS verdict, or use ` + `\`spur task update ${wbs} done --force-done --reason "<why>"\` to override.`);
@@ -104108,6 +104468,9 @@ ${templateBackground}`;
104108
104468
  const hopResult = await this.writeService.transition(ref, hop);
104109
104469
  reached = hopResult.toStatus ?? hop;
104110
104470
  result.transitionedTo = reached;
104471
+ if (hopResult.bookkeepingError !== undefined) {
104472
+ result.bookkeepingError = hopResult.bookkeepingError;
104473
+ }
104111
104474
  if (hop === "done") {
104112
104475
  result.closeAuditError = await reconcileDoneCloseAudit(this, wbs, {
104113
104476
  forced: false,
@@ -104123,6 +104486,9 @@ ${templateBackground}`;
104123
104486
  await this.ensurePipelineRunLink(wbs);
104124
104487
  const transitionResult = await this.writeService.transition(ref, target);
104125
104488
  result.transitionedTo = transitionResult.toStatus;
104489
+ if (transitionResult.bookkeepingError !== undefined) {
104490
+ result.bookkeepingError = transitionResult.bookkeepingError;
104491
+ }
104126
104492
  if (target === "done") {
104127
104493
  result.closeAuditError = await reconcileDoneCloseAudit(this, wbs, {
104128
104494
  forced: false,
@@ -104133,6 +104499,9 @@ ${templateBackground}`;
104133
104499
  } else {
104134
104500
  const transitionResult = await this.writeService.transition(ref, target);
104135
104501
  result.transitionedTo = transitionResult.toStatus;
104502
+ if (transitionResult.bookkeepingError !== undefined) {
104503
+ result.bookkeepingError = transitionResult.bookkeepingError;
104504
+ }
104136
104505
  }
104137
104506
  }
104138
104507
  return result;
@@ -104144,6 +104513,19 @@ ${templateBackground}`;
104144
104513
  const db2 = await getDb();
104145
104514
  await ensurePipelineRunLink(db2, wbs);
104146
104515
  }
104516
+ async reconcileTerminalBookkeeping(wbs, target) {
104517
+ if (target !== "done" && target !== "cancelled")
104518
+ return;
104519
+ const getDb = this.ctx.getDb;
104520
+ if (getDb === undefined)
104521
+ return;
104522
+ try {
104523
+ await reconcileExistingLifecycleRow(getDb, TASK_LIFECYCLE_PROFILE, wbs, target);
104524
+ return;
104525
+ } catch (err) {
104526
+ return err instanceof Error ? err.message : String(err);
104527
+ }
104528
+ }
104147
104529
  async batchCreate(jsonPath) {
104148
104530
  const raw = await this.ctx.fs.readFile(jsonPath);
104149
104531
  let parsed;
@@ -104459,6 +104841,7 @@ var init_task_service = __esm(() => {
104459
104841
  init_dist();
104460
104842
  init_errors8();
104461
104843
  init_run_path();
104844
+ init_lifecycle_adapter();
104462
104845
  init_feature_check();
104463
104846
  init_pipeline_run_link();
104464
104847
  init_planning_check_base();
@@ -105465,207 +105848,6 @@ var init_idea_handoff_cli = __esm(async () => {
105465
105848
  if (false) {}
105466
105849
  });
105467
105850
 
105468
- // ../../packages/app/src/workflow/lifecycle-adapter.ts
105469
- class LifecycleAdapter {
105470
- opts;
105471
- workflowCache;
105472
- constructor(opts) {
105473
- this.opts = opts;
105474
- }
105475
- async requestTransition(ref, currentStatus, to) {
105476
- const { profile } = this.opts;
105477
- const db2 = await this.opts.getDb();
105478
- const host = createDefaultWorkflowEngineHost();
105479
- host.registerGuard(new EnvShellGuardRunner(new NodeProcessExecutor3), "builtin");
105480
- const persistence2 = withActionTrace(new DbWorkflowPersistenceAdapter(db2), createRunLogTraceFailureRecorder(this.opts.cwd));
105481
- const svc = new WorkflowService(host, persistence2);
105482
- const workflow = this.bindGuardVar(await this.loadWorkflow(), ref.id);
105483
- const externalKey = `${profile.entityPrefix}:${ref.id}`;
105484
- const now2 = new Date().toISOString();
105485
- if (to === "done" && profile.entityPrefix === "task") {
105486
- const links = await this.opts.taskRunLinkDao(db2).listByWbs(ref.id, 20);
105487
- const hasPipelineRun = links.some((l) => l.kind === "pipeline");
105488
- const hasPriorBypass = links.some((l) => l.kind === "provenance_bypass");
105489
- if (!hasPipelineRun && !hasPriorBypass) {
105490
- if (this.opts.provenanceBypass === true) {
105491
- await this.opts.taskRunLinkDao(db2).insert({
105492
- id: createId("trl"),
105493
- wbs: ref.id,
105494
- run_id: "manual",
105495
- kind: "provenance_bypass",
105496
- created_at: now2
105497
- });
105498
- } else {
105499
- return {
105500
- allowed: false,
105501
- from: currentStatus,
105502
- to,
105503
- report: `No pipeline run recorded for ${ref.id}. Run the pipeline first (` + `spur workflow run task-pipeline.yaml --vars '{"wbs":"${ref.id}"}'), ` + "or pass --provenance-bypass on `spur task update` to bypass (recorded)."
105504
- };
105505
- }
105506
- }
105507
- const reviewDenial = await this.checkReviewReadyForDone(ref);
105508
- if (reviewDenial !== null) {
105509
- return {
105510
- allowed: false,
105511
- from: currentStatus,
105512
- to,
105513
- report: reviewDenial
105514
- };
105515
- }
105516
- }
105517
- const existing = await svc.findRunByKey(profile.workflowName, externalKey);
105518
- const runId = existing?.id ?? createId("run");
105519
- await svc.createOrAttachRun({
105520
- id: runId,
105521
- workflow_name: profile.workflowName,
105522
- mode: "state-machine",
105523
- status: "running",
105524
- started_at: existing?.started_at ?? now2,
105525
- completed_at: null,
105526
- metadata_json: "{}",
105527
- external_key: externalKey
105528
- });
105529
- if (existing === undefined) {
105530
- await this.opts.taskRunLinkDao(db2).insert({
105531
- id: createId("trl"),
105532
- wbs: ref.id,
105533
- run_id: runId,
105534
- kind: profile.linkKind,
105535
- created_at: now2
105536
- });
105537
- }
105538
- await svc.reseedRun(workflow, runId, currentStatus);
105539
- const result = await svc.requestTransition(workflow, runId, to, {
105540
- workdir: this.opts.cwd,
105541
- vars: { [profile.varKey]: ref.id, spurBin: this.opts.spurBin }
105542
- });
105543
- if (result.allowed) {
105544
- const enterFailure = await this.runTargetEnterActions(workflow, to, runId);
105545
- if (enterFailure !== null) {
105546
- return { allowed: false, from: currentStatus, to, report: enterFailure };
105547
- }
105548
- const terminalStatus = to === "cancelled" ? "failed" : to === "done" ? "done" : null;
105549
- await persistence2.finalizeRun(runId, terminalStatus ?? "running", new Date().toISOString(), undefined, to === "cancelled" ? "cancelled" : to === "done" ? "done" : undefined);
105550
- return { allowed: true, from: result.fromState, to: result.toState };
105551
- }
105552
- return {
105553
- allowed: false,
105554
- from: currentStatus,
105555
- to,
105556
- report: this.formatDenialWithLegalPaths(result, workflow, currentStatus, ref.id)
105557
- };
105558
- }
105559
- async runTargetEnterActions(workflow, to, runId) {
105560
- if (this.opts.runEnterActions === false)
105561
- return null;
105562
- const actions = workflow.states.find((state) => state.id === to)?.onEnter ?? [];
105563
- if (actions.length === 0)
105564
- return null;
105565
- const runner = new StreamingShellActionRunner(new NodeProcessExecutor3);
105566
- const vars = workflow.vars ?? {};
105567
- for (const [index2, action] of actions.entries()) {
105568
- if (action.kind !== "shell") {
105569
- return `State "${to}" onEnter[${index2}] is kind "${action.kind}"; the lifecycle caller only runs shell onEnter actions.`;
105570
- }
105571
- const command = action.options?.command;
105572
- if (typeof command !== "string" || command.trim() === "") {
105573
- return `State "${to}" onEnter[${index2}] has no shell command.`;
105574
- }
105575
- const result = await runner.execute({ ...action.options }, {
105576
- runId,
105577
- workdir: this.opts.cwd,
105578
- stateOrNodeId: to,
105579
- vars,
105580
- env: {}
105581
- });
105582
- if (!result.ok) {
105583
- const stderr = typeof result.data?.stderr === "string" ? result.data.stderr.trim() : "";
105584
- const tail2 = stderr.length > 2000 ? stderr.slice(-2000) : stderr;
105585
- const exitCode = result.data?.exitCode ?? "unknown";
105586
- return [`State "${to}" onEnter shell failed (exit ${String(exitCode)}).`, result.error ?? "", tail2].filter((part) => part !== "").join(" ");
105587
- }
105588
- }
105589
- return null;
105590
- }
105591
- formatDenialWithLegalPaths(result, workflow, currentStatus, id) {
105592
- const base2 = this.formatDenial(result.detail, result.guardReport);
105593
- if (result.reason !== "no-such-transition")
105594
- return base2;
105595
- const legal = workflow.transitions.filter((t) => t.from === currentStatus).map((t) => t.to);
105596
- if (legal.length === 0)
105597
- return base2;
105598
- const command = this.opts.profile.entityPrefix === "feature" ? `spur feature sync ${id} (derives the legal hop path)` : `spur task update ${id} <legal-status> or \`spur task record ${id} --transition <legal-status>\``;
105599
- return `${base2}. Legal path(s) from ${currentStatus}: ${legal.join(" \u2192 ")}. Reach them via \`${command}\`.`;
105600
- }
105601
- async loadWorkflow() {
105602
- if (this.workflowCache === undefined) {
105603
- const def = await loadWorkflowDef(this.opts.workflowPath, { validateSchema: false });
105604
- if (def.kind !== "state-machine") {
105605
- throw new Error(`${this.opts.profile.workflowName} workflow must be a state-machine; got "${def.kind}"`);
105606
- }
105607
- this.workflowCache = def;
105608
- }
105609
- return this.workflowCache;
105610
- }
105611
- bindGuardVar(workflow, id) {
105612
- return {
105613
- ...workflow,
105614
- vars: { ...workflow.vars, [this.opts.profile.varKey]: id, spurBin: this.opts.spurBin }
105615
- };
105616
- }
105617
- async checkReviewReadyForDone(ref) {
105618
- const markdown = await this.loadTaskMarkdown(ref);
105619
- if (markdown === null) {
105620
- return null;
105621
- }
105622
- const reviewBody = extractReviewSectionBody(markdown);
105623
- if (reviewBody === null || !hasPopulatedPriorityTable(reviewBody)) {
105624
- return `Review L3 gate failed for task ${ref.id}: ### Review must contain a populated ` + `P1\u2013P4 priority findings table before testing\u2192done (strict-core). ` + `Author the table via /sp:dev-review, then re-run the done transition.`;
105625
- }
105626
- return null;
105627
- }
105628
- async loadTaskMarkdown(ref) {
105629
- if (this.opts.readTaskMarkdown) {
105630
- return this.opts.readTaskMarkdown(ref);
105631
- }
105632
- try {
105633
- const fs3 = createNodeFileSystem3(this.opts.cwd);
105634
- return await fs3.readFile(ref.filePath);
105635
- } catch {
105636
- return null;
105637
- }
105638
- }
105639
- formatDenial(detail, guardReport) {
105640
- if (guardReport === undefined || guardReport === null)
105641
- return detail;
105642
- const report = typeof guardReport === "string" ? guardReport : JSON.stringify(guardReport);
105643
- return `${detail} \u2014 ${report}`;
105644
- }
105645
- }
105646
- var TASK_LIFECYCLE_PROFILE, FEATURE_LIFECYCLE_PROFILE;
105647
- var init_lifecycle_adapter = __esm(() => {
105648
- init_src2();
105649
- init_dist8();
105650
- init_dist5();
105651
- init_task_check();
105652
- init_action_trace();
105653
- init_shell();
105654
- init_shell2();
105655
- TASK_LIFECYCLE_PROFILE = {
105656
- linkKind: "lifecycle",
105657
- workflowName: "task-lifecycle",
105658
- entityPrefix: "task",
105659
- varKey: "wbs"
105660
- };
105661
- FEATURE_LIFECYCLE_PROFILE = {
105662
- linkKind: "feature-lifecycle",
105663
- workflowName: "feature-lifecycle",
105664
- entityPrefix: "feature",
105665
- varKey: "featureId"
105666
- };
105667
- });
105668
-
105669
105851
  // ../../packages/app/src/workflow/mermaid-render.ts
105670
105852
  function escId(id) {
105671
105853
  return id.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -106117,6 +106299,7 @@ __export(exports_src2, {
106117
106299
  refreshHistoryRollups: () => refreshHistoryRollups,
106118
106300
  redactAndBound: () => redactAndBound,
106119
106301
  recordAgentQuotaEvent: () => recordAgentQuotaEvent,
106302
+ reconcileExistingLifecycleRow: () => reconcileExistingLifecycleRow,
106120
106303
  readyRefineCommand: () => readyRefineCommand,
106121
106304
  readyRefineAllCommand: () => readyRefineAllCommand,
106122
106305
  readWorkflowRunRecord: () => readWorkflowRunRecord,
@@ -115595,11 +115778,19 @@ ${result.content}`);
115595
115778
  "scenario title>` bullets or `Scenario: AC1 \u2014 <title>` blocks (AC<n> is task-local, never R<n>);",
115596
115779
  "`(req: R<n>)` binds a requirement; `ac_altitude: task-local` exempts task-only AC.",
115597
115780
  'Sections replace, with one exception: `--section "Q&A"` APPENDS a timestamped',
115598
- "`#### Q&A entry \u2014 <ISO>` block. Start the body with `<!-- qa:replace -->` to replace it wholesale."
115781
+ "`#### Q&A entry \u2014 <ISO>` block. Start the body with `<!-- qa:replace -->` to replace it wholesale.",
115782
+ "Extend instead of replace: `--append` with `--section` writes the new body after the existing",
115783
+ "section content (existing bytes preserved; an empty/missing section is created) \u2014 the bounded",
115784
+ "way to extend a populated Review/Testing section without destroying it (task 1057)."
115599
115785
  ].join(`
115600
- `)).option(...SHARED_OPTIONS.section).option(...SHARED_OPTIONS.fromFile).option(...SHARED_OPTIONS.featureFrontmatter).option(...SHARED_OPTIONS.prioritySet).option("--ac-numbering <mode>", "Set the ac_numbering frontmatter field (task-local) \u2014 opts the task into the Requirements\u2194AC coverage check").option("--ac-altitude <mode>", "Set the ac_altitude frontmatter field. Valid: `graduating` (default; feature-AC subset rule enforced) or `task-local` (skip the DD-09 subset rule \u2014 task scenarios are intentionally not feature ship criteria). Mirrors the L1 schema enum (packages/domain/src/planning/schema.ts:304).").option("--estimate-hours <n>", "Set the estimate_hours frontmatter field (positive number) \u2014 decomposition size estimate; the inline pipeline driver dispatches no subagent at/below its floor").option("--no-lifecycle", "Suppress lifecycle workflow run creation (use during pipeline runs to avoid orphaned lifecycle runs)").option("--assignee <spec-id>", "Set the assignee frontmatter field to an agent spec id").option("--force-done", "Allow transitioning to `done` even when the verify verdict is not PASS; records an override (task 0292). Waives the verdict only \u2014 the FSM path still applies, so from an earlier status walk the hops first: `todo` \u2192 `wip` \u2192 `testing` \u2192 `done` (each hop runs the structural `spur task check`)").option("--reason <text>", "Rationale for a forced-done override (paired with --force-done; persisted as done_reason)").option("--provenance-bypass", "Record an audited provenance-bypass link when no pipeline run exists (replaces the SPUR_PROVENANCE_OVERRIDE env var; pair with --force-done)", false).option("--verdict-dir <path>", "Directory holding <wbs>-verdict.json artifacts (default: .spur/run)").option(...SHARED_OPTIONS.folderTasks).option(...SHARED_OPTIONS.json).option(...SHARED_OPTIONS.jsonEnvelope).action(async (wbs, status, options) => {
115786
+ `)).option(...SHARED_OPTIONS.section).option(...SHARED_OPTIONS.fromFile).option("--append", "With --section: write the new body after the existing section content (existing content + one blank line + new body; surrounding whitespace trimmed) instead of replacing it wholesale. Requires --section; an empty/missing section is created. Section names are case-sensitive (see `spur task sections <wbs> list`).", false).option(...SHARED_OPTIONS.featureFrontmatter).option(...SHARED_OPTIONS.prioritySet).option("--ac-numbering <mode>", "Set the ac_numbering frontmatter field (task-local) \u2014 opts the task into the Requirements\u2194AC coverage check").option("--ac-altitude <mode>", "Set the ac_altitude frontmatter field. Valid: `graduating` (default; feature-AC subset rule enforced) or `task-local` (skip the DD-09 subset rule \u2014 task scenarios are intentionally not feature ship criteria). Mirrors the L1 schema enum (packages/domain/src/planning/schema.ts:304).").option("--estimate-hours <n>", "Set the estimate_hours frontmatter field (positive number) \u2014 decomposition size estimate; the inline pipeline driver dispatches no subagent at/below its floor").option("--no-lifecycle", "Suppress lifecycle workflow run creation (use during pipeline runs to avoid orphaned lifecycle runs)").option("--assignee <spec-id>", "Set the assignee frontmatter field to an agent spec id").option("--force-done", "Allow transitioning to `done` even when the verify verdict is not PASS; records an override (task 0292). Waives the verdict only \u2014 the FSM path still applies, so from an earlier status walk the hops first: `todo` \u2192 `wip` \u2192 `testing` \u2192 `done` (each hop runs the structural `spur task check`)").option("--reason <text>", "Rationale for a forced-done override (paired with --force-done; persisted as done_reason)").option("--provenance-bypass", "Record an audited provenance-bypass link when no pipeline run exists (replaces the SPUR_PROVENANCE_OVERRIDE env var; pair with --force-done)", false).option("--verdict-dir <path>", "Directory holding <wbs>-verdict.json artifacts (default: .spur/run)").option(...SHARED_OPTIONS.folderTasks).option(...SHARED_OPTIONS.json).option(...SHARED_OPTIONS.jsonEnvelope).action(async (wbs, status, options) => {
115601
115787
  const svc = await makeService(context4, options.folder, options.lifecycle === false, options.provenanceBypass === true);
115602
115788
  try {
115789
+ if (options.append === true && options.section === undefined) {
115790
+ writeJsonError(context4.output, options, "--append requires --section", "VALIDATION_FAILED");
115791
+ context4.setExitCode(2);
115792
+ return;
115793
+ }
115603
115794
  if (options.assignee !== undefined) {
115604
115795
  if (options.section !== undefined) {
115605
115796
  writeJsonError(context4.output, options, "--assignee cannot be combined with --section", "VALIDATION_FAILED");
@@ -115626,7 +115817,7 @@ ${result.content}`);
115626
115817
  context4.setExitCode(2);
115627
115818
  return;
115628
115819
  }
115629
- const result = await svc.updateSection(wbs, options.section, options.fromFile);
115820
+ const result = await svc.updateSection(wbs, options.section, options.fromFile, options.append === true);
115630
115821
  if (options.json) {
115631
115822
  context4.output.write(toEnvelopeJson(result, { enveloped: options.jsonEnvelope }));
115632
115823
  } else {
@@ -115710,6 +115901,9 @@ ${result.content}`);
115710
115901
  } else if (outcome.closeAuditError !== undefined) {
115711
115902
  context4.output.error(`warning: failed to record done close audit fields: ${outcome.closeAuditError}`);
115712
115903
  }
115904
+ if (result.bookkeepingError !== undefined) {
115905
+ context4.output.error(`warning: failed to reconcile task-lifecycle bookkeeping row: ${result.bookkeepingError} ` + "(task file is committed; replay the same terminal transition to repair)");
115906
+ }
115713
115907
  if (options.json) {
115714
115908
  context4.output.write(toEnvelopeJson(result, { enveloped: options.jsonEnvelope }));
115715
115909
  } else {
@@ -116088,6 +116282,9 @@ Recover with: ${err.recoveryCommand}` : err.message;
116088
116282
  if (result.closeAuditError !== undefined) {
116089
116283
  context4.output.error(`warning: failed to record done close audit fields: ${result.closeAuditError}`);
116090
116284
  }
116285
+ if (result.bookkeepingError !== undefined) {
116286
+ context4.output.error(`warning: failed to reconcile task-lifecycle bookkeeping row: ${result.bookkeepingError} ` + "(task file is committed; replay the same terminal transition to repair)");
116287
+ }
116091
116288
  if (result.verdictMessage !== undefined) {
116092
116289
  context4.output.error(`\u24D8 verdict artifact ${result.verdictState}: ${result.verdictMessage}`);
116093
116290
  }
@@ -116456,7 +116653,10 @@ async function makeService(context4, folderOverride, noLifecycle = false, proven
116456
116653
  const writeService = new PlanningWriteService({
116457
116654
  fs: context4.fs,
116458
116655
  ...lifecycle ? { lifecycle } : {},
116459
- emitter: makePlanningEmitter(context4)
116656
+ emitter: makePlanningEmitter(context4),
116657
+ onTransitionCommitted: async (ref, _from, to) => {
116658
+ await reconcileExistingLifecycleRow(() => context4.getDb(), TASK_LIFECYCLE_PROFILE, ref.id, to);
116659
+ }
116460
116660
  });
116461
116661
  return new TaskService({
116462
116662
  fs: context4.fs,
@@ -118331,7 +118531,7 @@ import { createRequire } from "module";
118331
118531
  var CLI_CONFIG = {
118332
118532
  binaryName: "spur",
118333
118533
  binaryLabel: "spur",
118334
- binaryVersion: "0.3.97",
118534
+ binaryVersion: "0.3.98",
118335
118535
  configDir: ".spur",
118336
118536
  configFile: ".spur/config.yaml",
118337
118537
  databaseFile: ".spur/spur.db"
@@ -127157,6 +127357,10 @@ init_dist5();
127157
127357
  await init_src3();
127158
127358
  import { homedir as homedir12 } from "os";
127159
127359
  import { basename as basename20, join as join51, relative as relative11, resolve as resolve23 } from "path";
127360
+ // ../server/package.json
127361
+ var version3 = "0.3.98";
127362
+
127363
+ // ../server/src/modules/health/index.ts
127160
127364
  var startedAt = Date.now();
127161
127365
  var OPERATOR_AGENT_ID = "board-operator";
127162
127366
  var healthModule = {
@@ -127167,6 +127371,9 @@ var healthModule = {
127167
127371
  const memory = process.memoryUsage();
127168
127372
  return c3.json({
127169
127373
  status: "ok",
127374
+ timestamp: new Date().toISOString(),
127375
+ service: "spur",
127376
+ version: version3,
127170
127377
  uptime_seconds: Math.round(uptime),
127171
127378
  memory_rss_mb: Math.round(memory.rss / 1048576 * 100) / 100,
127172
127379
  memory_heap_mb: Math.round(memory.heapUsed / 1048576 * 100) / 100
@@ -127263,7 +127470,7 @@ var healthModule = {
127263
127470
  const doc2 = doctorMap.get(ex.name);
127264
127471
  const installed = doc2?.installed ?? !avail.disabled;
127265
127472
  const usable = doc2?.usable ?? !avail.disabled;
127266
- const version3 = doc2?.version ?? null;
127473
+ const version4 = doc2?.version ?? null;
127267
127474
  const error51 = doc2?.error ?? null;
127268
127475
  const isProject = projectExecutors.has(ex.name);
127269
127476
  const isGlobal = globalExecutors.has(ex.name);
@@ -127280,7 +127487,7 @@ var healthModule = {
127280
127487
  disabledSince: avail.since,
127281
127488
  installed,
127282
127489
  usable,
127283
- version: version3,
127490
+ version: version4,
127284
127491
  error: error51,
127285
127492
  elected: [],
127286
127493
  executionCapabilities: ex.executionCapabilities,
@@ -127516,6 +127723,9 @@ var healthModule = {
127516
127723
  try {
127517
127724
  body = await c3.req.json();
127518
127725
  } catch {}
127726
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
127727
+ return c3.json({ error: "request body must be a JSON object" }, 400);
127728
+ }
127519
127729
  const target = body.name ?? body.path;
127520
127730
  if (!target) {
127521
127731
  return c3.json({ error: "Missing name or path in request body" }, 400);
@@ -127547,6 +127757,9 @@ var healthModule = {
127547
127757
  } catch {
127548
127758
  return c3.json({ error: "Invalid JSON request body" }, 400);
127549
127759
  }
127760
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
127761
+ return c3.json({ error: "request body must be a JSON object" }, 400);
127762
+ }
127550
127763
  const executorName = body.name ?? body.executor;
127551
127764
  if (!executorName || typeof executorName !== "string" || executorName.trim().length === 0) {
127552
127765
  return c3.json({ error: "Missing or invalid executor name" }, 400);
@@ -128589,6 +128802,9 @@ var processesModule = {
128589
128802
  } catch {
128590
128803
  return c3.json({ error: "request body must be valid JSON" }, 400);
128591
128804
  }
128805
+ if (json4 === null || typeof json4 !== "object" || Array.isArray(json4)) {
128806
+ return c3.json({ error: "request body must be a JSON object" }, 400);
128807
+ }
128592
128808
  const body = json4;
128593
128809
  if (typeof body.line !== "string" || body.line.length === 0) {
128594
128810
  return c3.json({ error: 'field "line" is required' }, 400);
@@ -128806,7 +129022,13 @@ function createTaskHandlers(ctx) {
128806
129022
  return { ok: true, data: createResponseShape2(r2) };
128807
129023
  }),
128808
129024
  transition: os4.task.transition.handler(async ({ input }) => {
128809
- await ctx.transitionTask(input);
129025
+ const guarded = await ctx.transitionTask(input);
129026
+ if (guarded.kind === "transitioned" && guarded.result.bookkeepingError !== undefined) {
129027
+ ctx.logger.error(`task ${input.wbs}: failed to reconcile task-lifecycle bookkeeping row after transition to ${guarded.result.toStatus}: ${guarded.result.bookkeepingError} ` + `(task file is committed; replay the same terminal transition, e.g. \`spur task record ${input.wbs} --transition ${input.toStatus}\`, to repair)`, { wbs: input.wbs, toStatus: guarded.result.toStatus });
129028
+ }
129029
+ if (guarded.kind === "transitioned" && guarded.result.closeAuditError !== undefined) {
129030
+ ctx.logger.error(`task ${input.wbs}: failed to record done close audit fields after transition to ${input.toStatus}: ${guarded.result.closeAuditError} ` + `(task file is committed; replay the same terminal transition, e.g. \`spur task record ${input.wbs} --transition ${input.toStatus}\`, to repair)`, { wbs: input.wbs, toStatus: input.toStatus });
129031
+ }
128810
129032
  return { ok: true, data: { wbs: input.wbs, status: input.toStatus } };
128811
129033
  }),
128812
129034
  body: os4.task.body.handler(async ({ input }) => {
@@ -130775,7 +130997,6 @@ async function generateOpenApiSpec(pluginPaths = {}) {
130775
130997
  }
130776
130998
 
130777
130999
  // ../server/src/router.ts
130778
- var version3 = "0.0.0";
130779
131000
  var os5 = implement(contract);
130780
131001
  var EMPTY_BOARD_CATALOG = { catalogVersion: 1, host: null, modules: [] };
130781
131002
  var stubCtx = new Proxy({}, {
@@ -130989,6 +131210,7 @@ function createServerContext(appRt, options) {
130989
131210
  runDir: join53(cwd, ".spur", "run"),
130990
131211
  webDistPath: options.webDistPath,
130991
131212
  boardModules: options.boardModules,
131213
+ logger: appRt.logger,
130992
131214
  async getDb() {
130993
131215
  if (!dbPromise) {
130994
131216
  const created = (async () => {
@@ -131014,7 +131236,14 @@ function createServerContext(appRt, options) {
131014
131236
  const lazyEmitter = new LazyPlanningEventEmitter(this.getDb.bind(this), eventsBus);
131015
131237
  taskSvc = new TaskService({
131016
131238
  fs: fs3,
131017
- writeService: new PlanningWriteService({ fs: fs3, projectName: "spur", emitter: lazyEmitter }),
131239
+ writeService: new PlanningWriteService({
131240
+ fs: fs3,
131241
+ projectName: "spur",
131242
+ emitter: lazyEmitter,
131243
+ onTransitionCommitted: async (ref, _from, to) => {
131244
+ await reconcileExistingLifecycleRow(this.getDb.bind(this), TASK_LIFECYCLE_PROFILE, ref.id, to);
131245
+ }
131246
+ }),
131018
131247
  tasksDir: folders.tasksDir,
131019
131248
  foldersConfig: folders.foldersConfig,
131020
131249
  projectName: "spur",