@gr8ful/spf 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. package/dist/test/watch.test.js +0 -687
@@ -7,6 +7,13 @@
7
7
  * tree -> publish those as real issues -> done/blocked. A spec is not
8
8
  * individually workable, so this lane never opens a PR — it hands the build
9
9
  * lane its next batch of `ready`-able work instead (see `core/refine.ts`).
10
+ * When the refiner raises material ambiguity instead of a tree, this lane
11
+ * loops through a human instead of guessing or blocking: `escalateSpec`
12
+ * posts the questions and moves the spec to `needs-feedback`; a human
13
+ * answers in the issue's comments and adds `continue-refinement`;
14
+ * `claimSpecs` resumes it — the SAME `adw_id`, comment thread folded into
15
+ * the prompt (`buildSpecPrompt`) — for as many rounds as it takes. See
16
+ * `provider.ts`'s `WatchState` doc comment for the full state diagram.
10
17
  *
11
18
  * Provider-agnostic (drives whatever `IssueProvider` it's given) and
12
19
  * chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
@@ -34,11 +41,27 @@
34
41
  * - Worktree-per-issue, isolated outside the repo, made cheap by SPF's
35
42
  * own `--cwd` support: no new chain-dispatch plumbing needed, just
36
43
  * pointing an existing chain at a different working tree.
44
+ *
45
+ * What's NEW since the lean v1 above: `claimNewWork` no longer walks
46
+ * `listEligible()` in whatever order the tracker happened to return —
47
+ * `orderEligible` sorts by priority (a `<prefix>:priority:pN` label), then
48
+ * sibling affinity (prefer a leaf whose parent already has work in flight),
49
+ * then creation order; and it refuses to claim a leaf whose `blocked_by`
50
+ * isn't fully `<prefix>:done` yet (`frontierBlockedOn`) — making real, at
51
+ * last, the frontier `assets/prompts/refiner/system.md` has always promised
52
+ * the refiner. `finishReviews` also now closes a landed leaf and rolls a
53
+ * container up to `done` + closed once every child under it carries
54
+ * `<prefix>:done` (`rollUp`) — still no Projects v2 mirroring, no CI-fix
55
+ * retry loop, no auto-merge; those remain deliberately out of scope.
37
56
  */
38
57
  import path from "node:path";
58
+ import { PRIORITY_RANK } from "./data_types.js";
59
+ import { parseRefineMarker } from "./refine.js";
39
60
  const MAX_ORPHAN_ATTEMPTS = 2;
61
+ /** GitHub's own documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild` doc comment) — `rollUp`'s own recursion bound, so a malformed/cyclic hierarchy can't spin forever. */
62
+ const MAX_ROLLUP_DEPTH = 8;
40
63
  export function createWatchState() {
41
- return { inflight: new Set(), refining: new Set() };
64
+ return { inflight: new Set(), refining: new Set(), inflightParents: new Map() };
42
65
  }
43
66
  function slugifyTitle(title) {
44
67
  return (title
@@ -124,20 +147,30 @@ export async function reconcileOrphans(deps, state) {
124
147
  }
125
148
  }
126
149
  /**
127
- * Post the summary comment on a decomposed spec and transition it to `done`
128
- * — the refine lane's one shared finishing move, reached from both the
129
- * normal path (`runSpec`, right after a successful publish) and the
130
- * orphan-resume path (`reconcileRefining`, when a completed publish's
131
- * marker survived a crash the `transition` itself didn't). `created` only
132
- * has titles/kinds in the normal path an orphan resume has nothing but
133
- * the ids `WatchMarker.refined` recorded, and the comment degrades to a
134
- * bare list of `#id`s rather than blocking on a re-fetch.
150
+ * Post the summary comment on a decomposed spec and move it to
151
+ * `spec-in-progress` deliberately NOT `done`: a product manager watching
152
+ * this spec's status must not see "done" until every issue the refiner
153
+ * produced is itself `<prefix>:done` (`finishTrackedSpecs`, below, is what
154
+ * makes THAT move, once it's actually true). Reached from both the normal
155
+ * path (`runSpec`, right after a successful publish) and the orphan-resume
156
+ * path (`reconcileRefining`, when a completed publish's marker survived a
157
+ * crash the `transition` itself didn't). `created` only has titles/kinds in
158
+ * the normal path — an orphan resume has nothing but the ids
159
+ * `WatchMarker.refined` recorded, and the comment degrades to a bare list of
160
+ * `#id`s rather than blocking on a re-fetch.
161
+ *
162
+ * One exception: a decomposition that produced ZERO issues (shouldn't
163
+ * happen — `gates.refinementWellFormed` requires at least one leaf, but this
164
+ * stays defensive rather than assumed) has nothing for `finishTrackedSpecs`
165
+ * to ever wait on, so it goes straight to `done` instead of `spec-in-progress`.
135
166
  */
136
- async function finishSpec(deps, issue, created) {
167
+ async function announceRefined(deps, issue, created, rounds = 0) {
168
+ const roundsNote = rounds > 0 ? ` (after ${rounds} round${rounds === 1 ? "" : "s"} of feedback)` : "";
137
169
  const body = created.length > 0
138
- ? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
170
+ ? `spf watch refined this spec into ${created.length} issue(s)${roundsNote}:\n\n` +
139
171
  created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
140
- `\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building.`
172
+ `\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building. ` +
173
+ `This spec moves to \`${deps.labelPrefix}:done\` once every one of them does.`
141
174
  : `spf watch refined this spec but the refiner produced no issues.`;
142
175
  deps.notify({
143
176
  kind: "spec_refined",
@@ -148,15 +181,205 @@ async function finishSpec(deps, issue, created) {
148
181
  });
149
182
  if (!deps.dryRun) {
150
183
  await deps.provider.comment(issue, body);
151
- await deps.provider.transition(issue, "done");
184
+ if (created.length === 0) {
185
+ await deps.provider.transition(issue, "done");
186
+ // Best-effort and never fatal, same reasoning as finishTrackedSpecs'
187
+ // own closeIssue call below: the spec is already `done` by the time
188
+ // this runs, so a tracker that can't close must not turn a
189
+ // successfully refined spec into `blocked`.
190
+ await deps.provider.closeIssue?.(issue).catch((error) => {
191
+ deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
192
+ });
193
+ return;
194
+ }
195
+ await deps.provider.transition(issue, "spec-in-progress");
196
+ }
197
+ }
198
+ /**
199
+ * Poll every `spec-in-progress` spec: once every id `WatchMarker.refined`
200
+ * recorded — every issue the refiner produced, leaf or container — carries
201
+ * `<prefix>:done`, the spec's own decomposed work is actually finished, and
202
+ * only then does this move it `-> done`. This is the whole point of
203
+ * `announceRefined` landing on `spec-in-progress` rather than `done`
204
+ * straight away: the spec's status is what a product manager reads to know
205
+ * whether the work is finished, and "done" the instant a tree gets PUBLISHED
206
+ * would be a lie — the work hasn't started yet, let alone finished.
207
+ *
208
+ * A container in the refined list is done exactly when `rollUp` (see
209
+ * `finishReviews`) has already rolled it up — by the time every id here is
210
+ * `<prefix>:done`, every leaf beneath every container is too, transitively,
211
+ * with no need to walk the hierarchy again from this side.
212
+ *
213
+ * A referenced id that 404s (deleted from the tracker) is treated as
214
+ * satisfied — same policy as `frontierBlockedOn`'s blockers: a removed issue
215
+ * must not wedge the spec's completion forever. A spec with no marker, or an
216
+ * empty `refined` list, is left alone with a log line rather than assumed
217
+ * done — data that shouldn't exist given the gate's at-least-one-leaf rule,
218
+ * but never silently marked complete on that assumption.
219
+ */
220
+ export async function finishTrackedSpecs(deps) {
221
+ const tracked = await deps.provider.listInState("spec-in-progress");
222
+ const doneLabel = `${deps.labelPrefix}:done`;
223
+ for (const issue of tracked) {
224
+ const marker = await deps.provider.readMarker(issue);
225
+ const refined = marker?.refined ?? [];
226
+ if (refined.length === 0) {
227
+ deps.log(`watch: spec ${issue.id} is spec-in-progress with no refined issues recorded — leaving it alone`);
228
+ continue;
229
+ }
230
+ const finished = [];
231
+ let allDone = true;
232
+ for (const id of refined) {
233
+ const child = await deps.provider.getIssue(id);
234
+ if (!child) {
235
+ deps.log(`watch: spec ${issue.id}: refined issue #${id} no longer exists — treating it as done rather than wedging the spec forever`);
236
+ continue;
237
+ }
238
+ if (!child.labels.includes(doneLabel)) {
239
+ allDone = false;
240
+ break; // one unfinished issue is enough to know — no need to check the rest this tick
241
+ }
242
+ finished.push(child);
243
+ }
244
+ if (!allDone) {
245
+ deps.log(`watch: spec ${issue.id}: still waiting on work — not every refined issue is \`${doneLabel}\` yet`);
246
+ continue;
247
+ }
248
+ deps.log(`watch: spec ${issue.id}: every refined issue is \`${doneLabel}\` — closing`);
249
+ deps.notify({
250
+ kind: "spec_done",
251
+ level: "info",
252
+ title: `spec ${issue.id} done`,
253
+ detail: `${refined.length} issue(s) all landed.`,
254
+ fields: [["issue", issue.id], ["title", issue.title], ["refined", String(refined.length)]],
255
+ });
256
+ if (!deps.dryRun) {
257
+ const body = `spf watch: every issue decomposed from this spec is now \`${doneLabel}\`:\n\n` +
258
+ finished.map((c) => `- #${c.id}: ${c.title}`).join("\n");
259
+ await deps.provider.comment(issue, body);
260
+ await deps.provider.transition(issue, "done");
261
+ await deps.provider.closeIssue?.(issue).catch((error) => {
262
+ deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${doneLabel}\`: ${error.message}`);
263
+ });
264
+ }
265
+ }
266
+ }
267
+ const MAX_THREAD_CHARS = 20_000;
268
+ /**
269
+ * Render the spec issue's comment thread for the refiner's prompt, replacing
270
+ * the previous bare `title\n\nbody` — the whole reason a human's answer
271
+ * could never reach a resumed run before this feature. When `feedback` names
272
+ * the timestamp of the most recent question comment, everything at or after
273
+ * it is surfaced as "answers to your open questions" (what a resumed run
274
+ * most needs to read); everything earlier is "earlier discussion" — context,
275
+ * not necessarily an answer. A spec that has never been escalated (no
276
+ * `feedback`) has no such split: any pre-existing comments are all "earlier
277
+ * discussion".
278
+ *
279
+ * Truncates the RENDERED thread, not the comment list, to roughly
280
+ * `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
281
+ * "N earlier comment(s) omitted" line, never a silent truncation.
282
+ *
283
+ * `priority` — the spec's own `<prefix>:priority:pN` label, read by
284
+ * `runSpec` below — renders as its own `## Priority` section right after the
285
+ * header, present or absent independent of whether there's any comment
286
+ * thread at all: a spec with no priority label (the common case today) omits
287
+ * the section entirely, exactly the prompt this function produced before
288
+ * priority existed. `core/refine.ts`'s `publish()` is what actually ENFORCES
289
+ * the ceiling this section only asks for — see its own doc comment.
290
+ *
291
+ * Exported and pure (no provider, no I/O) so it's directly unit-testable.
292
+ */
293
+ export function buildSpecPrompt(issue, comments, feedback, priority) {
294
+ const withoutPriority = `${issue.title}\n\n${issue.body}`.trim();
295
+ const header = priority
296
+ ? `${withoutPriority}\n\n## Priority\n\nThis spec is labeled ${priority}. That's a CEILING for everything you produce: no node may be more urgent than ${priority} — less urgent is fine, more urgent is not.`
297
+ : withoutPriority;
298
+ if (comments.length === 0)
299
+ return header;
300
+ const render = (list) => list.map((c) => `**@${c.author}** (${c.created_at}):\n${c.body.trim()}`).join("\n\n");
301
+ let kept = comments;
302
+ let dropped = 0;
303
+ while (render(kept).length > MAX_THREAD_CHARS && kept.length > 1) {
304
+ kept = kept.slice(1);
305
+ dropped++;
306
+ }
307
+ const askedAt = feedback?.asked_at ? Date.parse(feedback.asked_at) : null;
308
+ const answers = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) >= askedAt) : [];
309
+ const earlier = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) < askedAt) : kept;
310
+ const parts = [];
311
+ if (dropped > 0)
312
+ parts.push(`_... ${dropped} earlier comment(s) omitted for length._`);
313
+ if (answers.length > 0)
314
+ parts.push(`### Answers to your open questions (round ${feedback.rounds})\n\n${render(answers)}`);
315
+ if (earlier.length > 0)
316
+ parts.push(`### Earlier discussion\n\n${render(earlier)}`);
317
+ if (parts.length === 0)
318
+ return header;
319
+ return `${header}\n\n## Discussion on the spec issue\n\n${parts.join("\n\n")}`;
320
+ }
321
+ /**
322
+ * The refine lane's own finishing move for "the refiner can't proceed
323
+ * without a human" — the escalation twin of `announceRefined` above. Posts every
324
+ * question as its own rich comment section (the question, why it matters,
325
+ * the options considered, its recommendation, and the evidence it read — so
326
+ * a human can often answer in a word or two), records the round in the
327
+ * marker so a resumed run's prompt (`buildSpecPrompt`) can tell "answers"
328
+ * from "earlier discussion", and moves the spec to `needs-feedback` rather
329
+ * than `blocked` — the refiner correctly refusing to guess is a legitimate
330
+ * outcome, not a failure. `marker` is spread first so `worktree`/`branch`
331
+ * (just written by the caller) survive into the marker this writes.
332
+ */
333
+ async function escalateSpec(deps, issue, marker, questions, adwId, round) {
334
+ const body = `## spf needs your input (round ${round})\n\n` +
335
+ questions
336
+ .map((q) => {
337
+ const lines = [`### ${q.question}`];
338
+ if (q.why_it_matters)
339
+ lines.push(`**Why it matters:** ${q.why_it_matters}`);
340
+ if (q.options.length > 0)
341
+ lines.push(`**Options considered:**\n${q.options.map((o) => `- ${o}`).join("\n")}`);
342
+ if (q.recommendation)
343
+ lines.push(`**Recommendation:** ${q.recommendation}`);
344
+ if (q.evidence.length > 0)
345
+ lines.push(`**Evidence:**\n${q.evidence.map((e) => `- ${e}`).join("\n")}`);
346
+ return lines.join("\n\n");
347
+ })
348
+ .join("\n\n---\n\n") +
349
+ `\n\n---\n\nAnswer inline, then add the \`${deps.labelPrefix}:continue-refinement\` label — refinement resumes from where it left off (adw_id \`${adwId}\`).`;
350
+ deps.notify({
351
+ // "error" level, not "info" — this is the same class of event as
352
+ // issue_blocked ("spf needs a human"), and it belongs on an `errors`-scope
353
+ // channel just as much as an `all`-scope one.
354
+ kind: "spec_needs_feedback",
355
+ level: "error",
356
+ title: `spec ${issue.id} needs feedback`,
357
+ detail: `${questions.length} question(s), round ${round}.`,
358
+ fields: [
359
+ ["issue", issue.id],
360
+ ["title", issue.title],
361
+ ["chain", deps.refineChain],
362
+ ["adw_id", adwId],
363
+ ["round", String(round)],
364
+ ],
365
+ });
366
+ if (!deps.dryRun) {
367
+ await deps.provider.comment(issue, body);
368
+ await deps.provider.writeMarker(issue, { ...marker, feedback: { rounds: round, asked_at: new Date().toISOString() } });
369
+ await deps.provider.transition(issue, "needs-feedback");
152
370
  }
153
371
  }
