@gr8ful/spf 0.3.0 → 0.4.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 (41) hide show
  1. package/README.md +55 -5
  2. package/assets/defaults/spf.config.yaml +16 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/skill/references/config.md +38 -2
  6. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  7. package/assets/templates/ts.spf.config.yaml +10 -2
  8. package/dist/chains/context.d.ts +9 -0
  9. package/dist/chains/index.js +5 -0
  10. package/dist/chains/steps.d.ts +24 -0
  11. package/dist/chains/steps.js +55 -4
  12. package/dist/cli/commands/doctor.js +6 -0
  13. package/dist/cli/commands/init.js +30 -3
  14. package/dist/cli/commands/install-skill.js +5 -2
  15. package/dist/cli/commands/list.js +1 -0
  16. package/dist/cli/commands/run.js +5 -1
  17. package/dist/cli/commands/watch.js +69 -8
  18. package/dist/cli/index.js +3 -3
  19. package/dist/cli/interview.js +17 -0
  20. package/dist/core/data_types.d.ts +78 -0
  21. package/dist/core/data_types.js +42 -0
  22. package/dist/core/gates.d.ts +13 -0
  23. package/dist/core/gates.js +103 -0
  24. package/dist/core/issues/github_provider.d.ts +35 -9
  25. package/dist/core/issues/github_provider.js +76 -28
  26. package/dist/core/issues/jira_provider.d.ts +14 -1
  27. package/dist/core/issues/jira_provider.js +9 -7
  28. package/dist/core/issues/provider.d.ts +77 -15
  29. package/dist/core/issues/provider.js +7 -4
  30. package/dist/core/notify/channel.d.ts +1 -1
  31. package/dist/core/refine.d.ts +39 -0
  32. package/dist/core/refine.js +144 -0
  33. package/dist/core/watch.d.ts +56 -1
  34. package/dist/core/watch.js +200 -11
  35. package/dist/test/chains.test.js +1 -0
  36. package/dist/test/init_command.test.js +17 -0
  37. package/dist/test/interview.test.js +4 -3
  38. package/dist/test/refine.test.d.ts +1 -0
  39. package/dist/test/refine.test.js +126 -0
  40. package/dist/test/watch.test.js +173 -5
  41. package/package.json +1 -1
