@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
@@ -117,6 +117,23 @@ export const DocumentOutput = envelopeType("DocumentOutput", {
117
117
  * this list before publish ever runs (unique keys, resolvable references,
118
118
  * no cycles, container/leaf kind agreement, at least one leaf).
119
119
  */
120
+ /**
121
+ * `p0` (drop everything) .. `p3` (someday) — see `assets/prompts/refiner/system.md`'s
122
+ * `## Priority` section for what each rung means. Two rules the schema alone
123
+ * can't enforce, checked instead by `gates.refinementWellFormed`
124
+ * (monotonicity: no node outranks its `parent`) and `core/refine.ts`'s
125
+ * `publish()` (the spec's own priority, when known, is a ceiling clamped onto
126
+ * every node): see both files' doc comments.
127
+ */
128
+ export const RefinedPrioritySchema = v.picklist(["p0", "p1", "p2", "p3"]);
129
+ /** 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. */
130
+ export const PRIORITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
131
+ /** `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. */
132
+ export function clampPriority(priority, ceiling) {
133
+ if (!ceiling)
134
+ return priority;
135
+ return PRIORITY_RANK[priority] < PRIORITY_RANK[ceiling] ? ceiling : priority;
136
+ }
120
137
  export const RefinedIssueSchema = v.object({
121
138
  key: v.string(),
122
139
  kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
@@ -124,10 +141,33 @@ export const RefinedIssueSchema = v.object({
124
141
  body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
125
142
  parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
126
143
  blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
144
+ priority: v.optional(RefinedPrioritySchema, "p2"), // what spf watch's build lane schedules by — see RefinedPrioritySchema
145
+ });
146
+ /**
147
+ * One open question the refiner could not answer itself — material ambiguity
148
+ * (scope, data model, an external dependency choice, a UX contract, anything
149
+ * that would contradict an ADR) it is refusing to guess on, per
150
+ * `assets/prompts/refiner/system.md`'s "ask, don't decide" rule. `id` is
151
+ * stable within one round so a resumed run's answers can be matched back to
152
+ * the question they answer; `why_it_matters`/`options`/`recommendation`/
153
+ * `evidence` exist so a human can answer in one word ("go with your rec")
154
+ * instead of re-deriving the tradeoff the refiner already worked out.
155
+ * `gates.refinementWellFormed` requires `issues` to be empty whenever this is
156
+ * non-empty — escalating means publishing nothing THIS round, never a
157
+ * partial tree pinned to an unanswered question.
158
+ */
159
+ export const RefineQuestionSchema = v.object({
160
+ id: v.string(),
161
+ question: v.string(),
162
+ why_it_matters: v.optional(v.string(), ""),
163
+ options: v.optional(v.array(v.string()), () => []),
164
+ recommendation: v.optional(v.string(), ""),
165
+ evidence: v.optional(v.array(v.string()), () => []),
127
166
  });
128
167
  /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
129
168
  export const RefineOutput = envelopeType("RefineOutput", {
130
169
  issues: v.optional(v.array(RefinedIssueSchema), () => []),
170
+ questions: v.optional(v.array(RefineQuestionSchema), () => []),
131
171
  });
132
172
  // ── Deterministic quality blocks ─────────────────────────────────────────────
133
173
  export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
@@ -304,6 +344,50 @@ export const ConfigDefaultsSchema = v.object({
304
344
  // .spf/ is the whole per-repo footprint now — no adws/ tree to protect.
305
345
  protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
306
346
  data_dir: v.optional(v.string(), ".spf/data"),
347
+ /**
348
+ * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
349
+ *
350
+ * BOTH ABSENT BY DEFAULT, and absence is a total no-op: `agents.ts`'s
351
+ * `assertRunBudget()` returns immediately when neither is set, so every
352
+ * existing config behaves byte-identically to before this field existed.
353
+ * There is no ambient environment variable and no implicit default — an
354
+ * unbounded run stays the default because a surprise mid-run failure on a
355
+ * ceiling nobody chose is worse than the spend.
356
+ *
357
+ * SCOPE IS THE RUN, NOT THE CALL. Enforced against the Run's ACCUMULATED
358
+ * usage (`run.tokens`/`run.cost` — the same totals `run.addUsage()` mirrors
359
+ * into the sessions row), checked BEFORE every agent dispatch including
360
+ * every JSON-repair retry and every gate correction, because the cost of
361
+ * the call about to happen is unknowable in advance. That makes these a
362
+ * hard cap on FURTHER spend rather than a post-hoc report: reaching the
363
+ * ceiling stops the next call, it does not merely note that the last one
364
+ * was expensive. A tripped ceiling fails the phase closed — see
365
+ * `agents.ts`'s `BudgetExceeded`.
366
+ *
367
+ * `max_run_cost` is USD (the same unit the provider's own usage.cost
368
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
369
+ * tokens, i.e. the spend number — every turn re-sends the whole
370
+ * conversation, so this counts cached re-reads too, exactly like the
371
+ * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
372
+ * for why that number is much larger than "material moved").
373
+ *
374
+ * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
375
+ * run", which is a config mistake, not a budget — it would fail the first
376
+ * phase of every chain with a budget message instead of saying what is
377
+ * actually wrong.
378
+ *
379
+ * THE BACK-FILL TRAP (see `agents.ts`'s `loadConfig`): that function copies
380
+ * a handful of `defaults` keys DOWN onto each agent that hasn't set them
381
+ * (coding_agent/model/thinking/color/tools/writes/env_allowlist). These two
382
+ * keys are deliberately NOT in that list and must never be added to it —
383
+ * they are RUN-scoped ceilings, not per-agent settings, and a per-agent
384
+ * copy would read as "each agent may spend this much", which is a
385
+ * different (and unenforced) feature. `mergeRawConfig`'s `defaults` spread
386
+ * is what carries them through config layering, key-by-key; pinned by
387
+ * `src/test/budget.test.ts`.
388
+ */
389
+ max_run_cost: v.optional(v.pipe(v.number(), v.gtValue(0))),
390
+ max_run_tokens: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
307
391
  });
308
392
  /**
309
393
  * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
@@ -373,20 +457,47 @@ export const ObservabilityConfigSchema = v.object({
373
457
  */
374
458
  export const WatchIssueProviderSchema = v.picklist(["github", "jira"]);
375
459
  export const WatchCodeHostSchema = v.picklist(["github", "bitbucket"]);
376
- /** Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` + `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. */
460
+ /**
461
+ * What each `RefinedIssue.kind` creates as, on Jira — Jira's create endpoint
462
+ * requires a real `issuetype`, and project setups vary (renamed types,
463
+ * non-English instances, custom schemes), so this is a name -> name map,
464
+ * not a hardcoded assumption. Every field defaults independently: a config
465
+ * that only sets `bug: Defect` still gets Epic/Epic/Story/Task for the
466
+ * other four. `jira_provider.ts`'s `createIssue`/`validateIssueTypes` are
467
+ * the readers; `spf watch init` and `spf watch`'s own startup check
468
+ * (`cli/commands/watch.ts`) both validate this against the real project
469
+ * before anything unattended runs on it.
470
+ */
471
+ export const JiraIssueTypeMapSchema = v.object({
472
+ epic: v.optional(v.string(), "Epic"),
473
+ feature: v.optional(v.string(), "Epic"),
474
+ story: v.optional(v.string(), "Story"),
475
+ bug: v.optional(v.string(), "Bug"),
476
+ task: v.optional(v.string(), "Task"),
477
+ });
478
+ /**
479
+ * Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
480
+ * `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
481
+ * object replace on config-file-layer merge, like `refine`/
482
+ * `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
483
+ * file that touches `watch.jira` at all must repeat `issue_types` too if it
484
+ * wants to keep a customized mapping, same caveat that already applies to
485
+ * `base_url`/`project_key` today.
486
+ */
377
487
  export const WatchJiraConfigSchema = v.object({
378
488
  base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
379
489
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
490
+ issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
380
491
  });
381
492
  /**
382
493
  * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
383
494
  * spec into a feature/story tree of real issues, instead of running
384
495
  * `watch.chain` against it directly (a spec is not individually workable —
385
496
  * see `core/refine.ts`). Off by default so an existing `watch:` config's
386
- * behavior is unchanged by upgrading; turning it on with
387
- * `issue_provider: jira` fails loudly at `spf watch` startup, since
388
- * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
389
- * yet see its module comment.
497
+ * behavior is unchanged by upgrading. Needs `issue_provider: github` or
498
+ * `"jira"` both implement `IssueAuthoringProvider` (create/link/list)
499
+ * any other value fails loudly at `spf watch` startup rather than running a
500
+ * refine lane that can never publish anything.
390
501
  */
391
502
  export const WatchRefineConfigSchema = v.object({
392
503
  enabled: v.optional(v.boolean(), false),
@@ -403,6 +514,22 @@ export const WatchConfigSchema = v.object({
403
514
  base_branch: v.optional(v.string(), "main"),
404
515
  poll_ms: v.optional(v.number(), 60_000),
405
516
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
517
+ /**
518
+ * Threaded straight through as `runChainDef`'s `options` argument for
519
+ * every unattended `spf watch` dispatch (build lane AND refine lane) —
520
+ * the exact same `Record<string, string>` shape an interactive `spf
521
+ * <chain> --suite <name>` builds in `cli/commands/run.ts`'s
522
+ * `dispatchChain` (e.g. `{suite: "strict"}`, `{agent: "..."}`). Fixes the
523
+ * KNOWN LIMITATION called out in PR #20: `cli/commands/watch.ts`'s
524
+ * `runChain`/`runRefine` wrappers used to call `runChainDef` with no
525
+ * options at all, so nothing --suite-shaped could ever reach a chain run
526
+ * `spf watch` dispatched — see `cli/commands/watch.ts`. Empty by default,
527
+ * so an existing `watch:` config's behavior is unchanged by upgrading.
528
+ * Whole-object replace on merge, like `jira`/`refine` above and
529
+ * `observability.otel` — see `agents.ts`'s `mergeRawConfig` and
530
+ * `data_types.test.ts`'s merge-survival test for this field.
531
+ */
532
+ chain_options: v.optional(v.record(v.string(), v.string()), () => ({})),
406
533
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
407
534
  refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
408
535
  });
@@ -462,6 +589,58 @@ export const ReviewConfigSchema = v.object({
462
589
  require_human_signoff: v.optional(v.boolean(), false),
463
590
  signoff_timeout_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 300),
464
591
  });
592
+ /**
593
+ * Risk-tiered per-role model routing — SPF #14. One rung of a ladder:
594
+ * `name` is what `tiering.roles` points at, `model` speaks EXACTLY the same
595
+ * vocabulary as an agent's own `model:` for that backend (provider/model-id
596
+ * for flue, Claude Code's bare alias/full-name for claude_code) — there is
597
+ * deliberately no per-provider table, since for flue the provider is
598
+ * already the first segment of the value.
599
+ *
600
+ * `coding_agent` declares WHICH BACKEND'S VOCABULARY this rung's `model`
601
+ * speaks — same picklist and same default as `AgentConfigSchema`'s own
602
+ * field (`:409`), reused rather than restated. A tier changes an agent's
603
+ * `model` and NOTHING else (`coding_agent` stays the agent's own, always),
604
+ * so a rung can only route roles whose `coding_agent` matches its own — see
605
+ * `core/tiering.ts`'s rule T, enforced by `agents.ts`'s `validate()`.
606
+ */
607
+ export const TierSchema = v.object({
608
+ name: v.pipe(v.string(), v.minLength(1)),
609
+ coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
610
+ model: v.pipe(v.string(), v.minLength(1)),
611
+ });
612
+ /**
613
+ * OFF by default, and `enabled: false`/absent is a TOTAL no-op: every agent
614
+ * dispatches at exactly the model its roster entry names, byte-identical to
615
+ * before this key existed — same discipline `max_run_cost`/`max_run_tokens`
616
+ * hold themselves to (`ConfigDefaultsSchema`'s own comment above).
617
+ *
618
+ * TOP-LEVEL (`SFConfigSchema` below), deliberately NOT nested under
619
+ * `defaults:` — `agents.ts`'s `loadConfig` back-fill loop copies a FIXED
620
+ * list of `defaults` keys DOWN onto every agent that hasn't set them, and
621
+ * `AgentConfigSchema` is a non-strict `v.object`, so a stray copy would be
622
+ * silently STRIPPED at parse rather than rejected (see
623
+ * `ConfigDefaultsSchema`'s "THE BACK-FILL TRAP" comment — `max_run_cost`/
624
+ * `max_run_tokens` needed an explicit never-add-this-to-the-list comment to
625
+ * survive exactly that trap). A top-level key sits outside that loop
626
+ * entirely, so the trap cannot apply here at all.
627
+ *
628
+ * `tiers` is the ladder, WEAKEST FIRST: a risk level shifts every routed
629
+ * role UP or DOWN this list by the same step, so order is the whole
630
+ * semantics — a SEQUENCE states that unambiguously where a mapping's key
631
+ * order would be parser-dependent.
632
+ *
633
+ * `roles` is the baseline tier per ROLE (an agent name). Naming an agent
634
+ * here IS the operator's statement "route this one by tier" — so the
635
+ * resolved tier wins over that agent's own `model:`. An agent NOT named
636
+ * here is never retiered: its `model:` stands, untouched. See
637
+ * `core/tiering.ts`'s `resolveTiering` for the full precedence rule.
638
+ */
639
+ export const TieringConfigSchema = v.object({
640
+ enabled: v.optional(v.boolean(), false),
641
+ tiers: v.optional(v.array(TierSchema), () => []),
642
+ roles: v.optional(v.record(v.string(), v.string()), () => ({})),
643
+ });
465
644
  export const SFConfigSchema = v.object({
466
645
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
467
646
  observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
@@ -470,6 +649,7 @@ export const SFConfigSchema = v.object({
470
649
  watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
471
650
  notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
472
651
  review: v.optional(ReviewConfigSchema, () => v.parse(ReviewConfigSchema, {})),
652
+ tiering: v.optional(TieringConfigSchema, () => v.parse(TieringConfigSchema, {})),
473
653
  });
474
654
  // ── Tracing ──────────────────────────────────────────────────────────────────
475
655
  /** 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>;