@gr8ful/spf 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +51 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/paths.d.ts +3 -0
  46. package/dist/core/paths.js +48 -1
  47. package/dist/core/runner.d.ts +8 -0
  48. package/dist/core/runner.js +7 -0
  49. package/dist/core/session.d.ts +25 -0
  50. package/dist/core/session.js +97 -28
  51. package/dist/core/tiering.d.ts +145 -0
  52. package/dist/core/tiering.js +235 -0
  53. package/dist/core/tracer.d.ts +10 -0
  54. package/dist/core/tracer.js +12 -0
  55. package/dist/core/watch.d.ts +89 -9
  56. package/dist/core/watch.js +193 -19
  57. package/dist/ui/server/db.d.ts +39 -0
  58. package/dist/ui/server/db.js +61 -0
  59. package/package.json +2 -1
  60. package/dist/test/agent_cc.test.d.ts +0 -1
  61. package/dist/test/agent_cc.test.js +0 -95
  62. package/dist/test/agent_flue.test.d.ts +0 -1
  63. package/dist/test/agent_flue.test.js +0 -83
  64. package/dist/test/chains.test.d.ts +0 -12
  65. package/dist/test/chains.test.js +0 -92
  66. package/dist/test/data_types.test.d.ts +0 -10
  67. package/dist/test/data_types.test.js +0 -220
  68. package/dist/test/env_file.test.d.ts +0 -1
  69. package/dist/test/env_file.test.js +0 -74
  70. package/dist/test/fake_asker.d.ts +0 -23
  71. package/dist/test/fake_asker.js +0 -30
  72. package/dist/test/git_helper.test.d.ts +0 -1
  73. package/dist/test/git_helper.test.js +0 -59
  74. package/dist/test/hermetic_git.d.ts +0 -1
  75. package/dist/test/hermetic_git.js +0 -22
  76. package/dist/test/init_command.test.d.ts +0 -14
  77. package/dist/test/init_command.test.js +0 -136
  78. package/dist/test/interview.test.d.ts +0 -15
  79. package/dist/test/interview.test.js +0 -425
  80. package/dist/test/notify.test.d.ts +0 -1
  81. package/dist/test/notify.test.js +0 -174
  82. package/dist/test/ollama_provider.test.d.ts +0 -1
  83. package/dist/test/ollama_provider.test.js +0 -103
  84. package/dist/test/otel.test.d.ts +0 -26
  85. package/dist/test/otel.test.js +0 -512
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Best-of-N: run ONE prompt N times in parallel, then let CODE pick the winner.
3
+ *
4
+ * THE SHAPE IS NOT NEW, and that is the whole argument for it. `spf watch`
5
+ * already runs N chains CONCURRENTLY IN ONE PROCESS — each with its own git
6
+ * worktree, its own adw_id, its own `Run`, all sharing the main repo's WAL
7
+ * SQLite through a data-dir symlink (see `core/watch.ts`'s `runIssue`,
8
+ * `cli/commands/watch.ts`'s `runChain` closure and its `linkDataDir`). Fan-out
9
+ * is that proven mechanism pointed at one prompt instead of N issues: N
10
+ * sibling Runs with derived adw_ids over N worktrees, followed by a
11
+ * DETERMINISTIC `pickBest`. "Agent proposes, code disposes" — extended to
12
+ * SELECTION, which is the only new idea here.
13
+ *
14
+ * THE UNIT OF ISOLATION IS THE RUN, NEVER AN AGENT CALL. Every attempt gets a
15
+ * fresh worktree and a fresh Run whose `repo_root` is bound once, in the Run
16
+ * constructor. That is what keeps `gates.resolveClaim`,
17
+ * `permissions.snapshot/enforce`, the quality checks and `changes.capture`
18
+ * judging the tree their agents actually wrote in. A per-call cwd swap would
19
+ * point them at the wrong tree and hand out green gates on unverified work; a
20
+ * `SandboxHandle`/cwd-threading design was evaluated for exactly this and
21
+ * REJECTED. Do not reintroduce it.
22
+ *
23
+ * SELECTION READS SQLITE, NOT RETURN VALUES. Gate reports (`tracer.gateRow`)
24
+ * and per-run usage (`sessions.total_tokens`/`total_cost`) already land in the
25
+ * shared db keyed by adw_id, so the basis below is a SELECT away. Widening
26
+ * `steps.runSteps`' return contract to carry metrics back was evaluated and
27
+ * REJECTED as needless blast radius: every chain, every step, and the watch
28
+ * dispatch would change to serve one new caller. `runAttempt` still returns
29
+ * just an exit code, exactly like `runChainDef` does.
30
+ *
31
+ * WHY THE BUDGET SHIPS WITH THIS. An n-multiplier on spend without a ceiling
32
+ * is a footgun — the only budget-like constant before this changeset was
33
+ * `JSON_FIX_ATTEMPTS = 2`. `defaults.max_run_cost`/`max_run_tokens`
34
+ * (`core/agents.ts`'s `assertRunBudget`) are checked before each attempt's
35
+ * NEXT agent call, never after — so one attempt can still overshoot the
36
+ * ceiling by a whole call, and a chain with only ONE agent dispatch
37
+ * (`scout`, `prompt`, `build`) can never trip it at all. With that caveat, N
38
+ * attempts are bounded by roughly N × ceiling, and each one that DOES trip
39
+ * it fails its phase closed with a message naming the ceiling and the spend.
40
+ *
41
+ * WINNER HANDLING KEEPS THE HUMAN ACCOUNTABLE. Each attempt commits on its own
42
+ * branch in its own worktree. The winner's branch AND worktree are KEPT and
43
+ * reported (branch + adw_id + the selection basis); the losers' worktrees and
44
+ * branches are deleted. SPF DOES NOT MERGE THE WINNER — the operator does.
45
+ * Keeping the winner's worktree is deliberate, not laziness: a chain that
46
+ * doesn't commit (or that failed after writing) leaves work in the tree only,
47
+ * and `git worktree remove --force` would destroy it. `git merge`/
48
+ * `git cherry-pick` from the main repo work fine against a branch that is
49
+ * checked out elsewhere, so nothing is blocked by keeping it.
50
+ *
51
+ * ABORT GRANULARITY, stated plainly because it is a real limit — and, as of
52
+ * `deps.firstSuccess`, OPT-IN rather than the default. Best-of-N exists to
53
+ * COMPARE N candidates; the DEFAULT (`firstSuccess` unset/false) therefore
54
+ * dispatches every attempt regardless of how early one succeeds, so the
55
+ * gates/cost/tokens comparison in `pickBest` is over the whole field the
56
+ * operator actually asked for, not "whatever happened to still be queued."
57
+ * Passing `firstSuccess: true` restores first-past-the-post: once an attempt
58
+ * exits 0 the race is decided and no further attempt is STARTED —
59
+ * `deps.runAttempt` is never called for a queued attempt, which is where the
60
+ * saving actually is (with `concurrency` 2 and `--n 4`, half the attempts are
61
+ * typically never dispatched). Either way, attempts ALREADY IN FLIGHT run to
62
+ * completion: `steps.runSteps` has no cancellation seam, and adding one would
63
+ * mean changing the driver every chain shares for the benefit of this one
64
+ * caller. `AttemptDispatch.isAborted()` is threaded to the runner so a
65
+ * dispatch that CAN check between phases does when `firstSuccess` is on, and
66
+ * so a test can prove the flag is honored — cooperative by design, not by
67
+ * accident.
68
+ */
69
+ import path from "node:path";
70
+ import { removeRunWorktree, withRunWorktree } from "./git_helper.js";
71
+ /** Sentinel exit code meaning "never dispatched — the race was already decided". Distinct from any real chain exit code. */
72
+ export const ABORTED_EXIT = -1;
73
+ export const ZERO_METRICS = { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
74
+ // ── naming (deterministic, so a killed run leaves cleanable debris) ──────────
75
+ /** `<baseAdw>-<i>` — a derived, greppable sibling of the base id. */
76
+ export function attemptAdwId(baseAdwId, index) {
77
+ return `${baseAdwId}-${index}`;
78
+ }
79
+ /**
80
+ * `spf/fanout/<baseAdw>-<i>` — namespaced so the winner is obvious in
81
+ * `git branch` and the losers are obviously disposable. Separate from
82
+ * `spf-watch/*`/`spf-refine/*` (see `core/watch.ts`) because these branches
83
+ * are never pushed by SPF: a human merges the winner from the local ref.
84
+ */
85
+ export function attemptBranch(baseAdwId, index) {
86
+ return `spf/fanout/${attemptAdwId(baseAdwId, index)}`;
87
+ }
88
+ /** The worktree leaf name. Deterministic from the adw_id, like watch's `issue-<id>`. */
89
+ export function attemptWorktreeName(baseAdwId, index) {
90
+ return `fanout-${attemptAdwId(baseAdwId, index)}`;
91
+ }
92
+ export function attemptWorktreePath(worktreesDir, baseAdwId, index) {
93
+ return path.join(worktreesDir, attemptWorktreeName(baseAdwId, index));
94
+ }
95
+ // ── selection: the deterministic disposer ───────────────────────────────────
96
+ /**
97
+ * The selection basis, in order. Every criterion is read from the shared
98
+ * SQLite or measured by this module — never asked of an agent.
99
+ *
100
+ * 1. the run SUCCEEDED (exit 0). A failed attempt is not a candidate at all.
101
+ * 2. fewest gate FAILURES, then most gate PASSES. Failures first because zero
102
+ * is the bar: an attempt that needed a correction round is worse than one
103
+ * that never tripped a gate, regardless of how many gates each ran. Passes
104
+ * break that tie in favour of the attempt that VERIFIED more.
105
+ * 3. lowest total cost.
106
+ * 4. fewest tokens.
107
+ * 5. lowest wall time — a LAST RESORT, deliberately below cost and tokens:
108
+ * every code phase runs through `spawnSync` (`core/quality.ts`), which
109
+ * blocks the shared event loop, so "concurrent" attempts partly serialize
110
+ * on exactly the phases fan-out means to compare, and `wall_ms` ends up
111
+ * favouring whichever attempt happened to run last by a scheduling
112
+ * accident rather than anything about the candidate. Cost and tokens are
113
+ * read from the trace and are not subject to that; wall time still
114
+ * breaks a genuine tie between two runs identical on both.
115
+ * 6. lexicographic adw_id.
116
+ *
117
+ * (6) exists to make this a TOTAL order: adw_ids are unique per attempt, so
118
+ * the comparator can never return 0 for two different attempts, so the winner
119
+ * cannot depend on which attempt happened to finish first. That is what
120
+ * "deterministic" has to mean here — pinned by a permutation test in
121
+ * `src/test/fanout.test.ts`.
122
+ */
123
+ export function compareAttempts(a, b) {
124
+ const rank = (attempt) => (attempt.status === "success" ? 0 : 1);
125
+ return (rank(a) - rank(b) ||
126
+ a.gate_failures - b.gate_failures ||
127
+ b.gate_passes - a.gate_passes ||
128
+ a.cost - b.cost ||
129
+ a.tokens - b.tokens ||
130
+ a.wall_ms - b.wall_ms ||
131
+ (a.adw_id < b.adw_id ? -1 : a.adw_id > b.adw_id ? 1 : 0));
132
+ }
133
+ /** Which criterion actually separated the winner from its closest rival — the honest half of a printed basis. */
134
+ function decidingCriterion(winner, runnerUp) {
135
+ if (winner.gate_failures !== runnerUp.gate_failures) {
136
+ return `fewer gate failures (${winner.gate_failures} vs ${runnerUp.gate_failures})`;
137
+ }
138
+ if (winner.gate_passes !== runnerUp.gate_passes) {
139
+ return `more gate passes (${winner.gate_passes} vs ${runnerUp.gate_passes})`;
140
+ }
141
+ if (winner.cost !== runnerUp.cost) {
142
+ return `lower cost ($${winner.cost.toFixed(4)} vs $${runnerUp.cost.toFixed(4)})`;
143
+ }
144
+ if (winner.tokens !== runnerUp.tokens) {
145
+ return `fewer tokens (${winner.tokens.toLocaleString("en-US")} vs ${runnerUp.tokens.toLocaleString("en-US")})`;
146
+ }
147
+ if (winner.wall_ms !== runnerUp.wall_ms) {
148
+ // Named as contended, not a clean property of the candidate — see the
149
+ // criteria list above.
150
+ return `lower wall time (${(winner.wall_ms / 1000).toFixed(1)}s vs ${(runnerUp.wall_ms / 1000).toFixed(1)}s — contended under concurrency, a last resort)`;
151
+ }
152
+ return `lexicographic adw_id tiebreak (${winner.adw_id} before ${runnerUp.adw_id})`;
153
+ }
154
+ /**
155
+ * Pick the winner. PURE: no I/O, no clock, no filesystem — everything it
156
+ * needs is already on the attempts, which is what makes the selection
157
+ * testable and reproducible from a trace.
158
+ *
159
+ * Sorts a COPY: the caller's array keeps its index order for printing.
160
+ */
161
+ export function pickBest(attempts) {
162
+ const succeeded = attempts.filter((a) => a.status === "success");
163
+ if (succeeded.length === 0) {
164
+ return { winner: null, basis: `no attempt succeeded (${attempts.length} tried) — nothing to select` };
165
+ }
166
+ const ranked = [...succeeded].sort(compareAttempts);
167
+ const winner = ranked[0];
168
+ const summary = `${winner.gate_failures} gate failure(s), ${winner.gate_passes} gate pass(es), ` +
169
+ `$${winner.cost.toFixed(4)}, ${(winner.wall_ms / 1000).toFixed(1)}s`;
170
+ if (ranked.length === 1) {
171
+ return {
172
+ winner,
173
+ basis: `the only attempt that succeeded of ${attempts.length} — ${summary}`,
174
+ };
175
+ }
176
+ return {
177
+ winner,
178
+ basis: `${decidingCriterion(winner, ranked[1])} over ${ranked[1].adw_id}; ${succeeded.length} of ${attempts.length} attempt(s) succeeded — ${summary}`,
179
+ };
180
+ }
181
+ /**
182
+ * Run `n` attempts of one prompt, select deterministically, clean up the
183
+ * losers, keep the winner.
184
+ *
185
+ * Concurrency is the same inflight-budget idea as `watch.claimNewWork`,
186
+ * expressed as a fixed worker pool over a shared queue: at most
187
+ * `concurrency` attempts are ever in flight, and a worker that finds the race
188
+ * already decided records the remaining attempts as `skipped` WITHOUT
189
+ * dispatching them. Attempts are returned in index order regardless of the
190
+ * order they finished — the printed table must not shuffle between runs.
191
+ */
192
+ export async function runBestOf(deps) {
193
+ deps.log(`fanout: chain "${deps.chainName}" x${deps.n} off ${deps.baseBranch}, concurrency ${deps.concurrency}, ` +
194
+ `adw_id ${attemptAdwId(deps.baseAdwId, 1)}..${attemptAdwId(deps.baseAdwId, deps.n)}` +
195
+ (deps.firstSuccess ? ", first-success (stops dispatching once one attempt succeeds)" : ""));
196
+ const queue = Array.from({ length: deps.n }, (_, i) => i + 1);
197
+ const attempts = [];
198
+ /** Flipped exactly once, by the first attempt to exit 0. Never flipped back. */
199
+ let decided = false;
200
+ const runOne = async (index) => {
201
+ const adwId = attemptAdwId(deps.baseAdwId, index);
202
+ const branch = attemptBranch(deps.baseAdwId, index);
203
+ const worktree = attemptWorktreePath(deps.worktreesDir, deps.baseAdwId, index);
204
+ const started = performance.now();
205
+ let exitCode = null;
206
+ let error = null;
207
+ try {
208
+ exitCode = await withRunWorktree(deps.repoRoot, attemptWorktreeName(deps.baseAdwId, index), {
209
+ worktreesDir: deps.worktreesDir,
210
+ branch,
211
+ baseBranch: deps.baseBranch,
212
+ linkDataDir: deps.linkDataDir,
213
+ git: deps.git,
214
+ log: deps.log,
215
+ }, async (wt) => {
216
+ const code = await deps.runAttempt({
217
+ index,
218
+ adwId,
219
+ cwd: wt.path,
220
+ branch,
221
+ prompt: deps.prompt,
222
+ isAborted: () => decided,
223
+ });
224
+ // Only a successful attempt can be selected, so only a successful
225
+ // attempt's tree and branch are worth carrying past this scope.
226
+ // A failed one is cleaned up NOW rather than lingering while its
227
+ // siblings finish — its trace survives in the shared SQLite
228
+ // regardless, which is where a post-mortem looks anyway.
229
+ if (code === 0)
230
+ wt.keep();
231
+ return code;
232
+ });
233
+ }
234
+ catch (caught) {
235
+ error = caught.message;
236
+ deps.log(`fanout: attempt ${index} (${adwId}) errored: ${error}`);
237
+ }
238
+ if (exitCode === 0 && deps.firstSuccess)
239
+ decided = true;
240
+ let metrics = ZERO_METRICS;
241
+ try {
242
+ metrics = deps.readMetrics(adwId);
243
+ }
244
+ catch (caught) {
245
+ // Metrics are for RANKING, never for correctness — an attempt whose
246
+ // rows can't be read ranks as a zero-gate, zero-cost run rather than
247
+ // failing the whole fan-out.
248
+ deps.log(`fanout: could not read metrics for ${adwId}: ${caught.message}`);
249
+ }
250
+ // `ABORTED_EXIT` is a runner saying "I declined to spend" — a sibling won
251
+ // between this attempt leaving the queue and its first agent call. That is
252
+ // a skip, not a failure: reporting it as `fail` would make a clean
253
+ // early-abort look like N-1 broken runs in the result table.
254
+ const status = exitCode === 0 ? "success" : exitCode === ABORTED_EXIT ? "skipped" : error !== null ? "error" : "fail";
255
+ return {
256
+ index,
257
+ adw_id: adwId,
258
+ branch,
259
+ worktree,
260
+ status,
261
+ exit_code: exitCode,
262
+ error,
263
+ wall_ms: performance.now() - started,
264
+ kept: false,
265
+ ...metrics,
266
+ };
267
+ };
268
+ const skipped = (index) => ({
269
+ index,
270
+ adw_id: attemptAdwId(deps.baseAdwId, index),
271
+ branch: attemptBranch(deps.baseAdwId, index),
272
+ worktree: attemptWorktreePath(deps.worktreesDir, deps.baseAdwId, index),
273
+ status: "skipped",
274
+ exit_code: ABORTED_EXIT,
275
+ error: null,
276
+ wall_ms: 0,
277
+ kept: false,
278
+ ...ZERO_METRICS,
279
+ });
280
+ const worker = async () => {
281
+ for (;;) {
282
+ const index = queue.shift();
283
+ if (index === undefined)
284
+ return;
285
+ if (decided) {
286
+ deps.log(`fanout: attempt ${index} skipped — an earlier attempt already succeeded`);
287
+ attempts.push(skipped(index));
288
+ continue;
289
+ }
290
+ attempts.push(await runOne(index));
291
+ }
292
+ };
293
+ const workers = Array.from({ length: Math.max(1, Math.min(deps.concurrency, deps.n)) }, () => worker());
294
+ await Promise.all(workers);
295
+ attempts.sort((a, b) => a.index - b.index);
296
+ const { winner, basis } = pickBest(attempts);
297
+ // Cleanup is the disposer's last act, and it runs AFTER selection because a
298
+ // loser is only knowable once every attempt has been ranked. Failed and
299
+ // skipped attempts own nothing by now (the scoped worktree helper already
300
+ // removed a failed one; a skipped one never had a tree), so this only ever
301
+ // touches attempts that SUCCEEDED and lost.
302
+ for (const attempt of attempts) {
303
+ if (attempt.status !== "success")
304
+ continue;
305
+ if (winner && attempt.adw_id === winner.adw_id) {
306
+ attempt.kept = true;
307
+ continue;
308
+ }
309
+ deps.log(`fanout: discarding attempt ${attempt.index} (${attempt.adw_id}) — branch ${attempt.branch}`);
310
+ removeRunWorktree(deps.repoRoot, { path: attempt.worktree, branch: attempt.branch }, { git: deps.git, log: deps.log });
311
+ }
312
+ return { winner, basis, attempts };
313
+ }
@@ -40,6 +40,14 @@ export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunConte
40
40
  *
41
41
  * "Container" and "leaf" are derived from the graph, not asserted by the
42
42
  * agent: a node is a container iff some other node names it as `parent`.
43
+ *
44
+ * `questions` (see `RefineQuestionSchema`'s doc comment) is the human-in-the-
45
+ * loop escalation path, and it is mutually exclusive with `issues`: a
46
+ * refinement that raises material ambiguity must publish NOTHING this round
47
+ * — a partial tree pinned to an unanswered question is worse than none.
48
+ * When `questions` is non-empty this gate checks only its own shape (unique,
49
+ * non-blank ids; non-blank question text) and skips every `issues` rule
50
+ * below, since there is no tree to validate.
43
51
  */
44
52
  export declare function refinementWellFormed(envelope: EnvelopeBase, _run: RunContext): GateReport;
45
53
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
@@ -141,12 +141,35 @@ const LEAF_KINDS = new Set(["story", "bug", "task"]);
141
141
  *
142
142
  * "Container" and "leaf" are derived from the graph, not asserted by the
143
143
  * agent: a node is a container iff some other node names it as `parent`.
144
+ *
145
+ * `questions` (see `RefineQuestionSchema`'s doc comment) is the human-in-the-
146
+ * loop escalation path, and it is mutually exclusive with `issues`: a
147
+ * refinement that raises material ambiguity must publish NOTHING this round
148
+ * — a partial tree pinned to an unanswered question is worse than none.
149
+ * When `questions` is non-empty this gate checks only its own shape (unique,
150
+ * non-blank ids; non-blank question text) and skips every `issues` rule
151
+ * below, since there is no tree to validate.
144
152
  */
145
153
  export function refinementWellFormed(envelope, _run) {
146
154
  const report = new GateReport();
147
155
  const issues = envelope.issues ?? [];
156
+ const questions = envelope.questions ?? [];
157
+ if (questions.length > 0) {
158
+ report.check("issues empty while escalating", issues.length === 0, issues.length === 0
159
+ ? "no issues published alongside the questions"
160
+ : `${issues.length} issue(s) published alongside ${questions.length} question(s) — escalating means publishing nothing this round`);
161
+ const seenIds = new Set();
162
+ for (const q of questions) {
163
+ const blank = !q.id.trim();
164
+ const dup = !blank && seenIds.has(q.id);
165
+ seenIds.add(q.id);
166
+ report.check(`question ${JSON.stringify(q.id)}`, !blank && !dup, blank ? "question id is blank" : dup ? "duplicate question id — every question needs a unique id within this round" : "id ok");
167
+ report.check(`question ${JSON.stringify(q.id)}.question`, q.question.trim().length > 0, q.question.trim().length > 0 ? "has text" : "question text is blank");
168
+ }
169
+ return report;
170
+ }
148
171
  if (issues.length === 0) {
149
- report.check("issues", false, "a refinement produced no issues at all — decompose the spec into at least one leaf");
172
+ report.check("issues", false, "a refinement produced no issues at all and raised no questions — decompose the spec into at least one leaf, or escalate what's ambiguous");
150
173
  return report;
151
174
  }
152
175
  const byKey = new Map();
@@ -86,3 +86,101 @@ export interface GitHandle {
86
86
  }
87
87
  /** Every operation this returns is bound to `repoRoot` — never `process.cwd()`. */
88
88
  export declare function makeGit(repoRoot: string): GitHandle;
89
+ export interface RunWorktreeSpec {
90
+ /** Absolute path of the worktree's working directory. */
91
+ path: string;
92
+ /** The branch created at `path`. Deleting it requires removing the worktree first. */
93
+ branch: string;
94
+ }
95
+ export interface RunWorktreeOptions {
96
+ /** Absolute. OUTSIDE the repo — a worktree inside it would be walked by the repo's own tooling. */
97
+ worktreesDir: string;
98
+ /** Created by `git worktree add -b`, so it must NOT already exist (see the cleanup-first note below). */
99
+ branch: string;
100
+ /** Branch name only (`"main"`), not a remote ref — the remote prefix is applied by `resolveStartPoint`. */
101
+ baseBranch: string;
102
+ /** Default "origin". */
103
+ remote?: string;
104
+ /**
105
+ * Default true: fetch `remote/baseBranch` before branching, so a long-lived
106
+ * process doesn't fork every run off a stale base. Unlike watch's inlined
107
+ * `git.fetch(...)`, a FAILED fetch here is not fatal — see `resolveStartPoint`.
108
+ */
109
+ fetchBase?: boolean;
110
+ /** Overrides the whole base resolution — an explicit ref (sha, tag, local branch) to branch from. */
111
+ startPoint?: string;
112
+ /** Wire `<worktree>/.spf/data` to the MAIN repo's data dir. Injected, never done here — see the section comment. */
113
+ linkDataDir?: (worktreePath: string) => void;
114
+ /** Bound to the MAIN repo root. Injected so a unit test needs no real repo; defaults to `makeGit(repoRoot)`. */
115
+ git?: GitHandle;
116
+ log?: (message: string) => void;
117
+ }
118
+ /**
119
+ * Create `<worktreesDir>/<name>` on a fresh `branch` off the resolved base,
120
+ * and wire its data dir. Returns the spec its caller must eventually hand to
121
+ * `removeRunWorktree`.
122
+ *
123
+ * CLEARS A STALE WORKTREE DIRECTORY FIRST, but only when it's safe to: the
124
+ * path is deterministic from `name`, so the only way it can already exist is
125
+ * a previous attempt that never reached its own cleanup (killed mid-run,
126
+ * crashed, machine restart) — OR a previous attempt whose kept payload lives
127
+ * only in that tree's uncommitted edits (see `cli/commands/fanout.ts`'s
128
+ * winner-handling note: a chain with no commit step leaves its work in the
129
+ * worktree, not on the branch). `git worktree remove --force` deletes
130
+ * modified/untracked files with no confirmation and exit 0, so blindly
131
+ * force-removing here would silently destroy a kept winner the moment a
132
+ * `--adw-id` retry reuses its deterministic name. Refuse instead, loudly,
133
+ * before touching anything. `worktreeRemove` is a no-op when there is
134
+ * nothing to remove, so a genuinely stale (clean) leftover is still cleared
135
+ * exactly as before. (Same reasoning as `core/watch.ts`'s `runIssue`, which
136
+ * clears its own worktree the same way — modulo this same guard.)
137
+ *
138
+ * ALSO REFUSES A PRE-EXISTING BRANCH up front, for the same reason and the
139
+ * same actionable-error idea: the fan-out winner's branch is handed to a
140
+ * human to merge and can share this exact deterministic name across separate
141
+ * invocations (an explicit `--adw-id` retry), so it is never deleted here —
142
+ * see `removeRunWorktree` for the only place that happens, once an attempt
143
+ * is known to be discarded. Checking first turns `git worktree add -b`'s raw
144
+ * `fatal: a branch named '...' already exists` (which every attempt would
145
+ * otherwise hit identically, for the least helpful reason) into one message
146
+ * naming the collision and the way out.
147
+ */
148
+ export declare function createRunWorktree(repoRoot: string, name: string, opts: RunWorktreeOptions): RunWorktreeSpec;
149
+ /**
150
+ * Remove a run worktree and delete its branch. NEVER throws — cleanup runs on
151
+ * the failure path too, and a cleanup error must not replace the real error
152
+ * that got us here.
153
+ *
154
+ * Order matters and is not cosmetic: git refuses to delete a branch that is
155
+ * checked out in a worktree, so the worktree goes first. A caller that wants
156
+ * to KEEP the branch (the fan-out winner) must simply not call this.
157
+ */
158
+ export declare function removeRunWorktree(repoRoot: string, worktree: RunWorktreeSpec, opts?: {
159
+ git?: GitHandle;
160
+ log?: (message: string) => void;
161
+ }): void;
162
+ /** What the callback of `withRunWorktree` is handed. */
163
+ export interface RunWorktreeHandle extends RunWorktreeSpec {
164
+ /**
165
+ * Make this worktree and its branch SURVIVE the scope.
166
+ *
167
+ * The whole point of the scoped form: a caller that cannot decide the fate
168
+ * of a tree until it sees the result (fan-out keeps a successful attempt's
169
+ * branch so it can still be selected and, if it wins, handed to a human;
170
+ * a failed attempt's tree is worthless the moment it fails) says so from
171
+ * inside, once it knows. Not calling it is the default, and the default is
172
+ * cleanup.
173
+ */
174
+ keep(): void;
175
+ }
176
+ /**
177
+ * Run `fn` against a fresh run worktree, cleaning it up afterwards unless
178
+ * `wt.keep()` was called — including when `fn` throws, which is the case a
179
+ * hand-rolled create/remove pair reliably gets wrong.
180
+ *
181
+ * `opts` sits BEFORE the callback (a small departure from the usual
182
+ * `(a, b, fn)` shape) so the required options can't be forgotten behind an
183
+ * optional trailing argument, and so the callback stays the last thing you
184
+ * read — the JS convention that matters here.
185
+ */
186
+ export declare function withRunWorktree<T>(repoRoot: string, name: string, opts: RunWorktreeOptions, fn: (worktree: RunWorktreeHandle) => Promise<T>): Promise<T>;
@@ -10,6 +10,7 @@
10
10
  * and conflating them is exactly the anchor bug this factory exists to close.
11
11
  */
12
12
  import { spawnSync } from "node:child_process";
13
+ import { existsSync, mkdirSync } from "node:fs";
13
14
  import path from "node:path";
14
15
  function git(args, cwd) {
15
16
  const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
@@ -153,3 +154,129 @@ export function makeGit(repoRoot) {
153
154
  },
154
155
  };
155
156
  }
157
+ /**
158
+ * Where the base of a new run worktree comes from.
159
+ *
160
+ * A fetch failure is logged, not thrown: `spf fanout` is a local, interactive
161
+ * command that must still work on a repo with no remote, offline, or behind a
162
+ * proxy — and `refExists` below then falls back to the LOCAL base branch,
163
+ * which is the honest answer in all three cases. (Watch differs on purpose:
164
+ * a daemon that silently starts branching off a stale local base for hours is
165
+ * worse than a loud failure, so it keeps its unguarded `git.fetch`.)
166
+ */
167
+ function resolveStartPoint(git, opts) {
168
+ if (opts.startPoint)
169
+ return opts.startPoint;
170
+ const remote = opts.remote ?? "origin";
171
+ if (opts.fetchBase !== false) {
172
+ try {
173
+ git.fetch(remote, opts.baseBranch);
174
+ }
175
+ catch (error) {
176
+ // First line only: `git fetch`'s own error is the underlying command's
177
+ // full multiline stderr, and an offline/local-only repo hits this on
178
+ // EVERY attempt — five lines of "'origin' does not appear to be a git
179
+ // repository" repeated N times drowns out everything else fan-out prints.
180
+ const firstLine = error.message.split("\n")[0];
181
+ opts.log?.(`worktree: fetch ${remote} ${opts.baseBranch} failed (${firstLine}) — using the local base`);
182
+ }
183
+ }
184
+ const remoteRef = `${remote}/${opts.baseBranch}`;
185
+ return git.refExists(remoteRef) ? remoteRef : opts.baseBranch;
186
+ }
187
+ /**
188
+ * Create `<worktreesDir>/<name>` on a fresh `branch` off the resolved base,
189
+ * and wire its data dir. Returns the spec its caller must eventually hand to
190
+ * `removeRunWorktree`.
191
+ *
192
+ * CLEARS A STALE WORKTREE DIRECTORY FIRST, but only when it's safe to: the
193
+ * path is deterministic from `name`, so the only way it can already exist is
194
+ * a previous attempt that never reached its own cleanup (killed mid-run,
195
+ * crashed, machine restart) — OR a previous attempt whose kept payload lives
196
+ * only in that tree's uncommitted edits (see `cli/commands/fanout.ts`'s
197
+ * winner-handling note: a chain with no commit step leaves its work in the
198
+ * worktree, not on the branch). `git worktree remove --force` deletes
199
+ * modified/untracked files with no confirmation and exit 0, so blindly
200
+ * force-removing here would silently destroy a kept winner the moment a
201
+ * `--adw-id` retry reuses its deterministic name. Refuse instead, loudly,
202
+ * before touching anything. `worktreeRemove` is a no-op when there is
203
+ * nothing to remove, so a genuinely stale (clean) leftover is still cleared
204
+ * exactly as before. (Same reasoning as `core/watch.ts`'s `runIssue`, which
205
+ * clears its own worktree the same way — modulo this same guard.)
206
+ *
207
+ * ALSO REFUSES A PRE-EXISTING BRANCH up front, for the same reason and the
208
+ * same actionable-error idea: the fan-out winner's branch is handed to a
209
+ * human to merge and can share this exact deterministic name across separate
210
+ * invocations (an explicit `--adw-id` retry), so it is never deleted here —
211
+ * see `removeRunWorktree` for the only place that happens, once an attempt
212
+ * is known to be discarded. Checking first turns `git worktree add -b`'s raw
213
+ * `fatal: a branch named '...' already exists` (which every attempt would
214
+ * otherwise hit identically, for the least helpful reason) into one message
215
+ * naming the collision and the way out.
216
+ */
217
+ export function createRunWorktree(repoRoot, name, opts) {
218
+ const git = opts.git ?? makeGit(repoRoot);
219
+ const worktreePath = path.join(opts.worktreesDir, name);
220
+ if (existsSync(worktreePath)) {
221
+ const status = spawnSync("git", ["status", "--porcelain"], { cwd: worktreePath, encoding: "utf-8" });
222
+ if (status.status === 0 && status.stdout.trim() !== "") {
223
+ throw new Error(`${worktreePath} already exists and has uncommitted changes — refusing to remove it (it may be a ` +
224
+ `previous run's kept winner). Inspect or move it by hand, then retry, or use a fresh --adw-id.`);
225
+ }
226
+ }
227
+ try {
228
+ git.worktreeRemove(worktreePath);
229
+ }
230
+ catch (error) {
231
+ opts.log?.(`worktree: cleanup warning for ${worktreePath}: ${error.message}`);
232
+ }
233
+ if (git.refExists(opts.branch)) {
234
+ throw new Error(`branch ${opts.branch} already exists — delete it (\`git branch -D ${opts.branch}\`) once you no longer ` +
235
+ `need it, or use a fresh --adw-id`);
236
+ }
237
+ mkdirSync(opts.worktreesDir, { recursive: true });
238
+ git.worktreeAdd(worktreePath, opts.branch, resolveStartPoint(git, opts));
239
+ opts.linkDataDir?.(worktreePath);
240
+ return { path: worktreePath, branch: opts.branch };
241
+ }
242
+ /**
243
+ * Remove a run worktree and delete its branch. NEVER throws — cleanup runs on
244
+ * the failure path too, and a cleanup error must not replace the real error
245
+ * that got us here.
246
+ *
247
+ * Order matters and is not cosmetic: git refuses to delete a branch that is
248
+ * checked out in a worktree, so the worktree goes first. A caller that wants
249
+ * to KEEP the branch (the fan-out winner) must simply not call this.
250
+ */
251
+ export function removeRunWorktree(repoRoot, worktree, opts = {}) {
252
+ const git = opts.git ?? makeGit(repoRoot);
253
+ try {
254
+ git.worktreeRemove(worktree.path);
255
+ git.deleteLocalBranch(worktree.branch);
256
+ }
257
+ catch (error) {
258
+ opts.log?.(`worktree: cleanup warning for ${worktree.path}: ${error.message}`);
259
+ }
260
+ }
261
+ /**
262
+ * Run `fn` against a fresh run worktree, cleaning it up afterwards unless
263
+ * `wt.keep()` was called — including when `fn` throws, which is the case a
264
+ * hand-rolled create/remove pair reliably gets wrong.
265
+ *
266
+ * `opts` sits BEFORE the callback (a small departure from the usual
267
+ * `(a, b, fn)` shape) so the required options can't be forgotten behind an
268
+ * optional trailing argument, and so the callback stays the last thing you
269
+ * read — the JS convention that matters here.
270
+ */
271
+ export async function withRunWorktree(repoRoot, name, opts, fn) {
272
+ const spec = createRunWorktree(repoRoot, name, opts);
273
+ let kept = false;
274
+ const handle = { ...spec, keep: () => void (kept = true) };
275
+ try {
276
+ return await fn(handle);
277
+ }
278
+ finally {
279
+ if (!kept)
280
+ removeRunWorktree(repoRoot, spec, { git: opts.git, log: opts.log });
281
+ }
282
+ }
@@ -15,7 +15,7 @@
15
15
  * makes (a repo with >100 open `<prefix>:ready` issues at once is not this
16
16
  * version's problem to solve).
17
17
  */
18
- import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
18
+ import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
19
19
  /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
20
20
  export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
21
21
  export type IssueKind = (typeof ISSUE_KINDS)[number];
@@ -73,7 +73,13 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
73
73
  * 100 sub-issues per parent, 8 levels of nesting.
74
74
  */
75
75
  linkChild(parent: Issue, child: Issue): Promise<void>;
76
+ /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
77
+ private fetchComments;
76
78
  private findMarkerComment;
77
79
  readMarker(issue: Issue): Promise<WatchMarker | null>;
78
80
  writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
81
+ /** Oldest-first (GitHub's own comment order), the hidden marker comment filtered out. */
82
+ listComments(issue: Issue): Promise<IssueComment[]>;
83
+ /** `state_reason: "completed"` — the refine lane's own reason for closing a spec once it's fully decomposed; see `finishSpec` in `watch.ts`. */
84
+ closeIssue(issue: Issue): Promise<void>;
79
85
  }