@gr8ful/spf 0.15.0 → 0.17.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 +15 -5
- package/assets/skill/references/config.md +9 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +109 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +27 -27
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +86 -4
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +126 -12
- package/dist/core/data_types.js +101 -4
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/index.html +2 -2
- package/web/assets/index-CRujNW-1.js +0 -11
- package/web/assets/index-Cto6nuQL.css +0 -1
package/dist/cli/commands/ui.js
CHANGED
|
@@ -6,23 +6,33 @@ import { runUi } from "../../ui/server/serve.js";
|
|
|
6
6
|
export async function uiCommand(argv) {
|
|
7
7
|
const { options, flags } = parseCli(argv, ["cwd", "config", "db", "port"], ["no-open"]);
|
|
8
8
|
const anchor = paths.resolveAnchor(options["cwd"]);
|
|
9
|
-
let
|
|
9
|
+
let db;
|
|
10
|
+
let sessionsDir;
|
|
11
|
+
let label;
|
|
10
12
|
if (options["db"]) {
|
|
11
|
-
|
|
13
|
+
// `--db <path>` always names a local sqlite file — there is no D1
|
|
14
|
+
// equivalent of "point at this one file" from the CLI yet.
|
|
15
|
+
const dbPath = path.resolve(anchor.cwd, options["db"]);
|
|
16
|
+
db = { kind: "sqlite", path: dbPath };
|
|
17
|
+
label = dbPath;
|
|
12
18
|
}
|
|
13
19
|
else {
|
|
14
20
|
const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
|
|
15
|
-
|
|
21
|
+
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
22
|
+
db = dataPaths.db;
|
|
23
|
+
sessionsDir = dataPaths.sessions_dir;
|
|
24
|
+
label = db.kind === "sqlite" ? db.path : `d1:${db.database_id}`;
|
|
16
25
|
}
|
|
17
26
|
try {
|
|
18
27
|
const handle = await runUi({
|
|
19
|
-
|
|
28
|
+
db,
|
|
29
|
+
sessionsDir,
|
|
20
30
|
webDir: paths.WEB_DIR,
|
|
21
31
|
port: options["port"] ? Number.parseInt(options["port"], 10) : undefined,
|
|
22
32
|
open: !flags["no-open"],
|
|
23
33
|
});
|
|
24
34
|
console.log(`[spf] ui ${handle.url}`);
|
|
25
|
-
console.log(`[spf] db ${
|
|
35
|
+
console.log(`[spf] db ${label}`);
|
|
26
36
|
// runUi() already registers SIGINT/SIGTERM handlers that exit the
|
|
27
37
|
// process; just keep this call from returning until then.
|
|
28
38
|
await new Promise(() => { });
|
|
@@ -217,14 +217,14 @@ export async function watchInitCommand(argv) {
|
|
|
217
217
|
return 0;
|
|
218
218
|
}
|
|
219
219
|
/** Shared by `runChain`/`runRefine`/the fan-out lane's dispatch: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. Top-level (not a `watchCommand` local) so `makeWatchFanoutDispatch` below can share it — see that factory's own doc comment for why. */
|
|
220
|
-
function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
|
|
220
|
+
async function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
|
|
221
221
|
let detail = prefix;
|
|
222
222
|
let db;
|
|
223
223
|
try {
|
|
224
224
|
const wtAnchor = paths.resolveAnchor(cwd);
|
|
225
225
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
226
|
-
db =
|
|
227
|
-
const failed = db.phases(adwId).find((p) => p.status === "fail");
|
|
226
|
+
db = await SfDb.open(wtDataPaths.db, wtDataPaths.sessions_dir);
|
|
227
|
+
const failed = (await db.phases(adwId)).find((p) => p.status === "fail");
|
|
228
228
|
if (failed)
|
|
229
229
|
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
230
230
|
}
|
|
@@ -232,7 +232,7 @@ function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
|
|
|
232
232
|
// best-effort — the generic message above still points at where to look
|
|
233
233
|
}
|
|
234
234
|
finally {
|
|
235
|
-
db?.close();
|
|
235
|
+
await db?.close();
|
|
236
236
|
}
|
|
237
237
|
return detail;
|
|
238
238
|
}
|
|
@@ -245,14 +245,13 @@ function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
|
|
|
245
245
|
* is a nice-to-have, not something that gets to block the PR-open flow
|
|
246
246
|
* it's decorating. Top-level for the same reason as `detailFromFailedPhase`.
|
|
247
247
|
*/
|
|
248
|
-
function reviewSummaryFor(cfg, cwd, adwId) {
|
|
248
|
+
async function reviewSummaryFor(cfg, cwd, adwId) {
|
|
249
249
|
let db;
|
|
250
250
|
try {
|
|
251
251
|
const wtAnchor = paths.resolveAnchor(cwd);
|
|
252
252
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
253
|
-
db =
|
|
254
|
-
const envelope = db
|
|
255
|
-
.envelopes(adwId)
|
|
253
|
+
db = await SfDb.open(wtDataPaths.db, wtDataPaths.sessions_dir);
|
|
254
|
+
const envelope = (await db.envelopes(adwId))
|
|
256
255
|
.filter((e) => e.output_type === ReviewOutput.name)
|
|
257
256
|
.at(-1); // the LATEST verdict — a revise loop can produce several
|
|
258
257
|
if (!envelope?.payload_json)
|
|
@@ -264,7 +263,7 @@ function reviewSummaryFor(cfg, cwd, adwId) {
|
|
|
264
263
|
return undefined; // best-effort — see the doc comment above
|
|
265
264
|
}
|
|
266
265
|
finally {
|
|
267
|
-
db?.close();
|
|
266
|
+
await db?.close();
|
|
268
267
|
}
|
|
269
268
|
}
|
|
270
269
|
/**
|
|
@@ -309,14 +308,14 @@ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
|
|
|
309
308
|
* a shared handle in — the same per-call shape `detailFromFailedPhase`/
|
|
310
309
|
* `reviewSummaryFor` above already use against the same WAL db.
|
|
311
310
|
*/
|
|
312
|
-
const readMetrics = (adwId) => {
|
|
313
|
-
if (!existsSync(dataPaths.db_path))
|
|
314
|
-
return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
|
|
311
|
+
const readMetrics = async (adwId) => {
|
|
315
312
|
let db;
|
|
316
313
|
try {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
314
|
+
if (!(await SfDb.exists(dataPaths)))
|
|
315
|
+
return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
|
|
316
|
+
db = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
|
|
317
|
+
const gates = await db.gates(adwId);
|
|
318
|
+
const session = await db.session(adwId);
|
|
320
319
|
// `passed` is a SQLite integer boolean that CAN be NULL on a row an
|
|
321
320
|
// older tracer wrote. Counted explicitly in both directions, never as
|
|
322
321
|
// `!g.passed`: a NULL is unknown, and letting it read as a failure
|
|
@@ -332,7 +331,7 @@ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
|
|
|
332
331
|
return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
|
|
333
332
|
}
|
|
334
333
|
finally {
|
|
335
|
-
db?.close();
|
|
334
|
+
await db?.close();
|
|
336
335
|
}
|
|
337
336
|
};
|
|
338
337
|
/**
|
|
@@ -341,24 +340,25 @@ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
|
|
|
341
340
|
* candidate id. Safe direction is FALSE (see `WatchFanoutDeps.adwIdsFree`'s
|
|
342
341
|
* own doc comment): an unreadable db reports "taken" rather than "free".
|
|
343
342
|
*/
|
|
344
|
-
const adwIdsFree = (adwIds) => {
|
|
345
|
-
if (!existsSync(dataPaths.db_path))
|
|
346
|
-
return true; // no db yet — nothing to collide with
|
|
343
|
+
const adwIdsFree = async (adwIds) => {
|
|
347
344
|
let db;
|
|
348
345
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
346
|
+
if (!(await SfDb.exists(dataPaths)))
|
|
347
|
+
return true; // no db yet — nothing to collide with
|
|
348
|
+
db = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
|
|
349
|
+
const results = await Promise.all(adwIds.map((id) => db.session(id)));
|
|
350
|
+
return results.every((session) => session === null);
|
|
351
351
|
}
|
|
352
352
|
catch {
|
|
353
353
|
return false;
|
|
354
354
|
}
|
|
355
355
|
finally {
|
|
356
|
-
db?.close();
|
|
356
|
+
await db?.close();
|
|
357
357
|
}
|
|
358
358
|
};
|
|
359
|
-
const reviewFor = (opts) => ({
|
|
359
|
+
const reviewFor = async (opts) => ({
|
|
360
360
|
reviewRequired: resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer"),
|
|
361
|
-
reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId),
|
|
361
|
+
reviewSummary: await reviewSummaryFor(cfg, opts.cwd, opts.adwId),
|
|
362
362
|
});
|
|
363
363
|
return { runAttempt, readMetrics, adwIdsFree, reviewFor };
|
|
364
364
|
}
|
|
@@ -586,9 +586,9 @@ export async function watchCommand(argv) {
|
|
|
586
586
|
// is reflected here too, not just each chain's static/YAML default.
|
|
587
587
|
const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
|
|
588
588
|
if (code === 0) {
|
|
589
|
-
return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId) };
|
|
589
|
+
return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: await reviewSummaryFor(cfg, opts.cwd, opts.adwId) };
|
|
590
590
|
}
|
|
591
|
-
const detail = detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
591
|
+
const detail = await detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
592
592
|
return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
|
|
593
593
|
};
|
|
594
594
|
/**
|
|
@@ -620,7 +620,7 @@ export async function watchCommand(argv) {
|
|
|
620
620
|
// comment for the KNOWN LIMITATION this fixes.
|
|
621
621
|
const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
|
|
622
622
|
if (code !== 0) {
|
|
623
|
-
const detail = detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
623
|
+
const detail = await detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
624
624
|
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [], split: [] };
|
|
625
625
|
}
|
|
626
626
|
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
package/dist/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import * as agents from "../core/agents.js";
|
|
9
9
|
import * as agentCc from "../core/agent_cc.js";
|
|
10
10
|
import * as agentFlue from "../core/agent_flue.js";
|
|
11
|
+
import * as agentOpencode from "../core/agent_opencode.js";
|
|
11
12
|
import * as notify from "../core/notify/notifier.js";
|
|
12
13
|
import * as otel from "../core/otel.js";
|
|
13
14
|
import * as paths from "../core/paths.js";
|
|
@@ -212,7 +213,7 @@ export async function main() {
|
|
|
212
213
|
process.exitCode = await eventsCommand(rest);
|
|
213
214
|
return;
|
|
214
215
|
case "abort":
|
|
215
|
-
process.exitCode = abortCommand(rest);
|
|
216
|
+
process.exitCode = await abortCommand(rest);
|
|
216
217
|
return;
|
|
217
218
|
default: {
|
|
218
219
|
const chain = findChain(cmd);
|
|
@@ -243,6 +244,7 @@ export async function main() {
|
|
|
243
244
|
// agent_cc.ts's shutdown() just kills any still-running claude children.
|
|
244
245
|
await agentFlue.shutdown();
|
|
245
246
|
await agentCc.shutdown();
|
|
247
|
+
await agentOpencode.shutdown();
|
|
246
248
|
// Belt-and-braces alongside the per-run `sandbox.withRunScope` wraps in
|
|
247
249
|
// dispatchChain/watch/fanout: catches any sandboxed run's lease this
|
|
248
250
|
// process still holds when the CLI exits (a per-run wrap that itself
|
package/dist/cli/interview.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface DetectedContext {
|
|
|
6
6
|
gitName?: string;
|
|
7
7
|
scripts: Record<string, string>;
|
|
8
8
|
claudeOnPath: boolean;
|
|
9
|
+
opencodeOnPath: boolean;
|
|
9
10
|
/** Whatever's already in `.env` — shown masked so a re-run can offer "keep current" instead of asking blind. */
|
|
10
11
|
existingEnv: Map<string, string>;
|
|
11
12
|
/** The packaged roster's agent names (planner, builder, scout, reviewer, documenter today) — read from the built-in config so a 6th agent added there needs no interview change. */
|
package/dist/cli/interview.js
CHANGED
|
@@ -62,6 +62,7 @@ export function gatherContext(repoRoot, existingEnv = new Map()) {
|
|
|
62
62
|
gitName: gitConfigValue(repoRoot, "user.name"),
|
|
63
63
|
scripts: readScripts(repoRoot),
|
|
64
64
|
claudeOnPath: binaryOnPath("claude"),
|
|
65
|
+
opencodeOnPath: binaryOnPath("opencode"),
|
|
65
66
|
existingEnv,
|
|
66
67
|
rosterNames: readRosterNames(),
|
|
67
68
|
};
|
|
@@ -90,10 +91,38 @@ export async function runInterview(asker, ctx) {
|
|
|
90
91
|
asker.heading("Coding agent");
|
|
91
92
|
const codingAgent = await asker.select("Which backend runs each agent?", [
|
|
92
93
|
{ value: "claude_code", label: "claude_code — shells out to the `claude` CLI you already use", hint: "cc" },
|
|
94
|
+
{ value: "opencode", label: "opencode — shells out to the `opencode` CLI, provider/model-id (anthropic, ollama, ...)", hint: "oc" },
|
|
93
95
|
{ value: "flue", label: "flue — in-process, provider/model-id (openai, anthropic, openrouter, ...)" },
|
|
94
96
|
], "claude_code");
|
|
95
97
|
defaults.coding_agent = codingAgent;
|
|
96
|
-
if (codingAgent === "
|
|
98
|
+
if (codingAgent === "opencode") {
|
|
99
|
+
if (!ctx.opencodeOnPath) {
|
|
100
|
+
asker.note("warning: `opencode` was not found on PATH — install it (npm install -g opencode-ai), or pick a launch command below, before running spf.");
|
|
101
|
+
}
|
|
102
|
+
const launchCommand = await asker.text("Launch command for the `opencode` CLI", { default: "opencode" });
|
|
103
|
+
if (launchCommand !== "opencode")
|
|
104
|
+
configEnv["SPF_OPENCODE_CMD"] = launchCommand;
|
|
105
|
+
// opencode's own model vocabulary is "provider/model-id" — same shape
|
|
106
|
+
// Flue speaks (see agent_opencode.ts's module doc comment) — so this
|
|
107
|
+
// asks the same way the flue branch below does, minus Flue's own
|
|
108
|
+
// resolveModel() validation (that function belongs to agent_flue.ts,
|
|
109
|
+
// not opencode).
|
|
110
|
+
const model = await asker.text('Model ("provider/model-id", opencode\'s own vocabulary)', {
|
|
111
|
+
default: "anthropic/claude-sonnet-4-6",
|
|
112
|
+
validate: (val) => (val.trim() ? null : "required"),
|
|
113
|
+
});
|
|
114
|
+
defaults.model = model;
|
|
115
|
+
asker.note("opencode manages its own authentication (`opencode auth login`, or a provider's own env var like ANTHROPIC_API_KEY) — spf does not drive that login flow. `spf doctor` checks for ~/.local/share/opencode/auth.json (informational only).");
|
|
116
|
+
// Same pinned-roster fix the claude_code/ollama/cloudflare branches below
|
|
117
|
+
// already apply: the packaged roster pins planner/reviewer/documenter to
|
|
118
|
+
// their own Flue-style provider/model-id strings, which always win over
|
|
119
|
+
// defaults.model — override all three to the model chosen above.
|
|
120
|
+
for (const name of ["planner", "reviewer", "documenter"]) {
|
|
121
|
+
agentOverrides.push({ name, model: defaults.model });
|
|
122
|
+
}
|
|
123
|
+
notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to match, since they're already valid provider/model-id strings for opencode too, but not necessarily ones you've configured auth for.");
|
|
124
|
+
}
|
|
125
|
+
else if (codingAgent === "claude_code") {
|
|
97
126
|
if (!ctx.claudeOnPath) {
|
|
98
127
|
asker.note("warning: `claude` was not found on PATH — install it (or pick a launch command below) before running spf.");
|
|
99
128
|
}
|
|
@@ -505,10 +534,63 @@ export async function runInterview(asker, ctx) {
|
|
|
505
534
|
const protectedList = protectedFiles.split(",").map((s) => s.trim()).filter(Boolean);
|
|
506
535
|
if (protectedList.join(",") !== ".spf/,spf.config.yaml")
|
|
507
536
|
defaults.protected_files = protectedList;
|
|
508
|
-
|
|
537
|
+
// Local sqlite (today's default, unchanged) or a remote Cloudflare D1
|
|
538
|
+
// database (SPF #66, PR 3) — `resolveObservabilityDb`/`resolveDataPaths`
|
|
539
|
+
// (data_types.ts/paths.ts, PR 1+2) already accept either shape; this is
|
|
540
|
+
// just wiring the interview up to the option. `spf init --yes` never
|
|
541
|
+
// reaches this "advanced" gate at all, so it stays zero-prompt and
|
|
542
|
+
// defaults to local exactly as before.
|
|
543
|
+
asker.note("Data egress: choosing remote (D1) sends the complete trace — your raw request text, tool arguments and results, envelope payloads, and gate violation details, not just spans or metadata — to Cloudflare on every run. Stick with local sqlite if that data should never leave this machine.");
|
|
544
|
+
const dbBackend = await asker.select("Store trace data locally or remotely (Cloudflare D1)?", [
|
|
545
|
+
{ value: "local", label: "local sqlite (default)" },
|
|
546
|
+
{ value: "d1", label: "remote — Cloudflare D1 (sends full trace off-box, see note above)" },
|
|
547
|
+
], "local");
|
|
548
|
+
let dbValue;
|
|
549
|
+
if (dbBackend === "d1") {
|
|
550
|
+
const databaseId = await asker.text("Cloudflare D1 database_id", {
|
|
551
|
+
validate: (v) => (v.trim() ? null : "required"),
|
|
552
|
+
});
|
|
553
|
+
// `account_id_env`/`api_token_env` both default (schema-side, see
|
|
554
|
+
// data_types.ts's D1DbConfigSchema) to the SAME env vars the Cloudflare
|
|
555
|
+
// Workers AI provider branch above already reads — leaving both out
|
|
556
|
+
// here keeps the generated config terse (the object below just says
|
|
557
|
+
// `{kind:"d1", database_id}`) while still resolving to the exact same
|
|
558
|
+
// normalized descriptor `resolveObservabilityDb` would produce with
|
|
559
|
+
// them spelled out.
|
|
560
|
+
dbValue = { kind: "d1", database_id: databaseId };
|
|
561
|
+
// Reuse CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_API_TOKEN if the coding-agent
|
|
562
|
+
// section above already collected them (this run is ALSO routed
|
|
563
|
+
// through Cloudflare Workers AI) — `envExampleKeys` is the definitive
|
|
564
|
+
// record of "already asked this run", true whether or not the answer
|
|
565
|
+
// itself ended up blank (e.g. an existing .env value was kept as-is).
|
|
566
|
+
// Otherwise collect them now, same validation/env-bucket convention as
|
|
567
|
+
// that branch: both are secrets, so they go into `env` (written to
|
|
568
|
+
// `.env`), never `configEnv`.
|
|
569
|
+
if (!envExampleKeys.includes("CLOUDFLARE_ACCOUNT_ID")) {
|
|
570
|
+
const accountId = await asker.text("CLOUDFLARE_ACCOUNT_ID", {
|
|
571
|
+
validate: (v) => (v.trim() ? null : "required — find it in the Cloudflare dashboard (right sidebar)"),
|
|
572
|
+
});
|
|
573
|
+
env["CLOUDFLARE_ACCOUNT_ID"] = accountId;
|
|
574
|
+
envExampleKeys.push("CLOUDFLARE_ACCOUNT_ID");
|
|
575
|
+
}
|
|
576
|
+
if (!envExampleKeys.includes("CLOUDFLARE_API_TOKEN")) {
|
|
577
|
+
const apiToken = await asker.secret("CLOUDFLARE_API_TOKEN", { current: ctx.existingEnv.get("CLOUDFLARE_API_TOKEN") });
|
|
578
|
+
if (apiToken)
|
|
579
|
+
env["CLOUDFLARE_API_TOKEN"] = apiToken;
|
|
580
|
+
envExampleKeys.push("CLOUDFLARE_API_TOKEN");
|
|
581
|
+
}
|
|
582
|
+
// D1's own consistency model, not local WAL sqlite's — see
|
|
583
|
+
// trace_db.ts's D1TraceDb class doc comment (SPF #66) for the full
|
|
584
|
+
// spike-verified explanation this paraphrases.
|
|
585
|
+
asker.note("D1 is not local WAL sqlite: spf ui and a running chain each talk to it over independent HTTP calls, not one shared file, so (per Cloudflare's own read-replication docs) a live trace view can briefly show a slightly-stale read while a chain is actively writing — D1's own consistency model, not a bug. No phase/gate/run outcome depends on that live read.");
|
|
586
|
+
asker.note("spf doctor probes the resolved D1 endpoint (informational, never a hard failure).");
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
dbValue = await asker.text("observability.db", { default: ".spf/data/spf.db" });
|
|
590
|
+
}
|
|
509
591
|
const pollMs = await asker.text("observability.poll_ms", { default: "500" });
|
|
510
|
-
if (
|
|
511
|
-
defaults["__observability__"] = { ...(
|
|
592
|
+
if (dbValue !== ".spf/data/spf.db" || pollMs !== "500") {
|
|
593
|
+
defaults["__observability__"] = { ...(dbValue !== ".spf/data/spf.db" ? { db: dbValue } : {}), ...(pollMs !== "500" ? { poll_ms: Number(pollMs) } : {}) };
|
|
512
594
|
}
|
|
513
595
|
if (watch) {
|
|
514
596
|
const watchPollMs = await asker.text("watch.poll_ms", { default: "60000" });
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode coding agent interface — a third backend alongside `agent_flue.ts`
|
|
3
|
+
* (`coding_agent: flue`) and `agent_cc.ts` (`coding_agent: claude_code`), for
|
|
4
|
+
* running agents on the `opencode` CLI. Subprocess-based, for the same
|
|
5
|
+
* reason `agent_cc.ts` is: shelling out costs SPF zero new dependencies, and
|
|
6
|
+
* anyone using this backend needs the `opencode` CLI installed anyway.
|
|
7
|
+
*
|
|
8
|
+
* The `opencode` command is resolved from `PATH` by default. To route it
|
|
9
|
+
* through a wrapper, proxy, or launcher, set `SPF_OPENCODE_CMD` — same
|
|
10
|
+
* mechanism, same rationale, and same `{model}` token substitution as
|
|
11
|
+
* `agent_cc.ts`'s `SPF_CLAUDE_CMD` (see that module's doc comment for the
|
|
12
|
+
* `ollama launch`-style motivating example). Unlike `SPF_CLAUDE_CMD`, no
|
|
13
|
+
* `ollama launch`-shaped wrapper is special-cased here: that fix was for a
|
|
14
|
+
* specific cobra flag-parsing quirk in `claude`'s own launcher story, and
|
|
15
|
+
* there is no equivalent evidence for `opencode` — inventing one would be
|
|
16
|
+
* guessing at a bug that may not exist.
|
|
17
|
+
*
|
|
18
|
+
* This module's understanding of the real `opencode` CLI started from a docs
|
|
19
|
+
* research spike, then was checked against real `opencode run --format json`
|
|
20
|
+
* invocations (via `npx opencode-ai@1.18.26`, a free `opencode/*` model) —
|
|
21
|
+
* a plain text reply, a bash tool call, a slow bash tool call (to look for
|
|
22
|
+
* multi-line status transitions), a `--session <id>` resume, and an invalid
|
|
23
|
+
* `--model` string. Every fact below is tagged:
|
|
24
|
+
*
|
|
25
|
+
* [OFFICIAL] — stated in opencode's own published docs.
|
|
26
|
+
* [VERIFIED] — confirmed against a real `opencode run --format json`
|
|
27
|
+
* invocation, per the spike above. One real run is
|
|
28
|
+
* corroboration, not a version-pinned contract — still
|
|
29
|
+
* parsed defensively.
|
|
30
|
+
* [SOURCE] — inferred from opencode's repo/issue tracker, NOT written
|
|
31
|
+
* down anywhere in prose, and NOT independently exercised by
|
|
32
|
+
* the spike above (e.g. a multi-line tool_use transition
|
|
33
|
+
* never actually appeared in any run tried). High confidence
|
|
34
|
+
* on field NAMES, but not a contractual guarantee — parsed
|
|
35
|
+
* defensively throughout this module for exactly that
|
|
36
|
+
* reason.
|
|
37
|
+
*
|
|
38
|
+
* COMMAND SHAPE [OFFICIAL]: `opencode run [message..]` — the prompt is a
|
|
39
|
+
* POSITIONAL argument, not a flag (contrast `claude -p <prompt>`, also
|
|
40
|
+
* positional-ish but behind `-p`). Confirmed flags this module uses:
|
|
41
|
+
* `--model`/`-m` (`provider/model` string, free-form — same vocabulary
|
|
42
|
+
* shape as Flue's own `provider/model-id`, e.g. `anthropic/claude-sonnet-4-
|
|
43
|
+
* 20250514`, `ollama/qwen3-coder:30b`), `--format json` (structured NDJSON
|
|
44
|
+
* output — opencode's analog of `claude`'s `--output-format stream-json`),
|
|
45
|
+
* `--dir` (working directory), `--session <id>` (resume a specific
|
|
46
|
+
* session), `--auto` (auto-approve permissions not explicitly denied —
|
|
47
|
+
* opencode's rough analog of `claude`'s `--dangerously-skip-permissions`),
|
|
48
|
+
* `--variant` (provider-specific reasoning effort — opencode's analog of
|
|
49
|
+
* `claude`'s `--effort`).
|
|
50
|
+
*
|
|
51
|
+
* NO SYSTEM-PROMPT FLAG [OFFICIAL]: opencode has nothing like `claude`'s
|
|
52
|
+
* `--system-prompt`. `request.system_prompt` is prepended to the message
|
|
53
|
+
* text instead (see `run()` below) — a REAL LIMITATION, not a workaround
|
|
54
|
+
* that fully replicates a separate channel: from opencode's own point of
|
|
55
|
+
* view, the system prompt is just the first paragraph of the user's
|
|
56
|
+
* message, not a distinct role in the transcript it sees.
|
|
57
|
+
*
|
|
58
|
+
* NO PER-CALL TOOL-ALLOWLIST FLAG [OFFICIAL]: tool restriction is
|
|
59
|
+
* CONFIG-ONLY, via a `permission` map in an `opencode.json` file (values
|
|
60
|
+
* `"allow"`/`"ask"`/`"deny"` per tool name) pointed at by the `OPENCODE_CONFIG`
|
|
61
|
+
* env var — see `writeTempPermissionConfig()` below. `--auto` only
|
|
62
|
+
* suppresses `"ask"`; it never overrides an explicit `"deny"`.
|
|
63
|
+
*
|
|
64
|
+
* KNOWN LIMITATION — CONFIG PRECEDENCE: per opencode's own config-merge
|
|
65
|
+
* docs, a target repo's OWN project-level `opencode.json` (if one exists)
|
|
66
|
+
* merges at HIGHER precedence than the file `OPENCODE_CONFIG` points at.
|
|
67
|
+
* That means a repo carrying its own `opencode.json` can silently override
|
|
68
|
+
* (widen or narrow) the restriction this module writes — a real, documented
|
|
69
|
+
* gap in this backend's tool-restriction guarantee, not a bug this module
|
|
70
|
+
* can paper over from the outside.
|
|
71
|
+
*
|
|
72
|
+
* `write`/`apply_patch` ARE GATED THROUGH `edit` [OFFICIAL]: opencode's own
|
|
73
|
+
* docs say these two are not independent permission keys — both ride the
|
|
74
|
+
* `edit` key. This module never writes `write`/`apply_patch` keys of their
|
|
75
|
+
* own for exactly that reason.
|
|
76
|
+
*
|
|
77
|
+
* NDJSON EVENT SHAPE [VERIFIED]: one JSON object per line,
|
|
78
|
+
* `{ type, timestamp, sessionID, part, ... }`. Types this module reacts to:
|
|
79
|
+
* `step_start` (carries the REAL `sessionID` — captured from the first one
|
|
80
|
+
* seen and returned as `AgentResult.session_id`), `tool_use` (carries
|
|
81
|
+
* `part.tool`/`part.callID`/`part.state.status`/`part.state.input`/
|
|
82
|
+
* `part.state.output`/`part.state.time.{start,end}` — every observed call,
|
|
83
|
+
* even a deliberately slow one, arrived as exactly ONE already-terminal
|
|
84
|
+
* line, `status: "completed"`, never a separate pending/running line first),
|
|
85
|
+
* `text` (carries the response text at `part.text`), `step_finish` (carries
|
|
86
|
+
* `part.reason`: `"stop"` = done, `"tool-calls"` = more coming; and
|
|
87
|
+
* `part.tokens.{input,output,reasoning,cache.{read,write}}` +
|
|
88
|
+
* `part.cost` as a plain number — ALL VERIFIED field names/shapes), `error`
|
|
89
|
+
* (carries `error.name`/`error.data.message` — verified via an invalid
|
|
90
|
+
* `--model` string, which this build surfaced as `error.name:
|
|
91
|
+
* "UnknownError"` with exit code 1, NOT the exit-0-on-error upstream bug
|
|
92
|
+
* described below; that bug may be real for other error classes/versions,
|
|
93
|
+
* so the defense against it stays).
|
|
94
|
+
*
|
|
95
|
+
* NDJSON EVENT SHAPE, UNVERIFIED PORTION [SOURCE]: whether `tool_use` can
|
|
96
|
+
* ever arrive as MULTIPLE lines for the same call (a pending/running status
|
|
97
|
+
* before the terminal one) was not observed in the spike above — every call
|
|
98
|
+
* tried was a fast bash command that appeared already-`completed` on its
|
|
99
|
+
* first (and only) line. `OcToolCallTracker` still defensively folds a
|
|
100
|
+
* multi-line sequence if one occurs (see its own doc comment), but that path
|
|
101
|
+
* is unexercised, not confirmed absent.
|
|
102
|
+
*
|
|
103
|
+
* SESSION IDS ARE NOT CLIENT-CHOOSABLE [OFFICIAL + VERIFIED]: opencode's
|
|
104
|
+
* server assigns a ULID-based id (`ses_<26 chars>`, exact shape confirmed —
|
|
105
|
+
* e.g. `ses_f9bf9448cffe9LnZCxoR5m2uyf`) on session creation. The spike
|
|
106
|
+
* above also confirmed the RESUME half: capturing a `sessionID` from one
|
|
107
|
+
* call and passing it back via `--session <id>` on a second, unrelated
|
|
108
|
+
* `run()` genuinely continued the first call's conversation (the model
|
|
109
|
+
* correctly recalled content from the first turn). So a first-contact call
|
|
110
|
+
* passes NO `--session`/`--continue` at all, and the real id is captured
|
|
111
|
+
* from the first `step_start` event. See
|
|
112
|
+
* `pendingSessionLabel()` for the placeholder this module hands back before
|
|
113
|
+
* that capture happens, and `agents.ts`'s `agentSessionId()`/`send()` for how
|
|
114
|
+
* the placeholder gets replaced with the real id for the REST of a phase's
|
|
115
|
+
* retries — this is the "session-id lifecycle" change described in that
|
|
116
|
+
* module.
|
|
117
|
+
*
|
|
118
|
+
* DEVIATION FROM A LITERAL "only pass --session when request.resume" RULE:
|
|
119
|
+
* within one phase, `agents.ts`'s `send()` re-assigns its local `sessionId`
|
|
120
|
+
* to whatever THIS module's last call returned, but `AgentRequest.resume`
|
|
121
|
+
* itself is computed once per phase and does NOT flip to `true` for a
|
|
122
|
+
* same-phase JSON-repair retry or gate-correction (`agents.ts` only ever
|
|
123
|
+
* threads `!isNewSession` through, unchanged, on every `send()` in a phase).
|
|
124
|
+
* Passed literally, that would mean: brand-new phase, first call succeeds
|
|
125
|
+
* and captures a real `ses_...` id — the very next correction in that same
|
|
126
|
+
* phase would see `request.resume === false` and `request.session_id` ===
|
|
127
|
+
* a REAL id, and a literal "only pass --session when resume" rule would
|
|
128
|
+
* drop `--session` entirely, causing opencode to silently open a SECOND,
|
|
129
|
+
* unrelated session and lose the conversation the correction is supposed to
|
|
130
|
+
* continue. `run()` instead keys off whether `request.session_id` is this
|
|
131
|
+
* module's OWN placeholder shape (see `isPendingSessionLabel()`): a
|
|
132
|
+
* placeholder means genuinely first contact (no `--session`, let opencode
|
|
133
|
+
* mint one); anything else — a same-phase corrected id OR a real id carried
|
|
134
|
+
* over from `agent_map.json` on `resume: true` — gets `--session <id>`.
|
|
135
|
+
* This is a deliberate judgment call flagged for review: it stays
|
|
136
|
+
* functionally equivalent to "pass --session whenever there's a real id to
|
|
137
|
+
* resume," which is what the source instructions' own mechanism (the
|
|
138
|
+
* mid-phase `sessionId` correction) requires to actually work.
|
|
139
|
+
*
|
|
140
|
+
* KNOWN UPSTREAM BUGS [SOURCE, filed against opencode's own repo]: exit code
|
|
141
|
+
* can be 0 even on a session error or an invalid-model error — this module
|
|
142
|
+
* never trusts exit code 0 alone; it also requires either a `step_finish`
|
|
143
|
+
* with `reason: "stop"` or an explicit `error` event, and treats "the
|
|
144
|
+
* stream ended with neither" as a hard failure too (same spirit as
|
|
145
|
+
* `agent_cc.ts`'s "no result message" check). Some invocations reportedly
|
|
146
|
+
* hang indefinitely on upstream API errors with no exit code ever — this
|
|
147
|
+
* module does NOT attempt a watchdog/timeout for that (out of scope, the
|
|
148
|
+
* same choice `agent_cc.ts` makes: timeouts are the caller's job); it only
|
|
149
|
+
* guarantees that a missing final event, once the process DOES exit, throws
|
|
150
|
+
* a clear error instead of something worse.
|
|
151
|
+
*
|
|
152
|
+
* STDIN [OFFICIAL]: opencode reads stdin when it is not a TTY, merging it
|
|
153
|
+
* with the positional message. `child.stdin.end()` runs immediately after
|
|
154
|
+
* spawn — not for `agent_cc.ts`'s reason (a ~3s "waiting to see if anything
|
|
155
|
+
* is piped" stall), but to avoid ANY unintended stdin-content merge into the
|
|
156
|
+
* prompt, since the full prompt (system + user) already travels as the
|
|
157
|
+
* positional `message` argument.
|
|
158
|
+
*
|
|
159
|
+
* AUTH [OFFICIAL]: `~/.local/share/opencode/auth.json` is the credential
|
|
160
|
+
* store; `opencode auth list` is the documented non-interactive
|
|
161
|
+
* is-it-configured check. This module does not drive `opencode auth login`
|
|
162
|
+
* — see `cli/commands/doctor.ts`'s opencode checks, which treat auth the
|
|
163
|
+
* same way `claude login` is treated for `claude_code`: already the
|
|
164
|
+
* operator's job, informational only.
|
|
165
|
+
*
|
|
166
|
+
* KNOWN RISK — SINGLE-ARGV PROMPT SIZE: `system_prompt` + `prompt` travel as
|
|
167
|
+
* ONE positional argv element (see `run()`'s `message`), unlike
|
|
168
|
+
* `agent_cc.ts`'s two separate flags plus a separate `--json-schema` blob.
|
|
169
|
+
* On Linux, `execve` enforces a ~128KB ceiling PER ARGUMENT
|
|
170
|
+
* (`MAX_ARG_STRLEN`), independent of the much larger total `ARG_MAX` — a
|
|
171
|
+
* large combined system+user prompt (e.g. a big `previous_envelope` JSON
|
|
172
|
+
* blob folded into the user prompt, see `agents.ts`) could approach that
|
|
173
|
+
* ceiling and fail with a bare `E2BIG` via `child.on("error")`, for a
|
|
174
|
+
* prompt pair that would run fine under `claude_code`. STDIN is
|
|
175
|
+
* deliberately closed above, foreclosing the natural workaround (opencode
|
|
176
|
+
* DOES read and merge non-TTY stdin). Not fixed here: no measurement of
|
|
177
|
+
* real envelope sizes against this ceiling has been done, and the fix
|
|
178
|
+
* (piping the prompt some other way) needs opencode-side confirmation of
|
|
179
|
+
* what it actually supports — disclosed as a real, unverified risk rather
|
|
180
|
+
* than guessed at.
|
|
181
|
+
*/
|
|
182
|
+
import type { AgentRequest, AgentResult } from "./data_types.ts";
|
|
183
|
+
/** One line of `opencode run --format json` output, loosely typed — see the module doc comment for what's actually verified vs. inferred. */
|
|
184
|
+
export type OcStreamMessage = Record<string, any>;
|
|
185
|
+
/**
|
|
186
|
+
* Folds `tool_use` events into the SAME record shape `agent_flue`'s
|
|
187
|
+
* `ToolCallTracker` and `agent_cc`'s `CcToolCallTracker` produce, so
|
|
188
|
+
* `agents.ts`'s `eventForwarder` needs no backend-specific branching beyond
|
|
189
|
+
* picking which tracker class to instantiate.
|
|
190
|
+
*
|
|
191
|
+
* Unlike CC's assistant/tool_use + user/tool_result PAIR (two distinct
|
|
192
|
+
* messages, linked by `tool_use_id`), opencode's NDJSON carries ONE
|
|
193
|
+
* `tool_use` event per call [VERIFIED for the common case: every call
|
|
194
|
+
* observed in the module's spike, including a deliberately slow one,
|
|
195
|
+
* arrived as a single already-`"completed"` line]. Whether `part.state.status`
|
|
196
|
+
* can ALSO transition across multiple lines for one call (e.g. `pending` ->
|
|
197
|
+
* `running` -> `completed`/`error`) before that terminal line remains
|
|
198
|
+
* [SOURCE]-only — not observed, not confirmed absent — so this folds on
|
|
199
|
+
* "looks terminal" rather than a hardcoded status enum, in case it does:
|
|
200
|
+
* `state.output` being present is treated as the authoritative terminal
|
|
201
|
+
* signal (an explicit `"error"`/`"completed"`/`"done"` status also counts).
|
|
202
|
+
*/
|
|
203
|
+
export declare class OcToolCallTracker {
|
|
204
|
+
private open;
|
|
205
|
+
private closed;
|
|
206
|
+
observe(message: OcStreamMessage): Record<string, any> | null;
|
|
207
|
+
}
|
|
208
|
+
export declare function isKnownToolName(name: string): boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Resolve `SPF_OPENCODE_CMD` for THIS call, substituting a literal `{model}`
|
|
211
|
+
* token with `model` — same mechanism and rationale as `agent_cc.ts`'s
|
|
212
|
+
* `resolveClaudeCmdSpec`. Exported as its own pure function for the same
|
|
213
|
+
* reason: unit-testable without spawning a real subprocess.
|
|
214
|
+
*/
|
|
215
|
+
export declare function resolveOpencodeCmdSpec(model: string): string;
|
|
216
|
+
/**
|
|
217
|
+
* NOT a real session id, and NOT interchangeable with `agent_cc.ts`'s
|
|
218
|
+
* `newSessionId()` — opencode's server assigns its own `ses_<ULID>` id on
|
|
219
|
+
* first contact; nothing this module mints is ever valid to pass to
|
|
220
|
+
* `--session`. This exists purely as a COSMETIC placeholder for
|
|
221
|
+
* `agents.ts`'s pre-call logging (the `agent_start` trace event, the
|
|
222
|
+
* console line) before the real id is captured from the first response —
|
|
223
|
+
* see `run()`'s session-id handling and this module's doc comment for the
|
|
224
|
+
* full lifecycle.
|
|
225
|
+
*/
|
|
226
|
+
export declare function pendingSessionLabel(): string;
|
|
227
|
+
/**
|
|
228
|
+
* The `--session` decision — see the module doc comment's "DEVIATION FROM A
|
|
229
|
+
* LITERAL only-pass-when-resume RULE" note for why this keys off the id's
|
|
230
|
+
* SHAPE (a placeholder means genuinely first contact) rather than
|
|
231
|
+
* `request.resume`. Extracted as its own pure function specifically so this
|
|
232
|
+
* judgment call is unit-testable without spawning a real subprocess.
|
|
233
|
+
*/
|
|
234
|
+
export declare function sessionArgs(sessionId: string): string[];
|
|
235
|
+
/** Kill any still-running `opencode` children — call once, at process exit. Safe if none are running. */
|
|
236
|
+
export declare function shutdown(): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* Run one `opencode run` turn. See the module doc comment for the full
|
|
239
|
+
* session-id lifecycle, the system-prompt-via-prepend limitation, and the
|
|
240
|
+
* tool-restriction-via-temp-config mechanism.
|
|
241
|
+
*
|
|
242
|
+
* `onEvent` receives each parsed NDJSON line UNFOLDED, exactly as
|
|
243
|
+
* `agent_cc.run()`/`agent_flue.run()` forward their own raw events — folding
|
|
244
|
+
* into one record per tool call is `eventForwarder`'s job (`agents.ts`), via
|
|
245
|
+
* this module's `OcToolCallTracker`.
|
|
246
|
+
*/
|
|
247
|
+
export declare function run(request: AgentRequest, onEvent?: (message: OcStreamMessage) => void, onSpawn?: (pid: number) => void, onExit?: (pid: number) => void): Promise<AgentResult>;
|