@gr8ful/spf 0.9.1 → 0.10.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 (38) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/interview.js +40 -16
  16. package/dist/core/agent_cc.d.ts +11 -0
  17. package/dist/core/agent_cc.js +25 -2
  18. package/dist/core/agent_flue.js +14 -5
  19. package/dist/core/agents.d.ts +61 -1
  20. package/dist/core/agents.js +363 -6
  21. package/dist/core/data_types.d.ts +316 -0
  22. package/dist/core/data_types.js +143 -0
  23. package/dist/core/loop.d.ts +230 -0
  24. package/dist/core/loop.js +290 -0
  25. package/dist/core/quality.d.ts +1 -2
  26. package/dist/core/sandbox.d.ts +236 -0
  27. package/dist/core/sandbox.js +655 -0
  28. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  29. package/dist/core/sandbox_cloudflare.js +505 -0
  30. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  31. package/dist/core/sandbox_opensandbox.js +484 -0
  32. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  33. package/dist/core/sandbox_sdk_types.js +20 -0
  34. package/dist/core/watch.d.ts +56 -0
  35. package/dist/core/watch.js +354 -51
  36. package/dist/core/worktree_data.d.ts +1 -0
  37. package/dist/core/worktree_data.js +37 -0
  38. package/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `spf loop <chain> "<goal>" --until-suite <name> --max N` — the wiring half
