@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.
- package/README.md +57 -2
- package/assets/skill/cookbooks/authoring_chains.md +96 -84
- package/assets/skill/cookbooks/roster.md +3 -1
- package/assets/skill/references/config.md +52 -5
- package/assets/templates/ts.spf.config.yaml +12 -0
- package/dist/chains/context.d.ts +2 -0
- package/dist/chains/index.d.ts +21 -2
- package/dist/chains/index.js +73 -104
- package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
- package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
- package/dist/chains/steps.d.ts +117 -0
- package/dist/chains/steps.js +299 -0
- package/dist/cli/ask.d.ts +27 -0
- package/dist/cli/ask.js +125 -0
- package/dist/cli/commands/doctor.js +14 -24
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +96 -9
- package/dist/cli/commands/run.d.ts +1 -1
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/watch.js +20 -3
- package/dist/cli/env_file.d.ts +18 -0
- package/dist/cli/env_file.js +99 -0
- package/dist/cli/index.js +6 -2
- package/dist/cli/interview.d.ts +26 -0
- package/dist/cli/interview.js +417 -0
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +55 -0
- package/dist/core/data_types.js +30 -0
- package/dist/core/notify/channel.d.ts +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/prompts.d.ts +2 -0
- package/dist/core/prompts.js +2 -0
- package/dist/core/providers.d.ts +12 -0
- package/dist/core/providers.js +24 -0
- package/dist/core/quality.d.ts +9 -0
- package/dist/core/quality.js +10 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.d.ts +6 -1
- package/dist/core/session.js +10 -3
- package/dist/core/tracer.js +1 -1
- package/dist/core/utils.d.ts +6 -2
- package/dist/core/utils.js +11 -2
- package/dist/core/watch.d.ts +10 -0
- package/dist/core/watch.js +67 -4
- package/dist/test/chains.test.d.ts +12 -0
- package/dist/test/chains.test.js +86 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/env_file.test.d.ts +1 -0
- package/dist/test/env_file.test.js +74 -0
- package/dist/test/fake_asker.d.ts +23 -0
- package/dist/test/fake_asker.js +30 -0
- package/dist/test/init_command.test.d.ts +1 -0
- package/dist/test/init_command.test.js +66 -0
- package/dist/test/interview.test.d.ts +1 -0
- package/dist/test/interview.test.js +297 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/ui_server.test.js +1 -1
- package/dist/test/watch.test.js +114 -1
- package/dist/ui/shared/types.d.ts +1 -1
- package/package.json +5 -2
- package/dist/chains/adw_build.d.ts +0 -12
- package/dist/chains/adw_build.js +0 -27
- package/dist/chains/adw_build_review.d.ts +0 -21
- package/dist/chains/adw_build_review.js +0 -55
- package/dist/chains/adw_build_test.d.ts +0 -21
- package/dist/chains/adw_build_test.js +0 -67
- package/dist/chains/adw_document.d.ts +0 -23
- package/dist/chains/adw_document.js +0 -59
- package/dist/chains/adw_plan.d.ts +0 -12
- package/dist/chains/adw_plan.js +0 -27
- package/dist/chains/adw_plan_build.d.ts +0 -12
- package/dist/chains/adw_plan_build.js +0 -30
- package/dist/chains/adw_plan_build_test.d.ts +0 -16
- package/dist/chains/adw_plan_build_test.js +0 -65
- package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
- package/dist/chains/adw_plan_build_test_quality.js +0 -66
- package/dist/chains/adw_prompt.d.ts +0 -12
- package/dist/chains/adw_prompt.js +0 -25
- package/dist/chains/adw_quality.d.ts +0 -12
- package/dist/chains/adw_quality.js +0 -32
- package/dist/chains/adw_scout.d.ts +0 -12
- package/dist/chains/adw_scout.js +0 -27
|
@@ -0,0 +1,297 @@
|
|
|
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 { test, before, after } from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { runInterview, gatherContext } from "../cli/interview.js";
|
|
22
|
+
import { loadConfig, validate } from "../core/agents.js";
|
|
23
|
+
import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
|
|
24
|
+
import { createFakeAsker } from "./fake_asker.js";
|
|
25
|
+
let dir;
|
|
26
|
+
before(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), "spf-interview-test-"));
|
|
28
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
29
|
+
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/widgets.git"], { cwd: dir });
|
|
30
|
+
// -c user.*, not a global git config: this must pass in a fresh CI runner
|
|
31
|
+
// with no git identity configured at all (same reasoning as ui_server.test.ts).
|
|
32
|
+
execFileSync("git", ["-c", "user.email=test@example.com", "-c", "user.name=spf tests", "commit", "-q", "--allow-empty", "-m", "init"], { cwd: dir });
|
|
33
|
+
});
|
|
34
|
+
after(() => {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
/** 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. */
|
|
38
|
+
function mergedConfigPath() {
|
|
39
|
+
const configPath = join(dir, `spf.config.${Math.random().toString(36).slice(2)}.yaml`);
|
|
40
|
+
return configPath;
|
|
41
|
+
}
|
|
42
|
+
test("claude_code + watch(github/github): overrides the three pinned agents and merges cleanly", async () => {
|
|
43
|
+
const ctx = gatherContext(dir, new Map());
|
|
44
|
+
const asker = createFakeAsker({
|
|
45
|
+
select: {
|
|
46
|
+
"backend runs": "claude_code",
|
|
47
|
+
"Model (Claude": "sonnet",
|
|
48
|
+
Authentication: "login",
|
|
49
|
+
"Issue tracker": "github",
|
|
50
|
+
"Code host": "github",
|
|
51
|
+
"Chain to run": "plan-build-test",
|
|
52
|
+
},
|
|
53
|
+
confirm: {
|
|
54
|
+
'Add a "typecheck"': false,
|
|
55
|
+
'Add a "lint"': false,
|
|
56
|
+
'Add a "build"': false,
|
|
57
|
+
'Add a "test"': false,
|
|
58
|
+
"Enable spf watch": true,
|
|
59
|
+
"Configure advanced": false,
|
|
60
|
+
"Write .spf": true,
|
|
61
|
+
},
|
|
62
|
+
secret: { GITHUB_TOKEN: "ghp_abcdefghijklmnop" },
|
|
63
|
+
});
|
|
64
|
+
const result = await runInterview(asker, ctx);
|
|
65
|
+
assert.ok(result);
|
|
66
|
+
const config = result.config;
|
|
67
|
+
assert.equal(config.defaults.coding_agent, "claude_code");
|
|
68
|
+
assert.equal(config.defaults.model, "sonnet");
|
|
69
|
+
const overrides = config.agents;
|
|
70
|
+
assert.deepEqual(overrides.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
|
|
71
|
+
for (const a of overrides)
|
|
72
|
+
assert.equal(a.model, "sonnet");
|
|
73
|
+
assert.equal(config.watch.repo, "acme/widgets"); // detected from origin
|
|
74
|
+
assert.equal(result.env.GITHUB_TOKEN, "ghp_abcdefghijklmnop");
|
|
75
|
+
assert.ok(result.envExampleKeys.includes("GITHUB_TOKEN"));
|
|
76
|
+
const configPath = mergedConfigPath();
|
|
77
|
+
const { stringify } = await import("yaml");
|
|
78
|
+
writeFileSync(configPath, stringify(config));
|
|
79
|
+
const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
|
|
80
|
+
assert.equal(cfg.defaults.coding_agent, "claude_code");
|
|
81
|
+
assert.equal(cfg.agents.find((a) => a.name === "planner").model, "sonnet");
|
|
82
|
+
assert.equal(cfg.agents.find((a) => a.name === "builder").model, "sonnet"); // inherits defaults.model, needed no override
|
|
83
|
+
validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
|
|
84
|
+
});
|
|
85
|
+
test("flue + openrouter: no pinned-agent overrides needed, provider key collected", async () => {
|
|
86
|
+
const ctx = gatherContext(dir, new Map());
|
|
87
|
+
const asker = createFakeAsker({
|
|
88
|
+
select: { "backend runs": "flue", Provider: "openrouter" },
|
|
89
|
+
text: { "Model id": "moonshotai/kimi-k2.7" },
|
|
90
|
+
confirm: {
|
|
91
|
+
'Add a "typecheck"': false,
|
|
92
|
+
'Add a "lint"': false,
|
|
93
|
+
'Add a "build"': false,
|
|
94
|
+
'Add a "test"': false,
|
|
95
|
+
"Enable spf watch": false,
|
|
96
|
+
"Configure advanced": false,
|
|
97
|
+
"Write .spf": true,
|
|
98
|
+
},
|
|
99
|
+
secret: { OPENROUTER_API_KEY: "sk-or-v1-test" },
|
|
100
|
+
});
|
|
101
|
+
const result = await runInterview(asker, ctx);
|
|
102
|
+
assert.ok(result);
|
|
103
|
+
const config = result.config;
|
|
104
|
+
assert.equal(config.defaults.coding_agent, "flue");
|
|
105
|
+
assert.equal(config.defaults.model, "openrouter/moonshotai/kimi-k2.7");
|
|
106
|
+
assert.equal(config.agents, undefined, "flue never needs the pinned-roster fix — agents' own models are already valid Flue ids");
|
|
107
|
+
assert.equal(result.env.OPENROUTER_API_KEY, "sk-or-v1-test");
|
|
108
|
+
assert.ok(!("watch" in config));
|
|
109
|
+
});
|
|
110
|
+
test("watch(jira/bitbucket): collects exactly JIRA_* + BITBUCKET_*, never GITHUB_TOKEN", async () => {
|
|
111
|
+
const ctx = gatherContext(dir, new Map());
|
|
112
|
+
const asker = createFakeAsker({
|
|
113
|
+
select: {
|
|
114
|
+
"backend runs": "claude_code",
|
|
115
|
+
"Model (Claude": "sonnet",
|
|
116
|
+
Authentication: "login",
|
|
117
|
+
"Issue tracker": "jira",
|
|
118
|
+
"Code host": "bitbucket",
|
|
119
|
+
"Chain to run": "plan-build-test",
|
|
120
|
+
},
|
|
121
|
+
text: {
|
|
122
|
+
"Repo (workspace/repo_slug)": "acme/widgets-repo",
|
|
123
|
+
"Jira base URL": "https://acme.atlassian.net/",
|
|
124
|
+
"Jira project key": "proj",
|
|
125
|
+
},
|
|
126
|
+
confirm: {
|
|
127
|
+
'Add a "typecheck"': false,
|
|
128
|
+
'Add a "lint"': false,
|
|
129
|
+
'Add a "build"': false,
|
|
130
|
+
'Add a "test"': false,
|
|
131
|
+
"Enable spf watch": true,
|
|
132
|
+
"Configure advanced": false,
|
|
133
|
+
"Write .spf": true,
|
|
134
|
+
},
|
|
135
|
+
secret: { JIRA_API_TOKEN: "jira-token", BITBUCKET_API_TOKEN: "bb-token" },
|
|
136
|
+
});
|
|
137
|
+
const result = await runInterview(asker, ctx);
|
|
138
|
+
assert.ok(result);
|
|
139
|
+
const config = result.config;
|
|
140
|
+
assert.equal(config.watch.issue_provider, "jira");
|
|
141
|
+
assert.equal(config.watch.code_host, "bitbucket");
|
|
142
|
+
assert.equal(config.watch.jira.base_url, "https://acme.atlassian.net"); // trailing slash stripped
|
|
143
|
+
assert.equal(config.watch.jira.project_key, "PROJ"); // upper-cased
|
|
144
|
+
assert.equal(result.env.JIRA_API_TOKEN, "jira-token");
|
|
145
|
+
assert.equal(result.env.BITBUCKET_API_TOKEN, "bb-token");
|
|
146
|
+
assert.equal(result.env.GITHUB_TOKEN, undefined, "neither provider needs GitHub — must not ask for or write GITHUB_TOKEN");
|
|
147
|
+
});
|
|
148
|
+
test("declining the final confirm returns null — nothing to write", async () => {
|
|
149
|
+
const ctx = gatherContext(dir, new Map());
|
|
150
|
+
const asker = createFakeAsker({ defaultConfirm: false }); // every confirm, including the final one, says no
|
|
151
|
+
const result = await runInterview(asker, ctx);
|
|
152
|
+
assert.equal(result, null);
|
|
153
|
+
});
|
|
154
|
+
test("notifications: declining the gate leaves config.notifications undefined", async () => {
|
|
155
|
+
const ctx = gatherContext(dir, new Map());
|
|
156
|
+
const asker = createFakeAsker({
|
|
157
|
+
select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
|
|
158
|
+
confirm: {
|
|
159
|
+
'Add a "typecheck"': false,
|
|
160
|
+
'Add a "lint"': false,
|
|
161
|
+
'Add a "build"': false,
|
|
162
|
+
'Add a "test"': false,
|
|
163
|
+
"Enable spf watch": false,
|
|
164
|
+
"Send notifications": false,
|
|
165
|
+
"Configure advanced": false,
|
|
166
|
+
"Write .spf": true,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const result = await runInterview(asker, ctx);
|
|
170
|
+
assert.ok(result);
|
|
171
|
+
const config = result.config;
|
|
172
|
+
assert.equal(config.notifications, undefined);
|
|
173
|
+
});
|
|
174
|
+
test("notifications: accepting collects the scope, a channel, and its webhook env key", async () => {
|
|
175
|
+
const ctx = gatherContext(dir, new Map());
|
|
176
|
+
const asker = createFakeAsker({
|
|
177
|
+
select: {
|
|
178
|
+
"backend runs": "claude_code",
|
|
179
|
+
"Model (Claude": "sonnet",
|
|
180
|
+
Authentication: "login",
|
|
181
|
+
"Notify on": "all",
|
|
182
|
+
Channel: "slack",
|
|
183
|
+
},
|
|
184
|
+
confirm: {
|
|
185
|
+
'Add a "typecheck"': false,
|
|
186
|
+
'Add a "lint"': false,
|
|
187
|
+
'Add a "build"': false,
|
|
188
|
+
'Add a "test"': false,
|
|
189
|
+
"Enable spf watch": false,
|
|
190
|
+
"Send notifications": true,
|
|
191
|
+
"Add another channel": false,
|
|
192
|
+
"Configure advanced": false,
|
|
193
|
+
"Write .spf": true,
|
|
194
|
+
},
|
|
195
|
+
secret: { SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXXX" },
|
|
196
|
+
});
|
|
197
|
+
const result = await runInterview(asker, ctx);
|
|
198
|
+
assert.ok(result);
|
|
199
|
+
const config = result.config;
|
|
200
|
+
assert.equal(config.notifications.events, "all");
|
|
201
|
+
assert.deepEqual(config.notifications.channels, [{ kind: "slack", webhook_url_env: "SLACK_WEBHOOK_URL" }]);
|
|
202
|
+
assert.equal(result.env.SLACK_WEBHOOK_URL, "https://hooks.slack.com/services/T000/B000/XXXX");
|
|
203
|
+
assert.ok(result.envExampleKeys.includes("SLACK_WEBHOOK_URL"));
|
|
204
|
+
// The written document must still merge and validate cleanly, same
|
|
205
|
+
// pipeline `spf doctor` runs.
|
|
206
|
+
const configPath = mergedConfigPath();
|
|
207
|
+
const { stringify } = await import("yaml");
|
|
208
|
+
writeFileSync(configPath, stringify(config));
|
|
209
|
+
const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
|
|
210
|
+
assert.equal(cfg.notifications.events, "all");
|
|
211
|
+
assert.equal(cfg.notifications.channels[0].kind, "slack");
|
|
212
|
+
});
|
|
213
|
+
test("customize models per agent: declining keeps today's behavior — only the three pinned agents, all matching defaults.model", async () => {
|
|
214
|
+
const ctx = gatherContext(dir, new Map());
|
|
215
|
+
const asker = createFakeAsker({
|
|
216
|
+
select: { "backend runs": "claude_code", "Model (Claude": "opus", Authentication: "login" },
|
|
217
|
+
confirm: {
|
|
218
|
+
'Add a "typecheck"': false,
|
|
219
|
+
'Add a "lint"': false,
|
|
220
|
+
'Add a "build"': false,
|
|
221
|
+
'Add a "test"': false,
|
|
222
|
+
"Enable spf watch": false,
|
|
223
|
+
"Customize models per agent": false,
|
|
224
|
+
"Send notifications": false,
|
|
225
|
+
"Configure advanced": false,
|
|
226
|
+
"Write .spf": true,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const result = await runInterview(asker, ctx);
|
|
230
|
+
assert.ok(result);
|
|
231
|
+
const config = result.config;
|
|
232
|
+
assert.deepEqual(config.agents.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
|
|
233
|
+
for (const a of config.agents)
|
|
234
|
+
assert.equal(a.model, "opus");
|
|
235
|
+
});
|
|
236
|
+
test("customize models per agent: accepting patches the pinned three and appends builder/scout", async () => {
|
|
237
|
+
const ctx = gatherContext(dir, new Map());
|
|
238
|
+
assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "reviewer", "scout"]);
|
|
239
|
+
const asker = createFakeAsker({
|
|
240
|
+
select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
|
|
241
|
+
text: {
|
|
242
|
+
" planner": "opus",
|
|
243
|
+
" builder": "sonnet",
|
|
244
|
+
" scout": "haiku",
|
|
245
|
+
" reviewer": "opus",
|
|
246
|
+
" documenter": "sonnet",
|
|
247
|
+
},
|
|
248
|
+
confirm: {
|
|
249
|
+
'Add a "typecheck"': false,
|
|
250
|
+
'Add a "lint"': false,
|
|
251
|
+
'Add a "build"': false,
|
|
252
|
+
'Add a "test"': false,
|
|
253
|
+
"Enable spf watch": false,
|
|
254
|
+
"Customize models per agent": true,
|
|
255
|
+
"Send notifications": false,
|
|
256
|
+
"Configure advanced": false,
|
|
257
|
+
"Write .spf": true,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
const result = await runInterview(asker, ctx);
|
|
261
|
+
assert.ok(result);
|
|
262
|
+
const config = result.config;
|
|
263
|
+
const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
|
|
264
|
+
assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku" });
|
|
265
|
+
const configPath = mergedConfigPath();
|
|
266
|
+
const { stringify } = await import("yaml");
|
|
267
|
+
writeFileSync(configPath, stringify(config));
|
|
268
|
+
const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
|
|
269
|
+
validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
|
|
270
|
+
assert.equal(cfg.agents.find((a) => a.name === "scout").model, "haiku");
|
|
271
|
+
});
|
|
272
|
+
test("an existing .env value is offered back as the default when a secret is left blank", async () => {
|
|
273
|
+
const ctx = gatherContext(dir, new Map([["GITHUB_TOKEN", "ghp_existingvalue"]]));
|
|
274
|
+
const asker = createFakeAsker({
|
|
275
|
+
select: {
|
|
276
|
+
"backend runs": "claude_code",
|
|
277
|
+
"Model (Claude": "sonnet",
|
|
278
|
+
Authentication: "login",
|
|
279
|
+
"Issue tracker": "github",
|
|
280
|
+
"Code host": "github",
|
|
281
|
+
"Chain to run": "plan-build-test",
|
|
282
|
+
},
|
|
283
|
+
confirm: {
|
|
284
|
+
'Add a "typecheck"': false,
|
|
285
|
+
'Add a "lint"': false,
|
|
286
|
+
'Add a "build"': false,
|
|
287
|
+
'Add a "test"': false,
|
|
288
|
+
"Enable spf watch": true,
|
|
289
|
+
"Configure advanced": false,
|
|
290
|
+
"Write .spf": true,
|
|
291
|
+
},
|
|
292
|
+
// no GITHUB_TOKEN entry in `secret` — the fake leaves it blank, same as a real user hitting Enter to keep the current value
|
|
293
|
+
});
|
|
294
|
+
const result = await runInterview(asker, ctx);
|
|
295
|
+
assert.ok(result);
|
|
296
|
+
assert.equal(result.env.GITHUB_TOKEN, undefined, "a blank secret answer must not overwrite the existing value with empty");
|
|
297
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
});
|
|
@@ -25,7 +25,7 @@ before(async () => {
|
|
|
25
25
|
execFileSync("git", ["-c", "user.email=test@example.com", "-c", "user.name=spf tests", "commit", "-q", "--allow-empty", "-m", "init"], { cwd: dir });
|
|
26
26
|
const dbPath = join(dir, "spf.db");
|
|
27
27
|
const tracer = new Tracer(dbPath, join(dir, "sessions", ADW_ID, "events.jsonl"));
|
|
28
|
-
tracer.sessionStart(ADW_ID, "tester", "
|
|
28
|
+
tracer.sessionStart(ADW_ID, "tester", "quality");
|
|
29
29
|
const phase = {
|
|
30
30
|
phase_id: `${ADW_ID}_01_request`,
|
|
31
31
|
adw_id: ADW_ID,
|
package/dist/test/watch.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
|
|
4
|
+
import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans, tick } from "../core/watch.js";
|
|
5
5
|
/** In-memory fake — exactly the seam `provider.ts` exists for. */
|
|
6
6
|
class FakeProvider {
|
|
7
7
|
entries = new Map();
|
|
@@ -96,9 +96,15 @@ function makeDeps(provider, codeHost, overrides = {}) {
|
|
|
96
96
|
dryRun: false,
|
|
97
97
|
runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
|
|
98
98
|
log: () => { },
|
|
99
|
+
notify: () => { },
|
|
99
100
|
...overrides,
|
|
100
101
|
};
|
|
101
102
|
}
|
|
103
|
+
/** Collects every `deps.notify(...)` call, for asserting kinds/levels without a real channel. */
|
|
104
|
+
function collectNotifications() {
|
|
105
|
+
const events = [];
|
|
106
|
+
return { events, notify: (event) => events.push(event) };
|
|
107
|
+
}
|
|
102
108
|
async function waitUntil(predicate, timeoutMs = 2000) {
|
|
103
109
|
const start = Date.now();
|
|
104
110
|
while (!predicate()) {
|
|
@@ -281,3 +287,110 @@ test("finishReviews: a still-open PR leaves the issue in review", async () => {
|
|
|
281
287
|
assert.equal(provider.transitions.length, 0);
|
|
282
288
|
assert.equal(provider.entries.get("42").state, "review");
|
|
283
289
|
});
|
|
290
|
+
// ── notifications ────────────────────────────────────────────────────────────
|
|
291
|
+
test("claim -> PR opened -> merged fires issue_claimed, pr_opened, then issue_done (all info-level)", async () => {
|
|
292
|
+
const provider = new FakeProvider();
|
|
293
|
+
provider.addIssue("50", "Add a /health endpoint");
|
|
294
|
+
const codeHost = new FakeCodeHost();
|
|
295
|
+
const state = createWatchState();
|
|
296
|
+
const { events, notify } = collectNotifications();
|
|
297
|
+
const deps = makeDeps(provider, codeHost, { notify });
|
|
298
|
+
await claimNewWork(deps, state);
|
|
299
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
300
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened"]);
|
|
301
|
+
assert.ok(events.every((e) => e.level === "info"));
|
|
302
|
+
codeHost.prs.set(1000, { merged: true, state: "closed", ciStatus: "success" });
|
|
303
|
+
await finishReviews(deps);
|
|
304
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened", "issue_done"]);
|
|
305
|
+
assert.equal(events.at(-1).level, "info");
|
|
306
|
+
});
|
|
307
|
+
test("a rejected chain run fires issue_blocked at error level", async () => {
|
|
308
|
+
const provider = new FakeProvider();
|
|
309
|
+
provider.addIssue("51", "Flaky feature");
|
|
310
|
+
const codeHost = new FakeCodeHost();
|
|
311
|
+
const state = createWatchState();
|
|
312
|
+
const { events, notify } = collectNotifications();
|
|
313
|
+
const deps = makeDeps(provider, codeHost, {
|
|
314
|
+
notify,
|
|
315
|
+
runChain: async () => ({ accepted: false, adwId: "issue-51", detail: "build-test failed" }),
|
|
316
|
+
});
|
|
317
|
+
await claimNewWork(deps, state);
|
|
318
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
319
|
+
const blocked = events.filter((e) => e.kind === "issue_blocked");
|
|
320
|
+
assert.equal(blocked.length, 1);
|
|
321
|
+
assert.equal(blocked[0].level, "error");
|
|
322
|
+
assert.equal(blocked[0].detail, "build-test failed");
|
|
323
|
+
});
|
|
324
|
+
test("an accepted run with nothing committed fires issue_blocked", async () => {
|
|
325
|
+
const provider = new FakeProvider();
|
|
326
|
+
provider.addIssue("52", "No-op request");
|
|
327
|
+
const codeHost = new FakeCodeHost();
|
|
328
|
+
const state = createWatchState();
|
|
329
|
+
const { events, notify } = collectNotifications();
|
|
330
|
+
const deps = makeDeps(provider, codeHost, { notify, worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
|
|
331
|
+
await claimNewWork(deps, state);
|
|
332
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
333
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "issue_blocked"]);
|
|
334
|
+
assert.match(events[1].detail ?? "", /no committed changes/);
|
|
335
|
+
});
|
|
336
|
+
test("an exception mid-runIssue fires watch_error at error level", async () => {
|
|
337
|
+
const provider = new FakeProvider();
|
|
338
|
+
provider.addIssue("53", "Explodes");
|
|
339
|
+
const codeHost = new FakeCodeHost();
|
|
340
|
+
const state = createWatchState();
|
|
341
|
+
const { events, notify } = collectNotifications();
|
|
342
|
+
const deps = makeDeps(provider, codeHost, {
|
|
343
|
+
notify,
|
|
344
|
+
runChain: async () => {
|
|
345
|
+
throw new Error("kaboom");
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
await claimNewWork(deps, state);
|
|
349
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
350
|
+
const errors = events.filter((e) => e.kind === "watch_error");
|
|
351
|
+
assert.equal(errors.length, 1);
|
|
352
|
+
assert.equal(errors[0].level, "error");
|
|
353
|
+
assert.match(errors[0].detail ?? "", /kaboom/);
|
|
354
|
+
});
|
|
355
|
+
test("finishReviews: a closed-without-merging PR fires issue_blocked", async () => {
|
|
356
|
+
const provider = new FakeProvider();
|
|
357
|
+
provider.addIssue("54", "rejected", "review", { pr: 700, branch: "spf-watch/54-x" });
|
|
358
|
+
const codeHost = new FakeCodeHost();
|
|
359
|
+
codeHost.prs.set(700, { merged: false, state: "closed", ciStatus: "failure" });
|
|
360
|
+
const { events, notify } = collectNotifications();
|
|
361
|
+
await finishReviews(makeDeps(provider, codeHost, { notify }));
|
|
362
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
|
|
363
|
+
assert.equal(events[0].level, "error");
|
|
364
|
+
});
|
|
365
|
+
test("reconcileOrphans: giving up past the retry cap fires issue_blocked", async () => {
|
|
366
|
+
const provider = new FakeProvider();
|
|
367
|
+
provider.addIssue("55", "orphaned, no marker", "working", { attempt: 2 }); // one more push exceeds MAX_ORPHAN_ATTEMPTS (2)
|
|
368
|
+
const codeHost = new FakeCodeHost();
|
|
369
|
+
const state = createWatchState();
|
|
370
|
+
const { events, notify } = collectNotifications();
|
|
371
|
+
await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
|
|
372
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
|
|
373
|
+
assert.match(events[0].detail ?? "", /Gave up after 2 orphaned attempts/);
|
|
374
|
+
});
|
|
375
|
+
test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only the terminal give-up does", async () => {
|
|
376
|
+
const provider = new FakeProvider();
|
|
377
|
+
provider.addIssue("56", "orphaned, resumable", "working", { pr: 800, branch: "spf-watch/56-x" });
|
|
378
|
+
const codeHost = new FakeCodeHost();
|
|
379
|
+
codeHost.prs.set(800, { merged: false, state: "open", ciStatus: "pending" });
|
|
380
|
+
const state = createWatchState();
|
|
381
|
+
const { events, notify } = collectNotifications();
|
|
382
|
+
await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
|
|
383
|
+
assert.deepEqual(events, []);
|
|
384
|
+
});
|
|
385
|
+
test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
|
|
386
|
+
const provider = new FakeProvider();
|
|
387
|
+
const codeHost = new FakeCodeHost();
|
|
388
|
+
const state = createWatchState();
|
|
389
|
+
const { events, notify } = collectNotifications();
|
|
390
|
+
provider.listEligible = async () => {
|
|
391
|
+
throw new Error("listEligible exploded");
|
|
392
|
+
};
|
|
393
|
+
await tick(makeDeps(provider, codeHost, { notify }), state);
|
|
394
|
+
assert.deepEqual(events.map((e) => e.kind), ["watch_error"]);
|
|
395
|
+
assert.match(events[0].detail ?? "", /listEligible exploded/);
|
|
396
|
+
});
|
|
@@ -15,7 +15,7 @@ export type PhaseKind = "engineer" | "code" | "agent";
|
|
|
15
15
|
export type EventType = "phase_start" | "phase_end" | "agent_start" | "agent_end" | "tool_call" | "handoff" | "gate_pass" | "gate_fail" | "log" | "error";
|
|
16
16
|
export interface Session {
|
|
17
17
|
adw_id: string;
|
|
18
|
-
/**
|
|
18
|
+
/** Chain(s) that ran this session, e.g. "plan + build-test". */
|
|
19
19
|
adw_name: string | null;
|
|
20
20
|
request: string | null;
|
|
21
21
|
status: SessionStatus | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gr8ful/spf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
"pretest": "npm run build:cli",
|
|
39
39
|
"test": "node --test \"dist/**/*.test.js\"",
|
|
40
40
|
"prepack": "node scripts/check-pack-ready.js",
|
|
41
|
-
"size-check": "node scripts/check-tarball-size.js"
|
|
41
|
+
"size-check": "node scripts/check-tarball-size.js",
|
|
42
|
+
"tag:major": "node scripts/tag-release.js major",
|
|
43
|
+
"tag:minor": "node scripts/tag-release.js minor",
|
|
44
|
+
"tag:patch": "node scripts/tag-release.js patch"
|
|
42
45
|
},
|
|
43
46
|
"dependencies": {
|
|
44
47
|
"@flue/runtime": "2.0.3",
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ADW Build — one-shot implementation workflow.
|
|
3
|
-
*
|
|
4
|
-
* Usage:
|
|
5
|
-
* spf build "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4]
|
|
6
|
-
*
|
|
7
|
-
* Phases: engineer(request) -> builder
|
|
8
|
-
*/
|
|
9
|
-
import type { ChainContext } from "./context.ts";
|
|
10
|
-
export declare const REQUIRED_AGENTS: string[];
|
|
11
|
-
export declare const REQUIRED_SUITES: string[];
|
|
12
|
-
export declare function main(ctx: ChainContext): Promise<number>;
|