@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.
- package/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- 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();
|
package/dist/core/agent_cc.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/core/agent_cc.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/core/agent_flue.js
CHANGED
|
@@ -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(
|
|
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
|
|
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);
|
package/dist/core/agents.d.ts
CHANGED
|
@@ -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 {};
|