@gr8ful/spf 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +51 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/paths.d.ts +3 -0
  46. package/dist/core/paths.js +48 -1
  47. package/dist/core/runner.d.ts +8 -0
  48. package/dist/core/runner.js +7 -0
  49. package/dist/core/session.d.ts +25 -0
  50. package/dist/core/session.js +97 -28
  51. package/dist/core/tiering.d.ts +145 -0
  52. package/dist/core/tiering.js +235 -0
  53. package/dist/core/tracer.d.ts +10 -0
  54. package/dist/core/tracer.js +12 -0
  55. package/dist/core/watch.d.ts +89 -9
  56. package/dist/core/watch.js +193 -19
  57. package/dist/ui/server/db.d.ts +39 -0
  58. package/dist/ui/server/db.js +61 -0
  59. package/package.json +2 -1
  60. package/dist/test/agent_cc.test.d.ts +0 -1
  61. package/dist/test/agent_cc.test.js +0 -95
  62. package/dist/test/agent_flue.test.d.ts +0 -1
  63. package/dist/test/agent_flue.test.js +0 -83
  64. package/dist/test/chains.test.d.ts +0 -12
  65. package/dist/test/chains.test.js +0 -92
  66. package/dist/test/data_types.test.d.ts +0 -10
  67. package/dist/test/data_types.test.js +0 -220
  68. package/dist/test/env_file.test.d.ts +0 -1
  69. package/dist/test/env_file.test.js +0 -74
  70. package/dist/test/fake_asker.d.ts +0 -23
  71. package/dist/test/fake_asker.js +0 -30
  72. package/dist/test/git_helper.test.d.ts +0 -1
  73. package/dist/test/git_helper.test.js +0 -59
  74. package/dist/test/hermetic_git.d.ts +0 -1
  75. package/dist/test/hermetic_git.js +0 -22
  76. package/dist/test/init_command.test.d.ts +0 -14
  77. package/dist/test/init_command.test.js +0 -136
  78. package/dist/test/interview.test.d.ts +0 -15
  79. package/dist/test/interview.test.js +0 -425
  80. package/dist/test/notify.test.d.ts +0 -1
  81. package/dist/test/notify.test.js +0 -174
  82. package/dist/test/ollama_provider.test.d.ts +0 -1
  83. package/dist/test/ollama_provider.test.js +0 -103
  84. package/dist/test/otel.test.d.ts +0 -26
  85. package/dist/test/otel.test.js +0 -512
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -0,0 +1,401 @@
1
+ /**
2
+ * `spf fanout <chain> "<prompt>" --n 3` — best-of-N, disposed by code.
3
+ *
4
+ * The wiring half of `core/fanout.ts` (which holds the mechanism and the
5
+ * selection basis): config, the chain-dispatch callback, the data-dir symlink,
6
+ * the SQLite metrics read, and the result table. Same split as
7
+ * `core/watch.ts` / `cli/commands/watch.ts`, for the same reason — `core/`
8
+ * stays out of `src/chains/`'s dependency direction, so the fan-out mechanism
9
+ * is testable with no chains, no agents and no real git.
10
+ *
11
+ * A REAL SUBCOMMAND, NOT A `--best-of` FLAG ON `run`. Fan-out is a different
12
+ * verb: it creates N branches and N worktrees, keeps one, and deletes the
13
+ * rest. Hiding that behind a flag on `spf run` would make an ordinary run
14
+ * silently capable of deleting branches, and would have to answer what
15
+ * `--adw-id` even means when there are N of them. `run.ts` is untouched.
16
+ *
17
+ * SPF DOES NOT MERGE THE WINNER. It hands back a branch name, an adw_id and
18
+ * the basis on which that branch was chosen; a human merges or cherry-picks
19
+ * it. That is the accountability line this command must not cross — every
20
+ * other route (auto-merge, auto-push) removes the one human decision that
21
+ * makes N machine-written candidates safe to have produced at all.
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import path from "node:path";
27
+ import * as agents from "../../core/agents.js";
28
+ import * as paths from "../../core/paths.js";
29
+ import { isRepoAt, makeGit } from "../../core/git_helper.js";
30
+ import { ABORTED_EXIT, attemptAdwId, runBestOf, ZERO_METRICS, } from "../../core/fanout.js";
31
+ import { findChain, runChain as runChainDef } from "../../chains/index.js";
32
+ import { SfDb } from "../../ui/server/db.js";
33
+ import { newId, parseCli, resolvePrompt } from "../../core/utils.js";
34
+ /**
35
+ * A ceiling on `--n`, not a recommendation. Each attempt is a full chain run
36
+ * with its own agents, so `--n` multiplies spend directly — and the machine
37
+ * also pays in worktrees, concurrent SQLite writers and disk. Anyone who
38
+ * genuinely wants more than this should be running a script that says so out
39
+ * loud, not typing a bigger number into an interactive command.
40
+ */
41
+ const MAX_N = 8;
42
+ const USAGE = `usage: spf fanout <chain> "<prompt or path/to/prompt.md>" [--n 3] [--concurrency N] ` +
43
+ `[--base <branch>] [--adw-id <id>] [--first-success] [--config <path>] [--cwd <dir>]\n` +
44
+ ` spf fanout --clean <base-adw-id> [--cwd <dir>] # remove leftover worktrees/branches from a killed or discarded run`;
45
+ /** Chains eligible to fan out: their derived phase string must show at least one commit step. See the check at dispatch time for why. */
46
+ function hasCommitStep(phases) {
47
+ return phases.includes("git(commit");
48
+ }
49
+ /** `42.1s` / `3m 07s` — a wall time a human reads, not a millisecond count. */
50
+ function formatDuration(ms) {
51
+ const seconds = ms / 1000;
52
+ if (seconds < 90)
53
+ return `${seconds.toFixed(1)}s`;
54
+ const minutes = Math.floor(seconds / 60);
55
+ return `${minutes}m ${String(Math.round(seconds - minutes * 60)).padStart(2, "0")}s`;
56
+ }
57
+ function formatGates(attempt) {
58
+ if (attempt.status === "skipped")
59
+ return "-";
60
+ return `${attempt.gate_passes}p/${attempt.gate_failures}f`;
61
+ }
62
+ /**
63
+ * The result table. Fixed column widths over a `console.table`: every other
64
+ * `spf` listing (`sessions`, `phases`) prints plain padded columns, and this
65
+ * output is read in a terminal beside them.
66
+ */
67
+ function printTable(attempts) {
68
+ const widths = {
69
+ adw: Math.max(6, ...attempts.map((a) => a.adw_id.length)),
70
+ branch: Math.max(6, ...attempts.map((a) => a.branch.length)),
71
+ };
72
+ console.log(` ${"#".padEnd(3)}${"adw_id".padEnd(widths.adw + 2)}${"branch".padEnd(widths.branch + 2)}` +
73
+ `${"status".padEnd(9)}${"gates".padEnd(9)}${"cost".padEnd(10)}time`);
74
+ for (const a of attempts) {
75
+ console.log(` ${String(a.index).padEnd(3)}${a.adw_id.padEnd(widths.adw + 2)}${a.branch.padEnd(widths.branch + 2)}` +
76
+ `${a.status.padEnd(9)}${formatGates(a).padEnd(9)}${agents.formatUsd(a.cost).padEnd(10)}` +
77
+ `${a.status === "skipped" ? "-" : formatDuration(a.wall_ms)}` +
78
+ `${a.error ? `\n error: ${a.error}` : ""}`);
79
+ }
80
+ }
81
+ /**
82
+ * Wire `<worktreePath>/.spf/data` to the main repo's `dataDir` — same
83
+ * purpose as `cli/commands/watch.ts`'s own `linkDataDir`: without it an
84
+ * attempt's chain resolves its session/trace data relative to `cwd` (its
85
+ * worktree) and writes into a fresh `.spf/data` that dies with the
86
+ * worktree — invisible in `spf ui`, and gone before `readMetrics` could even
87
+ * read the gate rows that DECIDE the winner. Symlinking it to the main
88
+ * repo's data dir is what makes the shared WAL SQLite the one place all N
89
+ * attempts report to (`tracer.ts`'s WAL + `busy_timeout=5000` is exactly
90
+ * what concurrent writers through this symlink already rely on under `spf
91
+ * watch`). Idempotent. A plain function of its two inputs (not a closure
92
+ * over `dataPaths`) so it can be exercised directly in a test, against a
93
+ * real filesystem and a real git worktree, without a real chain or agent.
94
+ *
95
+ * `mkdirSync(dataDir, ...)` BEFORE the symlink is not optional: a fresh
96
+ * clone with `.spf/` committed (`.spf/data` gitignored), or a repo straight
97
+ * out of `spf init` (which seeds `.spf/` and `.spf/chains/` but never
98
+ * `.spf/data` — nothing has traced a run there yet) has no `.spf/data` to
99
+ * point at. Without this, the symlink is dangling and every attempt's `Run`
100
+ * constructor fails its own `ensureDir` on first use — `spf fanout` would be
101
+ * broken on a repo's first ever run.
102
+ */
103
+ export function linkFanoutDataDir(worktreePath, dataDir) {
104
+ const target = path.join(worktreePath, ".spf", "data");
105
+ if (!existsSync(target)) {
106
+ mkdirSync(dataDir, { recursive: true });
107
+ mkdirSync(path.dirname(target), { recursive: true });
108
+ symlinkSync(dataDir, target, "dir");
109
+ }
110
+ excludeSpfDataFromGit(worktreePath);
111
+ }
112
+ /**
113
+ * Keep the `.spf/data` symlink invisible to `git status`/`git add -A` in the
114
+ * attempt's own worktree. `.spf/data/` — a trailing-slash DIRECTORY pattern,
115
+ * the shape this repo's own gitignore uses — does NOT match a SYMLINK of
116
+ * the same name, so without this, `git status` shows `?? .spf/data` and a
117
+ * committing chain's `git_helper.commitAll` (`git add -A`) stages it.
118
+ * Following the printed `git merge <winner-branch>` instruction then
119
+ * fast-forwards the symlink straight into the MAIN repo, replacing its real
120
+ * `.spf/data` directory with a symlink pointing back at itself — destroying
121
+ * the trace db (`ls .spf/data` becomes `ELOOP`). `info/exclude` is
122
+ * per-worktree, local-only, and idempotent to append to — the opposite of
123
+ * adding a pattern to a tracked `.gitignore`, which would ship this
124
+ * workaround into every clone for a symlink only `spf fanout` itself ever
125
+ * creates.
126
+ */
127
+ export function excludeSpfDataFromGit(worktreePath) {
128
+ const resolved = spawnSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: worktreePath, encoding: "utf-8" });
129
+ if (resolved.status !== 0)
130
+ return; // best-effort: worst case is the pre-existing staging risk, not a crash
131
+ const excludePath = path.resolve(worktreePath, resolved.stdout.trim());
132
+ const line = ".spf/data";
133
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf-8") : "";
134
+ if (existing.split("\n").some((l) => l.trim() === line))
135
+ return;
136
+ mkdirSync(path.dirname(excludePath), { recursive: true });
137
+ appendFileSync(excludePath, `${existing && !existing.endsWith("\n") ? "\n" : ""}${line}\n`);
138
+ }
139
+ /**
140
+ * `spf fanout --clean <base-adw-id>` — the cleanup half of the deterministic
141
+ * naming, for the two cases nothing else reaches: a run killed before its
142
+ * own `finally`s ran (SIGINT swallowed by a synchronous `spawnSync` phase, or
143
+ * `process.exit()` racing worktree cleanup — see `core/fanout.ts`'s lack of
144
+ * a cancellation seam), and a winner an operator has since merged/rejected
145
+ * and now wants gone. Matches by PREFIX rather than an index range: `--n`
146
+ * varies run to run, and a killed run may have left an arbitrary subset.
147
+ * `--force` on the worktree removal is deliberate and irreversible — this is
148
+ * explicitly a "the trees are trash now" command, unlike a normal run's own
149
+ * cleanup, which never force-removes a dirty tree it didn't already decide
150
+ * to discard (see `git_helper.ts`'s `createRunWorktree`).
151
+ */
152
+ function cleanFanoutAttempts(baseAdwId, cwdOpt) {
153
+ const anchor = paths.resolveAnchor(cwdOpt);
154
+ if (!isRepoAt(anchor.repo_root)) {
155
+ console.error(`${anchor.repo_root} is not a git repository`);
156
+ return 1;
157
+ }
158
+ const worktreesDir = path.join(homedir(), ".spf", "fanout", path.basename(anchor.repo_root), "worktrees");
159
+ const git = makeGit(anchor.repo_root);
160
+ let removed = 0;
161
+ if (existsSync(worktreesDir)) {
162
+ for (const entry of readdirSync(worktreesDir)) {
163
+ if (!entry.startsWith(`fanout-${baseAdwId}-`))
164
+ continue;
165
+ const worktreePath = path.join(worktreesDir, entry);
166
+ spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: anchor.repo_root, encoding: "utf-8" });
167
+ console.log(`[spf] fanout removed worktree ${worktreePath}`);
168
+ removed++;
169
+ }
170
+ }
171
+ const branchList = spawnSync("git", ["branch", "--list", `spf/fanout/${baseAdwId}-*`], {
172
+ cwd: anchor.repo_root,
173
+ encoding: "utf-8",
174
+ });
175
+ for (const line of (branchList.stdout || "").split("\n")) {
176
+ const branch = line.replace(/^\*?\s+/, "").trim();
177
+ if (!branch)
178
+ continue;
179
+ git.deleteLocalBranch(branch);
180
+ console.log(`[spf] fanout deleted branch ${branch}`);
181
+ removed++;
182
+ }
183
+ if (removed === 0) {
184
+ console.log(`[spf] fanout nothing to clean for ${baseAdwId} — no worktree or branch under that base id`);
185
+ }
186
+ return 0;
187
+ }
188
+ export async function fanoutCommand(argv) {
189
+ const { positionals, options, flags } = parseCli(argv, ["cwd", "config", "n", "concurrency", "base", "adw-id", "clean"], ["first-success"]);
190
+ if (options["clean"]) {
191
+ return cleanFanoutAttempts(options["clean"], options["cwd"]);
192
+ }
193
+ if (positionals.length < 2) {
194
+ console.error(USAGE);
195
+ return 1;
196
+ }
197
+ const [chainName, promptArg] = positionals;
198
+ const chain = findChain(chainName);
199
+ if (!chain) {
200
+ console.error(`unknown chain: ${chainName} — run \`spf list\` to see every chain`);
201
+ return 1;
202
+ }
203
+ if (!hasCommitStep(chain.phases)) {
204
+ // A winner with no commit step leaves its payload ONLY as uncommitted
205
+ // edits in the kept worktree — the branch this command hands back would
206
+ // carry zero commits, so `git merge`/`git cherry-pick` would be a no-op,
207
+ // and the printed `git worktree remove` cleanup line would DESTROY the
208
+ // only copy of the work. Refuse up front rather than print a lie.
209
+ console.error(`spf fanout: "${chain.name}" has no commit phase (${chain.phases}) — its winner's branch would carry zero ` +
210
+ `commits, and the work would exist only as uncommitted edits in a worktree that "git worktree remove" ` +
211
+ `would then destroy. Use a chain that commits (e.g. plan-build, plan-build-test, ` +
212
+ `plan-build-test-quality, simple-sdlc), or a repo-local chain (.spf/chains/*.yaml) with a commit step.`);
213
+ return 1;
214
+ }
215
+ const n = options["n"] ? Number.parseInt(options["n"], 10) : 3;
216
+ if (!Number.isInteger(n) || n < 2 || n > MAX_N) {
217
+ console.error(`--n must be an integer between 2 and ${MAX_N} (got ${JSON.stringify(options["n"] ?? "3")}) — for a single run use \`spf run ${chainName}\``);
218
+ return 1;
219
+ }
220
+ const anchor = paths.resolveAnchor(options["cwd"]);
221
+ if (!isRepoAt(anchor.repo_root)) {
222
+ // Not a nicety: every attempt IS a git worktree off a base branch. Without
223
+ // a repo there is nothing to isolate runs from each other with, and the
224
+ // "winner is a branch a human merges" contract has nothing to hand back.
225
+ console.error(`${anchor.repo_root} is not a git repository — spf fanout runs each attempt in its own git worktree`);
226
+ return 1;
227
+ }
228
+ const configPaths = paths.resolveConfigPaths(anchor, options["config"]).paths;
229
+ const cfg = agents.loadConfig(configPaths);
230
+ const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
231
+ const git = makeGit(anchor.repo_root);
232
+ // Reused rather than given a knob of its own: `watch.concurrency` (default
233
+ // 2) is already this repo's answer to "how many chains may run at once
234
+ // here", and it is the same machine, the same SQLite and the same agent
235
+ // quota either way. `--concurrency` overrides it for one invocation.
236
+ const concurrency = options["concurrency"] ? Number.parseInt(options["concurrency"], 10) : cfg.watch.concurrency;
237
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
238
+ console.error(`--concurrency must be a positive integer (got ${JSON.stringify(options["concurrency"])})`);
239
+ return 1;
240
+ }
241
+ const baseBranch = options["base"] ?? cfg.watch.base_branch;
242
+ const baseAdwId = options["adw-id"] ?? newId(8);
243
+ const worktreesDir = path.join(homedir(), ".spf", "fanout", path.basename(anchor.repo_root), "worktrees");
244
+ // Fail fast on a `--adw-id` reused from a previous fanout invocation.
245
+ // `readMetrics` (below) keys purely on adw_id, and `tracer.sessionStart`
246
+ // upserts an EXISTING session row (`status='running'`) while
247
+ // `sessionAddUsage` ACCUMULATES onto it — so a collision would silently mix
248
+ // the previous run's cost/tokens/gate rows into this run's selection
249
+ // basis, and the human reading `basis:` would be told a wrong reason.
250
+ if (existsSync(dataPaths.db_path)) {
251
+ const preflight = new SfDb(dataPaths.db_path);
252
+ try {
253
+ const collisions = Array.from({ length: n }, (_, i) => attemptAdwId(baseAdwId, i + 1)).filter((id) => preflight.session(id) !== null);
254
+ if (collisions.length > 0) {
255
+ console.error(`--adw-id ${baseAdwId} already has session rows for ${collisions.join(", ")} from a previous fanout ` +
256
+ `run — reusing it would mix that run's cost/tokens/gates into this one's selection basis. Use a ` +
257
+ `fresh --adw-id (or omit the flag to get one generated), or \`spf fanout --clean ${baseAdwId}\` first ` +
258
+ `if that previous run is done with.`);
259
+ return 1;
260
+ }
261
+ }
262
+ finally {
263
+ preflight.close();
264
+ }
265
+ }
266
+ if (cfg.defaults.max_run_cost === undefined && cfg.defaults.max_run_tokens === undefined) {
267
+ // Not an error — an unbounded run is the documented default. But `--n` is
268
+ // a spend multiplier, and this is the one moment the operator is asking
269
+ // for N times the bill, so it is the honest moment to mention the ceiling
270
+ // exists. See ConfigDefaultsSchema's budget block.
271
+ console.log(`[spf] fanout no defaults.max_run_cost / defaults.max_run_tokens configured — ` +
272
+ `${n} attempts will run to completion unbounded`);
273
+ }
274
+ const linkDataDir = (worktreePath) => linkFanoutDataDir(worktreePath, dataPaths.data_dir);
275
+ /**
276
+ * The shared db, opened lazily and once: it does not exist until the first
277
+ * attempt's tracer creates it, and `SfDb`'s constructor throws on a missing
278
+ * file. Readonly + WAL, so reading one finished attempt's rows while its
279
+ * siblings are still writing is exactly the access pattern the tracer's
280
+ * PRAGMAs are set up for.
281
+ */
282
+ // A holder rather than a bare `let`: assigned only inside the closure below,
283
+ // a plain local narrows to `null` everywhere else and the `finally`'s
284
+ // `close()` stops typechecking.
285
+ const held = { db: null };
286
+ function readMetrics(adwId) {
287
+ if (!held.db) {
288
+ if (!existsSync(dataPaths.db_path))
289
+ return ZERO_METRICS;
290
+ held.db = new SfDb(dataPaths.db_path);
291
+ }
292
+ const gates = held.db.gates(adwId);
293
+ const session = held.db.session(adwId);
294
+ // `passed` is a SQLite integer boolean that CAN be NULL on a row an older
295
+ // tracer wrote. Counted explicitly in both directions, never as
296
+ // `!g.passed`: a NULL is unknown, and letting it read as a failure would
297
+ // let a garbled row decide which candidate a human is told to merge.
298
+ return {
299
+ gate_passes: gates.filter((g) => g.passed === 1).length,
300
+ gate_failures: gates.filter((g) => g.passed === 0).length,
301
+ cost: session?.total_cost ?? 0,
302
+ tokens: session?.total_tokens ?? 0,
303
+ };
304
+ }
305
+ const runAttempt = async (dispatch) => {
306
+ // Cooperative abort, checked at the last possible moment before spend
307
+ // begins — a sibling can win between this attempt being pulled off the
308
+ // queue and its first agent call. See core/fanout.ts's ABORT GRANULARITY
309
+ // note for what this does and does not cover.
310
+ if (dispatch.isAborted())
311
+ return ABORTED_EXIT;
312
+ const ctx = {
313
+ prompt: dispatch.prompt,
314
+ config_paths: configPaths,
315
+ adw_id: dispatch.adwId,
316
+ // The attempt's OWN worktree. This is the anchor `session.ensure` binds
317
+ // the Run's repo_root to, once — which is what puts the gates,
318
+ // permissions snapshot, quality checks and changes capture on the tree
319
+ // these agents actually wrote in.
320
+ cwd: dispatch.cwd,
321
+ // Kept resolvable on purpose: this is the name `findChain` answers to,
322
+ // and `session.ensure` records it as the session's `adw_name`. The
323
+ // fan-out provenance rides the derived adw_id (`<base>-<i>`) and the
324
+ // branch instead — `chain_source` is contractually the absolute path of
325
+ // a `.spf/chains/*.yaml` file (see ChainContext), and a marker string
326
+ // stuffed in there would lie to `spf ui` about where the chain came from.
327
+ chain_name: chain.name,
328
+ // Nobody is at a TTY for attempt 2 of 3 — a chain that would prompt
329
+ // (simple-sdlc's sign-off) must take its unattended path, or N-1
330
+ // attempts would block forever on a prompt only one of them can show.
331
+ unattended: true,
332
+ chain_source: chain.source,
333
+ };
334
+ return runChainDef(chain, ctx);
335
+ };
336
+ // Best-effort operator guidance on an abnormal exit — NOT a real
337
+ // cancellation. Every code phase runs through a synchronous `spawnSync`
338
+ // (`core/quality.ts`), which blocks the event loop while it runs, so a
339
+ // SIGINT delivered during one is not even observed until it returns; an
340
+ // attempt whose chain HAS reached an async agent phase may still finalize
341
+ // its own session via `process.exit()` (`core/session.ts`'s
342
+ // `finalizeWhenKilled`) before this handler's log line ever flushes. What
343
+ // this reliably buys, in the case it can't prevent, is telling the
344
+ // operator the one thing the trace can't: which base adw_id to hand to
345
+ // `--clean` afterwards.
346
+ let interrupted = false;
347
+ const onSignal = (signal) => {
348
+ if (interrupted)
349
+ return;
350
+ interrupted = true;
351
+ console.error(`\n[spf] fanout: ${signal} received — attempts already past their last checkpoint run to completion. ` +
352
+ `Once everything has settled, clean up anything left under this base id with:\n` +
353
+ ` spf fanout --clean ${baseAdwId}\n`);
354
+ process.exit(130);
355
+ };
356
+ process.on("SIGINT", onSignal);
357
+ process.on("SIGTERM", onSignal);
358
+ try {
359
+ const result = await runBestOf({
360
+ chainName: chain.name,
361
+ prompt: resolvePrompt(promptArg),
362
+ n,
363
+ concurrency,
364
+ baseAdwId,
365
+ baseBranch,
366
+ repoRoot: anchor.repo_root,
367
+ worktreesDir,
368
+ git,
369
+ linkDataDir,
370
+ runAttempt,
371
+ readMetrics,
372
+ firstSuccess: flags["first-success"],
373
+ log: (message) => console.log(`[spf] ${message}`),
374
+ });
375
+ console.log(`\nfanout ${chain.name} — ${n} attempt(s), base ${baseBranch}`);
376
+ printTable(result.attempts);
377
+ if (!result.winner) {
378
+ console.error(`\nno winner: ${result.basis}`);
379
+ console.error(`Any attempt whose chain got far enough to open a session has its trace in the shared db — ` +
380
+ `\`spf phases <adw_id>\` to check (an attempt that failed before then, e.g. a worktree collision, has none).`);
381
+ return 1;
382
+ }
383
+ console.log(`\nwinner: ${result.winner.adw_id} branch ${result.winner.branch}`);
384
+ console.log(`basis: ${result.basis}`);
385
+ console.log(` (selection basis: succeeded > fewest gate failures > most gate passes > lowest cost > ` +
386
+ `fewest tokens > lowest wall time (contended under concurrency, last resort) > adw_id)`);
387
+ console.log(`kept: ${result.winner.worktree}`);
388
+ console.log(`\nSPF does not merge the winner — you do:`);
389
+ console.log(` git merge ${result.winner.branch} # or: git cherry-pick <sha> / git diff ${baseBranch}..${result.winner.branch}`);
390
+ console.log(` git worktree remove --force ${result.winner.worktree} && git branch -d ${result.winner.branch} ` +
391
+ `# when you're done with it — --force is required (the .spf/data symlink always makes the tree` +
392
+ ` "dirty" to git) and will discard anything you haven't committed or merged out of it first`);
393
+ console.log(`The other attempts' worktrees and branches were deleted; their traces remain in the db (\`spf phases <adw_id>\`).`);
394
+ return 0;
395
+ }
396
+ finally {
397
+ held.db?.close();
398
+ process.off("SIGINT", onSignal);
399
+ process.off("SIGTERM", onSignal);
400
+ }
401
+ }
@@ -126,6 +126,25 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
126
126
  # review:
