@gr8ful/spf 0.5.1 → 0.7.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 (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  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 +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  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 +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. package/dist/test/watch.test.js +0 -687
@@ -16,6 +16,7 @@ import { isRepoAt, makeGit } from "../../core/git_helper.js";
16
16
  import { GitHubProvider } from "../../core/issues/github_provider.js";
17
17
  import { JiraProvider } from "../../core/issues/jira_provider.js";
18
18
  import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
19
+ import { isAuthoringProvider } from "../../core/issues/provider.js";
19
20
  import { createWatchState, tick } from "../../core/watch.js";
20
21
  import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
21
22
  import { ReviewOutput } from "../../core/data_types.js";
@@ -107,7 +108,7 @@ function resolveIssueProvider(cfg) {
107
108
  console.error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens). See README.md\'s "spf watch" section.');
108
109
  return null;
109
110
  }
110
- return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token);
111
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
111
112
  }
112
113
  console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
113
114
  return null;
@@ -177,6 +178,14 @@ function releaseLock(lockPath) {
177
178
  * machine needs, with sensible colors/descriptions. Doesn't touch git or
178
179
  * run anything, so it skips watchCommand's repo/chain checks entirely —
179
180
  * you can seed labels before ever wiring up a worktree-capable checkout.
181
+ *
182
+ * On Jira, with `watch.refine.enabled`, this also runs a READ-ONLY check of
183
+ * `watch.jira.issue_types` against the real project — the same check
184
+ * `watchCommand`'s own startup gate runs, exposed here too so a bad mapping
185
+ * can be caught (and fixed) before ever starting the daemon, not just at
186
+ * startup time. Labels themselves stay a pure report on Jira either way
187
+ * (Jira labels are freeform strings with no color/description registry to
188
+ * seed — see `jira_provider.ts`'s module comment).
180
189
  */
181
190
  export async function watchInitCommand(argv) {
182
191
  const { options } = parseCli(argv, ["cwd", "config"], []);
@@ -193,6 +202,15 @@ export async function watchInitCommand(argv) {
193
202
  console.log(` ~ ${name} (updated color/description)`);
194
203
  for (const name of result.unchanged)
195
204
  console.log(` = ${name} (already correct)`);
205
+ if (cfg.watch.refine.enabled && cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
206
+ const checks = await provider.validateIssueTypes();
207
+ console.log(`\nvalidating watch.jira.issue_types against ${cfg.watch.jira.project_key}:`);
208
+ for (const c of checks) {
209
+ console.log(` ${c.exists ? "✓" : "✗"} ${c.kind} → ${c.jiraType}${c.exists ? "" : ` — no issue type named "${c.jiraType}" in ${cfg.watch.jira.project_key}`}`);
210
+ }
211
+ if (checks.some((c) => !c.exists))
212
+ return 1;
213
+ }
196
214
  return 0;
197
215
  }
198
216
  export async function watchCommand(argv) {
@@ -215,19 +233,37 @@ export async function watchCommand(argv) {
215
233
  return 1;
216
234
  }
217
235
  if (cfg.watch.refine.enabled) {
218
- if (cfg.watch.issue_provider !== "github") {
219
- // Fail loudly at startup, not silently every tick: JiraProvider
220
- // doesn't implement IssueAuthoringProvider yet (see its module
221
- // comment) — a refine lane that can never publish would otherwise
222
- // just claim every spec-ready spec and block it, forever.
236
+ if (cfg.watch.issue_provider !== "github" && cfg.watch.issue_provider !== "jira") {
237
+ // Fail loudly at startup, not silently every tick: neither provider
238
+ // implements IssueAuthoringProvider besides these two — a refine lane
239
+ // that can never publish would otherwise just claim every spec-ready
240
+ // spec and block it, forever.
223
241
  console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
224
- `the refine lane needs "github" (issue authoring isn't implemented for Jira yet)`);
242
+ `the refine lane needs "github" or "jira"`);
225
243
  return 1;
226
244
  }
227
245
  if (!findChain(cfg.watch.refine.chain)) {
228
246
  console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
229
247
  return 1;
230
248
  }
249
+ // Jira's issue-type mapping is user-configured (watch.jira.issue_types)
250
+ // and project-specific — validated here, at startup, for the same
251
+ // reason findChain() is: a bad mapping should stop the daemon before
252
+ // it starts, not fail silently every tick once the first spec-ready
253
+ // spec tries to publish. Silent on success, matching this function's
254
+ // other startup checks; loud (and the full per-kind report) on failure.
255
+ if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
256
+ const checks = await provider.validateIssueTypes();
257
+ const mismatches = checks.filter((c) => !c.exists);
258
+ if (mismatches.length > 0) {
259
+ console.error(`watch.jira.issue_types has ${mismatches.length} mismatch(es) against ${cfg.watch.jira.project_key}:`);
260
+ for (const c of checks) {
261
+ console.error(` ${c.exists ? "✓" : "✗"} ${c.kind} → ${c.jiraType}${c.exists ? "" : ` — no issue type named "${c.jiraType}" in ${cfg.watch.jira.project_key}`}`);
262
+ }
263
+ console.error(`Fix watch.jira.issue_types, or the project's issue types, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
264
+ return 1;
265
+ }
266
+ }
231
267
  }
232
268
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
233
269
  const lockPath = path.join(dataPaths.data_dir, "watch.lock");
@@ -352,17 +388,19 @@ export async function watchCommand(argv) {
352
388
  unattended: true,
353
389
  chain_source: chainDef.source,
354
390
  };
355
- // KNOWN LIMITATION (not fixed here): runChainDef is called below with no
356
- // third `options` argument, so nothing --suite-shaped ever reaches this
357
- // dispatch `resolveRequiredSuites`/`resolveRequiredAgents` below both
358
- // fall back to each chain's compiled-in/YAML-declared default. An
359
- // unattended watch run therefore can't override a chain's suite the way
360
- // an interactive `spf <chain> --suite <name>` can.
361
- const code = await runChainDef(chainDef, ctx);
362
- // Static for every chain but "prompt" (whose --agent flag `spf watch`
363
- // never passes) resolved with no options, exactly like `runChainDef`
364
- // above ran it.
365
- const reviewRequired = resolveRequiredAgents(chainDef, {}).includes("reviewer");
391
+ // `opts.chainOptions` is `watch.chain_options` (see WatchConfigSchema's
392
+ // doc comment), threaded down from `deps.chainOptions` by
393
+ // `core/watch.ts`'s `runIssue` the same options-map shape an
394
+ // interactive `spf <chain> --suite <name>` builds in
395
+ // `cli/commands/run.ts`'s `dispatchChain`. This closes the KNOWN
396
+ // LIMITATION from PR #20: an unattended watch dispatch used to call
397
+ // `runChainDef` with no options at all, so nothing --suite-shaped could
398
+ // ever reach it.
399
+ const code = await runChainDef(chainDef, ctx, opts.chainOptions);
400
+ // Resolved with the SAME options `runChainDef` just ran the chain with,
401
+ // so a `chain_options` override that swaps a "reviewer" step in or out
402
+ // is reflected here too, not just each chain's static/YAML default.
403
+ const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
366
404
  if (code === 0) {
367
405
  return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
368
406
  }
@@ -371,11 +409,15 @@ export async function watchCommand(argv) {
371
409
  };
372
410
  /**
373
411
  * Same shape as `runChain`, for the refine lane — with one extra step on
374
- * success: a chain's return value is just an exit code, so the created-
375
- * issues list `steps.publishIssues()` actually produced has to come back
376
- * through the side channel it wrote (`refine_publish.json`, under the
377
- * SAME symlinked session dir `linkDataDir` already wires up), not through
378
- * `runChainDef`'s return value.
412
+ * success: a chain's return value is just an exit code, so what
413
+ * `steps.publishIssues()` actually did published a tree, OR escalated a
414
+ * question set instead — has to come back through the side channel it
415
+ * wrote (`refine_publish.json` or `refine_questions.json`, under the SAME
416
+ * symlinked session dir `linkDataDir` already wires up), not through
417
+ * `runChainDef`'s return value. The two are read independently and both
418
+ * default to `[]`: `gates.refinementWellFormed` guarantees a chain never
419
+ * writes both files in the same run, so exactly one of `created`/
420
+ * `questions` is ever non-empty on an accepted run.
379
421
  */
380
422
  const runRefine = async (opts) => {
381
423
  const chainDef = findChain(cfg.watch.refine.chain); // checked above
@@ -386,29 +428,47 @@ export async function watchCommand(argv) {
386
428
  cwd: opts.cwd,
387
429
  chain_name: chainDef.name,
388
430
  issue_id: opts.issueId,
389
- // Same reasoning as runChain's ctx above — an unattended dispatch,
390
- // with no --suite-shaped options reaching it either (same KNOWN
391
- // LIMITATION).
431
+ // Same reasoning as runChain's ctx above — an unattended dispatch.
392
432
  unattended: true,
393
433
  chain_source: chainDef.source,
394
434
  };
395
- const code = await runChainDef(chainDef, ctx);
435
+ // Same `watch.chain_options` threading as runChain above — see its
436
+ // comment for the KNOWN LIMITATION this fixes.
437
+ const code = await runChainDef(chainDef, ctx, opts.chainOptions);
396
438
  if (code !== 0) {
397
439
  const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
398
- return { accepted: false, adwId: opts.adwId, detail, created: [] };
440
+ return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
399
441
  }
442
+ const wtAnchor = paths.resolveAnchor(opts.cwd);
443
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
444
+ const handoffDir = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff");
400
445
  let created = [];
401
446
  try {
402
- const wtAnchor = paths.resolveAnchor(opts.cwd);
403
- const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
404
- const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
405
- created = JSON.parse(readFileSync(summaryPath, "utf-8"));
447
+ created = JSON.parse(readFileSync(path.join(handoffDir, "refine_publish.json"), "utf-8"));
406
448
  }
407
449
  catch {
408
450
  // best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
409
451
  }
410
- return { accepted: true, adwId: opts.adwId, detail: "", created };
452
+ let questions = [];
453
+ try {
454
+ questions = JSON.parse(readFileSync(path.join(handoffDir, "refine_questions.json"), "utf-8"));
455
+ }
456
+ catch {
457
+ // best-effort, same as above — no questions file means this run wasn't an escalation
458
+ }
459
+ return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
411
460
  };
461
+ // `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
462
+ // structural check (see `provider.ts`), so both GitHub and Jira are
463
+ // recognized here without an `instanceof` chain that would need editing
464
+ // for every future authoring-capable provider. `null` on any tracker that
465
+ // ISN'T authoring-capable makes `rollUp` a logged no-op there rather than
466
+ // a startup failure the way `watch.refine.enabled` without authoring
467
+ // support is above: the build lane functions fine without container
468
+ // roll-up, unlike refine, which cannot function without authoring at all.
469
+ // Not a second `resolveAuthoringProvider()` call: that helper's own config
470
+ // validation already ran to produce `provider` itself.
471
+ const authoringProvider = isAuthoringProvider(provider) ? provider : null;
412
472
  const deps = {
413
473
  provider,
414
474
  codeHost,
@@ -418,6 +478,7 @@ export async function watchCommand(argv) {
418
478
  chain: cfg.watch.chain,
419
479
  baseBranch: cfg.watch.base_branch,
420
480
  concurrency: cfg.watch.concurrency,
481
+ chainOptions: cfg.watch.chain_options,
421
482
  refineEnabled: cfg.watch.refine.enabled,
422
483
  refineConcurrency: cfg.watch.refine.concurrency,
423
484
  refineChain: cfg.watch.refine.chain,
@@ -426,6 +487,7 @@ export async function watchCommand(argv) {
426
487
  linkDataDir,
427
488
  dryRun: Boolean(flags["dry-run"]),
428
489
  runChain,
490
+ listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
429
491
  log: (message) => console.log(message),
430
492
  notify: (event) => notifier?.send(event),
431
493
  };
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)
@@ -44,7 +49,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
44
49
  spf abort <adw_id> signal a run's process to stop
45
50
  spf version print the installed version
46
51
 
47
- Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
52
+ Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>] [--priority p0|p1|p2|p3]
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;
@@ -286,16 +286,20 @@ export async function runInterview(asker, ctx) {
286
286
  watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
287
287
  if (issueRepo)
288
288
  watch.issue_repo = issueRepo;
289
- // Issue authoring (create + link a hierarchy) is only implemented on
290
- // GitHubProvider today — see jira_provider.ts's module comment — so
291
- // this lane isn't offered at all on a Jira tracker rather than asking a
292
- // question that would just fail at `spf watch` startup.
293
- if (issueProvider === "github") {
289
+ // Issue authoring (create + link a hierarchy) is implemented on both
290
+ // GitHubProvider and JiraProvider — see jira_provider.ts's module
291
+ // comment for how Jira's version works (native issue types + the
292
+ // `parent` field).
293
+ if (issueProvider === "github" || issueProvider === "jira") {
294
294
  const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
295
295
  if (enableRefine) {
296
296
  const refineChainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
297
297
  const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
298
298
  watch.refine = { enabled: true, chain: refineChain };
299
+ if (issueProvider === "jira") {
300
+ asker.note('Jira issue types default to epic/feature -> "Epic", story -> "Story", bug -> "Bug", task -> "Task" — ' +
301
+ "customize per-kind in watch.jira.issue_types if your project renames any of them, then run `spf watch init` to validate.");
302
+ }
299
303
  }
300
304
  }
301
305
  if (issueProvider === "jira") {
@@ -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,