154
372
  /**
155
373
  * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
156
- * process isn't tracking is either a completed publish that crashed before
157
- * its own `transition(issue, "done")` ran (resume: finish it, no re-run),
158
- * or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
159
- * A no-op entirely when `watch.refine` is off see `WatchDeps.refineEnabled`.
374
+ * process isn't tracking is one of three things: a completed publish that
375
+ * crashed before its own `transition(issue, "done")` ran (resume: finish it,
376
+ * no re-run), a completed escalation that crashed before its own
377
+ * `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
378
+ * no re-asking — `escalateSpec` writes the marker's `feedback` before it
379
+ * transitions, so seeing `feedback` on a still-`refining` spec can only mean
380
+ * that last step didn't complete), or a genuine orphan (retry up to
381
+ * `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
382
+ * is off — see `WatchDeps.refineEnabled`.
160
383
  */
161
384
  export async function reconcileRefining(deps, state) {
162
385
  if (!deps.refineEnabled)
@@ -168,7 +391,20 @@ export async function reconcileRefining(deps, state) {
168
391
  const marker = await deps.provider.readMarker(issue);
169
392
  if (marker?.refined && marker.refined.length > 0) {
170
393
  deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
171
- await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
394
+ await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
395
+ continue;
396
+ }
397
+ if (marker?.feedback) {
398
+ deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
399
+ deps.notify({
400
+ kind: "spec_needs_feedback",
401
+ level: "error",
402
+ title: `spec ${issue.id} needs feedback`,
403
+ detail: `Round ${marker.feedback.rounds}.`,
404
+ fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.feedback.rounds)]],
405
+ });
406
+ if (!deps.dryRun)
407
+ await deps.provider.transition(issue, "needs-feedback");
172
408
  continue;
173
409
  }
174
410
  const attempt = (marker?.attempt ?? 0) + 1;
@@ -195,6 +431,70 @@ export async function reconcileRefining(deps, state) {
195
431
  }
196
432
  }
197
433
  }
434
+ /**
435
+ * `containerId`'s parent's parent's ... — the chain of `parent`s a nested
436
+ * epic-of-features roll-up needs to climb, each hop read straight out of an
437
+ * already-fetched `Issue.body`'s hidden `spf-refine:` marker (no extra
438
+ * tracker call). `depth` guards against a cycle a malformed marker could
439
+ * otherwise spin on forever, bounded to `MAX_ROLLUP_DEPTH` — GitHub's own
440
+ * documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild`
441
+ * doc comment).
442
+ */
443
+ function parentOf(issue) {
444
+ return parseRefineMarker(issue.body).parent;
445
+ }
446
+ /**
447
+ * A container is `done` the instant every child `listChildren` reports
448
+ * carries `<prefix>:done` — reached reactively, from the child that just
449
+ * finished (`finishReviews` below), never a periodic full scan: cheap on a
450
+ * quiet tick, and it means the moment the LAST child lands is the moment the
451
+ * container notices, not up to a poll interval later.
452
+ *
453
+ * A no-op, logged once, when `deps.listChildren` is unset (any tracker but
454
+ * GitHub today — see `WatchDeps`'s own doc comment) or when `containerId`
455
+ * is `null` (a top-level leaf has no container to roll up at all).
456
+ *
457
+ * Recurses on the container's OWN parent once it finishes, so an epic whose
458
+ * features each roll up in turn eventually rolls up itself — bounded by
459
+ * `MAX_ROLLUP_DEPTH` against a cyclic or absurdly deep marker.
460
+ */
461
+ async function rollUp(deps, containerId, depth = 0) {
462
+ if (!containerId || depth >= MAX_ROLLUP_DEPTH)
463
+ return;
464
+ if (!deps.listChildren) {
465
+ deps.log(`watch: ${containerId}: no listChildren on this tracker — container roll-up is GitHub-only, skipping`);
466
+ return;
467
+ }
468
+ const container = await deps.provider.getIssue(containerId);
469
+ if (!container)
470
+ return; // deleted — nothing left to roll up
471
+ const doneLabel = `${deps.labelPrefix}:done`;
472
+ if (container.labels.includes(doneLabel))
473
+ return; // already rolled up — never re-process, never loop on its own parent again
474
+ const children = await deps.listChildren(container);
475
+ const unfinished = children.filter((c) => !c.labels.includes(doneLabel));
476
+ if (unfinished.length > 0) {
477
+ deps.log(`watch: ${containerId} waiting on ${unfinished.map((c) => `#${c.id}`).join(", ")} before it can roll up`);
478
+ return;
479
+ }
480
+ deps.log(`watch: ${containerId}: every child is done — rolling up`);
481
+ deps.notify({
482
+ kind: "feature_done",
483
+ level: "info",
484
+ title: `feature ${containerId} done`,
485
+ detail: `${children.length} child issue(s) all landed.`,
486
+ fields: [["issue", containerId], ["title", container.title], ["children", String(children.length)]],
487
+ });
488
+ if (!deps.dryRun) {
489
+ const body = `spf watch: every child issue landed:\n\n${children.map((c) => `- #${c.id}: ${c.title}`).join("\n")}`;
490
+ await deps.provider.comment(container, body);
491
+ await deps.provider.transition(container, "done");
492
+ await deps.provider.closeIssue?.(container).catch((error) => {
493
+ deps.log(`watch: ${containerId}: closeIssue failed — left open, still \`${doneLabel}\`: ${error.message}`);
494
+ });
495
+ }
496
+ await rollUp(deps, parentOf(container), depth + 1);
497
+ }
198
498
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
199
499
  export async function finishReviews(deps) {
200
500
  const reviewing = await deps.provider.listInState("review", { includeAll: true });
@@ -214,6 +514,15 @@ export async function finishReviews(deps) {
214
514
  });
215
515
  if (!deps.dryRun) {
216
516
  await deps.provider.transition(issue, "done");
517
+ // Best-effort, never fatal — same pattern as announceRefined's own
518
+ // closeIssue call: the issue is already correctly `<prefix>:done`
519
+ // by the time this runs, so a tracker that can't close (or doesn't
520
+ // implement it at all) must not turn a successfully landed issue
521
+ // into `blocked`.
522
+ await deps.provider.closeIssue?.(issue).catch((error) => {
523
+ deps.log(`watch: ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
524
+ });
525
+ await rollUp(deps, parentOf(issue));
217
526
  cleanupWorktree(deps, marker);
218
527
  }
219
528
  }
@@ -254,7 +563,7 @@ async function runIssue(deps, issue) {
254
563
  deps.linkDataDir(worktreePath);
255
564
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
256
565
  const prompt = `${issue.title}\n\n${issue.body}`.trim();
257
- const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
566
+ const result = await deps.runChain({ prompt, cwd: worktreePath, adwId, chainOptions: deps.chainOptions });
258
567
  if (!result.accepted) {
259
568
  deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
260
569
  const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
@@ -335,6 +644,24 @@ async function runIssue(deps, issue) {
335
644
  cleanupWorktree(deps, { worktree: worktreePath, branch });
336
645
  }
337
646
  }
647
+ /**
648
+ * The spec's own `<prefix>:priority:pN` label, or `null` if it was never
649
+ * set. Deliberately NOT `issuePriority`'s default-to-`p2` behavior: `null`
650
+ * here means "no ceiling" (`clampPriority`'s own no-op case, `core/refine.ts`),
651
+ * so a spec predating this feature — the common case, since nothing sets
652
+ * this label automatically on a spec issue — publishes exactly as it always
653
+ * has, uncapped, rather than silently forcing every generated leaf down to
654
+ * `p2`. A spec's OWN priority never comes from a hidden `spf-refine:`
655
+ * marker: only refine-lane-created issues carry that marker, and a spec is,
656
+ * by definition, not one of those.
657
+ */
658
+ function specPriorityLabel(issue, labelPrefix) {
659
+ for (const p of ["p0", "p1", "p2", "p3"]) {
660
+ if (issue.labels.includes(`${labelPrefix}:priority:${p}`))
661
+ return p;
662
+ }
663
+ return null;
664
+ }
338
665
  /**
339
666
  * One spec's full claim -> decompose -> publish path, run in the background
340
667
  * — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
@@ -356,7 +683,7 @@ async function runSpec(deps, issue) {
356
683
  const existingMarker = await deps.provider.readMarker(issue);
357
684
  if (existingMarker?.refined && existingMarker.refined.length > 0) {
358
685
  deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
359
- await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
686
+ await announceRefined(deps, issue, existingMarker.refined.map((id) => ({ id })), existingMarker.feedback?.rounds ?? 0);
360
687
  return;
361
688
  }
362
689
  // See runIssue's identical comment: worktreePath/branch are deterministic
@@ -366,9 +693,30 @@ async function runSpec(deps, issue) {
366
693
  deps.git.fetch("origin", deps.baseBranch);
367
694
  deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
368
695
  deps.linkDataDir(worktreePath);
369
- await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
370
- const prompt = `${issue.title}\n\n${issue.body}`.trim();
371
- const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id });
696
+ // Spread existingMarker (not a bare object) so a spec resuming after a
697
+ // prior escalation carries its `feedback` (round count, last-asked-at)
698
+ // through to whatever this run writes next escalateSpec below and the
699
+ // final writeMarker on a successful publish both build on this object.
700
+ const marker = { ...existingMarker, worktree: worktreePath, branch, attempt: 0 };
701
+ await deps.provider.writeMarker(issue, marker);
702
+ // The comment thread is what makes this a HUMAN-in-the-loop, not just a
703
+ // one-shot prompt: a resumed run needs the human's answers, and
704
+ // buildSpecPrompt tells them apart from any earlier discussion using
705
+ // existingMarker.feedback.asked_at (unset on a spec that's never been
706
+ // escalated, in which case every comment is "earlier discussion").
707
+ const comments = await deps.provider.listComments(issue);
708
+ // The spec's own priority label, threaded two ways: into the prompt
709
+ // (buildSpecPrompt's `## Priority` section, so the refiner's per-node
710
+ // judgment is informed) AND into chainOptions (steps.publishIssues()
711
+ // reads `options["priority"]` and passes it to `core/refine.ts`'s
712
+ // `publish()` as a hard ceiling) — the prompt makes the rule sensible,
713
+ // the ceiling is what makes it TRUE regardless of what the model does
714
+ // with the prompt. `null` (no label set) reaches both as "no ceiling",
715
+ // unchanged from before this feature existed.
716
+ const specPriority = specPriorityLabel(issue, deps.labelPrefix);
717
+ const prompt = buildSpecPrompt(issue, comments, existingMarker?.feedback, specPriority);
718
+ const chainOptions = specPriority ? { ...deps.chainOptions, priority: specPriority } : deps.chainOptions;
719
+ const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id, chainOptions });
372
720
  if (!result.accepted) {
373
721
  deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
374
722
  const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
@@ -383,8 +731,17 @@ async function runSpec(deps, issue) {
383
731
  cleanupWorktree(deps, { worktree: worktreePath, branch });
384
732
  return;
385
733
  }
386
- await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0, refined: result.created.map((c) => c.id) });
387
- await finishSpec(deps, issue, result.created);
734
+ // Mutually exclusive by construction gates.refinementWellFormed
735
+ // guarantees a `questions`-bearing envelope publishes no `issues` — so
736
+ // this branches before, never alongside, the publish path below.
737
+ if (result.questions.length > 0) {
738
+ const round = (existingMarker?.feedback?.rounds ?? 0) + 1;
739
+ await escalateSpec(deps, issue, marker, result.questions, adwId, round);
740
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
741
+ return;
742
+ }
743
+ await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
744
+ await announceRefined(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
388
745
  cleanupWorktree(deps, { worktree: worktreePath, branch });
389
746
  }
390
747
  catch (error) {
@@ -401,16 +758,119 @@ async function runSpec(deps, issue) {
401
758
  cleanupWorktree(deps, { worktree: worktreePath, branch });
402
759
  }
403
760
  }
404
- /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
761
+ /**
762
+ * `issue`'s priority, as `claimNewWork` schedules by: the `<prefix>:priority:pN`
763
+ * LABEL first — a human relabeling an issue is the whole override mechanism
764
+ * (see `RefinedPrioritySchema`'s doc comment and the "Priority" section of
765
+ * `assets/prompts/refiner/system.md`), so it must win over whatever the
766
+ * hidden `spf-refine:` marker still says from publish time — and the marker
767
+ * only as a fallback, for an issue whose label was never applied at all (a
768
+ * hand-created issue with no `spf:priority:*` label, or one predating this
769
+ * feature). `parseRefineMarker` itself defaults to `p2` absent a marker, so
770
+ * this never needs its own fallback beyond that.
771
+ */
772
+ function issuePriority(issue, labelPrefix) {
773
+ for (const p of ["p0", "p1", "p2", "p3"]) {
774
+ if (issue.labels.includes(`${labelPrefix}:priority:${p}`))
775
+ return p;
776
+ }
777
+ return parseRefineMarker(issue.body).priority;
778
+ }
779
+ /**
780
+ * Sort `issues` the way `claimNewWork` walks them: priority first (p0 ahead
781
+ * of p2 regardless of creation order), then sibling affinity (a leaf whose
782
+ * hidden marker names a parent already in `inflightParents.values()` sorts
783
+ * ahead of an equal-priority leaf from an unrelated feature — the mechanism
784
+ * that tends to finish one feature before starting the next, without giving
785
+ * up the one-PR-per-story design), then creation order (oldest first,
786
+ * matching `listByLabel`'s own `sort=created&direction=asc` — the final,
787
+ * stable tiebreaker when priority and affinity both tie).
788
+ *
789
+ * Pure and exported so it's directly unit-testable without a provider, same
790
+ * spirit as `buildSpecPrompt` below. Two honest limits, both already true of
791
+ * what feeds it: affinity only reflects a sibling ACTUALLY in flight in this
792
+ * process right now — a daemon restart begins with `inflightParents` empty,
793
+ * so ordering degrades to priority + created-asc until it rebuilds itself
794
+ * over the next few ticks; and priority is read from the label, so an issue
795
+ * a human just relabeled sorts by its NEW priority starting next tick, never
796
+ * retroactively re-ordering claims a previous tick already made.
797
+ */
798
+ export function orderEligible(issues, inflightParents, labelPrefix) {
799
+ const inflightParentIds = new Set(inflightParents.values());
800
+ return issues
801
+ .map((issue, index) => ({
802
+ issue,
803
+ index, // preserves listEligible's own created-asc order as the final tiebreaker
804
+ priority: PRIORITY_RANK[issuePriority(issue, labelPrefix)],
805
+ hasAffinity: (() => {
806
+ const parent = parseRefineMarker(issue.body).parent;
807
+ return parent !== null && inflightParentIds.has(parent);
808
+ })(),
809
+ }))
810
+ .sort((a, b) => {
811
+ if (a.priority !== b.priority)
812
+ return a.priority - b.priority;
813
+ if (a.hasAffinity !== b.hasAffinity)
814
+ return a.hasAffinity ? -1 : 1;
815
+ return a.index - b.index;
816
+ })
817
+ .map((w) => w.issue);
818
+ }
819
+ /**
820
+ * Whether every id in `blockedBy` currently carries `<prefix>:done` — the
821
+ * frontier check `assets/prompts/refiner/system.md:62` has always promised
822
+ * the refiner ("the factory works the frontier: any leaf whose blockers are
823
+ * all done") but nothing enforced before this. `cache` is per-tick, shared
824
+ * across every candidate `claimNewWork` considers in one pass, so a blocker
825
+ * several leaves share costs exactly one `getIssue` call, not one per leaf.
826
+ * A blocker that 404s (deleted) is treated as satisfied, with a warning —
827
+ * a removed blocker must not wedge its dependents forever.
828
+ */
829
+ async function frontierBlockedOn(deps, blockedBy, cache) {
830
+ const doneLabel = `${deps.labelPrefix}:done`;
831
+ for (const id of blockedBy) {
832
+ let done = cache.get(id);
833
+ if (done === undefined) {
834
+ const blocker = await deps.provider.getIssue(id);
835
+ if (!blocker) {
836
+ deps.log(`watch: blocker #${id} no longer exists — treating it as satisfied rather than wedging its dependents`);
837
+ done = true;
838
+ }
839
+ else {
840
+ done = blocker.labels.includes(doneLabel);
841
+ }
842
+ cache.set(id, done);
843
+ }
844
+ if (!done)
845
+ return id;
846
+ }
847
+ return null;
848
+ }
849
+ /**
850
+ * Claim as many `ready` issues as the concurrency budget allows, in priority
851
+ * + sibling-affinity + created-asc order (`orderEligible`), skipping any
852
+ * whose `blocked_by` isn't fully `<prefix>:done` yet (`frontierBlockedOn`),
853
+ * and kick off `runIssue` for each claimed one in the background.
854
+ */
405
855
  export async function claimNewWork(deps, state) {
406
856
  if (state.inflight.size >= deps.concurrency)
407
857
  return;
408
858
  const eligible = await deps.provider.listEligible();
409
- for (const issue of eligible) {
859
+ const ordered = orderEligible(eligible, state.inflightParents, deps.labelPrefix);
860
+ const blockerCache = new Map(); // per-tick — see frontierBlockedOn's doc comment
861
+ for (const issue of ordered) {
410
862
  if (state.inflight.size >= deps.concurrency)
411
863
  break;
412
864
  if (state.inflight.has(issue.id))
413
865
  continue;
866
+ const marker = parseRefineMarker(issue.body);
867
+ if (marker.blocked_by.length > 0) {
868
+ const waitingOn = await frontierBlockedOn(deps, marker.blocked_by, blockerCache);
869
+ if (waitingOn) {
870
+ deps.log(`watch: ${issue.id} waiting on #${waitingOn} — not yet at the frontier`);
871
+ continue;
872
+ }
873
+ }
414
874
  if (deps.dryRun) {
415
875
  deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
416
876
  continue;
@@ -428,16 +888,40 @@ export async function claimNewWork(deps, state) {
428
888
  fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
429
889
  });
430
890
  state.inflight.add(issue.id);
431
- runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
891
+ if (marker.parent)
892
+ state.inflightParents.set(issue.id, marker.parent);
893
+ runIssue(deps, issue).finally(() => {
894
+ state.inflight.delete(issue.id);
895
+ state.inflightParents.delete(issue.id);
896
+ });
432
897
  }
433
898
  }
434
- /** Claim as many `spec-ready` specs as `refineConcurrency` allows, and kick off `runSpec` for each in the background. A no-op when `watch.refine` is off. */
435
- export async function claimSpecs(deps, state) {
899
+ /**
900
+ * Claim as many specs in `from` as `refineConcurrency` allows, and kick off
901
+ * `runSpec` for each in the background. A no-op when `watch.refine` is off.
902
+ *
903
+ * `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
904
+ * with `"continue-refinement"` — a human's signal that they've answered a
905
+ * prior round's questions and refinement should resume. Both share this same
906
+ * function (and `state.refining`'s budget) rather than a second copy of the
907
+ * claim loop, the same way the build lane's `claim()` itself takes a
908
+ * parameterized `{from, to}` rather than a hardcoded `ready -> working`.
909
+ *
910
+ * Known, deliberate wart: `claim()` only removes `from`'s label, so while a
911
+ * resumed run is in flight the issue briefly still carries
912
+ * `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
913
+ * replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
914
+ * and the run's own terminating `transition()` — to `needs-feedback` again,
915
+ * `done`, or `blocked` — strips every state label from the fresh snapshot it
916
+ * reads at that point, so this self-heals on the very next transition rather
917
+ * than needing a second mutator alongside `transition()`.
918
+ */
919
+ export async function claimSpecs(deps, state, from = "spec-ready") {
436
920
  if (!deps.refineEnabled)
437
921
  return;
438
922
  if (state.refining.size >= deps.refineConcurrency)
439
923
  return;
440
- const eligible = await deps.provider.listInState("spec-ready");
924
+ const eligible = await deps.provider.listInState(from);
441
925
  for (const issue of eligible) {
442
926
  if (state.refining.size >= deps.refineConcurrency)
443
927
  break;
@@ -447,7 +931,7 @@ export async function claimSpecs(deps, state) {
447
931
  deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
448
932
  continue;
449
933
  }
450
- const claimed = await deps.provider.claim(issue, { from: "spec-ready", to: "refining" });
934
+ const claimed = await deps.provider.claim(issue, { from, to: "refining" });
451
935
  if (!claimed) {
452
936
  deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
453
937
  continue;
@@ -470,11 +954,25 @@ function tickErrorHandler(deps, stage) {
470
954
  deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
471
955
  };
472
956
  }
473
- /** One poll tick: reconcile both lanes, finish reviews, then claim both lanes — each stage independently caught, so one stage's error never blocks the rest. */
957
+ /**
958
+ * One poll tick: reconcile both lanes, finish reviews, then claim both
959
+ * lanes — each stage independently caught, so one stage's error never blocks
960
+ * the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
961
+ * a human who already answered and is waiting) before fresh ones
962
+ * (`spec-ready`) — both share `state.refining`'s budget, so
963
+ * `refine.concurrency` still caps the lane as a whole either way.
964
+ */
474
965
  export async function tick(deps, state) {
475
966
  await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
476
967
  await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
477
968
  await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
969
+ // Right after finishReviews, not before it: a leaf that just landed this
970
+ // very tick (and, transitively, any container rollUp() rolled up because
971
+ // of it) can also be the last thing a spec-in-progress spec was waiting
972
+ // on — checking in the same tick is strictly cheaper than making a product
973
+ // manager wait one extra poll interval to see it.
974
+ await finishTrackedSpecs(deps).catch(tickErrorHandler(deps, "finishTrackedSpecs"));
975
+ await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
478
976
  await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
479
977
  await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
480
978
  }