@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.
Files changed (64) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/references/config.md +9 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +109 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +27 -27
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +86 -4
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +126 -12
  34. package/dist/core/data_types.js +101 -4
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/paths.d.ts +41 -4
  39. package/dist/core/paths.js +32 -3
  40. package/dist/core/quality.d.ts +7 -7
  41. package/dist/core/quality.js +16 -10
  42. package/dist/core/runner.d.ts +9 -3
  43. package/dist/core/runner.js +39 -27
  44. package/dist/core/session.d.ts +2 -2
  45. package/dist/core/session.js +39 -18
  46. package/dist/core/sqlite.d.ts +14 -7
  47. package/dist/core/sqlite.js +14 -7
  48. package/dist/core/trace_db.d.ts +118 -0
  49. package/dist/core/trace_db.js +278 -0
  50. package/dist/core/tracer.d.ts +64 -34
  51. package/dist/core/tracer.js +141 -69
  52. package/dist/core/watch.d.ts +4 -4
  53. package/dist/core/watch.js +2 -2
  54. package/dist/ui/server/app.js +10 -10
  55. package/dist/ui/server/db.d.ts +89 -21
  56. package/dist/ui/server/db.js +235 -99
  57. package/dist/ui/server/serve.d.ts +5 -1
  58. package/dist/ui/server/serve.js +4 -5
  59. package/package.json +1 -1
  60. package/web/assets/index-CQ3k1Y1-.css +1 -0
  61. package/web/assets/index-CU8tom6S.js +21 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CRujNW-1.js +0 -11
  64. package/web/assets/index-Cto6nuQL.css +0 -1
@@ -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 dbPath;
9
+ let db;
10
+ let sessionsDir;
11
+ let label;
10
12
  if (options["db"]) {
11
- dbPath = path.resolve(anchor.cwd, options["db"]);
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
- dbPath = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db).db_path;
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
- dbPath,
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 ${dbPath}`);
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 = new SfDb(wtDataPaths.db_path);
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 = new SfDb(wtDataPaths.db_path);
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
- db = new SfDb(dataPaths.db_path);
318
- const gates = db.gates(adwId);
319
- const session = db.session(adwId);
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
- db = new SfDb(dataPaths.db_path);
350
- return adwIds.every((id) => db.session(id) === null);
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
@@ -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. */
@@ -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 === "claude_code") {
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
- const dbPath = await asker.text("observability.db", { default: ".spf/data/spf.db" });
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 (dbPath !== ".spf/data/spf.db" || pollMs !== "500") {
511
- defaults["__observability__"] = { ...(dbPath !== ".spf/data/spf.db" ? { db: dbPath } : {}), ...(pollMs !== "500" ? { poll_ms: Number(pollMs) } : {}) };
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>;