@gr8ful/spf 0.12.0 → 0.13.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.
@@ -43,15 +43,32 @@ export interface RefinedQuestionRef {
43
43
  recommendation: string;
44
44
  evidence: string[];
45
45
  }
46
+ /**
47
+ * One proposed standalone spec, part of splitting an over-large spec into
48
+ * several — mirrors `SpecSplitSchema` (`core/data_types.ts`) field-for-field,
49
+ * same decoupling reasoning as `RefinedIssueRef`/`RefinedQuestionRef` above.
50
+ * Read back from `steps.publishIssues()`'s `refine_split.json` side channel
51
+ * (see `cli/commands/watch.ts`'s `runRefine`) and, once a human approves,
52
+ * handed to `core/refine.ts`'s `publishSpecs()` verbatim via
53
+ * `WatchDeps.publishSpecs` — structurally identical to `SpecSplit`, so no
54
+ * conversion is needed at that boundary.
55
+ */
56
+ export interface RefinedSpecSplitRef {
57
+ title: string;
58
+ body: string;
59
+ rationale: string;
60
+ }
46
61
  export interface RefineRunResult {
47
62
  accepted: boolean;
48
63
  adwId: string;
49
64
  /** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
50
65
  detail: string;
51
- /** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run escalated instead of publishing. */
66
+ /** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run escalated (questions) or proposed a split instead of publishing. */
52
67
  created: RefinedIssueRef[];
53
- /** What the refiner is asking, read back from its own side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run published a tree instead of escalating; `gates.refinementWellFormed` guarantees `created` and `questions` are never both non-empty. */
68
+ /** What the refiner is asking, read back from its own side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run published a tree or proposed a split instead of escalating; `gates.refinementWellFormed` guarantees `created`/`questions`/`split` are never more than one non-empty at once. */
54
69
  questions: RefinedQuestionRef[];
70
+ /** What the refiner proposed splitting this spec into, read back from its own side-channel file (`refine_split.json`). Empty unless this round proposed a split — see `questions`' own doc comment for the same three-way exclusion. */
71
+ split: RefinedSpecSplitRef[];
55
72
  }
56
73
  /**
57
74
  * The fan-out lane's injected trio + the two values `WatchDeps` cannot derive.
@@ -168,6 +185,24 @@ export interface WatchDeps {
168
185
  * without authoring at all (see `cli/commands/watch.ts`'s startup check).
169
186
  */
170
187
  listChildren?: (parent: Issue) => Promise<Issue[]>;
188
+ /**
189
+ * `core/refine.ts`'s `publishSpecs()`, pre-bound to this run's authoring
190
+ * provider and label prefix — same injection reasoning as `listChildren`
191
+ * just above: this module drives whatever it's given without importing a
192
+ * concrete provider type. `undefined` only on a tracker that isn't
193
+ * authoring-capable, which `cli/commands/watch.ts`'s startup check already
194
+ * refuses to let `refineEnabled` be true for (see its own comment on
195
+ * `watch.refine.enabled` requiring "github" or "jira") — so
196
+ * `executeApprovedSplits` treats an unset value here as an invariant
197
+ * violation, not a graceful degrade, unlike `listChildren`'s logged no-op.
198
+ */
199
+ publishSpecs?: (specs: RefinedSpecSplitRef[], opts: {
200
+ originalSpecId: string;
201
+ priority?: RefinedPriority | null;
202
+ }) => Promise<Array<{
203
+ id: string;
204
+ title: string;
205
+ }>>;
171
206
  log: (message: string) => void;
172
207
  /**
173
208
  * Structured push, alongside `log`'s plain string — a required field, like
@@ -295,17 +330,38 @@ export declare function finishTrackedSpecs(deps: WatchDeps): Promise<void>;
295
330
  * Exported and pure (no provider, no I/O) so it's directly unit-testable.
296
331
  */
297
332
  export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"], priority?: RefinedPriority | null): string;