3
+ * of `core/loop.ts` (which owns the driver, the ledger, and the breakers):
4
+ * config, the issue fetch for `--issue`, the per-iteration `runChain`
5
+ * dispatch, the sqlite readback, and the final table. Same split as
6
+ * `core/fanout.ts` / `cli/commands/fanout.ts` for the same reason — `core/`
7
+ * stays out of `src/chains/`'s dependency direction, so the driver is
8
+ * testable with no chains, no agents, no real git.
9
+ *
10
+ * `--issue <id>` resolves the SAME `IssueProvider` `spf watch` does
11
+ * (`resolveIssueProvider`, exported from `watch.ts` for exactly this reuse)
12
+ * and builds the goal prompt the same way `core/watch.ts`'s `runIssue` does
13
+ * — `${issue.title}\n\n${issue.body}`.trim() — so a ticket-pointed loop and
14
+ * a hand-typed one share one prompt convention. This is deliberately the
15
+ * ONLY thing `--issue` does: it does not claim, label, or comment on the
16
+ * issue, and no daemon watches it. Filing the goal AS a ticket the daemon
17
+ * picks up on its own is a different, larger feature (a `goal-ready` watch
18
+ * lane) that needs its own crash-safe marker schema and is not this.
19
+ */
20
+ import { existsSync } from "node:fs";
21
+ import * as agents from "../../core/agents.js";
22
+ import * as paths from "../../core/paths.js";
23
+ import * as quality from "../../core/quality.js";
24
+ import { makeGit } from "../../core/git_helper.js";
25
+ import { cumulativeSpend, resolveGoalId, runLoop, summarize, } from "../../core/loop.js";
26
+ import { findChain, runChain as runChainDef } from "../../chains/index.js";
27
+ import { SfDb } from "../../ui/server/db.js";
28
+ import { parseCli, resolvePrompt } from "../../core/utils.js";
29
+ import { isInteractive } from "../ask.js";
30
+ import { resolveIssueProvider } from "./watch.js";
31
+ /**
32
+ * Just enough of `Run` for `quality.resolveSuite`/`runSuite` (`RunLike` in
33
+ * `core/quality.ts`) to work against a real filesystem/console without a
34
+ * full agent-capable `Run` — this driver never opens an agent phase itself
35
+ * (`runChainDef` already did, inside the chain), it only needs to run one
36
+ * more deterministic check afterward. `phases: [{seq: 0, ...}]` gives
37
+ * `checkDir`'s `run.phases[run.phases.length - 1].seq` something real to
38
+ * read, mirroring a genuine single-phase run rather than faking the type.
39
+ */
40
+ function qualityRunLike(input) {
41
+ return {
42
+ cfg: { quality: input.cfg.quality },
43
+ phases: [{ phase_id: `${input.adwId}_00_loop_stop`, adw_id: input.adwId, seq: 0, params: { name: "loop_stop", kind: "code", owner: "quality", description: "loop stop check", retries: 0 }, status: "running", attempt: 0 }],
44
+ context_handoff_dir: input.contextHandoffDir,
45
+ repo_root: input.repoRoot,
46
+ console: { note: (message) => console.log(`[spf] loop ${message}`) },
47
+ tracer: { event: () => "" },
48
+ adw_id: input.adwId,
49
+ };
50
+ }
51
+ const KNOWN_OPTIONS = ["config", "cwd", "issue", "until-suite", "max", "max-cost", "max-tokens", "stuck-after", "min-interval-ms", "goal-id", "base"];
52
+ export const USAGE = `usage: spf loop <chain> "<goal or path/to/goal.md>" --until-suite <name> --max <N> [--issue <id>] ` +
53
+ `[--max-cost <usd>] [--max-tokens <n>] [--stuck-after <n>=3] [--min-interval-ms <n>=0] [--goal-id <id>] ` +
54
+ `[--config <path>] [--cwd <dir>] [--base <ref>]\n` +
55
+ `A goal cannot be judged by pixels in v1 — --until-suite names a quality.suites check (exit code = met), never a raw shell string.`;
56
+ /** `<title>\n\n<body>` — the exact prompt convention `core/watch.ts`'s `runIssue` uses, so a ticket-pointed loop and the build lane read a ticket identically. */
57
+ function promptFromIssue(issue) {
58
+ return `${issue.title}\n\n${issue.body}`.trim();
59
+ }
60
+ export async function loopCommand(argv) {
61
+ const { positionals, options } = parseCli(argv, KNOWN_OPTIONS);
62
+ if (positionals.length < 1) {
63
+ console.error(USAGE);
64
+ return 1;
65
+ }
66
+ const [chainName, promptArg] = positionals;
67
+ if (!options["until-suite"]) {
68
+ console.error(`--until-suite is required — name a quality.suites check whose exit code decides when the goal is met.\n${USAGE}`);
69
+ return 1;
70
+ }
71
+ if (!options["max"]) {
72
+ console.error(`--max is required — an outer iteration is a whole chain run, so there is no safe default.\n${USAGE}`);
73
+ return 1;
74
+ }
75
+ const max = Number.parseInt(options["max"], 10);
76
+ if (!Number.isInteger(max) || max < 1) {
77
+ console.error(`--max must be a positive integer (got ${JSON.stringify(options["max"])})`);
78
+ return 1;
79
+ }
80
+ if (promptArg === undefined && !options["issue"]) {
81
+ console.error(`a goal is required — either a prompt/path positional, or --issue <id>.\n${USAGE}`);
82
+ return 1;
83
+ }
84
+ if (promptArg !== undefined && options["issue"]) {
85
+ console.error(`pass a goal OR --issue <id>, not both`);
86
+ return 1;
87
+ }
88
+ const chain = findChain(chainName);
89
+ if (!chain) {
90
+ console.error(`unknown chain: ${chainName} — run \`spf list\` to see every chain`);
91
+ return 1;
92
+ }
93
+ const anchor = paths.resolveAnchor(options["cwd"]);
94
+ const configPaths = paths.resolveConfigPaths(anchor, options["config"]).paths;
95
+ const cfg = agents.loadConfig(configPaths);
96
+ const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
97
+ let goal;
98
+ if (options["issue"]) {
99
+ const provider = resolveIssueProvider(cfg);
100
+ if (!provider)
101
+ return 1;
102
+ const issue = await provider.getIssue(options["issue"]);
103
+ if (!issue) {
104
+ console.error(`issue ${options["issue"]} not found`);
105
+ return 1;
106
+ }
107
+ goal = promptFromIssue(issue);
108
+ }
109
+ else {
110
+ goal = resolvePrompt(promptArg);
111
+ }
112
+ const stop = { kind: "script", suite: options["until-suite"] };
113
+ // Fail fast on an unconfigured suite BEFORE minting a ledger or spending
114
+ // anything — the same "before any check runs, let alone any agent spawns"
115
+ // discipline `quality.resolveSuite` documents for itself.
116
+ try {
117
+ quality.resolveSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: "preflight" }), stop.suite);
118
+ }
119
+ catch (error) {
120
+ console.error(error instanceof Error ? error.message : String(error));
121
+ return 1;
122
+ }
123
+ const maxCost = options["max-cost"] ? Number.parseFloat(options["max-cost"]) : undefined;
124
+ const maxTokens = options["max-tokens"] ? Number.parseInt(options["max-tokens"], 10) : undefined;
125
+ const stuckAfter = options["stuck-after"] ? Number.parseInt(options["stuck-after"], 10) : 3;
126
+ const minIntervalMs = options["min-interval-ms"] ? Number.parseInt(options["min-interval-ms"], 10) : 0;
127
+ const goalId = resolveGoalId(options["goal-id"]);
128
+ const runIteration = async (iteration) => {
129
+ const ctx = {
130
+ prompt: iteration.prompt,
131
+ config_paths: configPaths,
132
+ adw_id: iteration.adw_id,
133
+ cwd: anchor.cwd,
134
+ chain_name: chain.name,
135
+ // Nobody is at a TTY for iteration 2 of N — same reasoning `fanout.ts`
136
+ // gives for its own per-attempt runs.
137
+ unattended: true,
138
+ chain_source: chain.source,
139
+ };
140
+ const chainOptions = {};
141
+ if (options["base"] !== undefined)
142
+ chainOptions["base"] = options["base"];
143
+ let exitCode = null;
144
+ let error = null;
145
+ try {
146
+ exitCode = await runChainDef(chain, ctx, chainOptions);
147
+ }
148
+ catch (caught) {
149
+ error = caught.message;
150
+ }
151
+ // Read back tokens/cost/gates by adw_id regardless of outcome — the
152
+ // same "never throw, rank as zero" discipline `fanout.ts`'s own
153
+ // `readMetrics` uses, since these numbers are for the ledger's spend
154
+ // total, not for deciding correctness.
155
+ let tokens = 0;
156
+ let cost = 0;
157
+ if (existsSync(dataPaths.db_path)) {
158
+ const db = new SfDb(dataPaths.db_path);
159
+ try {
160
+ const session = db.session(iteration.adw_id);
161
+ tokens = session?.total_tokens ?? 0;
162
+ cost = session?.total_cost ?? 0;
163
+ }
164
+ finally {
165
+ db.close();
166
+ }
167
+ }
168
+ let commitSha = null;
169
+ let stopVerdict = null;
170
+ if (exitCode === 0 && error === null) {
171
+ const git = makeGit(anchor.repo_root);
172
+ commitSha = git.shortSha();
173
+ const result = quality.runSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: iteration.adw_id }), stop.suite);
174
+ stopVerdict = { passed: result.passed, failures: result.failures, artifacts: result.artifacts };
175
+ }
176
+ return { exit_code: exitCode, error, commit_sha: commitSha, tokens, cost, stop_verdict: stopVerdict };
177
+ };
178
+ const result = await runLoop({
179
+ chainName: chain.name,
180
+ goal,
181
+ stop,
182
+ max,
183
+ budget: { maxCost, maxTokens },
184
+ stuckAfter,
185
+ minIntervalMs,
186
+ dataDir: dataPaths.data_dir,
187
+ goalId,
188
+ runIteration,
189
+ log: (message) => console.log(`[spf] ${message}`),
190
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
191
+ });
192
+ console.log(`\n${summarize(result)}`);
193
+ const spend = cumulativeSpend(result.ledger.attempts);
194
+ if (isInteractive() && result.ledger.attempts.length > 0) {
195
+ console.log(` ${result.ledger.attempts.length} attempt(s), $${spend.cost.toFixed(4)}`);
196
+ }
197
+ return result.exitCode;
198
+ }
@@ -1,7 +1,8 @@
1
1
  /** Shared by both `spf <chain> "..."` and `spf run <chain> "..."` — same dispatch. */
