@gr8ful/spf 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -27
- package/assets/prompts/refiner/system.md +11 -1
- package/assets/prompts/refiner/user.md +9 -3
- package/assets/skill/references/config.md +51 -13
- package/assets/templates/ts.spf.config.yaml +6 -2
- package/dist/chains/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.d.ts +0 -27
- package/dist/chains/steps.js +21 -2
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +47 -9
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/list.d.ts +1 -1
- package/dist/cli/commands/list.js +31 -12
- package/dist/cli/commands/phases.d.ts +1 -1
- package/dist/cli/commands/phases.js +18 -4
- package/dist/cli/commands/run.js +30 -2
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +93 -13
- package/dist/cli/index.js +4 -4
- package/dist/cli/interview.js +9 -5
- package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
- package/dist/cli/ui/fanout_dashboard.js +102 -0
- package/dist/cli/ui/ink_asker.d.ts +13 -0
- package/dist/cli/ui/ink_asker.js +247 -0
- package/dist/cli/ui/reports.d.ts +30 -0
- package/dist/cli/ui/reports.js +61 -0
- package/dist/cli/ui/run_dashboard.d.ts +15 -0
- package/dist/cli/ui/run_dashboard.js +131 -0
- package/dist/cli/ui/watch_dashboard.d.ts +22 -0
- package/dist/cli/ui/watch_dashboard.js +78 -0
- package/dist/core/console.d.ts +40 -1
- package/dist/core/console.js +25 -3
- package/dist/core/data_types.d.ts +108 -5
- package/dist/core/data_types.js +50 -5
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/gates.js +24 -1
- package/dist/core/issues/github_provider.d.ts +39 -5
- package/dist/core/issues/github_provider.js +103 -4
- package/dist/core/issues/jira_provider.d.ts +79 -12
- package/dist/core/issues/jira_provider.js +97 -2
- package/dist/core/issues/provider.d.ts +73 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +5 -1
- package/dist/core/runner.js +2 -1
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +5 -1
- package/dist/core/watch.d.ts +86 -3
- package/dist/core/watch.js +353 -29
- package/package.json +6 -1
package/dist/core/watch.js
CHANGED
|
@@ -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
|
|
135
|
-
* —
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
deps.
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
509
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -44,8 +44,11 @@
|
|
|
44
44
|
"@earendil-works/pi-ai": "0.83.0",
|
|
45
45
|
"@flue/runtime": "2.0.3",
|
|
46
46
|
"@hono/node-server": "^2.1.1",
|
|
47
|
+
"@inkjs/ui": "^2.0.0",
|
|
47
48
|
"@valibot/to-json-schema": "^1.7.1",
|
|
48
49
|
"hono": "^4.13.3",
|
|
50
|
+
"ink": "^7.1.1",
|
|
51
|
+
"react": "^19.2.8",
|
|
49
52
|
"valibot": "^1.4.2",
|
|
50
53
|
"yaml": "^2.5.1"
|
|
51
54
|
},
|
|
@@ -54,6 +57,8 @@
|
|
|
54
57
|
},
|
|
55
58
|
"devDependencies": {
|
|
56
59
|
"@types/node": "^22.10.0",
|
|
60
|
+
"@types/react": "^19.2.18",
|
|
61
|
+
"ink-testing-library": "^4.0.0",
|
|
57
62
|
"lefthook": "^2.1.10",
|
|
58
63
|
"typescript": "^7.0.2"
|
|
59
64
|
}
|