@gr8ful/spf 0.3.0 → 0.5.0
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 +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +237 -18
- package/dist/chains/steps.js +477 -58
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +297 -6
- package/package.json +5 -5
package/dist/cli/index.js
CHANGED
|
@@ -8,8 +8,10 @@ import path from "node:path";
|
|
|
8
8
|
import * as agentCc from "../core/agent_cc.js";
|
|
9
9
|
import * as agentFlue from "../core/agent_flue.js";
|
|
10
10
|
import * as notify from "../core/notify/notifier.js";
|
|
11
|
+
import * as otel from "../core/otel.js";
|
|
11
12
|
import * as paths from "../core/paths.js";
|
|
12
|
-
import { findChain } from "../chains/index.js";
|
|
13
|
+
import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
|
|
14
|
+
import { loadRepoChains } from "../chains/repo_chains.js";
|
|
13
15
|
import { dispatchChain, usageFor } from "./commands/run.js";
|
|
14
16
|
import { listCommand } from "./commands/list.js";
|
|
15
17
|
import { initCommand } from "./commands/init.js";
|
|
@@ -28,8 +30,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
28
30
|
|
|
29
31
|
spf list the chain registry — names, phases, what each needs
|
|
30
32
|
spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
|
|
31
|
-
spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
|
|
32
|
-
spf install-skill [--user] [--force] install the Claude Code skill
|
|
33
|
+
spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
|
|
34
|
+
spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
|
|
33
35
|
spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
|
|
34
36
|
spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
|
|
35
37
|
spf doctor [--json] check everything that fails silently otherwise
|
|
@@ -42,13 +44,33 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
42
44
|
spf abort <adw_id> signal a run's process to stop
|
|
43
45
|
spf version print the installed version
|
|
44
46
|
|
|
45
|
-
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>]
|
|
47
|
+
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
|
|
46
48
|
Run \`spf list\` to see every chain and what it needs.`;
|
|
47
49
|
/** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
|
|
48
50
|
function findCwdFlag(argv) {
|
|
49
51
|
const idx = argv.indexOf("--cwd");
|
|
50
52
|
return idx !== -1 ? argv[idx + 1] : undefined;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The moment someone types a chain name that doesn't resolve is the highest-
|
|
56
|
+
* traffic place a broken `.spf/chains/*.yaml` file is ever discovered — and,
|
|
57
|
+
* before this, the one place its load problem was withheld (`spf list` and
|
|
58
|
+
* `spf doctor` report it; this "unknown chain"/"unknown command or chain"
|
|
59
|
+
* path printed only the full HELP text). A file named for exactly the chain
|
|
60
|
+
* someone typed is surfaced first, ahead of any other broken file, since
|
|
61
|
+
* that's the one they're actually looking for right now.
|
|
62
|
+
*/
|
|
63
|
+
function reportUnresolvedChain(typedName) {
|
|
64
|
+
const problems = repoChainProblems();
|
|
65
|
+
if (problems.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const matching = typedName ? problems.filter((p) => path.basename(p.file).startsWith(typedName)) : [];
|
|
68
|
+
const rest = problems.filter((p) => !matching.includes(p));
|
|
69
|
+
const ordered = [...matching, ...rest];
|
|
70
|
+
console.error(`${problems.length} repo chain file(s) failed to load — one of these may be what you meant:`);
|
|
71
|
+
for (const p of ordered)
|
|
72
|
+
console.error(` ! ${p.file}: ${p.message}`);
|
|
73
|
+
}
|
|
52
74
|
export async function main() {
|
|
53
75
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
54
76
|
// `process.loadEnvFile()` with no argument reads from `process.cwd()` —
|
|
@@ -58,13 +80,41 @@ export async function main() {
|
|
|
58
80
|
// invoking shell's directory instead of the target repo's is exactly the
|
|
59
81
|
// anchor-mismatch bug that principle exists to close. Resolve the same
|
|
60
82
|
// way every command does, and load .env from the repo root it finds.
|
|
83
|
+
//
|
|
84
|
+
// Hoisted out of the try/catch below: resolveAnchor() cannot throw —
|
|
85
|
+
// findRepoRoot() (core/git_helper.ts) guards its own `git rev-parse
|
|
86
|
+
// --git-dir` probe with isRepoAt() and falls back to `cwd` itself when
|
|
87
|
+
// it's not a repo, AND guards the follow-up `--show-toplevel` call the
|
|
88
|
+
// same way (that one can fail even when isRepoAt() said yes — a bare repo
|
|
89
|
+
// or a `.git/` directory itself has no work tree), so it never throws
|
|
90
|
+
// either. findSfDir()'s walk is a plain existsSync/statSync loop with no
|
|
91
|
+
// failure path. The only thing in this block that CAN throw is
|
|
92
|
+
// loadEnvFile() (ENOENT when there's no .env), which is exactly what the
|
|
93
|
+
// catch below still guards.
|
|
94
|
+
const anchor = paths.resolveAnchor(findCwdFlag(rest));
|
|
61
95
|
try {
|
|
62
|
-
const anchor = paths.resolveAnchor(findCwdFlag(rest));
|
|
63
96
|
process.loadEnvFile(path.join(anchor.repo_root, ".env"));
|
|
64
97
|
}
|
|
65
98
|
catch {
|
|
66
99
|
// no .env there — fine, nothing to load
|
|
67
100
|
}
|
|
101
|
+
// Repo-local chains as DATA: a `.spf/chains/*.yaml` file names existing
|
|
102
|
+
// step factories, it never imports repo code — SPF's own dispatcher stays
|
|
103
|
+
// the one thing that ever executes. Registered here, before the command
|
|
104
|
+
// switch, so every dispatch path (`spf <chain>`, `spf run <chain>`, `spf
|
|
105
|
+
// list`, `spf doctor`, `spf watch`) sees the same merged registry without
|
|
106
|
+
// each of them re-scanning `.spf/chains/` on its own. This is also the
|
|
107
|
+
// ONCE in "`spf watch` registers chains from the main anchor once, at
|
|
108
|
+
// daemon start" — see the WATCH DIVERGENCE comment on `runChain` in
|
|
109
|
+
// `cli/commands/watch.ts` for what that means for a chain file edited on
|
|
110
|
+
// an issue branch mid-run. Zero filesystem cost when there's no `.spf/` at
|
|
111
|
+
// all (anchor.spf_dir is null for a repo that
|
|
112
|
+
// never ran `spf init`) — loadRepoChains() is only ever called once we
|
|
113
|
+
// know there's something to look at.
|
|
114
|
+
if (anchor.spf_dir) {
|
|
115
|
+
const { chains, problems } = loadRepoChains(anchor);
|
|
116
|
+
registerRepoChains(chains, problems);
|
|
117
|
+
}
|
|
68
118
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
69
119
|
console.log(HELP);
|
|
70
120
|
process.exitCode = 0;
|
|
@@ -81,6 +131,7 @@ export async function main() {
|
|
|
81
131
|
const chain = chainName ? findChain(chainName) : undefined;
|
|
82
132
|
if (!chain) {
|
|
83
133
|
console.error(`unknown chain: ${chainName ?? "(none given)"} — run \`spf list\` to see every chain`);
|
|
134
|
+
reportUnresolvedChain(chainName);
|
|
84
135
|
process.exitCode = 1;
|
|
85
136
|
return;
|
|
86
137
|
}
|
|
@@ -103,7 +154,7 @@ export async function main() {
|
|
|
103
154
|
process.exitCode = ejectCommand(rest);
|
|
104
155
|
return;
|
|
105
156
|
case "doctor":
|
|
106
|
-
process.exitCode = doctorCommand(rest);
|
|
157
|
+
process.exitCode = await doctorCommand(rest);
|
|
107
158
|
return;
|
|
108
159
|
case "ui":
|
|
109
160
|
process.exitCode = await uiCommand(rest);
|
|
@@ -129,6 +180,7 @@ export async function main() {
|
|
|
129
180
|
const chain = findChain(cmd);
|
|
130
181
|
if (!chain) {
|
|
131
182
|
console.error(`unknown command or chain: ${cmd}\n`);
|
|
183
|
+
reportUnresolvedChain(cmd);
|
|
132
184
|
console.error(HELP);
|
|
133
185
|
process.exitCode = 1;
|
|
134
186
|
return;
|
|
@@ -156,5 +208,10 @@ export async function main() {
|
|
|
156
208
|
// A no-op if notifications are off/unconfigured — awaits any in-flight
|
|
157
209
|
// webhook POST so a fast-exiting command doesn't drop it mid-flight.
|
|
158
210
|
await notify.flushAll();
|
|
211
|
+
// A no-op if `observability.otel` is not configured — drains the bounded
|
|
212
|
+
// span queue and awaits any in-flight OTLP POST, under its own hard
|
|
213
|
+
// deadline, so a fast-exiting command neither drops spans mid-flight nor
|
|
214
|
+
// waits on an unreachable collector. Never throws (core/otel.ts).
|
|
215
|
+
await otel.flushAll();
|
|
159
216
|
}
|
|
160
217
|
}
|
package/dist/cli/interview.js
CHANGED
|
@@ -21,7 +21,7 @@ import { ThinkingLevelSchema } from "../core/data_types.js";
|
|
|
21
21
|
import { loadConfig } from "../core/agents.js";
|
|
22
22
|
import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
|
|
23
23
|
import { DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
|
|
24
|
-
import {
|
|
24
|
+
import { allChains } from "../chains/index.js";
|
|
25
25
|
function gitConfigValue(repoRoot, key) {
|
|
26
26
|
const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
|
|
27
27
|
const value = result.status === 0 ? result.stdout.trim() : "";
|
|
@@ -120,7 +120,7 @@ export async function runInterview(asker, ctx) {
|
|
|
120
120
|
const token = await asker.secret("ANTHROPIC_AUTH_TOKEN", { current: ctx.existingEnv.get("ANTHROPIC_AUTH_TOKEN") });
|
|
121
121
|
env["ANTHROPIC_AUTH_TOKEN"] = token || "ollama";
|
|
122
122
|
envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN");
|
|
123
|
-
asker.note("spf doctor
|
|
123
|
+
asker.note("spf doctor now probes ANTHROPIC_BASE_URL with a minimal POST /v1/messages (informational only, never a hard failure) and flags a base URL that already ends in \"/v1\" as a likely double-path mistake — the default above (without a trailing /v1) is exactly what that check is guarding against. It still cannot validate ANTHROPIC_AUTH_TOKEN itself.");
|
|
124
124
|
}
|
|
125
125
|
// The packaged roster pins planner/reviewer/documenter to their own
|
|
126
126
|
// Flue-style provider/model-id strings, which always win over
|
|
@@ -149,11 +149,44 @@ export async function runInterview(asker, ctx) {
|
|
|
149
149
|
asker.note(`warning: ${error.message}`);
|
|
150
150
|
}
|
|
151
151
|
defaults.model = model;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
const envKeys = PROVIDER_ENV_KEYS[provider];
|
|
153
|
+
if (envKeys.length > 0) {
|
|
154
|
+
const envKey = envKeys[0];
|
|
155
|
+
const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
|
|
156
|
+
if (key)
|
|
157
|
+
env[envKey] = key;
|
|
158
|
+
envExampleKeys.push(envKey);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
asker.note(`${provider} is keyless — no API key to collect.`);
|
|
162
|
+
if (provider === "ollama") {
|
|
163
|
+
// Same shape as the claude_code branch's ANTHROPIC_BASE_URL prompt
|
|
164
|
+
// above: a local/cloud Ollama server has no key, just an address.
|
|
165
|
+
// The `/v1` suffix matches agent_flue.ts's own registration default
|
|
166
|
+
// (spike-verified: Ollama serves its OpenAI-compatible surface —
|
|
167
|
+
// the one Flue's pi-ai backend actually speaks — under `/v1`, not
|
|
168
|
+
// at the bare root `/api/...` Ollama also exposes).
|
|
169
|
+
const baseUrl = await asker.text("OLLAMA_BASE_URL", {
|
|
170
|
+
default: "http://localhost:11434/v1",
|
|
171
|
+
validate: (v) => (v.trim() ? null : "required"),
|
|
172
|
+
});
|
|
173
|
+
env["OLLAMA_BASE_URL"] = baseUrl;
|
|
174
|
+
envExampleKeys.push("OLLAMA_BASE_URL");
|
|
175
|
+
asker.note("spf doctor checks this one — a probe against OLLAMA_BASE_URL/models runs on every `spf doctor`.");
|
|
176
|
+
// Same problem the claude_code branch already solves above: the
|
|
177
|
+
// packaged roster pins planner/reviewer/documenter to their own
|
|
178
|
+
// fireworks/gemini/openai model strings, which always win over
|
|
179
|
+
// defaults.model. Left alone, this flow would produce a config
|
|
180
|
+
// where three of six agents are still routed at hosted providers
|
|
181
|
+
// whose keys this ollama flow never collects — `spf doctor` would
|
|
182
|
+
// immediately report missing FIREWORKS_API_KEY/GEMINI_API_KEY/
|
|
183
|
+
// OPENAI_API_KEY right after an interview that asked for neither.
|
|
184
|
+
for (const name of ["planner", "reviewer", "documenter"]) {
|
|
185
|
+
agentOverrides.push({ name, model: defaults.model });
|
|
186
|
+
}
|
|
187
|
+
notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to the ollama model chosen above, or spf doctor would report missing FIREWORKS_API_KEY/GEMINI_API_KEY/OPENAI_API_KEY for providers this flow never asked about.");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
157
190
|
}
|
|
158
191
|
// Declined (the default): today's behavior exactly — on claude_code, the
|
|
159
192
|
// three-agent auto-pin above stands unchanged; on flue, no agents: block
|
|
@@ -222,15 +255,49 @@ export async function runInterview(asker, ctx) {
|
|
|
222
255
|
{ value: "bitbucket", label: "Bitbucket" },
|
|
223
256
|
], "github");
|
|
224
257
|
const repoHint = codeHost === "bitbucket" ? "workspace/repo_slug" : "owner/name";
|
|
225
|
-
|
|
258
|
+
// github issues + bitbucket code is the one combination where a single
|
|
259
|
+
// repo field is ambiguous — they're two different repos in two
|
|
260
|
+
// different systems, not one repo worn two ways (github+github is one
|
|
261
|
+
// repo by construction; jira never reads `repo` at all). Ask a second,
|
|
262
|
+
// explicitly-labeled question only in that one case.
|
|
263
|
+
const splitRepo = issueProvider === "github" && codeHost === "bitbucket";
|
|
264
|
+
const repo = await asker.text(splitRepo ? `Code repo, where PRs open (${repoHint})` : `Repo (${repoHint})`, {
|
|
226
265
|
default: codeHost === "github" ? ctx.repoSlug ?? "" : "",
|
|
227
266
|
validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "${repoHint}"`),
|
|
228
267
|
});
|
|
268
|
+
let issueRepo = "";
|
|
269
|
+
if (splitRepo) {
|
|
270
|
+
asker.note("GitHub issues and the Bitbucket code repo are different repos here — spf watch needs both.");
|
|
271
|
+
issueRepo = await asker.text("Issue repo, where spf:ready issues live (owner/name)", {
|
|
272
|
+
default: ctx.repoSlug ?? "",
|
|
273
|
+
validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "owner/name"`),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
229
276
|
const labelPrefix = await asker.text("Label prefix", { default: "spf" });
|
|
230
277
|
const baseBranch = await asker.text("Base branch", { default: ctx.currentBranch });
|
|
231
|
-
|
|
278
|
+
// allChains(), not the built-in-only CHAINS: `registerRepoChains` already
|
|
279
|
+
// ran in main() before initCommand, so a pre-existing `.spf/chains/`
|
|
280
|
+
// repo chain is a legal watch.chain everywhere else (watch.ts resolves
|
|
281
|
+
// it via findChain, doctor.ts validates and labels it) — this picker is
|
|
282
|
+
// the one place a user chooses watch.chain, so it must offer the same
|
|
283
|
+
// set. The "(repo)" hint marker distinguishes it in the list.
|
|
284
|
+
const chainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
|
|
232
285
|
const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
|
|
233
286
|
watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
|
|
287
|
+
if (issueRepo)
|
|
288
|
+
watch.issue_repo = issueRepo;
|
|
289
|
+
// Issue authoring (create + link a hierarchy) is only implemented on
|
|
290
|
+
// GitHubProvider today — see jira_provider.ts's module comment — so
|
|
291
|
+
// this lane isn't offered at all on a Jira tracker rather than asking a
|
|
292
|
+
// question that would just fail at `spf watch` startup.
|
|
293
|
+
if (issueProvider === "github") {
|
|
294
|
+
const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
|
|
295
|
+
if (enableRefine) {
|
|
296
|
+
const refineChainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
|
|
297
|
+
const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
|
|
298
|
+
watch.refine = { enabled: true, chain: refineChain };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
234
301
|
if (issueProvider === "jira") {
|
|
235
302
|
const baseUrl = await asker.text("Jira base URL", {
|
|
236
303
|
default: "https://your-domain.atlassian.net",
|
|
@@ -334,6 +401,11 @@ export async function runInterview(asker, ctx) {
|
|
|
334
401
|
const concurrency = await asker.text("watch.concurrency", { default: "2" });
|
|
335
402
|
if (concurrency !== "2")
|
|
336
403
|
watch.concurrency = Number(concurrency);
|
|
404
|
+
if (watch.refine?.enabled) {
|
|
405
|
+
const refineConcurrency = await asker.text("watch.refine.concurrency", { default: "1" });
|
|
406
|
+
if (refineConcurrency !== "1")
|
|
407
|
+
watch.refine.concurrency = Number(refineConcurrency);
|
|
408
|
+
}
|
|
337
409
|
}
|
|
338
410
|
const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
|
|
339
411
|
if (engineerName && engineerName !== ctx.gitName)
|
package/dist/core/agent_cc.d.ts
CHANGED
|
@@ -11,10 +11,49 @@
|
|
|
11
11
|
* a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
|
|
12
12
|
* before running spf. Space-separated command chains are supported:
|
|
13
13
|
* - `SPF_CLAUDE_CMD="claude"` (default)
|
|
14
|
-
* - `SPF_CLAUDE_CMD="ollama launch claude"` (Ollama launcher
|
|
14
|
+
* - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher —
|
|
15
|
+
* the `--model` is `ollama launch`'s OWN flag, and is mandatory in
|
|
16
|
+
* headless mode; see below)
|
|
15
17
|
* The command/launcher must support the full Claude Code CLI interface.
|
|
16
18
|
* When unset, defaults to `claude`.
|
|
17
19
|
*
|
|
20
|
+
* Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
|
|
21
|
+
* `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
|
|
22
|
+
* ...args]`, so a plain passthrough shim needs nothing special, and a
|
|
23
|
+
* cmdSpec that already contains its own literal `--` is left completely
|
|
24
|
+
* alone — `args` lands after it exactly as written. `ollama launch <cmd>
|
|
25
|
+
* [flags...]` is the one documented shape that does NOT self-supply that
|
|
26
|
+
* separator: it uses cobra flag parsing, which treats anything typed after
|
|
27
|
+
* `launch <cmd>` as ITS OWN flags unless a literal `--` says otherwise —
|
|
28
|
+
* spike-verified live: `SPF_CLAUDE_CMD="ollama launch claude"` alone dies
|
|
29
|
+
* with `unknown shorthand flag: 'p' in -p` before `claude` ever starts
|
|
30
|
+
* (scratchpad/ollama-spike/probe2-claudecode-ollama/run3_ollama_launch_claude.log).
|
|
31
|
+
*
|
|
32
|
+
* The `--` insertion below fixes THAT failure, but is NOT by itself
|
|
33
|
+
* sufficient to reach `claude` — it only trades one error for the next one.
|
|
34
|
+
* `ollama launch` also requires its OWN `--model <tag>` flag, typed BEFORE
|
|
35
|
+
* the `--` separator, whenever it's run headless: with no model flag it
|
|
36
|
+
* falls back to an interactive model picker, and SPF always spawns with
|
|
37
|
+
* piped (non-interactive) stdio, so that picker can never run. Spike-verified
|
|
38
|
+
* live: `--` alone (no `--model` anywhere) dies one step later with `Error:
|
|
39
|
+
* model selection requires an interactive terminal; use --model to run in
|
|
40
|
+
* headless mode` (.../run4_ollama_launch_claude_dashdash.log) — that log IS
|
|
41
|
+
* the "just add `--`" experiment, and it fails. Putting `--model <tag>`
|
|
42
|
+
* AFTER the separator doesn't help either: at that point it's parsed as
|
|
43
|
+
* `claude`'s own `--model`, not `ollama launch`'s, so `ollama launch` still
|
|
44
|
+
* sees no model and still fails the same way. Only supplying `ollama
|
|
45
|
+
* launch`'s `--model` BEFORE the separator succeeds end-to-end
|
|
46
|
+
* (.../run5_ollama_launch_claude_full.log). So the operator's `SPF_CLAUDE_CMD`
|
|
47
|
+
* itself must read `ollama launch claude --model <tag>` (tag from `ollama
|
|
48
|
+
* list`) — this module can insert the `--`, but cannot supply the model tag
|
|
49
|
+
* on the operator's behalf; `doctor.ts` hard-fails a `SPF_CLAUDE_CMD` that
|
|
50
|
+
* omits it, since this is a static, deterministic misconfiguration.
|
|
51
|
+
*
|
|
52
|
+
* This module special-cases exactly the `ollama launch ...` token shape
|
|
53
|
+
* (with no `--` already present) and inserts the separator automatically at
|
|
54
|
+
* that position — never for any other wrapper, and never a second `--` if
|
|
55
|
+
* the operator already wrote one themselves.
|
|
56
|
+
*
|
|
18
57
|
* Every flag below was verified against a REAL local run of this exact
|
|
19
58
|
* machine's `claude` CLI (v2.1.237) before being written — not assumed from
|
|
20
59
|
* the SDK's docs, which describe a related but separately-versioned
|
package/dist/core/agent_cc.js
CHANGED
|
@@ -11,10 +11,49 @@
|
|
|
11
11
|
* a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
|
|
12
12
|
* before running spf. Space-separated command chains are supported:
|
|
13
13
|
* - `SPF_CLAUDE_CMD="claude"` (default)
|
|
14
|
-
* - `SPF_CLAUDE_CMD="ollama launch claude"` (Ollama launcher
|
|
14
|
+
* - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher —
|
|
15
|
+
* the `--model` is `ollama launch`'s OWN flag, and is mandatory in
|
|
16
|
+
* headless mode; see below)
|
|
15
17
|
* The command/launcher must support the full Claude Code CLI interface.
|
|
16
18
|
* When unset, defaults to `claude`.
|
|
17
19
|
*
|
|
20
|
+
* Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
|
|
21
|
+
* `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
|
|
22
|
+
* ...args]`, so a plain passthrough shim needs nothing special, and a
|
|
23
|
+
* cmdSpec that already contains its own literal `--` is left completely
|
|
24
|
+
* alone — `args` lands after it exactly as written. `ollama launch <cmd>
|
|
25
|
+
* [flags...]` is the one documented shape that does NOT self-supply that
|
|
26
|
+
* separator: it uses cobra flag parsing, which treats anything typed after
|
|
27
|
+
* `launch <cmd>` as ITS OWN flags unless a literal `--` says otherwise —
|
|
28
|
+
* spike-verified live: `SPF_CLAUDE_CMD="ollama launch claude"` alone dies
|
|
29
|
+
* with `unknown shorthand flag: 'p' in -p` before `claude` ever starts
|
|
30
|
+
* (scratchpad/ollama-spike/probe2-claudecode-ollama/run3_ollama_launch_claude.log).
|
|
31
|
+
*
|
|
32
|
+
* The `--` insertion below fixes THAT failure, but is NOT by itself
|
|
33
|
+
* sufficient to reach `claude` — it only trades one error for the next one.
|
|
34
|
+
* `ollama launch` also requires its OWN `--model <tag>` flag, typed BEFORE
|
|
35
|
+
* the `--` separator, whenever it's run headless: with no model flag it
|
|
36
|
+
* falls back to an interactive model picker, and SPF always spawns with
|
|
37
|
+
* piped (non-interactive) stdio, so that picker can never run. Spike-verified
|
|
38
|
+
* live: `--` alone (no `--model` anywhere) dies one step later with `Error:
|
|
39
|
+
* model selection requires an interactive terminal; use --model to run in
|
|
40
|
+
* headless mode` (.../run4_ollama_launch_claude_dashdash.log) — that log IS
|
|
41
|
+
* the "just add `--`" experiment, and it fails. Putting `--model <tag>`
|
|
42
|
+
* AFTER the separator doesn't help either: at that point it's parsed as
|
|
43
|
+
* `claude`'s own `--model`, not `ollama launch`'s, so `ollama launch` still
|
|
44
|
+
* sees no model and still fails the same way. Only supplying `ollama
|
|
45
|
+
* launch`'s `--model` BEFORE the separator succeeds end-to-end
|
|
46
|
+
* (.../run5_ollama_launch_claude_full.log). So the operator's `SPF_CLAUDE_CMD`
|
|
47
|
+
* itself must read `ollama launch claude --model <tag>` (tag from `ollama
|
|
48
|
+
* list`) — this module can insert the `--`, but cannot supply the model tag
|
|
49
|
+
* on the operator's behalf; `doctor.ts` hard-fails a `SPF_CLAUDE_CMD` that
|
|
50
|
+
* omits it, since this is a static, deterministic misconfiguration.
|
|
51
|
+
*
|
|
52
|
+
* This module special-cases exactly the `ollama launch ...` token shape
|
|
53
|
+
* (with no `--` already present) and inserts the separator automatically at
|
|
54
|
+
* that position — never for any other wrapper, and never a second `--` if
|
|
55
|
+
* the operator already wrote one themselves.
|
|
56
|
+
*
|
|
18
57
|
* Every flag below was verified against a REAL local run of this exact
|
|
19
58
|
* machine's `claude` CLI (v2.1.237) before being written — not assumed from
|
|
20
59
|
* the SDK's docs, which describe a related but separately-versioned
|
|
@@ -240,9 +279,17 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
240
279
|
"--strict-mcp-config", // see the module doc comment — required, not optional
|
|
241
280
|
];
|
|
242
281
|
const cmdSpec = process.env.SPF_CLAUDE_CMD || "claude";
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
282
|
+
const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
|
|
283
|
+
const [cmd, ...cmdArgs] = cmdTokens;
|
|
284
|
+
// See the module doc comment for why `ollama launch ...` (and ONLY that
|
|
285
|
+
// shape) gets an auto-inserted `--`: cobra flag parsing otherwise consumes
|
|
286
|
+
// `args`' own flags (e.g. `-p`) as `ollama launch`'s, before `claude` is
|
|
287
|
+
// ever reached. Any cmdSpec that already contains a literal `--` token is
|
|
288
|
+
// left completely alone — `args` is appended after it exactly as written,
|
|
289
|
+
// never a second separator.
|
|
290
|
+
const needsOllamaLaunchSeparator = cmdTokens[0] === "ollama" && cmdTokens[1] === "launch" && !cmdArgs.includes("--");
|
|
291
|
+
const fullArgs = needsOllamaLaunchSeparator ? [...cmdArgs, "--", ...args] : [...cmdArgs, ...args];
|
|
292
|
+
const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: request.env ?? operatorEnv() });
|
|
246
293
|
// The prompt travels as a positional argv element, not stdin — closing it
|
|
247
294
|
// immediately avoids a real, observed ~3s "no stdin data received" stall
|
|
248
295
|
// where `claude` otherwise waits to see whether anything is piped in.
|
package/dist/core/agent_flue.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { AgentRunError, createBashTool, createEditTool, createGlobTool, createGrepTool, createReadTool, createWriteTool, init, observe, useDataWriter, useModel, useSandbox, useTool, } from "@flue/runtime";
|
|
26
26
|
import { local, sqlite, start } from "@flue/runtime/node";
|
|
27
27
|
import { UsageBreakdown, makeAgentResult } from "./data_types.js";
|
|
28
|
+
import { registerOllamaModel } from "./ollama_provider.js";
|
|
28
29
|
import { nowIso, operatorEnv } from "./utils.js";
|
|
29
30
|
const RESULT_SNIPPET_CHARS = 20_000; // tool output rides along whole; clip only guards pathological cases
|
|
30
31
|
const ARG_VALUE_CHARS = 20_000; // args too — the UI scrolls, it must not be handed cut-off data
|
|
@@ -156,10 +157,12 @@ function resolveBuiltinTools(names) {
|
|
|
156
157
|
* `undefined`/`null` toolNames = every builtin (Flue's own default when no
|
|
157
158
|
* `tools` override is given — matches SPF's "unset = all tools usable").
|
|
158
159
|
* `local()`'s env does NOT inherit process.env by default (only PATH/HOME/
|
|
159
|
-
* USER/LANG/TERM/TMPDIR) — operatorEnv()
|
|
160
|
+
* USER/LANG/TERM/TMPDIR) — `env` is `request.env ?? operatorEnv()` from the
|
|
161
|
+
* caller, restoring today's actual behavior unless the agent's own
|
|
162
|
+
* `env_allowlist` narrowed it (see agents.ts).
|
|
160
163
|
*/
|
|
161
|
-
function sandboxFor(toolNames, cwd) {
|
|
162
|
-
const base = local({ cwd, env
|
|
164
|
+
function sandboxFor(toolNames, cwd, env) {
|
|
165
|
+
const base = local({ cwd, env });
|
|
163
166
|
if (!toolNames)
|
|
164
167
|
return base;
|
|
165
168
|
const factories = resolveBuiltinTools(toolNames);
|
|
@@ -188,7 +191,7 @@ function sfAgentRender({ id }) {
|
|
|
188
191
|
if (!spec)
|
|
189
192
|
throw new Error(`agent_flue: no render spec registered for conversation ${id} — run() must set it before dispatching`);
|
|
190
193
|
useModel(spec.model, { thinkingLevel: spec.thinking });
|
|
191
|
-
useSandbox(sandboxFor(spec.toolNames, spec.cwd));
|
|
194
|
+
useSandbox(sandboxFor(spec.toolNames, spec.cwd, spec.env));
|
|
192
195
|
const writeReport = useDataWriter("sf_report");
|
|
193
196
|
useTool({
|
|
194
197
|
name: "sf_report",
|
|
@@ -265,6 +268,26 @@ function contextTokensOf(usage) {
|
|
|
265
268
|
* not attempted here.
|
|
266
269
|
*/
|
|
267
270
|
export async function run(request, onEvent, onSpawn, onExit) {
|
|
271
|
+
// Ollama has no pi-ai/Flue built-in provider — it must be registered as a
|
|
272
|
+
// custom `createProvider(...)` before anything dispatches against it (see
|
|
273
|
+
// ollama_provider.ts's header comment for why: dummy api key, union
|
|
274
|
+
// re-registration, exact version pin). Doing this BEFORE `ensureRuntime()`
|
|
275
|
+
// means it's in place before that call's `start()` ever gets a chance to
|
|
276
|
+
// run its own default-provider registration — irrelevant today (pi-ai ships
|
|
277
|
+
// no "ollama" built-in to collide with) but keeps the ordering the one a
|
|
278
|
+
// future built-in couldn't quietly undermine. `resolveModel` is a cheap
|
|
279
|
+
// string split regardless of provider, so the check above costs nothing
|
|
280
|
+
// extra for a non-ollama agent — but `registerOllamaModel`'s own dynamic
|
|
281
|
+
// imports are NOT a load-time saving for the ollama case either (this
|
|
282
|
+
// module's own unconditional `@flue/runtime/node` import already pulls in
|
|
283
|
+
// pi-ai's full runtime for every run; see ollama_provider.ts's header
|
|
284
|
+
// comment for the measured numbers). What the lazy boundary actually buys
|
|
285
|
+
// is keeping ollama-only symbols off the module graph of anything that
|
|
286
|
+
// imports this module for `resolveModel()` alone (doctor.ts, interview.ts)
|
|
287
|
+
// without ever dispatching an ollama call.
|
|
288
|
+
const [provider, modelId] = resolveModel(request.model);
|
|
289
|
+
if (provider === "ollama")
|
|
290
|
+
await registerOllamaModel(modelId);
|
|
268
291
|
await ensureRuntime(request.flue_db_path);
|
|
269
292
|
REGISTRY.set(request.session_id, {
|
|
270
293
|
model: request.model,
|
|
@@ -274,6 +297,7 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
274
297
|
systemText: request.system_prompt,
|
|
275
298
|
outputSchema: request.output_schema,
|
|
276
299
|
outputTypeName: request.output_type_name,
|
|
300
|
+
env: request.env ?? operatorEnv(),
|
|
277
301
|
});
|
|
278
302
|
const pid = process.pid ?? -1;
|
|
279
303
|
onSpawn?.(pid);
|
package/dist/core/agents.d.ts
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* disposes.
|
|
9
9
|
*/
|
|
10
10
|
import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type SFConfig } from "./data_types.ts";
|
|
11
|
+
/**
|
|
12
|
+
* `undefined` (no `env_allowlist` configured — the default) means "don't
|
|
13
|
+
* filter at all"; both backends treat that as `request.env ?? operatorEnv()`,
|
|
14
|
+
* i.e. today's unfiltered behavior, byte-identical. Configured, this filters
|
|
15
|
+
* the operator's own environment down to the named keys plus the baseline
|
|
16
|
+
* `local()` would keep anyway.
|
|
17
|
+
*/
|
|
18
|
+
export declare function agentEnv(agent: AgentConfig): Record<string, string> | undefined;
|
|
11
19
|
export declare class GateFailure extends Error {
|
|
12
20
|
}
|
|
13
21
|
/**
|
package/dist/core/agents.js
CHANGED
|
@@ -17,8 +17,32 @@ import * as paths from "./paths.js";
|
|
|
17
17
|
import * as permissions from "./permissions.js";
|
|
18
18
|
import * as prompts from "./prompts.js";
|
|
19
19
|
import { GateReport, UsageBreakdown, makeEventRecord, SFConfigSchema, } from "./data_types.js";
|
|
20
|
-
import { newId } from "./utils.js";
|
|
20
|
+
import { newId, operatorEnv } from "./utils.js";
|
|
21
21
|
const JSON_FIX_ATTEMPTS = 2; // continue-with-correction attempts for malformed JSON
|
|
22
|
+
// Kept alongside agent_flue.ts's own comment on this same list — `local()`'s
|
|
23
|
+
// sandbox keeps these regardless of any `env` override, so an allowlist that
|
|
24
|
+
// omitted them would silently lose them there but not on agent_cc.ts's plain
|
|
25
|
+
// `spawn()`. Folding them in here keeps both backends' filtered env identical.
|
|
26
|
+
const ENV_BASELINE_KEYS = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
|
|
27
|
+
/**
|
|
28
|
+
* `undefined` (no `env_allowlist` configured — the default) means "don't
|
|
29
|
+
* filter at all"; both backends treat that as `request.env ?? operatorEnv()`,
|
|
30
|
+
* i.e. today's unfiltered behavior, byte-identical. Configured, this filters
|
|
31
|
+
* the operator's own environment down to the named keys plus the baseline
|
|
32
|
+
* `local()` would keep anyway.
|
|
33
|
+
*/
|
|
34
|
+
export function agentEnv(agent) {
|
|
35
|
+
if (!agent.env_allowlist)
|
|
36
|
+
return undefined;
|
|
37
|
+
const operator = operatorEnv();
|
|
38
|
+
const keep = new Set([...ENV_BASELINE_KEYS, ...agent.env_allowlist]);
|
|
39
|
+
const env = {};
|
|
40
|
+
for (const key of keep) {
|
|
41
|
+
if (operator[key] !== undefined)
|
|
42
|
+
env[key] = operator[key];
|
|
43
|
+
}
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
22
46
|
export class GateFailure extends Error {
|
|
23
47
|
}
|
|
24
48
|
/**
|
|
@@ -57,7 +81,19 @@ function mergeAgentLists(base, override) {
|
|
|
57
81
|
}
|
|
58
82
|
return merged;
|
|
59
83
|
}
|
|
60
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* `defaults`/`observability`/`quality`/`watch`/`notifications`/`review` merge
|
|
86
|
+
* key-by-key; `agents` merges by name.
|
|
87
|
+
*
|
|
88
|
+
* THE SILENT-DROP TRAP (adversarial history, not a hypothetical): this
|
|
89
|
+
* function returns a FIXED-SHAPE object literal — every top-level `SFConfig`
|
|
90
|
+
* key has to be named on BOTH sides of every merged field here, by hand, or
|
|
91
|
+
* a `.spf/spf.config.yaml` value for it is silently discarded before
|
|
92
|
+
* `v.parse(SFConfigSchema, raw)` ever sees it. Adding a key to
|
|
93
|
+
* `SFConfigSchema` without adding it here is exactly that bug — see
|
|
94
|
+
* `data_types.test.ts`'s merge-survival test for `review`, which is the
|
|
95
|
+
* regression guard this comment is here to justify.
|
|
96
|
+
*/
|
|
61
97
|
function mergeRawConfig(base, override) {
|
|
62
98
|
return {
|
|
63
99
|
defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
|
|
@@ -67,6 +103,9 @@ function mergeRawConfig(base, override) {
|
|
|
67
103
|
// channels is a whole-array replace on override, same as quality.checks —
|
|
68
104
|
// you don't want an override's channels appended to the built-in's.
|
|
69
105
|
notifications: { ...(base.notifications || {}), ...(override.notifications || {}) },
|
|
106
|
+
// review.require_human_signoff / review.signoff_timeout_seconds — see
|
|
107
|
+
// data_types.ts's ReviewConfigSchema doc comment for why this key exists.
|
|
108
|
+
review: { ...(base.review || {}), ...(override.review || {}) },
|
|
70
109
|
agents: mergeAgentLists(base.agents || [], override.agents || []),
|
|
71
110
|
};
|
|
72
111
|
}
|
|
@@ -90,7 +129,7 @@ export function loadConfig(configPaths) {
|
|
|
90
129
|
}
|
|
91
130
|
const defaults = raw.defaults || {};
|
|
92
131
|
for (const agent of raw.agents || []) {
|
|
93
|
-
for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes"]) {
|
|
132
|
+
for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes", "env_allowlist"]) {
|
|
94
133
|
if (key in defaults && !(key in agent))
|
|
95
134
|
agent[key] = defaults[key];
|
|
96
135
|
}
|
|
@@ -231,6 +270,7 @@ export async function execute(run, phase, call) {
|
|
|
231
270
|
output_type_name: call.output_type.name,
|
|
232
271
|
cwd: run.repo_root,
|
|
233
272
|
flue_db_path: path.join(run.data_dir, "flue.db"),
|
|
273
|
+
env: agentEnv(agent),
|
|
234
274
|
};
|
|
235
275
|
const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
|
|
236
276
|
const onSpawn = (pid) => run.tracer.processStart(run.adw_id, "agent", agent.name, pid, `${agent.coding_agent} ${agent.name} ${agent.model}`);
|