2
2
  import * as paths from "../../core/paths.js";
3
3
  import * as agents from "../../core/agents.js";
4
- import { parseCli, resolvePrompt } from "../../core/utils.js";
4
+ import { newId, parseCli, resolvePrompt } from "../../core/utils.js";
5
+ import { withRunScope } from "../../core/sandbox.js";
5
6
  import { runChain } from "../../chains/index.js";
6
7
  import { isInteractive } from "../ask.js";
7
8
  const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite", "priority"];
@@ -24,10 +25,15 @@ export async function dispatchChain(chain, argv) {
24
25
  return 2;
25
26
  }
26
27
  const anchor = paths.resolveAnchor(options["cwd"]);
28
+ // Minted here, not left null-and-minted-later inside session.ensure — a
29
+ // sandbox lease is keyed on `<adw_id>/<agent>` (design §4.3) and
30
+ // `withRunScope` below needs the SAME id up front to know which leases
31
+ // this run's `finally` must tear down.
32
+ const adwId = options["adw-id"] ?? newId(8);
27
33
  const ctx = {
28
34
  prompt: resolvePrompt(positionals[0]),
29
35
  config_paths: paths.resolveConfigPaths(anchor, options["config"]).paths,
30
- adw_id: options["adw-id"] ?? null,
36
+ adw_id: adwId,
31
37
  cwd: anchor.cwd,
32
38
  chain_name: chain.name,
33
39
  // Only `refine` reads this (a "## Parent: #<id>" back-reference on
@@ -74,11 +80,15 @@ export async function dispatchChain(chain, argv) {
74
80
  const dashboard = mountRunDashboard({ maxCost: cfg.defaults.max_run_cost, maxTokens: cfg.defaults.max_run_tokens });
75
81
  ctx.render_hooks = { sink: dashboard.sink, observer: dashboard.observer, pause: dashboard.pause, resume: dashboard.resume };
76
82
  try {
77
- return await runChain(chain, ctx, chainOptions);
83
+ // Same withRunScope as the unattended path below — an interactive
84
+ // dispatch is just as capable of opening a sandbox lease (design
85
+ // §4.3), and skipping this here would leak it silently since nothing
86
+ // else in this branch calls teardownRun.
87
+ return await withRunScope(adwId, () => runChain(chain, ctx, chainOptions));
78
88
  }
79
89
  finally {
80
90
  await dashboard.close();
81
91
  }
82
92
  }
83
- return runChain(chain, ctx, chainOptions);
93
+ return withRunScope(adwId, () => runChain(chain, ctx, chainOptions));
84
94
  }
