@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
@@ -125,9 +125,31 @@ export const RefinedIssueSchema = v.object({
125
125
  parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
126
126
  blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
127
127
  });
128
+ /**
129
+ * One open question the refiner could not answer itself — material ambiguity
130
+ * (scope, data model, an external dependency choice, a UX contract, anything
131
+ * that would contradict an ADR) it is refusing to guess on, per
132
+ * `assets/prompts/refiner/system.md`'s "ask, don't decide" rule. `id` is
133
+ * stable within one round so a resumed run's answers can be matched back to
134
+ * the question they answer; `why_it_matters`/`options`/`recommendation`/
135
+ * `evidence` exist so a human can answer in one word ("go with your rec")
136
+ * instead of re-deriving the tradeoff the refiner already worked out.
137
+ * `gates.refinementWellFormed` requires `issues` to be empty whenever this is
138
+ * non-empty — escalating means publishing nothing THIS round, never a
139
+ * partial tree pinned to an unanswered question.
140
+ */
141
+ export const RefineQuestionSchema = v.object({
142
+ id: v.string(),
143
+ question: v.string(),
144
+ why_it_matters: v.optional(v.string(), ""),
145
+ options: v.optional(v.array(v.string()), () => []),
146
+ recommendation: v.optional(v.string(), ""),
147
+ evidence: v.optional(v.array(v.string()), () => []),
148
+ });
128
149
  /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
129
150
  export const RefineOutput = envelopeType("RefineOutput", {
130
151
  issues: v.optional(v.array(RefinedIssueSchema), () => []),
152
+ questions: v.optional(v.array(RefineQuestionSchema), () => []),
131
153
  });
132
154
  // ── Deterministic quality blocks ─────────────────────────────────────────────
133
155
  export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
@@ -304,6 +326,50 @@ export const ConfigDefaultsSchema = v.object({
304
326
  // .spf/ is the whole per-repo footprint now — no adws/ tree to protect.
305
327
  protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
306
328
  data_dir: v.optional(v.string(), ".spf/data"),
329
+ /**
330
+ * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
331
+ *
332
+ * BOTH ABSENT BY DEFAULT, and absence is a total no-op: `agents.ts`'s
333
+ * `assertRunBudget()` returns immediately when neither is set, so every
334
+ * existing config behaves byte-identically to before this field existed.
335
+ * There is no ambient environment variable and no implicit default — an
336
+ * unbounded run stays the default because a surprise mid-run failure on a
337
+ * ceiling nobody chose is worse than the spend.
338
+ *
339
+ * SCOPE IS THE RUN, NOT THE CALL. Enforced against the Run's ACCUMULATED
340
+ * usage (`run.tokens`/`run.cost` — the same totals `run.addUsage()` mirrors
341
+ * into the sessions row), checked BEFORE every agent dispatch including
342
+ * every JSON-repair retry and every gate correction, because the cost of
343
+ * the call about to happen is unknowable in advance. That makes these a
344
+ * hard cap on FURTHER spend rather than a post-hoc report: reaching the
345
+ * ceiling stops the next call, it does not merely note that the last one
346
+ * was expensive. A tripped ceiling fails the phase closed — see
347
+ * `agents.ts`'s `BudgetExceeded`.
348
+ *
349
+ * `max_run_cost` is USD (the same unit the provider's own usage.cost
350
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
351
+ * tokens, i.e. the spend number — every turn re-sends the whole
352
+ * conversation, so this counts cached re-reads too, exactly like the
353
+ * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
354
+ * for why that number is much larger than "material moved").
355
+ *
356
+ * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
357
+ * run", which is a config mistake, not a budget — it would fail the first
358
+ * phase of every chain with a budget message instead of saying what is
359
+ * actually wrong.
360
+ *
361
+ * THE BACK-FILL TRAP (see `agents.ts`'s `loadConfig`): that function copies
362
+ * a handful of `defaults` keys DOWN onto each agent that hasn't set them
363
+ * (coding_agent/model/thinking/color/tools/writes/env_allowlist). These two
364
+ * keys are deliberately NOT in that list and must never be added to it —
365
+ * they are RUN-scoped ceilings, not per-agent settings, and a per-agent
366
+ * copy would read as "each agent may spend this much", which is a
367
+ * different (and unenforced) feature. `mergeRawConfig`'s `defaults` spread
368
+ * is what carries them through config layering, key-by-key; pinned by
369
+ * `src/test/budget.test.ts`.
370
+ */
371
+ max_run_cost: v.optional(v.pipe(v.number(), v.gtValue(0))),
372
+ max_run_tokens: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
307
373
  });
