@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,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
|
+
}
|
|
@@ -45,6 +45,15 @@ export declare class JiraProvider implements IssueProvider {
|
|
|
45
45
|
/** Jira labels are freeform strings, not a seedable registry — report what's used, create nothing. */
|
|
46
46
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
47
47
|
private toIssue;
|
|
48
|
+
/**
|
|
49
|
+
* POST with the JQL in the JSON body, NOT a GET with `jql` as a query
|
|
50
|
+
* param: this endpoint's own real-world behavior (confirmed by multiple
|
|
51
|
+
* independent bug reports against it, not just this project's own
|
|
52
|
+
* testing) is to silently ignore query-string parameters and return an
|
|
53
|
+
* empty `issues` array with a 200 OK — no error, nothing to catch. A
|
|
54
|
+
* `spf watch` that builds this as a GET query string would run cleanly
|
|
55
|
+
* forever without ever claiming a single issue.
|
|
56
|
+
*/
|
|
48
57
|
private searchByLabel;
|
|
49
58
|
listEligible(): Promise<Issue[]>;
|
|
50
59
|
/**
|
|
@@ -36,6 +36,9 @@ export class JiraProvider {
|
|
|
36
36
|
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
37
37
|
}
|
|
38
38
|
async jira(path, init) {
|
|
39
|
+
const debug = Boolean(process.env["SPF_JIRA_DEBUG"]);
|
|
40
|
+
if (debug)
|
|
41
|
+
console.error(`[jira debug] ${init?.method ?? "GET"} ${this.baseUrl}${path} body=${init?.body ?? "(none)"}`);
|
|
39
42
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
40
43
|
...init,
|
|
41
44
|
headers: {
|
|
@@ -51,7 +54,10 @@ export class JiraProvider {
|
|
|
51
54
|
}
|
|
52
55
|
if (response.status === 204)
|
|
53
56
|
return undefined;
|
|
54
|
-
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
if (debug)
|
|
59
|
+
console.error(`[jira debug] -> ${response.status} ${text.slice(0, 2000)}`);
|
|
60
|
+
return text ? JSON.parse(text) : undefined;
|
|
55
61
|
}
|
|
56
62
|
label(state) {
|
|
57
63
|
return `${this.labelPrefix}:${state}`;
|
|
@@ -68,9 +74,21 @@ export class JiraProvider {
|
|
|
68
74
|
labels: raw.fields.labels,
|
|
69
75
|
};
|
|
70
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* POST with the JQL in the JSON body, NOT a GET with `jql` as a query
|
|
79
|
+
* param: this endpoint's own real-world behavior (confirmed by multiple
|
|
80
|
+
* independent bug reports against it, not just this project's own
|
|
81
|
+
* testing) is to silently ignore query-string parameters and return an
|
|
82
|
+
* empty `issues` array with a 200 OK — no error, nothing to catch. A
|
|
83
|
+
* `spf watch` that builds this as a GET query string would run cleanly
|
|
84
|
+
* forever without ever claiming a single issue.
|
|
85
|
+
*/
|
|
71
86
|
async searchByLabel(label) {
|
|
72
87
|
const jql = `project = ${JSON.stringify(this.projectKey)} AND labels = ${JSON.stringify(label)}`;
|
|
73
|
-
const result = await this.jira(
|
|
88
|
+
const result = await this.jira("/rest/api/3/search/jql", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: JSON.stringify({ jql, maxResults: 100, fields: ["summary", "description", "labels"] }),
|
|
91
|
+
});
|
|
74
92
|
return result.issues.map((i) => this.toIssue(i));
|
|
75
93
|
}
|
|
76
94
|
async listEligible() {
|
package/dist/core/prompts.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/prompts.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/core/quality.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/core/quality.js
CHANGED
|
@@ -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
|
*
|
package/dist/core/session.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/session.js
CHANGED
|
@@ -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
|
-
|
|
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(" "));
|
package/dist/core/tracer.js
CHANGED
|
@@ -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, --
|
|
15
|
+
adw_name TEXT, -- chain(s) run, e.g. "plan + build-test"
|
|
16
16
|
request TEXT,
|
|
17
17
|
status TEXT,
|
|
18
18
|
engineer TEXT,
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Small shared helpers. Anything bigger belongs in its own module.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
package/dist/core/utils.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Small shared helpers. Anything bigger belongs in its own module.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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)
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -17,6 +17,18 @@ export interface WatchDeps {
|
|
|
17
17
|
baseBranch: string;
|
|
18
18
|
concurrency: number;
|
|
19
19
|
worktreesDir: string;
|
|
20
|
+
/**
|
|
21
|
+
* Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
|
|
22
|
+
* repo's own persistent data_dir, called once per worktree right after
|
|
23
|
+
* it's created. Without this, a chain run's session/trace data resolves
|
|
24
|
+
* relative to `cwd` (the worktree — see ChainContext's doc comment) and
|
|
25
|
+
* lands in a fresh, throwaway `.spf/data` that `cleanupWorktree` deletes
|
|
26
|
+
* along with the rest of the worktree once the issue finishes: no trace
|
|
27
|
+
* in `spf ui`, and no trace anywhere at all after cleanup. Injected (not
|
|
28
|
+
* called directly) so watch.ts's own tests never touch the real
|
|
29
|
+
* filesystem for it.
|
|
30
|
+
*/
|
|
31
|
+
linkDataDir: (worktreePath: string) => void;
|
|
20
32
|
dryRun: boolean;
|
|
21
33
|
runChain: (opts: {
|
|
22
34
|
prompt: string;
|
package/dist/core/watch.js
CHANGED
|
@@ -129,8 +129,19 @@ async function runIssue(deps, issue) {
|
|
|
129
129
|
const worktreePath = worktreePathFor(deps, issue);
|
|
130
130
|
const adwId = `issue-${issue.id}`;
|
|
131
131
|
try {
|
|
132
|
+
// Both worktreePath and branch are fully deterministic from issue.id —
|
|
133
|
+
// the only way either could already exist is a previous spf watch
|
|
134
|
+
// attempt for THIS issue that never reached its own cleanup (killed
|
|
135
|
+
// mid-run, crashed, machine restart). `git worktree add -b` refuses
|
|
136
|
+
// outright if the branch already exists ("fatal: a branch named '...'
|
|
137
|
+
// already exists"), which without this would permanently block the
|
|
138
|
+
// issue from ever being claimed again — it'd fail this same way on
|
|
139
|
+
// every single retry. Safe to clear unconditionally: worktreeRemove/
|
|
140
|
+
// deleteLocalBranch are both no-ops if there's nothing to remove.
|
|
141
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
132
142
|
deps.git.fetch("origin", deps.baseBranch);
|
|
133
143
|
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
144
|
+
deps.linkDataDir(worktreePath);
|
|
134
145
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
135
146
|
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
136
147
|
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
|
@@ -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 {};
|