333
+ /**
334
+ * The other half of the split flow: a human already approved
335
+ * (`<prefix>:split-approved`) exactly what `proposeSpecSplit` recorded in
336
+ * `WatchMarker.split`, so this is a deterministic `code`-shaped action, not
337
+ * an agent one — no scout, no refiner session, no worktree, mirroring why
338
+ * `steps.publishIssues()` is a `code` phase rather than an agent phase.
339
+ * Idempotent the same way `runSpec`'s own publish path is: a non-empty
340
+ * `marker.refined` (this function writes the created specs' ids there, same
341
+ * field `finishTrackedSpecs` polls) short-circuits, so a crash between
342
+ * creating the specs and posting the summary comment never double-creates on
343
+ * the next tick.
344
+ *
345
+ * Recording the child SPECS in `marker.refined` — not their eventual
346
+ * decomposed issues — is deliberate: it's what lets `finishTrackedSpecs`
347
+ * close the ORIGINAL spec once both child specs finish their own trees
348
+ * (transitively — each child spec is a `spec-in-progress` tracked the same
349
+ * way once IT publishes), with no new tracking code needed anywhere.
350
+ */
351
+ export declare function executeApprovedSplits(deps: WatchDeps): Promise<void>;
298
352
  /**
299
353
  * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
300
- * process isn't tracking is one of three things: a completed publish that
354
+ * process isn't tracking is one of four things: a completed publish that
301
355
  * crashed before its own `transition(issue, "done")` ran (resume: finish it,
302
- * no re-run), a completed escalation that crashed before its own
356
+ * no re-run), a completed split proposal that crashed before its own
357
+ * `transition(issue, "split-proposed")` ran (resume: finish THAT transition
358
+ * — checked before the escalation case below, since `proposeSpecSplit`
359
+ * writes BOTH `split` and `feedback` while `escalateSpec` writes only the
360
+ * latter), a completed escalation that crashed before its own
303
361
  * `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
304
- * no re-asking `escalateSpec` writes the marker's `feedback` before it
305
- * transitions, so seeing `feedback` on a still-`refining` spec can only mean
306
- * that last step didn't complete), or a genuine orphan (retry up to
307
- * `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
308
- * is off — see `WatchDeps.refineEnabled`.
362
+ * no re-asking), or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then
363
+ * give up). A no-op entirely when `watch.refine` is off see
364
+ * `WatchDeps.refineEnabled`.
309
365
  */
310
366
  export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
311
367
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
@@ -12,8 +12,14 @@
12
12
  * posts the questions and moves the spec to `needs-feedback`; a human
13
13
  * answers in the issue's comments and adds `continue-refinement`;
14
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.
15
+ * the prompt (`buildSpecPrompt`) — for as many rounds as it takes. A second,
16
+ * different escape hatch handles a spec too large for one decomposition (not
17
+ * ambiguity — size): `proposeSpecSplit` posts a concrete split into several
18
+ * standalone specs and moves the spec to `split-proposed`; a human either
19
+ * approves as-is (`split-approved`, executed deterministically by
20
+ * `executeApprovedSplits` — no agent re-run) or revises it the same way a
21
+ * question gets answered (`continue-refinement`). See `provider.ts`'s
22
+ * `WatchState` doc comment for the full state diagram.
17
23
  *
18
24
  * Provider-agnostic (drives whatever `IssueProvider` it's given) and
19
25
  * chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
@@ -395,17 +401,125 @@ async function escalateSpec(deps, issue, marker, questions, adwId, round) {
395
401
  await deps.provider.transition(issue, "needs-feedback");
396
402
  }
397
403
  }