308
374
  /**
309
375
  * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
@@ -403,6 +469,22 @@ export const WatchConfigSchema = v.object({
403
469
  base_branch: v.optional(v.string(), "main"),
404
470
  poll_ms: v.optional(v.number(), 60_000),
405
471
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
472
+ /**
473
+ * Threaded straight through as `runChainDef`'s `options` argument for
474
+ * every unattended `spf watch` dispatch (build lane AND refine lane) —
475
+ * the exact same `Record<string, string>` shape an interactive `spf
476
+ * <chain> --suite <name>` builds in `cli/commands/run.ts`'s
477
+ * `dispatchChain` (e.g. `{suite: "strict"}`, `{agent: "..."}`). Fixes the
478
+ * KNOWN LIMITATION called out in PR #20: `cli/commands/watch.ts`'s
479
+ * `runChain`/`runRefine` wrappers used to call `runChainDef` with no
480
+ * options at all, so nothing --suite-shaped could ever reach a chain run
481
+ * `spf watch` dispatched — see `cli/commands/watch.ts`. Empty by default,
482
+ * so an existing `watch:` config's behavior is unchanged by upgrading.
483
+ * Whole-object replace on merge, like `jira`/`refine` above and
484
+ * `observability.otel` — see `agents.ts`'s `mergeRawConfig` and
485
+ * `data_types.test.ts`'s merge-survival test for this field.
486
+ */
487
+ chain_options: v.optional(v.record(v.string(), v.string()), () => ({})),
406
488
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
407
489
  refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
408
490
  });
@@ -462,6 +544,58 @@ export const ReviewConfigSchema = v.object({
462
544
  require_human_signoff: v.optional(v.boolean(), false),
463
545
  signoff_timeout_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 300),
464
546
  });
