@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.
- package/README.md +61 -17
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +32 -6
- package/assets/prompts/refiner/user.md +37 -5
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +153 -4
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +6 -0
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +27 -9
- package/dist/chains/steps.js +82 -13
- package/dist/cli/commands/doctor.js +108 -1
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.js +51 -26
- package/dist/cli/index.js +12 -0
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +232 -0
- package/dist/core/data_types.js +135 -0
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +24 -1
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +7 -1
- package/dist/core/issues/github_provider.js +33 -2
- package/dist/core/issues/jira_provider.d.ts +5 -1
- package/dist/core/issues/jira_provider.js +26 -3
- package/dist/core/issues/provider.d.ts +53 -2
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +89 -9
- package/dist/core/watch.js +193 -19
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
package/dist/core/data_types.js
CHANGED
|
@@ -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>;
|