@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
@@ -13,6 +13,7 @@ import * as agents from "../../core/agents.js";
13
13
  import * as paths from "../../core/paths.js";
14
14
  import * as permissions from "../../core/permissions.js";
15
15
  import * as agentCc from "../../core/agent_cc.js";
16
+ import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
16
17
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
17
18
  import { binaryOnPath, parseCli } from "../../core/utils.js";
18
19
  import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
@@ -142,6 +143,23 @@ export function doctorCommand(argv) {
142
143
  : 'not set — Bitbucket app passwords are being removed; spf watch needs an Atlassian account email plus an API token instead; see README.md\'s "spf watch" section');
143
144
  }
144
145
  check(report, "watch.chain", Boolean(findChain(cfg.watch.chain)), findChain(cfg.watch.chain) ? cfg.watch.chain : `"${cfg.watch.chain}" is not a registered chain`);
146
+ if (cfg.watch.refine.enabled) {
147
+ check(report, "watch.refine.chain", Boolean(findChain(cfg.watch.refine.chain)), findChain(cfg.watch.refine.chain) ? cfg.watch.refine.chain : `"${cfg.watch.refine.chain}" is not a registered chain`);
148
+ check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github", cfg.watch.issue_provider === "github"
149
+ ? "github supports issue authoring (createIssue/sub-issues)"
150
+ : `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" (Jira issue authoring isn't implemented yet)`);
151
+ }
152
+ }
153
+ if (cfg.notifications.events !== "off") {
154
+ check(report, "notifications.events", true, cfg.notifications.events);
155
+ if (cfg.notifications.channels.length === 0) {
156
+ check(report, "notifications.channels", false, `notifications.events is ${JSON.stringify(cfg.notifications.events)} but no channels are configured`);
157
+ }
158
+ for (const ch of cfg.notifications.channels) {
159
+ const envKey = ch.webhook_url_env || DEFAULT_NOTIFY_ENV_KEY[ch.kind];
160
+ const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
161
+ check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
162
+ }
145
163
  }
146
164
  return finish(report, flags["json"]);
147
165
  }
@@ -10,6 +10,14 @@
10
10
  * `src/cli/index.ts`). Piped input, `--yes`, or `--template <name>` all
11
11
  * fall through to the original non-interactive behavior unchanged — a
12
12
  * scripted `spf init` must never hang waiting on stdin.
13
+ *
14
+ * Every path here also installs the repo-local Claude Code skill (the same
15
+ * work `spf install-skill` does by hand) unless `--no-skills` is passed —
16
+ * on every run, not just the first: `install-skill` is idempotent (a no-op
17
+ * once the skill is already up to date), so this never re-does work or
18
+ * clobbers a locally-edited skill file. `--user` (installing to
19
+ * `~/.claude/skills/spf` instead) stays a `spf install-skill` invocation of
20
+ * its own; `init` only ever writes the repo-local default.
13
21
  */
14
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
23
  import path from "node:path";
@@ -22,6 +30,7 @@ import { paint } from "../../core/console.js";
22
30
  import { createAsker, isInteractive, InterviewAborted } from "../ask.js";
23
31
  import { gatherContext, runInterview } from "../interview.js";
24
32
  import { readEnvFile, upsertEnvFile, writeEnvExample } from "../env_file.js";
33
+ import { installSkillCommand } from "./install-skill.js";
25
34
  const TEMPLATE_SUFFIX = ".spf.config.yaml";
26
35
  /** Every template's short name (e.g. "ts-cc"), derived from disk rather than hand-maintained — never drifts from what's actually packaged. */
