@gr8ful/spf 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -17
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +32 -6
- package/assets/prompts/refiner/user.md +37 -5
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +153 -4
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +6 -0
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +27 -9
- package/dist/chains/steps.js +82 -13
- package/dist/cli/commands/doctor.js +108 -1
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.js +51 -26
- package/dist/cli/index.js +12 -0
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +232 -0
- package/dist/core/data_types.js +135 -0
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +24 -1
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +7 -1
- package/dist/core/issues/github_provider.js +33 -2
- package/dist/core/issues/jira_provider.d.ts +5 -1
- package/dist/core/issues/jira_provider.js +26 -3
- package/dist/core/issues/provider.d.ts +53 -2
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +89 -9
- package/dist/core/watch.js +193 -19
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// an
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
|
|
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
|
|
364
|
-
*
|
|
365
|
-
* through the side channel it
|
|
366
|
-
*
|
|
367
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|