@gr8ful/spf 0.2.1 → 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 (60) hide show
  1. package/README.md +106 -6
  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 +83 -3
  6. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  7. package/assets/templates/ts.spf.config.yaml +22 -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 +18 -0
  13. package/dist/cli/commands/init.js +44 -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 +86 -8
  18. package/dist/cli/index.js +7 -3
  19. package/dist/cli/interview.d.ts +2 -0
  20. package/dist/cli/interview.js +107 -3
  21. package/dist/core/agents.js +4 -1
  22. package/dist/core/console.d.ts +13 -1
  23. package/dist/core/console.js +51 -1
  24. package/dist/core/data_types.d.ts +133 -0
  25. package/dist/core/data_types.js +72 -0
  26. package/dist/core/gates.d.ts +13 -0
  27. package/dist/core/gates.js +103 -0
  28. package/dist/core/issues/github_provider.d.ts +35 -9
  29. package/dist/core/issues/github_provider.js +76 -28
  30. package/dist/core/issues/jira_provider.d.ts +14 -1
  31. package/dist/core/issues/jira_provider.js +9 -7
  32. package/dist/core/issues/provider.d.ts +77 -15
  33. package/dist/core/issues/provider.js +7 -4
  34. package/dist/core/notify/channel.d.ts +32 -0
  35. package/dist/core/notify/channel.js +14 -0
  36. package/dist/core/notify/notifier.d.ts +42 -0
  37. package/dist/core/notify/notifier.js +100 -0
  38. package/dist/core/notify/slack_channel.d.ts +13 -0
  39. package/dist/core/notify/slack_channel.js +30 -0
  40. package/dist/core/notify/teams_channel.d.ts +17 -0
  41. package/dist/core/notify/teams_channel.js +38 -0
  42. package/dist/core/notify/webhook_channel.d.ts +13 -0
  43. package/dist/core/notify/webhook_channel.js +19 -0
  44. package/dist/core/refine.d.ts +39 -0
  45. package/dist/core/refine.js +144 -0
  46. package/dist/core/runner.d.ts +7 -0
  47. package/dist/core/runner.js +4 -1
  48. package/dist/core/session.js +3 -0
  49. package/dist/core/watch.d.ts +66 -1
  50. package/dist/core/watch.js +267 -15
  51. package/dist/test/chains.test.js +1 -0
  52. package/dist/test/data_types.test.js +34 -1
  53. package/dist/test/init_command.test.js +17 -0
  54. package/dist/test/interview.test.js +119 -0
  55. package/dist/test/notify.test.d.ts +1 -0
  56. package/dist/test/notify.test.js +174 -0
  57. package/dist/test/refine.test.d.ts +1 -0
  58. package/dist/test/refine.test.js +126 -0
  59. package/dist/test/watch.test.js +286 -5
  60. package/package.json +1 -1
@@ -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;
@@ -92,6 +109,13 @@ export async function reconcileOrphans(deps, state) {
92
109
  }