@@ -1,4 +1,8 @@
1
- import { type ReviewOutputT } from "../../core/data_types.ts";
1
+ import type { IssueProvider } from "../../core/issues/provider.ts";
2
+ import { type WatchFanoutDeps } from "../../core/watch.ts";
3
+ import { type ChainDefinition } from "../../chains/index.ts";
4
+ import { type ReviewOutputT, type SFConfig } from "../../core/data_types.ts";
5
+ import type { DataPaths } from "../../core/paths.ts";
2
6
  /**
3
7
  * `&`/`<`/`>` are active markup in every destination a digest lands in —
4
8
  * `<url|text>`/`*bold*` in Slack mrkdwn, raw HTML in GitHub's markdown
@@ -16,6 +20,14 @@ export declare function escapeForMarkup(text: string): string;
16
20
  export declare function truncateDigest(text: string): string;
17
21
  /** A `ReviewOutput` envelope, reduced to the short digest threaded into the PR body and the `pr_opened` notification — see `core/watch.ts`'s `ChainRunResult.reviewSummary`. */
18
22
  export declare function formatReviewDigest(review: ReviewOutputT): string;
23
+ /**
24
+ * Shared by `watch`, `watch init`, and `loop`'s `--issue` flag: resolve
25
+ * config into an `IssueProvider` — checking only what every caller needs.
26
+ * `watch`'s own extra checks (a real git repo, a registered chain) don't
27
+ * apply to seeding labels or to a one-shot issue fetch. Prints its own
28
+ * error and returns `null` on failure — the caller just needs to `return 1`.
29
+ */
30
+ export declare function resolveIssueProvider(cfg: SFConfig): IssueProvider | null;
19
31
  /**
20
32
  * `spf watch init` — idempotently seed the `<prefix>:*` labels the state
21
33
  * machine needs, with sensible colors/descriptions. Doesn't touch git or
@@ -31,4 +43,20 @@ export declare function formatReviewDigest(review: ReviewOutputT): string;
31
43
  * seed — see `jira_provider.ts`'s module comment).
32
44
  */
33
45
  export declare function watchInitCommand(argv: string[]): Promise<number>;
46
+ /**
47
+ * The fan-out lane's dispatch trio, as a plain function of config + the
48
+ * resolved chain — mirrors `cli/commands/fanout.ts`'s own `runAttempt`
49
+ * (`:375-404`) and `readMetrics` (`:355-373`) closures, plus `adwIdsFree`
50
+ * (the sessions-db half of that command's reuse preflight) and `reviewFor`
51
+ * (§6.5 — the two expressions `runChain` above already computes, pointed at
52
+ * a WINNER instead of the sole attempt). Exported as a plain function of its
53
+ * inputs — not a `watchCommand`-local closure — the same reason
54
+ * `linkFanoutDataDir` is exported from `cli/commands/fanout.ts`: it can then
55
+ * be exercised directly against a real repo, real worktrees and a fake
56
+ * chain, with no tracker, no agent and no control plane
57
+ * (`src/test/watch_fanout_sandbox_wiring.test.ts`). `watchCommand` itself
58
+ * calls this SAME factory and spreads the result into `deps.fanout`, so the
59
+ * tested object and the shipped object are the same object.
60
+ */
61
+ export declare function makeWatchFanoutDispatch(cfg: SFConfig, configPaths: string[], dataPaths: DataPaths, chainDef: ChainDefinition): Pick<WatchFanoutDeps, "runAttempt" | "readMetrics" | "adwIdsFree" | "reviewFor">;
34
62
  export declare function watchCommand(argv: string[]): Promise<number>;