404
+ /**
405
+ * The refine lane's third finishing move, alongside `announceRefined`
406
+ * (published a tree) and `escalateSpec` (material ambiguity): a spec too
407
+ * large for one decomposition (see `gates.refinementWellFormed`'s budget
408
+ * checks, and `assets/prompts/refiner/system.md`'s "Sizing and the budget").
409
+ * Posts each proposed spec as its own comment section — title, a short
410
+ * preview of its body, and the rationale for splitting it out — records the
411
+ * EXACT proposal in `WatchMarker.split` so `executeApprovedSplits` creates
412
+ * precisely what a human reviewed rather than whatever the marker happens to
413
+ * hold by the time approval lands, and moves the spec to `split-proposed`.
414
+ *
415
+ * Shares `feedback`'s bookkeeping with `escalateSpec`, deliberately: a human
416
+ * can revise a proposed split the same way they answer a question — comment
417
+ * inline, add `continue-refinement` — and `buildSpecPrompt` folds the
418
+ * comment thread back into the resumed prompt identically either way,
419
+ * regardless of which of the two escalation shapes the PREVIOUS round used.
420
+ * `reconcileRefining` below tells the two apart by checking `marker.split`
421
+ * before `marker.feedback`, since this function always writes both while
422
+ * `escalateSpec` writes only the latter.
423
+ */
424
+ async function proposeSpecSplit(deps, issue, marker, specs, adwId, round) {
425
+ const body = `## spf proposes splitting this spec (round ${round})\n\n` +
426
+ `This spec doesn't fit in one decomposition. Proposed split into ${specs.length} standalone specs:\n\n` +
427
+ specs
428
+ .map((s, i) => {
429
+ const lines = s.body.trim().split("\n");
430
+ const preview = lines.slice(0, 6).join("\n");
431
+ const truncated = lines.length > 6 ? "\n\n_(preview truncated — the full text lands in the created spec)_" : "";
432
+ return `### ${i + 1}. ${s.title}\n\n${preview}${truncated}\n\n**Why this is its own spec:** ${s.rationale}`;
433
+ })
434
+ .join("\n\n---\n\n") +
435
+ `\n\n---\n\nApprove as proposed by adding the \`${deps.labelPrefix}:split-approved\` label — spf will create these ${specs.length} specs with no further agent run. ` +
436
+ `Or comment with changes and add \`${deps.labelPrefix}:continue-refinement\` — refinement resumes from where it left off (adw_id \`${adwId}\`) and can revise the proposal.`;
437
+ deps.notify({
438
+ // "notice" level, not "info" — the same class of event as
439
+ // spec_needs_feedback: spf needs a human, so it belongs on an
440
+ // `attention`-scope channel just as much as an `all`-scope one.
441
+ kind: "spec_split_proposed",
442
+ level: "notice",
443
+ title: `spec ${issue.id} split proposed`,
444
+ detail: `${specs.length} proposed spec(s), round ${round}.`,
445
+ fields: [
446
+ ["issue", issue.id],
447
+ ["title", issue.title],
448
+ ["chain", deps.refineChain],
449
+ ["adw_id", adwId],
450
+ ["round", String(round)],
451
+ ],
452
+ });
453
+ if (!deps.dryRun) {
454
+ await deps.provider.comment(issue, body);
455
+ await deps.provider.writeMarker(issue, {
456
+ ...marker,
457
+ split: { specs, proposed_at: new Date().toISOString(), rounds: round },
458
+ feedback: { rounds: round, asked_at: new Date().toISOString() },
459
+ });
460
+ await deps.provider.transition(issue, "split-proposed");
461
+ }
462
+ }
463
+ /**
464
+ * The other half of the split flow: a human already approved
465
+ * (`<prefix>:split-approved`) exactly what `proposeSpecSplit` recorded in
466
+ * `WatchMarker.split`, so this is a deterministic `code`-shaped action, not
467
+ * an agent one — no scout, no refiner session, no worktree, mirroring why
468
+ * `steps.publishIssues()` is a `code` phase rather than an agent phase.
469
+ * Idempotent the same way `runSpec`'s own publish path is: a non-empty
470
+ * `marker.refined` (this function writes the created specs' ids there, same
471
+ * field `finishTrackedSpecs` polls) short-circuits, so a crash between
472
+ * creating the specs and posting the summary comment never double-creates on
473
+ * the next tick.
474
+ *
475
+ * Recording the child SPECS in `marker.refined` — not their eventual
476
+ * decomposed issues — is deliberate: it's what lets `finishTrackedSpecs`
477
+ * close the ORIGINAL spec once both child specs finish their own trees
478
+ * (transitively — each child spec is a `spec-in-progress` tracked the same
479
+ * way once IT publishes), with no new tracking code needed anywhere.
480
+ */
481
+ export async function executeApprovedSplits(deps) {
482
+ if (!deps.refineEnabled)
483
+ return;
484
+ const approved = await deps.provider.listInState("split-approved");
485
+ for (const issue of approved) {
486
+ const marker = await deps.provider.readMarker(issue);
487
+ if (marker?.refined && marker.refined.length > 0) {
488
+ deps.log(`watch: spec ${issue.id}: split already executed — finishing`);
489
+ await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.split?.rounds ?? marker.feedback?.rounds ?? 0);
490
+ continue;
491
+ }
492
+ const specs = marker?.split?.specs ?? [];
493
+ if (specs.length === 0) {
494
+ deps.log(`watch: spec ${issue.id} is \`${deps.labelPrefix}:split-approved\` but its marker records no proposed split — leaving it alone`);
495
+ continue;
496
+ }
497
+ if (!deps.publishSpecs) {
498
+ // See WatchDeps.publishSpecs's own doc comment: refineEnabled true
499
+ // without an authoring-capable tracker is a startup failure
500
+ // (cli/commands/watch.ts), never a state this function should reach.
501
+ throw new Error(`watch: spec ${issue.id}: refine is enabled but no publishSpecs was provided — this is an spf bug, not a config problem`);
502
+ }
503
+ deps.log(`watch: spec ${issue.id}: split approved — creating ${specs.length} spec(s)`);
504
+ if (deps.dryRun)
505
+ continue;
506
+ const created = await deps.publishSpecs(specs, { originalSpecId: issue.id, priority: specPriorityLabel(issue, deps.labelPrefix) });
507
+ await announceRefined(deps, issue, created.map((c) => ({ id: c.id, title: c.title, kind: "spec" })), marker?.split?.rounds ?? 0);
508
+ }
509
+ }
398
510
  /**
399
511
  * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
400
- * process isn't tracking is one of three things: a completed publish that
512
+ * process isn't tracking is one of four things: a completed publish that
401
513
  * crashed before its own `transition(issue, "done")` ran (resume: finish it,
402
- * no re-run), a completed escalation that crashed before its own
514
+ * no re-run), a completed split proposal that crashed before its own
515
+ * `transition(issue, "split-proposed")` ran (resume: finish THAT transition
516
+ * — checked before the escalation case below, since `proposeSpecSplit`
517
+ * writes BOTH `split` and `feedback` while `escalateSpec` writes only the
518
+ * latter), a completed escalation that crashed before its own
403
519
  * `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
404
- * no re-asking `escalateSpec` writes the marker's `feedback` before it
405
- * transitions, so seeing `feedback` on a still-`refining` spec can only mean
406
- * that last step didn't complete), or a genuine orphan (retry up to
407
- * `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
408
- * is off — see `WatchDeps.refineEnabled`.
520
+ * no re-asking), or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then
521
+ * give up). A no-op entirely when `watch.refine` is off see
522
+ * `WatchDeps.refineEnabled`.
409
523
  */
