@gr8ful/spf 0.5.0 → 0.6.0

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 (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +51 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/paths.d.ts +3 -0
  46. package/dist/core/paths.js +48 -1
  47. package/dist/core/runner.d.ts +8 -0
  48. package/dist/core/runner.js +7 -0
  49. package/dist/core/session.d.ts +25 -0
  50. package/dist/core/session.js +97 -28
  51. package/dist/core/tiering.d.ts +145 -0
  52. package/dist/core/tiering.js +235 -0
  53. package/dist/core/tracer.d.ts +10 -0
  54. package/dist/core/tracer.js +12 -0
  55. package/dist/core/watch.d.ts +89 -9
  56. package/dist/core/watch.js +193 -19
  57. package/dist/ui/server/db.d.ts +39 -0
  58. package/dist/ui/server/db.js +61 -0
  59. package/package.json +2 -1
  60. package/dist/test/agent_cc.test.d.ts +0 -1
  61. package/dist/test/agent_cc.test.js +0 -95
  62. package/dist/test/agent_flue.test.d.ts +0 -1
  63. package/dist/test/agent_flue.test.js +0 -83
  64. package/dist/test/chains.test.d.ts +0 -12
  65. package/dist/test/chains.test.js +0 -92
  66. package/dist/test/data_types.test.d.ts +0 -10
  67. package/dist/test/data_types.test.js +0 -220
  68. package/dist/test/env_file.test.d.ts +0 -1
  69. package/dist/test/env_file.test.js +0 -74
  70. package/dist/test/fake_asker.d.ts +0 -23
  71. package/dist/test/fake_asker.js +0 -30
  72. package/dist/test/git_helper.test.d.ts +0 -1
  73. package/dist/test/git_helper.test.js +0 -59
  74. package/dist/test/hermetic_git.d.ts +0 -1
  75. package/dist/test/hermetic_git.js +0 -22
  76. package/dist/test/init_command.test.d.ts +0 -14
  77. package/dist/test/init_command.test.js +0 -136
  78. package/dist/test/interview.test.d.ts +0 -15
  79. package/dist/test/interview.test.js +0 -425
  80. package/dist/test/notify.test.d.ts +0 -1
  81. package/dist/test/notify.test.js +0 -174
  82. package/dist/test/ollama_provider.test.d.ts +0 -1
  83. package/dist/test/ollama_provider.test.js +0 -103
  84. package/dist/test/otel.test.d.ts +0 -26
  85. package/dist/test/otel.test.js +0 -512
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
package/dist/cli/index.js CHANGED
@@ -13,6 +13,7 @@ import * as paths from "../core/paths.js";
13
13
  import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
14
14
  import { loadRepoChains } from "../chains/repo_chains.js";
15
15
  import { dispatchChain, usageFor } from "./commands/run.js";
16
+ import { estimateCommand } from "./commands/estimate.js";
16
17
  import { listCommand } from "./commands/list.js";
17
18
  import { initCommand } from "./commands/init.js";
18
19
  import { installSkillCommand } from "./commands/install-skill.js";
@@ -25,11 +26,15 @@ import { eventsCommand } from "./commands/events.js";
25
26
  import { abortCommand } from "./commands/abort.js";
26
27
  import { uiCommand } from "./commands/ui.js";
27
28
  import { watchCommand, watchInitCommand } from "./commands/watch.js";
29
+ import { fanoutCommand } from "./commands/fanout.js";
28
30
  import { versionCommand } from "./commands/version.js";
29
31
  const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
30
32
 
31
33
  spf list the chain registry — names, phases, what each needs
32
34
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
35
+ spf fanout <chain> "<prompt>" [--n 3] best-of-N: N isolated attempts, one deterministic winner branch — YOU merge it, spf never does
36
+ spf fanout --clean <base-adw-id> remove leftover worktrees/branches from a killed or discarded fanout run
37
+ spf estimate <chain> "<prompt>" [--n N] read-only: planned model routing + a token/cost projection from real trace history — starts nothing, exits 3 if there's no history yet
33
38
  spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
34
39
  spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
35
40
  spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
@@ -45,6 +50,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
45
50
  spf version print the installed version
46
51
 
47
52
  Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
53
+ Run budget: set defaults.max_run_cost (USD) and/or defaults.max_run_tokens in spf.config.yaml to stop the NEXT agent call once a run has already spent this much — checked before each call, never after, so a single call is never capped and a one-agent-dispatch chain (scout/prompt/build) can never trip it; absent (the default) = unbounded.
48
54
  Run \`spf list\` to see every chain and what it needs.`;
49
55
  /** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
50
56
  function findCwdFlag(argv) {
@@ -138,6 +144,12 @@ export async function main() {
138
144
  process.exitCode = await dispatchChain(chain, chainArgs);
139
145
  return;
140
146
  }
147
+ case "fanout":
148
+ process.exitCode = await fanoutCommand(rest);
149
+ return;
150
+ case "estimate":
151
+ process.exitCode = await estimateCommand(rest);
152
+ return;
141
153
  case "list":
142
154
  process.exitCode = listCommand();
143
155
  return;
@@ -7,6 +7,7 @@
7
7
  * with a correction — context intact, bounded retries. Agent proposes, code
8
8
  * disposes.
9
9
  */
10
+ import { type TierResolution } from "./tiering.ts";
10
11
  import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type SFConfig } from "./data_types.ts";