27
36
  function listTemplates() {
@@ -67,8 +76,8 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
67
76
  # explicit Flue-style provider/model-id strings, and an agent's own model
68
77
  # always wins over defaults.model — so those three keep running on whatever
69
78
  # backend you just switched to, with a model id it can't resolve, unless
70
- # you override their model here too (builder/scout have no model of their
71
- # own in the packaged roster, so they need no override). See
79
+ # you override their model here too (builder/scout/refiner have no model of
80
+ # their own in the packaged roster, so they need no override). See
72
81
  # assets/templates/ts.spf.config.yaml (or --template ts) for the full
73
82
  # working pattern.
74
83
 
@@ -86,6 +95,27 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
86
95
  # label_prefix: spf
87
96
  # chain: plan-build-test
88
97
  # base_branch: main
98
+ # # Optional second lane: decompose a spf:spec-ready product spec into a
99
+ # # feature/story-or-bug tree of real issues. Off by default; needs
100
+ # # issue_provider: github — issue authoring isn't implemented for Jira yet.
101
+ # refine:
102
+ # enabled: true
103
+ # chain: refine
104
+ # concurrency: 1
105
+
106
+ # Uncomment to push notifications for unattended work — spf watch's daemon
107
+ # lifecycle, and every chain run (spf <chain> / spf run, including watch's
108
+ # own per-issue runs). Interactive commands (doctor, list, sessions, ...)
109
+ # never notify — you're already looking at the terminal for those. events:
110
+ # "errors" sends only failures/blocked issues; "all" adds every milestone
111
+ # (run started, issue claimed, PR opened, ...). The URL is a secret and
112
+ # lives only in .env — never in this file. See README.md's "Notifications"
113
+ # section for how to get each webhook URL.
114
+ # notifications:
115
+ # events: errors # off (default) | errors | all
116
+ # channels:
117
+ # - kind: slack # slack | teams | webhook
118
+ # webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
89
119
  `;
90
120
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
91
121
  // shared project config, same as package.json. Only runtime/generated
@@ -99,10 +129,18 @@ const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s i
99
129
  # (gitignored) — .env.example lists the key names only.
100
130
  `;
101
131
  export async function initCommand(argv) {
102
- const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes"]);
132
+ const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes", "no-skills"]);
103
133
  const anchor = paths.resolveAnchor(options["cwd"]);
104
134
  const sfDir = path.join(anchor.repo_root, ".spf");
105
135
  mkdirSync(sfDir, { recursive: true });
136
+ // Idempotent (a no-op once the skill is already up to date, a `.new`
137
+ // sibling rather than an overwrite for a locally-edited file) — safe to
138
+ // call on every `spf init`, not just the first.
139
+ const installSkill = () => {
140
+ if (flags["no-skills"])
141
+ return;
142
+ installSkillCommand(options["cwd"] ? ["--cwd", options["cwd"]] : []);
143
+ };
106
144
  const configPath = path.join(sfDir, "spf.config.yaml");
107
145
  const templateName = options["template"];
108
146
  const interactive = !templateName && !flags["yes"] && isInteractive();
@@ -115,6 +153,7 @@ export async function initCommand(argv) {
115
153
  writeFileSync(configPath, content);
116
154
  console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
117
155
  }
156
+ installSkill();
118
157
  }
119
158
  else {
120
159
  const asker = createAsker();
@@ -124,6 +163,7 @@ export async function initCommand(argv) {
124
163
  if (!overwrite) {
125
164
  console.log("leaving the existing config alone (--force to skip this prompt)");
126
165
  asker.close();
166
+ installSkill();
127
167
  ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
128
168
  return 0;
129
169
  }
@@ -141,6 +181,7 @@ export async function initCommand(argv) {
141
181
  if (Object.keys(result.env).length > 0)
142
182
  upsertEnvFile(anchor.repo_root, result.env);
143
183
  writeEnvExample(anchor.repo_root, result.envExampleKeys);
184
+ installSkill();
144
185
  // The same merge-then-validate pipeline `spf doctor` runs — catches a
145
186
  // bad answer (e.g. a suite naming an unconfigured check) right after
146
187
  // writing, not at the user's first real chain run. Non-fatal: the
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * `spf install-skill` — copy the packaged Claude Code skill (`assets/skill/`)
3
- * into a target repo (or `~/.claude/skills/spf` with `--user`), on explicit
4
- * request only. Nothing here ever runs unless a user asks for it.
3
+ * into a target repo (or `~/.claude/skills/spf` with `--user`). `spf init`
4
+ * calls this itself, every run, unless `--no-skills` is passed it's the
5
+ * command name a user (or a script) still reaches for by hand: to reinstall
6
+ * after an edit, to target `--user` instead of repo-local, or on a repo
7
+ * that never ran `spf init` at all (a pure-built-ins setup).
5
8
  *
6
9
  * Idempotent via a manifest (`.spf-skill-version`: {package, version,
7
10
  * files: {relpath: sha256}}) written at the skill root:
@@ -9,5 +9,6 @@ export function listCommand() {
9
9
  console.log();
10
10
  }
11
11
  console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] (spf run <name> ... works identically)`);
12
+ console.log(`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`);
12
13
  return 0;
13
14
  }
@@ -2,7 +2,7 @@
2
2
  import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
4
  import { runChain } from "../../chains/index.js";
5
- const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base"];
5
+ const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue"];
6
6
  export function usageFor(chain) {
7
7
  return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
8
8
  }
@@ -19,6 +19,10 @@ export async function dispatchChain(chain, argv) {
19
19
  adw_id: options["adw-id"] ?? null,
20
20
  cwd: anchor.cwd,
21
21
  chain_name: chain.name,
22
+ // Only `refine` reads this (a "## Parent: #<id>" back-reference on
23
+ // every issue it creates — see ChainContext's doc comment); every
24
+ // other chain ignores it, so it's harmless to always pass through.
25
+ issue_id: options["issue"] ?? null,
22
26
  };
23
27
  const chainOptions = {};
24
28
  if (options["agent"] !== undefined)
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
10
10
  import path from "node:path";
11
11
  import * as agents from "../../core/agents.js";
12
12
  import * as paths from "../../core/paths.js";
13
+ import { resolveNotifier } from "../../core/notify/notifier.js";
13
14
  import { isRepoAt, makeGit } from "../../core/git_helper.js";
14
15
  import { GitHubProvider } from "../../core/issues/github_provider.js";
15
16
  import { JiraProvider } from "../../core/issues/jira_provider.js";
@@ -149,6 +150,21 @@ export async function watchCommand(argv) {
149
150
  console.error(`watch.chain ${JSON.stringify(cfg.watch.chain)} is not a registered chain — run \`spf list\` to see every chain`);
150
151
  return 1;
151
152
  }
153
+ if (cfg.watch.refine.enabled) {
154
+ if (cfg.watch.issue_provider !== "github") {
155
+ // Fail loudly at startup, not silently every tick: JiraProvider
156
+ // doesn't implement IssueAuthoringProvider yet (see its module
157
+ // comment) — a refine lane that can never publish would otherwise
158
+ // just claim every spec-ready spec and block it, forever.
159
+ console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
160
+ `the refine lane needs "github" (issue authoring isn't implemented for Jira yet)`);
161
+ return 1;
162
+ }
163
+ if (!findChain(cfg.watch.refine.chain)) {
164
+ console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
165
+ return 1;
166
+ }
167
+ }
152
168
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
153
169
  const lockPath = path.join(dataPaths.data_dir, "watch.lock");