93
110
  else {
94
111
  deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
112
+ deps.notify({
113
+ kind: "issue_blocked",
114
+ level: "error",
115
+ title: `issue ${issue.id} blocked`,
116
+ detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`,
117
+ fields: [["issue", issue.id], ["title", issue.title]],
118
+ });
95
119
  if (!deps.dryRun) {
96
120
  await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
97
121
  cleanupWorktree(deps, marker);
@@ -99,6 +123,78 @@ export async function reconcileOrphans(deps, state) {
99
123
  }
100
124
  }
101
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
+ }
102
198
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
103
199
  export async function finishReviews(deps) {
104
200
  const reviewing = await deps.provider.listInState("review", { includeAll: true });
@@ -109,6 +205,13 @@ export async function finishReviews(deps) {
109
205
  const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
110
206
  if (status.merged) {
111
207
  deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
208
+ deps.notify({
209
+ kind: "issue_done",
210
+ level: "info",
211
+ title: `issue ${issue.id} done`,
212
+ detail: `PR #${marker.pr} merged.`,
213
+ fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
214
+ });
112
215
  if (!deps.dryRun) {
113
216
  await deps.provider.transition(issue, "done");
114
217
  cleanupWorktree(deps, marker);
@@ -116,6 +219,13 @@ export async function finishReviews(deps) {
116
219
  }
117
220
  else if (status.state === "closed") {
118
221
  deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
222
+ deps.notify({
223
+ kind: "issue_blocked",
224
+ level: "error",
225
+ title: `issue ${issue.id} blocked`,
226
+ detail: `PR #${marker.pr} was closed without merging.`,
227
+ fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
228
+ });
119
229
  if (!deps.dryRun) {
120
230
  await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
121
231
  cleanupWorktree(deps, marker);
@@ -147,13 +257,28 @@ async function runIssue(deps, issue) {
147
257
  const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
148
258
  if (!result.accepted) {
149
259
  deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
150
- await deps.provider.transition(issue, "blocked", result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`);
260
+ const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
261
+ deps.notify({
262
+ kind: "issue_blocked",
263
+ level: "error",
264
+ title: `issue ${issue.id} blocked`,
265
+ detail,
266
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
267
+ });
268
+ await deps.provider.transition(issue, "blocked", detail);
151
269
  cleanupWorktree(deps, { worktree: worktreePath, branch });
152
270
  return;
153
271
  }
154
272
  const wtGit = deps.worktreeGit(worktreePath);
155
273
  if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
156
274
  deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
275
+ deps.notify({
276
+ kind: "issue_blocked",
277
+ level: "error",
278
+ title: `issue ${issue.id} blocked`,
279
+ detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
280
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
281
+ });
157
282
  await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
158
283
  cleanupWorktree(deps, { worktree: worktreePath, branch });
159
284
  return;
@@ -173,14 +298,94 @@ async function runIssue(deps, issue) {
173
298
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
174
299
  await deps.provider.transition(issue, "review");
175
300
  deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
301
+ deps.notify({
302
+ kind: "pr_opened",
303
+ level: "info",
304
+ title: `PR #${pr.number} opened`,
305
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
306
+ url: pr.url || undefined,
307
+ });
176
308
  }
177
309
  catch (error) {
178
310
  const message = error.message;
179
311
  deps.log(`watch: ${issue.id}: error: ${message}`);
312
+ deps.notify({
313
+ kind: "watch_error",
314
+ level: "error",
315
+ title: `issue ${issue.id} errored`,
316
+ detail: message,
317
+ fields: [["issue", issue.id], ["title", issue.title]],
318
+ });
180
319
  await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
181
320
  cleanupWorktree(deps, { worktree: worktreePath, branch });
182
321
  }
183
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
+ }
184
389
  /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
185
390
  export async function claimNewWork(deps, state) {
186
391
  if (state.inflight.size >= deps.concurrency)
@@ -201,13 +406,60 @@ export async function claimNewWork(deps, state) {
201
406
  continue;
202
407
  }
203
408
  deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
409
+ deps.notify({
410
+ kind: "issue_claimed",
411
+ level: "info",
412
+ title: `issue ${issue.id} claimed`,
413
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
414
+ });
204
415
  state.inflight.add(issue.id);
205
416
  runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
206
417
  }
207
418
  }
208
- /** One poll tick: reconcile, finish, claim each independently caught, so one phase's error never blocks the rest. */
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
+ }
451
+ function tickErrorHandler(deps, stage) {
452
+ return (error) => {
453
+ const message = error.message;
454
+ deps.log(`watch: ${stage} error: ${message}`);
455
+ deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
456
+ };
457
+ }
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. */
209
459
  export async function tick(deps, state) {
210
- await reconcileOrphans(deps, state).catch((error) => deps.log(`watch: reconcileOrphans error: ${error.message}`));
211
- await finishReviews(deps).catch((error) => deps.log(`watch: finishReviews error: ${error.message}`));
212
- await claimNewWork(deps, state).catch((error) => deps.log(`watch: claimNewWork error: ${error.message}`));
460
+ await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
461
+ await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
462
+ await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
463
+ await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
464
+ await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
213
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) " +
@@ -9,9 +9,13 @@
9
9
  */
10
10
  import { test } from "node:test";
11
11
  import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
12
15
  import * as v from "valibot";
13
16
  import { toJsonSchema } from "@valibot/to-json-schema";
14
- import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
17
+ import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
18
+ import { loadConfig } from "../core/agents.js";
15
19
  test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
16
20
  const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
17
21
  const unrestricted = v.parse(AgentConfigSchema, base);
@@ -47,3 +51,32 @@ test("every envelope type still converts to JSON Schema (the sf_report tool wiri
47
51
  test("PhaseParamsSchema itself (the one schema WITH a rawTransform) correctly refuses JSON Schema conversion", () => {
48
52
  assert.throws(() => toJsonSchema(PhaseParamsSchema), /raw_transform/);
49
53
  });
54
+ test("NotificationsConfigSchema defaults to off, no channels", () => {
55
+ const parsed = v.parse(NotificationsConfigSchema, {});
56
+ assert.equal(parsed.events, "off");
57
+ assert.equal(parsed.timeout_ms, 5_000);
58
+ assert.deepEqual(parsed.channels, []);
59
+ });
60
+ test("a channel's own `events` overrides the top-level scope; unset inherits it", () => {
61
+ const parsed = v.parse(NotificationsConfigSchema, {
62
+ events: "errors",
63
+ channels: [{ kind: "slack" }, { kind: "teams", events: "all" }],
64
+ });
65
+ assert.equal(parsed.channels[0].events, undefined, "unset per-channel scope stays undefined, not defaulted to the top-level value — the caller inherits at read time");
66
+ assert.equal(parsed.channels[1].events, "all");
67
+ });
68
+ test("notifications survives loadConfig's merge — key-by-key like observability/quality, channels replaced wholesale on override", () => {
69
+ const dir = mkdtempSync(join(tmpdir(), "spf-notify-merge-test-"));
70
+ try {
71
+ const base = join(dir, "base.yaml");
72
+ const override = join(dir, "override.yaml");
73
+ writeFileSync(base, "notifications:\n events: off\n channels:\n - {kind: webhook, webhook_url_env: BASE_HOOK}\n");
74
+ writeFileSync(override, "notifications:\n events: all\n channels:\n - {kind: slack, webhook_url_env: SLACK_WEBHOOK_URL}\n");
75
+ const cfg = loadConfig([base, override]);
76
+ assert.equal(cfg.notifications.events, "all", "events: key-by-key, override wins");
77
+ assert.deepEqual(cfg.notifications.channels.map((c) => c.kind), ["slack"], "channels: a whole-array replace, not an append — same semantics as quality.checks");
78
+ }
79
+ finally {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ });
@@ -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
+ });
@@ -151,6 +151,125 @@ test("declining the final confirm returns null — nothing to write", async () =
151
151
  const result = await runInterview(asker, ctx);
152
152
  assert.equal(result, null);
153
153
  });
