@gobing-ai/spur 0.3.37 → 0.3.39

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.
@@ -173,6 +173,12 @@ catalog here or maintain generated per-platform capability copies in the project
173
173
  - Surgical changes only — no drive-by refactors or speculative abstractions.
174
174
  - Surface changes keep `docs/04_DESIGN.md` in the **same commit** (T3); run `sp:doc-evolve`
175
175
  sync-check when unsure.
176
+ - **One writer per working tree.** Two agent sessions in one checkout overwrite each other silently
177
+ — the symptom reads as a model regression. Parallel agent work uses git worktree isolation (one
178
+ branch + one tree per agent).
179
+ - **Commit per task.** Start a task on a tree clean of other tasks' implementations; a dirty tree
180
+ mixes two tasks' evidence into one diff. The pipeline precheck warns (never blocks) with the file
181
+ list.
176
182
  <!-- PROJECT-SPECIFIC: import aliases, forbidden paths, CI rules. -->
177
183
 
178
184
  ---
@@ -104,6 +104,12 @@ vars:
104
104
  # Max checklist items under ## Plan before size precheck fails (R2, task 0454).
105
105
  # Override with `--vars '{"maxImplementPlanItems":"15"}'`.
106
106
  maxImplementPlanItems: "8"
107
+ # Diff-scope guard on the implement hop (R1, task 0487). When the target task
108
+ # body backticks at least one path, non-corpus changes outside those paths
109
+ # fail the step by name. New files beside a declared file are allowed. Empty
110
+ # (default) = on; set to "off" to bypass:
111
+ # `--vars '{"implementScopeGuard":"off"}'`.
112
+ implementScopeGuard: ""
107
113
 
108
114
  states:
109
115
  - id: precheck
@@ -114,16 +120,58 @@ states:
114
120
  onEnter:
115
121
  # Soft doctor — write status and always exit 0 so transitions can branch to
116
122
  # `failed` cleanly (same pattern as the quality-gate soft probe).
123
+ #
124
+ # R2 (0487): probe BOTH resolved executors ($agent and $implementAgent) and
125
+ # FAIL on `authenticated: unauthenticated`. Previously only $agent was probed
126
+ # and auth was informational, so a run whose implement executor had no
127
+ # provider key sailed through precheck into a guaranteed implement failure
128
+ # (runs e8cb00e7 / b16bfbf4: "auth: no … API key not found for provider
129
+ # 'volc'" → precheck ✓). `unknown` auth keeps the old soft behavior — some
130
+ # agents expose no auth-status verb. `spur agent doctor` CLI exit-code
131
+ # semantics are deliberately untouched; the gate lives here.
132
+ # R4 (0487): one divergence line when the two executors differ (legitimate
133
+ # when only implementAgent is pinned, but it must be visible in the log).
117
134
  - kind: shell
118
135
  options:
119
136
  command: >-
120
137
  mkdir -p .spur/run &&
121
138
  DOCTOR_FILE=".spur/run/$wbs-precheck-doctor.status" &&
122
- if $spurBin agent doctor $agent; then
123
- echo PASS > "$DOCTOR_FILE";
139
+ STATUS=PASS;
140
+ if [ -n "$implementAgent" ] && [ "$implementAgent" != "$agent" ]; then
141
+ echo "precheck: agent=$agent implementAgent=$implementAgent (executors diverge)";
142
+ EXECS="$agent $implementAgent";
124
143
  else
