@gr8ful/spf 0.14.0 → 0.16.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 (77) hide show
  1. package/README.md +51 -9
  2. package/assets/skill/references/config.md +12 -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 +121 -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 +91 -30
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +95 -5
  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 +356 -15
  34. package/dist/core/data_types.js +180 -7
  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/issues/github_provider.d.ts +66 -2
  39. package/dist/core/issues/github_provider.js +161 -2
  40. package/dist/core/issues/jira_provider.d.ts +50 -9
  41. package/dist/core/issues/jira_provider.js +62 -2
  42. package/dist/core/paths.d.ts +41 -4
  43. package/dist/core/paths.js +32 -3
  44. package/dist/core/quality.d.ts +7 -7
  45. package/dist/core/quality.js +16 -10
  46. package/dist/core/refine.js +2 -2
  47. package/dist/core/runner.d.ts +9 -3
  48. package/dist/core/runner.js +39 -27
  49. package/dist/core/session.d.ts +2 -2
  50. package/dist/core/session.js +39 -18
  51. package/dist/core/sqlite.d.ts +14 -7
  52. package/dist/core/sqlite.js +14 -7
  53. package/dist/core/trace_db.d.ts +118 -0
  54. package/dist/core/trace_db.js +278 -0
  55. package/dist/core/tracer.d.ts +64 -34
  56. package/dist/core/tracer.js +141 -69
  57. package/dist/core/watch.d.ts +4 -4
  58. package/dist/core/watch.js +2 -2
  59. package/dist/ui/server/app.js +10 -10
  60. package/dist/ui/server/db.d.ts +89 -21
  61. package/dist/ui/server/db.js +235 -99
  62. package/dist/ui/server/serve.d.ts +5 -1
  63. package/dist/ui/server/serve.js +4 -5
  64. package/package.json +1 -1
  65. package/web/assets/index-CQ3k1Y1-.css +1 -0
  66. package/web/assets/index-CU8tom6S.js +21 -0
  67. package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  68. package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  69. package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
  70. package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  71. package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
  72. package/web/index.html +33 -2
  73. package/web/logo.svg +4 -4
  74. package/web/assets/index-C7nF068F.css +0 -1
  75. package/web/assets/index-mzSArcnQ.js +0 -11
  76. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  77. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
@@ -73,7 +73,7 @@ function makeStep(fn, meta = {}) {
73
73
  export async function startRun(ctx, requiredAgents, requiredSuites) {
74
74
  const cfg = agentsCfg.loadConfig(ctx.config_paths);
75
75
  agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
76
- const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
76
+ const run = await session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
77
77
  // Provenance, once per run, before any phase opens: a repo-local chain
78
78
  // (.spf/chains/*.yaml) records the file it came from. `chain_name` alone
79
79
  // stops being enough to reconstruct a run the moment a target repo can
@@ -88,7 +88,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
88
88
  // it is a note. Adding a picklist member would force a UI change for a
89
89
  // payload the UI already renders generically.
90
90
  if (ctx.chain_source) {
91
- run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
91
+ await run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
92
92
  }
93
93
  // Tiering resolution (SPF #14) — one more run-scoped fact, computed once,
94
94
  // before any phase opens, beside chain_source above. `risk`/`signals` are
@@ -103,7 +103,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
103
103
  servedOllamaTags,
104
104
  required: requiredAgents,
105
105
  });