127
127
  # require_human_signoff: false # true = an unattended simple-sdlc run fails closed instead of committing on the reviewer's verdict alone
128
128
  # signoff_timeout_seconds: 300
129
+
130
+ # Uncomment to route roles to cheaper/stronger models by a run-global risk
131
+ # signal (chain kind + prompt length) — OFF by default, and absence/false is
132
+ # a TOTAL no-op: every agent dispatches at exactly the model its roster
133
+ # entry names. \`spf init --template ts-flue-ollama\` ships this ON with two
134
+ # MEASURED local tags; this starter leaves it off and commented because no
135
+ # tag below has been measured against YOUR roster. See
136
+ # assets/skill/references/config.md's "tiering" section for the full
137
+ # mechanism (the ladder walk, rule T, the availability probe) before turning
138
+ # this on — \`spf doctor\` checks these rungs the same way it checks
139
+ # cfg.agents[].model, but ONLY once enabled: true.
140
+ # tiering:
141
+ # enabled: true
142
+ # tiers:
143
+ # - { name: cheap, coding_agent: flue, model: google/gemini-3.6-flash }
144
+ # - { name: strong, coding_agent: flue, model: openai/gpt-5.6-terra }
145
+ # roles:
146
+ # builder: cheap
147
+ # reviewer: strong
129
148
  `;
130
149
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
131
150
  // shared project config, same as package.json. Only runtime/generated
@@ -1,8 +1,26 @@
1
1
  import * as paths from "../../core/paths.ts";
2
+ import type { SFConfig } from "../../core/data_types.ts";
2
3
  import { SfDb } from "../../ui/server/db.ts";
3
4
  export interface TraceHandle {
4
5
  db: SfDb;
5
6
  anchor: paths.RepoAnchor;
6
7
  dataDir: string;
8
+ /** Already loaded by this same resolution — `spf estimate` needs it (ceilings, resolveTiering) and reusing it means one loadConfig, not two. */
9
+ cfg: SFConfig;
7
10
  }
8
11
  export declare function openTrace(options: Record<string, string>): TraceHandle;
12
+ /**
13
+ * Like `openTrace`, but never throws on a missing db file: `db` is `null`
14
+ * in that one slot instead. `anchor`/`dataDir`/`cfg` still come from the
15
+ * SAME `resolveTrace` call either way — a cold start (no `spf.db` yet)
16
+ * still carries a real, loaded `cfg`, which is what lets `spf estimate`
17
+ * echo `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
18
+ * before it exits, rather than crashing with `SfDb`'s "point spf ui at a
19
+ * target repo" message (which is about `spf ui`, not `spf estimate`).
20
+ */
21
+ export declare function openTraceIfExists(options: Record<string, string>): {
22
+ db: SfDb | null;
23
+ anchor: paths.RepoAnchor;
24
+ dataDir: string;
25
+ cfg: SFConfig;
26
+ };
@@ -1,10 +1,29 @@
1
- /** Shared by sessions/phases/events/abort — resolve the workspace and open its trace db. */
1
+ /** Shared by sessions/phases/events/abort/estimate — resolve the workspace and open its trace db. */
2
+ import { existsSync } from "node:fs";
2
3
  import * as agents from "../../core/agents.js";
3
4
  import * as paths from "../../core/paths.js";
4
5
  import { SfDb } from "../../ui/server/db.js";
5
- export function openTrace(options) {
6
+ /** The one place anchor/config/data-path resolution happens, so `openTrace` and `openTraceIfExists` cannot diverge on it. */
7
+ function resolveTrace(options) {
6
8
  const anchor = paths.resolveAnchor(options["cwd"]);
7
9
  const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
8
10
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
9
- return { db: new SfDb(dataPaths.db_path), anchor, dataDir: dataPaths.data_dir };
11
+ return { dbPath: dataPaths.db_path, anchor, dataDir: dataPaths.data_dir, cfg };
12
+ }
13
+ export function openTrace(options) {
14
+ const { dbPath, anchor, dataDir, cfg } = resolveTrace(options);
15
+ return { db: new SfDb(dbPath), anchor, dataDir, cfg };
16
+ }
17
+ /**
18
+ * Like `openTrace`, but never throws on a missing db file: `db` is `null`
19
+ * in that one slot instead. `anchor`/`dataDir`/`cfg` still come from the
20
+ * SAME `resolveTrace` call either way — a cold start (no `spf.db` yet)
21
+ * still carries a real, loaded `cfg`, which is what lets `spf estimate`
22
+ * echo `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
23
+ * before it exits, rather than crashing with `SfDb`'s "point spf ui at a
24
+ * target repo" message (which is about `spf ui`, not `spf estimate`).
25
+ */
26
+ export function openTraceIfExists(options) {
27
+ const { dbPath, anchor, dataDir, cfg } = resolveTrace(options);
28
+ return { db: existsSync(dbPath) ? new SfDb(dbPath) : null, anchor, dataDir, cfg };
10
29
  }
@@ -260,6 +260,17 @@ export async function watchCommand(argv) {
260
260
  */
261
261
  function linkDataDir(worktreePath) {
262
262
  const target = path.join(worktreePath, ".spf", "data");
263
+ // Hard guard, independent of however `worktreePath` got resolved: a real
264
+ // self-referential .spf/data symlink was observed live once already
265
+ // (points at itself — every later `mkdirSync` through it throws ELOOP;
266
+ // see `paths.healBrokenDataDir` for the recovery side of this same bug).
267
+ // The exact trigger was never pinned down, but `worktreePath` resolving
268
+ // to the main repo's own `data_dir` can never be correct regardless of
269
+ // how it got there, so refuse outright rather than create it again.
270
+ if (path.resolve(target) === path.resolve(dataPaths.data_dir)) {
271
+ console.error(`watch: refusing to link ${target} to itself — worktree path resolved to the main repo's own data_dir`);
272
+ return;
273
+ }
263
274
  if (existsSync(target))
264
275
  return;
265
276
  mkdirSync(path.dirname(target), { recursive: true });
@@ -341,17 +352,19 @@ export async function watchCommand(argv) {
341
352
  unattended: true,
342
353
  chain_source: chainDef.source,
343
354
  };
344
- // KNOWN LIMITATION (not fixed here): runChainDef is called below with no
345
- // third `options` argument, so nothing --suite-shaped ever reaches this
346
- // dispatch `resolveRequiredSuites`/`resolveRequiredAgents` below both
347
- // fall back to each chain's compiled-in/YAML-declared default. An
348
- // unattended watch run therefore can't override a chain's suite the way
349
- // an interactive `spf <chain> --suite <name>` can.
350
- const code = await runChainDef(chainDef, ctx);
351
- // Static for every chain but "prompt" (whose --agent flag `spf watch`
352
- // never passes) resolved with no options, exactly like `runChainDef`
353
- // above ran it.
354
- const reviewRequired = resolveRequiredAgents(chainDef, {}).includes("reviewer");
355
+ // `opts.chainOptions` is `watch.chain_options` (see WatchConfigSchema's
356
+ // doc comment), threaded down from `deps.chainOptions` by
357
+ // `core/watch.ts`'s `runIssue` the same options-map shape an
358
+ // interactive `spf <chain> --suite <name>` builds in
359
+ // `cli/commands/run.ts`'s `dispatchChain`. This closes the KNOWN
360
+ // LIMITATION from PR #20: an unattended watch dispatch used to call
361
+ // `runChainDef` with no options at all, so nothing --suite-shaped could
362
+ // ever reach it.
363
+ const code = await runChainDef(chainDef, ctx, opts.chainOptions);
364
+ // Resolved with the SAME options `runChainDef` just ran the chain with,
365
+ // so a `chain_options` override that swaps a "reviewer" step in or out
366
+ // is reflected here too, not just each chain's static/YAML default.
367
+ const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
355
368
  if (code === 0) {
356
369
  return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
357
370
  }
@@ -360,11 +373,15 @@ export async function watchCommand(argv) {
360
373
  };
361
374
  /**
362
375
  * Same shape as `runChain`, for the refine lane — with one extra step on
363
- * success: a chain's return value is just an exit code, so the created-
364
- * issues list `steps.publishIssues()` actually produced has to come back
365
- * through the side channel it wrote (`refine_publish.json`, under the
366
- * SAME symlinked session dir `linkDataDir` already wires up), not through
367
- * `runChainDef`'s return value.
376
+ * success: a chain's return value is just an exit code, so what
377
+ * `steps.publishIssues()` actually did published a tree, OR escalated a
378
+ * question set instead — has to come back through the side channel it
379
+ * wrote (`refine_publish.json` or `refine_questions.json`, under the SAME
380
+ * symlinked session dir `linkDataDir` already wires up), not through
381
+ * `runChainDef`'s return value. The two are read independently and both
382
+ * default to `[]`: `gates.refinementWellFormed` guarantees a chain never
383
+ * writes both files in the same run, so exactly one of `created`/
384
+ * `questions` is ever non-empty on an accepted run.
368
385
  */
369
386
  const runRefine = async (opts) => {
370
387
  const chainDef = findChain(cfg.watch.refine.chain); // checked above
@@ -375,28 +392,35 @@ export async function watchCommand(argv) {
375
392
  cwd: opts.cwd,
376
393
  chain_name: chainDef.name,
377
394
  issue_id: opts.issueId,
378
- // Same reasoning as runChain's ctx above — an unattended dispatch,
379
- // with no --suite-shaped options reaching it either (same KNOWN
380
- // LIMITATION).
395
+ // Same reasoning as runChain's ctx above — an unattended dispatch.
381
396
  unattended: true,
382
397
  chain_source: chainDef.source,
383
398
  };
384
- const code = await runChainDef(chainDef, ctx);
399
+ // Same `watch.chain_options` threading as runChain above — see its
400
+ // comment for the KNOWN LIMITATION this fixes.
401
+ const code = await runChainDef(chainDef, ctx, opts.chainOptions);
385
402
  if (code !== 0) {
386
403
  const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
387
- return { accepted: false, adwId: opts.adwId, detail, created: [] };
404
+ return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
388
405
  }
406
+ const wtAnchor = paths.resolveAnchor(opts.cwd);
407
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
408
+ const handoffDir = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff");
389
409
  let created = [];
390
410
  try {
391
- const wtAnchor = paths.resolveAnchor(opts.cwd);
392
- const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
393
- const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
394
- created = JSON.parse(readFileSync(summaryPath, "utf-8"));
411
+ created = JSON.parse(readFileSync(path.join(handoffDir, "refine_publish.json"), "utf-8"));
395
412
  }
396
413
  catch {
397
414
  // best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
398
415
  }
399
- return { accepted: true, adwId: opts.adwId, detail: "", created };
416
+ let questions = [];
417
+ try {
418
+ questions = JSON.parse(readFileSync(path.join(handoffDir, "refine_questions.json"), "utf-8"));
419
+ }
420
+ catch {
421
+ // best-effort, same as above — no questions file means this run wasn't an escalation
422
+ }
423
+ return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
400
424
  };
401
425
  const deps = {
402
426
  provider,
@@ -407,6 +431,7 @@ export async function watchCommand(argv) {
407
431
  chain: cfg.watch.chain,
408
432
  baseBranch: cfg.watch.base_branch,
409
433
  concurrency: cfg.watch.concurrency,
434
+ chainOptions: cfg.watch.chain_options,
410
435
  refineEnabled: cfg.watch.refine.enabled,
411
436
  refineConcurrency: cfg.watch.refine.concurrency,
412
437
  refineChain: cfg.watch.refine.chain,