@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.
- package/README.md +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- 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 +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- 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 +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- 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 +48 -2
- 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 +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- 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/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- 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 +174 -11
- package/dist/core/watch.js +531 -33
- 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/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- 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
|
@@ -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
|
-
/**
|
|
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
|
|
387
|
-
* `
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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>;
|