106
- run.tracer.event(makeEventRecord({
106
+ await run.tracer.event(makeEventRecord({
107
107
  adw_id: run.adw_id,
108
108
  type: "log",
109
109
  name: "tiering",
@@ -123,7 +123,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
123
123
  // reading the console when the two disagree.
124
124
  for (const [agentName, effective] of Object.entries(tiering.changedModels(run.tiering))) {
125
125
  const route = run.tiering.routing[agentName];
126
- run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
126
+ await run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
127
127
  }
128
128
  return run;
129
129
  }
@@ -176,7 +176,23 @@ function appendTrailer(message, trailer) {
176
176
  * -> no trailer, ever: a trailer that lies launders an AI verdict into a git
177
177
  * attestation.
178
178
  */
179
- export function commitEnvelope(run, ph, envelope, signoff) {
179
+ export async function commitEnvelope(run, ph, envelope, signoff) {
180
+ // An agent's own plan can (and sometimes does) instruct it to `git commit`
181
+ // its own work directly — e.g. a "Commit convention" section the planner
182
+ // wrote into the plan, following a target repo's own commit-message
183
+ // rules. When that happens, this phase's working tree is already clean by
184
+ // the time it runs: `commitAll`'s `git status --porcelain` finds nothing
185
+ // staged and throws "nothing to commit", even though real, tested work
186
+ // already landed on the branch a phase ago. Distinguish that from the
187
+ // genuine "nothing happened at all" case by checking whether HEAD already
188
+ // holds committed changes ahead of `run.base` — if so, this phase has
189
+ // nothing left to do and the existing HEAD *is* the result, not a failure.
190
+ const base = run.cfg.watch?.base_branch ?? "main";
191
+ if (!run.git.isDirty() && run.git.diffFiles(base).length > 0) {
192
+ const sha = run.git.shortSha();
193
+ await ph.log({ sha, message: "(already committed by a preceding phase — nothing new to stage)" });
194
+ return;
195
+ }
180
196
  let message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
181
197
  if (signoff) {
182
198
  const trailerLine = `Signed-off-by: ${signoff.name} <${signoff.email}>`;
@@ -186,11 +202,11 @@ export function commitEnvelope(run, ph, envelope, signoff) {
186
202
  if (!alreadyPresent)
187
203
  message = appendTrailer(message, trailerLine);
188
204
  }
189
- ph.log({ sha: run.git.commitAll(message), message });
205
+ await ph.log({ sha: run.git.commitAll(message), message });
190
206
  }
191
207
  /** Log a change-capture result the same way every chain that captures one did. */
192
- export function logChangeset(ph, result) {
193
- ph.log({
208
+ export async function logChangeset(ph, result) {
209
+ await ph.log({
194
210
  base: `${result.base.label} @ ${result.base.commit.slice(0, 7)}`,
195
211
  reason: result.base.reason,
196
212
  files: result.files.length + result.untracked.length,
@@ -328,7 +344,7 @@ export function request(opts = {}) {
328
344
  const payload = { input: state.prompt };
329
345
  if (opts.logBaseline)
330
346
  payload.baseline = run.git.shortSha(state.baseline);
331
- ph.log(payload);
347
+ await ph.log(payload);
332
348
  });
333
349
  };
334
350
  return makeStep(fn, { label: "engineer(request)" });
@@ -454,8 +470,8 @@ export function qualityCheck(opts = { suite: "all" }) {
454
470
  description: opts.description ??
455
471
  (suiteName === "all" ? "Run the deterministic quality blocks" : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
456
472
  }), async (ph) => {
457
- const result = quality.runSuite(run, suiteName);
458
- quality.record(ph, result);
473
+ const result = await quality.runSuite(run, suiteName);
474
+ await quality.record(ph, result);
459
475
  state.quality = result;
460
476
  state.accepted = result.passed;
461
477
  state.reason = result.passed ? "" : `quality failed: ${result.failures.join("; ")}`;
@@ -498,8 +514,8 @@ export function fixLoop(opts = { suite: "test" }) {
498
514
  ? "Lint, typecheck, and build before testing"
499
515
  : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
500
516
  }), async (ph) => {
501
- const r = quality.runSuite(run, suiteName);
502
- quality.record(ph, r);
517
+ const r = await quality.runSuite(run, suiteName);
518
+ await quality.record(ph, r);
503
519
  return r;
504
520
  });
505
521
  if (result.passed)
@@ -597,7 +613,7 @@ export function commit(opts = {}) {
597
613
  owner: "git",
598
614
  description: opts.description ??
599
615
  (opts.onlyIfAccepted ? "Land the code only after the suite came back green" : "Land the builder's changes, using the message it wrote"),
600
- }), async (ph) => commitEnvelope(run, ph, state.previous));
616
+ }), async (ph) => await commitEnvelope(run, ph, state.previous));
601
617
  };
602
618
  return makeStep(fn, { label: "git(commit)" });
603
619
  }
@@ -616,7 +632,7 @@ export function changes(opts = {}) {
616
632
  description: opts.description ?? `Diff the working tree against ${base} — the change to be written up`,
617
633
  }), async (ph) => {
618
634
  const result = changesLib.capture(run, makeChangeCapture({ base }));
619
- logChangeset(ph, result);
635
+ await logChangeset(ph, result);
620
636
  if (result.empty) {
621
637
  throw new Error(`nothing changed since ${result.base.label} (${result.base.reason}) — documenting runs after a build. ` +
622
638
  `Build something first, or point --base at the ref the work should be measured from.`);
@@ -770,7 +786,7 @@ export function publishIssues(opts = {}) {
770
786
  clearStaleRefineOutputFiles(run.context_handoff_dir);
771
787
  if (questions.length > 0) {
772
788
  writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
773
- ph.log({ escalated: questions.length });
789
+ await ph.log({ escalated: questions.length });
774
790
  return;
775
791
  }
776
792
  if (split.length > 0) {
@@ -781,7 +797,7 @@ export function publishIssues(opts = {}) {
781
797
  // `proposeSpecSplit`, same division of labor as the `questions`
782
798
  // branch above (this writes, `runSpec` posts/transitions).
783
799
  writeFileSync(path.join(run.context_handoff_dir, "refine_split.json"), JSON.stringify(split, null, 2));
784
- ph.log({ split_proposed: split.length });
800
+ await ph.log({ split_proposed: split.length });
785
801
  return;
786
802
  }
787
803
  const tracker = refineLib.resolveAuthoringProvider(run.cfg);
@@ -791,7 +807,7 @@ export function publishIssues(opts = {}) {
791
807
  priorityCeiling,
792
808
  });
793
809
  writeFileSync(path.join(run.context_handoff_dir, "refine_publish.json"), JSON.stringify(created.map((c) => ({ id: c.issue.id, title: c.issue.title, kind: c.kind, isLeaf: c.isLeaf })), null, 2));
794
- ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
810
+ await ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
795
811
  });
796
812
  };
797
813
  return makeStep(fn, { label: "code(publish)" });
@@ -843,5 +859,5 @@ export async function runSteps(ctx, requiredAgents, requiredSuites, steps, optio
843
859
  for (const step of steps) {
844
860
  await step(run, state);
845
861
  }
846
- return run.finish(state.accepted, state.reason);
862
+ return await run.finish(state.accepted, state.reason);
847
863
  }
@@ -1 +1 @@
1
- export declare function abortCommand(argv: string[]): number;
1
+ export declare function abortCommand(argv: string[]): Promise<number>;
@@ -4,18 +4,45 @@
4
4
  * recorded as still running for this adw_id and SIGTERM them. Since Flue
5
5
  * runs in-process, that pid is the whole `spf` invocation driving the chain
6
6
  * — this stops the run, not just the current model call.
7
+ *
8
+ * LOCAL-FILE-SPECIFIC: reads the `processes` table through a second raw
9
+ * `Database` handle on the same sqlite file `db` already opened — there is
10
+ * no such file for a `kind:"d1"` repo (SPF #66), so this fails clearly
11
+ * instead of pretending a network-backed process table works the same way.
12
+ * A future release could reach the same table over `SfDb`'s own (now async)
13
+ * query surface instead of a raw second handle — not done here because nothing
14
+ * else in this command needs it, and the raw handle already matched
15
+ * `db.journalMode`'s existing guarantees for the local case.
7
16
  */
8
17
  import { Database } from "../../core/sqlite.js";
9
18
  import { parseCli } from "../../core/utils.js";
10
- import { openTrace } from "./trace.js";
11
- export function abortCommand(argv) {
19
+ import { openTrace, resolveTrace } from "./trace.js";
20
+ export async function abortCommand(argv) {
12
21
  const { positionals, options } = parseCli(argv, ["cwd", "config"]);
13
22
  if (positionals.length < 1) {
14
23
  console.error("usage: spf abort <adw_id> [--cwd <dir>] [--config <path>]");
15
24
  return 1;
16
25
  }
17
26
  const adwId = positionals[0];
18
- const { db, dataDir } = openTrace(options);
27
+ // Checked BEFORE opening anything: a d1-backed repo has no local process
28
+ // table regardless of whether its trace db can even be reached, so there
29
+ // is no reason to pay for (or risk failing) an `openTrace` HTTP round
30
+ // trip just to then refuse. This also means no `SfDb` handle is ever
31
+ // opened for the d1 case — nothing to leave unclosed.
32
+ const { dataPaths } = resolveTrace(options);
33
+ if (dataPaths.db.kind === "d1") {
34
+ console.error(`spf abort is not supported for a d1-backed repo (observability.db.kind: "d1") — ` +
35
+ `there is no local process table to read for database_id ${JSON.stringify(dataPaths.db.database_id)}. ` +
36
+ `Stop the process by pid/OS tooling directly, or on the machine that ran it.`);
37
+ return 1;
38
+ }
39
+ const { db, dataDir } = await openTrace(options);
40
+ if (db.path === null) {
41
+ // Unreachable in practice — the `dataPaths.db.kind === "d1"` guard above
42
+ // already rejected the only case `db.path` can be null for — but keeps
43
+ // this typed as a real narrowing rather than a `!` assertion.
44
+ throw new Error("spf abort: db.path is null after the d1 guard — this should never happen");
45
+ }
19
46
  // SfDb opens read-only; a live process row needs a separate writable
20
47
  // handle only to read it here (no write happens) — reuse the same file.
21
48
  const raw = new Database(db.path, { readonly: true });
@@ -8,11 +8,13 @@
8
8
  */
9
9
  import { existsSync, statSync } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { homedir } from "node:os";
11
12
  import path from "node:path";
12
13
  import * as agents from "../../core/agents.js";
13
14
  import * as paths from "../../core/paths.js";
14
15
  import * as permissions from "../../core/permissions.js";
15
16
  import * as agentCc from "../../core/agent_cc.js";
17
+ import * as agentOpencode from "../../core/agent_opencode.js";
16
18
  import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
17
19
  import { endpointLabel, redact, resolveTracesUrl } from "../../core/otel.js";
18
20
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
@@ -28,6 +30,7 @@ import * as sandbox from "../../core/sandbox.js";
28
30
  import { loadOpenSandboxSdk } from "../../core/sandbox_opensandbox.js";
29
31
  import { isInteractive } from "../ask.js";
30
32
  import { paint as paintPlain } from "../../core/console.js";
33
+ import { SfDb } from "../../ui/server/db.js";
31
34
  /**
32
35
  * A transient "checking X..." status line around a network probe — real
33
36
  * work only starts inside `run()`; this doesn't touch the promise's timing.
@@ -283,7 +286,54 @@ export async function doctorCommand(argv) {
283
286
  }
284
287
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
285
288
  check(report, "data_dir", true, dataPaths.data_dir);
286
- check(report, "db_path", true, `${dataPaths.db_path}${existsSync(dataPaths.db_path) ? "" : " (not created yet fine before the first run)"}`);
289
+ // `db_path` is `null` for a `kind: "d1"` observability.dbthere is no
290
+ // local file to check for existence; `spf doctor`'s own D1 reachability
291
+ // probe (account id / API token / database id) is PR 3's job (SPF #66).
292
+ check(report, "db_path", true, dataPaths.db_path
293
+ ? `${dataPaths.db_path}${existsSync(dataPaths.db_path) ? "" : " (not created yet — fine before the first run)"}`
294
+ : `d1 database_id=${JSON.stringify(dataPaths.db.kind === "d1" ? dataPaths.db.database_id : "")} (remote — no local file)`);
295
+ // D1 trace db — only when observability.db resolves to a remote Cloudflare
296
+ // D1 database (PR 1's resolveObservabilityDb / PR 2's D1TraceDb, both
297
+ // already folded into `dataPaths.db` by `paths.resolveDataPaths` above).
298
+ // Same two-step shape as every other Cloudflare probe in this file (Workers
299
+ // AI above, sandbox.cloudflare below): a static config-shape check first —
300
+ // the account/token env vars actually need to be SET before there's
301
+ // anything to probe — then a separate, NEVER-hard-failing reachability
302
+ // check, gated on --no-probe like every other live probe here.
303
+ if (dataPaths.db.kind === "d1") {
304
+ const { account_id_env, api_token_env, database_id } = dataPaths.db;
305
+ const missingEnv = [account_id_env, api_token_env].filter((k) => !process.env[k]);
306
+ check(report, "D1 trace db credentials", true, // informational/warning only — same never-a-hard-failure contract as the Cloudflare Workers AI endpoint check above
307
+ missingEnv.length === 0
308
+ ? `${account_id_env} and ${api_token_env} are set`
309
+ : `${missingEnv.join(", ")} not set — required for observability.db kind: "d1"`, missingEnv.length === 0 ? undefined : "warn");
310
+ if (missingEnv.length === 0 && !flags["no-probe"]) {
311
+ // A cheap, read-only "does a sessions table exist yet" check —
312
+ // `SfDb.exists()` (PR 2), never a full `Tracer`/write. Mirrors
313
+ // `SfDb.open()`'s own friendly-error probe but collapses "nothing
314
+ // written yet" to a soft finding instead of throwing.
315
+ const probe = await withProbeStatus("D1 trace db reachability", async () => {
316
+ try {
317
+ return { ok: true, exists: await SfDb.exists(dataPaths) };
318
+ }
319
+ catch (error) {
320
+ return { ok: false, error: error.message };
321
+ }
322
+ });
323
+ check(report, "D1 trace db reachability", true, // informational/warning only — same contract as every other reachability probe in this file
324
+ probe.ok
325
+ ? probe.exists
326
+ ? `reachable: D1 database ${database_id} has a "sessions" table`
327
+ : `reachable: D1 database ${database_id} has no "sessions" table yet (fine before the first run)`
328
+ : `unreachable or errored: ${probe.error}`,
329
+ // "warn" only when the request itself failed (unreachable, bad
330
+ // credentials, wrong database_id) — that's the case an operator
331
+ // needs to act on. A successful request that simply finds no
332
+ // "sessions" table yet is the healthy fresh-database state (same
333
+ // as the local `db_path` check above), so it's "info", not "warn".
334
+ probe.ok ? "info" : "warn");
335
+ }
336
+ }
287
337
  check(report, "flue_db_path", true, path.join(dataPaths.data_dir, "flue.db"));
288
338
  // Validate the WHOLE roster and EVERY declared suite — doctor's job is "is
289
339
  // everything defined here healthy", not "can one specific chain run".
@@ -366,6 +416,57 @@ export async function doctorCommand(argv) {
366
416
  " (this is a real, billable inference request — pass --no-probe to skip both network probes)", result.ok ? "info" : "warn");
367
417
  }
368
418
  }
419
+ const usesOpencode = cfg.agents.some((a) => a.coding_agent === "opencode");
420
+ if (usesOpencode) {
421
+ // Mirrors the "claude CLI" check above — SPF_OPENCODE_CMD can point at a
422
+ // wrapper/launcher instead of the literal `opencode` binary (see
423
+ // agent_opencode.ts's module comment), so check whatever
424
+ // agent_opencode.ts will actually spawn: cmdSpec's first token.
425
+ const cmdSpec = process.env["SPF_OPENCODE_CMD"] || "opencode";
426
+ const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
427
+ const cmdBin = cmdTokens[0] || "opencode";
428
+ const opencodeOnPath = binaryOnPath(cmdBin);
429
+ let version = "";
430
+ if (opencodeOnPath && cmdBin === "opencode") {
431
+ const result = spawnSync("opencode", ["--version"], { encoding: "utf-8" });
432
+ version = result.status === 0 ? result.stdout.trim() : "";
433
+ }
434
+ check(report, "opencode CLI", opencodeOnPath, opencodeOnPath
435
+ ? version || (cmdBin === "opencode" ? "on PATH, but --version failed" : `"${cmdBin}" on PATH (via SPF_OPENCODE_CMD) — --version not checked for a wrapper/launcher`)
436
+ : `"${cmdBin}" not found on PATH — required by any coding_agent: opencode agent${cmdBin !== "opencode" ? " (checked SPF_OPENCODE_CMD's first token, not the literal \"opencode\")" : ""}`);
437
+ // Auth is treated as ALREADY CONFIGURED territory here, not something
438
+ // doctor drives interactively (no `opencode auth login` flow) — per
439
+ // opencode's own docs, ~/.local/share/opencode/auth.json is the
440
+ // credential store and `opencode auth list` is the documented
441
+ // non-interactive check. Informational only (`ok: true` regardless),
442
+ // same "fine if authenticated another way" contract as the
443
+ // ANTHROPIC_API_KEY check above — and it also defends against a known
444
+ // upstream race condition that can write an empty (0-byte) token file,
445
+ // which existsSync alone would miss.
446
+ // `~/.local/share` is only the XDG_DATA_HOME *default* — an operator
447
+ // with that env var set (routine on Linux) has opencode's credential
448
+ // store elsewhere; honoring it here avoids a false "not authenticated"
449
+ // warning that `opencode auth login` (writing to the SAME XDG location)
450
+ // would never actually clear.
451
+ const dataHome = process.env["XDG_DATA_HOME"] || path.join(homedir(), ".local", "share");
452
+ const authPath = path.join(dataHome, "opencode", "auth.json");
453
+ let authDetail;
454
+ let authWarn = true;
455
+ if (!existsSync(authPath)) {
456
+ authDetail = `${authPath} not found — run \`opencode auth login\` (or set the provider's own env var) before running spf`;
457
+ }
458
+ else {
459
+ const size = statSync(authPath).size;
460
+ if (size > 0) {
461
+ authDetail = `${authPath} present (${size} bytes)`;
462
+ authWarn = false;
463
+ }
464
+ else {
465
+ authDetail = `${authPath} exists but is empty — a known opencode upstream race can write an empty token file; re-run \`opencode auth login\``;
466
+ }
467
+ }
468
+ check(report, "opencode auth", true, authDetail, authWarn ? "warn" : "info");
469
+ }
369
470
  // Flue agents pointed at a local Ollama server (`model: ollama/...`) have
370
471
  // no API key to check (providers.ts's PROVIDER_ENV_KEYS.ollama is `[]`,
371
472
  // handled in the per-agent loop below) but DO have a server that might
@@ -454,7 +555,7 @@ export async function doctorCommand(argv) {
454
555
  check(report, `${label} model`, false, error.message);
455
556
  }
456
557
  }
457
- const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : isKnownFlueToolName;
558
+ const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : agent.coding_agent === "opencode" ? agentOpencode.isKnownToolName : isKnownFlueToolName;
458
559
  for (const toolName of agent.tools ?? []) {
459
560
  if (!isKnownToolName(toolName))
460
561
  check(report, `${label} tool "${toolName}"`, false, "not a known tool name");
@@ -750,14 +851,14 @@ export async function doctorCommand(argv) {
750
851
  const live = sandbox.leases();
751
852
  check(report, "sandbox live leases", true, `${live.length} in THIS process (the lease journal at <data_dir>/sandboxes.json is not implemented in this build — cross-process/orphan visibility via \`spf sandbox list\` is not yet available)`, "info");
752
853
  }
753
- // #12 — claude_code x remote backend, hard ✗, roster-wide (validate()
754
- // above is chain-scoped to whatever `required` it was called with —
755
- // doctor happens to pass the whole roster there too, but this is named
756
- // separately so the specific rule that failed has its own line).
854
+ // #12 — claude_code/opencode x remote backend, hard ✗, roster-wide
855
+ // (validate() above is chain-scoped to whatever `required` it was called
856
+ // with — doctor happens to pass the whole roster there too, but this is
857
+ // named separately so the specific rule that failed has its own line).
757
858
  for (const agent of cfg.agents) {
758
859
  const backend = resolvedBackend(agent);
759
- if (agent.coding_agent === "claude_code" && backend !== "local") {
760
- check(report, `agent "${agent.name}" coding_agent x sandbox`, false, `coding_agent "claude_code" cannot use sandbox backend ${JSON.stringify(backend)} — it always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
860
+ if ((agent.coding_agent === "claude_code" || agent.coding_agent === "opencode") && backend !== "local") {
861
+ check(report, `agent "${agent.name}" coding_agent x sandbox`, false, `coding_agent ${JSON.stringify(agent.coding_agent)} cannot use sandbox backend ${JSON.stringify(backend)} — it always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
761
862
  }
762
863
  }
763
864
  // #13 — image set, and able to run the transport. Hard ✗ only when
@@ -861,6 +962,14 @@ export async function doctorCommand(argv) {
861
962
  ? "set"
862
963
  : 'not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo); see README.md\'s "GITHUB_TOKEN scope" section');
863
964
  }
965
+ if (cfg.watch.issue_provider === "github") {
966
+ const statusMapEntries = Object.entries(cfg.watch.github.status_map).filter(([, v]) => Boolean(v));
967
+ check(report, "watch.github.status_map", true, statusMapEntries.length > 0
968
+ ? cfg.watch.github.project_number
969
+ ? `${statusMapEntries.length} state(s) configured to sync Projects v2 #${cfg.watch.github.project_number}'s Status field — run \`spf watch init\` to validate them, and make sure GITHUB_TOKEN has the "project" scope`
970
+ : `${statusMapEntries.length} state(s) configured but watch.github.project_number is unset — status sync stays disabled until it's set`
971
+ : "not configured — spf watch will only update labels on this repo, never a Projects v2 board (optional, off by default)", "info");
972
+ }
864
973
  if (cfg.watch.issue_provider === "jira") {
865
974
  check(report, "watch.jira", Boolean(cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()), cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()
866
975
  ? `${cfg.watch.jira.base_url} (${cfg.watch.jira.project_key})`
@@ -868,6 +977,10 @@ export async function doctorCommand(argv) {
868
977
  check(report, "JIRA_EMAIL / JIRA_API_TOKEN", Boolean(process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]), process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]
869
978
  ? "set"
870
979
  : 'not set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens); see README.md\'s "spf watch" section');
980
+ const statusMapEntries = Object.entries(cfg.watch.jira.status_map).filter(([, v]) => Boolean(v));
981
+ check(report, "watch.jira.status_map", true, statusMapEntries.length > 0
982
+ ? `${statusMapEntries.length} state(s) configured to sync a native Jira status — run \`spf watch init\` to validate them against the real project's statuses`
983
+ : "not configured — spf watch will only update labels on this project, never the Jira Status field (optional, off by default)", "info");
871
984
  }
872
985
  if (cfg.watch.code_host === "bitbucket") {
873
986
  check(report, "BITBUCKET_EMAIL / BITBUCKET_API_TOKEN", Boolean(process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]), process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]
@@ -173,7 +173,7 @@ export async function estimateCommand(argv) {
173
173
  return 1;
174
174
  }
175
175
  const prompt = resolvePrompt(promptArg);
176
- const trace = openTraceIfExists(options);
176
+ const trace = await openTraceIfExists(options);
177
177
  const cfg = trace.cfg;
178
178
  // Same shape as run.ts:54-56 — conditional insertion, never `{agent: flags["agent"] ?? ""}`.
179
179
  // `flags` never carries a value-taking option, and an empty-string sentinel
@@ -195,7 +195,7 @@ export async function estimateCommand(argv) {
195
195
  catch (error) {
196
196
  validateError = error instanceof Error ? error.message : String(error);
197
197
  }
198
- const history = trace.db ? trace.db.chainPhaseHistory(chain.name) : { sessions: [], joinedExcluded: 0 };
198
+ const history = trace.db ? await trace.db.chainPhaseHistory(chain.name) : { sessions: [], joinedExcluded: 0 };
199
199
  const sample = selectSample(history.sessions, history.joinedExcluded);
200
200
  const coldStart = sample.sessions.length === 0;
201
201
  const phases = aggregatePhases(sample.sessions);
@@ -205,7 +205,7 @@ export async function estimateCommand(argv) {
205
205
  const historicalModels = new Map();
206
206
  if (trace.db) {
207
207
  for (const session of sample.sessions) {
208
- for (const row of trace.db.agentSessions(session.adw_id)) {
208
+ for (const row of await trace.db.agentSessions(session.adw_id)) {
209
209
  if (row.model !== null && !historicalModels.has(row.agent))
210
210
  historicalModels.set(row.agent, row.model);
211
211
  }
@@ -20,9 +20,9 @@ export async function eventsCommand(argv) {
20
20
  return 1;
21
21
  }
22
22
  const adwId = positionals[0];
23
- const { db } = openTrace(options);
23
+ const { db } = await openTrace(options);
24
24
  let after = options["after"] ? Number.parseInt(options["after"], 10) : 0;
25
- const page = db.events(adwId, after, options["limit"] ? Number.parseInt(options["limit"], 10) : 500);
25
+ const page = await db.events(adwId, after, options["limit"] ? Number.parseInt(options["limit"], 10) : 500);
26
26
  if (flags["json"] && !flags["follow"]) {
27
27
  console.log(JSON.stringify(page.events, null, 2));
28
28
  return 0;
@@ -35,8 +35,8 @@ export async function eventsCommand(argv) {
35
35
  console.error(`-- following ${adwId}; ^C to stop --`);
36
36
  for (;;) {
37
37
  await sleep(500);
38
- const session = db.session(adwId);
39
- const next = db.events(adwId, after, 500);
38
+ const session = await db.session(adwId);
39
+ const next = await db.events(adwId, after, 500);
40
40
  for (const e of next.events)
41
41
  printEvent(e);
42
42
  if (next.events.length > 0)
@@ -266,10 +266,11 @@ export async function fanoutCommand(argv) {
266
266
  // `sessionAddUsage` ACCUMULATES onto it — so a collision would silently mix
267
267
  // the previous run's cost/tokens/gate rows into this run's selection
268
268
  // basis, and the human reading `basis:` would be told a wrong reason.
269
- if (existsSync(dataPaths.db_path)) {
270
- const preflight = new SfDb(dataPaths.db_path);
269
+ if (await SfDb.exists(dataPaths)) {
270
+ const preflight = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
271
271
  try {
272
- const collisions = Array.from({ length: n }, (_, i) => attemptAdwId(baseAdwId, i + 1)).filter((id) => preflight.session(id) !== null);
272
+ const collisionChecks = await Promise.all(Array.from({ length: n }, (_, i) => attemptAdwId(baseAdwId, i + 1)).map(async (id) => ((await preflight.session(id)) !== null ? id : null)));
273
+ const collisions = collisionChecks.filter((id) => id !== null);
273
274
  if (collisions.length > 0) {
274
275
  console.error(`--adw-id ${baseAdwId} already has session rows for ${collisions.join(", ")} from a previous fanout ` +
275
276
  `run — reusing it would mix that run's cost/tokens/gates into this one's selection basis. Use a ` +
@@ -279,7 +280,7 @@ export async function fanoutCommand(argv) {
279
280
  }
280
281
  }
281
282
  finally {
282
- preflight.close();
283
+ await preflight.close();
283
284
  }
284
285
  }
285
286
  if (cfg.defaults.max_run_cost === undefined && cfg.defaults.max_run_tokens === undefined) {
@@ -292,24 +293,82 @@ export async function fanoutCommand(argv) {
292
293
  }
293
294
  const linkDataDir = (worktreePath) => linkFanoutDataDir(worktreePath, dataPaths.data_dir);
294
295
  /**
295
- * The shared db, opened lazily and once: it does not exist until the first
296
- * attempt's tracer creates it, and `SfDb`'s constructor throws on a missing
297
- * file. Readonly + WAL, so reading one finished attempt's rows while its
298
- * siblings are still writing is exactly the access pattern the tracer's
299
- * PRAGMAs are set up for.
296
+ * The shared db, opened lazily: it does not exist until the first
297
+ * attempt's tracer creates it, and `SfDb.open` throws on a repo whose db
298
+ * has never been written to. Readonly + WAL, so reading one finished
299
+ * attempt's rows while its siblings are still writing is exactly the
300
+ * access pattern the tracer's PRAGMAs are set up for.
301
+ *
302
+ * The IN-FLIGHT PROMISE is memoized, not the resolved value: `core/
303
+ * fanout.ts` runs `concurrency` workers in parallel, each calling
304
+ * `readMetrics` independently, so a bare "check `held.db`, then `await`
305
+ * open, then assign" would let two workers both observe `held.db` unset,
306
+ * both open a connection, and orphan one unclosed `SfDb` handle. Every
307
+ * concurrent caller instead awaits this SAME promise, so at most one
308
+ * `SfDb.exists`/`SfDb.open` pair ever runs at a time.
309
+ *
310
+ * Memoized ONLY while pending or successful, though — a rejected open (a
311
+ * transient D1 network/auth blip) clears the memo (`held.opening = null`
312
+ * in `ensureDb`'s `.catch()` below) rather than caching the failure
313
+ * forever. Without that, one blip on attempt 1 would leave every later
314
+ * attempt reading `ZERO_METRICS` for the rest of the run, degrading the
315
+ * whole fanout's ranking over a single transient error instead of just
316
+ * the one attempt that hit it.
317
+ *
318
+ * The same reasoning applies to `SfDb.exists` resolving `false`: "not
319
+ * created yet" is a snapshot, not a permanent fact — a sibling attempt
320
+ * still mid-`session.ensure` can create the db moments later. HEAD's
321
+ * `existsSync` re-checked on every call; memoizing a `false` verdict
322
+ * across this run's whole lifetime would silently zero out every later
323
+ * attempt's real metrics once the db does show up, so `ensureDb` clears
324
+ * the memo on a `false` verdict too, not just on rejection.
300
325
  */
301
- // A holder rather than a bare `let`: assigned only inside the closure below,
302
- // a plain local narrows to `null` everywhere else and the `finally`'s
303
- // `close()` stops typechecking.
304
- const held = { db: null };
305
- function readMetrics(adwId) {
306
- if (!held.db) {
307
- if (!existsSync(dataPaths.db_path))
308
- return ZERO_METRICS;
309
- held.db = new SfDb(dataPaths.db_path);
326
+ // A holder object rather than a bare `let`: with a bare `let db: SfDb |
327
+ // null = null` reassigned only inside `ensureDb`'s nested async closure,
328
+ // TypeScript cannot track that reassignment across the closure boundary —
329
+ // at the outer `finally`'s `db?.close()` read site (below), it infers
330
+ // `db`'s type as `never` (narrowed from the closure never having run, as
331
+ // far as the checker can tell at that point) and refuses to compile:
332
+ // "Property 'close' does not exist on type 'never'". Routing the same
333
+ // mutable cell through a holder object's property sidesteps that —
334
+ // `held.db` is never narrowed to a literal `null` the way a bare `let`
335
+ // is, so the read site stays typed `SfDb | null` throughout. `opening`
336
+ // rides the same holder for the same reason: it too is reassigned inside
337
+ // `ensureDb`'s `.catch()` closure (to clear the memo — see below), and a
338
+ // bare `let` there trips the identical `never`-narrowing bug at the
339
+ // outer `finally`'s own read of it.
340
+ const held = { db: null, opening: null };
341
+ function ensureDb() {
342
+ if (!held.opening) {
343
+ held.opening = (async () => {
344
+ if (await SfDb.exists(dataPaths)) {
345
+ held.db = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
346
+ }
347
+ else {
348
+ held.opening = null;
349
+ }
350
+ })().catch((error) => {
351
+ // Do not let one transient failure (a D1 network/auth blip) poison
352
+ // every subsequent attempt's metrics for the rest of this run: clear
353
+ // the memo so the NEXT caller gets a fresh open attempt instead of
354
+ // permanently reusing this rejected promise. The rejection itself
355
+ // still propagates to whichever concurrent callers are already
356
+ // awaiting this exact promise object (reassigning `held.opening`
357
+ // here does not change what an already-returned promise resolves
358
+ // to) — `readMetrics` above (and its caller in `core/fanout.ts`)
359
+ // already catches that and ranks the attempt on zero metrics.
360
+ held.opening = null;
361
+ throw error;
362
+ });
310
363
  }
311
- const gates = held.db.gates(adwId);
312
- const session = held.db.session(adwId);
364
+ return held.opening;
365
+ }
366
+ async function readMetrics(adwId) {
367
+ await ensureDb();
368
+ if (!held.db)
369
+ return ZERO_METRICS;
370
+ const gates = await held.db.gates(adwId);
371
+ const session = await held.db.session(adwId);
313
372
  // `passed` is a SQLite integer boolean that CAN be NULL on a row an older
314
373
  // tracer wrote. Counted explicitly in both directions, never as
315
374
  // `!g.passed`: a NULL is unknown, and letting it read as a failure would
@@ -444,7 +503,20 @@ export async function fanoutCommand(argv) {
444
503
  return 0;
445
504
  }
446
505
  finally {
447
- held.db?.close();
506
+ // `.catch(() => {})`, not a bare `await`: `held.opening` is a settled
507
+ // promise `readMetrics` (via `ensureDb`) may have already awaited and
508
+ // handled upstream (`core/fanout.ts` catches a `readMetrics` rejection,
509
+ // logs it, and ranks that attempt on zero metrics) — re-awaiting the
510
+ // SAME rejected promise here a second time would throw straight out of
511
+ // this `finally`, turning a completed, winner-printed `spf fanout` run
512
+ // into a nonzero exit, and would also skip `held.db?.close()` and the
513
+ // `process.off` calls below (a `finally` that throws never reaches its
514
+ // own remaining statements). Nothing here needs the resolved value or
515
+ // the rejection reason — only that the open attempt has settled before
516
+ // deciding whether there's a `held.db` to close.
517
+ if (held.opening)
518
+ await held.opening.catch(() => { });
519
+ await held.db?.close();
448
520
  process.off("SIGINT", onSignal);
449
521
  process.off("SIGTERM", onSignal);
450
522
  }