@@ -18,7 +18,9 @@ import { JiraProvider } from "../../core/issues/jira_provider.js";
18
18
  import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
19
19
  import { isAuthoringProvider } from "../../core/issues/provider.js";
20
20
  import { createWatchState, tick } from "../../core/watch.js";
21
- import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
21
+ import { findChain, hasCommitStep, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
22
+ import { withRunScope } from "../../core/sandbox.js";
23
+ import { excludeSpfDataFromGit } from "../../core/worktree_data.js";
22
24
  import { ReviewOutput } from "../../core/data_types.js";
23
25
  import { SfDb } from "../../ui/server/db.js";
24
26
  import { parseCli } from "../../core/utils.js";
@@ -72,12 +74,13 @@ export function formatReviewDigest(review) {
72
74
  return truncateDigest(lines.join("\n"));
73
75
  }
74
76
  /**
75
- * Shared by `watch` and `watch init`: resolve config into an `IssueProvider`
76
- * checking only what BOTH need. `watch`'s own extra checks (a real git
77
- * repo, a registered chain) don't apply to seeding labels. Prints its own
77
+ * Shared by `watch`, `watch init`, and `loop`'s `--issue` flag: resolve
78
+ * config into an `IssueProvider` checking only what every caller needs.
79
+ * `watch`'s own extra checks (a real git repo, a registered chain) don't
80
+ * apply to seeding labels or to a one-shot issue fetch. Prints its own
78
81
  * error and returns `null` on failure — the caller just needs to `return 1`.
79
82
  */
80
- function resolveIssueProvider(cfg) {
83
+ export function resolveIssueProvider(cfg) {
81
84
  if (cfg.watch.issue_provider === "github") {
82
85
  // `issue_repo` (falling back to `repo`) — NOT `repo` alone — because
83
86
  // `repo` always names `code_host`'s own repo (see WatchConfigSchema's
@@ -214,6 +217,152 @@ export async function watchInitCommand(argv) {
214
217
  }
215
218
  return 0;
216
219
  }
220
+ /** Shared by `runChain`/`runRefine`/the fan-out lane's dispatch: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. Top-level (not a `watchCommand` local) so `makeWatchFanoutDispatch` below can share it — see that factory's own doc comment for why. */
221
+ function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
222
+ let detail = prefix;
223
+ let db;
224
+ try {
225
+ const wtAnchor = paths.resolveAnchor(cwd);
226
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
227
+ db = new SfDb(wtDataPaths.db_path);
228
+ const failed = db.phases(adwId).find((p) => p.status === "fail");
229
+ if (failed)
230
+ detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
231
+ }
232
+ catch {
233
+ // best-effort — the generic message above still points at where to look
234
+ }
235
+ finally {
236
+ db?.close();
237
+ }
238
+ return detail;
239
+ }
240
+ /**
241
+ * Best-effort: the reviewer's latest verdict for this run, read back from
242
+ * the worktree's own (symlinked) trace db and reduced to a digest — same
243
+ * DB, same try/catch shape as `detailFromFailedPhase` above, but on the
244
+ * SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
245
+ * never produced a `ReviewOutput` envelope at all — never thrown: a digest
246
+ * is a nice-to-have, not something that gets to block the PR-open flow
247
+ * it's decorating. Top-level for the same reason as `detailFromFailedPhase`.
248
+ */
249
+ function reviewSummaryFor(cfg, cwd, adwId) {
250
+ let db;
251
+ try {
252
+ const wtAnchor = paths.resolveAnchor(cwd);
253
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
254
+ db = new SfDb(wtDataPaths.db_path);
255
+ const envelope = db
256
+ .envelopes(adwId)
257
+ .filter((e) => e.output_type === ReviewOutput.name)
258
+ .at(-1); // the LATEST verdict — a revise loop can produce several
259
+ if (!envelope?.payload_json)
260
+ return undefined;
261
+ const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
262
+ return formatReviewDigest(review);
263
+ }
264
+ catch {
265
+ return undefined; // best-effort — see the doc comment above
266
+ }
267
+ finally {
268
+ db?.close();
269
+ }
270
+ }
271
+ /**
272
+ * The fan-out lane's dispatch trio, as a plain function of config + the
273
+ * resolved chain — mirrors `cli/commands/fanout.ts`'s own `runAttempt`
274
+ * (`:375-404`) and `readMetrics` (`:355-373`) closures, plus `adwIdsFree`
275
+ * (the sessions-db half of that command's reuse preflight) and `reviewFor`
276
+ * (§6.5 — the two expressions `runChain` above already computes, pointed at
277
+ * a WINNER instead of the sole attempt). Exported as a plain function of its
278
+ * inputs — not a `watchCommand`-local closure — the same reason
279
+ * `linkFanoutDataDir` is exported from `cli/commands/fanout.ts`: it can then
280
+ * be exercised directly against a real repo, real worktrees and a fake
281
+ * chain, with no tracker, no agent and no control plane
282
+ * (`src/test/watch_fanout_sandbox_wiring.test.ts`). `watchCommand` itself
283
+ * calls this SAME factory and spreads the result into `deps.fanout`, so the
284
+ * tested object and the shipped object are the same object.
285
+ */
286
+ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
287
+ const runAttempt = async (dispatch) => {
288
+ // NO `dispatch.isAborted()` early return, unlike `spf fanout`'s own
289
+ // runAttempt: that check only ever fires under `firstSuccess`, which
290
+ // `spf watch` never sets (`decided` is never flipped — see
291
+ // `core/fanout.ts`). Including a check that can never observe true would
292
+ // be a lie about the abort model this dispatch actually honors.
293
+ const ctx = {
294
+ prompt: dispatch.prompt,
295
+ config_paths: configPaths,
296
+ adw_id: dispatch.adwId, // "issue-<id>-<i>" (or the salted/random base's own) — fanout.ts's attemptAdwId
297
+ cwd: dispatch.cwd, // THIS attempt's own worktree — the Run's repo_root anchor
298
+ chain_name: chainDef.name,
299
+ unattended: true, // same as runChain's and spf fanout's own dispatch — nobody is at a TTY for attempt 2 of 3
300
+ chain_source: chainDef.source,
301
+ };
302
+ // `cfg.watch.chain_options` reaches every attempt exactly as it reaches
303
+ // the single dispatch above — one shared map for all N attempts.
304
+ return withRunScope(dispatch.adwId, () => runChainDef(chainDef, ctx, cfg.watch.chain_options));
305
+ };
306
+ /**
307
+ * Opened and closed PER CALL, unlike `spf fanout`'s lazily-held single
308
+ * handle: `spf watch` is a long-lived daemon with several issues (and
309
+ * fan-outs) potentially in flight at once and no single `finally` to close
310
+ * a shared handle in — the same per-call shape `detailFromFailedPhase`/
311
+ * `reviewSummaryFor` above already use against the same WAL db.
312
+ */
313
+ const readMetrics = (adwId) => {
314
+ if (!existsSync(dataPaths.db_path))
315
+ return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
316
+ let db;
317
+ try {
318
+ db = new SfDb(dataPaths.db_path);
319
+ const gates = db.gates(adwId);
320
+ const session = db.session(adwId);
321
+ // `passed` is a SQLite integer boolean that CAN be NULL on a row an
322
+ // older tracer wrote. Counted explicitly in both directions, never as
323
+ // `!g.passed`: a NULL is unknown, and letting it read as a failure
324
+ // would let a garbled row decide which candidate wins.
325
+ return {
326
+ gate_passes: gates.filter((g) => g.passed === 1).length,
327
+ gate_failures: gates.filter((g) => g.passed === 0).length,
328
+ cost: session?.total_cost ?? 0,
329
+ tokens: session?.total_tokens ?? 0,
330
+ };
331
+ }
332
+ catch {
333
+ return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
334
+ }
335
+ finally {
336
+ db?.close();
337
+ }
338
+ };
339
+ /**
340
+ * The other half of `spf fanout`'s reuse preflight — "does this adw_id
341
+ * have a session row yet" — one `SfDb.session(id)` point lookup per
342
+ * candidate id. Safe direction is FALSE (see `WatchFanoutDeps.adwIdsFree`'s
343
+ * own doc comment): an unreadable db reports "taken" rather than "free".
344
+ */
345
+ const adwIdsFree = (adwIds) => {
346
+ if (!existsSync(dataPaths.db_path))
347
+ return true; // no db yet — nothing to collide with
348
+ let db;
349
+ try {
350
+ db = new SfDb(dataPaths.db_path);
351
+ return adwIds.every((id) => db.session(id) === null);
352
+ }
353
+ catch {
354
+ return false;
355
+ }
356
+ finally {
357
+ db?.close();
358
+ }
359
+ };
360
+ const reviewFor = (opts) => ({
361
+ reviewRequired: resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer"),
362
+ reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId),
363
+ });
364
+ return { runAttempt, readMetrics, adwIdsFree, reviewFor };
365
+ }
217
366
  export async function watchCommand(argv) {
218
367
  const { options, flags } = parseCli(argv, ["cwd", "config"], ["dry-run", "once"]);
219
368
  const anchor = paths.resolveAnchor(options["cwd"]);
@@ -266,6 +415,39 @@ export async function watchCommand(argv) {
266
415
  }
267
416
  }
