@gr8ful/spf 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ spf init --template ts-cc # or start from a packaged, ready-to-run template
34
34
  spf list # every chain this install knows, its phases, what it needs
35
35
  ```
36
36
 
37
- On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code` or `flue`) and model, which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host — and writes `.spf/spf.config.yaml` with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to `.env` (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable `.env.example`. Re-running it later shows any existing `.env` value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, `--yes`, or `--template <name>` all skip the interview and fall back to the original non-interactive behavior — a scripted `spf init` never blocks on stdin.
37
+ On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code` or `flue`) and model (optionally customized per agent instead of one model for the whole roster), which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host, and whether to push notifications to Slack/Teams/a webhook — and writes `.spf/spf.config.yaml` with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to `.env` (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable `.env.example`. Re-running it later shows any existing `.env` value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, `--yes`, or `--template <name>` all skip the interview and fall back to the original non-interactive behavior — a scripted `spf init` never blocks on stdin.
38
38
 
39
39
  Without an interview, `spf init` writes the same small starter `.spf/spf.config.yaml`, commented, that merges on top of the packaged built-ins field by field. `--template <name>` writes a real, filled-in config instead of the commented-out starter — every packaged template's name prints after `spf init` runs, and the same files live in [`assets/templates/`](assets/templates/) to browse directly. Nothing here needs to exist for `spf` to run; it's how you make one repo's roster diverge from the defaults.
40
40
 
@@ -322,6 +322,56 @@ export BITBUCKET_API_TOKEN=... # same Atlassian API token mechanism as Jira ab
322
322
 
323
323
  **Bitbucket Cloud app passwords are being fully removed** (brownout window closing July 28, 2026) — this project only supports the replacement, API tokens, which need the account's email alongside the token (username alone no longer works).
324
324
 
325
+ ## Notifications
326
+
327
+ Optional, off by default: push a curated set of milestones to Slack,
328
+ Microsoft Teams, or a generic webhook. It's scoped to **unattended work**:
329
+ `spf watch`'s daemon lifecycle, and every chain run (`spf <chain>` / `spf
330
+ run`, including watch's own per-issue runs). Interactive commands — `doctor`,
331
+ `list`, `sessions`, `phases`, `events`, `init`, `ui`, `migrate`, `eject`,
332
+ `abort`, `version` — never notify; you're already looking at the terminal
333
+ for those.
334
+
335
+ ```yaml
336
+ # .spf/spf.config.yaml
337
+ notifications:
338
+ events: errors # off (default) | errors | all
339
+ channels:
340
+ - kind: slack # slack | teams | webhook
341
+ webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
342
+ ```
343
+
344
+ `events` is the whole filter: `errors` sends only failed runs/phases, blocked
345
+ issues, and watch errors; `all` adds every milestone — run started/finished,
346
+ issue claimed, PR opened, issue done. A channel's own `events` overrides the
347
+ top-level scope for just that channel. `spf doctor` reports whether each
348
+ configured channel's env var is set.
349
+
350
+ The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
351
+ names the key, never the URL itself, matching `GITHUB_TOKEN`/
352
+ `JIRA_API_TOKEN`. Defaults per kind: `SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`,
353
+ `SPF_WEBHOOK_URL`. `spf init`'s interview asks for this section and collects
354
+ the URL straight into `.env`, same as every other credential.
355
+
356
+ Getting each channel's URL:
357
+
358
+ - **Slack** — [Sending messages using Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks): create a Slack app, enable Incoming Webhooks, "Add New Webhook to Workspace".
359
+ - **Microsoft Teams** — [Send messages in Teams using incoming webhooks](https://support.microsoft.com/en-us/office/post-a-workflow-when-a-webhook-request-is-received-in-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498): in the target channel, add a Workflows webhook template (search for one along the lines of "Post to a channel when a webhook request is received" / "Send webhook alerts to a channel" — Microsoft's own naming here has shifted between revisions) and copy the generated URL. This is the *only* supported path now — the old Office 365 connector webhook has been retired by Microsoft.
360
+ - **webhook** — any endpoint that accepts a JSON POST of the event: Discord,
361
+ n8n, Zapier, a homegrown receiver.
362
+
363
+ Delivery never blocks or fails a run: an unconfigured/misconfigured channel
364
+ is skipped with one warning, and a failed POST logs one line and is
365
+ swallowed — never changes a run's exit code. One thing worth knowing under
366
+ `events: all`: `spf watch` runs its per-issue chains in-process, so a failed
367
+ issue produces **two** notifications for the same failure — `run_failed`
368
+ (keyed to the chain's own `adw_id`, e.g. `issue-142`) from the chain-run
369
+ side, and `issue_blocked` (keyed to the issue) from watch itself. Both are
370
+ genuinely informative, just worth expecting.
371
+
372
+ Full field reference: `spf install-skill`'s installed skill
373
+ (`references/config.md`).
374
+
325
375
  ## What's in this repo
326
376
 
327
377
  ```
@@ -12,9 +12,10 @@ always shows the resolved, merged result for the repo you're in.
12
12
  1. The packaged built-in default (`assets/defaults/spf.config.yaml` inside
13
13
  the installed CLI).
14
14
  2. `.spf/spf.config.yaml` in the target repo, if present — merged on top,
15
- field by field (`defaults`/`observability`/`quality` merge key-by-key;
16
- `agents` merges by `name`: a matching name patches that entry, a new name
17
- appends).
15
+ field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`
16
+ merge key-by-key `notifications.channels` replaces wholesale, same as
17
+ `quality.checks`; `agents` merges by `name`: a matching name patches that
18
+ entry, a new name appends).
18
19
  3. An explicit `--config <path>` replaces both — standalone, no built-in
19
20
  underneath it.
20
21
 
@@ -107,6 +108,49 @@ sections: `assets/templates/ts.spf.config.yaml` in the spf package (or
107
108
  `spf init --template ts` to write it straight into `.spf/spf.config.yaml`).
108
109
  `spf init` with no `--template` prints every packaged template's name.
109
110
 
111
+ ### `notifications`
112
+
113
+ Optional outbound push for unattended work — `spf watch`'s daemon lifecycle,
114
+ and every chain run (`spf <chain>` / `spf run`, including watch's own
115
+ per-issue runs). Interactive commands (`doctor`, `list`, `sessions`,
116
+ `phases`, `events`, `init`, `ui`, `migrate`, `eject`, `abort`, `version`)
117
+ never notify — you're already looking at the terminal for those. Off by
118
+ default; adding it is entirely additive.
119
+
120
+ | Field | Type | Meaning |
121
+ |---|---|---|
122
+ | `events` | `"off"` \| `"errors"` \| `"all"` | The whole filter. `off` (default): nothing. `errors`: only failed runs/phases, blocked issues, watch errors. `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus errors. |
123
+ | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
124
+ | `channels[]` | array | See below. |
125
+
126
+ `channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
127
+ overrides `events` for just that channel (unset = inherit). `webhook_url_env`
128
+ names the `.env` key holding the secret URL — never the URL itself, matching
129
+ `GITHUB_TOKEN`/`JIRA_API_TOKEN`. Empty/omitted uses the kind's own default:
130
+ `SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`, `SPF_WEBHOOK_URL`. `name` is a
131
+ cosmetic label for warning lines when you have two channels of the same
132
+ kind.
133
+
134
+ ```yaml
135
+ notifications:
136
+ events: errors
137
+ channels:
138
+ - kind: slack
139
+ webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
140
+ - kind: teams
141
+ events: all # per-channel override
142
+ - kind: webhook
143
+ webhook_url_env: OPS_WEBHOOK_URL
144
+ name: ops-bus
145
+ ```
146
+
147
+ Delivery never blocks or fails a run: a channel with an unset env var is
148
+ skipped with one warning at startup (`spf doctor` reports the same thing as
149
+ a check); a failed POST logs one line and is swallowed, never changing the
150
+ run's exit code. See the main README's "Notifications" section for how to
151
+ get each channel's webhook URL, and `spf init`'s interview, which asks for
152
+ this section and collects the URL straight into `.env`.
153
+
110
154
  ### `agents[]`
111
155
 
112
156
  | Field | Required | Meaning |
@@ -64,3 +64,15 @@ agents:
64
64
  # label_prefix: spf
65
65
  # chain: plan-build-test
66
66
  # base_branch: main
67
+
68
+ # Optional: push notifications for unattended work — spf watch's daemon
69
+ # lifecycle, and every chain run (including watch's own per-issue runs).
70
+ # Interactive commands (doctor, list, sessions, ...) never notify. events:
71
+ # "errors" sends only failures/blocked issues; "all" adds every milestone.
72
+ # The webhook URL is a secret — put it in .env under the key named below,
73
+ # never here. See the main README's "Notifications" section.
74
+ # notifications:
75
+ # events: errors # off (default) | errors | all
76
+ # channels:
77
+ # - kind: slack # slack | teams | webhook
78
+ # webhook_url_env: SLACK_WEBHOOK_URL
@@ -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";
@@ -143,6 +144,17 @@ export function doctorCommand(argv) {
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`);
145
146
  }
