@gr8ful/spf 0.1.7 → 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.
Files changed (94) hide show
  1. package/README.md +57 -2
  2. package/assets/skill/cookbooks/authoring_chains.md +96 -84
  3. package/assets/skill/cookbooks/roster.md +3 -1
  4. package/assets/skill/references/config.md +52 -5
  5. package/assets/templates/ts.spf.config.yaml +12 -0
  6. package/dist/chains/context.d.ts +2 -0
  7. package/dist/chains/index.d.ts +21 -2
  8. package/dist/chains/index.js +73 -104
  9. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  10. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  11. package/dist/chains/steps.d.ts +117 -0
  12. package/dist/chains/steps.js +299 -0
  13. package/dist/cli/ask.d.ts +27 -0
  14. package/dist/cli/ask.js +125 -0
  15. package/dist/cli/commands/doctor.js +14 -24
  16. package/dist/cli/commands/init.d.ts +1 -1
  17. package/dist/cli/commands/init.js +96 -9
  18. package/dist/cli/commands/run.d.ts +1 -1
  19. package/dist/cli/commands/run.js +3 -1
  20. package/dist/cli/commands/watch.js +20 -3
  21. package/dist/cli/env_file.d.ts +18 -0
  22. package/dist/cli/env_file.js +99 -0
  23. package/dist/cli/index.js +6 -2
  24. package/dist/cli/interview.d.ts +26 -0
  25. package/dist/cli/interview.js +417 -0
  26. package/dist/core/agents.js +4 -1
  27. package/dist/core/console.d.ts +13 -1
  28. package/dist/core/console.js +51 -1
  29. package/dist/core/data_types.d.ts +55 -0
  30. package/dist/core/data_types.js +30 -0
  31. package/dist/core/notify/channel.d.ts +32 -0
  32. package/dist/core/notify/channel.js +14 -0
  33. package/dist/core/notify/notifier.d.ts +42 -0
  34. package/dist/core/notify/notifier.js +100 -0
  35. package/dist/core/notify/slack_channel.d.ts +13 -0
  36. package/dist/core/notify/slack_channel.js +30 -0
  37. package/dist/core/notify/teams_channel.d.ts +17 -0
  38. package/dist/core/notify/teams_channel.js +38 -0
  39. package/dist/core/notify/webhook_channel.d.ts +13 -0
  40. package/dist/core/notify/webhook_channel.js +19 -0
  41. package/dist/core/prompts.d.ts +2 -0
  42. package/dist/core/prompts.js +2 -0
  43. package/dist/core/providers.d.ts +12 -0
  44. package/dist/core/providers.js +24 -0
  45. package/dist/core/quality.d.ts +9 -0
  46. package/dist/core/quality.js +10 -0
  47. package/dist/core/runner.d.ts +7 -0
  48. package/dist/core/runner.js +4 -1
  49. package/dist/core/session.d.ts +6 -1
  50. package/dist/core/session.js +10 -3
  51. package/dist/core/tracer.js +1 -1
  52. package/dist/core/utils.d.ts +6 -2
  53. package/dist/core/utils.js +11 -2
  54. package/dist/core/watch.d.ts +10 -0
  55. package/dist/core/watch.js +67 -4
  56. package/dist/test/chains.test.d.ts +12 -0
  57. package/dist/test/chains.test.js +86 -0
  58. package/dist/test/data_types.test.js +34 -1
  59. package/dist/test/env_file.test.d.ts +1 -0
  60. package/dist/test/env_file.test.js +74 -0
  61. package/dist/test/fake_asker.d.ts +23 -0
  62. package/dist/test/fake_asker.js +30 -0
  63. package/dist/test/init_command.test.d.ts +1 -0
  64. package/dist/test/init_command.test.js +66 -0
  65. package/dist/test/interview.test.d.ts +1 -0
  66. package/dist/test/interview.test.js +297 -0
  67. package/dist/test/notify.test.d.ts +1 -0
  68. package/dist/test/notify.test.js +174 -0
  69. package/dist/test/ui_server.test.js +1 -1
  70. package/dist/test/watch.test.js +114 -1
  71. package/dist/ui/shared/types.d.ts +1 -1
  72. package/package.json +5 -2
  73. package/dist/chains/adw_build.d.ts +0 -12
  74. package/dist/chains/adw_build.js +0 -27
  75. package/dist/chains/adw_build_review.d.ts +0 -21
  76. package/dist/chains/adw_build_review.js +0 -55
  77. package/dist/chains/adw_build_test.d.ts +0 -21
  78. package/dist/chains/adw_build_test.js +0 -67
  79. package/dist/chains/adw_document.d.ts +0 -23
  80. package/dist/chains/adw_document.js +0 -59
  81. package/dist/chains/adw_plan.d.ts +0 -12
  82. package/dist/chains/adw_plan.js +0 -27
  83. package/dist/chains/adw_plan_build.d.ts +0 -12
  84. package/dist/chains/adw_plan_build.js +0 -30
  85. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  86. package/dist/chains/adw_plan_build_test.js +0 -65
  87. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  88. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  89. package/dist/chains/adw_prompt.d.ts +0 -12
  90. package/dist/chains/adw_prompt.js +0 -25
  91. package/dist/chains/adw_quality.d.ts +0 -12
  92. package/dist/chains/adw_quality.js +0 -32
  93. package/dist/chains/adw_scout.d.ts +0 -12
  94. package/dist/chains/adw_scout.js +0 -27