268
417
  }
418
+ // watch.fanout.n > 1 startup gates — all guarded so watch.fanout.n: 1 (the
419
+ // default) reaches none of them, and a bad configuration stops the daemon
420
+ // before it starts rather than failing silently every tick.
421
+ if (cfg.watch.fanout.n > 1) {
422
+ // §5.1 — `spf fanout` refuses a chain with no commit step
423
+ // (`cli/commands/fanout.ts`); `spf watch`'s single-dispatch lane has no
424
+ // such requirement (a chain that commits nothing is handled honestly by
425
+ // `openPrForWinner`'s own `diffFiles` -> blocked path). For `n > 1` the
426
+ // same gate MUST apply, for a worse reason: `runBestOf` force-removes
427
+ // every SUCCESSFUL LOSER's worktree after selection
428
+ // (`git worktree remove --force`, which deletes uncommitted/untracked
429
+ // files with no confirmation). A chain with no commit step leaves its
430
+ // entire payload as uncommitted edits, so fan-out would destroy N-1
431
+ // candidates outright and then block the issue anyway once the winner's
432
+ // own empty diff is discovered.
433
+ const watchChain = findChain(cfg.watch.chain); // already checked above
434
+ if (!hasCommitStep(watchChain.phases)) {
435
+ console.error(`watch.fanout.n is ${cfg.watch.fanout.n} but watch.chain ${JSON.stringify(cfg.watch.chain)} has no commit phase ` +
436
+ `(${watchChain.phases}) — best-of-N discards every losing attempt's worktree (uncommitted work included), ` +
437
+ `so a chain that leaves its payload uncommitted would destroy N-1 candidates and then block the issue for ` +
438
+ `an empty diff. Use a chain that commits (plan-build, plan-build-test, plan-build-test-quality, simple-sdlc, ` +
439
+ `or a repo-local chain with a commit step), or set watch.fanout.n: 1.`);
440
+ return 1;
441
+ }
442
+ // §5.2 — `spf fanout` prints this once, interactively, when it's about
443
+ // to spend --n times over an unbounded ceiling; a daemon would otherwise
444
+ // repeat that bill every tick with nobody watching, so it's said once,
445
+ // loudly, at startup instead.
446
+ if (cfg.defaults.max_run_cost === undefined && cfg.defaults.max_run_tokens === undefined) {
447
+ console.log(`[spf] watch no defaults.max_run_cost / defaults.max_run_tokens configured — watch.fanout.n=${cfg.watch.fanout.n} ` +
448
+ `means every claimed issue runs ${cfg.watch.fanout.n} attempts to completion, unbounded`);
449
+ }
450
+ }
269
451
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
270
452
  const lockPath = path.join(dataPaths.data_dir, "watch.lock");