125
- echo FAIL > "$DOCTOR_FILE";
126
- fi &&
144
+ EXECS="$agent";
145
+ fi;
146
+ for EXE in $EXECS; do
147
+ OUT=$($spurBin agent doctor "$EXE" --json 2>&1) || {
148
+ echo "precheck: FAIL - doctor exited non-zero for $EXE";
149
+ echo "$OUT";
150
+ STATUS=FAIL;
151
+ continue;
152
+ };
153
+ AUTH=$(printf '%s' "$OUT" | jq -r '.agents[0].authenticated // "unknown"' 2>/dev/null || echo unknown);
154
+ DETAIL=$(printf '%s' "$OUT" | jq -r '.agents[0].modelStatus.detail // ""' 2>/dev/null || echo "");
155
+ echo "precheck: $EXE auth=$AUTH $DETAIL";
156
+ if [ "$AUTH" = unauthenticated ]; then
157
+ echo "precheck: FAIL - executor $EXE is unauthenticated; $DETAIL";
158
+ STATUS=FAIL;
159
+ fi;
160
+ done;
161
+ echo "$STATUS" > "$DOCTOR_FILE";
162
+ exit 0
163
+ # R6 (0487): pre-launch hygiene WARNING (never a block) — starting a task on
164
+ # a tree already dirty with another task's implementation is how 0485's diff
165
+ # got swept into 0486's run. Corpus dirs are excluded: the pipeline writes
166
+ # those itself.
167
+ - kind: shell
168
+ options:
169
+ command: >-
170
+ DIRTY=$(git status --porcelain -- . ':(exclude)docs/tasks*' ':(exclude)docs/features' 2>/dev/null);
171
+ if [ -n "$DIRTY" ]; then
172
+ echo "precheck: WARNING - working tree has uncommitted non-corpus changes; commit or stash before starting a new task:";
173
+ echo "$DIRTY";
174
+ fi;
127
175
  exit 0
128
176
  - kind: note
129
177
  options:
@@ -149,13 +197,17 @@ states:
149
197
  # R2 (0454): task size precheck — evaluate R-item and Plan-item counts.
150
198
  # Writes PASS/FAIL to .spur/run/<wbs>-precheck-size.status. Always exit 0
151
199
  # (soft check, like doctor). The precheck→implement guard reads the file.
200
+ # R3 (0487): `--executor` adds the size-vs-capability gate — a task past the
201
+ # DEFAULT caps routed to a sub-`capable-1` executor blocks here instead of
202
+ # burning the full implementTimeoutMs and exiting 3 (run ca130182).
152
203
  - kind: shell
153
204
  options:
154
205
  command: >-
155
206
  SIZE_FILE=".spur/run/$wbs-precheck-size.status" &&
156
207
  mkdir -p .spur/run &&
157
208
  bun plugins/sp/scripts/task-size-precheck.ts "$wbs"
158
- --spur-bin "$spurBin" --max-reqs "$maxImplementReqs" --max-plan-items "$maxImplementPlanItems" &&
209
+ --spur-bin "$spurBin" --max-reqs "$maxImplementReqs" --max-plan-items "$maxImplementPlanItems"
210
+ --executor "$implementAgent" &&
159
211
  exit 0
160
212
 
161
213
  - id: implement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
5
5
  "keywords": [
6
6
  "spur",
package/spur.js CHANGED
@@ -62497,7 +62497,12 @@ class AgentService {
62497
62497
  }
62498
62498
  }