@@ -0,0 +1,417 @@
1
+ /**
2
+ * The question flow behind `spf init`'s interactive interview. No filesystem
3
+ * writes happen here — `commands/init.ts` owns those, so this module stays
4
+ * unit-testable against a scripted `Asker` (see `src/test/interview.test.ts`)
5
+ * without touching disk beyond the read-only `DetectedContext` gather.
6
+ *
7
+ * Every section below mirrors an existing, already-shipped code path rather
8
+ * than inventing new vocabulary:
9
+ * - coding agent -> src/core/agent_cc.ts / agent_flue.ts, src/core/providers.ts
10
+ * - quality checks -> src/core/data_types.ts's QualityCheckSpecSchema
11
+ * - watch ticket loop -> src/cli/commands/watch.ts's resolveIssueProvider/resolveCodeHostProvider
12
+ * - the pinned-roster fix -> assets/templates/ts-cc.spf.config.yaml, done by hand today
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { spawnSync } from "node:child_process";
16
+ import path from "node:path";
17
+ import { binaryOnPath } from "../core/utils.js";
18
+ import { PROVIDER_ENV_KEYS } from "../core/providers.js";
19
+ import { resolveModel } from "../core/agent_flue.js";
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";
24
+ import { CHAINS } from "../chains/index.js";
25
+ function gitConfigValue(repoRoot, key) {
26
+ const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
27
+ const value = result.status === 0 ? result.stdout.trim() : "";
28
+ return value || undefined;
29
+ }
30
+ /** Parses the common GitHub/Bitbucket remote URL shapes (https and ssh) into "owner/name". Best-effort — an unparseable or missing remote just means no default to offer. */
31
+ function parseRemoteSlug(url) {
32
+ if (!url)
33
+ return undefined;
34
+ const match = /(?:[/:])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url.trim());
35
+ return match ? match[1] : undefined;
36
+ }
37
+ function readScripts(repoRoot) {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf-8"));
40
+ return pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
41
+ }
42
+ catch {
43
+ return {};
44
+ }
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
+ }
55
+ export function gatherContext(repoRoot, existingEnv = new Map()) {
56
+ const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: repoRoot, encoding: "utf-8" });
57
+ const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot, encoding: "utf-8" });
58
+ return {
59
+ repoSlug: parseRemoteSlug(remote.status === 0 ? remote.stdout.trim() : undefined),
60
+ currentBranch: branch.status === 0 && branch.stdout.trim() !== "HEAD" ? branch.stdout.trim() : "main",
61
+ gitEmail: gitConfigValue(repoRoot, "user.email"),
62
+ gitName: gitConfigValue(repoRoot, "user.name"),
63
+ scripts: readScripts(repoRoot),
64
+ claudeOnPath: binaryOnPath("claude"),
65
+ existingEnv,
66
+ rosterNames: readRosterNames(),
67
+ };
68
+ }
69
+ const QUALITY_TIMEOUTS = { typecheck: 60, lint: 60, build: 120, test: 180 };
70
+ function splitArgv(command) {
71
+ return command.trim().split(/\s+/).filter(Boolean);
72
+ }
73
+ /**
74
+ * Runs the interview and returns the config/env to write, or `null` if the
75
+ * user declines the final confirmation. Throws `InterviewAborted` (from
76
+ * `./ask.ts`) on Ctrl-C/EOF — the caller decides the exit code for that.
77
+ */
78
+ export async function runInterview(asker, ctx) {
79
+ const defaults = {};
80
+ const agentOverrides = [];
81
+ const env = {};
82
+ const envExampleKeys = [];
83
+ const notes = [];
84
+ // ── 1. coding agent ────────────────────────────────────────────────────────
85
+ asker.heading("Coding agent");
86
+ const codingAgent = await asker.select("Which backend runs each agent?", [
87
+ { value: "claude_code", label: "claude_code — shells out to the `claude` CLI you already use", hint: "cc" },
88
+ { value: "flue", label: "flue — in-process, provider/model-id (openai, anthropic, openrouter, ...)" },
89
+ ], "claude_code");
90
+ defaults.coding_agent = codingAgent;
91
+ if (codingAgent === "claude_code") {
92
+ if (!ctx.claudeOnPath) {
93
+ asker.note("warning: `claude` was not found on PATH — install it (or set a launch command below) before running spf.");
94
+ }
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" });
96
+ if (launchCommand !== "claude")
97
+ env["SPF_CLAUDE_CMD"] = launchCommand;
98
+ const model = await asker.select("Model (Claude Code's own vocabulary — not provider/model-id)", [
99
+ { value: "sonnet", label: "sonnet" },
100
+ { value: "opus", label: "opus" },
101
+ { value: "haiku", label: "haiku" },
102
+ { value: "custom", label: "custom — type an exact model name" },
103
+ ], "sonnet");
104
+ defaults.model = model === "custom" ? await asker.text("Exact model name") : model;
105
+ const auth = await asker.select("Authentication", [
106
+ { value: "login", label: "already logged in via `claude login`", hint: "writes nothing" },
107
+ { value: "key", label: "ANTHROPIC_API_KEY" },
108
+ { value: "endpoint", label: "custom endpoint (Ollama / Ollama Cloud / a proxy)" },
109
+ ], "login");
110
+ if (auth === "key") {
111
+ const current = ctx.existingEnv.get("ANTHROPIC_API_KEY");
112
+ const key = await asker.secret("ANTHROPIC_API_KEY", { current });
113
+ if (key)
114
+ env["ANTHROPIC_API_KEY"] = key;
115
+ envExampleKeys.push("ANTHROPIC_API_KEY");
116
+ }
117
+ else if (auth === "endpoint") {
118
+ const baseUrl = await asker.text("ANTHROPIC_BASE_URL", { default: "http://localhost:11434" });
119
+ env["ANTHROPIC_BASE_URL"] = baseUrl;
120
+ const token = await asker.secret("ANTHROPIC_AUTH_TOKEN", { current: ctx.existingEnv.get("ANTHROPIC_AUTH_TOKEN") });
121
+ env["ANTHROPIC_AUTH_TOKEN"] = token || "ollama";
122
+ envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN");
123
+ asker.note("spf doctor does not check these two — confirm the endpoint is reachable yourself.");
124
+ }
125
+ // The packaged roster pins planner/reviewer/documenter to their own
126
+ // Flue-style provider/model-id strings, which always win over
127
+ // defaults.model (agents.ts's back-fill only applies when an agent
128
+ // doesn't already set its own `model`). Left alone, switching the
129
+ // backend to claude_code sends those three straight to the `claude`
130
+ // CLI's `--model` flag as e.g. "fireworks/accounts/.../kimi-k3", which
131
+ // it cannot resolve. Override all three to the same model chosen above
132
+ // — exactly what assets/templates/ts-cc.spf.config.yaml does by hand.
133
+ for (const name of ["planner", "reviewer", "documenter"]) {
134
+ agentOverrides.push({ name, model: defaults.model });
135
+ }
136
+ notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to match, since claude_code can't resolve their packaged Flue-style ids.");
137
+ }
138
+ else {
139
+ const provider = await asker.select("Provider", Object.keys(PROVIDER_ENV_KEYS).map((p) => ({ value: p, label: p })), "anthropic");
140
+ const modelId = await asker.text(`Model id (after "${provider}/")`, {
141
+ default: provider === "anthropic" ? "claude-sonnet-4-6" : "",
142
+ validate: (val) => (val.trim() ? null : "required"),
143
+ });
144
+ const model = `${provider}/${modelId}`;
145
+ try {
146
+ resolveModel(model);
147
+ }
148
+ catch (error) {
149
+ asker.note(`warning: ${error.message}`);
150
+ }
151
+ defaults.model = model;
152
+ const envKey = PROVIDER_ENV_KEYS[provider][0];
153
+ const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
154
+ if (key)
155
+ env[envKey] = key;
156
+ envExampleKeys.push(envKey);
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
+ }
186
+ // ── 2. quality checks ──────────────────────────────────────────────────────
187
+ asker.heading("Quality checks");
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.");
189
+ const checks = [];
190
+ const configuredNames = [];
191
+ for (const [checkName, operation, scriptGuess] of [
192
+ ["typecheck", "typecheck", "typecheck"],
193
+ ["lint", "lint", "lint"],
194
+ ["build", "build", "build"],
195
+ ["test", "build", "test"],
196
+ ]) {
197
+ const detectedScript = ctx.scripts[scriptGuess] ? `npm run ${scriptGuess}` : "";
198
+ const wantIt = await asker.confirm(`Add a "${checkName}" check?`, Boolean(detectedScript));
199
+ if (!wantIt)
200
+ continue;
201
+ const command = await asker.text(` ${checkName} command`, { default: detectedScript || `npm run ${scriptGuess}` });
202
+ checks.push({ name: checkName, operation, argv: splitArgv(command), timeout_seconds: QUALITY_TIMEOUTS[checkName] });
203
+ configuredNames.push(checkName);
204
+ }
205
+ if (checks.length > 0) {
206
+ const suites = { all: configuredNames };
207
+ if (configuredNames.includes("test"))
208
+ suites.test = ["test"];
209
+ defaults["__quality__"] = { checks, suites };
210
+ }
211
+ // ── 3. watch ticket loop ────────────────────────────────────────────────────
212
+ asker.heading("Watch ticket loop (spf watch)");
213
+ const enableWatch = await asker.confirm("Enable spf watch (poll a tracker, run a chain per issue, open a PR)?", false);
214
+ let watch = null;
215
+ if (enableWatch) {
216
+ const issueProvider = await asker.select("Issue tracker", [
217
+ { value: "github", label: "GitHub Issues" },
218
+ { value: "jira", label: "Jira" },
219
+ ], "github");
220
+ const codeHost = await asker.select("Code host (where PRs open)", [
221
+ { value: "github", label: "GitHub" },
222
+ { value: "bitbucket", label: "Bitbucket" },
223
+ ], "github");
224
+ const repoHint = codeHost === "bitbucket" ? "workspace/repo_slug" : "owner/name";
225
+ const repo = await asker.text(`Repo (${repoHint})`, {
226
+ default: codeHost === "github" ? ctx.repoSlug ?? "" : "",
227
+ validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "${repoHint}"`),
228
+ });
229
+ const labelPrefix = await asker.text("Label prefix", { default: "spf" });
230
+ const baseBranch = await asker.text("Base branch", { default: ctx.currentBranch });
231
+ const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
232
+ const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
233
+ watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
234
+ if (issueProvider === "jira") {
235
+ const baseUrl = await asker.text("Jira base URL", {
236
+ default: "https://your-domain.atlassian.net",
237
+ validate: (val) => (/^https:\/\/.+/.test(val) ? null : "must start with https://"),
238
+ });
239
+ const projectKey = await asker.text("Jira project key", { validate: (val) => (val.trim() ? null : "required") });
240
+ watch.jira = { base_url: baseUrl.replace(/\/+$/, ""), project_key: projectKey.toUpperCase() };
241
+ }
242
+ const needsGithub = issueProvider === "github" || codeHost === "github";
243
+ if (needsGithub) {
244
+ asker.note('classic PAT, "repo" scope (private) or "public_repo" (public-only) — never "project".');
245
+ const token = await asker.secret("GITHUB_TOKEN", { current: ctx.existingEnv.get("GITHUB_TOKEN") });
246
+ if (token)
247
+ env["GITHUB_TOKEN"] = token;
248
+ envExampleKeys.push("GITHUB_TOKEN");
249
+ }
250
+ if (issueProvider === "jira") {
251
+ const email = await asker.text("JIRA_EMAIL", { default: ctx.gitEmail ?? "" });
252
+ if (email)
253
+ env["JIRA_EMAIL"] = email;
254
+ const token = await asker.secret("JIRA_API_TOKEN", { current: ctx.existingEnv.get("JIRA_API_TOKEN") });
255
+ if (token)
256
+ env["JIRA_API_TOKEN"] = token;
257
+ envExampleKeys.push("JIRA_EMAIL", "JIRA_API_TOKEN");
258
+ asker.note("id.atlassian.com -> Security -> API tokens");
259
+ }
260
+ if (codeHost === "bitbucket") {
261
+ const email = await asker.text("BITBUCKET_EMAIL", { default: ctx.gitEmail ?? "" });
262
+ if (email)
263
+ env["BITBUCKET_EMAIL"] = email;
264
+ const token = await asker.secret("BITBUCKET_API_TOKEN", { current: ctx.existingEnv.get("BITBUCKET_API_TOKEN") });
265
+ if (token)
266
+ env["BITBUCKET_API_TOKEN"] = token;
267
+ envExampleKeys.push("BITBUCKET_EMAIL", "BITBUCKET_API_TOKEN");
268
+ asker.note("Bitbucket app passwords are being removed — this needs an Atlassian API token instead.");
269
+ }
270
+ }
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) ─────────────────────────────────────────────────────
311
+ const wantAdvanced = await asker.confirm("\nConfigure advanced settings (thinking level, tools, protected files, data dir, poll intervals, ...)?", false);
312
+ if (wantAdvanced) {
313
+ asker.heading("Advanced");
314
+ const thinkingChoices = ThinkingLevelSchema.options.map((o) => ({ value: o, label: o }));
315
+ const thinking = await asker.select("defaults.thinking", thinkingChoices, "medium");
316
+ if (thinking !== "medium")
317
+ defaults.thinking = thinking;
318
+ const dataDir = await asker.text("defaults.data_dir", { default: ".spf/data" });
319
+ if (dataDir !== ".spf/data")
320
+ defaults.data_dir = dataDir;
321
+ const protectedFiles = await asker.text("defaults.protected_files (comma-separated)", { default: ".spf/, spf.config.yaml" });
322
+ const protectedList = protectedFiles.split(",").map((s) => s.trim()).filter(Boolean);
323
+ if (protectedList.join(",") !== ".spf/,spf.config.yaml")
324
+ defaults.protected_files = protectedList;
325
+ const dbPath = await asker.text("observability.db", { default: ".spf/data/spf.db" });
326
+ const pollMs = await asker.text("observability.poll_ms", { default: "500" });
327
+ if (dbPath !== ".spf/data/spf.db" || pollMs !== "500") {
328
+ defaults["__observability__"] = { ...(dbPath !== ".spf/data/spf.db" ? { db: dbPath } : {}), ...(pollMs !== "500" ? { poll_ms: Number(pollMs) } : {}) };
329
+ }
330
+ if (watch) {
331
+ const watchPollMs = await asker.text("watch.poll_ms", { default: "60000" });
332
+ if (watchPollMs !== "60000")
333
+ watch.poll_ms = Number(watchPollMs);
334
+ const concurrency = await asker.text("watch.concurrency", { default: "2" });
335
+ if (concurrency !== "2")
336
+ watch.concurrency = Number(concurrency);
337
+ }
338
+ const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
339
+ if (engineerName && engineerName !== ctx.gitName)
340
+ env["ENGINEER_NAME"] = engineerName;
341
+ if (watch?.issue_provider === "jira") {
342
+ const debug = await asker.confirm("Enable SPF_JIRA_DEBUG (request/response logging)?", false);
343
+ if (debug)
344
+ env["SPF_JIRA_DEBUG"] = "1";
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
+ }
351
+ }
352
+ // ── 6. review + confirm ─────────────────────────────────────────────────────
353
+ const observability = defaults["__observability__"];
354
+ delete defaults["__observability__"];
355
+ const quality = defaults["__quality__"];
356
+ delete defaults["__quality__"];
357
+ const config = { defaults };
358
+ if (agentOverrides.length > 0)
359
+ config.agents = agentOverrides;
360
+ if (quality)
361
+ config.quality = quality;
362
+ if (watch)
363
+ config.watch = watch;
364
+ if (observability)
365
+ config.observability = observability;
366
+ if (notifications)
367
+ config.notifications = notifications;
368
+ // No schema validation here on purpose: `config.agents` overrides are
369
+ // intentionally PARTIAL (name + model only, no prompt_engineering) — valid
370
+ // only once merged by name into the packaged roster (agents.ts's
371
+ // mergeAgentLists), same as any hand-written override file. Validating
372
+ // this raw document against the full SFConfigSchema would flag every
373
+ // claude_code interview as broken. `commands/init.ts` runs the real
374
+ // merge-then-validate pipeline (agents.loadConfig + agents.validate,
375
+ // the same one `spf doctor` uses) after writing, and reports there.
376
+ asker.heading("Review");
377
+ console.log(renderPreview(config));
378
+ if (notes.length > 0) {
379
+ for (const n of notes)
380
+ asker.note(n);
381
+ }
382
+ if (Object.keys(env).length > 0) {
383
+ console.log("");
384
+ console.log(".env keys to write: " + Object.keys(env).join(", "));
385
+ }
386
+ const proceed = await asker.confirm("\nWrite .spf/spf.config.yaml and .env?", true);
387
+ if (!proceed)
388
+ return null;
389
+ return { config, env, envExampleKeys };
390
+ }
391
+ /** An array is worth recursing into only if it holds further objects (e.g. `agents:`, `quality.checks`) — an array of primitives (e.g. `argv: ["npm","run","test"]`) reads better printed inline than one bare, contentless line per entry. */
392
+ function isArrayOfObjects(value) {
393
+ return Array.isArray(value) && value.some((item) => item && typeof item === "object");
394
+ }
395
+ function renderPreview(config) {
396
+ const lines = [];
397
+ const walk = (obj, indent) => {
398
+ for (const [key, value] of Object.entries(obj)) {
399
+ if (isArrayOfObjects(value)) {
400
+ lines.push(`${indent}${key}:`);
401
+ for (const item of value) {
402
+ lines.push(`${indent} -`);
403
+ walk(item, indent + " ");
404
+ }
405
+ }
406
+ else if (value && typeof value === "object" && !Array.isArray(value)) {
407
+ lines.push(`${indent}${key}:`);
408
+ walk(value, indent + " ");
409
+ }
410
+ else {
411
+ lines.push(`${indent}${key}: ${JSON.stringify(value)}`);
412
+ }
413
+ }
414
+ };
415
+ walk(config, "");
416
+ return lines.join("\n");
417
+ }
@@ -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. */