154
170
  try {
@@ -161,6 +177,9 @@ export async function watchCommand(argv) {
161
177
  const git = makeGit(anchor.repo_root);
162
178
  const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
163
179
  mkdirSync(worktreesDir, { recursive: true });
180
+ // `null` when notifications are off/unconfigured — every call below is a
181
+ // no-op fallback to `console.error` in that case (see notify()`s default).
182
+ const notifier = resolveNotifier(cfg, { dryRun: Boolean(flags["dry-run"]) });
164
183
  /**
165
184
  * Without this, a claimed issue's chain resolves its session/trace data
166
185
  * relative to `cwd` (the worktree, not the main repo — see
@@ -182,25 +201,65 @@ export async function watchCommand(argv) {
182
201
  mkdirSync(path.dirname(target), { recursive: true });
183
202
  symlinkSync(dataPaths.data_dir, target, "dir");
184
203
  }
204
+ /** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
205
+ function detailFromFailedPhase(cwd, adwId, prefix) {
206
+ let detail = prefix;
207
+ try {
208
+ const wtAnchor = paths.resolveAnchor(cwd);
209
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
210
+ const db = new SfDb(wtDataPaths.db_path);
211
+ const failed = db.phases(adwId).find((p) => p.status === "fail");
212
+ if (failed)
213
+ detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
214
+ }
215
+ catch {
216
+ // best-effort — the generic message above still points at where to look
217
+ }
218
+ return detail;
219
+ }
185
220
  const runChain = async (opts) => {
186
221
  const chainDef = findChain(cfg.watch.chain); // checked above
187
222
  const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
188
223
  const code = await runChainDef(chainDef, ctx);
189
224
  if (code === 0)
190
225
  return { accepted: true, adwId: opts.adwId, detail: "" };
191
- let detail = `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`;
226
+ const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
227
+ return { accepted: false, adwId: opts.adwId, detail };
228
+ };
229
+ /**
230
+ * Same shape as `runChain`, for the refine lane — with one extra step on
231
+ * success: a chain's return value is just an exit code, so the created-
232
+ * issues list `steps.publishIssues()` actually produced has to come back
233
+ * through the side channel it wrote (`refine_publish.json`, under the
234
+ * SAME symlinked session dir `linkDataDir` already wires up), not through
235
+ * `runChainDef`'s return value.
236
+ */
237
+ const runRefine = async (opts) => {
238
+ const chainDef = findChain(cfg.watch.refine.chain); // checked above
239
+ const ctx = {
240
+ prompt: opts.prompt,
241
+ config_paths: configPaths,
242
+ adw_id: opts.adwId,
243
+ cwd: opts.cwd,
244
+ chain_name: chainDef.name,
245
+ issue_id: opts.issueId,
246
+ };
247
+ const code = await runChainDef(chainDef, ctx);
248
+ if (code !== 0) {
249
+ const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
250
+ return { accepted: false, adwId: opts.adwId, detail, created: [] };
251
+ }
252
+ let created = [];
192
253
  try {
193
254
  const wtAnchor = paths.resolveAnchor(opts.cwd);
194
255
  const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
195
- const db = new SfDb(wtDataPaths.db_path);
196
- const failed = db.phases(opts.adwId).find((p) => p.status === "fail");
197
- if (failed)
198
- detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
256
+ const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
257
+ created = JSON.parse(readFileSync(summaryPath, "utf-8"));
199
258
  }
200
259
  catch {
201
- // best-effort — the generic message above still points at where to look
260
+ // best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
202
261
  }
203
- return { accepted: false, adwId: opts.adwId, detail };
262
+ return { accepted: true, adwId: opts.adwId, detail: "", created };
204
263
  };
205
264
  const deps = {
206
265
  provider,
@@ -211,11 +270,16 @@ export async function watchCommand(argv) {
211
270
  chain: cfg.watch.chain,
212
271
  baseBranch: cfg.watch.base_branch,
213
272
  concurrency: cfg.watch.concurrency,
273
+ refineEnabled: cfg.watch.refine.enabled,
274
+ refineConcurrency: cfg.watch.refine.concurrency,
275
+ refineChain: cfg.watch.refine.chain,
276
+ runRefine,
214
277
  worktreesDir,
215
278
  linkDataDir,
216
279
  dryRun: Boolean(flags["dry-run"]),
217
280
  runChain,
218
281
  log: (message) => console.log(message),
282
+ notify: (event) => notifier?.send(event),
219
283
  };
220
284
  const state = createWatchState();
221
285
  let stopping = false;
@@ -249,7 +313,19 @@ export async function watchCommand(argv) {
249
313
  };
250
314
  process.on("SIGINT", stop);
251
315
  process.on("SIGTERM", stop);
252
- console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}${flags["dry-run"] ? " (dry run)" : ""}`);
316
+ console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
317
+ (cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
318
+ (flags["dry-run"] ? " (dry run)" : ""));
319
+ deps.notify({
320
+ kind: "watch_started",
321
+ level: "info",
322
+ title: "watch started",
323
+ fields: [
324
+ ["repo", cfg.watch.repo],
325
+ ["label_prefix", cfg.watch.label_prefix],
326
+ ["chain", cfg.watch.chain],
327
+ ],
328
+ });
253
329
  try {
254
330
  for (;;) {
255
331
  await tick(deps, state);
@@ -268,6 +344,8 @@ export async function watchCommand(argv) {
268
344
  return 0;
269
345
  }
270
346
  finally {
347
+ deps.notify({ kind: "watch_stopped", level: "info", title: "watch stopped", fields: [["repo", cfg.watch.repo]] });
348
+ await notifier?.flush();
271
349
  process.off("SIGINT", stop);
272
350
  process.off("SIGTERM", stop);
273
351
  releaseLock(lockPath);
package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import * as agentCc from "../core/agent_cc.js";
9
9
  import * as agentFlue from "../core/agent_flue.js";
10
+ import * as notify from "../core/notify/notifier.js";
10
11
  import * as paths from "../core/paths.js";
11
12
  import { findChain } from "../chains/index.js";
12
13
  import { dispatchChain, usageFor } from "./commands/run.js";
@@ -27,8 +28,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
27
28
 
28
29
  spf list the chain registry — names, phases, what each needs
29
30
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
30
- spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
31
- spf install-skill [--user] [--force] install the Claude Code skill (repo-local by default)
31
+ spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
32
+ spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
32
33
  spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
33
34
  spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
34
35
  spf doctor [--json] check everything that fails silently otherwise
@@ -41,7 +42,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
41
42
  spf abort <adw_id> signal a run's process to stop
42
43
  spf version print the installed version
43
44
 
44
- Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>]
45
+ Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
45
46
  Run \`spf list\` to see every chain and what it needs.`;
46
47
  /** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
47
48
  function findCwdFlag(argv) {
@@ -152,5 +153,8 @@ export async function main() {
152
153
  // agent_cc.ts's shutdown() just kills any still-running claude children.
153
154
  await agentFlue.shutdown();
154
155
  await agentCc.shutdown();
156
+ // A no-op if notifications are off/unconfigured — awaits any in-flight
157
+ // webhook POST so a fast-exiting command doesn't drop it mid-flight.
158
+ await notify.flushAll();
155
159
  }
156
160
  }
@@ -8,6 +8,8 @@ export interface DetectedContext {
8
8
  claudeOnPath: boolean;
9
9
  /** Whatever's already in `.env` — shown masked so a re-run can offer "keep current" instead of asking blind. */
10
10
  existingEnv: Map<string, string>;
11
+ /** The packaged roster's agent names (planner, builder, scout, reviewer, documenter today) — read from the built-in config so a 6th agent added there needs no interview change. */
12
+ rosterNames: string[];
11
13
  }
12
14
  export declare function gatherContext(repoRoot: string, existingEnv?: Map<string, string>): DetectedContext;
13
15
  export interface InterviewResult {
@@ -18,6 +18,9 @@ import { binaryOnPath } from "../core/utils.js";
18
18
  import { PROVIDER_ENV_KEYS } from "../core/providers.js";
19
19
  import { resolveModel } from "../core/agent_flue.js";
20
20
  import { ThinkingLevelSchema } from "../core/data_types.js";
21
+ import { loadConfig } from "../core/agents.js";
22
+ import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
23
+ import { DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
21
24
  import { CHAINS } from "../chains/index.js";
22
25
  function gitConfigValue(repoRoot, key) {
23
26
  const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
@@ -40,6 +43,15 @@ function readScripts(repoRoot) {
40
43
  return {};
41
44
  }
42
45
  }
46
+ /** Best-effort — a corrupt/missing built-in config falls back to the three names the auto-override already knows about, rather than failing the whole interview. */
47
+ function readRosterNames() {
48
+ try {
49
+ return loadConfig([BUILTIN_CONFIG_PATH]).agents.map((a) => a.name);
50
+ }
51
+ catch {
52
+ return ["planner", "builder", "scout", "reviewer", "documenter"];
53
+ }
54
+ }
43
55
  export function gatherContext(repoRoot, existingEnv = new Map()) {
44
56
  const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: repoRoot, encoding: "utf-8" });
45
57
  const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot, encoding: "utf-8" });
