@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,330 @@
1
+ /**
2
+ * The question flow behind `spf init`'s interactive interview. No filesystem
3
+ * writes happen here — `commands/init.ts` owns those, so this module stays
4
+ * unit-testable against a scripted `Asker` (see `src/test/interview.test.ts`)
5
+ * without touching disk beyond the read-only `DetectedContext` gather.
6
+ *
7
+ * Every section below mirrors an existing, already-shipped code path rather
8
+ * than inventing new vocabulary:
9
+ * - coding agent -> src/core/agent_cc.ts / agent_flue.ts, src/core/providers.ts
10
+ * - quality checks -> src/core/data_types.ts's QualityCheckSpecSchema
11
+ * - watch ticket loop -> src/cli/commands/watch.ts's resolveIssueProvider/resolveCodeHostProvider
12
+ * - the pinned-roster fix -> assets/templates/ts-cc.spf.config.yaml, done by hand today
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { spawnSync } from "node:child_process";
16
+ import path from "node:path";
17
+ import { binaryOnPath } from "../core/utils.js";
18
+ import { PROVIDER_ENV_KEYS } from "../core/providers.js";
19
+ import { resolveModel } from "../core/agent_flue.js";
20
+ import { ThinkingLevelSchema } from "../core/data_types.js";
21
+ import { CHAINS } from "../chains/index.js";
22
+ function gitConfigValue(repoRoot, key) {
23
+ const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
24
+ const value = result.status === 0 ? result.stdout.trim() : "";
25
+ return value || undefined;
26
+ }
27
+ /** Parses the common GitHub/Bitbucket remote URL shapes (https and ssh) into "owner/name". Best-effort — an unparseable or missing remote just means no default to offer. */
28
+ function parseRemoteSlug(url) {
29
+ if (!url)
30
+ return undefined;
31
+ const match = /(?:[/:])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url.trim());
32
+ return match ? match[1] : undefined;
33
+ }
34
+ function readScripts(repoRoot) {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf-8"));
37
+ return pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ export function gatherContext(repoRoot, existingEnv = new Map()) {
44
+ const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: repoRoot, encoding: "utf-8" });
45
+ const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot, encoding: "utf-8" });
46
+ return {
47
+ repoSlug: parseRemoteSlug(remote.status === 0 ? remote.stdout.trim() : undefined),
48
+ currentBranch: branch.status === 0 && branch.stdout.trim() !== "HEAD" ? branch.stdout.trim() : "main",
49
+ gitEmail: gitConfigValue(repoRoot, "user.email"),
50
+ gitName: gitConfigValue(repoRoot, "user.name"),
51
+ scripts: readScripts(repoRoot),
52
+ claudeOnPath: binaryOnPath("claude"),
53
+ existingEnv,
54
+ };
55
+ }
56
+ const QUALITY_TIMEOUTS = { typecheck: 60, lint: 60, build: 120, test: 180 };
57
+ function splitArgv(command) {
58
+ return command.trim().split(/\s+/).filter(Boolean);
59
+ }
60
+ /**
61
+ * Runs the interview and returns the config/env to write, or `null` if the
62
+ * user declines the final confirmation. Throws `InterviewAborted` (from
63
+ * `./ask.ts`) on Ctrl-C/EOF — the caller decides the exit code for that.
64
+ */
65
+ export async function runInterview(asker, ctx) {
66
+ const defaults = {};
67
+ const agentOverrides = [];
68
+ const env = {};
69
+ const envExampleKeys = [];
70
+ const notes = [];
71
+ // ── 1. coding agent ────────────────────────────────────────────────────────
72
+ asker.heading("Coding agent");
73
+ const codingAgent = await asker.select("Which backend runs each agent?", [
74
+ { value: "claude_code", label: "claude_code — shells out to the `claude` CLI you already use", hint: "cc" },
75
+ { value: "flue", label: "flue — in-process, provider/model-id (openai, anthropic, openrouter, ...)" },
76
+ ], "claude_code");
77
+ defaults.coding_agent = codingAgent;
78
+ if (codingAgent === "claude_code") {
79
+ if (!ctx.claudeOnPath) {
80
+ asker.note("warning: `claude` was not found on PATH — install it (or set a launch command below) before running spf.");
81
+ }
82
+ const launchCommand = await asker.text("Launch command for the `claude` CLI (space-separated; SPF_CLAUDE_CMD)", { default: "claude" });
83
+ if (launchCommand !== "claude")
84
+ env["SPF_CLAUDE_CMD"] = launchCommand;
85
+ const model = await asker.select("Model (Claude Code's own vocabulary — not provider/model-id)", [
86
+ { value: "sonnet", label: "sonnet" },
87
+ { value: "opus", label: "opus" },
88
+ { value: "haiku", label: "haiku" },
89
+ { value: "custom", label: "custom — type an exact model name" },
90
+ ], "sonnet");
91
+ defaults.model = model === "custom" ? await asker.text("Exact model name") : model;
92
+ const auth = await asker.select("Authentication", [
93
+ { value: "login", label: "already logged in via `claude login`", hint: "writes nothing" },
94
+ { value: "key", label: "ANTHROPIC_API_KEY" },
95
+ { value: "endpoint", label: "custom endpoint (Ollama / Ollama Cloud / a proxy)" },
96
+ ], "login");
97
+ if (auth === "key") {
98
+ const current = ctx.existingEnv.get("ANTHROPIC_API_KEY");
99
+ const key = await asker.secret("ANTHROPIC_API_KEY", { current });
100
+ if (key)
101
+ env["ANTHROPIC_API_KEY"] = key;
102
+ envExampleKeys.push("ANTHROPIC_API_KEY");
103
+ }
104
+ else if (auth === "endpoint") {
105
+ const baseUrl = await asker.text("ANTHROPIC_BASE_URL", { default: "http://localhost:11434" });
106
+ env["ANTHROPIC_BASE_URL"] = baseUrl;
107
+ const token = await asker.secret("ANTHROPIC_AUTH_TOKEN", { current: ctx.existingEnv.get("ANTHROPIC_AUTH_TOKEN") });
108
+ env["ANTHROPIC_AUTH_TOKEN"] = token || "ollama";
109
+ envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN");
110
+ asker.note("spf doctor does not check these two — confirm the endpoint is reachable yourself.");
111
+ }
112
+ // The packaged roster pins planner/reviewer/documenter to their own
113
+ // Flue-style provider/model-id strings, which always win over
114
+ // defaults.model (agents.ts's back-fill only applies when an agent
115
+ // doesn't already set its own `model`). Left alone, switching the
116
+ // backend to claude_code sends those three straight to the `claude`
117
+ // CLI's `--model` flag as e.g. "fireworks/accounts/.../kimi-k3", which
118
+ // it cannot resolve. Override all three to the same model chosen above
119
+ // — exactly what assets/templates/ts-cc.spf.config.yaml does by hand.
120
+ for (const name of ["planner", "reviewer", "documenter"]) {
121
+ agentOverrides.push({ name, model: defaults.model });
122
+ }
123
+ notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to match, since claude_code can't resolve their packaged Flue-style ids.");
124
+ }
125
+ else {
126
+ const provider = await asker.select("Provider", Object.keys(PROVIDER_ENV_KEYS).map((p) => ({ value: p, label: p })), "anthropic");
127
+ const modelId = await asker.text(`Model id (after "${provider}/")`, {
128
+ default: provider === "anthropic" ? "claude-sonnet-4-6" : "",
129
+ validate: (val) => (val.trim() ? null : "required"),
130
+ });
131
+ const model = `${provider}/${modelId}`;
132
+ try {
133
+ resolveModel(model);
134
+ }
135
+ catch (error) {
136
+ asker.note(`warning: ${error.message}`);
137
+ }
138
+ defaults.model = model;
139
+ const envKey = PROVIDER_ENV_KEYS[provider][0];
140
+ const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
141
+ if (key)
142
+ env[envKey] = key;
143
+ envExampleKeys.push(envKey);
144
+ }
145
+ // ── 2. quality checks ──────────────────────────────────────────────────────
146
+ asker.heading("Quality checks");
147
+ asker.note("a chain that gates on a suite (e.g. plan-build-test's `test`) fails loudly before anything runs if the suite is unconfigured.");
148
+ const checks = [];
149
+ const configuredNames = [];
150
+ for (const [checkName, operation, scriptGuess] of [
151
+ ["typecheck", "typecheck", "typecheck"],
152
+ ["lint", "lint", "lint"],
153
+ ["build", "build", "build"],
154
+ ["test", "build", "test"],
155
+ ]) {
156
+ const detectedScript = ctx.scripts[scriptGuess] ? `npm run ${scriptGuess}` : "";
157
+ const wantIt = await asker.confirm(`Add a "${checkName}" check?`, Boolean(detectedScript));
158
+ if (!wantIt)
159
+ continue;
160
+ const command = await asker.text(` ${checkName} command`, { default: detectedScript || `npm run ${scriptGuess}` });
161
+ checks.push({ name: checkName, operation, argv: splitArgv(command), timeout_seconds: QUALITY_TIMEOUTS[checkName] });
162
+ configuredNames.push(checkName);
163
+ }
164
+ if (checks.length > 0) {
165
+ const suites = { all: configuredNames };
166
+ if (configuredNames.includes("test"))
167
+ suites.test = ["test"];
168
+ defaults["__quality__"] = { checks, suites };
169
+ }
170
+ // ── 3. watch ticket loop ────────────────────────────────────────────────────
171
+ asker.heading("Watch ticket loop (spf watch)");
172
+ const enableWatch = await asker.confirm("Enable spf watch (poll a tracker, run a chain per issue, open a PR)?", false);
173
+ let watch = null;
174
+ if (enableWatch) {
175
+ const issueProvider = await asker.select("Issue tracker", [
176
+ { value: "github", label: "GitHub Issues" },
177
+ { value: "jira", label: "Jira" },
178
+ ], "github");
179
+ const codeHost = await asker.select("Code host (where PRs open)", [
180
+ { value: "github", label: "GitHub" },
181
+ { value: "bitbucket", label: "Bitbucket" },
182
+ ], "github");
183
+ const repoHint = codeHost === "bitbucket" ? "workspace/repo_slug" : "owner/name";
184
+ const repo = await asker.text(`Repo (${repoHint})`, {
185
+ default: codeHost === "github" ? ctx.repoSlug ?? "" : "",
186
+ validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "${repoHint}"`),
187
+ });
188
+ const labelPrefix = await asker.text("Label prefix", { default: "spf" });
189
+ const baseBranch = await asker.text("Base branch", { default: ctx.currentBranch });
190
+ const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
191
+ const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
192
+ watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
193
+ if (issueProvider === "jira") {
194
+ const baseUrl = await asker.text("Jira base URL", {
195
+ default: "https://your-domain.atlassian.net",
196
+ validate: (val) => (/^https:\/\/.+/.test(val) ? null : "must start with https://"),
197
+ });
198
+ const projectKey = await asker.text("Jira project key", { validate: (val) => (val.trim() ? null : "required") });
199
+ watch.jira = { base_url: baseUrl.replace(/\/+$/, ""), project_key: projectKey.toUpperCase() };
200
+ }
201
+ const needsGithub = issueProvider === "github" || codeHost === "github";
202
+ if (needsGithub) {
203
+ asker.note('classic PAT, "repo" scope (private) or "public_repo" (public-only) — never "project".');
204
+ const token = await asker.secret("GITHUB_TOKEN", { current: ctx.existingEnv.get("GITHUB_TOKEN") });
205
+ if (token)
206
+ env["GITHUB_TOKEN"] = token;
207
+ envExampleKeys.push("GITHUB_TOKEN");
208
+ }
209
+ if (issueProvider === "jira") {
210
+ const email = await asker.text("JIRA_EMAIL", { default: ctx.gitEmail ?? "" });
211
+ if (email)
212
+ env["JIRA_EMAIL"] = email;
213
+ const token = await asker.secret("JIRA_API_TOKEN", { current: ctx.existingEnv.get("JIRA_API_TOKEN") });
214
+ if (token)
215
+ env["JIRA_API_TOKEN"] = token;
216
+ envExampleKeys.push("JIRA_EMAIL", "JIRA_API_TOKEN");
217
+ asker.note("id.atlassian.com -> Security -> API tokens");
218
+ }
219
+ if (codeHost === "bitbucket") {
220
+ const email = await asker.text("BITBUCKET_EMAIL", { default: ctx.gitEmail ?? "" });
221
+ if (email)
222
+ env["BITBUCKET_EMAIL"] = email;
223
+ const token = await asker.secret("BITBUCKET_API_TOKEN", { current: ctx.existingEnv.get("BITBUCKET_API_TOKEN") });
224
+ if (token)
225
+ env["BITBUCKET_API_TOKEN"] = token;
226
+ envExampleKeys.push("BITBUCKET_EMAIL", "BITBUCKET_API_TOKEN");
227
+ asker.note("Bitbucket app passwords are being removed — this needs an Atlassian API token instead.");
228
+ }
229
+ }
230
+ // ── 4. advanced (gated) ─────────────────────────────────────────────────────
231
+ const wantAdvanced = await asker.confirm("\nConfigure advanced settings (thinking level, tools, protected files, data dir, poll intervals, ...)?", false);
232
+ if (wantAdvanced) {
233
+ asker.heading("Advanced");
234
+ const thinkingChoices = ThinkingLevelSchema.options.map((o) => ({ value: o, label: o }));
235
+ const thinking = await asker.select("defaults.thinking", thinkingChoices, "medium");
236
+ if (thinking !== "medium")
237
+ defaults.thinking = thinking;
238
+ const dataDir = await asker.text("defaults.data_dir", { default: ".spf/data" });
239
+ if (dataDir !== ".spf/data")
240
+ defaults.data_dir = dataDir;
241
+ const protectedFiles = await asker.text("defaults.protected_files (comma-separated)", { default: ".spf/, spf.config.yaml" });
242
+ const protectedList = protectedFiles.split(",").map((s) => s.trim()).filter(Boolean);
243
+ if (protectedList.join(",") !== ".spf/,spf.config.yaml")
244
+ defaults.protected_files = protectedList;
245
+ const dbPath = await asker.text("observability.db", { default: ".spf/data/spf.db" });
246
+ const pollMs = await asker.text("observability.poll_ms", { default: "500" });
247
+ if (dbPath !== ".spf/data/spf.db" || pollMs !== "500") {
248
+ defaults["__observability__"] = { ...(dbPath !== ".spf/data/spf.db" ? { db: dbPath } : {}), ...(pollMs !== "500" ? { poll_ms: Number(pollMs) } : {}) };
249
+ }
250
+ if (watch) {
251
+ const watchPollMs = await asker.text("watch.poll_ms", { default: "60000" });
252
+ if (watchPollMs !== "60000")
253
+ watch.poll_ms = Number(watchPollMs);
254
+ const concurrency = await asker.text("watch.concurrency", { default: "2" });
255
+ if (concurrency !== "2")
256
+ watch.concurrency = Number(concurrency);
257
+ }
258
+ const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
259
+ if (engineerName && engineerName !== ctx.gitName)
260
+ env["ENGINEER_NAME"] = engineerName;
261
+ if (watch?.issue_provider === "jira") {
262
+ const debug = await asker.confirm("Enable SPF_JIRA_DEBUG (request/response logging)?", false);
263
+ if (debug)
264
+ env["SPF_JIRA_DEBUG"] = "1";
265
+ }
266
+ }
267
+ // ── 5. review + confirm ─────────────────────────────────────────────────────
268
+ const observability = defaults["__observability__"];
269
+ delete defaults["__observability__"];
270
+ const quality = defaults["__quality__"];
271
+ delete defaults["__quality__"];
272
+ const config = { defaults };
273
+ if (agentOverrides.length > 0)
274
+ config.agents = agentOverrides;
275
+ if (quality)
276
+ config.quality = quality;
277
+ if (watch)
278
+ config.watch = watch;
279
+ if (observability)
280
+ config.observability = observability;
281
+ // No schema validation here on purpose: `config.agents` overrides are
282
+ // intentionally PARTIAL (name + model only, no prompt_engineering) — valid
283
+ // only once merged by name into the packaged roster (agents.ts's
284
+ // mergeAgentLists), same as any hand-written override file. Validating
285
+ // this raw document against the full SFConfigSchema would flag every
286
+ // claude_code interview as broken. `commands/init.ts` runs the real
287
+ // merge-then-validate pipeline (agents.loadConfig + agents.validate,
288
+ // the same one `spf doctor` uses) after writing, and reports there.
289
+ asker.heading("Review");
290
+ console.log(renderPreview(config));
291
+ if (notes.length > 0) {
292
+ for (const n of notes)
293
+ asker.note(n);
294
+ }
295
+ if (Object.keys(env).length > 0) {
296
+ console.log("");
297
+ console.log(".env keys to write: " + Object.keys(env).join(", "));
298
+ }
299
+ const proceed = await asker.confirm("\nWrite .spf/spf.config.yaml and .env?", true);
300
+ if (!proceed)
301
+ return null;
302
+ return { config, env, envExampleKeys };
303
+ }
304
+ /** An array is worth recursing into only if it holds further objects (e.g. `agents:`, `quality.checks`) — an array of primitives (e.g. `argv: ["npm","run","test"]`) reads better printed inline than one bare, contentless line per entry. */
305
+ function isArrayOfObjects(value) {
306
+ return Array.isArray(value) && value.some((item) => item && typeof item === "object");
307
+ }
308
+ function renderPreview(config) {
309
+ const lines = [];
310
+ const walk = (obj, indent) => {
311
+ for (const [key, value] of Object.entries(obj)) {
312
+ if (isArrayOfObjects(value)) {
313
+ lines.push(`${indent}${key}:`);
314
+ for (const item of value) {
315
+ lines.push(`${indent} -`);
316
+ walk(item, indent + " ");
317
+ }
318
+ }
319
+ else if (value && typeof value === "object" && !Array.isArray(value)) {
320
+ lines.push(`${indent}${key}:`);
321
+ walk(value, indent + " ");
322
+ }
323
+ else {
324
+ lines.push(`${indent}${key}: ${JSON.stringify(value)}`);
325
+ }
326
+ }
327
+ };
328
+ walk(config, "");
329
+ return lines.join("\n");
330
+ }
@@ -1,4 +1,6 @@
1
1
  /** Prompt rendering: load system/user refs from config, replace {{placeholders}}. */
2
+ /** Shared instructions for any documenter call fed a change.asEnvelope() capture. */
3
+ export declare const DOCUMENT_NOTES = "Read diff_path in full before writing. Document only what the diff shows, then copy the write-up into app_docs/ as your task describes.";
2
4
  export declare function render(templatePath: string, variables: Record<string, string>): string;
3
5
  /** Save the exact prompt sent, before execution — the audit copy. */
4
6
  export declare function save(directory: string, name: string, content: string): string;
@@ -1,6 +1,8 @@
1
1
  /** Prompt rendering: load system/user refs from config, replace {{placeholders}}. */
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ /** Shared instructions for any documenter call fed a change.asEnvelope() capture. */
5
+ export const DOCUMENT_NOTES = "Read diff_path in full before writing. Document only what the diff shows, then copy the write-up into app_docs/ as your task describes.";
4
6
  export function render(templatePath, variables) {
5
7
  let text = readFileSync(templatePath, "utf-8");
6
8
  for (const [key, value] of Object.entries(variables)) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Common Flue providers' env var conventions — public knowledge (pi-ai's own
3
+ * resolution table is internal, unexported, and not something to reach into
4
+ * for this). Missing from this table just means "unknown provider, skipped
5
+ * the key check" — never a false failure.
6
+ *
7
+ * Shared by `spf doctor` (checks whichever key the configured model's
8
+ * provider prefix implies) and the `spf init` interview (asks for the key up
9
+ * front, keyed off the provider the user picked) — one table, so the two
10
+ * can never drift apart on what a provider needs.
11
+ */
12
+ export declare const PROVIDER_ENV_KEYS: Record<string, string[]>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Common Flue providers' env var conventions — public knowledge (pi-ai's own
3
+ * resolution table is internal, unexported, and not something to reach into
4
+ * for this). Missing from this table just means "unknown provider, skipped
5
+ * the key check" — never a false failure.
6
+ *
7
+ * Shared by `spf doctor` (checks whichever key the configured model's
8
+ * provider prefix implies) and the `spf init` interview (asks for the key up
9
+ * front, keyed off the provider the user picked) — one table, so the two
10
+ * can never drift apart on what a provider needs.
11
+ */
12
+ export const PROVIDER_ENV_KEYS = {
13
+ anthropic: ["ANTHROPIC_API_KEY"],
14
+ openai: ["OPENAI_API_KEY"],
15
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
16
+ openrouter: ["OPENROUTER_API_KEY"],
17
+ fireworks: ["FIREWORKS_API_KEY"],
18
+ groq: ["GROQ_API_KEY"],
19
+ mistral: ["MISTRAL_API_KEY"],
20
+ xai: ["XAI_API_KEY"],
21
+ deepseek: ["DEEPSEEK_API_KEY"],
22
+ together: ["TOGETHER_API_KEY"],
23
+ cerebras: ["CEREBRAS_API_KEY"],
24
+ };
@@ -53,6 +53,15 @@ export declare function runSuite(run: RunLike, suiteName: string): QualityResult
53
53
  export declare function runTests(run: RunLike): QualityResult;
54
54
  /** Every configured check, across every configured suite's union — the `all` suite. */
55
55
  export declare function runQuality(run: RunLike): QualityResult;
56
+ /**
57
+ * Log a deterministic block's verdict — the same shape every chain uses.
58
+ *
59
+ * Every quality/test phase in every chain reported this same summary by
60
+ * hand; one copy here instead of one per chain.
61
+ */
62
+ export declare function record(ph: {
63
+ log: (payload: Record<string, unknown>) => void;
64
+ }, result: QualityResult): void;
56
65
  /**
57
66
  * Wrap a deterministic result so an agent can be handed it directly.
58
67
  *
@@ -170,6 +170,16 @@ export function runTests(run) {
170
170
  export function runQuality(run) {
171
171
  return runSuite(run, "all");
172
172
  }
173
+ /**
174
+ * Log a deterministic block's verdict — the same shape every chain uses.
175
+ *
176
+ * Every quality/test phase in every chain reported this same summary by
177
+ * hand; one copy here instead of one per chain.
178
+ */
179
+ export function record(ph, result) {
180
+ const passed = result.checks.filter((c) => c.passed).length;
181
+ ph.log({ passed: result.passed, checks: `${passed}/${result.checks.length}`, artifacts: result.artifacts.join(", ") });
182
+ }
173
183
  /**
174
184
  * Wrap a deterministic result so an agent can be handed it directly.
175
185
  *
@@ -12,5 +12,10 @@ import type { SFConfig } from "./data_types.ts";
12
12
  * process happened to start; it is an explicit decision, threaded down from
13
13
  * the CLI/chain context. Defaults to `process.cwd()` only for direct callers
14
14
  * (tests, scratch scripts) that have no anchor of their own to pass.
15
+ *
16
+ * `chainName` is the CLI name (`"plan-build-test"`, not a module basename) —
17
+ * every chain is a composed step list now, not its own file, so there is no
18
+ * longer a `process.argv[1]` basename that means anything. Direct callers
19
+ * that have no chain of their own fall back to `"adw"`.
15
20
  */
16
- export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string): Run;
21
+ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string, chainName?: string): Run;
@@ -33,8 +33,13 @@ function finalizeWhenKilled(run) {
33
33
  * process happened to start; it is an explicit decision, threaded down from
34
34
  * the CLI/chain context. Defaults to `process.cwd()` only for direct callers
35
35
  * (tests, scratch scripts) that have no anchor of their own to pass.
36
+ *
37
+ * `chainName` is the CLI name (`"plan-build-test"`, not a module basename) —
38
+ * every chain is a composed step list now, not its own file, so there is no
39
+ * longer a `process.argv[1]` basename that means anything. Direct callers
40
+ * that have no chain of their own fall back to `"adw"`.
36
41
  */
37
- export function ensure(cfg, adwId, cwd) {
42
+ export function ensure(cfg, adwId, cwd, chainName) {
38
43
  const id = adwId || newId(8);
39
44
  const anchor = paths.resolveAnchor(cwd);
40
45
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
@@ -49,8 +54,7 @@ export function ensure(cfg, adwId, cwd) {
49
54
  dataDir: dataPaths.data_dir,
50
55
  });
51
56
  const scriptPath = process.argv[1] || "adw";
52
- const adwName = path.basename(scriptPath, path.extname(scriptPath));
53
- tracer.sessionStart(id, run.engineer, adwName);
57
+ tracer.sessionStart(id, run.engineer, chainName || "adw");
54
58
  // This process is the run. Record it before any phase opens, so a run that
55
59
  // hangs in its first agent call is still killable by adw_id.
56
60
  tracer.processStart(id, "adw", "", process.pid ?? -1, [path.basename(scriptPath), ...process.argv.slice(2)].join(" "));
@@ -12,7 +12,7 @@ import { newId, nowIso } from "./utils.js";
12
12
  const SCHEMA = `
13
13
  CREATE TABLE IF NOT EXISTS sessions (
14
14
  adw_id TEXT PRIMARY KEY,
15
- adw_name TEXT, -- ADW script(s) run, e.g. "adw_plan + adw_build_test"
15
+ adw_name TEXT, -- chain(s) run, e.g. "plan + build-test"
16
16
  request TEXT,
17
17
  status TEXT,
18
18
  engineer TEXT,
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Small shared helpers. Anything bigger belongs in its own module.
3
3
  *
4
- * Bun loads .env files automatically for anything run with `bun run`/`bun`,
5
- * so there is no load_dotenv() call to make here that parity is free.
4
+ * `.env` loading lives in cli/index.ts's `main()`, via Node's built-in
5
+ * `process.loadEnvFile()` anchored to the resolved repo root, not
6
+ * `process.cwd()`. Not here: this module has no anchor of its own to load
7
+ * relative to.
6
8
  */
7
9
  import path from "node:path";
8
10
  /**
@@ -22,6 +24,8 @@ export declare function nowIso(): string;
22
24
  export declare function ensureDir(dirPath: string): string;
23
25
  /** CLI prompt arg: a file path resolves to its contents, else inline text. */
24
26
  export declare function resolvePrompt(arg: string): string;
27
+ /** True when `bin` resolves on PATH (or exists, if given as an absolute/relative path). Shared by `spf doctor` and the `spf init` interview so both agree on what's installed. */
28
+ export declare function binaryOnPath(bin: string): boolean;
25
29
  export declare function engineerName(): string;
26
30
  /**
27
31
  * Minimal `--flag value` / `--bare-flag` CLI parsing — the CLI only ever
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Small shared helpers. Anything bigger belongs in its own module.
3
3
  *
4
- * Bun loads .env files automatically for anything run with `bun run`/`bun`,
5
- * so there is no load_dotenv() call to make here that parity is free.
4
+ * `.env` loading lives in cli/index.ts's `main()`, via Node's built-in
5
+ * `process.loadEnvFile()` anchored to the resolved repo root, not
6
+ * `process.cwd()`. Not here: this module has no anchor of its own to load
7
+ * relative to.
6
8
  */
7
9
  import { randomBytes } from "node:crypto";
8
10
  import { spawnSync } from "node:child_process";
@@ -50,6 +52,13 @@ export function resolvePrompt(arg) {
50
52
  }
51
53
  return arg;
52
54
  }
55
+ /** True when `bin` resolves on PATH (or exists, if given as an absolute/relative path). Shared by `spf doctor` and the `spf init` interview so both agree on what's installed. */
56
+ export function binaryOnPath(bin) {
57
+ if (path.isAbsolute(bin) || bin.includes("/"))
58
+ return existsSync(bin);
59
+ const result = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" });
60
+ return result.status === 0;
61
+ }
53
62
  export function engineerName() {
54
63
  const name = (process.env.ENGINEER_NAME || "").trim();
55
64
  if (name)
@@ -0,0 +1,12 @@
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
+ export {};
@@ -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 {};