@gr8ful/spf 0.5.1 → 0.7.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 (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. package/dist/test/watch.test.js +0 -687
@@ -1,425 +0,0 @@
1
- /**
2
- * `spf init`'s interactive interview (`cli/interview.ts`) — driven through
3
- * the `Asker` seam with a scripted fake (`./fake_asker.ts`), never a real
4
- * TTY. Covers the three things a wrong answer would actually break:
5
- * 1. the pinned-roster trap (planner/reviewer/documenter overridden
6
- * whenever the backend switches to claude_code — see agent_cc.ts/
7
- * agents.ts's back-fill comment),
8
- * 2. the generated config is a genuine override — validating it requires
9
- * merging with the packaged built-in roster first, exactly like
10
- * `spf doctor` does, never the raw document alone,
11
- * 3. each `spf watch` combination collects exactly the credentials
12
- * `cli/commands/watch.ts`'s resolveIssueProvider/resolveCodeHostProvider
13
- * actually read — no more, no less.
14
- */
15
- import "./hermetic_git.js";
16
- import { test, before, after } from "node:test";
17
- import assert from "node:assert/strict";
18
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
- import { tmpdir } from "node:os";
20
- import { join } from "node:path";
21
- import { execFileSync } from "node:child_process";
22
- import { runInterview, gatherContext } from "../cli/interview.js";
23
- import { loadConfig, validate } from "../core/agents.js";
24
- import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
25
- import { createFakeAsker } from "./fake_asker.js";
26
- let dir;
27
- before(() => {
28
- dir = mkdtempSync(join(tmpdir(), "spf-interview-test-"));
29
- execFileSync("git", ["init", "-q"], { cwd: dir });
30
- execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/widgets.git"], { cwd: dir });
31
- // -c user.*, not a global git config: this must pass in a fresh CI runner
32
- // with no git identity configured at all (same reasoning as ui_server.test.ts).
33
- execFileSync("git", ["-c", "user.email=test@example.com", "-c", "user.name=spf tests", "commit", "-q", "--allow-empty", "-m", "init"], { cwd: dir });
34
- });
35
- after(() => {
36
- rmSync(dir, { recursive: true, force: true });
37
- });
38
- /** Merge with the real packaged roster and validate — the same pipeline `spf doctor` runs, and the only honest way to check a partial override document. */
39
- function mergedConfigPath() {
40
- const configPath = join(dir, `spf.config.${Math.random().toString(36).slice(2)}.yaml`);
41
- return configPath;
42
- }
43
- test("claude_code + watch(github/github): overrides the three pinned agents and merges cleanly", async () => {
44
- const ctx = gatherContext(dir, new Map());
45
- const asker = createFakeAsker({
46
- select: {
47
- "backend runs": "claude_code",
48
- "Model (Claude": "sonnet",
49
- Authentication: "login",
50
- "Issue tracker": "github",
51
- "Code host": "github",
52
- "Chain to run": "plan-build-test",
53
- },
54
- confirm: {
55
- 'Add a "typecheck"': false,
56
- 'Add a "lint"': false,
57
- 'Add a "build"': false,
58
- 'Add a "test"': false,
59
- "Enable spf watch": true,
60
- "Configure advanced": false,
61
- "Write .spf": true,
62
- },
63
- secret: { GITHUB_TOKEN: "ghp_abcdefghijklmnop" },
64
- });
65
- const result = await runInterview(asker, ctx);
66
- assert.ok(result);
67
- const config = result.config;
68
- assert.equal(config.defaults.coding_agent, "claude_code");
69
- assert.equal(config.defaults.model, "sonnet");
70
- const overrides = config.agents;
71
- assert.deepEqual(overrides.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
72
- for (const a of overrides)
73
- assert.equal(a.model, "sonnet");
74
- assert.equal(config.watch.repo, "acme/widgets"); // detected from origin
75
- assert.equal(result.env.GITHUB_TOKEN, "ghp_abcdefghijklmnop");
76
- assert.ok(result.envExampleKeys.includes("GITHUB_TOKEN"));
77
- const configPath = mergedConfigPath();
78
- const { stringify } = await import("yaml");
79
- writeFileSync(configPath, stringify(config));
80
- const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
81
- assert.equal(cfg.defaults.coding_agent, "claude_code");
82
- assert.equal(cfg.agents.find((a) => a.name === "planner").model, "sonnet");
83
- assert.equal(cfg.agents.find((a) => a.name === "builder").model, "sonnet"); // inherits defaults.model, needed no override
84
- validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
85
- });
86
- test("flue + openrouter: no pinned-agent overrides needed, provider key collected", async () => {
87
- const ctx = gatherContext(dir, new Map());
88
- const asker = createFakeAsker({
89
- select: { "backend runs": "flue", Provider: "openrouter" },
90
- text: { "Model id": "moonshotai/kimi-k2.7" },
91
- confirm: {
92
- 'Add a "typecheck"': false,
93
- 'Add a "lint"': false,
94
- 'Add a "build"': false,
95
- 'Add a "test"': false,
96
- "Enable spf watch": false,
97
- "Configure advanced": false,
98
- "Write .spf": true,
99
- },
100
- secret: { OPENROUTER_API_KEY: "sk-or-v1-test" },
101
- });
102
- const result = await runInterview(asker, ctx);
103
- assert.ok(result);
104
- const config = result.config;
105
- assert.equal(config.defaults.coding_agent, "flue");
106
- assert.equal(config.defaults.model, "openrouter/moonshotai/kimi-k2.7");
107
- assert.equal(config.agents, undefined, "flue never needs the pinned-roster fix — agents' own models are already valid Flue ids");
108
- assert.equal(result.env.OPENROUTER_API_KEY, "sk-or-v1-test");
109
- assert.ok(!("watch" in config));
110
- });
111
- test("flue + ollama: keyless provider skips the API-key prompt, but still asks for OLLAMA_BASE_URL", async () => {
112
- const ctx = gatherContext(dir, new Map());
113
- const asker = createFakeAsker({
114
- select: { "backend runs": "flue", Provider: "ollama" },
115
- text: { "Model id": "llama3" }, // deliberately no "OLLAMA_BASE_URL" entry — the fake asker falls back to its default
116
- confirm: {
117
- 'Add a "typecheck"': false,
118
- 'Add a "lint"': false,
119
- 'Add a "build"': false,
120
- 'Add a "test"': false,
121
- "Enable spf watch": false,
122
- "Configure advanced": false,
123
- "Write .spf": true,
124
- },
125
- // Deliberately no `secret` entries: a keyless provider must never call
126
- // asker.secret() at all — PROVIDER_ENV_KEYS.ollama is `[]`, so indexing
127
- // [0] for a prompt label would be `undefined`, not skip the prompt.
128
- });
129
- const result = await runInterview(asker, ctx);
130
- assert.ok(result);
131
- const config = result.config;
132
- assert.equal(config.defaults.coding_agent, "flue");
133
- assert.equal(config.defaults.model, "ollama/llama3");
134
- assert.deepEqual(result.env, { OLLAMA_BASE_URL: "http://localhost:11434/v1" }, "no API key, but the base URL prompt's default is written");
135
- assert.deepEqual(result.envExampleKeys, ["OLLAMA_BASE_URL"], "OLLAMA_BASE_URL is the only key name to record for a keyless provider");
136
- // Unlike the openrouter case above, ollama DOES need the pinned-roster
137
- // fix: planner/reviewer/documenter pin their own fireworks/gemini/openai
138
- // model strings in the packaged roster, which would otherwise always win
139
- // over defaults.model and leave three agents needing keys this interview
140
- // never asked for.
141
- assert.deepEqual(config.agents, [
142
- { name: "planner", model: "ollama/llama3" },
143
- { name: "reviewer", model: "ollama/llama3" },
144
- { name: "documenter", model: "ollama/llama3" },
145
- ], "planner/reviewer/documenter are pinned to the chosen ollama model, same fix as the claude_code branch");
146
- });
147
- test("flue + ollama: a custom OLLAMA_BASE_URL answer is written verbatim", async () => {
148
- const ctx = gatherContext(dir, new Map());
149
- const asker = createFakeAsker({
150
- select: { "backend runs": "flue", Provider: "ollama" },
151
- text: { "Model id": "llama3", OLLAMA_BASE_URL: "http://gpu-box.local:11434/v1" },
152
- confirm: {
153
- 'Add a "typecheck"': false,
154
- 'Add a "lint"': false,
155
- 'Add a "build"': false,
156
- 'Add a "test"': false,
157
- "Enable spf watch": false,
158
- "Configure advanced": false,
159
- "Write .spf": true,
160
- },
161
- });
162
- const result = await runInterview(asker, ctx);
163
- assert.ok(result);
164
- assert.equal(result.env.OLLAMA_BASE_URL, "http://gpu-box.local:11434/v1");
165
- });
166
- test("watch(jira/bitbucket): collects exactly JIRA_* + BITBUCKET_*, never GITHUB_TOKEN", async () => {
167
- const ctx = gatherContext(dir, new Map());
168
- const asker = createFakeAsker({
169
- select: {
170
- "backend runs": "claude_code",
171
- "Model (Claude": "sonnet",
172
- Authentication: "login",
173
- "Issue tracker": "jira",
174
- "Code host": "bitbucket",
175
- "Chain to run": "plan-build-test",
176
- },
177
- text: {
178
- "Repo (workspace/repo_slug)": "acme/widgets-repo",
179
- "Jira base URL": "https://acme.atlassian.net/",
180
- "Jira project key": "proj",
181
- },
182
- confirm: {
183
- 'Add a "typecheck"': false,
184
- 'Add a "lint"': false,
185
- 'Add a "build"': false,
186
- 'Add a "test"': false,
187
- "Enable spf watch": true,
188
- "Configure advanced": false,
189
- "Write .spf": true,
190
- },
191
- secret: { JIRA_API_TOKEN: "jira-token", BITBUCKET_API_TOKEN: "bb-token" },
192
- });
193
- const result = await runInterview(asker, ctx);
194
- assert.ok(result);
195
- const config = result.config;
196
- assert.equal(config.watch.issue_provider, "jira");
197
- assert.equal(config.watch.code_host, "bitbucket");
198
- assert.equal(config.watch.jira.base_url, "https://acme.atlassian.net"); // trailing slash stripped
199
- assert.equal(config.watch.jira.project_key, "PROJ"); // upper-cased
200
- assert.equal(result.env.JIRA_API_TOKEN, "jira-token");
201
- assert.equal(result.env.BITBUCKET_API_TOKEN, "bb-token");
202
- assert.equal(result.env.GITHUB_TOKEN, undefined, "neither provider needs GitHub — must not ask for or write GITHUB_TOKEN");
203
- });
204
- test("watch(github issues + bitbucket code): asks two repo questions and writes issue_repo separately from repo", async () => {
205
- const ctx = gatherContext(dir, new Map());
206
- const asker = createFakeAsker({
207
- select: {
208
- "backend runs": "claude_code",
209
- "Model (Claude": "sonnet",
210
- Authentication: "login",
211
- "Issue tracker": "github",
212
- "Code host": "bitbucket",
213
- "Chain to run": "plan-build-test",
214
- },
215
- text: {
216
- "Code repo, where PRs open (workspace/repo_slug)": "acme-workspace/widgets-code",
217
- "Issue repo, where spf:ready issues live (owner/name)": "acme/widgets-issues",
218
- },
219
- confirm: {
220
- 'Add a "typecheck"': false,
221
- 'Add a "lint"': false,
222
- 'Add a "build"': false,
223
- 'Add a "test"': false,
224
- "Enable spf watch": true,
225
- "Also enable the refine lane": false,
226
- "Configure advanced": false,
227
- "Write .spf": true,
228
- },
229
- secret: { GITHUB_TOKEN: "ghp_x", BITBUCKET_API_TOKEN: "bb-token" },
230
- });
231
- const result = await runInterview(asker, ctx);
232
- assert.ok(result);
233
- const config = result.config;
234
- assert.equal(config.watch.issue_provider, "github");
235
- assert.equal(config.watch.code_host, "bitbucket");
236
- assert.equal(config.watch.repo, "acme-workspace/widgets-code", "repo is the CODE HOST's repo");
237
- assert.equal(config.watch.issue_repo, "acme/widgets-issues", "issue_repo overrides repo for the issue tracker side");
238
- const configPath = mergedConfigPath();
239
- const { stringify } = await import("yaml");
240
- writeFileSync(configPath, stringify(config));
241
- const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
242
- assert.equal(cfg.watch.repo, "acme-workspace/widgets-code");
243
- assert.equal(cfg.watch.issue_repo, "acme/widgets-issues");
244
- });
245
- test("watch(github issues + github code): a single repo answers both, issue_repo stays unset", async () => {
246
- const ctx = gatherContext(dir, new Map());
247
- const asker = createFakeAsker({
248
- select: {
249
- "backend runs": "claude_code",
250
- "Model (Claude": "sonnet",
251
- Authentication: "login",
252
- "Issue tracker": "github",
253
- "Code host": "github",
254
- "Chain to run": "plan-build-test",
255
- },
256
- text: { "Repo (owner/name)": "acme/widgets" },
257
- confirm: {
258
- 'Add a "typecheck"': false,
259
- 'Add a "lint"': false,
260
- 'Add a "build"': false,
261
- 'Add a "test"': false,
262
- "Enable spf watch": true,
263
- "Also enable the refine lane": false,
264
- "Configure advanced": false,
265
- "Write .spf": true,
266
- },
267
- secret: { GITHUB_TOKEN: "ghp_x" },
268
- });
269
- const result = await runInterview(asker, ctx);
270
- assert.ok(result);
271
- const config = result.config;
272
- assert.equal(config.watch.repo, "acme/widgets");
273
- assert.equal(config.watch.issue_repo, undefined, "the single-repo case never writes issue_repo at all");
274
- });
275
- test("declining the final confirm returns null — nothing to write", async () => {
276
- const ctx = gatherContext(dir, new Map());
277
- const asker = createFakeAsker({ defaultConfirm: false }); // every confirm, including the final one, says no
278
- const result = await runInterview(asker, ctx);
279
- assert.equal(result, null);
280
- });
281
- test("notifications: declining the gate leaves config.notifications undefined", async () => {
282
- const ctx = gatherContext(dir, new Map());
283
- const asker = createFakeAsker({
284
- select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
285
- confirm: {
286
- 'Add a "typecheck"': false,
287
- 'Add a "lint"': false,
288
- 'Add a "build"': false,
289
- 'Add a "test"': false,
290
- "Enable spf watch": false,
291
- "Send notifications": false,
292
- "Configure advanced": false,
293
- "Write .spf": true,
294
- },
295
- });
296
- const result = await runInterview(asker, ctx);
297
- assert.ok(result);
298
- const config = result.config;
299
- assert.equal(config.notifications, undefined);
300
- });
301
- test("notifications: accepting collects the scope, a channel, and its webhook env key", async () => {
302
- const ctx = gatherContext(dir, new Map());
303
- const asker = createFakeAsker({
304
- select: {
305
- "backend runs": "claude_code",
306
- "Model (Claude": "sonnet",
307
- Authentication: "login",
308
- "Notify on": "all",
309
- Channel: "slack",
310
- },
311
- confirm: {
312
- 'Add a "typecheck"': false,
313
- 'Add a "lint"': false,
314
- 'Add a "build"': false,
315
- 'Add a "test"': false,
316
- "Enable spf watch": false,
317
- "Send notifications": true,
318
- "Add another channel": false,
319
- "Configure advanced": false,
320
- "Write .spf": true,
321
- },
322
- secret: { SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXXX" },
323
- });
324
- const result = await runInterview(asker, ctx);
325
- assert.ok(result);
326
- const config = result.config;
327
- assert.equal(config.notifications.events, "all");
328
- assert.deepEqual(config.notifications.channels, [{ kind: "slack", webhook_url_env: "SLACK_WEBHOOK_URL" }]);
329
- assert.equal(result.env.SLACK_WEBHOOK_URL, "https://hooks.slack.com/services/T000/B000/XXXX");
330
- assert.ok(result.envExampleKeys.includes("SLACK_WEBHOOK_URL"));
331
- // The written document must still merge and validate cleanly, same
332
- // pipeline `spf doctor` runs.
333
- const configPath = mergedConfigPath();
334
- const { stringify } = await import("yaml");
335
- writeFileSync(configPath, stringify(config));
336
- const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
337
- assert.equal(cfg.notifications.events, "all");
338
- assert.equal(cfg.notifications.channels[0].kind, "slack");
339
- });
340
- test("customize models per agent: declining keeps today's behavior — only the three pinned agents, all matching defaults.model", async () => {
341
- const ctx = gatherContext(dir, new Map());
342
- const asker = createFakeAsker({
343
- select: { "backend runs": "claude_code", "Model (Claude": "opus", Authentication: "login" },
344
- confirm: {
345
- 'Add a "typecheck"': false,
346
- 'Add a "lint"': false,
347
- 'Add a "build"': false,
348
- 'Add a "test"': false,
349
- "Enable spf watch": false,
350
- "Customize models per agent": false,
351
- "Send notifications": false,
352
- "Configure advanced": false,
353
- "Write .spf": true,
354
- },
355
- });
356
- const result = await runInterview(asker, ctx);
357
- assert.ok(result);
358
- const config = result.config;
359
- assert.deepEqual(config.agents.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
360
- for (const a of config.agents)
361
- assert.equal(a.model, "opus");
362
- });
363
- test("customize models per agent: accepting patches the pinned three and appends builder/scout/refiner", async () => {
364
- const ctx = gatherContext(dir, new Map());
365
- assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "refiner", "reviewer", "scout"]);
366
- const asker = createFakeAsker({
367
- select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
368
- text: {
369
- " planner": "opus",
370
- " builder": "sonnet",
371
- " scout": "haiku",
372
- " refiner": "opus",
373
- " reviewer": "opus",
374
- " documenter": "sonnet",
375
- },
376
- confirm: {
377
- 'Add a "typecheck"': false,
378
- 'Add a "lint"': false,
379
- 'Add a "build"': false,
380
- 'Add a "test"': false,
381
- "Enable spf watch": false,
382
- "Customize models per agent": true,
383
- "Send notifications": false,
384
- "Configure advanced": false,
385
- "Write .spf": true,
386
- },
387
- });
388
- const result = await runInterview(asker, ctx);
389
- assert.ok(result);
390
- const config = result.config;
391
- const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
392
- assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku", refiner: "opus" });
393
- const configPath = mergedConfigPath();
394
- const { stringify } = await import("yaml");
395
- writeFileSync(configPath, stringify(config));
396
- const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
397
- validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
398
- assert.equal(cfg.agents.find((a) => a.name === "scout").model, "haiku");
399
- });
400
- test("an existing .env value is offered back as the default when a secret is left blank", async () => {
401
- const ctx = gatherContext(dir, new Map([["GITHUB_TOKEN", "ghp_existingvalue"]]));
402
- const asker = createFakeAsker({
403
- select: {
404
- "backend runs": "claude_code",
405
- "Model (Claude": "sonnet",
406
- Authentication: "login",
407
- "Issue tracker": "github",
408
- "Code host": "github",
409
- "Chain to run": "plan-build-test",
410
- },
411
- confirm: {
412
- 'Add a "typecheck"': false,
413
- 'Add a "lint"': false,
414
- 'Add a "build"': false,
415
- 'Add a "test"': false,
416
- "Enable spf watch": true,
417
- "Configure advanced": false,
418
- "Write .spf": true,
419
- },
420
- // no GITHUB_TOKEN entry in `secret` — the fake leaves it blank, same as a real user hitting Enter to keep the current value
421
- });
422
- const result = await runInterview(asker, ctx);
423
- assert.ok(result);
424
- assert.equal(result.env.GITHUB_TOKEN, undefined, "a blank secret answer must not overwrite the existing value with empty");
425
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,174 +0,0 @@
1
- /**
2
- * The notifier: the `events` filter matrix, per-channel scope override, a
3
- * real webhook POST against a local `node:http` receiver (not a `fetch`
4
- * mock — proves the actual bytes on the wire), a rejecting channel never
5
- * propagating, `flush()` actually awaiting pending sends, and an unset env
6
- * var degrading to a warning instead of a crash.
7
- */
8
- import { test } from "node:test";
9
- import assert from "node:assert/strict";
10
- import { createServer } from "node:http";
11
- import * as v from "valibot";
12
- import { Notifier, resolveNotifier, DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
13
- import { SFConfigSchema } from "../core/data_types.js";
14
- function baseConfig(overrides = {}) {
15
- return v.parse(SFConfigSchema, { notifications: { events: "errors", channels: [], ...overrides } });
16
- }
17
- function infoEvent() {
18
- return { kind: "run_started", level: "info", title: "run started", fields: [] };
19
- }
20
- function errorEvent() {
21
- return { kind: "run_failed", level: "error", title: "run failed", fields: [] };
22
- }
23
- class RecordingChannel {
24
- received = [];
25
- label = "recording";
26
- async send(event) {
27
- this.received.push(event);
28
- }
29
- }
30
- class RejectingChannel {
31
- label = "rejecting";
32
- async send() {
33
- throw new Error("boom");
34
- }
35
- }
36
- class BlockedChannel {
37
- label = "blocked";
38
- release;
39
- gate = new Promise((resolve) => (this.release = resolve));
40
- async send() {
41
- await this.gate;
42
- }
43
- }
44
- test("events: off never sends, regardless of level", () => {
45
- const channel = new RecordingChannel();
46
- const notifier = new Notifier([{ channel, scope: "off" }], 1000, false, () => { });
47
- notifier.send(infoEvent());
48
- notifier.send(errorEvent());
49
- assert.deepEqual(channel.received, []);
50
- });
51
- test("events: errors sends only level=error", () => {
52
- const channel = new RecordingChannel();
53
- const notifier = new Notifier([{ channel, scope: "errors" }], 1000, false, () => { });
54
- notifier.send(infoEvent());
55
- notifier.send(errorEvent());
56
- assert.equal(channel.received.length, 1);
57
- assert.equal(channel.received[0].level, "error");
58
- });
59
- test("events: all sends both info and error", () => {
60
- const channel = new RecordingChannel();
61
- const notifier = new Notifier([{ channel, scope: "all" }], 1000, false, () => { });
62
- notifier.send(infoEvent());
63
- notifier.send(errorEvent());
64
- assert.equal(channel.received.length, 2);
65
- });
66
- test("a per-channel scope overrides the top-level scope", () => {
67
- const loud = new RecordingChannel();
68
- const quiet = new RecordingChannel();
69
- const notifier = new Notifier([
70
- { channel: loud, scope: "all" },
71
- { channel: quiet, scope: "errors" },
72
- ], 1000, false, () => { });
73
- notifier.send(infoEvent());
74
- assert.equal(loud.received.length, 1);
75
- assert.equal(quiet.received.length, 0);
76
- });
77
- test("dry-run logs and sends nothing to any channel", () => {
78
- const channel = new RecordingChannel();
79
- const logs = [];
80
- const notifier = new Notifier([{ channel, scope: "all" }], 1000, true, (m) => logs.push(m));
81
- notifier.send(infoEvent());
82
- assert.deepEqual(channel.received, []);
83
- assert.equal(logs.length, 1);
84
- assert.match(logs[0], /would notify/);
85
- });
86
- test("a rejecting channel logs one line and never throws or blocks other channels", async () => {
87
- const rejecting = new RejectingChannel();
88
- const ok = new RecordingChannel();
89
- const logs = [];
90
- const notifier = new Notifier([
91
- { channel: rejecting, scope: "all" },
92
- { channel: ok, scope: "all" },
93
- ], 1000, false, (m) => logs.push(m));
94
- assert.doesNotThrow(() => notifier.send(infoEvent()));
95
- await notifier.flush();
96
- assert.equal(ok.received.length, 1);
97
- assert.equal(logs.length, 1);
98
- assert.match(logs[0], /rejecting notification failed: boom/);
99
- });
100
- test("flush() actually awaits pending sends before returning", async () => {
101
- const blocked = new BlockedChannel();
102
- const notifier = new Notifier([{ channel: blocked, scope: "all" }], 1000, false, () => { });
103
- notifier.send(infoEvent());
104
- let flushed = false;
105
- const flushPromise = notifier.flush().then(() => {
106
- flushed = true;
107
- });
108
- await new Promise((r) => setTimeout(r, 20));
109
- assert.equal(flushed, false, "flush must not resolve before the pending send does");
110
- blocked.release();
111
- await flushPromise;
112
- assert.equal(flushed, true);
113
- });
114
- test("resolveNotifier returns null when events is off", () => {
115
- const cfg = baseConfig({ events: "off", channels: [{ kind: "webhook", webhook_url_env: "SPF_TEST_WEBHOOK" }] });
116
- assert.equal(resolveNotifier(cfg), null);
117
- });
118
- test("resolveNotifier returns null when no channels are configured", () => {
119
- const cfg = baseConfig({ events: "all", channels: [] });
120
- assert.equal(resolveNotifier(cfg), null);
121
- });
122
- test("resolveNotifier skips a channel whose env var is unset, with one warning naming the key — never throws", () => {
123
- delete process.env["SPF_TEST_WEBHOOK_UNSET"];
124
- const cfg = baseConfig({ events: "all", channels: [{ kind: "webhook", webhook_url_env: "SPF_TEST_WEBHOOK_UNSET" }] });
125
- const logs = [];
126
- const notifier = resolveNotifier(cfg, { log: (m) => logs.push(m) });
127
- assert.equal(notifier, null);
128
- assert.equal(logs.length, 1);
129
- assert.match(logs[0], /SPF_TEST_WEBHOOK_UNSET is not set/);
130
- });
131
- test("resolveNotifier falls back to the kind's default env key when webhook_url_env is empty", () => {
132
- process.env["SLACK_WEBHOOK_URL"] = "http://127.0.0.1:1/unused";
133
- try {
134
- const cfg = baseConfig({ events: "all", channels: [{ kind: "slack" }] });
135
- const notifier = resolveNotifier(cfg);
136
- assert.notEqual(notifier, null);
137
- assert.equal(DEFAULT_NOTIFY_ENV_KEY["slack"], "SLACK_WEBHOOK_URL");
138
- }
139
- finally {
140
- delete process.env["SLACK_WEBHOOK_URL"];
141
- }
142
- });
143
- test("a webhook channel posts the real NotifyEvent JSON over the wire to a local receiver", async () => {
144
- let receivedBody = "";
145
- let receivedContentType = "";
146
- const server = createServer((req, res) => {
147
- receivedContentType = req.headers["content-type"] ?? "";
148
- let body = "";
149
- req.on("data", (chunk) => (body += chunk));
150
- req.on("end", () => {
151
- receivedBody = body;
152
- res.writeHead(200);
153
- res.end("ok");
154
- });
155
- });
156
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
157
- const port = server.address().port;
158
- const envKey = "SPF_TEST_WEBHOOK_LIVE";
159
- process.env[envKey] = `http://127.0.0.1:${port}/hook`;
160
- try {
161
- const cfg = baseConfig({ events: "all", channels: [{ kind: "webhook", webhook_url_env: envKey }] });
162
- const notifier = resolveNotifier(cfg);
163
- assert.notEqual(notifier, null);
164
- const event = { kind: "issue_claimed", level: "info", title: "issue 42 claimed", fields: [["issue", "42"]] };
165
- notifier.send(event);
166
- await notifier.flush();
167
- assert.equal(receivedContentType, "application/json");
168
- assert.deepEqual(JSON.parse(receivedBody), event);
169
- }
170
- finally {
171
- delete process.env[envKey];
172
- await new Promise((resolve) => server.close(() => resolve()));
173
- }
174
- });
@@ -1 +0,0 @@
1
- export {};