@gobing-ai/spur 0.3.36 → 0.3.37

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.
@@ -15,34 +15,6 @@
15
15
  "reason": "GENUINE PRE-FIX BYPASS. Closed 2026-08-06, well after the rule existed (2026-07-24), via the `--no-lifecycle` hole closed on 2026-08-07. Its Solution needs one file:line citation. The only entry here representing real unpaid debt.",
16
16
  "since": "2026-08-07"
17
17
  },
18
- {
19
- "kind": "feature",
20
- "id": "M",
21
- "code": "L3.ac-bdd-error",
22
- "reason": "CHECKER GAP, not corpus debt. M is a wayfinder MAP; its Acceptance Criteria section carries an explicit prose disclaimer ('This feature is a wayfinder MAP... It has no acceptance criteria — its target is the ## Goal'). The BDD validator requires a `Feature:` declaration and has no notion of the map feature type. Correct fix is to teach `feature check` about wayfinder maps, not to bolt fake Gherkin onto a map.",
23
- "since": "2026-08-07"
24
- },
25
- {
26
- "kind": "feature",
27
- "id": "M",
28
- "code": "L3.ac-bdd-invalid",
29
- "reason": "Downstream of the M ac-bdd-error entry — same missing-Feature-declaration cause, reported twice by the validator.",
30
- "since": "2026-08-07"
31
- },
32
- {
33
- "kind": "feature",
34
- "id": "F82",
35
- "code": "L3.ac-bdd-error",
36
- "reason": "CHECKER GAP, same class as M: wayfinder map with a deliberate no-AC prose disclaimer (plus a noted exception for two implementation-ready child tickets). Not a syntax error to fix.",
37
- "since": "2026-08-07"
38
- },
39
- {
40
- "kind": "feature",
41
- "id": "F82",
42
- "code": "L3.ac-bdd-invalid",
43
- "reason": "Downstream of the F82 ac-bdd-error entry — same cause, reported twice.",
44
- "since": "2026-08-07"
45
- },
46
18
  {
47
19
  "kind": "feature",
48
20
  "id": "F821",
@@ -140,7 +140,7 @@ spur <noun> <verb> … --json
140
140
  spur <noun> --help
141
141
  ```
142
142
 
143
- **Long-tail:** Additional `/sp:dev-*` commands (handover, gitmsg, fixall, dogfood, reverse, arch,
143
+ **Long-tail:** Additional `/sp:dev-*` commands (handover, gitmsg, fixall, findconflict, dogfood, reverse, arch,
144
144
  …) are indexed in the project plugin README (`plugins/sp/README.md` when present).
145
145
 
146
146
  **Outside spur-cli:** Nouns not fully documented in `sp:spur-cli` (`agent`, `history`, `message`,
@@ -57,9 +57,11 @@ vars:
57
57
  # `--vars '{"agent":"claude"}'`. Pinned (not left to the AiRunner's <default>
58
58
  # selection) so a broken/misconfigured agent on the box can't silently capture the run.
59
59
  agent: "omp"
60
- # Implement-only executor override (R1, task 0454). Defaults to same as agent.
61
- # Override with `--vars '{"implementAgent":"omp-zai"}'` to pin implement to a
62
- # specific executor while other hops keep the default agent.
60
+ # Implement-only executor override (R1, task 0454). Resolves to this YAML literal
61
+ # unless overridden. The `--agent` flag (execution-batch.md §3.2) forwards into BOTH
62
+ # `agent` and `implementAgent`, so a pinned `--agent X` reaches implement too. To
63
+ # pin ONLY implement while other hops keep the default, pass
64
+ # `--vars '{"implementAgent":"omp-zai"}'`.
63
65
  implementAgent: "omp"
64
66
  # Step-level timeout for agentic hops (review / verify / test-fix) in ms.
65
67
  # Soft quality-gate shells are unbounded by this var (host shell only).
@@ -90,6 +92,12 @@ vars:
90
92
  # project can point it at its own formatter; invoked best-effort (a missing or
91
93
  # failing formatter must never abort a run — the quality gate is the real gate).
92
94
  formatCmd: "bun run format"
95
+ # Bounded `file:line` anchors extracted from the failing quality-gate log by the
96
+ # `test` / `test-recheck` hops (deduped, max 20, space-separated on one line).
97
+ # `test-fix` reads `.spur/run/<wbs>-test-gate.findings` into this var and names the
98
+ # anchors directly in the /sp:dev-fixall input, so the fix agent starts AT the failing
99
+ # file:line instead of re-deriving it from a fresh gate run (0482 R3).
100
+ gateFindings: ""
93
101
  # Max R-items in ## Requirements before size precheck fails (R2, task 0454).
94
102
  # Override with `--vars '{"maxImplementReqs":"12"}'`.
95
103
  maxImplementReqs: "5"
@@ -147,7 +155,7 @@ states:
147
155
  SIZE_FILE=".spur/run/$wbs-precheck-size.status" &&
148
156
  mkdir -p .spur/run &&
149
157
  bun plugins/sp/scripts/task-size-precheck.ts "$wbs"
150
- --max-reqs "$maxImplementReqs" --max-plan-items "$maxImplementPlanItems" &&
158
+ --spur-bin "$spurBin" --max-reqs "$maxImplementReqs" --max-plan-items "$maxImplementPlanItems" &&
151
159
  exit 0
152
160
 
153
161
  - id: implement
@@ -214,10 +222,14 @@ states:
214
222
  command: >-
215
223
  mkdir -p .spur/run &&
216
224
  STATUS_FILE=".spur/run/$wbs-test-gate.status" &&
225
+ LOG_FILE=".spur/run/$wbs-test-gate.log" &&
217
226
  ATTEMPT_FILE=".spur/run/$wbs-test-fix-attempt" &&
218
227
  echo 0 > "$ATTEMPT_FILE" &&
219
228
  set +e &&
220
- ( sh -c "$qualityGateCmd" ); gate_rc=$?; set -e &&
229
+ ( sh -c "$qualityGateCmd" ) > "$LOG_FILE" 2>&1; gate_rc=$?; set -e &&
230
+ cat "$LOG_FILE" &&
231
+ FINDINGS_FILE=".spur/run/$wbs-test-gate.findings" &&
232
+ set +e; grep -oE '[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+' "$LOG_FILE" | sort -u | head -20 | tr '\n' ' ' > "$FINDINGS_FILE"; set -e &&
221
233
  if [ "$gate_rc" -eq 0 ]; then
222
234
  printf 'PASS\n' > "$STATUS_FILE";
223
235
  else
@@ -238,10 +250,19 @@ states:
238
250
  ATTEMPT_FILE=".spur/run/$wbs-test-fix-attempt" &&
239
251
  n=$(cat "$ATTEMPT_FILE" 2>/dev/null || echo 0) &&
240
252
  printf '%s\n' "$((n + 1))" > "$ATTEMPT_FILE"
253
+ # R3 (0482): project the extracted gate anchors into a var so the dispatch input
254
+ # can NAME the failing file:line, not merely point at a log. A vars template cannot
255
+ # run a shell, so the read is an action, not an inline `$(cat ...)` substitution.
256
+ - kind: file.read.into-var
257
+ options:
258
+ path: .spur/run/${vars.wbs}-test-gate.findings
259
+ var: gateFindings
241
260
  - kind: agent.run
242
261
  options:
243
262
  agent: ${vars.agent}
244
- input: /sp:dev-fixall "${vars.qualityGateCmd}"
263
+ # R3 (0482): `--findings` names the failing anchors inline; `--gate-log` remains
264
+ # the full-context escape hatch when the digest is not enough.
265
+ input: /sp:dev-fixall "${vars.qualityGateCmd}" --gate-log .spur/run/${vars.wbs}-test-gate.log --findings "${vars.gateFindings}"
245
266
  timeoutMs: ${vars.stepTimeoutMs}
246
267
 
247
268
  - id: test-recheck
@@ -256,8 +277,12 @@ states:
256
277
  command: >-
257
278
  mkdir -p .spur/run &&
258
279
  STATUS_FILE=".spur/run/$wbs-test-gate.status" &&
280
+ LOG_FILE=".spur/run/$wbs-test-gate.log" &&
259
281
  set +e &&
260
- ( sh -c "$qualityGateCmd" ); gate_rc=$?; set -e &&
282
+ ( sh -c "$qualityGateCmd" ) > "$LOG_FILE" 2>&1; gate_rc=$?; set -e &&
283
+ cat "$LOG_FILE" &&
284
+ FINDINGS_FILE=".spur/run/$wbs-test-gate.findings" &&
285
+ set +e; grep -oE '[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+' "$LOG_FILE" | sort -u | head -20 | tr '\n' ' ' > "$FINDINGS_FILE"; set -e &&
261
286
  if [ "$gate_rc" -eq 0 ]; then
262
287
  printf 'PASS\n' > "$STATUS_FILE";
263
288
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
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
@@ -60540,7 +60540,7 @@ var init_schema4 = __esm(() => {
60540
60540
  {
60541
60541
  schema_version: STAGE_REGISTRY_SCHEMA_VERSION,
60542
60542
  id: "test",
60543
- aliases: ["dev-unit"],
60543
+ aliases: ["dev-unit", "dev-fixall"],
60544
60544
  description: "dev-unit: generate/extend tests to coverage target",
60545
60545
  artifacts: [{ kind: "test-file", direction: "output", required: true }],
60546
60546
  reasoning_skill: "sp:code-testing",
@@ -60572,7 +60572,10 @@ var init_schema4 = __esm(() => {
60572
60572
  retry: { max_attempts: 2, terminal_stop: "escalate" },
60573
60573
  model_policy: {
60574
60574
  min_tier: "capable-1",
60575
- fallback: []
60575
+ fallback: [
60576
+ { tier: "capable-2", trigger: "resource-exhaustion" },
60577
+ { tier: "capable-2", trigger: "gate-fail" }
60578
+ ]
60576
60579
  },
60577
60580
  context_layers: [],
60578
60581
  observability: [],
@@ -60635,7 +60638,10 @@ var init_schema4 = __esm(() => {
60635
60638
  retry: { max_attempts: 3, terminal_stop: "block" },
60636
60639
  model_policy: {
60637
60640
  min_tier: "capable-1",
60638
- fallback: []
60641
+ fallback: [
60642
+ { tier: "capable-2", trigger: "resource-exhaustion" },
60643
+ { tier: "capable-2", trigger: "gate-fail" }
60644
+ ]
60639
60645
  },
60640
60646
  context_layers: [],
60641
60647
  observability: [],
@@ -61762,7 +61768,7 @@ function normalizeFeatureStatus(raw) {
61762
61768
  }
61763
61769
  return resolved;
61764
61770
  }
61765
- var TASK_STATUSES, FEATURE_STATUSES, TASK_STATUS_ICONS, FEATURE_STATUS_ICONS, PRIORITIES, PROFILES, TASK_TYPES, TASK_VARIANTS, DEFAULT_TASK_VARIANT = "standard", FEATURE_ID_PATTERN, TASK_STATUS_ALIASES, FEATURE_STATUS_ALIASES, isoDateString, featureId, wbsString, taskFrontmatterSchema, featureFrontmatterSchema, TASK_TEMPLATES, taskBatchItemSchema, taskBatchSchema;
61771
+ var TASK_STATUSES, FEATURE_STATUSES, TASK_STATUS_ICONS, FEATURE_STATUS_ICONS, PRIORITIES, PROFILES, TASK_TYPES, TASK_VARIANTS, DEFAULT_TASK_VARIANT = "standard", FEATURE_ID_PATTERN, WAYFINDER_MAP_TAG = "wayfinder-map", TASK_STATUS_ALIASES, FEATURE_STATUS_ALIASES, isoDateString, featureId, wbsString, taskFrontmatterSchema, featureFrontmatterSchema, TASK_TEMPLATES, taskBatchItemSchema, taskBatchSchema;
61766
61772
  var init_schema6 = __esm(() => {
61767
61773
  init_zod();
61768
61774
  TASK_STATUSES = ["backlog", "todo", "wip", "testing", "blocked", "done", "cancelled"];
@@ -62359,6 +62365,7 @@ __export(exports_src, {
62359
62365
  acquireCreateLock: () => acquireCreateLock,
62360
62366
  WorkspaceDao: () => WorkspaceDao,
62361
62367
  WorkflowStateDao: () => WorkflowStateDao,
62368
+ WAYFINDER_MAP_TAG: () => WAYFINDER_MAP_TAG,
62362
62369
  VOLATILE_LAYER_NAMES: () => VOLATILE_LAYER_NAMES,
62363
62370
  UNKNOWN_MODEL_PRICING: () => UNKNOWN_MODEL_PRICING,
62364
62371
  UNIVERSAL_SECTIONS: () => UNIVERSAL_SECTIONS,
@@ -62602,7 +62609,6 @@ class AgentService {
62602
62609
  let currentModel = resolved.model;
62603
62610
  let currentSource = resolved.source;
62604
62611
  let currentStage = resolved.stage;
62605
- const maxEscalations = currentStage?.policy.fallback.length ?? 0;
62606
62612
  const attemptedExecutors = new Set(currentStage ? [currentStage.executorName] : []);
62607
62613
  if (!jsonOutput && TIER2_AGENTS.has(currentAgent)) {
62608
62614
  this.ctx.output.error(`Warning: ${currentAgent} is a Tier-2 agent (TUI/gateway only)`);
@@ -62722,12 +62728,16 @@ class AgentService {
62722
62728
  if (result.exitCode === 0)
62723
62729
  break;
62724
62730
  const escalationSignal = classifyObjectiveFailure(result);
62725
- if (escalationSignal === undefined || currentStage === undefined || attempt >= maxEscalations) {
62731
+ if (escalationSignal === undefined || currentStage === undefined) {
62732
+ break;
62733
+ }
62734
+ const matchingFallbacks = currentStage.policy.fallback.filter((fallback) => fallback.trigger === escalationSignal);
62735
+ if (matchingFallbacks.length === 0 || escalationSignal !== "resource-exhaustion" && attempt >= matchingFallbacks.length) {
62726
62736
  break;
62727
62737
  }
62728
62738
  runFlags.signal = escalationSignal;
62729
62739
  runFlags["from-executor"] = currentStage.executorName;
62730
- const nextResolved = await this.resolveAgent(prompt, runFlags, doctorRunner);
62740
+ const nextResolved = await this.resolveAgent(prompt, runFlags, doctorRunner, attemptedExecutors);
62731
62741
  if (!nextResolved.ok || nextResolved.stage === undefined || attemptedExecutors.has(nextResolved.stage.executorName)) {
62732
62742
  if (!jsonOutput) {
62733
62743
  this.ctx.output.error(`Escalation chain exhausted after ${attempt + 1} attempt(s); executors tried: ${[...attemptedExecutors].join(", ")}`);
@@ -62774,26 +62784,58 @@ class AgentService {
62774
62784
  ...observer !== undefined ? { observer } : {}
62775
62785
  };
62776
62786
  }
62777
- async resolveAgent(prompt, flags, doctorRunner) {
62787
+ async resolveAgent(prompt, flags, doctorRunner, exclude) {
62778
62788
  const raw = stringFlag(flags, "agent", "auto");
62779
62789
  if (raw === "auto")
62780
- return this.resolveAgentAuto(prompt, flags, doctorRunner);
62790
+ return this.resolveAgentAuto(prompt, flags, doctorRunner, exclude);
62781
62791
  if (raw === "inline")
62782
- return this.resolveAgentAuto(prompt, flags, doctorRunner);
62783
- return this.resolveExecutorSelector(raw, doctorRunner, "explicit");
62792
+ return this.resolveAgentAuto(prompt, flags, doctorRunner, exclude);
62793
+ return this.resolvePinned(raw, prompt, flags, doctorRunner, exclude);
62794
+ }
62795
+ async resolvePinned(selector, prompt, flags, doctorRunner, exclude) {
62796
+ if (stringFlag(flags, "signal", "") !== "") {
62797
+ const stageRecord2 = this.resolveCanonicalStage(prompt, flags);
62798
+ if (stageRecord2 !== undefined) {
62799
+ const stageRes = await this.resolveStageModelPolicy(stageRecord2, flags, doctorRunner, exclude);
62800
+ if (stageRes !== undefined)
62801
+ return stageRes;
62802
+ }
62803
+ }
62804
+ const base2 = await this.resolveExecutorSelector(selector, doctorRunner, "explicit");
62805
+ if (!base2.ok)
62806
+ return base2;
62807
+ const stageRecord = this.resolveCanonicalStage(prompt, flags);
62808
+ if (stageRecord !== undefined) {
62809
+ const executors = this.ctx.agentConfig?.executors;
62810
+ const pinned = executors?.find((e) => e.name === selector);
62811
+ const tier = pinned !== undefined ? getExecutorTier(pinned) : stageRecord.model_policy.min_tier;
62812
+ return {
62813
+ ...base2,
62814
+ stage: {
62815
+ stageId: stageRecord.id,
62816
+ policy: stageRecord.model_policy,
62817
+ executorName: selector,
62818
+ executorTier: tier
62819
+ }
62820
+ };
62821
+ }
62822
+ return base2;
62784
62823
  }
62785
- async resolveAgentAuto(prompt, flags, doctorRunner) {
62786
- const config4 = this.ctx.agentConfig;
62824
+ resolveCanonicalStage(prompt, flags) {
62787
62825
  const phase = extractPhase(prompt);
62788
62826
  const stageFlag = stringFlag(flags, "stage", "");
62789
62827
  const targetStageId = stageFlag !== "" ? stageFlag : phase !== undefined ? phase : undefined;
62790
- if (targetStageId !== undefined) {
62791
- const stageRecord = getCanonicalStage(targetStageId);
62792
- if (stageRecord !== undefined) {
62793
- const stageRes = await this.resolveStageModelPolicy(stageRecord, flags, doctorRunner);
62794
- if (stageRes !== undefined) {
62795
- return stageRes;
62796
- }
62828
+ if (targetStageId === undefined)
62829
+ return;
62830
+ return getCanonicalStage(targetStageId);
62831
+ }
62832
+ async resolveAgentAuto(prompt, flags, doctorRunner, exclude) {
62833
+ const config4 = this.ctx.agentConfig;
62834
+ const stageRecord = this.resolveCanonicalStage(prompt, flags);
62835
+ if (stageRecord !== undefined) {
62836
+ const stageRes = await this.resolveStageModelPolicy(stageRecord, flags, doctorRunner, exclude);
62837
+ if (stageRes !== undefined) {
62838
+ return stageRes;
62797
62839
  }
62798
62840
  }
62799
62841
  if (config4?.default !== undefined) {
@@ -62803,7 +62845,7 @@ class AgentService {
62803
62845
  }
62804
62846
  return this.resolveAgentPriority(doctorRunner);
62805
62847
  }
62806
- async resolveStageModelPolicy(stageRecord, flags, doctorRunner) {
62848
+ async resolveStageModelPolicy(stageRecord, flags, doctorRunner, exclude) {
62807
62849
  const executors = this.ctx.agentConfig?.executors;
62808
62850
  if (!executors || executors.length === 0) {
62809
62851
  return;
@@ -62812,6 +62854,44 @@ class AgentService {
62812
62854
  const signalRaw = stringFlag(flags, "signal", "");
62813
62855
  const signal = signalRaw.length > 0 ? signalRaw : undefined;
62814
62856
  const fromExecutor = stringFlag(flags, "from-executor", "") || undefined;
62857
+ if (signal === "resource-exhaustion" && fromExecutor !== undefined) {
62858
+ const failed = executors.find((e) => e.name === fromExecutor);
62859
+ if (failed !== undefined) {
62860
+ const failedTier = getExecutorTier(failed);
62861
+ const failedCanonical = resolveAgentName(failed.agent);
62862
+ const exhaustedAgents2 = new Set(executors.filter((executor) => executor.name === fromExecutor || (exclude?.has(executor.name) ?? false)).map((executor) => resolveAgentName(executor.agent)).filter((agent) => agent !== undefined));
62863
+ const sideways = executors.filter((e) => {
62864
+ const canonical = resolveAgentName(e.agent);
62865
+ return e.name !== fromExecutor && getExecutorTier(e) === failedTier && canonical !== failedCanonical && (canonical === undefined || !exhaustedAgents2.has(canonical)) && !(exclude?.has(e.name) ?? false);
62866
+ });
62867
+ for (const executor of sideways) {
62868
+ const canonical = resolveAgentName(executor.agent);
62869
+ if (canonical === undefined) {
62870
+ return {
62871
+ ok: false,
62872
+ exitCode: 2,
62873
+ message: `Executor '${executor.name}' for stage '${stageRecord.id}' maps to unknown agent '${executor.agent}'`
62874
+ };
62875
+ }
62876
+ const usable = await this.checkUsable(canonical, doctorRunner);
62877
+ if (usable.ok) {
62878
+ this.ctx.output.error(`Failover: ${fromExecutor} (tier ${failedTier}) exhausted; using same-tier ${executor.name} on a different binary`);
62879
+ return {
62880
+ ok: true,
62881
+ agent: canonical,
62882
+ model: executor.model,
62883
+ source: "stage",
62884
+ stage: {
62885
+ stageId: stageRecord.id,
62886
+ policy,
62887
+ executorName: executor.name,
62888
+ executorTier: getExecutorTier(executor)
62889
+ }
62890
+ };
62891
+ }
62892
+ }
62893
+ }
62894
+ }
62815
62895
  let targetTier = policy.min_tier;
62816
62896
  if (signal !== undefined) {
62817
62897
  const currentExec = executors.find((e) => e.name === fromExecutor);
@@ -62824,7 +62904,11 @@ class AgentService {
62824
62904
  }
62825
62905
  }
62826
62906
  }
62827
- const eligible = executors.filter((e) => isTierEligible(getExecutorTier(e), targetTier));
62907
+ const exhaustedAgents = signal === "resource-exhaustion" ? new Set(executors.filter((executor) => exclude?.has(executor.name) ?? false).map((executor) => resolveAgentName(executor.agent)).filter((agent) => agent !== undefined)) : undefined;
62908
+ const eligible = executors.filter((e) => {
62909
+ const canonical = resolveAgentName(e.agent);
62910
+ return isTierEligible(getExecutorTier(e), targetTier) && !(exclude?.has(e.name) ?? false) && (canonical === undefined || !(exhaustedAgents?.has(canonical) ?? false));
62911
+ });
62828
62912
  if (eligible.length === 0) {
62829
62913
  return;
62830
62914
  }
@@ -63098,7 +63182,7 @@ function classifyObjectiveFailure(result) {
63098
63182
  if (result.exitCode === 0)
63099
63183
  return;
63100
63184
  const text4 = `${result.stderr} ${result.stdout}`.toLowerCase();
63101
- if (/\b(rate[\s-]?limit|429|too many requests|quota|token limit|token budget|context length|maximum context|context window)\b/.test(text4)) {
63185
+ if (/\b(rate[\s_-]?limit(?:_exceeded|_error)?|429|529|too many requests|quota|usage[\s_-]?limit|limit[\s_-]?(reached|will[\s_-]?reset|resets?)|out of tokens?|insufficient[\s_-]?(credits?|balance|quota|funds)|token[\s_-]?(limit|budget)|context[\s_-]?(length|window)|maximum context|overloaded)\b/.test(text4)) {
63102
63186
  return "resource-exhaustion";
63103
63187
  }
63104
63188
  return;
@@ -64174,7 +64258,7 @@ var init_feature_check = __esm(() => {
64174
64258
  const status = fm.status ?? "backlog";
64175
64259
  const entry = this.resolveMatrixEntry(isGroupFeature(fm), status);
64176
64260
  this.runL2(doc2, entry, findings);
64177
- this.runL3(doc2, findings);
64261
+ this.runL3(doc2, findings, fm);
64178
64262
  if (options?.featuresDir) {
64179
64263
  await this.checkOneActiveGoal(fm, featureId2, options.featuresDir, findings, options.asStatus);
64180
64264
  await this.checkChildrenLimit(featureId2, options.featuresDir, findings);
@@ -64186,55 +64270,59 @@ var init_feature_check = __esm(() => {
64186
64270
  await this.runL4(doc2, featureId2, status, taskScanDirs, dogfoodDir, runDir, findings);
64187
64271
  return { id: featureId2, ...this.summarizeWithStatus(status, findings, strict, options?.severityOverrides) };
64188
64272
  }
64189
- runL3(doc2, findings) {
64273
+ runL3(doc2, findings, fm) {
64190
64274
  const rawAc = doc2.getSection("Acceptance Criteria");
64191
64275
  if (rawAc !== null && rawAc.trim().length > 0) {
64192
- const acBody = stripAcFence(rawAc);
64193
- const checklist = parseChecklist(acBody);
64194
- const looksGherkin = /^\s*(Feature:|Scenario:|Scenario Outline:)/m.test(acBody);
64195
- if (checklist.length > 0 && !looksGherkin) {
64196
- const emptyItems = checklist.filter((c3) => c3.text.length === 0);
64197
- for (const item of emptyItems) {
64276
+ const tags = Array.isArray(fm.tags) ? fm.tags : [];
64277
+ const isWayfinderMap = tags.includes(WAYFINDER_MAP_TAG);
64278
+ if (!isWayfinderMap) {
64279
+ const acBody = stripAcFence(rawAc);
64280
+ const checklist = parseChecklist(acBody);
64281
+ const looksGherkin = /^\s*(Feature:|Scenario:|Scenario Outline:)/m.test(acBody);
64282
+ if (checklist.length > 0 && !looksGherkin) {
64283
+ const emptyItems = checklist.filter((c3) => c3.text.length === 0);
64284
+ for (const item of emptyItems) {
64285
+ findings.push({
64286
+ layer: "L3",
64287
+ code: FINDING_CODES.L3_AC_CHECKLIST_TEXT,
64288
+ severity: "warning",
64289
+ section: "Acceptance Criteria",
64290
+ line: item.line,
64291
+ message: "Checklist item has no text"
64292
+ });
64293
+ }
64294
+ return;
64295
+ }
64296
+ const bddResult = validateAcceptanceCriteria(acBody);
64297
+ for (const err of bddResult.errors) {
64298
+ findings.push({
64299
+ layer: "L3",
64300
+ code: FINDING_CODES.L3_AC_BDD_ERROR,
64301
+ severity: "error",
64302
+ section: "Acceptance Criteria",
64303
+ line: err.line,
64304
+ message: `BDD: ${err.message}`
64305
+ });
64306
+ }
64307
+ for (const warn of bddResult.warnings) {
64198
64308
  findings.push({
64199
64309
  layer: "L3",
64200
- code: FINDING_CODES.L3_AC_CHECKLIST_TEXT,
64310
+ code: FINDING_CODES.L3_AC_BDD_WARNING,
64201
64311
  severity: "warning",
64202
64312
  section: "Acceptance Criteria",
64203
- line: item.line,
64204
- message: "Checklist item has no text"
64313
+ line: warn.line,
64314
+ message: `BDD: ${warn.message}`
64315
+ });
64316
+ }
64317
+ if (!bddResult.valid) {
64318
+ findings.push({
64319
+ layer: "L3",
64320
+ code: FINDING_CODES.L3_AC_BDD_INVALID,
64321
+ severity: "error",
64322
+ section: "Acceptance Criteria",
64323
+ message: "Acceptance Criteria validation failed; fix BDD syntax errors"
64205
64324
  });
64206
64325
  }
64207
- return;
64208
- }
64209
- const bddResult = validateAcceptanceCriteria(acBody);
64210
- for (const err of bddResult.errors) {
64211
- findings.push({
64212
- layer: "L3",
64213
- code: FINDING_CODES.L3_AC_BDD_ERROR,
64214
- severity: "error",
64215
- section: "Acceptance Criteria",
64216
- line: err.line,
64217
- message: `BDD: ${err.message}`
64218
- });
64219
- }
64220
- for (const warn of bddResult.warnings) {
64221
- findings.push({
64222
- layer: "L3",
64223
- code: FINDING_CODES.L3_AC_BDD_WARNING,
64224
- severity: "warning",
64225
- section: "Acceptance Criteria",
64226
- line: warn.line,
64227
- message: `BDD: ${warn.message}`
64228
- });
64229
- }
64230
- if (!bddResult.valid) {
64231
- findings.push({
64232
- layer: "L3",
64233
- code: FINDING_CODES.L3_AC_BDD_INVALID,
64234
- severity: "error",
64235
- section: "Acceptance Criteria",
64236
- message: "Acceptance Criteria validation failed; fix BDD syntax errors"
64237
- });
64238
64326
  }
64239
64327
  }
64240
64328
  const scopeBody = doc2.getSection("Scope");
@@ -64659,6 +64747,10 @@ class FeatureService {
64659
64747
  throw new Error(`Field "${key}" is not settable via update \u2014 status changes go through the lifecycle ` + `transition (spur feature update ${id} <status>); id and timestamps are machine-owned.`);
64660
64748
  }
64661
64749
  const ref = await this.refFor(id);
64750
+ if (key === "tags") {
64751
+ const tags = value.split(",").map((t) => t.trim()).filter(Boolean);
64752
+ return this.ctx.writeService.updateFrontmatterArray(ref, key, tags);
64753
+ }
64662
64754
  return this.ctx.writeService.updateFrontmatter(ref, key, value);
64663
64755
  }
64664
64756
  async transition(id, toStatus, actor) {
@@ -68585,9 +68677,14 @@ var init_task_check = __esm(() => {
68585
68677
  if (taskAc.trim().length === 0)
68586
68678
  return;
68587
68679
  let featureAc;
68680
+ let featureTags = [];
68588
68681
  try {
68589
68682
  const raw = await this.fs.readFile(featurePath);
68590
- featureAc = stripAcFence(MarkdownDocument.parse(raw, "feature").getSection("Acceptance Criteria") ?? "");
68683
+ const featureDoc = MarkdownDocument.parse(raw, "feature");
68684
+ featureTags = Array.isArray(featureDoc.frontmatterData?.tags) ? featureDoc.frontmatterData.tags.filter((t) => typeof t === "string") : [];
68685
+ if (featureTags.includes(WAYFINDER_MAP_TAG))
68686
+ return;
68687
+ featureAc = stripAcFence(featureDoc.getSection("Acceptance Criteria") ?? "");
68591
68688
  } catch {
68592
68689
  return;
68593
68690
  }
@@ -71304,7 +71401,7 @@ class AgentRunActionRunner {
71304
71401
  };
71305
71402
  }
71306
71403
  if (!ok) {
71307
- await writePartialWorkArtifact(context4, agentLabel, model, traced, cwd);
71404
+ await writePartialWorkArtifact(context4, agentLabel, model, traced, cwd, sessionDir);
71308
71405
  }
71309
71406
  const partialWorkHint = `partial work (if any) preserved at .spur/run/${context4.runId}-${stepLabel}-partial.md; resume from that tree per the timed-out-implement runbook (plugins/sp/skills/spur-dev/references/execution-workflow.md)`;
71310
71407
  const error51 = ok ? undefined : traced.signal !== undefined ? timeoutMs !== undefined ? `agent.run '${stepLabel}' (${agentLabel}) terminated by signal ${traced.signal} (configured timeout: ${timeoutMs}ms; timeout or cancellation); ${partialWorkHint}` : `agent.run '${stepLabel}' (${agentLabel}) was cancelled by signal ${traced.signal}; ${partialWorkHint}` : traced.message !== undefined ? `agent.run '${stepLabel}' (${agentLabel}) dispatch failed: ${traced.message}` : `agent.run '${stepLabel}' (${agentLabel}) exited with code ${exitCode}; ${partialWorkHint}`;
@@ -71332,7 +71429,7 @@ class AgentRunActionRunner {
71332
71429
  }
71333
71430
  return {
71334
71431
  ok,
71335
- data: buildResultData(exitCode, agentLabel, capture, answer, invocation),
71432
+ data: buildResultData(exitCode, agentLabel, capture, answer, invocation, ok ? undefined : traced.stdout, ok ? undefined : traced.stderr ?? undefined, this.agentConfig.secretValues),
71336
71433
  error: error51,
71337
71434
  setVars: ok ? {
71338
71435
  __agentSession: resumeRetried ? "no-resume" : "open",
@@ -71373,12 +71470,19 @@ async function discoverSessionId(sessionDir) {
71373
71470
  return;
71374
71471
  }
71375
71472
  }
71376
- function buildResultData(exitCode, agentLabel, capture, answer, invocation) {
71473
+ function buildResultData(exitCode, agentLabel, capture, answer, invocation, stdout, stderr, secretValues = []) {
71377
71474
  const data = { exitCode, agent: agentLabel };
71378
- if (capture)
71379
- data.answer = answer;
71475
+ if (capture) {
71476
+ data.answer = exitCode === 0 ? answer : tail(redactAndBound(answer, secretValues, Number.MAX_SAFE_INTEGER), 4096);
71477
+ }
71380
71478
  if (invocation !== undefined)
71381
71479
  data.invocation = invocation;
71480
+ if (stdout !== undefined && stdout.length > 0) {
71481
+ data.stdoutTail = tail(redactAndBound(stdout, secretValues, Number.MAX_SAFE_INTEGER), 4096);
71482
+ }
71483
+ if (stderr !== undefined && stderr.length > 0) {
71484
+ data.stderrTail = tail(redactAndBound(stderr, secretValues, Number.MAX_SAFE_INTEGER), 4096);
71485
+ }
71382
71486
  return data;
71383
71487
  }
71384
71488
  function asOptionalString(value) {
@@ -71438,7 +71542,7 @@ function extractCompletedRequirementsHeuristic(taskMarkdown) {
71438
71542
  }
71439
71543
  return found.size > 0 ? [...found] : ["unknown \u2014 Solution empty and Plan checkboxes open"];
71440
71544
  }
71441
- async function writePartialWorkArtifact(context4, agentLabel, model, traced, cwd) {
71545
+ async function writePartialWorkArtifact(context4, agentLabel, model, traced, cwd, sessionDir) {
71442
71546
  try {
71443
71547
  const signal = traced.signal;
71444
71548
  const exitReason = signal !== undefined ? `killed by signal ${signal} (likely timeout or cancellation)` : traced.message !== undefined ? `dispatch error: ${traced.message}` : `exited with code ${traced.exitCode}`;
@@ -71448,6 +71552,16 @@ async function writePartialWorkArtifact(context4, agentLabel, model, traced, cwd
71448
71552
  const headerLine = model !== undefined ? `${agentLabel} (model: ${model})` : agentLabel;
71449
71553
  const inv = traced.invocation;
71450
71554
  const argvLine = inv ? `${inv.command} ${inv.argv.join(" ")}` : "(invocation not captured)";
71555
+ const latchedSessionPath = join17(cwd, ".spur", "run", `${context4.runId}-agent-session.json`);
71556
+ const resumeContext = [
71557
+ "",
71558
+ "## resume context",
71559
+ "",
71560
+ `- session dir: ${sessionDir ?? "(none captured)"}`,
71561
+ `- latched session file: ${latchedSessionPath}`,
71562
+ ""
71563
+ ].join(`
71564
+ `);
71451
71565
  let completedSection = "";
71452
71566
  const wbs = String(context4.vars.wbs ?? "");
71453
71567
  if (wbs) {
@@ -71508,6 +71622,7 @@ async function writePartialWorkArtifact(context4, agentLabel, model, traced, cwd
71508
71622
  "```",
71509
71623
  stderrTail || "(empty)",
71510
71624
  "```",
71625
+ resumeContext,
71511
71626
  completedSection
71512
71627
  ].join(`
71513
71628
  `);
