@gr8ful/spf 0.1.7 → 0.2.1

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 (70) hide show
  1. package/README.md +7 -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 +5 -2
  5. package/dist/chains/context.d.ts +2 -0
  6. package/dist/chains/index.d.ts +21 -2
  7. package/dist/chains/index.js +73 -104
  8. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  9. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  10. package/dist/chains/steps.d.ts +117 -0
  11. package/dist/chains/steps.js +299 -0
  12. package/dist/cli/ask.d.ts +27 -0
  13. package/dist/cli/ask.js +125 -0
  14. package/dist/cli/commands/doctor.js +2 -24
  15. package/dist/cli/commands/init.d.ts +1 -1
  16. package/dist/cli/commands/init.js +82 -9
  17. package/dist/cli/commands/run.d.ts +1 -1
  18. package/dist/cli/commands/run.js +3 -1
  19. package/dist/cli/commands/watch.js +3 -3
  20. package/dist/cli/env_file.d.ts +18 -0
  21. package/dist/cli/env_file.js +99 -0
  22. package/dist/cli/index.js +2 -2
  23. package/dist/cli/interview.d.ts +24 -0
  24. package/dist/cli/interview.js +330 -0
  25. package/dist/core/prompts.d.ts +2 -0
  26. package/dist/core/prompts.js +2 -0
  27. package/dist/core/providers.d.ts +12 -0
  28. package/dist/core/providers.js +24 -0
  29. package/dist/core/quality.d.ts +9 -0
  30. package/dist/core/quality.js +10 -0
  31. package/dist/core/session.d.ts +6 -1
  32. package/dist/core/session.js +7 -3
  33. package/dist/core/tracer.js +1 -1
  34. package/dist/core/utils.d.ts +6 -2
  35. package/dist/core/utils.js +11 -2
  36. package/dist/test/chains.test.d.ts +12 -0
  37. package/dist/test/chains.test.js +86 -0
  38. package/dist/test/env_file.test.d.ts +1 -0
  39. package/dist/test/env_file.test.js +74 -0
  40. package/dist/test/fake_asker.d.ts +23 -0
  41. package/dist/test/fake_asker.js +30 -0
  42. package/dist/test/init_command.test.d.ts +1 -0
  43. package/dist/test/init_command.test.js +66 -0
  44. package/dist/test/interview.test.d.ts +1 -0
  45. package/dist/test/interview.test.js +179 -0
  46. package/dist/test/ui_server.test.js +1 -1
  47. package/dist/ui/shared/types.d.ts +1 -1
  48. package/package.json +5 -2
  49. package/dist/chains/adw_build.d.ts +0 -12
  50. package/dist/chains/adw_build.js +0 -27
  51. package/dist/chains/adw_build_review.d.ts +0 -21
  52. package/dist/chains/adw_build_review.js +0 -55
  53. package/dist/chains/adw_build_test.d.ts +0 -21
  54. package/dist/chains/adw_build_test.js +0 -67
  55. package/dist/chains/adw_document.d.ts +0 -23
  56. package/dist/chains/adw_document.js +0 -59
  57. package/dist/chains/adw_plan.d.ts +0 -12
  58. package/dist/chains/adw_plan.js +0 -27
  59. package/dist/chains/adw_plan_build.d.ts +0 -12
  60. package/dist/chains/adw_plan_build.js +0 -30
  61. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  62. package/dist/chains/adw_plan_build_test.js +0 -65
  63. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  64. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  65. package/dist/chains/adw_prompt.d.ts +0 -12
  66. package/dist/chains/adw_prompt.js +0 -25
  67. package/dist/chains/adw_quality.d.ts +0 -12
  68. package/dist/chains/adw_quality.js +0 -32
  69. package/dist/chains/adw_scout.d.ts +0 -12
  70. package/dist/chains/adw_scout.js +0 -27
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `.env` upsert (`cli/env_file.ts`) — the write-side counterpart of
3
+ * `process.loadEnvFile()` in `cli/index.ts`, which every command already
4
+ * uses to load `<repo_root>/.env`. Mirrors `ensureGitignore`'s idempotent
5
+ * shape: preserve everything unrelated, touch only the keys asked for.
6
+ */
7
+ import { test, beforeEach, afterEach } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { maskSecret, readEnvFile, upsertEnvFile, writeEnvExample } from "../cli/env_file.js";
13
+ let dir;
14
+ // Each test gets its own scratch directory with no .env yet — cheap, and
15
+ // avoids one test's leftover .env leaking into the next.
16
+ beforeEach(() => {
17
+ dir = mkdtempSync(join(tmpdir(), "spf-env-file-test-"));
18
+ });
19
+ afterEach(() => {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ });
22
+ test("upsertEnvFile creates a fresh .env with the given keys", () => {
23
+ const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
24
+ assert.deepEqual(result, { added: ["GITHUB_TOKEN"], updated: [], unchanged: [] });
25
+ assert.equal(readEnvFile(join(dir, ".env")).get("GITHUB_TOKEN"), "ghp_abc");
26
+ });
27
+ test("upsertEnvFile preserves unrelated lines and comments verbatim, appends new keys under its own header", () => {
28
+ writeFileSync(join(dir, ".env"), "# a hand-written comment\nFOO=bar\n\nBAZ=qux\n");
29
+ upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
30
+ const raw = readFileSync(join(dir, ".env"), "utf-8");
31
+ assert.match(raw, /# a hand-written comment/);
32
+ assert.match(raw, /FOO=bar/);
33
+ assert.match(raw, /BAZ=qux/);
34
+ assert.match(raw, /GITHUB_TOKEN=ghp_abc/);
35
+ });
36
+ test("upsertEnvFile rewrites an existing key in place — never a duplicate line", () => {
37
+ writeFileSync(join(dir, ".env"), "GITHUB_TOKEN=ghp_old\nOTHER=1\n");
38
+ const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_new" });
39
+ assert.deepEqual(result, { added: [], updated: ["GITHUB_TOKEN"], unchanged: [] });
40
+ const raw = readFileSync(join(dir, ".env"), "utf-8");
41
+ assert.equal((raw.match(/GITHUB_TOKEN=/g) || []).length, 1);
42
+ assert.match(raw, /GITHUB_TOKEN=ghp_new/);
43
+ assert.match(raw, /OTHER=1/);
44
+ });
45
+ test("upsertEnvFile is a no-op the second time with the same values", () => {
46
+ upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
47
+ const before = readFileSync(join(dir, ".env"), "utf-8");
48
+ const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
49
+ assert.deepEqual(result, { added: [], updated: [], unchanged: ["GITHUB_TOKEN"] });
50
+ assert.equal(readFileSync(join(dir, ".env"), "utf-8"), before);
51
+ });
52
+ test("readEnvFile ignores blank lines and comments, and returns an empty map for a missing file", () => {
53
+ writeFileSync(join(dir, ".env"), "# comment\n\nA=1\n # indented comment\nB=2\n");
54
+ const values = readEnvFile(join(dir, ".env"));
55
+ assert.deepEqual([...values.entries()], [["A", "1"], ["B", "2"]]);
56
+ assert.equal(readEnvFile(join(dir, "nope.env")).size, 0);
57
+ });
58
+ test("maskSecret keeps only the last 4 characters; short values are fully masked", () => {
59
+ const value = "ghp_1234567890abcdef"; // 20 chars, last 4 = "cdef"
60
+ const masked = maskSecret(value);
61
+ assert.equal(masked.length, value.length);
62
+ assert.match(masked, /^•+cdef$/);
63
+ assert.equal(maskSecret("abcd"), "••••");
64
+ assert.equal(maskSecret("ab"), "••");
65
+ });
66
+ test("writeEnvExample writes key names with empty values, sorted and de-duplicated", () => {
67
+ writeEnvExample(dir, ["GITHUB_TOKEN", "JIRA_EMAIL", "GITHUB_TOKEN"]);
68
+ const raw = readFileSync(join(dir, ".env.example"), "utf-8");
69
+ assert.equal(raw, "# spf — keys required by this repo's spf.config.yaml; fill in real values in .env (gitignored)\nGITHUB_TOKEN=\nJIRA_EMAIL=\n");
70
+ });
71
+ test("writeEnvExample writes nothing when there are no keys", () => {
72
+ writeEnvExample(dir, []);
73
+ assert.equal(existsSync(join(dir, ".env.example")), false);
74
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared test double for `cli/ask.ts`'s `Asker` interface — the seam
3
+ * `runInterview` (`cli/interview.ts`) exists to be driven through, the same
4
+ * way `FakeProvider`/`FakeCodeHost` (`watch.test.ts`) stand in for
5
+ * `IssueProvider`/`CodeHostProvider`.
6
+ *
7
+ * Answers are keyed by a SUBSTRING of the question's label, not by call
8
+ * order — `runInterview` asks a different number of questions depending on
9
+ * earlier answers (skip a quality check, skip watch entirely, ...), so a
10
+ * positional queue would silently misalign the moment a test changes one
11
+ * answer. Substring matching keeps each scenario's script self-documenting
12
+ * and immune to that.
13
+ */
14
+ import type { Asker } from "../cli/ask.js";
15
+ export interface AskerScript {
16
+ text?: Record<string, string>;
17
+ select?: Record<string, string>;
18
+ confirm?: Record<string, boolean>;
19
+ secret?: Record<string, string>;
20
+ /** Used when a confirm's label matches nothing in `confirm` above. Defaults to the caller's own default. */
21
+ defaultConfirm?: boolean;
22
+ }
23
+ export declare function createFakeAsker(script: AskerScript): Asker;
@@ -0,0 +1,30 @@
1
+ function firstMatch(map, label) {
2
+ if (!map)
3
+ return undefined;
4
+ const key = Object.keys(map).find((k) => label.includes(k));
5
+ return key === undefined ? undefined : map[key];
6
+ }
7
+ export function createFakeAsker(script) {
8
+ return {
9
+ async text(label, opts) {
10
+ const answer = firstMatch(script.text, label);
11
+ return answer !== undefined ? answer : (opts?.default ?? "");
12
+ },
13
+ async select(label, _choices, dflt) {
14
+ const answer = firstMatch(script.select, label);
15
+ return (answer !== undefined ? answer : dflt);
16
+ },
17
+ async confirm(label, dflt) {
18
+ const answer = firstMatch(script.confirm, label);
19
+ if (answer !== undefined)
20
+ return answer;
21
+ return script.defaultConfirm ?? dflt;
22
+ },
23
+ async secret(label, _opts) {
24
+ return firstMatch(script.secret, label) ?? "";
25
+ },
26
+ note() { },
27
+ heading() { },
28
+ close() { },
29
+ };
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Regression guard for `spf init`'s non-interactive paths — `--yes`,
3
+ * `--template <name>`, and (implicitly, since the test runner's stdin/stdout
4
+ * are never a TTY) plain `spf init` with no flags. All three must keep
5
+ * writing the exact same content they always have, byte for byte, and must
6
+ * never touch stdin — a scripted `spf init` in CI must not hang.
7
+ *
8
+ * The interactive interview itself is covered directly against `Asker`
9
+ * in `interview.test.ts`; `initCommand`'s interactive branch always calls
10
+ * the real `createAsker()` (backed by `node:readline` on `process.stdin`),
11
+ * which has nothing to read in a test process — exercising it here would
12
+ * just hang, so it isn't.
13
+ */
14
+ import { test, beforeEach, afterEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { execFileSync } from "node:child_process";
20
+ import { initCommand } from "../cli/commands/init.js";
21
+ let dir;
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), "spf-init-test-"));
24
+ execFileSync("git", ["init", "-q"], { cwd: dir });
25
+ });
26
+ afterEach(() => {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ });
29
+ test("--yes writes the plain starter config, all commented out, with no interview", async () => {
30
+ const code = await initCommand(["--cwd", dir, "--yes"]);
31
+ assert.equal(code, 0);
32
+ const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
33
+ assert.match(content, /merged ON TOP of spf's packaged built-in defaults/);
34
+ assert.match(content, /^# quality:/m); // every real section stays commented out
35
+ assert.doesNotMatch(content, /^defaults:/m);
36
+ });
37
+ test("plain `spf init` (no TTY in a test process) falls through to the same starter config as --yes", async () => {
38
+ const code = await initCommand(["--cwd", dir]);
39
+ assert.equal(code, 0);
40
+ const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
41
+ assert.match(content, /merged ON TOP of spf's packaged built-in defaults/);
42
+ });
43
+ test("--template writes the named template as-is, skipping the interview even without --yes", async () => {
44
+ const code = await initCommand(["--cwd", dir, "--template", "ts-cc"]);
45
+ assert.equal(code, 0);
46
+ const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
47
+ assert.match(content, /Claude Code backend, pointed at Ollama/);
48
+ });
49
+ test("an existing config is left alone without --force, even for --yes", async () => {
50
+ await initCommand(["--cwd", dir, "--yes"]);
51
+ const before = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
52
+ const code = await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force
53
+ assert.equal(code, 0);
54
+ assert.equal(readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8"), before);
55
+ });
56
+ test("--force overwrites an existing config non-interactively", async () => {
57
+ await initCommand(["--cwd", dir, "--yes"]);
58
+ await initCommand(["--cwd", dir, "--template", "ts-cc", "--force"]);
59
+ const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
60
+ assert.match(content, /Claude Code backend, pointed at Ollama/);
61
+ });
62
+ test("never writes .env or .env.example on the non-interactive paths", async () => {
63
+ await initCommand(["--cwd", dir, "--yes"]);
64
+ assert.equal(existsSync(join(dir, ".env")), false);
65
+ assert.equal(existsSync(join(dir, ".env.example")), false);
66
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,179 @@
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("an existing .env value is offered back as the default when a secret is left blank", async () => {
155
+ const ctx = gatherContext(dir, new Map([["GITHUB_TOKEN", "ghp_existingvalue"]]));
156
+ const asker = createFakeAsker({
157
+ select: {
158
+ "backend runs": "claude_code",
159
+ "Model (Claude": "sonnet",
160
+ Authentication: "login",
161
+ "Issue tracker": "github",
162
+ "Code host": "github",
163
+ "Chain to run": "plan-build-test",
164
+ },
165
+ confirm: {
166
+ 'Add a "typecheck"': false,
167
+ 'Add a "lint"': false,
168
+ 'Add a "build"': false,
169
+ 'Add a "test"': false,
170
+ "Enable spf watch": true,
171
+ "Configure advanced": false,
172
+ "Write .spf": true,
173
+ },
174
+ // no GITHUB_TOKEN entry in `secret` — the fake leaves it blank, same as a real user hitting Enter to keep the current value
175
+ });
176
+ const result = await runInterview(asker, ctx);
177
+ assert.ok(result);
178
+ assert.equal(result.env.GITHUB_TOKEN, undefined, "a blank secret answer must not overwrite the existing value with empty");
179
+ });
@@ -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", "adw_quality");
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,
@@ -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
- /** ADW script(s) that ran this session, e.g. "adw_plan + adw_build_test". */
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.1.7",
3
+ "version": "0.2.1",
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>;
@@ -1,27 +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 * as agents from "../core/agents.js";
10
- import * as gates from "../core/gates.js";
11
- import * as session from "../core/session.js";
12
- import { BuildOutput, makeAgentCall, makePhaseParams } from "../core/data_types.js";
13
- export const REQUIRED_AGENTS = ["builder"];
14
- export const REQUIRED_SUITES = [];
15
- export async function main(ctx) {
16
- const { prompt, config_paths, adw_id, cwd } = ctx;
17
- const cfg = agents.loadConfig(config_paths);
18
- agents.validate(cfg, REQUIRED_AGENTS, REQUIRED_SUITES, cwd);
19
- const run = session.ensure(cfg, adw_id, cwd);
20
- await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }), async (ph) => {
21
- ph.log({ input: prompt });
22
- });
23
- await run.phase(makePhaseParams({ name: "build", kind: "agent", owner: "builder", retries: 1, description: "Implement the request" }), async (ph) => {
24
- await ph.call(makeAgentCall({ output_type: BuildOutput, prompt, gates: [gates.diffMatchesClaims] }));
25
- });
26
- return run.finish();
27
- }
@@ -1,21 +0,0 @@
1
- /**
2
- * ADW Build Review — implement, then confirm it is what was asked for.
3
- *
4
- * Usage:
5
- * spf build-review "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4]
6
- *
7
- * Phases: engineer(request) -> builder -> reviewer [-> builder(revise) -> reviewer ... bounded]
8
- *
9
- * Review is not testing. Tests answer "does it run"; the reviewer answers "is this
10
- * the thing that was asked for" — it reads the spec (`plan.md` from a prior plan
11
- * phase if the session has one, else the prompt verbatim), reads the code that was
12
- * written, and rules on each requirement.
13
- *
14
- * Like the tester, the reviewer's phase succeeds when it RUNS and REPORTS. A
15
- * rejection does not fail the phase; it fails the run, checked at the end, after
16
- * the bounded revise loop has had its chances.
17
- */
18
- import type { ChainContext } from "./context.ts";
19
- export declare const REQUIRED_AGENTS: string[];
20
- export declare const REQUIRED_SUITES: string[];
21
- export declare function main(ctx: ChainContext): Promise<number>;
@@ -1,55 +0,0 @@
1
- /**
2
- * ADW Build Review — implement, then confirm it is what was asked for.
3
- *
4
- * Usage:
5
- * spf build-review "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4]
6
- *
7
- * Phases: engineer(request) -> builder -> reviewer [-> builder(revise) -> reviewer ... bounded]
8
- *
9
- * Review is not testing. Tests answer "does it run"; the reviewer answers "is this
10
- * the thing that was asked for" — it reads the spec (`plan.md` from a prior plan
11
- * phase if the session has one, else the prompt verbatim), reads the code that was
12
- * written, and rules on each requirement.
13
- *
14
- * Like the tester, the reviewer's phase succeeds when it RUNS and REPORTS. A
15
- * rejection does not fail the phase; it fails the run, checked at the end, after
16
- * the bounded revise loop has had its chances.
17
- */
18
- import * as agents from "../core/agents.js";
19
- import * as gates from "../core/gates.js";
20
- import * as session from "../core/session.js";
21
- import { BuildOutput, ReviewOutput, makeAgentCall, makePhaseParams } from "../core/data_types.js";
22
- export const REQUIRED_AGENTS = ["builder", "reviewer"];
23
- export const REQUIRED_SUITES = [];
24
- const MAX_REVISION_LOOPS = 3;
25
- export async function main(ctx) {
26
- const { prompt, config_paths, adw_id, cwd } = ctx;
27
- const cfg = agents.loadConfig(config_paths);
28
- agents.validate(cfg, REQUIRED_AGENTS, REQUIRED_SUITES, cwd);
29
- const run = session.ensure(cfg, adw_id, cwd);
30
- await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }), async (ph) => {
31
- ph.log({ input: prompt });
32
- });
33
- let previous = await run.phase(makePhaseParams({ name: "build", kind: "agent", owner: "builder", description: "Implement the request" }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, gates: [gates.diffMatchesClaims] })));
34
- let review = null;
35
- for (let i = 1; i <= MAX_REVISION_LOOPS; i++) {
36
- review = await run.phase(makePhaseParams({
37
- name: `review_${i}`,
38
- kind: "agent",
39
- owner: "reviewer",
40
- description: "Rule on every requirement in the spec, against the code on disk",
41
- }), (ph) => ph.call(makeAgentCall({ output_type: ReviewOutput, prompt, previous, gates: [gates.artifactsExist, gates.verdictConsistent] })));
42
- if (review.approved)
43
- break;
44
- if (i === MAX_REVISION_LOOPS)
45
- break;
46
- previous = await run.phase(makePhaseParams({
47
- name: `revise_${i}`,
48
- kind: "agent",
49
- owner: "builder",
50
- retries: 1,
51
- description: "Close every blocking finding the reviewer named",
52
- }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, previous: review, gates: [gates.diffMatchesClaims] })));
53
- }
54
- return run.finish(review !== null && review.approved, `the reviewer never approved after ${MAX_REVISION_LOOPS} revision(s)`);
55
- }
@@ -1,21 +0,0 @@
1
- /**
2
- * ADW Build Test — implement, then verify; failures flow back into the builder.
3
- *
4
- * Usage:
5
- * spf build-test "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4]
6
- *
7
- * Phases: engineer(request) -> builder -> code(test) [-> builder(fix) -> code(test) ... bounded]
8
- *
9
- * Testing is CODE. The suite's command is written down in core/quality.ts,
10
- * so running it needs no judgement — only repairing it does. Failures reach the
11
- * builder as an envelope through `quality.asEnvelope`, which is the same door an
12
- * agent's report came through, so the repair loop is unchanged.
13
- *
14
- * A failing suite does NOT fail its phase: the runner did its job, the code is
15
- * what failed. It fails the run, checked at the end, after the bounded fix loop
16
- * has had its chances.
17
- */
18
- import type { ChainContext } from "./context.ts";
19
- export declare const REQUIRED_AGENTS: string[];
20
- export declare const REQUIRED_SUITES: string[];
21
- export declare function main(ctx: ChainContext): Promise<number>;
@@ -1,67 +0,0 @@
1
- /**
2
- * ADW Build Test — implement, then verify; failures flow back into the builder.
3
- *
4
- * Usage:
5
- * spf build-test "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4]
6
- *
7
- * Phases: engineer(request) -> builder -> code(test) [-> builder(fix) -> code(test) ... bounded]
8
- *
9
- * Testing is CODE. The suite's command is written down in core/quality.ts,
10
- * so running it needs no judgement — only repairing it does. Failures reach the
11
- * builder as an envelope through `quality.asEnvelope`, which is the same door an
12
- * agent's report came through, so the repair loop is unchanged.
13
- *
14
- * A failing suite does NOT fail its phase: the runner did its job, the code is
15
- * what failed. It fails the run, checked at the end, after the bounded fix loop
16
- * has had its chances.
17
- */
18
- import * as agents from "../core/agents.js";
19
- import * as gates from "../core/gates.js";
20
- import * as quality from "../core/quality.js";
21
- import * as session from "../core/session.js";
22
- import { BuildOutput, makeAgentCall, makePhaseParams } from "../core/data_types.js";
23
- export const REQUIRED_AGENTS = ["builder"];
24
- export const REQUIRED_SUITES = ["test"];
25
- const MAX_FIX_LOOPS = 3;
26
- function record(ph, result) {
27
- const passed = result.checks.filter((c) => c.passed).length;
28
- ph.log({ passed: result.passed, checks: `${passed}/${result.checks.length}`, artifacts: result.artifacts.join(", ") });
29
- }
30
- export async function main(ctx) {
31
- const { prompt, config_paths, adw_id, cwd } = ctx;
32
- const cfg = agents.loadConfig(config_paths);
33
- agents.validate(cfg, REQUIRED_AGENTS, REQUIRED_SUITES, cwd);
34
- const run = session.ensure(cfg, adw_id, cwd);
35
- await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }), async (ph) => {
36
- ph.log({ input: prompt });
37
- });
38
- await run.phase(makePhaseParams({ name: "build", kind: "agent", owner: "builder", description: "Implement the request" }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, gates: [gates.diffMatchesClaims] })));
39
- let test = null;
40
- for (let i = 1; i <= MAX_FIX_LOOPS; i++) {
41
- test = await run.phase(makePhaseParams({
42
- name: `test_${i}`,
43
- kind: "code",
44
- owner: "quality",
45
- description: "Run the suite — a known command, so code runs it and no agent has to rediscover it",
46
- }), async (ph) => {
47
- const result = quality.runTests(run);
48
- record(ph, result);
49
- return result;
50
- });
51
- if (test.passed)
52
- break;
53
- await run.phase(makePhaseParams({
54
- name: `fix_${i}`,
55
- kind: "agent",
56
- owner: "builder",
57
- retries: 1,
58
- description: "Repair what the suite reported, from its verbatim output",
59
- }), (ph) => ph.call(makeAgentCall({
60
- output_type: BuildOutput,
61
- prompt,
62
- previous: quality.asEnvelope(test, "tests"),
63
- gates: [gates.diffMatchesClaims],
64
- })));
65
- }
66
- return run.finish(test !== null && test.passed, `the suite still failed after ${MAX_FIX_LOOPS} fix attempt(s)`);
67
- }