@@ -51,6 +63,7 @@ export function gatherContext(repoRoot, existingEnv = new Map()) {
51
63
  scripts: readScripts(repoRoot),
52
64
  claudeOnPath: binaryOnPath("claude"),
53
65
  existingEnv,
66
+ rosterNames: readRosterNames(),
54
67
  };
55
68
  }
56
69
  const QUALITY_TIMEOUTS = { typecheck: 60, lint: 60, build: 120, test: 180 };
@@ -79,7 +92,7 @@ export async function runInterview(asker, ctx) {
79
92
  if (!ctx.claudeOnPath) {
80
93
  asker.note("warning: `claude` was not found on PATH — install it (or set a launch command below) before running spf.");
81
94
  }
82
- const launchCommand = await asker.text("Launch command for the `claude` CLI (space-separated; SPF_CLAUDE_CMD)", { default: "claude" });
95
+ const launchCommand = await asker.text('Launch command for the `claude` CLI — e.g. "ollama launch claude" to route through a wrapper (SPF_CLAUDE_CMD)', { default: "claude" });
83
96
  if (launchCommand !== "claude")
84
97
  env["SPF_CLAUDE_CMD"] = launchCommand;
85
98
  const model = await asker.select("Model (Claude Code's own vocabulary — not provider/model-id)", [
@@ -142,6 +155,34 @@ export async function runInterview(asker, ctx) {
142
155
  env[envKey] = key;
143
156
  envExampleKeys.push(envKey);
144
157
  }
158
+ // Declined (the default): today's behavior exactly — on claude_code, the
159
+ // three-agent auto-pin above stands unchanged; on flue, no agents: block
160
+ // at all. Accepted: ask every roster agent's model, defaulting to the one
161
+ // chosen above, and patch/append its override — `mergeAgentLists`
162
+ // (agents.ts:63-71) shallow-patches by name, so re-setting `model` on an
163
+ // already-pinned entry (claude_code's planner/reviewer/documenter) is
164
+ // exactly the right shape, no `prompt_engineering` needed (interview.ts's
165
+ // review-section comment explains why that's fine).
166
+ const customizeModels = await asker.confirm("Customize models per agent?", false);
167
+ if (customizeModels) {
168
+ asker.heading("Per-agent models");
169
+ for (const name of ctx.rosterNames) {
170
+ const answer = await asker.text(` ${name}`, { default: String(defaults.model) });
171
+ if (codingAgent === "flue") {
172
+ try {
173
+ resolveModel(answer);
174
+ }
175
+ catch (error) {
176
+ asker.note(`warning: ${error.message}`);
177
+ }
178
+ }
179
+ const existing = agentOverrides.find((a) => a.name === name);
180
+ if (existing)
181
+ existing.model = answer;
182
+ else
183
+ agentOverrides.push({ name, model: answer });
184
+ }
185
+ }
145
186
  // ── 2. quality checks ──────────────────────────────────────────────────────
146
187
  asker.heading("Quality checks");
147
188
  asker.note("a chain that gates on a suite (e.g. plan-build-test's `test`) fails loudly before anything runs if the suite is unconfigured.");
@@ -190,6 +231,18 @@ export async function runInterview(asker, ctx) {
190
231
  const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
191
232
  const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
192
233
  watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
234
+ // Issue authoring (create + link a hierarchy) is only implemented on
235
+ // GitHubProvider today — see jira_provider.ts's module comment — so
236
+ // this lane isn't offered at all on a Jira tracker rather than asking a
237
+ // question that would just fail at `spf watch` startup.
238
+ if (issueProvider === "github") {
239
+ const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
240
+ if (enableRefine) {
241
+ const refineChainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
242
+ const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
243
+ watch.refine = { enabled: true, chain: refineChain };
244
+ }
245
+ }
193
246
  if (issueProvider === "jira") {
194
247
  const baseUrl = await asker.text("Jira base URL", {
195
248
  default: "https://your-domain.atlassian.net",
@@ -227,7 +280,46 @@ export async function runInterview(asker, ctx) {
227
280
  asker.note("Bitbucket app passwords are being removed — this needs an Atlassian API token instead.");
228
281
  }
229
282
  }
230
- // ── 4. advanced (gated) ─────────────────────────────────────────────────────
283
+ // ── 4. notifications ─────────────────────────────────────────────────────────
284
+ asker.heading("Notifications");
285
+ const enableNotify = await asker.confirm("Send notifications to Slack, Teams, or a webhook?", false);
286
+ let notifications = null;
287
+ if (enableNotify) {
288
+ const events = await asker.select("Notify on", [
289
+ { value: "errors", label: "errors — failed runs, blocked issues, watch errors" },
290
+ { value: "all", label: "all — every milestone (claimed, PR opened, done, ...) plus errors" },
291
+ ], "errors");
292
+ const channels = [];
293
+ for (;;) {
294
+ const kind = await asker.select("Channel", [
295
+ { value: "slack", label: "Slack — Incoming Webhook" },
296
+ { value: "teams", label: "Microsoft Teams — Workflows webhook" },
297
+ { value: "webhook", label: "generic webhook — POSTs the raw event as JSON" },
298
+ ], "slack");
299
+ const envKey = DEFAULT_NOTIFY_ENV_KEY[kind];
300
+ if (kind === "slack") {
301
+ asker.note("Slack app -> Incoming Webhooks -> Add New Webhook to Workspace.");
302
+ asker.note("Docs: https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks");
303
+ }
304
+ else if (kind === "teams") {
305
+ asker.note("In the target channel, add a Workflows webhook template (search for one like \"Post to a channel when a webhook request is received\"). The old Office 365 connector webhooks are retired — this is the only path now.");
306
+ asker.note("Docs: https://support.microsoft.com/en-us/office/post-a-workflow-when-a-webhook-request-is-received-in-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498");
307
+ }
308
+ else {
309
+ asker.note("Any endpoint that accepts a JSON POST — Discord, n8n, Zapier, your own.");
310
+ }
311
+ const url = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
312
+ if (url)
313
+ env[envKey] = url;
314
+ envExampleKeys.push(envKey);
315
+ channels.push({ kind, webhook_url_env: envKey });
316
+ const another = await asker.confirm("Add another channel?", false);
317
+ if (!another)
318
+ break;
319
+ }
320
+ notifications = { events, channels };
321
+ }
322
+ // ── 5. advanced (gated) ─────────────────────────────────────────────────────
231
323
  const wantAdvanced = await asker.confirm("\nConfigure advanced settings (thinking level, tools, protected files, data dir, poll intervals, ...)?", false);
232
324
  if (wantAdvanced) {
233
325
  asker.heading("Advanced");
@@ -254,6 +346,11 @@ export async function runInterview(asker, ctx) {
254
346
  const concurrency = await asker.text("watch.concurrency", { default: "2" });
255
347
  if (concurrency !== "2")
256
348
  watch.concurrency = Number(concurrency);
349
+ if (watch.refine?.enabled) {
350
+ const refineConcurrency = await asker.text("watch.refine.concurrency", { default: "1" });
351
+ if (refineConcurrency !== "1")
352
+ watch.refine.concurrency = Number(refineConcurrency);
353
+ }
257
354
  }
258
355
  const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
259
356
  if (engineerName && engineerName !== ctx.gitName)
@@ -263,8 +360,13 @@ export async function runInterview(asker, ctx) {
263
360
  if (debug)
264
361
  env["SPF_JIRA_DEBUG"] = "1";
265
362
  }
363
+ if (notifications) {
364
+ const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
365
+ if (timeoutMs !== "5000")
366
+ notifications.timeout_ms = Number(timeoutMs);
367
+ }
266
368
  }
267
- // ── 5. review + confirm ─────────────────────────────────────────────────────
369
+ // ── 6. review + confirm ─────────────────────────────────────────────────────
268
370
  const observability = defaults["__observability__"];
269
371
  delete defaults["__observability__"];
270
372
  const quality = defaults["__quality__"];
@@ -278,6 +380,8 @@ export async function runInterview(asker, ctx) {
278
380
  config.watch = watch;
279
381
  if (observability)
280
382
  config.observability = observability;
383
+ if (notifications)
384
+ config.notifications = notifications;
281
385
  // No schema validation here on purpose: `config.agents` overrides are
282
386
  // intentionally PARTIAL (name + model only, no prompt_engineering) — valid
283
387
  // only once merged by name into the packaged roster (agents.ts's
@@ -57,13 +57,16 @@ function mergeAgentLists(base, override) {
57
57
  }
58
58
  return merged;
59
59
  }
60
- /** `defaults`/`observability`/`quality` merge key-by-key; `agents` merges by name. */
60
+ /** `defaults`/`observability`/`quality`/`watch`/`notifications` merge key-by-key; `agents` merges by name. */
61
61
  function mergeRawConfig(base, override) {
62
62
  return {
63
63
  defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
64
64
  observability: { ...(base.observability || {}), ...(override.observability || {}) },
65
65
  quality: { ...(base.quality || {}), ...(override.quality || {}) },
66
66
  watch: { ...(base.watch || {}), ...(override.watch || {}) },
67
+ // channels is a whole-array replace on override, same as quality.checks —
68
+ // you don't want an override's channels appended to the built-in's.
69
+ notifications: { ...(base.notifications || {}), ...(override.notifications || {}) },
67
70
  agents: mergeAgentLists(base.agents || [], override.agents || []),
68
71
  };
69
72
  }
@@ -7,6 +7,10 @@
7
7
  * so a CI log reads exactly like a terminal.
8
8
  */
9
9
  import type { EnvelopeBase, EventRecord, GateReport, Phase } from "./data_types.ts";
10
+ import type { NotifyEvent } from "./notify/channel.ts";
11
+ interface Notifier {
12
+ send(event: NotifyEvent): void;
13
+ }
10
14
  export declare function paint(style: string, text: string): string;
11
15
  interface Tracer {
12
16
  event(record: EventRecord): string;
@@ -15,11 +19,19 @@ interface Tracer {
15
19
  export declare class Console {
16
20
  private tracer;
17
21
  private adwId;
22
+ /** `null` when notifications are off — every call site below guards with `?.`. */
23
+ private notifier;
24
+ /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
25
+ private chainName;
18
26
  private phaseId;
19
27
  private phaseName;
20
28
  private results;
21
29
  private finished;
22
- constructor(tracer: Tracer, adwId: string);
30
+ constructor(tracer: Tracer, adwId: string,
31
+ /** `null` when notifications are off — every call site below guards with `?.`. */
32
+ notifier?: Notifier | null,
33
+ /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
34
+ chainName?: string);
23
35
  private emit;
24
36
  sessionStarted(adwId: string, engineer: string): void;
25
37
  sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): void;