@gr8ful/spf 0.1.6 → 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 (78) 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/assets/templates/ts-cc.spf.config.yaml +16 -0
  6. package/assets/templates/ts-flue-openrouter.spf.config.yaml +15 -0
  7. package/assets/templates/ts.spf.config.yaml +19 -4
  8. package/dist/chains/context.d.ts +2 -0
  9. package/dist/chains/index.d.ts +21 -2
  10. package/dist/chains/index.js +73 -104
  11. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  12. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  13. package/dist/chains/steps.d.ts +117 -0
  14. package/dist/chains/steps.js +299 -0
  15. package/dist/cli/ask.d.ts +27 -0
  16. package/dist/cli/ask.js +125 -0
  17. package/dist/cli/commands/doctor.js +2 -24
  18. package/dist/cli/commands/init.d.ts +1 -1
  19. package/dist/cli/commands/init.js +92 -9
  20. package/dist/cli/commands/run.d.ts +1 -1
  21. package/dist/cli/commands/run.js +3 -1
  22. package/dist/cli/commands/watch.js +26 -4
  23. package/dist/cli/env_file.d.ts +18 -0
  24. package/dist/cli/env_file.js +99 -0
  25. package/dist/cli/index.js +2 -2
  26. package/dist/cli/interview.d.ts +24 -0
  27. package/dist/cli/interview.js +330 -0
  28. package/dist/core/issues/jira_provider.d.ts +9 -0
  29. package/dist/core/issues/jira_provider.js +20 -2
  30. package/dist/core/prompts.d.ts +2 -0
  31. package/dist/core/prompts.js +2 -0
  32. package/dist/core/providers.d.ts +12 -0
  33. package/dist/core/providers.js +24 -0
  34. package/dist/core/quality.d.ts +9 -0
  35. package/dist/core/quality.js +10 -0
  36. package/dist/core/session.d.ts +6 -1
  37. package/dist/core/session.js +7 -3
  38. package/dist/core/tracer.js +1 -1
  39. package/dist/core/utils.d.ts +6 -2
  40. package/dist/core/utils.js +11 -2
  41. package/dist/core/watch.d.ts +12 -0
  42. package/dist/core/watch.js +11 -0
  43. package/dist/test/chains.test.d.ts +12 -0
  44. package/dist/test/chains.test.js +86 -0
  45. package/dist/test/env_file.test.d.ts +1 -0
  46. package/dist/test/env_file.test.js +74 -0
  47. package/dist/test/fake_asker.d.ts +23 -0
  48. package/dist/test/fake_asker.js +30 -0
  49. package/dist/test/init_command.test.d.ts +1 -0
  50. package/dist/test/init_command.test.js +66 -0
  51. package/dist/test/interview.test.d.ts +1 -0
  52. package/dist/test/interview.test.js +179 -0
  53. package/dist/test/ui_server.test.js +1 -1
  54. package/dist/test/watch.test.js +41 -1
  55. package/dist/ui/shared/types.d.ts +1 -1
  56. package/package.json +5 -2
  57. package/dist/chains/adw_build.d.ts +0 -12
  58. package/dist/chains/adw_build.js +0 -27
  59. package/dist/chains/adw_build_review.d.ts +0 -21
  60. package/dist/chains/adw_build_review.js +0 -55
  61. package/dist/chains/adw_build_test.d.ts +0 -21
  62. package/dist/chains/adw_build_test.js +0 -67
  63. package/dist/chains/adw_document.d.ts +0 -23
  64. package/dist/chains/adw_document.js +0 -59
  65. package/dist/chains/adw_plan.d.ts +0 -12
  66. package/dist/chains/adw_plan.js +0 -27
  67. package/dist/chains/adw_plan_build.d.ts +0 -12
  68. package/dist/chains/adw_plan_build.js +0 -30
  69. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  70. package/dist/chains/adw_plan_build_test.js +0 -65
  71. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  72. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  73. package/dist/chains/adw_prompt.d.ts +0 -12
  74. package/dist/chains/adw_prompt.js +0 -25
  75. package/dist/chains/adw_quality.d.ts +0 -12
  76. package/dist/chains/adw_quality.js +0 -32
  77. package/dist/chains/adw_scout.d.ts +0 -12
  78. package/dist/chains/adw_scout.js +0 -27
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Regression net for the chain registry. Chains used to each be a hand-written
3
+ * module; now every one but `simple-sdlc` is a `steps` list whose
4
+ * `phases`/`requiredAgents`/`requiredSuites` are DERIVED (see
5
+ * `chains/steps.ts`'s `derivePhases`/`deriveRequiredAgents`/
6
+ * `deriveRequiredSuites`). These tests pin what that derivation produces
7
+ * today, so a step-factory change that silently alters a chain's shape (an
8
+ * agent dropped from `requiredAgents`, a suite no longer required, a phase
9
+ * missing from the display string) fails here instead of only showing up as
10
+ * a `spf list` diff nobody happened to read.
11
+ */
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
15
+ // name -> [phases, requiredAgents (with no options), requiredSuites]
16
+ const EXPECTED = {
17
+ prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
18
+ scout: { phases: "engineer(request) -> scout", agents: ["scout"], suites: [] },
19
+ plan: { phases: "engineer(request) -> planner", agents: ["planner"], suites: [] },
20
+ build: { phases: "engineer(request) -> builder", agents: ["builder"], suites: [] },
21
+ "plan-build": { phases: "engineer(request) -> planner -> builder -> git(commit)", agents: ["planner", "builder"], suites: [] },
22
+ "build-test": {
23
+ phases: "engineer(request) -> builder -> code(test) [-> builder(fix) -> code(test) ...] bounded",
24
+ agents: ["builder"],
25
+ suites: ["test"],
26
+ },
27
+ "plan-build-test": {
28
+ phases: "engineer(request) -> planner -> builder -> code(test) [-> builder(fix) -> code(test) ...] bounded -> git(commit)",
29
+ agents: ["planner", "builder"],
30
+ suites: ["test"],
31
+ },
32
+ "plan-build-test-quality": {
33
+ phases: "engineer(request) -> planner -> builder -> code(verify) [-> builder(fix) -> code(verify) ...] bounded -> git(commit)",
34
+ agents: ["planner", "builder"],
35
+ suites: ["all"],
36
+ },
37
+ "build-review": {
38
+ phases: "engineer(request) -> builder -> reviewer [-> builder(revise) -> reviewer ...] bounded",
39
+ agents: ["builder", "reviewer"],
40
+ suites: [],
41
+ },
42
+ quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
43
+ document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
44
+ "simple-sdlc": {
45
+ phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
46
+ "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
47
+ "-> code(changes) -> documenter -> git(commit_docs)",
48
+ agents: ["planner", "builder", "reviewer", "documenter"],
49
+ suites: ["test"],
50
+ },
51
+ };
52
+ test("every chain in the registry has an expectation pinned here", () => {
53
+ const names = CHAINS.map((c) => c.name).sort();
54
+ assert.deepEqual(names, Object.keys(EXPECTED).sort(), "a chain was added/removed/renamed without updating this test");
55
+ });
56
+ for (const chain of CHAINS) {
57
+ const expected = EXPECTED[chain.name];
58
+ test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
59
+ assert.equal(chain.phases, expected.phases);
60
+ assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
61
+ assert.deepEqual(chain.requiredSuites, expected.suites);
62
+ });
63
+ }
64
+ test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
65
+ const chain = findChain("prompt");
66
+ assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
67
+ assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
68
+ });
69
+ test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
70
+ for (const chain of CHAINS) {
71
+ if (chain.name === "simple-sdlc") {
72
+ assert.ok(chain.run, "simple-sdlc should still be the one hand-written chain");
73
+ assert.equal(chain.steps, undefined);
74
+ }
75
+ else {
76
+ assert.ok(chain.steps && chain.steps.length > 0, `${chain.name} should be a steps list`);
77
+ assert.equal(chain.run, undefined, `${chain.name} should not also define run()`);
78
+ }
79
+ }
80
+ });
81
+ test("findChain resolves every registered name and nothing else", () => {
82
+ for (const chain of CHAINS) {
83
+ assert.equal(findChain(chain.name)?.name, chain.name);
84
+ }
85
+ assert.equal(findChain("not-a-real-chain"), undefined);
86
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -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,
@@ -1,5 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import path from "node:path";
3
4
  import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
4
5
  /** In-memory fake — exactly the seam `provider.ts` exists for. */
5
6
  class FakeProvider {
@@ -91,6 +92,7 @@ function makeDeps(provider, codeHost, overrides = {}) {
91
92
  baseBranch: "main",
92
93
  concurrency: 2,
93
94
  worktreesDir: "/tmp/spf-watch-test-worktrees",
95
+ linkDataDir: () => { },
94
96
  dryRun: false,
95
97
  runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
96
98
  log: () => { },
@@ -110,12 +112,45 @@ test("branchNameFor: sanitizes a title into a safe branch name", () => {
110
112
  assert.equal(branchNameFor({ id: "7", title: "", body: "", labels: [] }), "spf-watch/7-issue");
111
113
  assert.equal(branchNameFor({ id: "PROJ-123", title: "Fix the thing", body: "", labels: [] }), "spf-watch/PROJ-123-fix-the-thing");
112
114
  });
115
+ test("claimNewWork: clears a stale worktree/branch from a killed prior attempt before creating a fresh one", async () => {
116
+ const provider = new FakeProvider();
117
+ provider.addIssue("2", "Retry me");
118
+ const codeHost = new FakeCodeHost();
119
+ const state = createWatchState();
120
+ const calls = [];
121
+ // Simulates a `spf watch` process killed mid-run for this exact issue: a
122
+ // real `git.worktreeAdd` would refuse outright with "fatal: a branch
123
+ // named '...' already exists" until the leftover branch is cleared.
124
+ let branchExists = true;
125
+ const deps = makeDeps(provider, codeHost, {
126
+ git: fakeGit({
127
+ worktreeRemove: (p) => calls.push(`remove:${p}`),
128
+ deleteLocalBranch: (n) => {
129
+ calls.push(`delete-branch:${n}`);
130
+ branchExists = false;
131
+ },
132
+ worktreeAdd: (p, b) => {
133
+ if (branchExists)
134
+ throw new Error(`fatal: a branch named '${b}' already exists`);
135
+ calls.push(`add:${p}`);
136
+ },
137
+ }),
138
+ });
139
+ await claimNewWork(deps, state);
140
+ await waitUntil(() => state.inflight.size === 0);
141
+ const branchIdx = calls.findIndex((c) => c.startsWith("delete-branch:"));
142
+ const addIdx = calls.findIndex((c) => c.startsWith("add:"));
143
+ assert.ok(branchIdx !== -1 && addIdx !== -1 && branchIdx < addIdx, `expected the stale branch cleared before worktreeAdd, got: ${calls.join(", ")}`);
144
+ // The retry then succeeds normally, same as any other claim.
145
+ assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
146
+ });
113
147
  test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
114
148
  const provider = new FakeProvider();
115
149
  provider.addIssue("1", "Add a /health endpoint");
116
150
  const codeHost = new FakeCodeHost();
117
151
  const state = createWatchState();
118
- const deps = makeDeps(provider, codeHost);
152
+ const linkedWorktrees = [];
153
+ const deps = makeDeps(provider, codeHost, { linkDataDir: (worktreePath) => linkedWorktrees.push(worktreePath) });
119
154
  await claimNewWork(deps, state);
120
155
  await waitUntil(() => state.inflight.size === 0);
121
156
  assert.deepEqual(provider.claimCalls, ["1"]);
@@ -123,6 +158,11 @@ test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves
123
158
  assert.match(codeHost.openedPrs[0].title, /^Add a \/health endpoint \(1\)/);
124
159
  assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
125
160
  assert.equal(provider.entries.get("1").marker?.pr, 1000);
161
+ // linkDataDir must run before the chain does — otherwise the run's own
162
+ // session/trace data resolves into the worktree's throwaway .spf/data
163
+ // instead of the main repo's persistent one (invisible in spf ui, and
164
+ // deleted along with the worktree on cleanup).
165
+ assert.deepEqual(linkedWorktrees, [path.join(deps.worktreesDir, "issue-1")]);
126
166
  });
127
167
  test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
128
168
  const provider = new FakeProvider();
@@ -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.6",
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>;