@@ -71551,12 +71666,16 @@ async function gitHasNonCorpusChanges(cwd, excludeGlobs = ["docs/tasks3/*", "doc
71551
71666
  function tail(text4, maxChars) {
71552
71667
  if (text4.length <= maxChars)
71553
71668
  return text4;
71554
- return `... (truncated) ...
71555
- ${text4.slice(text4.length - maxChars)}`;
71669
+ const marker = `... (truncated) ...
71670
+ `;
71671
+ if (maxChars <= marker.length)
71672
+ return marker.slice(0, maxChars);
71673
+ return `${marker}${text4.slice(text4.length - (maxChars - marker.length))}`;
71556
71674
  }
71557
71675
  var PARTIAL_ARTIFACT_TAIL_CHARS = 4000, AGENT_RUN_PROGRESS_INTERVAL_MS = 30000, KIND = "agent.run";
71558
71676
  var init_agent_run = __esm(() => {
71559
71677
  init_dist5();
71678
+ init_agent_execution();
71560
71679
  init_task_locator();
71561
71680
  init_steering();
71562
71681
  });
@@ -72406,8 +72525,9 @@ class WorkflowAppService {
72406
72525
  });
72407
72526
  const runId = opts.runId ?? crypto.randomUUID();
72408
72527
  const isDry = opts.dryRun === true;
72528
+ const warnings = [];
72409
72529
  const runVars = {
72410
- ...await resolveDefaultAgentVar(this.ctx.cwd, opts.vars),
72530
+ ...await resolveDefaultAgentVar(this.ctx.cwd, opts.vars, (m) => warnings.push(m)),
72411
72531
  ...opts.vars ?? {},
72412
72532
  __runId: runId
72413
72533
  };
@@ -72430,7 +72550,16 @@ class WorkflowAppService {
72430
72550
  }
72431
72551
  await this.stampFailureReason(runId, result);
72432
72552
  await this.maybeLinkPipelineRun(file2, runId, opts);
72433
- return result;
72553
+ const runResult = result;
72554
+ if (warnings.length > 0) {
72555
+ for (const warning of warnings) {
72556
+ try {
72557
+ this.ctx.warn?.(warning);
72558
+ } catch {}
72559
+ }
72560
+ runResult.warnings = warnings;
72561
+ }
72562
+ return runResult;
72434
72563
  }
