@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
@@ -164,6 +164,20 @@ export type DocumentOutputT = v.InferOutput<typeof DocumentOutput.schema>;
164
164
  * this list before publish ever runs (unique keys, resolvable references,
165
165
  * no cycles, container/leaf kind agreement, at least one leaf).
166
166
  */
167
+ /**
168
+ * `p0` (drop everything) .. `p3` (someday) — see `assets/prompts/refiner/system.md`'s
169
+ * `## Priority` section for what each rung means. Two rules the schema alone
170
+ * can't enforce, checked instead by `gates.refinementWellFormed`
171
+ * (monotonicity: no node outranks its `parent`) and `core/refine.ts`'s
172
+ * `publish()` (the spec's own priority, when known, is a ceiling clamped onto
173
+ * every node): see both files' doc comments.
174
+ */
175
+ export declare const RefinedPrioritySchema: v.PicklistSchema<["p0", "p1", "p2", "p3"], undefined>;
176
+ export type RefinedPriority = v.InferOutput<typeof RefinedPrioritySchema>;
177
+ /** Lower rank = more urgent. The one place both `gates.refinementWellFormed` (monotonicity) and `core/refine.ts`'s `publish()` (the spec-priority ceiling) get their ordering from — see `RefinedPrioritySchema`'s doc comment. */
178
+ export declare const PRIORITY_RANK: Record<RefinedPriority, number>;
179
+ /** `priority`, pulled down to `ceiling` if it outranks it — never raised. `ceiling` nullish (a bare `spf refine` with nothing to inherit from) is a no-op. */
180
+ export declare function clampPriority(priority: RefinedPriority, ceiling: RefinedPriority | null | undefined): RefinedPriority;
167
181
  export declare const RefinedIssueSchema: v.ObjectSchema<{
168
182
  readonly key: v.StringSchema<undefined>;
169
183
  readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
@@ -171,8 +185,31 @@ export declare const RefinedIssueSchema: v.ObjectSchema<{
171
185
  readonly body: v.StringSchema<undefined>;
172
186
  readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
173
187
  readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
188
+ readonly priority: v.OptionalSchema<v.PicklistSchema<["p0", "p1", "p2", "p3"], undefined>, "p2">;
174
189
  }, undefined>;
175
190
  export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
191
+ /**
192
+ * One open question the refiner could not answer itself — material ambiguity
193
+ * (scope, data model, an external dependency choice, a UX contract, anything
194
+ * that would contradict an ADR) it is refusing to guess on, per
195
+ * `assets/prompts/refiner/system.md`'s "ask, don't decide" rule. `id` is
196
+ * stable within one round so a resumed run's answers can be matched back to
197
+ * the question they answer; `why_it_matters`/`options`/`recommendation`/
198
+ * `evidence` exist so a human can answer in one word ("go with your rec")
199
+ * instead of re-deriving the tradeoff the refiner already worked out.
200
+ * `gates.refinementWellFormed` requires `issues` to be empty whenever this is
201
+ * non-empty — escalating means publishing nothing THIS round, never a
202
+ * partial tree pinned to an unanswered question.
203
+ */
204
+ export declare const RefineQuestionSchema: v.ObjectSchema<{
205
+ readonly id: v.StringSchema<undefined>;
206
+ readonly question: v.StringSchema<undefined>;
207
+ readonly why_it_matters: v.OptionalSchema<v.StringSchema<undefined>, "">;
208
+ readonly options: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
209
+ readonly recommendation: v.OptionalSchema<v.StringSchema<undefined>, "">;
210
+ readonly evidence: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
211
+ }, undefined>;
212
+ export type RefineQuestion = v.InferOutput<typeof RefineQuestionSchema>;
176
213
  /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
177
214
  export declare const RefineOutput: EnvelopeType<{
178
215
  status: "fail" | "success";
@@ -186,6 +223,15 @@ export declare const RefineOutput: EnvelopeType<{
186
223
  body: string;
187
224
  parent: string;
188
225
  blocked_by: string[];
226
+ priority: "p0" | "p1" | "p2" | "p3";
227
+ }[];
228
+ questions: {
229
+ id: string;
230
+ question: string;
231
+ why_it_matters: string;
232
+ options: string[];
233
+ recommendation: string;
234
+ evidence: string[];
189
235
  }[];
190
236
  }>;
191
237
  export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
@@ -388,6 +434,50 @@ export declare const ConfigDefaultsSchema: v.ObjectSchema<{
388
434
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
389
435
  readonly protected_files: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => string[]>;
390
436
  readonly data_dir: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data">;
437
+ /**
438
+ * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
439
+ *
440
+ * BOTH ABSENT BY DEFAULT, and absence is a total no-op: `agents.ts`'s
441
+ * `assertRunBudget()` returns immediately when neither is set, so every
442
+ * existing config behaves byte-identically to before this field existed.
443
+ * There is no ambient environment variable and no implicit default — an
444
+ * unbounded run stays the default because a surprise mid-run failure on a
445
+ * ceiling nobody chose is worse than the spend.
446
+ *
447
+ * SCOPE IS THE RUN, NOT THE CALL. Enforced against the Run's ACCUMULATED
448
+ * usage (`run.tokens`/`run.cost` — the same totals `run.addUsage()` mirrors
449
+ * into the sessions row), checked BEFORE every agent dispatch including
450
+ * every JSON-repair retry and every gate correction, because the cost of
451
+ * the call about to happen is unknowable in advance. That makes these a
452
+ * hard cap on FURTHER spend rather than a post-hoc report: reaching the
453
+ * ceiling stops the next call, it does not merely note that the last one
454
+ * was expensive. A tripped ceiling fails the phase closed — see
455
+ * `agents.ts`'s `BudgetExceeded`.
456
+ *
457
+ * `max_run_cost` is USD (the same unit the provider's own usage.cost
458
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
459
+ * tokens, i.e. the spend number — every turn re-sends the whole
460
+ * conversation, so this counts cached re-reads too, exactly like the
461
+ * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
462
+ * for why that number is much larger than "material moved").
463
+ *
464
+ * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
465
+ * run", which is a config mistake, not a budget — it would fail the first
466
+ * phase of every chain with a budget message instead of saying what is
467
+ * actually wrong.
468
+ *
469
+ * THE BACK-FILL TRAP (see `agents.ts`'s `loadConfig`): that function copies
470
+ * a handful of `defaults` keys DOWN onto each agent that hasn't set them
471
+ * (coding_agent/model/thinking/color/tools/writes/env_allowlist). These two
472
+ * keys are deliberately NOT in that list and must never be added to it —
473
+ * they are RUN-scoped ceilings, not per-agent settings, and a per-agent
474
+ * copy would read as "each agent may spend this much", which is a
475
+ * different (and unenforced) feature. `mergeRawConfig`'s `defaults` spread
476
+ * is what carries them through config layering, key-by-key; pinned by
477
+ * `src/test/budget.test.ts`.
478
+ */
479
+ readonly max_run_cost: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
480
+ readonly max_run_tokens: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
391
481
  }, undefined>;
392
482
  export type ConfigDefaults = v.InferOutput<typeof ConfigDefaultsSchema>;
393
483
  /**
@@ -460,10 +550,50 @@ export declare const WatchIssueProviderSchema: v.PicklistSchema<["github", "jira
460
550
  export type WatchIssueProviderKind = v.InferOutput<typeof WatchIssueProviderSchema>;
461
551
  export declare const WatchCodeHostSchema: v.PicklistSchema<["github", "bitbucket"], undefined>;
462
552
  export type WatchCodeHostKind = v.InferOutput<typeof WatchCodeHostSchema>;
463
- /** Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` + `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. */
553
+ /**
554
+ * What each `RefinedIssue.kind` creates as, on Jira — Jira's create endpoint
555
+ * requires a real `issuetype`, and project setups vary (renamed types,
556
+ * non-English instances, custom schemes), so this is a name -> name map,
557
+ * not a hardcoded assumption. Every field defaults independently: a config
558
+ * that only sets `bug: Defect` still gets Epic/Epic/Story/Task for the
559
+ * other four. `jira_provider.ts`'s `createIssue`/`validateIssueTypes` are
560
+ * the readers; `spf watch init` and `spf watch`'s own startup check
561
+ * (`cli/commands/watch.ts`) both validate this against the real project
562
+ * before anything unattended runs on it.
563
+ */
564
+ export declare const JiraIssueTypeMapSchema: v.ObjectSchema<{
565
+ readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
566
+ readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
567
+ readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
568
+ readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
569
+ readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
570
+ }, undefined>;
571
+ export type JiraIssueTypeMap = v.InferOutput<typeof JiraIssueTypeMapSchema>;
572
+ /**
573
+ * Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
574
+ * `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
575
+ * object replace on config-file-layer merge, like `refine`/
576
+ * `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
577
+ * file that touches `watch.jira` at all must repeat `issue_types` too if it
578
+ * wants to keep a customized mapping, same caveat that already applies to
579
+ * `base_url`/`project_key` today.
580
+ */
464
581
  export declare const WatchJiraConfigSchema: v.ObjectSchema<{
465
582
  readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
466
583
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
584
+ readonly issue_types: v.OptionalSchema<v.ObjectSchema<{
585
+ readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
586
+ readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
587
+ readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
588
+ readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
589
+ readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
590
+ }, undefined>, () => {
591
+ epic: string;
592
+ feature: string;
593
+ story: string;
594
+ bug: string;
595
+ task: string;
596
+ }>;
467
597
  }, undefined>;
468
598
  export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
469
599
  /**
@@ -471,10 +601,10 @@ export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
471
601
  * spec into a feature/story tree of real issues, instead of running
472
602
  * `watch.chain` against it directly (a spec is not individually workable —
473
603
  * see `core/refine.ts`). Off by default so an existing `watch:` config's
474
- * behavior is unchanged by upgrading; turning it on with
475
- * `issue_provider: jira` fails loudly at `spf watch` startup, since
476
- * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
477
- * yet see its module comment.
604
+ * behavior is unchanged by upgrading. Needs `issue_provider: github` or
605
+ * `"jira"` both implement `IssueAuthoringProvider` (create/link/list)
606
+ * any other value fails loudly at `spf watch` startup rather than running a
607
+ * refine lane that can never publish anything.
478
608
  */
479
609
  export declare const WatchRefineConfigSchema: v.ObjectSchema<{
480
610
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
@@ -492,12 +622,48 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
492
622
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
493
623
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 60000>;
494
624
  readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
625
+ /**
626
+ * Threaded straight through as `runChainDef`'s `options` argument for
627
+ * every unattended `spf watch` dispatch (build lane AND refine lane) —
628
+ * the exact same `Record<string, string>` shape an interactive `spf
629
+ * <chain> --suite <name>` builds in `cli/commands/run.ts`'s
630
+ * `dispatchChain` (e.g. `{suite: "strict"}`, `{agent: "..."}`). Fixes the
631
+ * KNOWN LIMITATION called out in PR #20: `cli/commands/watch.ts`'s
632
+ * `runChain`/`runRefine` wrappers used to call `runChainDef` with no
633
+ * options at all, so nothing --suite-shaped could ever reach a chain run
634
+ * `spf watch` dispatched — see `cli/commands/watch.ts`. Empty by default,
635
+ * so an existing `watch:` config's behavior is unchanged by upgrading.
636
+ * Whole-object replace on merge, like `jira`/`refine` above and
637
+ * `observability.otel` — see `agents.ts`'s `mergeRawConfig` and
638
+ * `data_types.test.ts`'s merge-survival test for this field.
639
+ */
640
+ readonly chain_options: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
495
641
  readonly jira: v.OptionalSchema<v.ObjectSchema<{
496
642
  readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
497
643
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
644
+ readonly issue_types: v.OptionalSchema<v.ObjectSchema<{
645
+ readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
646
+ readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
647
+ readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
648
+ readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
649
+ readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
650
+ }, undefined>, () => {
651
+ epic: string;
652
+ feature: string;
653
+ story: string;
654
+ bug: string;
655
+ task: string;
656
+ }>;
498
657
  }, undefined>, () => {
499
658
  base_url: string;
500
659
  project_key: string;
660
+ issue_types: {
661
+ epic: string;
662
+ feature: string;
663
+ story: string;
664
+ bug: string;
665
+ task: string;
666
+ };
501
667
  }>;
502
668
  readonly refine: v.OptionalSchema<v.ObjectSchema<{
503
669
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
@@ -574,6 +740,64 @@ export declare const ReviewConfigSchema: v.ObjectSchema<{
574
740
  readonly signoff_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 300>;
575
741
  }, undefined>;
576
742
  export type ReviewConfig = v.InferOutput<typeof ReviewConfigSchema>;
743
+ /**
744
+ * Risk-tiered per-role model routing — SPF #14. One rung of a ladder:
745
+ * `name` is what `tiering.roles` points at, `model` speaks EXACTLY the same
746
+ * vocabulary as an agent's own `model:` for that backend (provider/model-id
747
+ * for flue, Claude Code's bare alias/full-name for claude_code) — there is
748
+ * deliberately no per-provider table, since for flue the provider is
749
+ * already the first segment of the value.
750
+ *
751
+ * `coding_agent` declares WHICH BACKEND'S VOCABULARY this rung's `model`
752
+ * speaks — same picklist and same default as `AgentConfigSchema`'s own
753
+ * field (`:409`), reused rather than restated. A tier changes an agent's
754
+ * `model` and NOTHING else (`coding_agent` stays the agent's own, always),
755
+ * so a rung can only route roles whose `coding_agent` matches its own — see
756
+ * `core/tiering.ts`'s rule T, enforced by `agents.ts`'s `validate()`.
757
+ */
758
+ export declare const TierSchema: v.ObjectSchema<{
759
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
760
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
761
+ readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
762
+ }, undefined>;
763
+ export type Tier = v.InferOutput<typeof TierSchema>;
764
+ /**
765
+ * OFF by default, and `enabled: false`/absent is a TOTAL no-op: every agent
766
+ * dispatches at exactly the model its roster entry names, byte-identical to
767
+ * before this key existed — same discipline `max_run_cost`/`max_run_tokens`
768
+ * hold themselves to (`ConfigDefaultsSchema`'s own comment above).
769
+ *
770
+ * TOP-LEVEL (`SFConfigSchema` below), deliberately NOT nested under
771
+ * `defaults:` — `agents.ts`'s `loadConfig` back-fill loop copies a FIXED
772
+ * list of `defaults` keys DOWN onto every agent that hasn't set them, and
773
+ * `AgentConfigSchema` is a non-strict `v.object`, so a stray copy would be
774
+ * silently STRIPPED at parse rather than rejected (see
775
+ * `ConfigDefaultsSchema`'s "THE BACK-FILL TRAP" comment — `max_run_cost`/
776
+ * `max_run_tokens` needed an explicit never-add-this-to-the-list comment to
777
+ * survive exactly that trap). A top-level key sits outside that loop
778
+ * entirely, so the trap cannot apply here at all.
779
+ *
780
+ * `tiers` is the ladder, WEAKEST FIRST: a risk level shifts every routed
781
+ * role UP or DOWN this list by the same step, so order is the whole
782
+ * semantics — a SEQUENCE states that unambiguously where a mapping's key
783
+ * order would be parser-dependent.
784
+ *
785
+ * `roles` is the baseline tier per ROLE (an agent name). Naming an agent
786
+ * here IS the operator's statement "route this one by tier" — so the
787
+ * resolved tier wins over that agent's own `model:`. An agent NOT named
788
+ * here is never retiered: its `model:` stands, untouched. See
789
+ * `core/tiering.ts`'s `resolveTiering` for the full precedence rule.
790
+ */
791
+ export declare const TieringConfigSchema: v.ObjectSchema<{
792
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
793
+ readonly tiers: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
794
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
795
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
796
+ readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
797
+ }, undefined>, undefined>, () => never[]>;
798
+ readonly roles: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
799
+ }, undefined>;
800
+ export type TieringConfig = v.InferOutput<typeof TieringConfigSchema>;
577
801
  export declare const SFConfigSchema: v.ObjectSchema<{
578
802
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
579
803
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -584,6 +808,50 @@ export declare const SFConfigSchema: v.ObjectSchema<{
584
808
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
585
809
  readonly protected_files: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => string[]>;
586
810
  readonly data_dir: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data">;
811
+ /**
812
+ * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
813
+ *
814
+ * BOTH ABSENT BY DEFAULT, and absence is a total no-op: `agents.ts`'s
815
+ * `assertRunBudget()` returns immediately when neither is set, so every
816
+ * existing config behaves byte-identically to before this field existed.
817
+ * There is no ambient environment variable and no implicit default — an
818
+ * unbounded run stays the default because a surprise mid-run failure on a
819
+ * ceiling nobody chose is worse than the spend.
820
+ *
821
+ * SCOPE IS THE RUN, NOT THE CALL. Enforced against the Run's ACCUMULATED
822
+ * usage (`run.tokens`/`run.cost` — the same totals `run.addUsage()` mirrors
823
+ * into the sessions row), checked BEFORE every agent dispatch including
824
+ * every JSON-repair retry and every gate correction, because the cost of
825
+ * the call about to happen is unknowable in advance. That makes these a
826
+ * hard cap on FURTHER spend rather than a post-hoc report: reaching the
827
+ * ceiling stops the next call, it does not merely note that the last one
828
+ * was expensive. A tripped ceiling fails the phase closed — see
829
+ * `agents.ts`'s `BudgetExceeded`.
830
+ *
831
+ * `max_run_cost` is USD (the same unit the provider's own usage.cost
832
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
833
+ * tokens, i.e. the spend number — every turn re-sends the whole
834
+ * conversation, so this counts cached re-reads too, exactly like the
835
+ * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
836
+ * for why that number is much larger than "material moved").
837
+ *
838
+ * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
839
+ * run", which is a config mistake, not a budget — it would fail the first
840
+ * phase of every chain with a budget message instead of saying what is
841
+ * actually wrong.
842
+ *
843
+ * THE BACK-FILL TRAP (see `agents.ts`'s `loadConfig`): that function copies
844
+ * a handful of `defaults` keys DOWN onto each agent that hasn't set them
845
+ * (coding_agent/model/thinking/color/tools/writes/env_allowlist). These two
846
+ * keys are deliberately NOT in that list and must never be added to it —
847
+ * they are RUN-scoped ceilings, not per-agent settings, and a per-agent
848
+ * copy would read as "each agent may spend this much", which is a
849
+ * different (and unenforced) feature. `mergeRawConfig`'s `defaults` spread
850
+ * is what carries them through config layering, key-by-key; pinned by
851
+ * `src/test/budget.test.ts`.
852
+ */
853
+ readonly max_run_cost: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
854
+ readonly max_run_tokens: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
587
855
  }, undefined>, () => {
588
856
  coding_agent: "claude_code" | "flue";
589
857
  model: string;
@@ -593,6 +861,8 @@ export declare const SFConfigSchema: v.ObjectSchema<{
593
861
  tools?: string[] | null | undefined;
594
862
  protected_files: string[];
595
863
  data_dir: string;
864
+ max_run_cost?: number | undefined;
865
+ max_run_tokens?: number | undefined;
596
866
  }>;
597
867
  readonly observability: v.OptionalSchema<v.ObjectSchema<{
598
868
  readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
@@ -660,12 +930,48 @@ export declare const SFConfigSchema: v.ObjectSchema<{
660
930
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
661
931
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 60000>;
662
932
  readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
933
+ /**
934
+ * Threaded straight through as `runChainDef`'s `options` argument for
935
+ * every unattended `spf watch` dispatch (build lane AND refine lane) —
936
+ * the exact same `Record<string, string>` shape an interactive `spf
937
+ * <chain> --suite <name>` builds in `cli/commands/run.ts`'s
938
+ * `dispatchChain` (e.g. `{suite: "strict"}`, `{agent: "..."}`). Fixes the
939
+ * KNOWN LIMITATION called out in PR #20: `cli/commands/watch.ts`'s
940
+ * `runChain`/`runRefine` wrappers used to call `runChainDef` with no
941
+ * options at all, so nothing --suite-shaped could ever reach a chain run
942
+ * `spf watch` dispatched — see `cli/commands/watch.ts`. Empty by default,
943
+ * so an existing `watch:` config's behavior is unchanged by upgrading.
944
+ * Whole-object replace on merge, like `jira`/`refine` above and
945
+ * `observability.otel` — see `agents.ts`'s `mergeRawConfig` and
946
+ * `data_types.test.ts`'s merge-survival test for this field.
947
+ */
948
+ readonly chain_options: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
663
949
  readonly jira: v.OptionalSchema<v.ObjectSchema<{
664
950
  readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
665
951
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
952
+ readonly issue_types: v.OptionalSchema<v.ObjectSchema<{
953
+ readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
954
+ readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
955
+ readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
956
+ readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
957
+ readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
958
+ }, undefined>, () => {
959
+ epic: string;
960
+ feature: string;
961
+ story: string;
962
+ bug: string;
963
+ task: string;
964
+ }>;
666
965
  }, undefined>, () => {
667
966
  base_url: string;
668
967
  project_key: string;
968
+ issue_types: {
969
+ epic: string;
970
+ feature: string;
971
+ story: string;
972
+ bug: string;
973
+ task: string;
974
+ };
669
975
  }>;
670
976
  readonly refine: v.OptionalSchema<v.ObjectSchema<{
671
977
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
@@ -686,9 +992,19 @@ export declare const SFConfigSchema: v.ObjectSchema<{
686
992
  base_branch: string;
687
993
  poll_ms: number;
688
994
  concurrency: number;
995
+ chain_options: {
996
+ [x: string]: string;
997
+ };
689
998
  jira: {
690
999
  base_url: string;
691
1000
  project_key: string;
1001
+ issue_types: {
1002
+ epic: string;
1003
+ feature: string;
1004
+ story: string;
1005
+ bug: string;
1006
+ task: string;
1007
+ };
692
1008
  };
693
1009
  refine: {
694
1010
  enabled: boolean;
@@ -722,6 +1038,25 @@ export declare const SFConfigSchema: v.ObjectSchema<{
722
1038
  require_human_signoff: boolean;
723
1039
  signoff_timeout_seconds: number;
724
1040
  }>;
1041
+ readonly tiering: v.OptionalSchema<v.ObjectSchema<{
1042
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
1043
+ readonly tiers: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
1044
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1045
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
1046
+ readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1047
+ }, undefined>, undefined>, () => never[]>;
1048
+ readonly roles: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
1049
+ }, undefined>, () => {
1050
+ enabled: boolean;
1051
+ tiers: {
1052
+ name: string;
1053
+ coding_agent: "claude_code" | "flue";
1054
+ model: string;
1055
+ }[];
1056
+ roles: {
1057
+ [x: string]: string;
1058
+ };
1059
+ }>;
725
1060
  }, undefined>;
726
1061
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
727
1062
  /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */