@gr8ful/spf 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. package/dist/test/watch.test.js +0 -687
@@ -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
+ }
@@ -99,7 +99,10 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
99
99
  # base_branch: main
100
100
  # # Optional second lane: decompose a spf:spec-ready product spec into a
101
101
  # # feature/story-or-bug tree of real issues. Off by default; needs
102
- # # issue_provider: github — issue authoring isn't implemented for Jira yet.
102
+ # # issue_provider: github OR jira both support issue authoring. On Jira,
103
+ # # each kind maps to a real issue type via watch.jira.issue_types
104
+ # # (defaults: epic/feature -> Epic, story -> Story, bug -> Bug,
105
+ # # task -> Task) — \`spf watch init\` validates this against the project.
103
106
  # refine:
104
107
  # enabled: true
105
108
  # chain: refine
@@ -126,6 +129,25 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
126
129
  # review:
127
130
  # require_human_signoff: false # true = an unattended simple-sdlc run fails closed instead of committing on the reviewer's verdict alone
128
131
  # signoff_timeout_seconds: 300
132
+
133
+ # Uncomment to route roles to cheaper/stronger models by a run-global risk
134
+ # signal (chain kind + prompt length) — OFF by default, and absence/false is
135
+ # a TOTAL no-op: every agent dispatches at exactly the model its roster
136
+ # entry names. \`spf init --template ts-flue-ollama\` ships this ON with two
137
+ # MEASURED local tags; this starter leaves it off and commented because no
138
+ # tag below has been measured against YOUR roster. See
139
+ # assets/skill/references/config.md's "tiering" section for the full
140
+ # mechanism (the ladder walk, rule T, the availability probe) before turning
141
+ # this on — \`spf doctor\` checks these rungs the same way it checks
142
+ # cfg.agents[].model, but ONLY once enabled: true.
143
+ # tiering:
144
+ # enabled: true
145
+ # tiers:
146
+ # - { name: cheap, coding_agent: flue, model: google/gemini-3.6-flash }
147
+ # - { name: strong, coding_agent: flue, model: openai/gpt-5.6-terra }
148
+ # roles:
149
+ # builder: cheap
150
+ # reviewer: strong
129
151
  `;
130
152
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
131
153
  // shared project config, same as package.json. Only runtime/generated
@@ -3,9 +3,9 @@ import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
4
  import { runChain } from "../../chains/index.js";
5
5
  import { isInteractive } from "../ask.js";
6
- const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite"];
6
+ const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite", "priority"];
7
7
  export function usageFor(chain) {
8
- return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>]`;
8
+ return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] [--priority p0|p1|p2|p3]`;
9
9
  }
10
10
  export async function dispatchChain(chain, argv) {
11
11
  const { positionals, options } = parseCli(argv, KNOWN_OPTIONS);
@@ -52,5 +52,12 @@ export async function dispatchChain(chain, argv) {
52
52
  chainOptions["base"] = options["base"];
53
53
  if (options["suite"] !== undefined)
54
54
  chainOptions["suite"] = options["suite"];
55
+ // Only `refine`'s `publishIssues()` step reads this (a ceiling clamped
56
+ // onto every node it creates — see `core/refine.ts`'s `publish()`); every
57
+ // other chain ignores it, so it's harmless to always pass through, same
58
+ // as `issue` above. `publishIssues()` itself validates the value —
59
+ // rejecting anything but p0|p1|p2|p3 — so this stays a plain passthrough.
60
+ if (options["priority"] !== undefined)
61
+ chainOptions["priority"] = options["priority"];
55
62
  return runChain(chain, ctx, chainOptions);
56
63
  }
@@ -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
  }
@@ -21,6 +21,14 @@ export declare function formatReviewDigest(review: ReviewOutputT): string;
21
21
  * machine needs, with sensible colors/descriptions. Doesn't touch git or
22
22
  * run anything, so it skips watchCommand's repo/chain checks entirely —
23
23
  * you can seed labels before ever wiring up a worktree-capable checkout.
24
+ *
25
+ * On Jira, with `watch.refine.enabled`, this also runs a READ-ONLY check of
26
+ * `watch.jira.issue_types` against the real project — the same check
27
+ * `watchCommand`'s own startup gate runs, exposed here too so a bad mapping
28
+ * can be caught (and fixed) before ever starting the daemon, not just at
29
+ * startup time. Labels themselves stay a pure report on Jira either way
30
+ * (Jira labels are freeform strings with no color/description registry to
31
+ * seed — see `jira_provider.ts`'s module comment).
24
32
  */
25
33
  export declare function watchInitCommand(argv: string[]): Promise<number>;
26
34
  export declare function watchCommand(argv: string[]): Promise<number>;