62499
62499
  if (args.json) {
62500
- this.ctx.output.write(toJson({ agents: results }));
62500
+ const executorByName = new Map((executors ?? []).map((e) => [e.name, e]));
62501
+ const rows = results.map((result) => {
62502
+ const executor = executorByName.get(result.agent) ?? { name: result.agent, agent: result.agent };
62503
+ return { ...result, capabilityTier: getExecutorTier(executor) };
62504
+ });
62505
+ this.ctx.output.write(toJson({ agents: rows }));
62501
62506
  } else if (args.agent !== undefined) {
62502
62507
  this.ctx.output.write(renderDoctorDetail(results[0] ?? null));
62503
62508
  } else {
@@ -67989,7 +67994,7 @@ function hasPopulatedPriorityTable(body) {
67989
67994
  const cells = line.split("|");
67990
67995
  if (cells.length < 3)
67991
67996
  continue;
67992
- const severityIdx = cells.findIndex((c3) => /^\s*P[1-4]\s*$/.test(c3));
67997
+ const severityIdx = cells.findIndex((c3) => /^\s*P[1-4]\b/.test(c3));
67993
67998
  if (severityIdx === -1)
67994
67999
  continue;
67995
68000
  const hasContent = cells.some((c3, i2) => i2 !== severityIdx && !isPlaceholderCell(c3));
@@ -67999,8 +68004,12 @@ function hasPopulatedPriorityTable(body) {
67999
68004
  return false;
68000
68005
  }
68001
68006
  function extractReviewSectionBody(markdown) {
68002
- const match = markdown.match(/^### Review[ \t]*\n([\s\S]*?)(?=^### |Z)/m);
68003
- return match ? match[1] ?? "" : null;
68007
+ const heading = markdown.match(/^### Review[ \t]*\n/m);
68008
+ if (!heading)
68009
+ return null;
68010
+ const rest = markdown.slice((heading.index ?? 0) + heading[0].length);
68011
+ const next = rest.match(/^### /m);
68012
+ return next ? rest.slice(0, next.index ?? 0) : rest;
68004
68013
  }
68005
68014
  function isReviewScaffold(body) {
68006
68015
  if (isPlaceholderBody(body))
@@ -71241,6 +71250,7 @@ var init_steering = __esm(() => {
71241
71250
  });
71242
71251
 
71243
71252
  // ../../packages/app/src/workflow/actions/agent-run.ts
71253
+ import { tmpdir } from "os";
71244
71254
  import { dirname as dirname14, isAbsolute as isAbsolute3, join as join17 } from "path";
71245
71255
 
71246
71256
  class AgentRunActionRunner {
@@ -71330,6 +71340,7 @@ class AgentRunActionRunner {
71330
71340
  let resumeRetried = false;
71331
71341
  let steeringNote;
71332
71342
  let traced;
71343
+ const diffBaseline = requireDiff === true ? await createGitWorkingTreeSnapshot(cwd, this.agentConfig.excludeGlobs) : undefined;
71333
71344
  try {
71334
71345
  for (;; ) {
71335
71346
  steeringNote = undefined;
@@ -71393,12 +71404,25 @@ class AgentRunActionRunner {
71393
71404
  }
71394
71405
  }
71395
71406
  const stepLabel = context4.stateOrNodeId;
71396
- if (ok && requireDiff === true && !await gitHasNonCorpusChanges(cwd, this.agentConfig.excludeGlobs)) {
71397
- return {
71398
- ok: false,
71399
- data: buildResultData(exitCode, agentLabel, capture, answer, invocation),
71400
- error: `agent.run '${stepLabel}' (${agentLabel}) exited 0 but produced zero non-corpus file changes \u2014 empty implement (no-op). The implement agent must change at least one file outside the configured task/feature folders; fix the implement input and re-run the pipeline.`
71401
- };
71407
+ if (ok && requireDiff === true) {
71408
+ const changed = diffBaseline === undefined ? await gitNonCorpusChangedFiles(cwd, this.agentConfig.excludeGlobs) : await gitChangesSinceSnapshot(cwd, diffBaseline, this.agentConfig.excludeGlobs);
71409
+ if (changed.length === 0) {
71410
+ return {
71411
+ ok: false,
71412
+ data: buildResultData(exitCode, agentLabel, capture, answer, invocation),
71413
+ error: `agent.run '${stepLabel}' (${agentLabel}) exited 0 but produced zero non-corpus file changes \u2014 empty implement (no-op). The implement agent must change at least one file outside the configured task/feature folders; fix the implement input and re-run the pipeline.`
71414
+ };
71415
+ }
71416
+ const wbs = String(context4.vars.wbs ?? "");
71417
+ const guardOff = String(context4.vars.implementScopeGuard ?? "") === "off";
71418
+ const rogue = guardOff ? [] : await findOutOfScopeChanges(cwd, wbs, changed);
71419
+ if (rogue.length > 0) {
71420
+ return {
71421
+ ok: false,
71422
+ data: buildResultData(exitCode, agentLabel, capture, answer, invocation),
71423
+ error: `agent.run '${stepLabel}' (${agentLabel}) changed files outside task ${wbs}'s declared surfaces: ${rogue.join(", ")}. Implement only the target WBS; revert the out-of-scope changes (or name those paths in the task body). Set the run var implementScopeGuard: "off" to bypass.`
71424
+ };
71425
+ }
71402
71426
  }
71403
71427
  if (!ok) {
71404
71428
  await writePartialWorkArtifact(context4, agentLabel, model, traced, cwd, sessionDir);
@@ -71439,7 +71463,10 @@ class AgentRunActionRunner {
71439
71463
  ...steeringNote !== undefined ? { __steeringNote: steeringNote } : {}
71440
71464
  } : undefined
71441
71465
  };
71442
- } finally {}
71466
+ } finally {
71467
+ if (diffBaseline !== undefined)
71468
+ await deleteSnapshotIndex(diffBaseline);
71469
+ }
71443
71470
  }
71444
71471
  }
71445
71472
  async function discoverSessionId(sessionDir) {
@@ -71647,20 +71674,151 @@ async function gitDiffStat(cwd) {
71647
71674
  return "";
71648
71675
  }
71649
71676
  }
71650
- async function gitHasNonCorpusChanges(cwd, excludeGlobs = ["docs/tasks3/*", "docs/features/*"]) {
71677
+ async function createGitWorkingTreeSnapshot(cwd, excludeGlobs = ["docs/tasks3/*", "docs/features/*"]) {
71678
+ const indexFile = join17(tmpdir(), `spur-implement-scope-${crypto.randomUUID()}.index`);
71679
+ const fs3 = createNodeFileSystem3(cwd);
71680
+ let keepIndex = false;
71681
+ try {
71682
+ await fs3.ensureDir(dirname14(indexFile));
71683
+ const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
71684
+ env.GIT_INDEX_FILE = indexFile;
71685
+ const executor = new NodeProcessExecutor3;
71686
+ const common4 = { cwd, env, forceBuffered: true, rejectOnError: false };
71687
+ const read = await executor.run({ command: "git", args: ["read-tree", "HEAD"], ...common4 });
71688
+ if (read.exitCode !== 0)
71689
+ return;
71690
+ const excludes = excludeGlobs.map((glob) => `:(exclude)${glob}`);
71691
+ const add = await executor.run({ command: "git", args: ["add", "-A", "--", ".", ...excludes], ...common4 });
71692
+ if (add.exitCode !== 0)
71693
+ return;
71694
+ const tree = await executor.run({ command: "git", args: ["write-tree"], ...common4 });
71695
+ if (tree.exitCode !== 0 || tree.stdout.trim() === "")
71696
+ return;
71697
+ keepIndex = true;
71698
+ return { indexFile, tree: tree.stdout.trim() };
71699
+ } catch {
71700
+ return;
71701
+ } finally {
71702
+ if (!keepIndex && await fs3.exists(indexFile))
71703
+ await fs3.deleteFile(indexFile);
71704
+ }
71705
+ }
71706
+ async function deleteSnapshotIndex(snapshot) {
71707
+ try {
71708
+ await createNodeFileSystem3().deleteFile(snapshot.indexFile);
71709
+ } catch {}
71710
+ }
71711
+ async function gitChangesSinceSnapshot(cwd, before, excludeGlobs = ["docs/tasks3/*", "docs/features/*"]) {
71712
+ const after = await createGitWorkingTreeSnapshot(cwd, excludeGlobs);
71713
+ if (after === undefined)
71714
+ return gitNonCorpusChangedFiles(cwd, excludeGlobs);
71715
+ try {
71716
+ const result = await new NodeProcessExecutor3().run({
71717
+ command: "git",
71718
+ args: ["diff", "--name-status", "-z", before.tree, after.tree],
71719
+ cwd,
71720
+ maxOutput: 1024 * 1024,
71721
+ forceBuffered: true,
71722
+ rejectOnError: false
71723
+ });
71724
+ return result.exitCode === 0 ? parseNameStatusPaths(result.stdout) : gitNonCorpusChangedFiles(cwd, excludeGlobs);
71725
+ } finally {
71726
+ await deleteSnapshotIndex(after);
71727
+ }
71728
+ }
71729
+ function parseNameStatusPaths(stdout) {
71730
+ const fields = stdout.split("\x00").filter(Boolean);
71731
+ const changes = [];
71732
+ for (let i2 = 0;i2 < fields.length; ) {
71733
+ const status = fields[i2++] ?? "";
71734
+ if (/^[RC]/.test(status))
71735
+ i2++;
71736
+ const path9 = fields[i2++] ?? "";
71737
+ if (path9)
71738
+ changes.push({ path: path9, untracked: status === "A" });
71739
+ }
71740
+ return changes;
71741
+ }
71742
+ async function gitNonCorpusChangedFiles(cwd, excludeGlobs = ["docs/tasks3/*", "docs/features/*"]) {
71651
71743
  try {
71652
71744
  const excludes = excludeGlobs.map((g) => `:(exclude)${g}`);
71653
71745
  const result = await new NodeProcessExecutor3().run({
71654
71746
  command: "git",
71655
- args: ["status", "--porcelain", "--", ".", ...excludes],
71747
+ args: ["status", "--porcelain", "-uall", "--", ".", ...excludes],
71656
71748
  cwd,
71657
71749
  maxOutput: 1024 * 1024,
71658
71750
  forceBuffered: true,
71659
71751
  rejectOnError: false
71660
71752
  });
71661
- return result.exitCode === 0 && result.stdout.trim() !== "";
71753
+ if (result.exitCode !== 0)
71754
+ return [];
71755
+ return parsePorcelainPaths(result.stdout);
71662
71756
  } catch {
71663
- return false;
71757
+ return [];
71758
+ }
71759
+ }
71760
+ function parsePorcelainPaths(stdout) {
71761
+ const paths = [];
71762
+ for (const line of stdout.split(`
71763
+ `)) {
71764
+ if (line.length < 4)
71765
+ continue;
71766
+ let path9 = line.slice(3);
71767
+ const arrow = path9.indexOf(" -> ");
71768
+ if (arrow !== -1)
71769
+ path9 = path9.slice(arrow + 4);
71770
+ path9 = path9.trim().replace(/^"|"$/g, "");
71771
+ if (path9)
71772
+ paths.push({ path: path9, untracked: line.startsWith("??") });
71773
+ }
71774
+ return paths;
71775
+ }
71776
+ function extractTaskScopeAllowlist(taskMarkdown) {
71777
+ const rules = new Map;
71778
+ for (const match of taskMarkdown.matchAll(/`([^`\n]+)`/g)) {
71779
+ let token = (match[1] ?? "").trim();
71780
+ const prefix = /\/\*+$/.test(token);
71781
+ token = token.replace(/:\d+(-\d+)?$/, "").replace(/\/\*+$/, "").replace(/\/$/, "").replace(/^\.\//, "");
71782
+ if (/\s/.test(token) || token.startsWith("-") || token.startsWith("/") || token.startsWith("../")) {
71783
+ continue;
71784
+ }
71785
+ const isPath = token.includes("/") || /^[\w.-]+\.\w{1,10}$/.test(token);
71786
+ if (!isPath)
71787
+ continue;
71788
+ const isFile = /\.[\w-]{1,10}$/.test(token);
71789
+ const rule = { path: token, prefix: prefix || !isFile };
71790
+ rules.set(`${rule.prefix ? "prefix" : "file"}:${rule.path}`, rule);
71791
+ }
71792
+ return [...rules.values()];
71793
+ }
71794
+ function isInScope(change, allowlist) {
71795
+ return allowlist.some((rule) => {
71796
+ if (rule.prefix)
71797
+ return change.path === rule.path || change.path.startsWith(`${rule.path}/`);
71798
+ if (change.path === rule.path)
71799
+ return true;
71800
+ return change.untracked && dirname14(change.path) === dirname14(rule.path);
71801
+ });
71802
+ }
71803
+ async function findOutOfScopeChanges(cwd, wbs, changed) {
71804
+ if (!wbs)
71805
+ return [];
71806
+ try {
71807
+ const fs3 = createNodeFileSystem3(cwd);
71808
+ const locator = TaskLocator.forDirs(fs3, [
71809
+ join17(cwd, "docs", "tasks3"),
71810
+ join17(cwd, "docs", "tasks2"),
71811
+ join17(cwd, "docs", "tasks")
71812
+ ]);
71813
+ const hit = await locator.findByWbs(wbs);
71814
+ if (!hit)
71815
+ return [];
71816
+ const allowlist = extractTaskScopeAllowlist(await fs3.readFile(hit.filePath));
71817
+ if (allowlist.length === 0)
71818
+ return [];
71819
+ return changed.filter((change) => !isInScope(change, allowlist)).map((change) => change.path);
71820
+ } catch {
71821
+ return [];
71664
71822
  }
71665
71823
  }
71666
71824
  function tail(text4, maxChars) {
@@ -72912,26 +73070,30 @@ async function fileExists(path9) {
72912
73070
  return await fs3.exists(path9);
72913
73071
  }
72914
73072
  async function resolveDefaultAgentVar(cwd, callerVars, warn) {
73073
+ const result = {};
73074
+ if (callerVars?.implementAgent === undefined && callerVars?.agent !== undefined) {
73075
+ result.implementAgent = callerVars.agent;
73076
+ }
72915
73077
  let config4;
72916
73078
  try {
72917
73079
  config4 = await loadSpurConfig(cwd);
72918
73080
  } catch {
72919
- return {};
73081
+ return result;
72920
73082
  }
72921
73083
  const configured = config4.agent?.default;
72922
73084
  if (typeof configured !== "string" || configured.length === 0) {
72923
- return {};
73085
+ return result;
72924
73086
  }
72925
73087
  const valid = config4.agent?.executors?.some((e) => e.name === configured) === true || resolveAgentName(configured) !== undefined;
72926
73088
  if (!valid) {
72927
73089
  warn(`agent.default "${configured}" does not name a configured executor or agent binary; leaving the pipeline's literal agent in force`);
72928
- return {};
73090
+ return result;
72929
73091
  }
72930
- const result = {};
72931
73092
  if (callerVars?.agent === undefined)
72932
73093
  result.agent = configured;
72933
- if (callerVars?.implementAgent === undefined)
73094
+ if (result.implementAgent === undefined && callerVars?.implementAgent === undefined) {
72934
73095
  result.implementAgent = configured;
73096
+ }
72935
73097
  return result;
72936
73098
  }
72937
73099
  async function resolveWorkflowLogRetentionDays(cwd) {
@@ -81463,7 +81625,7 @@ init_dist6();
81463
81625
  var CLI_CONFIG = {
81464
81626
  binaryName: "spur",
81465
81627
  binaryLabel: "spur",
81466
- binaryVersion: "0.3.37",
81628
+ binaryVersion: "0.3.39",
81467
81629
  configDir: ".spur",
81468
81630
  configFile: ".spur/config.yaml",
81469
81631
  databaseFile: ".spur/spur.db"
@@ -92879,7 +93041,7 @@ ${result.content}`);
92879
93041
  '`SPUR_PROVENANCE_OVERRIDE=1 spur task update <wbs> done --force-done --reason "\u2026"`.',
92880
93042
  "See the gate checklist (spur-dev/references/gate-checklists.md)."
92881
93043
  ].join(`
92882
- `)).option("--section <name>", "Section name to replace").option("--from-file <path>", "File to read section body from (requires --section)").option("--feature <id>", "Set the feature_id frontmatter field (traceability edge)").option("--priority <p>", "Set the priority frontmatter field (P0\u2013P3)").option("--ac-numbering <mode>", "Set the ac_numbering frontmatter field (task-local) \u2014 opts the task into the Requirements\u2194AC coverage check").option("--no-lifecycle", "Suppress lifecycle workflow run creation (use during pipeline runs to avoid orphaned lifecycle runs)").option("--force-done", "Allow transitioning to `done` even when the verify verdict is not PASS; records an override (task 0292)").option("--reason <text>", "Rationale for a forced-done override (paired with --force-done; persisted as done_reason)").option("--verdict-dir <path>", "Directory holding <wbs>-verdict.json artifacts (default: .spur/run)").option("--folder <path>", "Custom tasks folder").option("--json", "Output machine-readable JSON").action(async (wbs, status, options) => {
93044
+ `)).option("--section <name>", "Section name to replace").option("--from-file <path>", "File to read section body from (requires --section)").option("--feature <id>", "Set the feature_id frontmatter field (traceability edge)").option("--priority <p>", "Set the priority frontmatter field (P0\u2013P3)").option("--ac-numbering <mode>", "Set the ac_numbering frontmatter field (task-local) \u2014 opts the task into the Requirements\u2194AC coverage check").option("--no-lifecycle", "Suppress lifecycle workflow run creation (use during pipeline runs to avoid orphaned lifecycle runs)").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("--verdict-dir <path>", "Directory holding <wbs>-verdict.json artifacts (default: .spur/run)").option("--folder <path>", "Custom tasks folder").option("--json", "Output machine-readable JSON").action(async (wbs, status, options) => {
92883
93045
  const svc = await makeService2(context4, options.folder, options.lifecycle === false);
92884
93046
  try {
92885
93047
  if (options.section !== undefined) {