271
453
  try {
@@ -308,61 +490,20 @@ export async function watchCommand(argv) {
308
490
  console.error(`watch: refusing to link ${target} to itself — worktree path resolved to the main repo's own data_dir`);
309
491
  return;
310
492
  }
311
- if (existsSync(target))
312
- return;
313
- mkdirSync(path.dirname(target), { recursive: true });
314
- symlinkSync(dataPaths.data_dir, target, "dir");
315
- }
316
- /** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
317
- function detailFromFailedPhase(cwd, adwId, prefix) {
318
- let detail = prefix;
319
- let db;
320
- try {
321
- const wtAnchor = paths.resolveAnchor(cwd);
322
- const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
323
- db = new SfDb(wtDataPaths.db_path);
324
- const failed = db.phases(adwId).find((p) => p.status === "fail");
325
- if (failed)
326
- detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
327
- }
328
- catch {
329
- // best-effort — the generic message above still points at where to look
330
- }
331
- finally {
332
- db?.close();
333
- }
334
- return detail;
335
- }
336
- /**
337
- * Best-effort: the reviewer's latest verdict for this run, read back from
338
- * the worktree's own (symlinked) trace db and reduced to a digest — same
339
- * DB, same try/catch shape as `detailFromFailedPhase` above, but on the
340
- * SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
341
- * never produced a `ReviewOutput` envelope at all — never thrown: a digest
342
- * is a nice-to-have, not something that gets to block the PR-open flow
343
- * it's decorating.
344
- */
345
- function reviewSummaryFor(cwd, adwId) {
346
- let db;
347
- try {
348
- const wtAnchor = paths.resolveAnchor(cwd);
349
- const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
350
- db = new SfDb(wtDataPaths.db_path);
351
- const envelope = db
352
- .envelopes(adwId)
353
- .filter((e) => e.output_type === ReviewOutput.name)
354
- .at(-1); // the LATEST verdict — a revise loop can produce several
355
- if (!envelope?.payload_json)
356
- return undefined;
357
- const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
358
- return formatReviewDigest(review);
359
- }
360
- catch {
361
- return undefined; // best-effort — see the doc comment above
362
- }
363
- finally {
364
- db?.close();
493
+ if (!existsSync(target)) {
494
+ mkdirSync(path.dirname(target), { recursive: true });
495
+ symlinkSync(dataPaths.data_dir, target, "dir");
365
496
  }
497
+ // Keeps the symlink invisible to `git status`/`git add -A` in THIS
498
+ // worktree — see `core/worktree_data.ts`'s doc comment for the ELOOP
499
+ // disaster this closes. Called every time, guarded by nothing of its
500
+ // own: it's idempotent and best-effort (a `git rev-parse` failure is
501
+ // swallowed), so there's no wrong time to call it, including when
502
+ // resuming an orphaned worktree whose `.spf/data` symlink already
503
+ // exists. Previously only `spf fanout`'s own worktrees got this; `spf
504
+ // watch`'s single-dispatch worktrees carried the same exposure and now
505
+ // get the identical fix.
506
+ excludeSpfDataFromGit(worktreePath);
366
507
  }
367
508
  const runChain = async (opts) => {
368
509
  // WATCH DIVERGENCE: `findChain` here resolves against the registry
@@ -397,15 +538,15 @@ export async function watchCommand(argv) {
397
538
  // LIMITATION from PR #20: an unattended watch dispatch used to call
398
539
  // `runChainDef` with no options at all, so nothing --suite-shaped could
399
540
  // ever reach it.
400
- const code = await runChainDef(chainDef, ctx, opts.chainOptions);
541
+ const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
401
542
  // Resolved with the SAME options `runChainDef` just ran the chain with,
402
543
  // so a `chain_options` override that swaps a "reviewer" step in or out
403
544
  // is reflected here too, not just each chain's static/YAML default.
404
545
  const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
405
546
  if (code === 0) {
406
- return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
547
+ return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId) };
407
548
  }
408
- const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
549
+ const detail = detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
409
550
  return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
410
551
  };