11
12
  /**
12
13
  * `undefined` (no `env_allowlist` configured — the default) means "don't
@@ -18,6 +19,51 @@ import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type Env
18
19
  export declare function agentEnv(agent: AgentConfig): Record<string, string> | undefined;
19
20
  export declare class GateFailure extends Error {
20
21
  }
22
+ /**
23
+ * A run that has reached (or passed) one of `defaults`' budget ceilings.
24
+ *
25
+ * Its own class, like `GateFailure`, so a caller can tell "this run was cut
26
+ * off on purpose" from "the agent broke" — thrown from `send()` below, which
27
+ * puts it inside `run.phase()`'s try/catch, so the phase records `fail` and
28
+ * the run exits non-zero. That is the intended outcome: fail CLOSED. A budget
29
+ * that logged a warning and kept spending would not be a budget.
30
+ */
31
+ export declare class BudgetExceeded extends Error {
32
+ }
33
+ /**
34
+ * `$0.412` — three decimals, with a trailing zero trimmed so a round ceiling
35
+ * reads as the operator wrote it (`$0.40`, not `$0.400`). Cents are not
36
+ * enough precision here: a single cheap call is often sub-cent, and a budget
37
+ * message that says `$0.00 of max_run_cost $0.40` tells nobody anything.
38
+ */
39
+ export declare function formatUsd(value: number): string;
40
+ /**
41
+ * The accumulating totals a budget check reads. Structurally a subset of
42
+ * `Run` (`core/runner.ts`) — `run.tokens`/`run.cost` are incremented by
43
+ * `run.addUsage()` after every send — so the real `Run` satisfies it with no
44
+ * adapter, and a test can pass a plain object with fake usage.
45
+ */
46
+ export interface RunBudgetState {
47
+ cfg: SFConfig;
48
+ tokens: number;
49
+ cost: number;
50
+ }
51
+ /**
52
+ * Throw if this run has spent its budget. Called BEFORE every agent
53
+ * dispatch, never after.
54
+ *
55
+ * WHY BEFORE, AND WHY `>=`: the cost of the call about to happen is
56
+ * unknowable until it finishes, so a ceiling can only ever be enforced as
57
+ * "no further calls" — checked after the fact it would be a report, not a
58
+ * cap. `>=` follows from that: a run that has already spent exactly its
59
+ * ceiling has nothing left to spend, and letting one more (unbounded) call
60
+ * through would make the ceiling soft by exactly one call — which, on a
61
+ * chain whose last phase is the expensive one, is the whole overrun.
62
+ *
63
+ * Both ceilings absent (the default) returns immediately: zero behavior
64
+ * change, no arithmetic, nothing to get wrong.
65
+ */
66
+ export declare function assertRunBudget(run: RunBudgetState): void;
21
67
  /**
22
68
  * Load and merge every existing path in `configPaths`, in order — later
23
69
  * paths override earlier ones. Built-in defaults first, an optional `.spf/`
@@ -35,6 +81,9 @@ export declare function validate(cfg: SFConfig, required: string[], requiredSuit
35
81
  interface RunForAgents {
36
82
  cfg: SFConfig;
37
83
  adw_id: string;
84
+ /** Run-total tokens/cost so far, mirrored by `addUsage` below — read by `assertRunBudget` before every send. */
85
+ tokens: number;
86
+ cost: number;
38
87
  repo_root: string;
39
88
  spf_dir: string | null;
40
89
  data_dir: string;
@@ -45,6 +94,13 @@ interface RunForAgents {
45
94
  model: string;
46
95
  coding_agent: string;
47
96
  }>;
