@gr8ful/spf 0.9.2 → 0.10.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 (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +354 -51
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import * as agentFlue from "../core/agent_flue.js";
11
11
  import * as notify from "../core/notify/notifier.js";
12
12
  import * as otel from "../core/otel.js";
13
13
  import * as paths from "../core/paths.js";
14
+ import * as sandbox from "../core/sandbox.js";
14
15
  import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
15
16
  import { loadRepoChains } from "../chains/repo_chains.js";
16
17
  import { dispatchChain, usageFor } from "./commands/run.js";
@@ -28,6 +29,7 @@ import { abortCommand } from "./commands/abort.js";
28
29
  import { uiCommand } from "./commands/ui.js";
29
30
  import { watchCommand, watchInitCommand } from "./commands/watch.js";
30
31
  import { fanoutCommand } from "./commands/fanout.js";
32
+ import { loopCommand } from "./commands/loop.js";
31
33
  import { versionCommand } from "./commands/version.js";
32
34
  const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
33
35
 
@@ -35,6 +37,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
35
37
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
36
38
  spf fanout <chain> "<prompt>" [--n 3] best-of-N: N isolated attempts, one deterministic winner branch — YOU merge it, spf never does
37
39
  spf fanout --clean <base-adw-id> remove leftover worktrees/branches from a killed or discarded fanout run
40
+ spf loop <chain> "<goal>" --until-suite <name> --max N [--issue <id>] run a chain repeatedly toward a goal until a named quality.suites check passes
38
41
  spf estimate <chain> "<prompt>" [--n N] read-only: planned model routing + a token/cost projection from real trace history — starts nothing, exits 3 if there's no history yet
39
42
  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)
40
43
  spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
@@ -167,6 +170,9 @@ export async function main() {
167
170
  case "fanout":
168
171
  process.exitCode = await fanoutCommand(rest);
169
172
  return;
173
+ case "loop":
174
+ process.exitCode = await loopCommand(rest);
175
+ return;
170
176
  case "estimate":
171
177
  process.exitCode = await estimateCommand(rest);
172
178
  return;
@@ -237,6 +243,14 @@ export async function main() {
237
243
  // agent_cc.ts's shutdown() just kills any still-running claude children.
238
244
  await agentFlue.shutdown();
239
245
  await agentCc.shutdown();
246
+ // Belt-and-braces alongside the per-run `sandbox.withRunScope` wraps in
247
+ // dispatchChain/watch/fanout: catches any sandboxed run's lease this
248
+ // process still holds when the CLI exits (a per-run wrap that itself
249
+ // throws before reaching its own teardown, a chain invoked through a
250
+ // path that predates a wrap, etc.) — without this, `backend: opensandbox`
251
+ // + default `scope: agent` leaks one container per dispatched agent for
252
+ // the process's entire lifetime. A no-op when no sandbox was ever created.
253
+ await sandbox.teardownAll();
240
254
  // A no-op if notifications are off/unconfigured — awaits any in-flight
241
255
  // webhook POST so a fast-exiting command doesn't drop it mid-flight.
242
256
  await notify.flushAll();
@@ -123,6 +123,17 @@ export declare function isKnownToolName(name: string): boolean;
123
123
  * substitution is unit-testable without spawning a real subprocess.
124
124
  */
125
125
  export declare function resolveClaudeCmdSpec(model: string): string;
126
+ /**
127
+ * Whether a resolved `cmdSpec` launches `claude` through `ollama launch` —
128
+ * the one wrapper shape that needs its own `--` separator (see the module
129
+ * doc comment) AND the one whose reported `total_cost_usd` isn't a real
130
+ * Anthropic-billed dollar figure (see the cost note at this module's `run()`
131
+ * call site): Ollama bills these sessions by flat subscription or local
132
+ * compute, never per token, so whatever price `claude`'s own internal
133
+ * (Anthropic) pricing table assigns to the actual serving model is fiction.
134
+ * Exported as its own pure function, same reasoning as `resolveClaudeCmdSpec`.
135
+ */
136
+ export declare function isOllamaLaunchCmd(cmdSpec: string): boolean;
126
137
  /** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
127
138
  export declare function shutdown(): Promise<void>;
128
139
  /**
@@ -249,6 +249,20 @@ const EFFORT_MAP = {
249
249
  export function resolveClaudeCmdSpec(model) {
250
250
  return (process.env.SPF_CLAUDE_CMD || "claude").replaceAll("{model}", model);
251
251
  }
252
+ /**
253
+ * Whether a resolved `cmdSpec` launches `claude` through `ollama launch` —
254
+ * the one wrapper shape that needs its own `--` separator (see the module
255
+ * doc comment) AND the one whose reported `total_cost_usd` isn't a real
256
+ * Anthropic-billed dollar figure (see the cost note at this module's `run()`
257
+ * call site): Ollama bills these sessions by flat subscription or local
258
+ * compute, never per token, so whatever price `claude`'s own internal
259
+ * (Anthropic) pricing table assigns to the actual serving model is fiction.
260
+ * Exported as its own pure function, same reasoning as `resolveClaudeCmdSpec`.
261
+ */
262
+ export function isOllamaLaunchCmd(cmdSpec) {
263
+ const [first, second] = cmdSpec.split(/\s+/).filter(Boolean);
264
+ return first === "ollama" && second === "launch";
265
+ }
252
266
  // ── process lifecycle ────────────────────────────────────────────────────────
253
267
  const inFlight = new Set();
254
268
  /** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
@@ -311,7 +325,8 @@ export async function run(request, onEvent, onSpawn, onExit) {
311
325
  // ever reached. Any cmdSpec that already contains a literal `--` token is
312
326
  // left completely alone — `args` is appended after it exactly as written,
313
327
  // never a second separator.
314
- const needsOllamaLaunchSeparator = cmdTokens[0] === "ollama" && cmdTokens[1] === "launch" && !cmdArgs.includes("--");
328
+ const isOllamaLaunch = isOllamaLaunchCmd(cmdSpec);
329
+ const needsOllamaLaunchSeparator = isOllamaLaunch && !cmdArgs.includes("--");
315
330
  const fullArgs = needsOllamaLaunchSeparator ? [...cmdArgs, "--", ...args] : [...cmdArgs, ...args];
316
331
  const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: request.env ?? operatorEnv() });
317
332
  // The prompt travels as a positional argv element, not stdin — closing it
@@ -377,7 +392,15 @@ export async function run(request, onEvent, onSpawn, onExit) {
377
392
  reasoning: u.output_tokens_details?.thinking_tokens ?? 0,
378
393
  // CC gives one total cost, not a per-component breakdown like Flue/pi-ai
379
394
  // do — folded honestly under `total`, not split up to fabricate one.
380
- cost: { total: final.total_cost_usd ?? 0 },
395
+ // Under `ollama launch`, though, `total_cost_usd` isn't honest at all:
396
+ // it's `claude`'s own internal (Anthropic) per-token price table applied
397
+ // to whatever model actually served the request, and Ollama bills
398
+ // these sessions by flat subscription/local compute, not per token — so
399
+ // the number has no relationship to what the operator is actually
400
+ // charged. Zeroed here, matching the same-situation call already made
401
+ // for the Flue backend's own Ollama models (see `ollama_provider.ts`'s
402
+ // `modelFor()`).
403
+ cost: { total: isOllamaLaunch ? 0 : (final.total_cost_usd ?? 0) },
381
404
  }, totalTokens);
382
405
  const modelUsages = final.modelUsage ?? {};
383
406
  const contextWindow = Object.values(modelUsages)[0]?.contextWindow ?? 0;
@@ -26,6 +26,7 @@ import { AgentRunError, createBashTool, createEditTool, createGlobTool, createGr
26
26
  import { local, sqlite, start } from "@flue/runtime/node";
27
27
  import { UsageBreakdown, makeAgentResult } from "./data_types.js";
28
28
  import { registerOllamaModel } from "./ollama_provider.js";
29
+ import * as sandbox from "./sandbox.js";
29
30
  import { nowIso, operatorEnv } from "./utils.js";
30
31
  const RESULT_SNIPPET_CHARS = 20_000; // tool output rides along whole; clip only guards pathological cases
31
32
  const ARG_VALUE_CHARS = 20_000; // args too — the UI scrolls, it must not be handed cut-off data
@@ -160,12 +161,19 @@ function resolveBuiltinTools(names) {
160
161
  * USER/LANG/TERM/TMPDIR) — `env` is `request.env ?? operatorEnv()` from the
161
162
  * caller, restoring today's actual behavior unless the agent's own
162
163
  * `env_allowlist` narrowed it (see agents.ts).
164
+ *
165
+ * `spec.sandbox` (SPF #15) is absent for every agent today — byte-identical
166
+ * to before this field existed. Set, it routes to `sandbox.factoryFor()`
167
+ * instead of `local()`; note NO `cwd` reaches `useSandbox` for a remote
168
+ * backend either way (a `cwd` override on `useSandbox` drops adapter-added
169
+ * properties — see the sandbox design doc §2). The workspace dir travels on
170
+ * the spec itself (`spec.sandbox.workspace_dir`), read by the adapter.
163
171
  */
164
- function sandboxFor(toolNames, cwd, env) {
165
- const base = local({ cwd, env });
166
- if (!toolNames)
172
+ function sandboxFor(spec) {
173
+ const base = spec.sandbox ? sandbox.factoryFor(spec.sandbox) : local({ cwd: spec.cwd, env: spec.env });
174
+ if (!spec.toolNames)
167
175
  return base;
168
- const factories = resolveBuiltinTools(toolNames);
176
+ const factories = resolveBuiltinTools(spec.toolNames);
169
177
  return { ...base, tools: (env) => factories.map((f) => f(env)) };
170
178
  }
171
179
  // ── model pattern validation ─────────────────────────────────────────────────
@@ -191,7 +199,7 @@ function sfAgentRender({ id }) {
191
199
  if (!spec)
192
200
  throw new Error(`agent_flue: no render spec registered for conversation ${id} — run() must set it before dispatching`);
193
201
  useModel(spec.model, { thinkingLevel: spec.thinking });
194
- useSandbox(sandboxFor(spec.toolNames, spec.cwd, spec.env));
202
+ useSandbox(sandboxFor(spec));
195
203
  const writeReport = useDataWriter("sf_report");
196
204
  useTool({
197
205
  name: "sf_report",
@@ -298,6 +306,7 @@ export async function run(request, onEvent, onSpawn, onExit) {
298
306
  outputSchema: request.output_schema,
299
307
  outputTypeName: request.output_type_name,
300
308
  env: request.env ?? operatorEnv(),
309
+ sandbox: request.sandbox,
301
310
  });
302
311
  const pid = process.pid ?? -1;
303
312
  onSpawn?.(pid);
@@ -8,7 +8,7 @@
8
8
  * disposes.
9
9
  */
10
10
  import { type TierResolution } from "./tiering.ts";
11
- import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type SFConfig } from "./data_types.ts";
11
+ import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type SFConfig, type SandboxSpec } from "./data_types.ts";
12
12
  /**
13
13
  * `undefined` (no `env_allowlist` configured — the default) means "don't
14
14
  * filter at all"; both backends treat that as `request.env ?? operatorEnv()`,
@@ -98,6 +98,24 @@ export declare function applyConfigEnv(env: Record<string, string>): void;
98
98
  export declare function resolve(cfg: SFConfig, name: string): AgentConfig;
99
99
  /** Fail fast: every required name must resolve to a usable agent. */
100
100
  export declare function validate(cfg: SFConfig, required: string[], requiredSuites?: string[], cwd?: string): void;
101
+ /**
102
+ * Config-only, per-agent — no `Run`, no session id, no network, no fs
103
+ * beyond the config already in hand. Called from `validate()`'s existing
104
+ * per-agent loop, chain-scoped (only agents the resolved chain requires).
105
+ * `spf doctor`'s equivalents (checks #7/#12/#13/#14/#17) walk the WHOLE
106
+ * roster instead — a deliberately wider, separate scope; see `validate()`'s
107
+ * own comment for why the two are not the same check.
108
+ */
109
+ export declare function validateSandboxConfig(cfg: SFConfig, agent: AgentConfig): string[];
110
+ /**
111
+ * `scope: "run"` is safe only when every required agent's create-time spec
112
+ * is provably identical — compared as backend + resolved env KEY NAMES
113
+ * (never values) + the create-time-reachable rest of the spec (image,
114
+ * setup, egress, workspace_dir, handoff_dir). A no-op for `scope: "agent"`
115
+ * (the default) and for a chain whose required agents all resolve to
116
+ * `local` — the common path pays nothing.
117
+ */
118
+ export declare function validateSandboxRunScope(cfg: SFConfig, required: string[]): string[];
101
119
  interface RunForAgents {
102
120
  cfg: SFConfig;
103
121
  adw_id: string;
@@ -143,6 +161,48 @@ interface RunForAgents {
143
161
  coding_agent: string;
144
162
  }) => void;
145
163
  }
164
+ /**
165
+ * PURE, SYNC — no network, no filesystem, no session id (session ids exist
166
+ * only after `agentSessionId()`, which runs after this — SPF #15's design
167
+ * doc §4.4 walks through exactly why the ordering forces that). `run.cfg` +
168
+ * `run.adw_id` + `run.repo_root` + `run.context_handoff_dir` + the agent in,
169
+ * `SandboxSpec | undefined` out. `undefined` (the resolved backend is
170
+ * "local") means `AgentRequest.sandbox` stays unset — byte-identical to
171
+ * before this feature existed.
172
+ *
173
+ * `handoff_sandbox` PRESERVES `handoff_host`'s shape —
174
+ * `<handoff_dir>/sessions/<adw_id>/context_handoff`, mirroring
175
+ * `run.context_handoff_dir` — because two shipped prompts
176
+ * (planner/user.md, documenter/user.md) parse `<adw_id>` out of that exact
177
+ * shape to name `specs/<adw_id>_<slug>.md`/`app_docs/<adw_id>_<slug>.md`. A
178
+ * bare `handoff_dir` would silently cost those filenames their provenance,
179
+ * with `gates.artifactsExist` still green (it only checks the CLAIMED path).
180
+ */
181
+ export declare function sandboxSpecFor(run: RunForAgents, agent: AgentConfig): SandboxSpec | undefined;
182
+ /**
183
+ * OUTBOUND (sandbox -> host), mutates the freshly parsed envelope in place.
184
+ * Rewrites `artifacts` and, when present, `changed_files` — the handoff
185
+ * rule is checked FIRST (disjoint from workspace_dir by validation, so
186
+ * order is not load-bearing here, but the implementation still checks it
187
+ * first so it stays correct if that invariant is ever relaxed). Applied
188
+ * immediately after every parse (the first prompt AND every JSON-repair/
189
+ * gate-correction re-parse), so it is total over every dispatch.
190
+ */
191
+ export declare function translateEnvelopePaths(envelope: EnvelopeBase, spec: SandboxSpec): void;
192
+ /**
193
+ * INBOUND (host -> sandbox), the exact inverse — applied to `call.previous`
194
+ * at the `variables` build site ONLY, before `JSON.stringify`. Returns a
195
+ * COPY: the persisted envelope and the caller's own object must keep host
196
+ * paths, so this one does not mutate where `translateEnvelopePaths` does.
197
+ *
198
+ * Rule order IS load-bearing here, unlike the outbound direction:
199
+ * `handoff_host` is `<data_dir>/sessions/<adw_id>/context_handoff` and
200
+ * `data_dir` resolves against `repo_root` by default, so `handoff_host` is
201
+ * normally UNDER `host_root`. Checking `host_root` first would rewrite a
202
+ * handoff-plane path to somewhere under `workspace_dir` the mirror never
203
+ * populates — so `handoff_host` is checked FIRST, always.
204
+ */
205
+ export declare function translateEnvelopeToSandbox(envelope: EnvelopeBase, spec: SandboxSpec): EnvelopeBase;
146
206
  /** One agent call: render prompts -> pi run -> typed parse -> gates -> envelope. */
147
207
  export declare function execute(run: RunForAgents, phase: Phase, call: AgentCall): Promise<EnvelopeBase>;
148
208
  export {};