547
+ /**
548
+ * Risk-tiered per-role model routing — SPF #14. One rung of a ladder:
549
+ * `name` is what `tiering.roles` points at, `model` speaks EXACTLY the same
550
+ * vocabulary as an agent's own `model:` for that backend (provider/model-id
551
+ * for flue, Claude Code's bare alias/full-name for claude_code) — there is
552
+ * deliberately no per-provider table, since for flue the provider is
553
+ * already the first segment of the value.
554
+ *
555
+ * `coding_agent` declares WHICH BACKEND'S VOCABULARY this rung's `model`
556
+ * speaks — same picklist and same default as `AgentConfigSchema`'s own
557
+ * field (`:409`), reused rather than restated. A tier changes an agent's
558
+ * `model` and NOTHING else (`coding_agent` stays the agent's own, always),
559
+ * so a rung can only route roles whose `coding_agent` matches its own — see
560
+ * `core/tiering.ts`'s rule T, enforced by `agents.ts`'s `validate()`.
561
+ */
562
+ export const TierSchema = v.object({
563
+ name: v.pipe(v.string(), v.minLength(1)),
564
+ coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
565
+ model: v.pipe(v.string(), v.minLength(1)),
566
+ });
567
+ /**
568
+ * OFF by default, and `enabled: false`/absent is a TOTAL no-op: every agent
569
+ * dispatches at exactly the model its roster entry names, byte-identical to
570
+ * before this key existed — same discipline `max_run_cost`/`max_run_tokens`
571
+ * hold themselves to (`ConfigDefaultsSchema`'s own comment above).
572
+ *
573
+ * TOP-LEVEL (`SFConfigSchema` below), deliberately NOT nested under
574
+ * `defaults:` — `agents.ts`'s `loadConfig` back-fill loop copies a FIXED
575
+ * list of `defaults` keys DOWN onto every agent that hasn't set them, and
576
+ * `AgentConfigSchema` is a non-strict `v.object`, so a stray copy would be
577
+ * silently STRIPPED at parse rather than rejected (see
578
+ * `ConfigDefaultsSchema`'s "THE BACK-FILL TRAP" comment — `max_run_cost`/
579
+ * `max_run_tokens` needed an explicit never-add-this-to-the-list comment to
580
+ * survive exactly that trap). A top-level key sits outside that loop
581
+ * entirely, so the trap cannot apply here at all.
582
+ *
583
+ * `tiers` is the ladder, WEAKEST FIRST: a risk level shifts every routed
584
+ * role UP or DOWN this list by the same step, so order is the whole
585
+ * semantics — a SEQUENCE states that unambiguously where a mapping's key
586
+ * order would be parser-dependent.
587
+ *
588
+ * `roles` is the baseline tier per ROLE (an agent name). Naming an agent
589
+ * here IS the operator's statement "route this one by tier" — so the
590
+ * resolved tier wins over that agent's own `model:`. An agent NOT named
591
+ * here is never retiered: its `model:` stands, untouched. See
592
+ * `core/tiering.ts`'s `resolveTiering` for the full precedence rule.
593
+ */
594
+ export const TieringConfigSchema = v.object({
595
+ enabled: v.optional(v.boolean(), false),
596
+ tiers: v.optional(v.array(TierSchema), () => []),
597
+ roles: v.optional(v.record(v.string(), v.string()), () => ({})),
598
+ });
465
599
  export const SFConfigSchema = v.object({
466
600
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
467
601
  observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
@@ -470,6 +604,7 @@ export const SFConfigSchema = v.object({
470
604
  watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
471
605
  notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
472
606
  review: v.optional(ReviewConfigSchema, () => v.parse(ReviewConfigSchema, {})),
607
+ tiering: v.optional(TieringConfigSchema, () => v.parse(TieringConfigSchema, {})),
473
608
  });
474
609
  // ── Tracing ──────────────────────────────────────────────────────────────────
475
610
  /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Best-of-N: run ONE prompt N times in parallel, then let CODE pick the winner.
3
+ *
4
+ * THE SHAPE IS NOT NEW, and that is the whole argument for it. `spf watch`
5
+ * already runs N chains CONCURRENTLY IN ONE PROCESS — each with its own git
6
+ * worktree, its own adw_id, its own `Run`, all sharing the main repo's WAL
7
+ * SQLite through a data-dir symlink (see `core/watch.ts`'s `runIssue`,
8
+ * `cli/commands/watch.ts`'s `runChain` closure and its `linkDataDir`). Fan-out
9
+ * is that proven mechanism pointed at one prompt instead of N issues: N
10
+ * sibling Runs with derived adw_ids over N worktrees, followed by a
11
+ * DETERMINISTIC `pickBest`. "Agent proposes, code disposes" — extended to
12
+ * SELECTION, which is the only new idea here.
13
+ *
14
+ * THE UNIT OF ISOLATION IS THE RUN, NEVER AN AGENT CALL. Every attempt gets a
15
+ * fresh worktree and a fresh Run whose `repo_root` is bound once, in the Run
16
+ * constructor. That is what keeps `gates.resolveClaim`,
17
+ * `permissions.snapshot/enforce`, the quality checks and `changes.capture`
18
+ * judging the tree their agents actually wrote in. A per-call cwd swap would
19
+ * point them at the wrong tree and hand out green gates on unverified work; a
20
+ * `SandboxHandle`/cwd-threading design was evaluated for exactly this and
21
+ * REJECTED. Do not reintroduce it.
22
+ *
23
+ * SELECTION READS SQLITE, NOT RETURN VALUES. Gate reports (`tracer.gateRow`)
24
+ * and per-run usage (`sessions.total_tokens`/`total_cost`) already land in the
25
+ * shared db keyed by adw_id, so the basis below is a SELECT away. Widening
26
+ * `steps.runSteps`' return contract to carry metrics back was evaluated and
27
+ * REJECTED as needless blast radius: every chain, every step, and the watch
28
+ * dispatch would change to serve one new caller. `runAttempt` still returns
29
+ * just an exit code, exactly like `runChainDef` does.
30
+ *
31
+ * WHY THE BUDGET SHIPS WITH THIS. An n-multiplier on spend without a ceiling
32
+ * is a footgun — the only budget-like constant before this changeset was
33
+ * `JSON_FIX_ATTEMPTS = 2`. `defaults.max_run_cost`/`max_run_tokens`
34
+ * (`core/agents.ts`'s `assertRunBudget`) are checked before each attempt's
35
+ * NEXT agent call, never after — so one attempt can still overshoot the
36
+ * ceiling by a whole call, and a chain with only ONE agent dispatch
37
+ * (`scout`, `prompt`, `build`) can never trip it at all. With that caveat, N
38
+ * attempts are bounded by roughly N × ceiling, and each one that DOES trip
39
+ * it fails its phase closed with a message naming the ceiling and the spend.
40
+ *
41
+ * WINNER HANDLING KEEPS THE HUMAN ACCOUNTABLE. Each attempt commits on its own
42
+ * branch in its own worktree. The winner's branch AND worktree are KEPT and
43
+ * reported (branch + adw_id + the selection basis); the losers' worktrees and
44
+ * branches are deleted. SPF DOES NOT MERGE THE WINNER — the operator does.
45
+ * Keeping the winner's worktree is deliberate, not laziness: a chain that
46
+ * doesn't commit (or that failed after writing) leaves work in the tree only,
47
+ * and `git worktree remove --force` would destroy it. `git merge`/
48
+ * `git cherry-pick` from the main repo work fine against a branch that is
49
+ * checked out elsewhere, so nothing is blocked by keeping it.
50
+ *
51
+ * ABORT GRANULARITY, stated plainly because it is a real limit — and, as of
52
+ * `deps.firstSuccess`, OPT-IN rather than the default. Best-of-N exists to
53
+ * COMPARE N candidates; the DEFAULT (`firstSuccess` unset/false) therefore
54
+ * dispatches every attempt regardless of how early one succeeds, so the
55
+ * gates/cost/tokens comparison in `pickBest` is over the whole field the
56
+ * operator actually asked for, not "whatever happened to still be queued."
57
+ * Passing `firstSuccess: true` restores first-past-the-post: once an attempt
58
+ * exits 0 the race is decided and no further attempt is STARTED —
59
+ * `deps.runAttempt` is never called for a queued attempt, which is where the
60
+ * saving actually is (with `concurrency` 2 and `--n 4`, half the attempts are
61
+ * typically never dispatched). Either way, attempts ALREADY IN FLIGHT run to
62
+ * completion: `steps.runSteps` has no cancellation seam, and adding one would
63
+ * mean changing the driver every chain shares for the benefit of this one
64
+ * caller. `AttemptDispatch.isAborted()` is threaded to the runner so a
65
+ * dispatch that CAN check between phases does when `firstSuccess` is on, and
66
+ * so a test can prove the flag is honored — cooperative by design, not by
67
+ * accident.
68
+ */
69
+ import { type GitHandle } from "./git_helper.ts";
70
+ /** Sentinel exit code meaning "never dispatched — the race was already decided". Distinct from any real chain exit code. */
71
+ export declare const ABORTED_EXIT = -1;
72
+ /** What one attempt's metrics look like, read back from the shared SQLite by adw_id. */
73
+ export interface AttemptMetrics {
74
+ /** `gate_results` rows with passed=1 for this adw_id. */
75
+ gate_passes: number;
76
+ /** `gate_results` rows with passed=0. Zero is the bar; a nonzero count is a retried/corrected run. */
77
+ gate_failures: number;
78
+ /** `sessions.total_cost` — USD. */
79
+ cost: number;
80
+ /** `sessions.total_tokens` — the SPEND number (cached re-reads included). */
81
+ tokens: number;
82
+ }
83
+ export declare const ZERO_METRICS: AttemptMetrics;
84
+ /**
85
+ * `"success"` means, and only means, EXIT CODE 0 — the same bar
86
+ * `cli/commands/watch.ts` applies before it opens a PR. Not the session row's
87
+ * status (which a killed run can leave stale), not the agent's own opinion of
88
+ * itself. Only a `"success"` attempt is eligible to win.
89
+ */
90
+ export type AttemptStatus = "success" | "fail" | "error" | "skipped";
91
+ export interface FanoutAttempt {
92
+ /** 1-based, matching the branch/adw_id suffix a human reads. */
93
+ index: number;
94
+ adw_id: string;
95
+ branch: string;
96
+ worktree: string;
97
+ status: AttemptStatus;
98
+ /** `null` when the dispatch threw before producing one; `ABORTED_EXIT` for a skipped attempt. */
99
+ exit_code: number | null;
100
+ error: string | null;
101
+ gate_passes: number;
102
+ gate_failures: number;
103
+ cost: number;
104
+ tokens: number;
105
+ wall_ms: number;
106
+ /** True only for the winner: its worktree and branch survived cleanup. */
107
+ kept: boolean;
108
+ }
109
+ export interface FanoutResult {
110
+ /** `null` when no attempt exited 0 — nothing is kept, and the CLI exits non-zero. */
111
+ winner: FanoutAttempt | null;
112
+ /** The printed, human-readable justification. Derived from the same comparator that chose the winner. */
113
+ basis: string;
114
+ /** Every attempt, in index order — including the ones never dispatched. */
115
+ attempts: FanoutAttempt[];
116
+ }
117
+ /** `<baseAdw>-<i>` — a derived, greppable sibling of the base id. */
118
+ export declare function attemptAdwId(baseAdwId: string, index: number): string;
119
+ /**
120
+ * `spf/fanout/<baseAdw>-<i>` — namespaced so the winner is obvious in
121
+ * `git branch` and the losers are obviously disposable. Separate from
122
+ * `spf-watch/*`/`spf-refine/*` (see `core/watch.ts`) because these branches
123
+ * are never pushed by SPF: a human merges the winner from the local ref.
124
+ */
125
+ export declare function attemptBranch(baseAdwId: string, index: number): string;
126
+ /** The worktree leaf name. Deterministic from the adw_id, like watch's `issue-<id>`. */
127
+ export declare function attemptWorktreeName(baseAdwId: string, index: number): string;
128
+ export declare function attemptWorktreePath(worktreesDir: string, baseAdwId: string, index: number): string;
129
+ /**
130
+ * The selection basis, in order. Every criterion is read from the shared
131
+ * SQLite or measured by this module — never asked of an agent.
132
+ *
133
+ * 1. the run SUCCEEDED (exit 0). A failed attempt is not a candidate at all.
134
+ * 2. fewest gate FAILURES, then most gate PASSES. Failures first because zero
135
+ * is the bar: an attempt that needed a correction round is worse than one
136
+ * that never tripped a gate, regardless of how many gates each ran. Passes
137
+ * break that tie in favour of the attempt that VERIFIED more.
138
+ * 3. lowest total cost.
139
+ * 4. fewest tokens.
140
+ * 5. lowest wall time — a LAST RESORT, deliberately below cost and tokens:
141
+ * every code phase runs through `spawnSync` (`core/quality.ts`), which
142
+ * blocks the shared event loop, so "concurrent" attempts partly serialize
143
+ * on exactly the phases fan-out means to compare, and `wall_ms` ends up
144
+ * favouring whichever attempt happened to run last by a scheduling
145
+ * accident rather than anything about the candidate. Cost and tokens are
146
+ * read from the trace and are not subject to that; wall time still
147
+ * breaks a genuine tie between two runs identical on both.
148
+ * 6. lexicographic adw_id.
149
+ *
150
+ * (6) exists to make this a TOTAL order: adw_ids are unique per attempt, so
151
+ * the comparator can never return 0 for two different attempts, so the winner
152
+ * cannot depend on which attempt happened to finish first. That is what
153
+ * "deterministic" has to mean here — pinned by a permutation test in
154
+ * `src/test/fanout.test.ts`.
155
+ */
156
+ export declare function compareAttempts(a: FanoutAttempt, b: FanoutAttempt): number;
157
+ /**
158
+ * Pick the winner. PURE: no I/O, no clock, no filesystem — everything it
159
+ * needs is already on the attempts, which is what makes the selection
160
+ * testable and reproducible from a trace.
161
+ *
162
+ * Sorts a COPY: the caller's array keeps its index order for printing.
163
+ */
164
+ export declare function pickBest(attempts: FanoutAttempt[]): {
165
+ winner: FanoutAttempt | null;
166
+ basis: string;
167
+ };
168
+ /** What `deps.runAttempt` is handed for one attempt. */
169
+ export interface AttemptDispatch {
170
+ index: number;
171
+ adwId: string;
172
+ /** The attempt's OWN worktree — the anchor its Run binds `repo_root` to. Never the main repo. */
173
+ cwd: string;
174
+ branch: string;
175
+ prompt: string;
176
+ /** Cooperative early-abort — see the module header's ABORT GRANULARITY note. */
177
+ isAborted: () => boolean;
178
+ }
179
+ /**
180
+ * Everything `runBestOf` needs, injected — the same seam `WatchDeps` is, and
181
+ * for the same reason: `core/` stays out of `src/chains/`'s dependency
182
+ * direction, so the chain dispatch (and the SQLite read) live in
183
+ * `cli/commands/fanout.ts` and this module is testable against stubs with no
184
+ * agents, no chains and no real git.
185
+ */
186
+ export interface FanoutDeps {
187
+ chainName: string;
188
+ prompt: string;
189
+ /** How many attempts. Bounded by the CLI, not here. */
190
+ n: number;
191
+ /** How many run at once. `cfg.watch.concurrency` (default 2) is the reused default. */
192
+ concurrency: number;
193
+ /** The parent id every attempt's adw_id and branch derives from. */
194
+ baseAdwId: string;
195
+ baseBranch: string;
196
+ /** The MAIN repo root — every worktree is created from here, and every Run is anchored to its own worktree. */
197
+ repoRoot: string;
198
+ /** Absolute, OUTSIDE the repo. */
199
+ worktreesDir: string;
200
+ /** Bound to `repoRoot`. */
201
+ git: GitHandle;
202
+ /** Wire `<worktree>/.spf/data` to the main repo's data dir — the non-obvious part that keeps traces where `spf ui` can see them. */
203
+ linkDataDir: (worktreePath: string) => void;
204
+ /** Run one attempt's chain in `cwd`, returning its exit code. */
205
+ runAttempt: (dispatch: AttemptDispatch) => Promise<number>;
206
+ /** Read this adw_id's gate/usage rows from the SHARED db. Must not throw — return `ZERO_METRICS` on any hiccup. */
207
+ readMetrics: (adwId: string) => AttemptMetrics;
208
+ log: (message: string) => void;
209
+ /**
210
+ * Opt-in first-past-the-post. Default (unset/false): every attempt that
211
+ * was going to run, runs — true best-of-N. `true`: stop dispatching
212
+ * further attempts once one exits 0, and let an in-flight one observe the
213
+ * decided race via `AttemptDispatch.isAborted()`. See the module header's
214
+ * ABORT GRANULARITY note.
215
+ */
216
+ firstSuccess?: boolean;
217
+ }
218
+ /**
219
+ * Run `n` attempts of one prompt, select deterministically, clean up the
220
+ * losers, keep the winner.
221
+ *
222
+ * Concurrency is the same inflight-budget idea as `watch.claimNewWork`,
223
+ * expressed as a fixed worker pool over a shared queue: at most
224
+ * `concurrency` attempts are ever in flight, and a worker that finds the race
225
+ * already decided records the remaining attempts as `skipped` WITHOUT
226
+ * dispatching them. Attempts are returned in index order regardless of the
227
+ * order they finished — the printed table must not shuffle between runs.
228
+ */
229
+ export declare function runBestOf(deps: FanoutDeps): Promise<FanoutResult>;