@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.
Files changed (91) hide show
  1. package/README.md +177 -9
  2. package/assets/defaults/spf.config.yaml +22 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/prompts/reviewer/system.md +1 -1
  6. package/assets/skill/SKILL.md +1 -0
  7. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  8. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  9. package/assets/skill/cookbooks/roster.md +15 -4
  10. package/assets/skill/cookbooks/spf_overview.md +1 -0
  11. package/assets/skill/references/config.md +104 -3
  12. package/assets/skill/references/observability.md +11 -2
  13. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  14. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  15. package/assets/templates/ts.spf.config.yaml +15 -2
  16. package/dist/chains/context.d.ts +39 -0
  17. package/dist/chains/index.d.ts +94 -10
  18. package/dist/chains/index.js +75 -5
  19. package/dist/chains/repo_chains.d.ts +139 -0
  20. package/dist/chains/repo_chains.js +428 -0
  21. package/dist/chains/simple_sdlc.d.ts +74 -1
  22. package/dist/chains/simple_sdlc.js +134 -4
  23. package/dist/chains/steps.d.ts +237 -18
  24. package/dist/chains/steps.js +477 -58
  25. package/dist/cli/ask.d.ts +14 -1
  26. package/dist/cli/ask.js +32 -2
  27. package/dist/cli/commands/doctor.d.ts +1 -1
  28. package/dist/cli/commands/doctor.js +324 -10
  29. package/dist/cli/commands/init.d.ts +12 -0
  30. package/dist/cli/commands/init.js +108 -4
  31. package/dist/cli/commands/install-skill.js +5 -2
  32. package/dist/cli/commands/list.js +43 -5
  33. package/dist/cli/commands/run.js +29 -2
  34. package/dist/cli/commands/watch.d.ts +18 -0
  35. package/dist/cli/commands/watch.js +214 -16
  36. package/dist/cli/index.js +63 -6
  37. package/dist/cli/interview.js +81 -9
  38. package/dist/core/agent_cc.d.ts +40 -1
  39. package/dist/core/agent_cc.js +51 -4
  40. package/dist/core/agent_flue.js +28 -4
  41. package/dist/core/agents.d.ts +8 -0
  42. package/dist/core/agents.js +43 -3
  43. package/dist/core/data_types.d.ts +182 -4
  44. package/dist/core/data_types.js +141 -2
  45. package/dist/core/gates.d.ts +13 -0
  46. package/dist/core/gates.js +103 -0
  47. package/dist/core/git_helper.d.ts +29 -0
  48. package/dist/core/git_helper.js +41 -1
  49. package/dist/core/issues/github_provider.d.ts +35 -9
  50. package/dist/core/issues/github_provider.js +76 -28
  51. package/dist/core/issues/jira_provider.d.ts +14 -1
  52. package/dist/core/issues/jira_provider.js +9 -7
  53. package/dist/core/issues/provider.d.ts +77 -15
  54. package/dist/core/issues/provider.js +7 -4
  55. package/dist/core/notify/channel.d.ts +1 -1
  56. package/dist/core/ollama_provider.d.ts +70 -0
  57. package/dist/core/ollama_provider.js +208 -0
  58. package/dist/core/otel.d.ts +352 -0
  59. package/dist/core/otel.js +793 -0
  60. package/dist/core/providers.js +4 -0
  61. package/dist/core/refine.d.ts +39 -0
  62. package/dist/core/refine.js +152 -0
  63. package/dist/core/session.js +39 -2
  64. package/dist/core/tracer.d.ts +31 -2
  65. package/dist/core/tracer.js +69 -11
  66. package/dist/core/watch.d.ts +67 -1
  67. package/dist/core/watch.js +217 -13
  68. package/dist/test/chains.test.js +9 -3
  69. package/dist/test/data_types.test.js +140 -2
  70. package/dist/test/git_helper.test.d.ts +1 -0
  71. package/dist/test/git_helper.test.js +59 -0
  72. package/dist/test/hermetic_git.d.ts +1 -0
  73. package/dist/test/hermetic_git.js +22 -0
  74. package/dist/test/init_command.test.d.ts +14 -1
  75. package/dist/test/init_command.test.js +71 -1
  76. package/dist/test/interview.test.d.ts +15 -1
  77. package/dist/test/interview.test.js +131 -3
  78. package/dist/test/ollama_provider.test.d.ts +1 -0
  79. package/dist/test/ollama_provider.test.js +103 -0
  80. package/dist/test/otel.test.d.ts +26 -0
  81. package/dist/test/otel.test.js +512 -0
  82. package/dist/test/refine.test.d.ts +1 -0
  83. package/dist/test/refine.test.js +189 -0
  84. package/dist/test/repo_chains.test.d.ts +21 -0
  85. package/dist/test/repo_chains.test.js +416 -0
  86. package/dist/test/signoff.test.d.ts +1 -0
  87. package/dist/test/signoff.test.js +329 -0
  88. package/dist/test/ui_server.test.d.ts +7 -1
  89. package/dist/test/ui_server.test.js +1 -0
  90. package/dist/test/watch.test.js +297 -6
  91. 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 (repo-local by default)
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
  }
@@ -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 { CHAINS } from "../chains/index.js";
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 does not check these twoconfirm the endpoint is reachable yourself.");
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 envKey = PROVIDER_ENV_KEYS[provider][0];
153
- const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
154
- if (key)
155
- env[envKey] = key;
156
- envExampleKeys.push(envKey);
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
- const repo = await asker.text(`Repo (${repoHint})`, {
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
- const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
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)
@@ -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
@@ -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 [cmd, ...cmdArgs] = cmdSpec.split(/\s+/);
244
- const fullArgs = [...cmdArgs, ...args];
245
- const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: operatorEnv() });
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.
@@ -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() restores today's actual behavior.
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: operatorEnv() });
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);
@@ -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
  /**
@@ -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
- /** `defaults`/`observability`/`quality`/`watch`/`notifications` merge key-by-key; `agents` merges by name. */
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}`);