@@ -7,6 +7,21 @@ export interface ChainRunResult {
7
7
  /** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
8
8
  detail: string;
9
9
  }
10
+ /** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
11
+ export interface RefinedIssueRef {
12
+ id: string;
13
+ title: string;
14
+ kind: string;
15
+ isLeaf: boolean;
16
+ }
17
+ export interface RefineRunResult {
18
+ accepted: boolean;
19
+ adwId: string;
20
+ /** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
21
+ detail: string;
22
+ /** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted`. */
23
+ created: RefinedIssueRef[];
24
+ }
10
25
  export interface WatchDeps {
11
26
  provider: IssueProvider;
12
27
  codeHost: CodeHostProvider;
@@ -17,6 +32,23 @@ export interface WatchDeps {
17
32
  chain: string;
18
33
  baseBranch: string;
19
34
  concurrency: number;
35
+ /**
36
+ * The second lane — decomposing a `<prefix>:spec-ready` product spec
37
+ * instead of building a `<prefix>:ready` issue. `false` (the default) is
38
+ * a complete no-op: `claimSpecs`/`reconcileRefining` return immediately,
39
+ * so an existing `spf watch` config sees no new poll traffic at all until
40
+ * this is turned on. See `WatchConfigSchema`'s `refine` field.
41
+ */
42
+ refineEnabled: boolean;
43
+ refineConcurrency: number;
44
+ refineChain: string;
45
+ /** Same shape as `runChain`, for the refine lane — see its own doc comment for why the two aren't unified into one callback. */
46
+ runRefine: (opts: {
47
+ prompt: string;
48
+ cwd: string;
49
+ adwId: string;
50
+ issueId: string;
51
+ }) => Promise<RefineRunResult>;
20
52
  worktreesDir: string;
21
53
  /**
22
54
  * Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
@@ -49,9 +81,22 @@ export interface WatchDeps {
49
81
  }
50
82
  export interface WatchRunState {
51
83
  inflight: Set<string>;
84
+ /**
85
+ * Separate from `inflight` — not a defensive copy of the same set, a
86
+ * genuinely different budget. The refine lane's `claimSpecs` caps against
87
+ * `refineConcurrency`, independent of the build lane's `concurrency`; a
88
+ * shared set would conflate "how many specs are being refined" with "how
89
+ * many issues are being built" and make either budget impossible to
90
+ * enforce on its own. The two never collide on an id in practice (a
91
+ * `spec-ready` issue and a `ready` issue are never the same issue), but
92
+ * that isn't why this is separate — the budgets are what require it.
93
+ */
94
+ refining: Set<string>;
52
95
  }
53
96
  export declare function createWatchState(): WatchRunState;
54
97
  export declare function branchNameFor(issue: Issue): string;
98
+ /** Same idea as `branchNameFor`, for the refine lane's throwaway worktree — a spec never gets a PR, so this branch is only ever fetched-from-and-thrown-away, never pushed. */
99
+ export declare function refineBranchNameFor(issue: Issue): string;
55
100
  /**
56
101
  * Any issue labeled `working` that THIS process isn't tracking is an
57
102
  * orphan — a daemon restart, or another instance's claim this process
@@ -59,9 +104,19 @@ export declare function branchNameFor(issue: Issue): string;
59
104
  * merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
60
105
  */
61
106
  export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
107
+ /**
108
+ * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
109
+ * process isn't tracking is either a completed publish that crashed before
110
+ * its own `transition(issue, "done")` ran (resume: finish it, no re-run),
111
+ * or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
112
+ * A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
113
+ */
114
+ export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
62
115
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
63
116
  export declare function finishReviews(deps: WatchDeps): Promise<void>;
64
117
  /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
65
118
  export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
66
- /** One poll tick: reconcile, finish, claim each independently caught, so one phase's error never blocks the rest. */
119
+ /** 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. */
120
+ export declare function claimSpecs(deps: WatchDeps, state: WatchRunState): Promise<void>;
121
+ /** 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. */
67
122
  export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
@@ -1,10 +1,18 @@
1
1
  /**
2
- * The `spf watch` state machine: poll -> claim -> run a chain -> PR ->
3
- * done/blocked. Provider-agnostic (drives whatever `IssueProvider` it's
4
- * given) and chain-agnostic (drives whatever `runChain` callback it's
2
+ * The `spf watch` state machine two lanes over the same poll loop.
3
+ *
4
+ * The build lane: poll -> claim -> run a chain -> PR -> done/blocked.
5
+ * The refine lane (`watch.refine.enabled`, off by default): poll a
6
+ * `spec-ready` product spec -> claim -> decompose it into a feature/story
7
+ * tree -> publish those as real issues -> done/blocked. A spec is not
8
+ * individually workable, so this lane never opens a PR — it hands the build
9
+ * lane its next batch of `ready`-able work instead (see `core/refine.ts`).
10
+ *
11
+ * Provider-agnostic (drives whatever `IssueProvider` it's given) and
12
+ * chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
5
13
  * given) — deliberately kept out of `src/chains/`'s dependency direction,
6
- * so this stays testable against a fake provider and a fake `runChain`
7
- * with no chain registry involved.
14
+ * so this stays testable against a fake provider and fake callbacks with no
15
+ * chain registry involved.
8
16
  *
9
17
  * Design lifted from the user's own GitHub-poller reference implementation
10
18
  * (a label-as-state-machine daemon), leaned down for a v1: no per-issue
@@ -30,24 +38,33 @@
30
38
  import path from "node:path";
31
39
  const MAX_ORPHAN_ATTEMPTS = 2;
32
40
  export function createWatchState() {
33
- return { inflight: new Set() };
41
+ return { inflight: new Set(), refining: new Set() };
34
42
  }
35
- export function branchNameFor(issue) {
36
- const slug = issue.title
43
+ function slugifyTitle(title) {
44
+ return (title
37
45
  .toLowerCase()
38
46
  .split(/\s+/)
39
47
  .filter(Boolean)
40
48
  .slice(0, 5)
41
49
  .join("-")
42
- .replace(/[^a-z0-9-]/g, "");
50
+ .replace(/[^a-z0-9-]/g, "") || "issue");
51
+ }
52
+ export function branchNameFor(issue) {
43
53
  // The issue's own id in the branch name isn't just labeling: Jira's
44
54
  // Bitbucket integration auto-links a PR to the issue when its key
45
55
  // appears anywhere in the branch name, no explicit API call needed.
46
- return `spf-watch/${issue.id}-${slug || "issue"}`.slice(0, 200);
56
+ return `spf-watch/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
57
+ }
58
+ /** Same idea as `branchNameFor`, for the refine lane's throwaway worktree — a spec never gets a PR, so this branch is only ever fetched-from-and-thrown-away, never pushed. */
59
+ export function refineBranchNameFor(issue) {
60
+ return `spf-refine/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
47
61
  }
48
62
  function worktreePathFor(deps, issue) {
49
63
  return path.join(deps.worktreesDir, `issue-${issue.id}`);
50
64
  }
65
+ function specWorktreePathFor(deps, issue) {
66
+ return path.join(deps.worktreesDir, `spec-${issue.id}`);
67
+ }
51
68
  function cleanupWorktree(deps, marker) {
52
69
  if (!marker)
53
70
  return;
@@ -106,6 +123,78 @@ export async function reconcileOrphans(deps, state) {
106
123
  }
107
124
  }
108
125
  }
126
+ /**
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.
135
+ */
136
+ async function finishSpec(deps, issue, created) {
137
+ const body = created.length > 0
138
+ ? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
139
+ 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.`
141
+ : `spf watch refined this spec but the refiner produced no issues.`;
142
+ deps.notify({
143
+ kind: "spec_refined",
144
+ level: "info",
145
+ title: `spec ${issue.id} refined`,
146
+ detail: `${created.length} issue(s) created.`,
147
+ fields: [["issue", issue.id], ["title", issue.title], ["created", String(created.length)]],
148
+ });
149
+ if (!deps.dryRun) {
150
+ await deps.provider.comment(issue, body);
151
+ await deps.provider.transition(issue, "done");
152
+ }
153
+ }
154
+ /**
155
+ * 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`.
160
+ */
161
+ export async function reconcileRefining(deps, state) {
162
+ if (!deps.refineEnabled)
163
+ return;
164
+ const refining = await deps.provider.listInState("refining");
165
+ for (const issue of refining) {
166
+ if (state.refining.has(issue.id))
167
+ continue;
168
+ const marker = await deps.provider.readMarker(issue);
169
+ if (marker?.refined && marker.refined.length > 0) {
170
+ deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
171
+ await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
172
+ continue;
173
+ }
174
+ const attempt = (marker?.attempt ?? 0) + 1;
175
+ if (attempt <= MAX_ORPHAN_ATTEMPTS) {
176
+ deps.log(`watch: spec ${issue.id} orphaned mid-refine, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to spec-ready`);
177
+ if (!deps.dryRun) {
178
+ await deps.provider.writeMarker(issue, { ...marker, attempt });
179
+ await deps.provider.transition(issue, "spec-ready");
180
+ }
181
+ }
182
+ else {
183
+ deps.log(`watch: spec ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
184
+ deps.notify({
185
+ kind: "issue_blocked",
186
+ level: "error",
187
+ title: `spec ${issue.id} blocked`,
188
+ detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`,
189
+ fields: [["issue", issue.id], ["title", issue.title]],
190
+ });
191
+ if (!deps.dryRun) {
192
+ await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`);
193
+ cleanupWorktree(deps, marker);
194
+ }
195
+ }
196
+ }
197
+ }
109
198
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
110
199
  export async function finishReviews(deps) {
111
200
  const reviewing = await deps.provider.listInState("review", { includeAll: true });
@@ -231,6 +320,72 @@ async function runIssue(deps, issue) {
231
320
  cleanupWorktree(deps, { worktree: worktreePath, branch });
232
321
  }
233
322
  }
323
+ /**
324
+ * One spec's full claim -> decompose -> publish path, run in the background
325
+ * — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
326
+ * PR half: no `diffFiles` check (the refiner has `writes: []`, so an empty
327
+ * diff is the CORRECT outcome, not a failure), no push, no `openPr`. Its
328
+ * mirror image is "publish, then finish" instead of "commit, then review".
329
+ */
330
+ async function runSpec(deps, issue) {
331
+ const branch = refineBranchNameFor(issue);
332
+ const worktreePath = specWorktreePathFor(deps, issue);
333
+ const adwId = `spec-${issue.id}`;
334
+ try {
335
+ // A spec re-claimed after a completed publish (the transition/comment
336
+ // that should have followed never ran — a crash, a kill) already has
337
+ // its answer on disk: skip straight to finishing rather than asking the
338
+ // refiner to redo work that already exists on the tracker. `to-tickets`
339
+ // (the skill this lane's prompt is ported from) has no such guard and
340
+ // would duplicate every issue on a re-run.
341
+ const existingMarker = await deps.provider.readMarker(issue);
342
+ if (existingMarker?.refined && existingMarker.refined.length > 0) {
343
+ deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
344
+ await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
345
+ return;
346
+ }
347
+ // See runIssue's identical comment: worktreePath/branch are deterministic
348
+ // from issue.id, so a leftover from a killed prior attempt is the only
349
+ // way either could already exist — clear it unconditionally.
350
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
351
+ deps.git.fetch("origin", deps.baseBranch);
352
+ deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
353
+ deps.linkDataDir(worktreePath);
354
+ await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
355
+ const prompt = `${issue.title}\n\n${issue.body}`.trim();
356
+ const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id });
357
+ if (!result.accepted) {
358
+ deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
359
+ const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
360
+ deps.notify({
361
+ kind: "issue_blocked",
362
+ level: "error",
363
+ title: `spec ${issue.id} blocked`,
364
+ detail,
365
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain], ["adw_id", adwId]],
366
+ });
367
+ await deps.provider.transition(issue, "blocked", detail);
368
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
369
+ return;
370
+ }
371
+ await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0, refined: result.created.map((c) => c.id) });
372
+ await finishSpec(deps, issue, result.created);
373
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
374
+ }
375
+ catch (error) {
376
+ const message = error.message;
377
+ deps.log(`watch: spec ${issue.id}: refine error: ${message}`);
378
+ deps.notify({
379
+ kind: "watch_error",
380
+ level: "error",
381
+ title: `spec ${issue.id} errored`,
382
+ detail: message,
383
+ fields: [["issue", issue.id], ["title", issue.title]],
384
+ });
385
+ await deps.provider.transition(issue, "blocked", `spf watch refine error: ${message}`).catch(() => undefined);
386
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
387
+ }
388
+ }
234
389
  /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
235
390
  export async function claimNewWork(deps, state) {
236
391
  if (state.inflight.size >= deps.concurrency)
@@ -261,6 +416,38 @@ export async function claimNewWork(deps, state) {
261
416
  runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
262
417
  }
263
418
  }
419
+ /** 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. */
420
+ export async function claimSpecs(deps, state) {
421
+ if (!deps.refineEnabled)
422
+ return;
423
+ if (state.refining.size >= deps.refineConcurrency)
424
+ return;
425
+ const eligible = await deps.provider.listInState("spec-ready");
426
+ for (const issue of eligible) {
427
+ if (state.refining.size >= deps.refineConcurrency)
428
+ break;
429
+ if (state.refining.has(issue.id))
430
+ continue;
431
+ if (deps.dryRun) {
432
+ deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
433
+ continue;
434
+ }
435
+ const claimed = await deps.provider.claim(issue, { from: "spec-ready", to: "refining" });
436
+ if (!claimed) {
437
+ deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
438
+ continue;
439
+ }
440
+ deps.log(`watch: claimed spec ${issue.id}: ${issue.title}`);
441
+ deps.notify({
442
+ kind: "issue_claimed",
443
+ level: "info",
444
+ title: `spec ${issue.id} claimed`,
445
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain]],
446
+ });
447
+ state.refining.add(issue.id);
448
+ runSpec(deps, issue).finally(() => state.refining.delete(issue.id));
449
+ }
450
+ }
264
451
  function tickErrorHandler(deps, stage) {
265
452
  return (error) => {
266
453
  const message = error.message;
@@ -268,9 +455,11 @@ function tickErrorHandler(deps, stage) {
268
455
  deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
269
456
  };
270
457
  }
271
- /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
458
+ /** 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. */
272
459
  export async function tick(deps, state) {
273
460
  await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
461
+ await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
274
462
  await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
463
+ await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
275
464
  await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
276
465
  }
@@ -41,6 +41,7 @@ const EXPECTED = {
41
41
  },
42
42
  quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
43
43
  document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
44
+ refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
44
45
  "simple-sdlc": {
45
46
  phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
46
47
  "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
@@ -64,3 +64,20 @@ test("never writes .env or .env.example on the non-interactive paths", async ()
64
64
  assert.equal(existsSync(join(dir, ".env")), false);
65
65
  assert.equal(existsSync(join(dir, ".env.example")), false);
66
66
  });
67
+ test("also installs the repo-local Claude Code skill by default, on every non-interactive path", async () => {
68
+ const code = await initCommand(["--cwd", dir, "--yes"]);
69
+ assert.equal(code, 0);
70
+ assert.ok(existsSync(join(dir, ".claude", "skills", "spf", "SKILL.md")), "spf init should install the skill unless --no-skills is passed");
71
+ });
72
+ test("--no-skills skips the skill install", async () => {
73
+ const code = await initCommand(["--cwd", dir, "--yes", "--no-skills"]);
74
+ assert.equal(code, 0);
75
+ assert.equal(existsSync(join(dir, ".claude", "skills", "spf")), false);
76
+ });
77
+ test("re-running spf init doesn't re-copy an unchanged skill install (install-skill's own idempotency)", async () => {
78
+ await initCommand(["--cwd", dir, "--yes"]);
79
+ const manifestPath = join(dir, ".claude", "skills", "spf", ".spf-skill-version");
80
+ const before = readFileSync(manifestPath, "utf-8");
81
+ await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force: config write is a no-op, skill install still runs
82
+ assert.equal(readFileSync(manifestPath, "utf-8"), before);
83
+ });
@@ -233,15 +233,16 @@ test("customize models per agent: declining keeps today's behavior — only the
233
233
  for (const a of config.agents)
234
234
  assert.equal(a.model, "opus");
235
235
  });
236
- test("customize models per agent: accepting patches the pinned three and appends builder/scout", async () => {
236
+ test("customize models per agent: accepting patches the pinned three and appends builder/scout/refiner", async () => {
237
237
  const ctx = gatherContext(dir, new Map());
238
- assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "reviewer", "scout"]);
238
+ assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "refiner", "reviewer", "scout"]);
239
239
  const asker = createFakeAsker({
240
240
  select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
241
241
  text: {
242
242
  " planner": "opus",
243
243
  " builder": "sonnet",
244
244
  " scout": "haiku",
245
+ " refiner": "opus",
245
246
  " reviewer": "opus",
246
247
  " documenter": "sonnet",
247
248
  },
@@ -261,7 +262,7 @@ test("customize models per agent: accepting patches the pinned three and appends
261
262
  assert.ok(result);
262
263
  const config = result.config;
263
264
  const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
264
- assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku" });
265
+ assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku", refiner: "opus" });
265
266
  const configPath = mergedConfigPath();
266
267
  const { stringify } = await import("yaml");
267
268
  writeFileSync(configPath, stringify(config));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { publish } from "../core/refine.js";
4
+ import { refinementWellFormed } from "../core/gates.js";
5
+ /** In-memory fake — exactly the seam `IssueAuthoringProvider` exists for. */
6
+ class FakeTracker {
7
+ created = [];
8
+ links = [];
9
+ nextId = 1;
10
+ async createIssue(input) {
11
+ this.created.push(input);
12
+ const id = String(this.nextId++);
13
+ return { id, internal_id: `db-${id}`, title: input.title, body: input.body, labels: input.labels };
14
+ }
15
+ async linkChild(parent, child) {
16
+ this.links.push({ parent: parent.id, child: child.id });
17
+ }
18
+ }
19
+ function node(overrides) {
20
+ return { body: "", parent: "", blocked_by: [], ...overrides };
21
+ }
22
+ // ── publish() ────────────────────────────────────────────────────────────
23
+ test("publish: creates a container before its children, and links them via linkChild", async () => {
24
+ const tracker = new FakeTracker();
25
+ const issues = [
26
+ node({ key: "S1", kind: "story", title: "Owner invites by email", parent: "F1" }),
27
+ node({ key: "F1", kind: "feature", title: "Team invitations" }),
28
+ ];
29
+ const created = await publish(tracker, issues, { labelPrefix: "spf" });
30
+ assert.deepEqual(tracker.created.map((c) => c.title), ["Team invitations", "Owner invites by email"], "the parent must be created before its child");
31
+ assert.deepEqual(tracker.links, [{ parent: "1", child: "2" }]);
32
+ assert.deepEqual(created.map((c) => ({ key: c.key, isLeaf: c.isLeaf })), [
33
+ { key: "F1", isLeaf: false },
34
+ { key: "S1", isLeaf: true },
35
+ ]);
36
+ });
37
+ test("publish: labels a leaf with its type AND spf:refined; a container gets only its type label", async () => {
38
+ const tracker = new FakeTracker();
39
+ const issues = [node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })];
40
+ await publish(tracker, issues, { labelPrefix: "spf" });
41
+ const feature = tracker.created.find((c) => c.title === "A feature");
42
+ const leaf = tracker.created.find((c) => c.title === "A leaf");
43
+ assert.deepEqual(feature.labels, ["spf:type:feature"]);
44
+ assert.deepEqual(leaf.labels, ["spf:type:story", "spf:refined"]);
45
+ });
46
+ test("publish: creates a blocker before what it blocks, and renders a real #n reference in the body", async () => {
47
+ const tracker = new FakeTracker();
48
+ const issues = [
49
+ node({ key: "S2", kind: "bug", title: "Fix the expiry check", blocked_by: ["S1"] }),
50
+ node({ key: "S1", kind: "story", title: "Owner invites by email" }),
51
+ ];
52
+ const created = await publish(tracker, issues, { labelPrefix: "spf" });
53
+ const s1Id = created.find((c) => c.key === "S1").issue.id;
54
+ const s2 = tracker.created.find((c) => c.title === "Fix the expiry check");
55
+ assert.equal(created[0].key, "S1", "the blocker publishes first");
56
+ assert.match(s2.body, new RegExp(`## Blocked by\\n\\n- #${s1Id}`));
57
+ });
58
+ test("publish: a leaf with no blockers gets the 'None (can start immediately)' text", async () => {
59
+ const tracker = new FakeTracker();
60
+ const created = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
61
+ assert.match(created[0].issue.body, /## Blocked by\n\nNone \(can start immediately\)\./);
62
+ });
63
+ test("publish: renders '## Parent: #<id>' when a spec issue id is given, and omits it otherwise", async () => {
64
+ const tracker = new FakeTracker();
65
+ const withSpec = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf", specIssueId: "42" });
66
+ assert.match(withSpec[0].issue.body, /## Parent\n\nDecomposed from #42\./);
67
+ const tracker2 = new FakeTracker();
68
+ const withoutSpec = await publish(tracker2, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
69
+ assert.doesNotMatch(withoutSpec[0].issue.body, /## Parent/);
70
+ });
71
+ // ── gates.refinementWellFormed ───────────────────────────────────────────
72
+ function envelope(issues) {
73
+ return { status: "success", summary: "", artifacts: [], notes_for_next_agent: "", issues };
74
+ }
75
+ test("refinementWellFormed: an empty issues list fails", () => {
76
+ const report = refinementWellFormed(envelope([]), { repo_root: "/repo" });
77
+ assert.equal(report.passed, false);
78
+ });
79
+ test("refinementWellFormed: a well-formed feature/story tree with no blockers passes clean", () => {
80
+ const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
81
+ assert.equal(report.passed, true);
82
+ });
83
+ test("refinementWellFormed: a container mislabeled as a leaf kind fails", () => {
84
+ const report = refinementWellFormed(envelope([node({ key: "F1", kind: "story", title: "Should be a feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
85
+ assert.equal(report.passed, false);
86
+ assert.ok(report.violations.some((v) => v.includes("F1.kind")));
87
+ });
88
+ test("refinementWellFormed: a two-node parent cycle has no leaves AND fails the dependency-graph check", () => {
89
+ // A finite, ACYCLIC parent forest always has at least one leaf by
90
+ // construction (some node's children set is empty) — the only way to
91
+ // drive leafCount to zero is a cycle, which the dependency-graph check
92
+ // already independently rejects. This test exercises that overlap
93
+ // directly rather than asserting an "all containers, no leaves" shape
94
+ // that isn't otherwise reachable.
95
+ const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A", parent: "F2" }), node({ key: "F2", kind: "feature", title: "B", parent: "F1" })]), { repo_root: "/repo" });
96
+ assert.equal(report.passed, false);
97
+ assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
98
+ assert.ok(report.violations.some((v) => v.toLowerCase().includes("leaves")));
99
+ });
100
+ test("refinementWellFormed: an unresolved parent key fails", () => {
101
+ const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "Orphaned", parent: "nonexistent" })]), {
102
+ repo_root: "/repo",
103
+ });
104
+ assert.equal(report.passed, false);
105
+ assert.ok(report.violations.some((v) => v.includes("parent")));
106
+ });
107
+ test("refinementWellFormed: an unresolved blocked_by key fails", () => {
108
+ const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A leaf", blocked_by: ["nonexistent"] })]), {
109
+ repo_root: "/repo",
110
+ });
111
+ assert.equal(report.passed, false);
112
+ assert.ok(report.violations.some((v) => v.includes("blocked_by")));
113
+ });
114
+ test("refinementWellFormed: a blocked_by cycle fails", () => {
115
+ const report = refinementWellFormed(envelope([
116
+ node({ key: "S1", kind: "story", title: "A", blocked_by: ["S2"] }),
117
+ node({ key: "S2", kind: "story", title: "B", blocked_by: ["S1"] }),
118
+ ]), { repo_root: "/repo" });
119
+ assert.equal(report.passed, false);
120
+ assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
121
+ });
122
+ test("refinementWellFormed: duplicate keys fail", () => {
123
+ const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A" }), node({ key: "S1", kind: "bug", title: "B" })]), { repo_root: "/repo" });
124
+ assert.equal(report.passed, false);
125
+ assert.ok(report.violations.some((v) => v.includes("duplicate")));
126
+ });