97
+ /**
98
+ * Set once by `startRun` (`src/chains/steps.ts`), null until then. Optional
99
+ * here — a structural interface must not force every present and future
100
+ * test fake to carry it — and `effectiveAgent`'s own `?? base.model`
101
+ * fallback already handles absence. See `core/tiering.ts`.
102
+ */
103
+ tiering?: TierResolution | null;
48
104
  tracer: {
49
105
  event: (record: ReturnType<typeof makeEventRecord>) => string;
50
106
  processStart: (adwId: string, kind: string, name: string, pid: number, command: string) => void;
@@ -16,6 +16,7 @@ import * as agentFlue from "./agent_flue.js";
16
16
  import * as paths from "./paths.js";
17
17
  import * as permissions from "./permissions.js";
18
18
  import * as prompts from "./prompts.js";
19
+ import { effectiveAgent } from "./tiering.js";
19
20
  import { GateReport, UsageBreakdown, makeEventRecord, SFConfigSchema, } from "./data_types.js";
20
21
  import { newId, operatorEnv } from "./utils.js";
21
22
  const JSON_FIX_ATTEMPTS = 2; // continue-with-correction attempts for malformed JSON
@@ -45,6 +46,55 @@ export function agentEnv(agent) {
45
46
  }
46
47
  export class GateFailure extends Error {
47
48
  }
49
+ /**
50
+ * A run that has reached (or passed) one of `defaults`' budget ceilings.
51
+ *
52
+ * Its own class, like `GateFailure`, so a caller can tell "this run was cut
53
+ * off on purpose" from "the agent broke" — thrown from `send()` below, which
54
+ * puts it inside `run.phase()`'s try/catch, so the phase records `fail` and
55
+ * the run exits non-zero. That is the intended outcome: fail CLOSED. A budget
56
+ * that logged a warning and kept spending would not be a budget.
57
+ */
58
+ export class BudgetExceeded extends Error {
59
+ }
60
+ /**
61
+ * `$0.412` — three decimals, with a trailing zero trimmed so a round ceiling
62
+ * reads as the operator wrote it (`$0.40`, not `$0.400`). Cents are not
63
+ * enough precision here: a single cheap call is often sub-cent, and a budget
64
+ * message that says `$0.00 of max_run_cost $0.40` tells nobody anything.
65
+ */
66
+ export function formatUsd(value) {
67
+ const fixed = value.toFixed(3);
68
+ return `$${fixed.endsWith("0") ? fixed.slice(0, -1) : fixed}`;
69
+ }
70
+ /**
71
+ * Throw if this run has spent its budget. Called BEFORE every agent
72
+ * dispatch, never after.
73
+ *
74
+ * WHY BEFORE, AND WHY `>=`: the cost of the call about to happen is
75
+ * unknowable until it finishes, so a ceiling can only ever be enforced as
76
+ * "no further calls" — checked after the fact it would be a report, not a
77
+ * cap. `>=` follows from that: a run that has already spent exactly its
78
+ * ceiling has nothing left to spend, and letting one more (unbounded) call
79
+ * through would make the ceiling soft by exactly one call — which, on a
80
+ * chain whose last phase is the expensive one, is the whole overrun.
81
+ *
82
+ * Both ceilings absent (the default) returns immediately: zero behavior
83
+ * change, no arithmetic, nothing to get wrong.
84
+ */
85
+ export function assertRunBudget(run) {
86
+ const { max_run_cost: maxCost, max_run_tokens: maxTokens } = run.cfg.defaults;
87
+ if (maxCost === undefined && maxTokens === undefined)
88
+ return;
89
+ if (maxCost !== undefined && run.cost >= maxCost) {
90
+ throw new BudgetExceeded(`run budget exceeded: ${formatUsd(run.cost)} of max_run_cost ${formatUsd(maxCost)} — ` +
91
+ `raise defaults.max_run_cost or split the work`);
92
+ }
93
+ if (maxTokens !== undefined && run.tokens >= maxTokens) {
94
+ throw new BudgetExceeded(`run budget exceeded: ${run.tokens.toLocaleString("en-US")} tokens of max_run_tokens ` +
95
+ `${maxTokens.toLocaleString("en-US")} — raise defaults.max_run_tokens or split the work`);
96
+ }
97
+ }
48
98
  /**
49
99
  * A ValiError's own `.message` is only its FIRST issue — fine for a quick
50
100
  * console line, not for something a human has to act on or a model has to
@@ -99,6 +149,15 @@ function mergeRawConfig(base, override) {
99
149
  defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
100
150
  observability: { ...(base.observability || {}), ...(override.observability || {}) },
101
151
  quality: { ...(base.quality || {}), ...(override.quality || {}) },
152
+ // `watch` is spread WHOLE — no per-sub-key enumeration inside it — so
153
+ // any WatchConfigSchema field, present or future (jira, refine,
154
+ // chain_options, ...), already passes through this line untouched.
155
+ // The silent-drop trap this function's own doc comment warns about is
156
+ // about the TOP-LEVEL keys named here, not watch's own fields; the
157
+ // actual boundary for one of those is WatchConfigSchema itself (an
158
+ // unknown key there is stripped at `v.parse`, same failure mode, just a
159
+ // different gate) — see `chain_options`'s merge-survival test in
160
+ // `data_types.test.ts` for the check that actually matters here.
102
161
  watch: { ...(base.watch || {}), ...(override.watch || {}) },
103
162
  // channels is a whole-array replace on override, same as quality.checks —
104
163
  // you don't want an override's channels appended to the built-in's.
@@ -106,6 +165,15 @@ function mergeRawConfig(base, override) {
106
165
  // review.require_human_signoff / review.signoff_timeout_seconds — see
107
166
  // data_types.ts's ReviewConfigSchema doc comment for why this key exists.
108
167
  review: { ...(base.review || {}), ...(override.review || {}) },
168
+ // tiering.enabled / .tiers / .roles — key-by-key at this level, so a repo
169
+ // that only flips `enabled` keeps the base's tiers/roles. `tiers` is a
170
+ // whole-LIST replace on override (like quality.checks and
171
+ // notifications.channels): a repo that declares its own ladder replaces
172
+ // the packaged one wholesale rather than getting an unordered splice of
173
+ // both. `roles` is a whole-OBJECT replace for the same reason — a
174
+ // half-merged role map would route some agents by the base's ladder and
175
+ // some by the override's. Pinned by src/test/data_types.test.ts.
176
+ tiering: { ...(base.tiering || {}), ...(override.tiering || {}) },
109
177
  agents: mergeAgentLists(base.agents || [], override.agents || []),
110
178
  };
111
179
  }
@@ -128,6 +196,15 @@ export function loadConfig(configPaths) {
128
196
  raw = mergeRawConfig(raw, parsed);
129
197
  }
130
198
  const defaults = raw.defaults || {};
199
+ // THE BACK-FILL LIST IS PER-AGENT SETTINGS ONLY. Every key here is copied
200
+ // DOWN onto each agent that didn't set it, so only fields that mean
201
+ // something about ONE agent belong. Run-scoped `defaults` keys must stay
202
+ // out: `data_dir`, `protected_files`, and `max_run_cost`/`max_run_tokens`
203
+ // are properties of the RUN, and a per-agent copy of a run budget would
204
+ // read as "each agent may spend this much" — a different, unenforced
205
+ // feature. (AgentConfigSchema is a non-strict v.object, so a stray copy
206
+ // would be silently STRIPPED at parse rather than rejected: the mistake
207
+ // would look like it worked. See ConfigDefaultsSchema's own note.)
131
208
  for (const agent of raw.agents || []) {
132
209
  for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes", "env_allowlist"]) {
133
210
  if (key in defaults && !(key in agent))
@@ -216,13 +293,81 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
216
293
  }
217
294
  }
218
295
  }
296
+ // Tiering (SPF #14) — every check below lives inside this ONE guard. A
297
+ // disabled ladder is not a config error, it is a config that is off: none
298
+ // of this fires for `enabled: false`, no matter what `tiers`/`roles` say —
299
+ // the same reason the packaged ladder + roles map surviving key-by-key
300
+ // merge into a repo that never opted in must not fail every chain.
301
+ if (cfg.tiering.enabled) {
302
+ // cfg-global: every declared rung must be well-formed for its OWN
303
+ // declared backend — the same branch this function already runs for
304
+ // agent.model, just keyed off the tier's coding_agent instead.
305
+ for (const tier of cfg.tiering.tiers) {
306
+ if (tier.coding_agent === "flue") {
307
+ try {
308
+ agentFlue.resolveModel(tier.model);
309
+ }
310
+ catch (error) {
311
+ problems.push(`tiering.tiers[${JSON.stringify(tier.name)}]: ${error.message}`);
312
+ }
313
+ }
314
+ else if (!tier.model.trim()) {
315
+ problems.push(`tiering.tiers[${JSON.stringify(tier.name)}]: model is empty`);
316
+ }
317
+ }
318
+ // cfg-global: `enabled: true` with nothing to route is a silent no-op —
319
+ // the same reason an unconfigured quality.suites entry above is an
320
+ // error rather than a quiet pass-through.
321
+ if (cfg.tiering.tiers.length === 0) {
322
+ problems.push("tiering.enabled is true but tiering.tiers is empty — declare at least one rung or set tiering.enabled: false");
323
+ }
324
+ if (Object.keys(cfg.tiering.roles).length === 0) {
325
+ problems.push("tiering.enabled is true but tiering.roles is empty — name at least one role or set tiering.enabled: false");
326
+ }
327
+ // Scoped to `required` — same precedent as the per-agent loop above
328
+ // (`:256`/`:277-288`): a `roles` key naming a role no phase in this run
329
+ // will dispatch is neither routed nor validated. This is what lets the
330
+ // packaged six-name `roles` map survive key-by-key merge into a repo
331
+ // with a pruned/renamed roster without failing every chain.
332
+ for (const name of required) {
333
+ const tierName = cfg.tiering.roles[name];
334
+ if (tierName === undefined)
335
+ continue; // precedence: not named in roles -> untouched, nothing to check
336
+ const tier = cfg.tiering.tiers.find((t) => t.name === tierName);
337
+ if (!tier) {
338
+ problems.push(`tiering.roles.${name} names tier ${JSON.stringify(tierName)}, which is not declared in tiering.tiers`);
339
+ continue;
340
+ }
341
+ const agent = cfg.agents.find((a) => a.name === name);
342
+ if (!agent)
343
+ continue; // already reported above by the per-agent loop's own resolve() failure
344
+ // Rule T — the backend-compatibility rule (§4.4A). A tier changes an
345
+ // agent's `model` and NOTHING else, so a role is routable by a tier
346
+ // only when their backends agree; a mismatch is a named error, never
347
+ // a silent skip, because a config that says "route this role by tier"
348
+ // and then quietly does not is the failure mode this codebase already
349
+ // refuses for suites.
350
+ if (agent.coding_agent !== tier.coding_agent) {
351
+ problems.push(`agent ${JSON.stringify(name)} (coding_agent: ${agent.coding_agent}) is routed by tiering.roles to tier ${JSON.stringify(tier.name)} ` +
352
+ `(coding_agent: ${tier.coding_agent}) — a tier's model only speaks its own backend's vocabulary; declare a ` +
353
+ `${agent.coding_agent} rung for ${JSON.stringify(name)} or remove it from tiering.roles`);
354
+ }
355
+ }
356
+ }
219
357
  if (problems.length > 0) {
220
358
  throw new Error("config validation failed:\n- " + problems.join("\n- "));
221
359
  }
222
360
  }
223
361
  /** One agent call: render prompts -> pi run -> typed parse -> gates -> envelope. */
224
362
  export async function execute(run, phase, call) {
225
- const agent = resolve(run.cfg, phase.params.owner);
363
+ // The single dispatch-site change tiering makes: one effective AgentConfig,
364
+ // `model` and ONLY `model` possibly overridden (rule T — `coding_agent` is
365
+ // never touched here). Every downstream reader below (`agent_start`'s
366
+ // payload, `run.console.agentStarted`, the actual `AgentRequest`,
367
+ // `agentSessionId`'s reuse comparison, the traced `agent_sessions.model`
368
+ // column, `run.saveAgentMap`) becomes consistent for free. See
369
+ // `core/tiering.ts`'s `effectiveAgent`.
370
+ const agent = effectiveAgent(run, resolve(run.cfg, phase.params.owner));
226
371
  const agentDir = path.join(run.session_dir, agent.name);
227
372
  mkdirSync(agentDir, { recursive: true });
228
373
  const variables = {
@@ -258,6 +403,12 @@ export async function execute(run, phase, call) {
258
403
  let latest = null;
259
404
  const spent = new UsageBreakdown();
260
405
  async function send(promptText) {
406
+ // The ONE dispatch point for this phase — the first prompt, every
407
+ // JSON-repair retry, and every gate correction all funnel through here,
408
+ // so checking the run's ceilings here is what makes them a cap on the
409
+ // WHOLE run rather than on the first call of each phase. See
410
+ // `assertRunBudget` for why it is checked before, not after.
411
+ assertRunBudget(run);
261
412
  const request = {
262
413
  prompt: promptText,
263
414
  system_prompt: systemText,
@@ -173,6 +173,28 @@ export declare const RefinedIssueSchema: v.ObjectSchema<{
173
173
  readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
174
174
  }, undefined>;
175
175
  export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
176
+ /**
177
+ * One open question the refiner could not answer itself — material ambiguity
178
+ * (scope, data model, an external dependency choice, a UX contract, anything
179
+ * that would contradict an ADR) it is refusing to guess on, per
180
+ * `assets/prompts/refiner/system.md`'s "ask, don't decide" rule. `id` is
181
+ * stable within one round so a resumed run's answers can be matched back to
182
+ * the question they answer; `why_it_matters`/`options`/`recommendation`/
183
+ * `evidence` exist so a human can answer in one word ("go with your rec")
184
+ * instead of re-deriving the tradeoff the refiner already worked out.
185
+ * `gates.refinementWellFormed` requires `issues` to be empty whenever this is
186
+ * non-empty — escalating means publishing nothing THIS round, never a
187
+ * partial tree pinned to an unanswered question.
188
+ */
189
+ export declare const RefineQuestionSchema: v.ObjectSchema<{
190
+ readonly id: v.StringSchema<undefined>;
191
+ readonly question: v.StringSchema<undefined>;
192
+ readonly why_it_matters: v.OptionalSchema<v.StringSchema<undefined>, "">;
193
+ readonly options: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
194
+ readonly recommendation: v.OptionalSchema<v.StringSchema<undefined>, "">;
195
+ readonly evidence: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
196
+ }, undefined>;
197
+ export type RefineQuestion = v.InferOutput<typeof RefineQuestionSchema>;
176
198
  /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
177
199
  export declare const RefineOutput: EnvelopeType<{
178
200
  status: "fail" | "success";
@@ -187,6 +209,14 @@ export declare const RefineOutput: EnvelopeType<{
187
209
  parent: string;
188
210
  blocked_by: string[];
189
211
  }[];
212
+ questions: {
213
+ id: string;
214
+ question: string;
215
+ why_it_matters: string;
216
+ options: string[];
217
+ recommendation: string;
218
+ evidence: string[];
219
+ }[];
190
220
  }>;
191
221
  export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
192
222
  export declare const QualityAreaSchema: v.PicklistSchema<["frontend", "backend"], undefined>;
@@ -388,6 +418,50 @@ export declare const ConfigDefaultsSchema: v.ObjectSchema<{
388
418
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
389
419
  readonly protected_files: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => string[]>;
390
420
  readonly data_dir: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data">;
421
+ /**
422
+ * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
423
+ *
424
+ * BOTH ABSENT BY DEFAULT, and absence is a total no-op: `agents.ts`'s
425
+ * `assertRunBudget()` returns immediately when neither is set, so every
426
+ * existing config behaves byte-identically to before this field existed.
427
+ * There is no ambient environment variable and no implicit default — an
428
+ * unbounded run stays the default because a surprise mid-run failure on a
429
+ * ceiling nobody chose is worse than the spend.
430
+ *
431
+ * SCOPE IS THE RUN, NOT THE CALL. Enforced against the Run's ACCUMULATED
432
+ * usage (`run.tokens`/`run.cost` — the same totals `run.addUsage()` mirrors
433
+ * into the sessions row), checked BEFORE every agent dispatch including
434
+ * every JSON-repair retry and every gate correction, because the cost of
435
+ * the call about to happen is unknowable in advance. That makes these a
436
+ * hard cap on FURTHER spend rather than a post-hoc report: reaching the
437
+ * ceiling stops the next call, it does not merely note that the last one
438
+ * was expensive. A tripped ceiling fails the phase closed — see
439
+ * `agents.ts`'s `BudgetExceeded`.
440
+ *
441
+ * `max_run_cost` is USD (the same unit the provider's own usage.cost
442
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
443
+ * tokens, i.e. the spend number — every turn re-sends the whole
444
+ * conversation, so this counts cached re-reads too, exactly like the
445
+ * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
446
+ * for why that number is much larger than "material moved").
447
+ *
448
+ * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
449
+ * run", which is a config mistake, not a budget — it would fail the first
450
+ * phase of every chain with a budget message instead of saying what is
451
+ * actually wrong.
452
+ *
453
+ * THE BACK-FILL TRAP (see `agents.ts`'s `loadConfig`): that function copies
454
+ * a handful of `defaults` keys DOWN onto each agent that hasn't set them
455
+ * (coding_agent/model/thinking/color/tools/writes/env_allowlist). These two
456
+ * keys are deliberately NOT in that list and must never be added to it —
457
+ * they are RUN-scoped ceilings, not per-agent settings, and a per-agent
458
+ * copy would read as "each agent may spend this much", which is a
459
+ * different (and unenforced) feature. `mergeRawConfig`'s `defaults` spread
460
+ * is what carries them through config layering, key-by-key; pinned by
461
+ * `src/test/budget.test.ts`.
462
+ */
463
+ readonly max_run_cost: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
464
+ readonly max_run_tokens: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
391
465
  }, undefined>;
392
466
  export type ConfigDefaults = v.InferOutput<typeof ConfigDefaultsSchema>;
393
467
  /**
@@ -492,6 +566,22 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
492
566
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
493
567
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 60000>;
494
568
  readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
569
+ /**
570
+ * Threaded straight through as `runChainDef`'s `options` argument for
571
+ * every unattended `spf watch` dispatch (build lane AND refine lane) —
572
+ * the exact same `Record<string, string>` shape an interactive `spf
573
+ * <chain> --suite <name>` builds in `cli/commands/run.ts`'s
574
+ * `dispatchChain` (e.g. `{suite: "strict"}`, `{agent: "..."}`). Fixes the
575
+ * KNOWN LIMITATION called out in PR #20: `cli/commands/watch.ts`'s
576
+ * `runChain`/`runRefine` wrappers used to call `runChainDef` with no
577
+ * options at all, so nothing --suite-shaped could ever reach a chain run
578
+ * `spf watch` dispatched — see `cli/commands/watch.ts`. Empty by default,
579
+ * so an existing `watch:` config's behavior is unchanged by upgrading.
580
+ * Whole-object replace on merge, like `jira`/`refine` above and
581
+ * `observability.otel` — see `agents.ts`'s `mergeRawConfig` and
582
+ * `data_types.test.ts`'s merge-survival test for this field.
583
+ */
584
+ readonly chain_options: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
495
585
  readonly jira: v.OptionalSchema<v.ObjectSchema<{
496
586
  readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
497
587
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -574,6 +664,64 @@ export declare const ReviewConfigSchema: v.ObjectSchema<{
574
664
  readonly signoff_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 300>;
575
665
  }, undefined>;
576
666
  export type ReviewConfig = v.InferOutput<typeof ReviewConfigSchema>;
667
+ /**
668
+ * Risk-tiered per-role model routing — SPF #14. One rung of a ladder:
669
+ * `name` is what `tiering.roles` points at, `model` speaks EXACTLY the same
670
+ * vocabulary as an agent's own `model:` for that backend (provider/model-id
671
+ * for flue, Claude Code's bare alias/full-name for claude_code) — there is
672
+ * deliberately no per-provider table, since for flue the provider is
673
+ * already the first segment of the value.
674
+ *
675
+ * `coding_agent` declares WHICH BACKEND'S VOCABULARY this rung's `model`
676
+ * speaks — same picklist and same default as `AgentConfigSchema`'s own
677
+ * field (`:409`), reused rather than restated. A tier changes an agent's
678
+ * `model` and NOTHING else (`coding_agent` stays the agent's own, always),
679
+ * so a rung can only route roles whose `coding_agent` matches its own — see
680
+ * `core/tiering.ts`'s rule T, enforced by `agents.ts`'s `validate()`.
681
+ */
682
+ export declare const TierSchema: v.ObjectSchema<{
683
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
684
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
685
+ readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
686
+ }, undefined>;
687
+ export type Tier = v.InferOutput<typeof TierSchema>;
688
+ /**
689
+ * OFF by default, and `enabled: false`/absent is a TOTAL no-op: every agent
690
+ * dispatches at exactly the model its roster entry names, byte-identical to
691
+ * before this key existed — same discipline `max_run_cost`/`max_run_tokens`
692
+ * hold themselves to (`ConfigDefaultsSchema`'s own comment above).
693
+ *
694
+ * TOP-LEVEL (`SFConfigSchema` below), deliberately NOT nested under
695
+ * `defaults:` — `agents.ts`'s `loadConfig` back-fill loop copies a FIXED
696
+ * list of `defaults` keys DOWN onto every agent that hasn't set them, and
697
+ * `AgentConfigSchema` is a non-strict `v.object`, so a stray copy would be
698
+ * silently STRIPPED at parse rather than rejected (see
699
+ * `ConfigDefaultsSchema`'s "THE BACK-FILL TRAP" comment — `max_run_cost`/
700
+ * `max_run_tokens` needed an explicit never-add-this-to-the-list comment to
701
+ * survive exactly that trap). A top-level key sits outside that loop
702
+ * entirely, so the trap cannot apply here at all.
703
+ *
704
+ * `tiers` is the ladder, WEAKEST FIRST: a risk level shifts every routed
705
+ * role UP or DOWN this list by the same step, so order is the whole
706
+ * semantics — a SEQUENCE states that unambiguously where a mapping's key
707
+ * order would be parser-dependent.
708
+ *
709
+ * `roles` is the baseline tier per ROLE (an agent name). Naming an agent
710
+ * here IS the operator's statement "route this one by tier" — so the
711
+ * resolved tier wins over that agent's own `model:`. An agent NOT named
712
+ * here is never retiered: its `model:` stands, untouched. See
713
+ * `core/tiering.ts`'s `resolveTiering` for the full precedence rule.
714
+ */
715
+ export declare const TieringConfigSchema: v.ObjectSchema<{
716
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
717
+ readonly tiers: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
718
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
719
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
720
+ readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
721
+ }, undefined>, undefined>, () => never[]>;
722
+ readonly roles: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
723
+ }, undefined>;
724
+ export type TieringConfig = v.InferOutput<typeof TieringConfigSchema>;
577
725
  export declare const SFConfigSchema: v.ObjectSchema<{
578
726
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
579
727
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -584,6 +732,50 @@ export declare const SFConfigSchema: v.ObjectSchema<{
584
732
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
585
733
  readonly protected_files: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => string[]>;
586
734
  readonly data_dir: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data">;
735
+ /**
736
+ * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
737
+ *
738
+ * BOTH ABSENT BY DEFAULT, and absence is a total no-op: `agents.ts`'s
739
+ * `assertRunBudget()` returns immediately when neither is set, so every
740
+ * existing config behaves byte-identically to before this field existed.
741
+ * There is no ambient environment variable and no implicit default — an
742
+ * unbounded run stays the default because a surprise mid-run failure on a
743
+ * ceiling nobody chose is worse than the spend.
744
+ *
745
+ * SCOPE IS THE RUN, NOT THE CALL. Enforced against the Run's ACCUMULATED
746
+ * usage (`run.tokens`/`run.cost` — the same totals `run.addUsage()` mirrors
747
+ * into the sessions row), checked BEFORE every agent dispatch including
748
+ * every JSON-repair retry and every gate correction, because the cost of
749
+ * the call about to happen is unknowable in advance. That makes these a
750
+ * hard cap on FURTHER spend rather than a post-hoc report: reaching the
751
+ * ceiling stops the next call, it does not merely note that the last one
752
+ * was expensive. A tripped ceiling fails the phase closed — see
753
+ * `agents.ts`'s `BudgetExceeded`.
754
+ *
755
+ * `max_run_cost` is USD (the same unit the provider's own usage.cost
756
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
757
+ * tokens, i.e. the spend number — every turn re-sends the whole
758
+ * conversation, so this counts cached re-reads too, exactly like the
759
+ * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
760
+ * for why that number is much larger than "material moved").
761
+ *
762
+ * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
763
+ * run", which is a config mistake, not a budget — it would fail the first
764
+ * phase of every chain with a budget message instead of saying what is
765
+ * actually wrong.
766
+ *
767
+ * THE BACK-FILL TRAP (see `agents.ts`'s `loadConfig`): that function copies
768
+ * a handful of `defaults` keys DOWN onto each agent that hasn't set them
769
+ * (coding_agent/model/thinking/color/tools/writes/env_allowlist). These two
770
+ * keys are deliberately NOT in that list and must never be added to it —
771
+ * they are RUN-scoped ceilings, not per-agent settings, and a per-agent
772
+ * copy would read as "each agent may spend this much", which is a
773
+ * different (and unenforced) feature. `mergeRawConfig`'s `defaults` spread
774
+ * is what carries them through config layering, key-by-key; pinned by
775
+ * `src/test/budget.test.ts`.
776
+ */
777
+ readonly max_run_cost: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
778
+ readonly max_run_tokens: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
587
779
  }, undefined>, () => {
588
780
  coding_agent: "claude_code" | "flue";
589
781
  model: string;
@@ -593,6 +785,8 @@ export declare const SFConfigSchema: v.ObjectSchema<{
593
785
  tools?: string[] | null | undefined;
594
786
  protected_files: string[];
595
787
  data_dir: string;
788
+ max_run_cost?: number | undefined;
789
+ max_run_tokens?: number | undefined;
596
790
  }>;
597
791
  readonly observability: v.OptionalSchema<v.ObjectSchema<{
598
792
  readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
@@ -660,6 +854,22 @@ export declare const SFConfigSchema: v.ObjectSchema<{
660
854
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
661
855
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 60000>;
662
856
  readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
857
+ /**
858
+ * Threaded straight through as `runChainDef`'s `options` argument for
859
+ * every unattended `spf watch` dispatch (build lane AND refine lane) —
860
+ * the exact same `Record<string, string>` shape an interactive `spf
861
+ * <chain> --suite <name>` builds in `cli/commands/run.ts`'s
862
+ * `dispatchChain` (e.g. `{suite: "strict"}`, `{agent: "..."}`). Fixes the
863
+ * KNOWN LIMITATION called out in PR #20: `cli/commands/watch.ts`'s
864
+ * `runChain`/`runRefine` wrappers used to call `runChainDef` with no
865
+ * options at all, so nothing --suite-shaped could ever reach a chain run
866
+ * `spf watch` dispatched — see `cli/commands/watch.ts`. Empty by default,
867
+ * so an existing `watch:` config's behavior is unchanged by upgrading.
868
+ * Whole-object replace on merge, like `jira`/`refine` above and
869
+ * `observability.otel` — see `agents.ts`'s `mergeRawConfig` and
870
+ * `data_types.test.ts`'s merge-survival test for this field.
871
+ */
872
+ readonly chain_options: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
663
873
  readonly jira: v.OptionalSchema<v.ObjectSchema<{
664
874
  readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
665
875
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -686,6 +896,9 @@ export declare const SFConfigSchema: v.ObjectSchema<{
686
896
  base_branch: string;
687
897
  poll_ms: number;
688
898
  concurrency: number;
899
+ chain_options: {
900
+ [x: string]: string;
901
+ };
689
902
  jira: {
690
903
  base_url: string;
691
904
  project_key: string;
@@ -722,6 +935,25 @@ export declare const SFConfigSchema: v.ObjectSchema<{
722
935
  require_human_signoff: boolean;
723
936
  signoff_timeout_seconds: number;
724
937
  }>;
938
+ readonly tiering: v.OptionalSchema<v.ObjectSchema<{
939
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
940
+ readonly tiers: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
941
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
942
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
943
+ readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
944
+ }, undefined>, undefined>, () => never[]>;
945
+ readonly roles: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
946
+ }, undefined>, () => {
947
+ enabled: boolean;
948
+ tiers: {
949
+ name: string;
950
+ coding_agent: "claude_code" | "flue";
951
+ model: string;
952
+ }[];
953
+ roles: {
954
+ [x: string]: string;
955
+ };
956
+ }>;
725
957
  }, undefined>;
726
958
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
727
959
  /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */