@gr8ful/spf 0.6.0 → 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.
@@ -41,11 +41,27 @@
41
41
  * - Worktree-per-issue, isolated outside the repo, made cheap by SPF's
42
42
  * own `--cwd` support: no new chain-dispatch plumbing needed, just
43
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.
44
56
  */
45
57
  import path from "node:path";
58
+ import { PRIORITY_RANK } from "./data_types.js";
59
+ import { parseRefineMarker } from "./refine.js";
46
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;
47
63
  export function createWatchState() {
48
- return { inflight: new Set(), refining: new Set() };
64
+ return { inflight: new Set(), refining: new Set(), inflightParents: new Map() };
49
65
  }
50
66
  function slugifyTitle(title) {
51
67
  return (title
@@ -131,21 +147,30 @@ export async function reconcileOrphans(deps, state) {
131
147
  }
132
148
  }
133
149
  /**
134
- * Post the summary comment on a decomposed spec and transition it to `done`
135
- * — the refine lane's one shared finishing move, reached from both the
136
- * normal path (`runSpec`, right after a successful publish) and the
137
- * orphan-resume path (`reconcileRefining`, when a completed publish's
138
- * marker survived a crash the `transition` itself didn't). `created` only
139
- * has titles/kinds in the normal path an orphan resume has nothing but
140
- * the ids `WatchMarker.refined` recorded, and the comment degrades to a
141
- * 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`.
142
166
  */
143
- async function finishSpec(deps, issue, created, rounds = 0) {
167
+ async function announceRefined(deps, issue, created, rounds = 0) {
144
168
  const roundsNote = rounds > 0 ? ` (after ${rounds} round${rounds === 1 ? "" : "s"} of feedback)` : "";
145
169
  const body = created.length > 0
146
170
  ? `spf watch refined this spec into ${created.length} issue(s)${roundsNote}:\n\n` +
147
171
  created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
148
- `\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.`
149
174
  : `spf watch refined this spec but the refiner produced no issues.`;
150
175
  deps.notify({
151
176
  kind: "spec_refined",
@@ -156,14 +181,87 @@ async function finishSpec(deps, issue, created, rounds = 0) {
156
181
  });
157
182
  if (!deps.dryRun) {
158
183
  await deps.provider.comment(issue, body);
159
- await deps.provider.transition(issue, "done");
160
- // Best-effort and never fatal: publishing already succeeded and the spec
161
- // is already `done` by the time this runs, so a tracker that can't close
162
- // (or a `closeIssue` that isn't implemented at all see `IssueProvider`'s
163
- // doc comment) must not turn a successfully refined spec into `blocked`.
164
- await deps.provider.closeIssue?.(issue).catch((error) => {
165
- deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
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)]],
166
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
+ }
167
265
  }
168
266
  }
169
267
  const MAX_THREAD_CHARS = 20_000;
@@ -182,10 +280,21 @@ const MAX_THREAD_CHARS = 20_000;
182
280
  * `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
183
281
  * "N earlier comment(s) omitted" line, never a silent truncation.
184
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
+ *
185
291
  * Exported and pure (no provider, no I/O) so it's directly unit-testable.
186
292
  */
187
- export function buildSpecPrompt(issue, comments, feedback) {
188
- const header = `${issue.title}\n\n${issue.body}`.trim();
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;
189
298
  if (comments.length === 0)
190
299
  return header;
191
300
  const render = (list) => list.map((c) => `**@${c.author}** (${c.created_at}):\n${c.body.trim()}`).join("\n\n");
@@ -211,7 +320,7 @@ export function buildSpecPrompt(issue, comments, feedback) {
211
320
  }
212
321
  /**
213
322
  * The refine lane's own finishing move for "the refiner can't proceed
214
- * without a human" — the escalation twin of `finishSpec` above. Posts every
323
+ * without a human" — the escalation twin of `announceRefined` above. Posts every
215
324
  * question as its own rich comment section (the question, why it matters,
216
325
  * the options considered, its recommendation, and the evidence it read — so
217
326
  * a human can often answer in a word or two), records the round in the
@@ -282,7 +391,7 @@ export async function reconcileRefining(deps, state) {
282
391
  const marker = await deps.provider.readMarker(issue);
283
392
  if (marker?.refined && marker.refined.length > 0) {
284
393
  deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
285
- await finishSpec(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
394
+ await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
286
395
  continue;
287
396
  }
288
397
  if (marker?.feedback) {
@@ -322,6 +431,70 @@ export async function reconcileRefining(deps, state) {
322
431
  }
323
432
  }
324
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
+ }
325
498
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
326
499
  export async function finishReviews(deps) {
327
500
  const reviewing = await deps.provider.listInState("review", { includeAll: true });
@@ -341,6 +514,15 @@ export async function finishReviews(deps) {
341
514
  });
342
515
  if (!deps.dryRun) {
343
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));
344
526
  cleanupWorktree(deps, marker);
345
527
  }
346
528
  }
@@ -462,6 +644,24 @@ async function runIssue(deps, issue) {
462
644
  cleanupWorktree(deps, { worktree: worktreePath, branch });
463
645
  }
464
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
+ }
465
665
  /**
466
666
  * One spec's full claim -> decompose -> publish path, run in the background
467
667
  * — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
@@ -483,7 +683,7 @@ async function runSpec(deps, issue) {
483
683
  const existingMarker = await deps.provider.readMarker(issue);
484
684
  if (existingMarker?.refined && existingMarker.refined.length > 0) {
485
685
  deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
486
- await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })), existingMarker.feedback?.rounds ?? 0);
686
+ await announceRefined(deps, issue, existingMarker.refined.map((id) => ({ id })), existingMarker.feedback?.rounds ?? 0);
487
687
  return;
488
688
  }
489
689
  // See runIssue's identical comment: worktreePath/branch are deterministic
@@ -505,8 +705,18 @@ async function runSpec(deps, issue) {
505
705
  // existingMarker.feedback.asked_at (unset on a spec that's never been
506
706
  // escalated, in which case every comment is "earlier discussion").
507
707
  const comments = await deps.provider.listComments(issue);
508
- const prompt = buildSpecPrompt(issue, comments, existingMarker?.feedback);
509
- const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id, chainOptions: deps.chainOptions });
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 });
510
720
  if (!result.accepted) {
511
721
  deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
512
722
  const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
@@ -531,7 +741,7 @@ async function runSpec(deps, issue) {
531
741
  return;
532
742
  }
533
743
  await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
534
- await finishSpec(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
744
+ await announceRefined(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
535
745
  cleanupWorktree(deps, { worktree: worktreePath, branch });
536
746
  }
537
747
  catch (error) {
@@ -548,16 +758,119 @@ async function runSpec(deps, issue) {
548
758
  cleanupWorktree(deps, { worktree: worktreePath, branch });
549
759
  }
550
760
  }
551
- /** 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
+ */
552
855
  export async function claimNewWork(deps, state) {
553
856
  if (state.inflight.size >= deps.concurrency)
554
857
  return;
555
858
  const eligible = await deps.provider.listEligible();
556
- 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) {
557
862
  if (state.inflight.size >= deps.concurrency)
558
863
  break;
559
864
  if (state.inflight.has(issue.id))
560
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
+ }
561
874
  if (deps.dryRun) {
562
875
  deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
563
876
  continue;
@@ -575,7 +888,12 @@ export async function claimNewWork(deps, state) {
575
888
  fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
576
889
  });
577
890
  state.inflight.add(issue.id);
578
- 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
+ });
579
897
  }
580
898
  }
581
899
  /**
@@ -648,6 +966,12 @@ export async function tick(deps, state) {
648
966
  await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
649
967
  await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
650
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"));
651
975
  await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
652
976
  await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
653
977
  await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",