411
552
  /**
@@ -435,9 +576,9 @@ export async function watchCommand(argv) {
435
576
  };
436
577
  // Same `watch.chain_options` threading as runChain above — see its
437
578
  // comment for the KNOWN LIMITATION this fixes.
438
- const code = await runChainDef(chainDef, ctx, opts.chainOptions);
579
+ const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
439
580
  if (code !== 0) {
440
- 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.`);
581
+ const detail = detailFromFailedPhase(cfg, 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.`);
441
582
  return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
442
583
  }
443
584
  const wtAnchor = paths.resolveAnchor(opts.cwd);
@@ -523,6 +664,20 @@ export async function watchCommand(argv) {
523
664
  notifier?.send(event); // unaffected either way — see mountWatchDashboard's own doc comment
524
665
  dashboard?.mirrorNotify(event);
525
666
  },
667
+ // `undefined` at the default `watch.fanout.n: 1` — `core/watch.ts`'s
668
+ // `runIssue` never even looks at `deps.fanout` in that case, so the
669
+ // object below doesn't exist at runtime at all unless best-of-N is
670
+ // actually on. `makeWatchFanoutDispatch` is the SAME factory
671
+ // `src/test/watch_fanout_sandbox_wiring.test.ts` exercises directly —
672
+ // the tested object and this shipped object are the same object.
673
+ fanout: cfg.watch.fanout.n > 1
674
+ ? {
675
+ n: cfg.watch.fanout.n,
676
+ concurrency: cfg.watch.fanout.concurrency,
677
+ repoRoot: anchor.repo_root, // GitHandle has no repo-root accessor — this is the same value `git` above is bound to
678
+ ...makeWatchFanoutDispatch(cfg, configPaths, dataPaths, findChain(cfg.watch.chain)), // checked at startup
679
+ }
680
+ : undefined,
526
681
  };
527
682
  const state = createWatchState();
528
683
  let stopping = false;