147
+ if (cfg.notifications.events !== "off") {
148
+ check(report, "notifications.events", true, cfg.notifications.events);
149
+ if (cfg.notifications.channels.length === 0) {
150
+ check(report, "notifications.channels", false, `notifications.events is ${JSON.stringify(cfg.notifications.events)} but no channels are configured`);
151
+ }
152
+ for (const ch of cfg.notifications.channels) {
153
+ const envKey = ch.webhook_url_env || DEFAULT_NOTIFY_ENV_KEY[ch.kind];
154
+ const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
155
+ check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
156
+ }
157
+ }
146
158
  return finish(report, flags["json"]);
147
159
  }
148
160
  function finish(report, json) {
@@ -86,6 +86,20 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
86
86
  # label_prefix: spf
87
87
  # chain: plan-build-test
88
88
  # base_branch: main
89
+
90
+ # Uncomment to push notifications for unattended work — spf watch's daemon
91
+ # lifecycle, and every chain run (spf <chain> / spf run, including watch's
92
+ # own per-issue runs). Interactive commands (doctor, list, sessions, ...)
93
+ # never notify — you're already looking at the terminal for those. events:
94
+ # "errors" sends only failures/blocked issues; "all" adds every milestone
95
+ # (run started, issue claimed, PR opened, ...). The URL is a secret and
96
+ # lives only in .env — never in this file. See README.md's "Notifications"
97
+ # section for how to get each webhook URL.
98
+ # notifications:
99
+ # events: errors # off (default) | errors | all
100
+ # channels:
101
+ # - kind: slack # slack | teams | webhook
102
+ # webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
89
103
  `;
90
104
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
91
105
  // shared project config, same as package.json. Only runtime/generated
@@ -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";
@@ -161,6 +162,9 @@ export async function watchCommand(argv) {
161
162
  const git = makeGit(anchor.repo_root);
162
163
  const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
163
164
  mkdirSync(worktreesDir, { recursive: true });
165
+ // `null` when notifications are off/unconfigured — every call below is a
166
+ // no-op fallback to `console.error` in that case (see notify()`s default).
167
+ const notifier = resolveNotifier(cfg, { dryRun: Boolean(flags["dry-run"]) });
164
168
  /**
165
169
  * Without this, a claimed issue's chain resolves its session/trace data
166
170
  * relative to `cwd` (the worktree, not the main repo — see
@@ -216,6 +220,7 @@ export async function watchCommand(argv) {
216
220
  dryRun: Boolean(flags["dry-run"]),
217
221
  runChain,
218
222
  log: (message) => console.log(message),
223
+ notify: (event) => notifier?.send(event),
219
224
  };
220
225
  const state = createWatchState();
221
226
  let stopping = false;
@@ -250,6 +255,16 @@ export async function watchCommand(argv) {
250
255
  process.on("SIGINT", stop);
251
256
  process.on("SIGTERM", stop);
252
257
  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)" : ""}`);
258
+ deps.notify({
259
+ kind: "watch_started",
260
+ level: "info",
261
+ title: "watch started",
262
+ fields: [
263
+ ["repo", cfg.watch.repo],
264
+ ["label_prefix", cfg.watch.label_prefix],
265
+ ["chain", cfg.watch.chain],
266
+ ],
267
+ });
253
268
  try {
254
269
  for (;;) {
255
270
  await tick(deps, state);
@@ -268,6 +283,8 @@ export async function watchCommand(argv) {
268
283
  return 0;
269
284
  }
270
285
  finally {
286
+ deps.notify({ kind: "watch_stopped", level: "info", title: "watch stopped", fields: [["repo", cfg.watch.repo]] });
287
+ await notifier?.flush();
271
288
  process.off("SIGINT", stop);
272
289
  process.off("SIGTERM", stop);
273
290
  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";
@@ -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.");
@@ -227,7 +268,46 @@ export async function runInterview(asker, ctx) {
227
268
  asker.note("Bitbucket app passwords are being removed — this needs an Atlassian API token instead.");
228
269
  }
229
270
  }
230
- // ── 4. advanced (gated) ─────────────────────────────────────────────────────
271
+ // ── 4. notifications ─────────────────────────────────────────────────────────
272
+ asker.heading("Notifications");
273
+ const enableNotify = await asker.confirm("Send notifications to Slack, Teams, or a webhook?", false);
274
+ let notifications = null;
275
+ if (enableNotify) {
276
+ const events = await asker.select("Notify on", [
277
+ { value: "errors", label: "errors — failed runs, blocked issues, watch errors" },
278
+ { value: "all", label: "all — every milestone (claimed, PR opened, done, ...) plus errors" },
279
+ ], "errors");
280
+ const channels = [];
281
+ for (;;) {
282
+ const kind = await asker.select("Channel", [
283
+ { value: "slack", label: "Slack — Incoming Webhook" },
284
+ { value: "teams", label: "Microsoft Teams — Workflows webhook" },
285
+ { value: "webhook", label: "generic webhook — POSTs the raw event as JSON" },
286
+ ], "slack");
287
+ const envKey = DEFAULT_NOTIFY_ENV_KEY[kind];
288
+ if (kind === "slack") {
289
+ asker.note("Slack app -> Incoming Webhooks -> Add New Webhook to Workspace.");
290
+ asker.note("Docs: https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks");
291
+ }
292
+ else if (kind === "teams") {
293
+ 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.");
294
+ 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");
295
+ }
296
+ else {
297
+ asker.note("Any endpoint that accepts a JSON POST — Discord, n8n, Zapier, your own.");
298
+ }
299
+ const url = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
300
+ if (url)
301
+ env[envKey] = url;
302
+ envExampleKeys.push(envKey);
303
+ channels.push({ kind, webhook_url_env: envKey });
304
+ const another = await asker.confirm("Add another channel?", false);
305
+ if (!another)
306
+ break;
307
+ }
308
+ notifications = { events, channels };
309
+ }
310
+ // ── 5. advanced (gated) ─────────────────────────────────────────────────────
231
311
  const wantAdvanced = await asker.confirm("\nConfigure advanced settings (thinking level, tools, protected files, data dir, poll intervals, ...)?", false);
232
312
  if (wantAdvanced) {
233
313
  asker.heading("Advanced");
@@ -263,8 +343,13 @@ export async function runInterview(asker, ctx) {
263
343
  if (debug)
264
344
  env["SPF_JIRA_DEBUG"] = "1";
265
345
  }
346
+ if (notifications) {
347
+ const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
348
+ if (timeoutMs !== "5000")
349
+ notifications.timeout_ms = Number(timeoutMs);
350
+ }
266
351
  }
267
- // ── 5. review + confirm ─────────────────────────────────────────────────────
352
+ // ── 6. review + confirm ─────────────────────────────────────────────────────
268
353
  const observability = defaults["__observability__"];
269
354
  delete defaults["__observability__"];
270
355
  const quality = defaults["__quality__"];
@@ -278,6 +363,8 @@ export async function runInterview(asker, ctx) {
278
363
  config.watch = watch;
279
364
  if (observability)
280
365
  config.observability = observability;
366
+ if (notifications)
367
+ config.notifications = notifications;
281
368
  // No schema validation here on purpose: `config.agents` overrides are
282
369
  // intentionally PARTIAL (name + model only, no prompt_engineering) — valid
283
370
  // 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;
@@ -45,13 +45,21 @@ function panel(lines, title, borderColor) {
45
45
  export class Console {
46
46
  tracer;
47
47
  adwId;
48
+ notifier;
49
+ chainName;
48
50
  phaseId = ""; // current lane — log events attach to it
49
51
  phaseName = "";
50
52
  results = []; // phase statuses, for the summary
51
53
  finished = false; // the summary panel prints once
52
- constructor(tracer, adwId) {
54
+ constructor(tracer, adwId,
55
+ /** `null` when notifications are off — every call site below guards with `?.`. */
56
+ notifier = null,
57
+ /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
58
+ chainName = "adw") {
53
59
  this.tracer = tracer;
54
60
  this.adwId = adwId;
61
+ this.notifier = notifier;
62
+ this.chainName = chainName;
55
63
  }