154
+ test("notifications: declining the gate leaves config.notifications undefined", async () => {
155
+ const ctx = gatherContext(dir, new Map());
156
+ const asker = createFakeAsker({
157
+ select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
158
+ confirm: {
159
+ 'Add a "typecheck"': false,
160
+ 'Add a "lint"': false,
161
+ 'Add a "build"': false,
162
+ 'Add a "test"': false,
163
+ "Enable spf watch": false,
164
+ "Send notifications": false,
165
+ "Configure advanced": false,
166
+ "Write .spf": true,
167
+ },
168
+ });
169
+ const result = await runInterview(asker, ctx);
170
+ assert.ok(result);
171
+ const config = result.config;
172
+ assert.equal(config.notifications, undefined);
173
+ });
174
+ test("notifications: accepting collects the scope, a channel, and its webhook env key", async () => {
175
+ const ctx = gatherContext(dir, new Map());
176
+ const asker = createFakeAsker({
177
+ select: {
178
+ "backend runs": "claude_code",
179
+ "Model (Claude": "sonnet",
180
+ Authentication: "login",
181
+ "Notify on": "all",
182
+ Channel: "slack",
183
+ },
184
+ confirm: {
185
+ 'Add a "typecheck"': false,
186
+ 'Add a "lint"': false,
187
+ 'Add a "build"': false,
188
+ 'Add a "test"': false,
189
+ "Enable spf watch": false,
190
+ "Send notifications": true,
191
+ "Add another channel": false,
192
+ "Configure advanced": false,
193
+ "Write .spf": true,
194
+ },
195
+ secret: { SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXXX" },
196
+ });
197
+ const result = await runInterview(asker, ctx);
198
+ assert.ok(result);
199
+ const config = result.config;
200
+ assert.equal(config.notifications.events, "all");
201
+ assert.deepEqual(config.notifications.channels, [{ kind: "slack", webhook_url_env: "SLACK_WEBHOOK_URL" }]);
202
+ assert.equal(result.env.SLACK_WEBHOOK_URL, "https://hooks.slack.com/services/T000/B000/XXXX");
203
+ assert.ok(result.envExampleKeys.includes("SLACK_WEBHOOK_URL"));
204
+ // The written document must still merge and validate cleanly, same
205
+ // pipeline `spf doctor` runs.
206
+ const configPath = mergedConfigPath();
207
+ const { stringify } = await import("yaml");
208
+ writeFileSync(configPath, stringify(config));
209
+ const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
210
+ assert.equal(cfg.notifications.events, "all");
211
+ assert.equal(cfg.notifications.channels[0].kind, "slack");
212
+ });
213
+ test("customize models per agent: declining keeps today's behavior — only the three pinned agents, all matching defaults.model", async () => {
214
+ const ctx = gatherContext(dir, new Map());
215
+ const asker = createFakeAsker({
216
+ select: { "backend runs": "claude_code", "Model (Claude": "opus", Authentication: "login" },
217
+ confirm: {
218
+ 'Add a "typecheck"': false,
219
+ 'Add a "lint"': false,
220
+ 'Add a "build"': false,
221
+ 'Add a "test"': false,
222
+ "Enable spf watch": false,
223
+ "Customize models per agent": false,
224
+ "Send notifications": false,
225
+ "Configure advanced": false,
226
+ "Write .spf": true,
227
+ },
228
+ });
229
+ const result = await runInterview(asker, ctx);
230
+ assert.ok(result);
231
+ const config = result.config;
232
+ assert.deepEqual(config.agents.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
233
+ for (const a of config.agents)
234
+ assert.equal(a.model, "opus");
235
+ });
236
+ test("customize models per agent: accepting patches the pinned three and appends builder/scout/refiner", async () => {
237
+ const ctx = gatherContext(dir, new Map());
238
+ assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "refiner", "reviewer", "scout"]);
239
+ const asker = createFakeAsker({
240
+ select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
241
+ text: {
242
+ " planner": "opus",
243
+ " builder": "sonnet",
244
+ " scout": "haiku",
245
+ " refiner": "opus",
246
+ " reviewer": "opus",
247
+ " documenter": "sonnet",
248
+ },
249
+ confirm: {
250
+ 'Add a "typecheck"': false,
251
+ 'Add a "lint"': false,
252
+ 'Add a "build"': false,
253
+ 'Add a "test"': false,
254
+ "Enable spf watch": false,
255
+ "Customize models per agent": true,
256
+ "Send notifications": false,
257
+ "Configure advanced": false,
258
+ "Write .spf": true,
259
+ },
260
+ });
261
+ const result = await runInterview(asker, ctx);
262
+ assert.ok(result);
263
+ const config = result.config;
264
+ const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
265
+ assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku", refiner: "opus" });
266
+ const configPath = mergedConfigPath();
267
+ const { stringify } = await import("yaml");
268
+ writeFileSync(configPath, stringify(config));
269
+ const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
270
+ validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
271
+ assert.equal(cfg.agents.find((a) => a.name === "scout").model, "haiku");
272
+ });
154
273
  test("an existing .env value is offered back as the default when a secret is left blank", async () => {
155
274
  const ctx = gatherContext(dir, new Map([["GITHUB_TOKEN", "ghp_existingvalue"]]));
156
275
  const asker = createFakeAsker({
@@ -0,0 +1 @@
1
+ export {};