410
524
  export async function reconcileRefining(deps, state) {
411
525
  if (!deps.refineEnabled)
@@ -420,6 +534,22 @@ export async function reconcileRefining(deps, state) {
420
534
  await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
421
535
  continue;
422
536
  }
537
+ // Checked BEFORE marker.feedback: proposeSpecSplit always writes both
538
+ // `split` and `feedback` together, while escalateSpec writes only the
539
+ // latter — so `split` present is the more specific signal and must win.
540
+ if (marker?.split) {
541
+ deps.log(`watch: spec ${issue.id} orphaned after proposing round ${marker.split.rounds}'s split — finishing the transition to split-proposed`);
542
+ deps.notify({
543
+ kind: "spec_split_proposed",
544
+ level: "notice",
545
+ title: `spec ${issue.id} split proposed`,
546
+ detail: `Round ${marker.split.rounds}.`,
547
+ fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.split.rounds)]],
548
+ });
549
+ if (!deps.dryRun)
550
+ await deps.provider.transition(issue, "split-proposed");
551
+ continue;
552
+ }
423
553
  if (marker?.feedback) {
424
554
  deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
425
555
  deps.notify({
@@ -1058,14 +1188,25 @@ async function runSpec(deps, issue) {
1058
1188
  return;
1059
1189
  }
1060
1190
  // Mutually exclusive by construction — gates.refinementWellFormed
1061
- // guarantees a `questions`-bearing envelope publishes no `issues` — so
1062
- // this branches before, never alongside, the publish path below.
1191
+ // guarantees a `questions`-bearing envelope publishes no `issues`/`split`
1192
+ // — so this branches before, never alongside, the split or publish paths
1193
+ // below.
1063
1194
  if (result.questions.length > 0) {
1064
1195
  const round = (existingMarker?.feedback?.rounds ?? 0) + 1;
1065
1196
  await escalateSpec(deps, issue, marker, result.questions, adwId, round);
1066
1197
  cleanupWorktree(deps, { worktree: worktreePath, branch });
1067
1198
  return;
1068
1199
  }
1200
+ // Same mutual exclusion, the other escalation shape: a spec too large
1201
+ // for one decomposition, proposed as several standalone specs instead of
1202
+ // a scope question. See proposeSpecSplit's own doc comment for why this
1203
+ // shares `feedback`'s round-tracking with the questions branch above.
1204
+ if (result.split.length > 0) {
1205
+ const round = (existingMarker?.feedback?.rounds ?? existingMarker?.split?.rounds ?? 0) + 1;
1206
+ await proposeSpecSplit(deps, issue, marker, result.split, adwId, round);
1207
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
1208
+ return;
1209
+ }
1069
1210
  await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
1070
1211
  await announceRefined(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
1071
1212
  cleanupWorktree(deps, { worktree: worktreePath, branch });
@@ -1336,6 +1477,11 @@ export async function tick(deps, state) {
1336
1477
  // on — checking in the same tick is strictly cheaper than making a product
1337
1478
  // manager wait one extra poll interval to see it.
1338
1479
  await finishTrackedSpecs(deps).catch(tickErrorHandler(deps, "finishTrackedSpecs"));
1480
+ // Right after finishTrackedSpecs, same reasoning: a spec a human approved
1481
+ // a split on this tick should get its child specs created this same tick,
1482
+ // not one poll interval later. No agent, no worktree — see
1483
+ // executeApprovedSplits's own doc comment.
1484
+ await executeApprovedSplits(deps).catch(tickErrorHandler(deps, "executeApprovedSplits"));
1339
1485
  await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
1340
1486
  await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
1341
1487
  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.12.0",
3
+ "version": "0.13.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",