56
64
  // ── the one helper: print AND trace, always together ────────────────────
57
65
  emit(line, level = "info") {
@@ -67,6 +75,15 @@ export class Console {
67
75
  // ── session ─────────────────────────────────────────────────────────────
68
76
  sessionStarted(adwId, engineer) {
69
77
  this.emit(`${paint("bold cyan", "adw_id:")} ${paint("bold", adwId)} ${paint("dim", "engineer")} ${engineer}`);
78
+ this.notifier?.send({
79
+ kind: "run_started",
80
+ level: "info",
81
+ title: `run started — ${this.chainName}`,
82
+ fields: [
83
+ ["adw_id", adwId],
84
+ ["engineer", engineer],
85
+ ],
86
+ });
70
87
  }
71
88
  sessionFinished(ok, tokens, cost, dbPath) {
72
89
  if (this.finished)
@@ -93,6 +110,17 @@ export class Console {
93
110
  name: this.phaseName || "console",
94
111
  payload: { message: plain, level: ok ? "info" : "error" },
95
112
  }));
113
+ this.notifier?.send({
114
+ kind: ok ? "run_finished" : "run_failed",
115
+ level: ok ? "info" : "error",
116
+ title: `run ${ok ? "finished" : "failed"} — ${this.chainName}`,
117
+ fields: [
118
+ ["adw_id", this.adwId],
119
+ ["phases", `${passed}/${this.results.length}`],
120
+ ["tokens", tokens.toLocaleString()],
121
+ ["cost", `$${cost.toFixed(4)}`],
122
+ ],
123
+ });
96
124
  }
