@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.
- package/README.md +7 -2
- package/assets/skill/cookbooks/authoring_chains.md +96 -84
- package/assets/skill/cookbooks/roster.md +3 -1
- package/assets/skill/references/config.md +5 -2
- package/assets/templates/ts-cc.spf.config.yaml +16 -0
- package/assets/templates/ts-flue-openrouter.spf.config.yaml +15 -0
- package/assets/templates/ts.spf.config.yaml +19 -4
- 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 +2 -24
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +92 -9
- package/dist/cli/commands/run.d.ts +1 -1
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/watch.js +26 -4
- package/dist/cli/env_file.d.ts +18 -0
- package/dist/cli/env_file.js +99 -0
- package/dist/cli/index.js +2 -2
- package/dist/cli/interview.d.ts +24 -0
- package/dist/cli/interview.js +330 -0
- package/dist/core/issues/jira_provider.d.ts +9 -0
- package/dist/core/issues/jira_provider.js +20 -2
- 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/session.d.ts +6 -1
- package/dist/core/session.js +7 -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 +12 -0
- package/dist/core/watch.js +11 -0
- package/dist/test/chains.test.d.ts +12 -0
- package/dist/test/chains.test.js +86 -0
- 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 +179 -0
- package/dist/test/ui_server.test.js +1 -1
- package/dist/test/watch.test.js +41 -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,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", "
|
|
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,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
|
|
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
|
-
/**
|
|
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
|
|
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>;
|
package/dist/chains/adw_build.js
DELETED
|
@@ -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>;
|