72435
72564
  async maybeLinkPipelineRun(file2, runId, opts) {
72436
72565
  const wbs = opts.vars?.wbs;
@@ -72689,10 +72818,13 @@ class WorkflowAppService {
72689
72818
  const processExec = this.ctx.processExecutor?.();
72690
72819
  const host = createDefaultWorkflowEngineHost(processExec !== undefined ? { processExecutor: processExec } : {});
72691
72820
  const bus = this.ctx.observabilityBus?.();
72692
- let agentSlice = {};
72821
+ let agentSlice = {
72822
+ ...this.ctx.secretValues !== undefined ? { secretValues: this.ctx.secretValues } : {}
72823
+ };
72693
72824
  try {
72694
72825
  const agent = (await loadSpurConfig(this.ctx.cwd)).agent;
72695
72826
  agentSlice = {
72827
+ ...agentSlice,
72696
72828
  ...agent?.default !== undefined ? { default: agent.default } : {},
72697
72829
  ...agent?.sessionAffinity !== undefined ? { sessionAffinity: agent.sessionAffinity } : {}
72698
72830
  };
@@ -72779,15 +72911,28 @@ async function fileExists(path9) {
72779
72911
  const fs3 = createNodeFileSystem3();
72780
72912
  return await fs3.exists(path9);
72781
72913
  }
72782
- async function resolveDefaultAgentVar(cwd, callerVars) {
72783
- if (callerVars?.agent !== undefined)
72784
- return {};
72914
+ async function resolveDefaultAgentVar(cwd, callerVars, warn) {
72915
+ let config4;
72785
72916
  try {
72786
- const configured = (await loadSpurConfig(cwd)).agent?.default;
72787
- return typeof configured === "string" && configured.length > 0 ? { agent: configured } : {};
72917
+ config4 = await loadSpurConfig(cwd);
72788
72918
  } catch {
72789
72919
  return {};
72790
72920
  }
72921
+ const configured = config4.agent?.default;
72922
+ if (typeof configured !== "string" || configured.length === 0) {
72923
+ return {};
72924
+ }
72925
+ const valid = config4.agent?.executors?.some((e) => e.name === configured) === true || resolveAgentName(configured) !== undefined;
72926
+ if (!valid) {
72927
+ warn(`agent.default "${configured}" does not name a configured executor or agent binary; leaving the pipeline's literal agent in force`);
72928
+ return {};
72929
+ }
72930
+ const result = {};
72931
+ if (callerVars?.agent === undefined)
72932
+ result.agent = configured;
72933
+ if (callerVars?.implementAgent === undefined)
72934
+ result.implementAgent = configured;
72935
+ return result;
72791
72936
  }
72792
72937
  async function resolveWorkflowLogRetentionDays(cwd) {
72793
72938
  try {
@@ -72894,6 +73039,7 @@ var TASK_PIPELINE_WORKFLOW = "task-pipeline", EMBEDDED_SCHEMA_PREFIX = "\x00embe
72894
73039
  var init_workflow_service = __esm(() => {
72895
73040
  init_loader();
72896
73041
  init_src2();
73042
+ init_dist9();
72897
73043
  init_dist7();
72898
73044
  init_dist5();
72899
73045
  init_builtins2();
@@ -81317,7 +81463,7 @@ init_dist6();
81317
81463
  var CLI_CONFIG = {
81318
81464
  binaryName: "spur",
81319
81465
  binaryLabel: "spur",
81320
- binaryVersion: "0.3.36",
81466
+ binaryVersion: "0.3.37",
81321
81467
  configDir: ".spur",
81322
81468
  configFile: ".spur/config.yaml",
81323
81469
  databaseFile: ".spur/spur.db"
@@ -91491,6 +91637,8 @@ function createServerContext(appRt, options) {
91491
91637
  workflowService() {
91492
91638
  workflowSvc ??= new WorkflowAppService({
91493
91639
  cwd,
91640
+ secretValues: configuredSecretValues(options.env ?? process.env),
91641
+ warn: (message) => appRt.logger.warn(message),
91494
91642
  getDb: this.getDb.bind(this),
91495
91643
  agentService: this.agentService.bind(this),
91496
91644
  ruleService: this.ruleService.bind(this),
@@ -92722,7 +92870,16 @@ ${result.content}`);
92722
92870
  context4.setExitCode(1);
92723
92871
  }
92724
92872
  });
92725
- task.command("update").summary("Update a task status or replace a section.").argument("<wbs>", "Task WBS number").argument("[status]", "New status (for lifecycle transition)").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) => {
92873
+ task.command("update").summary("Update a task status or replace a section.").argument("<wbs>", "Task WBS number").argument("[status]", "New status (for lifecycle transition)").addHelpText("after", [
92874
+ "Lifecycle: `task update <wbs> <status>` moves a task through",
92875
+ "backlog \u2192 todo \u2192 wip \u2192 testing \u2192 done, running the lifecycle guards on",
92876
+ "`wip \u2192 testing` (`spur task check`) and `testing \u2192 done` (`--strict-core`).",
92877
+ "A GuardDeniedError on `testing \u2192 done` means no pipeline run is recorded for the",
92878
+ "task: run `/sp:dev-verify <wbs> --next` to PASS it, or record the audited bypass with",
92879
+ '`SPUR_PROVENANCE_OVERRIDE=1 spur task update <wbs> done --force-done --reason "\u2026"`.',
92880
+ "See the gate checklist (spur-dev/references/gate-checklists.md)."
92881
+ ].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) => {
92726
92883
  const svc = await makeService2(context4, options.folder, options.lifecycle === false);
92727
92884
  try {
92728
92885
  if (options.section !== undefined) {
@@ -93721,6 +93878,24 @@ async function spawnAsyncWorkflowWorker(spurBin, cmd) {
93721
93878
  rejectOnError: false
93722
93879
  });
93723
93880
  }
93881
+ function asyncRegisterTimeoutMs() {
93882
+ const raw2 = Number(process.env.SPUR_ASYNC_REGISTER_TIMEOUT_MS);
93883
+ return Number.isFinite(raw2) && raw2 > 0 ? raw2 : 5000;
93884
+ }
93885
+ async function waitForRunRegistration(service, runId, timeoutMs = asyncRegisterTimeoutMs(), pollMs = 250) {
93886
+ const deadline = Date.now() + timeoutMs;
93887
+ for (;; ) {
93888
+ try {
93889
+ await service.trace(runId);
93890
+ return true;
93891
+ } catch {
93892
+ if (Date.now() >= deadline) {
93893
+ return false;
93894
+ }
93895
+ await sleep3(pollMs);
93896
+ }
93897
+ }
93898
+ }
93724
93899
  function parseVars(raw2) {
93725
93900
  if (raw2 === undefined) {
93726
93901
  return;
@@ -93759,6 +93934,8 @@ async function resolveWorkflowPaths(cwd) {
93759
93934
  function registerWorkflowCommand(program2, context4) {
93760
93935
  const makeSvc = (json3, bus) => new WorkflowAppService({
93761
93936
  cwd: context4.cwd,
93937
+ secretValues: configuredSecretValues(context4.env),
93938
+ warn: (message) => context4.output.error(`Warning: ${message}`),
93762
93939
  getDb: () => context4.getDb(),
93763
93940
  agentService: () => context4.agentService(),
93764
93941
  ruleService: () => context4.ruleService(),
@@ -93843,6 +94020,17 @@ ${result.errors.map((m) => ` - ${m}`).join(`
93843
94020
  context4.setExitCode(result2.status === "done" ? 0 : 1);
93844
94021
  return;
93845
94022
  }
94023
+ if (!await waitForRunRegistration(makeSvc(options.json), runId2)) {
94024
+ const reason = "async worker failed to start or register the run";
94025
+ const hint = "run the workflow synchronously (omit --async) to see the failure";
94026
+ if (json3) {
94027
+ context4.output.write(toJson2({ status: "failed", reason, hint }));
94028
+ } else if (!silent) {
94029
+ context4.output.write(`async spawn failed: ${reason} \u2014 ${hint}.`);
94030
+ }
94031
+ context4.setExitCode(1);
94032
+ return;
94033
+ }
93846
94034
  const asyncResult = { runId: runId2, status: "started", workflowName: file2 };
93847
94035
  if (json3)
93848
94036
  context4.output.write(toJson2(asyncResult));