97
125
  // ── phases ──────────────────────────────────────────────────────────────
98
126
  phaseStarted(phase) {
@@ -113,6 +141,19 @@ export class Console {
113
141
  if (!ok && phase.error)
114
142
  line += ` ${paint("red", clip(phase.error))}`;
115
143
  this.emit(line, ok ? "info" : "error");
144
+ if (!ok) {
145
+ this.notifier?.send({
146
+ kind: "phase_failed",
147
+ level: "error",
148
+ title: `phase failed — ${phase.params.name}`,
149
+ detail: phase.error ?? undefined,
150
+ fields: [
151
+ ["adw_id", this.adwId],
152
+ ["chain", this.chainName],
153
+ ["owner", phase.params.owner],
154
+ ],
155
+ });
156
+ }
116
157
  this.phaseId = "";
117
158
  this.phaseName = "";
118
159
  }
@@ -129,6 +170,15 @@ export class Console {
129
170
  }
130
171
  retry(name, attempt, limit, reason) {
131
172
  this.emit(` ${paint("yellow", "⟳")} ${name} retry ${attempt}/${limit} ${paint("dim", `— same session · ${clip(reason)}`)}`, "warn");
173
+ // info-level: routine self-healing, same as watch's own untracked
174
+ // orphan retries — visible under `events: all`, silent under `errors`.
175
+ this.notifier?.send({
176
+ kind: "phase_retry",
177
+ level: "info",
178
+ title: `retry ${attempt}/${limit} — ${name}`,
179
+ detail: reason,
180
+ fields: [["adw_id", this.adwId]],
181
+ });
132
182
  }
133
183
  // ── verification ────────────────────────────────────────────────────────
134
184
  /** A gate reports WHAT it checked, not just whether it passed. */
@@ -401,6 +401,42 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
401
401
  }>;
402
402
  }, undefined>;
403
403
  export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
404
+ /**
405
+ * Optional outbound push for unattended work (`spf watch`, any chain run) —
406
+ * everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
407
+ * it stays console-only on purpose; see `core/notify/notifier.ts`.
408
+ *
409
+ * `events` is the whole filter: "off" sends nothing, "errors" sends only
410
+ * NotifyEvents whose `level` is "error", "all" sends every curated
411
+ * milestone. A channel's own `events` overrides the top-level scope for
412
+ * just that channel (e.g. Slack gets everything, Teams gets errors only).
413
+ *
414
+ * `webhook_url_env` names the .env key holding the secret URL — never the
415
+ * URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
416
+ * default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
417
+ */
418
+ export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "all"], undefined>;
419
+ export type NotifyScope = v.InferOutput<typeof NotifyScopeSchema>;
420
+ export declare const NotifyChannelKindSchema: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
421
+ export type NotifyChannelKind = v.InferOutput<typeof NotifyChannelKindSchema>;
422
+ export declare const NotifyChannelSchema: v.ObjectSchema<{
423
+ readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
424
+ readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
425
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
426
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
427
+ }, undefined>;
428
+ export type NotifyChannel = v.InferOutput<typeof NotifyChannelSchema>;
429
+ export declare const NotificationsConfigSchema: v.ObjectSchema<{
430
+ readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
431
+ readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
432
+ readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
433
+ readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
434
+ readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
435
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
436
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
437
+ }, undefined>, undefined>, () => never[]>;
438
+ }, undefined>;
439
+ export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
404
440
  export declare const SFConfigSchema: v.ObjectSchema<{
405
441
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
406
442
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -494,6 +530,25 @@ export declare const SFConfigSchema: v.ObjectSchema<{
494
530
  project_key: string;
495
531
  };
496
532
  }>;
533
+ readonly notifications: v.OptionalSchema<v.ObjectSchema<{
534
+ readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
535
+ readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
536
+ readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
537
+ readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
538
+ readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
539
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
540
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
541
+ }, undefined>, undefined>, () => never[]>;
542
+ }, undefined>, () => {
543
+ events: "all" | "errors" | "off";
544
+ timeout_ms: number;
545
+ channels: {
546
+ kind: "slack" | "teams" | "webhook";
547
+ webhook_url_env: string;
548
+ events?: "all" | "errors" | "off" | null | undefined;
549
+ name: string;
550
+ }[];
551
+ }>;
497
552
  }, undefined>;
498
553
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
499
554
  /** One traced event, always logged against adw_id + phase. */