@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
@@ -1,23 +1,3 @@
1
- /**
2
- * `spf loop <chain> "<goal>" --until-suite <name> --max N` — the wiring half
3
- * of `core/loop.ts` (which owns the driver, the ledger, and the breakers):
4
- * config, the issue fetch for `--issue`, the per-iteration `runChain`
5
- * dispatch, the sqlite readback, and the final table. Same split as
6
- * `core/fanout.ts` / `cli/commands/fanout.ts` for the same reason — `core/`
7
- * stays out of `src/chains/`'s dependency direction, so the driver is
8
- * testable with no chains, no agents, no real git.
9
- *
10
- * `--issue <id>` resolves the SAME `IssueProvider` `spf watch` does
11
- * (`resolveIssueProvider`, exported from `watch.ts` for exactly this reuse)
12
- * and builds the goal prompt the same way `core/watch.ts`'s `runIssue` does
13
- * — `${issue.title}\n\n${issue.body}`.trim() — so a ticket-pointed loop and
14
- * a hand-typed one share one prompt convention. This is deliberately the
15
- * ONLY thing `--issue` does: it does not claim, label, or comment on the
16
- * issue, and no daemon watches it. Filing the goal AS a ticket the daemon
17
- * picks up on its own is a different, larger feature (a `goal-ready` watch
18
- * lane) that needs its own crash-safe marker schema and is not this.
19
- */
20
- import { existsSync } from "node:fs";
21
1
  import * as agents from "../../core/agents.js";
22
2
  import * as paths from "../../core/paths.js";
23
3
  import * as quality from "../../core/quality.js";
@@ -43,8 +23,13 @@ function qualityRunLike(input) {
43
23
  phases: [{ phase_id: `${input.adwId}_00_loop_stop`, adw_id: input.adwId, seq: 0, params: { name: "loop_stop", kind: "code", owner: "quality", description: "loop stop check", retries: 0 }, status: "running", attempt: 0 }],
44
24
  context_handoff_dir: input.contextHandoffDir,
45
25
  repo_root: input.repoRoot,
46
- console: { note: (message) => console.log(`[spf] loop ${message}`) },
47
- tracer: { event: () => "" },
26
+ console: {
27
+ note: (message) => {
28
+ console.log(`[spf] loop ${message}`);
29
+ return Promise.resolve();
30
+ },
31
+ },
32
+ tracer: { event: () => Promise.resolve("") },
48
33
  adw_id: input.adwId,
49
34
  };
50
35
  }
@@ -154,23 +139,37 @@ export async function loopCommand(argv) {
154
139
  // total, not for deciding correctness.
155
140
  let tokens = 0;
156
141
  let cost = 0;
157
- if (existsSync(dataPaths.db_path)) {
158
- const db = new SfDb(dataPaths.db_path);
159
- try {
160
- const session = db.session(iteration.adw_id);
161
- tokens = session?.total_tokens ?? 0;
162
- cost = session?.total_cost ?? 0;
163
- }
164
- finally {
165
- db.close();
142
+ // `SfDb.exists` covers both backends: `existsSync(dataPaths.db_path)`
143
+ // for local sqlite, and a cheap `sqlite_master` probe (false, not a
144
+ // throw, when the table isn't there yet) for d1. Both the existence
145
+ // check and the read itself are inside this loop's own try/catch —
146
+ // `runIteration` is documented as never throwing (see loop.ts's header),
147
+ // and a D1 hiccup reaching this readback must rank as zero, not abort
148
+ // the iteration, the same "never throw, rank as zero" discipline
149
+ // `fanout.ts`'s `readMetrics` already uses.
150
+ try {
151
+ if (await SfDb.exists(dataPaths)) {
152
+ const db = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
153
+ try {
154
+ const session = await db.session(iteration.adw_id);
155
+ tokens = session?.total_tokens ?? 0;
156
+ cost = session?.total_cost ?? 0;
157
+ }
158
+ finally {
159
+ await db.close();
160
+ }
166
161
  }
167
162
  }
163
+ catch {
164
+ // best-effort readback only — tokens/cost simply stay 0 for this
165
+ // iteration's ledger row.
166
+ }
168
167
  let commitSha = null;
169
168
  let stopVerdict = null;
170
169
  if (exitCode === 0 && error === null) {
171
170
  const git = makeGit(anchor.repo_root);
172
171
  commitSha = git.shortSha();
173
- const result = quality.runSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: iteration.adw_id }), stop.suite);
172
+ const result = await quality.runSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: iteration.adw_id }), stop.suite);
174
173
  stopVerdict = { passed: result.passed, failures: result.failures, artifacts: result.artifacts };
175
174
  }
176
175
  return { exit_code: exitCode, error, commit_sha: commitSha, tokens, cost, stop_verdict: stopVerdict };
@@ -101,7 +101,14 @@ export function migrateCommand(argv) {
101
101
  }
102
102
  }
103
103
  });
104
- const oldDbPath = resolveOld(oldConfig?.observability?.db ?? "adws/adw_data/spf.db");
104
+ // `observability.db` predates this schema's `{kind:"sqlite"|"d1",...}`
105
+ // object form (SPF #66) — a stamped `adws/` tree can only ever have written
106
+ // a bare path here, but a `typeof` guard costs nothing and keeps a
107
+ // legacy-config-carrying-a-d1-shaped-object from crashing `path.resolve()`
108
+ // with a TypeError, same explicit non-string handling `abort.ts` gives a
109
+ // d1-backed repo rather than letting it fail deep inside a file op.
110
+ const oldDbRaw = oldConfig?.observability?.db;
111
+ const oldDbPath = resolveOld(typeof oldDbRaw === "string" ? oldDbRaw : "adws/adw_data/spf.db");
105
112
  const oldSessionsDir = path.join(path.dirname(oldDbPath), "sessions");
106
113
  if (existsSync(oldDbPath)) {
107
114
  actions.push({ kind: "copy-file", detail: `${path.relative(anchor.repo_root, oldDbPath)} -> ${newDbRel} (WAL-checkpointed first)` });
@@ -8,8 +8,8 @@ export async function phasesCommand(argv) {
8
8
  return 1;
9
9
  }
10
10
  const adwId = positionals[0];
11
- const { db } = openTrace(options);
12
- const rows = db.phases(adwId);
11
+ const { db } = await openTrace(options);
12
+ const rows = await db.phases(adwId);
13
13
  if (flags["json"]) {
14
14
  console.log(JSON.stringify(rows, null, 2));
15
15
  return 0;
@@ -3,9 +3,9 @@ import { openTrace } from "./trace.js";
3
3
  import { isInteractive } from "../ask.js";
4
4
  export async function sessionsCommand(argv) {
5
5
  const { options, flags } = parseCli(argv, ["cwd", "config", "limit"], ["json"]);
6
- const { db } = openTrace(options);
6
+ const { db } = await openTrace(options);
7
7
  const limit = options["limit"] ? Number.parseInt(options["limit"], 10) : 20;
8
- const rows = db.sessions(limit);
8
+ const rows = await db.sessions(limit);
9
9
  if (flags["json"]) {
10
10
  console.log(JSON.stringify(rows, null, 2));
11
11
  return 0;
@@ -8,19 +8,39 @@ export interface TraceHandle {
8
8
  /** Already loaded by this same resolution — `spf estimate` needs it (ceilings, resolveTiering) and reusing it means one loadConfig, not two. */
9
9
  cfg: SFConfig;
10
10
  }
11
- export declare function openTrace(options: Record<string, string>): TraceHandle;
12
11
  /**
13
- * Like `openTrace`, but never throws on a missing db file: `db` is `null`
14
- * in that one slot instead. `anchor`/`dataDir`/`cfg` still come from the
15
- * SAME `resolveTrace` call either way a cold start (no `spf.db` yet)
16
- * still carries a real, loaded `cfg`, which is what lets `spf estimate`
17
- * echo `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
12
+ * The one place anchor/config/data-path resolution happens, so `openTrace`
13
+ * and `openTraceIfExists` cannot diverge on it. Exported (not just used
14
+ * internally) so a caller that needs to inspect `dataPaths.db.kind` BEFORE
15
+ * deciding whether to open anything at all `abort.ts`'s d1 guard — can
16
+ * reuse this exact resolution instead of a second, divergence-prone copy.
17
+ */
18
+ export declare function resolveTrace(options: Record<string, string>): {
19
+ dataPaths: paths.DataPaths;
20
+ anchor: paths.RepoAnchor;
21
+ dataDir: string;
22
+ cfg: SFConfig;
23
+ };
24
+ export declare function openTrace(options: Record<string, string>): Promise<TraceHandle>;
25
+ /**
26
+ * Like `openTrace`, but never throws on a db that has never been written
27
+ * to: `db` is `null` in that one slot instead. `anchor`/`dataDir`/`cfg`
28
+ * still come from the SAME `resolveTrace` call either way — a cold start
29
+ * (no `spf.db` yet, or a fresh D1 database with no `sessions` table) still
30
+ * carries a real, loaded `cfg`, which is what lets `spf estimate` echo
31
+ * `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
18
32
  * before it exits, rather than crashing with `SfDb`'s "point spf ui at a
19
33
  * target repo" message (which is about `spf ui`, not `spf estimate`).
34
+ *
35
+ * `SfDb.exists` covers both backends: `existsSync(dataPaths.db_path)` for
36
+ * local sqlite, and a cheap `sqlite_master` probe (false, not a throw, when
37
+ * the `sessions` table isn't there yet) for d1 — so `spf estimate`'s
38
+ * documented cold-start path (report + exit 3) actually reaches on a fresh
39
+ * D1 repo instead of crashing.
20
40
  */
21
- export declare function openTraceIfExists(options: Record<string, string>): {
41
+ export declare function openTraceIfExists(options: Record<string, string>): Promise<{
22
42
  db: SfDb | null;
23
43
  anchor: paths.RepoAnchor;
24
44
  dataDir: string;
25
45
  cfg: SFConfig;
26
- };
46
+ }>;
@@ -1,29 +1,42 @@
1
1
  /** Shared by sessions/phases/events/abort/estimate — resolve the workspace and open its trace db. */
2
- import { existsSync } from "node:fs";
3
2
  import * as agents from "../../core/agents.js";
4
3
  import * as paths from "../../core/paths.js";
5
4
  import { SfDb } from "../../ui/server/db.js";
6
- /** The one place anchor/config/data-path resolution happens, so `openTrace` and `openTraceIfExists` cannot diverge on it. */
7
- function resolveTrace(options) {
5
+ /**
6
+ * The one place anchor/config/data-path resolution happens, so `openTrace`
7
+ * and `openTraceIfExists` cannot diverge on it. Exported (not just used
8
+ * internally) so a caller that needs to inspect `dataPaths.db.kind` BEFORE
9
+ * deciding whether to open anything at all — `abort.ts`'s d1 guard — can
10
+ * reuse this exact resolution instead of a second, divergence-prone copy.
11
+ */
12
+ export function resolveTrace(options) {
8
13
  const anchor = paths.resolveAnchor(options["cwd"]);
9
14
  const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
10
15
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
11
- return { dbPath: dataPaths.db_path, anchor, dataDir: dataPaths.data_dir, cfg };
16
+ return { dataPaths, anchor, dataDir: dataPaths.data_dir, cfg };
12
17
  }
13
- export function openTrace(options) {
14
- const { dbPath, anchor, dataDir, cfg } = resolveTrace(options);
15
- return { db: new SfDb(dbPath), anchor, dataDir, cfg };
18
+ export async function openTrace(options) {
19
+ const { dataPaths, anchor, dataDir, cfg } = resolveTrace(options);
20
+ return { db: await SfDb.open(dataPaths.db, dataPaths.sessions_dir), anchor, dataDir, cfg };
16
21
  }
17
22
  /**
18
- * Like `openTrace`, but never throws on a missing db file: `db` is `null`
19
- * in that one slot instead. `anchor`/`dataDir`/`cfg` still come from the
20
- * SAME `resolveTrace` call either way — a cold start (no `spf.db` yet)
21
- * still carries a real, loaded `cfg`, which is what lets `spf estimate`
22
- * echo `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
23
+ * Like `openTrace`, but never throws on a db that has never been written
24
+ * to: `db` is `null` in that one slot instead. `anchor`/`dataDir`/`cfg`
25
+ * still come from the SAME `resolveTrace` call either way — a cold start
26
+ * (no `spf.db` yet, or a fresh D1 database with no `sessions` table) still
27
+ * carries a real, loaded `cfg`, which is what lets `spf estimate` echo
28
+ * `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
23
29
  * before it exits, rather than crashing with `SfDb`'s "point spf ui at a
24
30
  * target repo" message (which is about `spf ui`, not `spf estimate`).
31
+ *
32
+ * `SfDb.exists` covers both backends: `existsSync(dataPaths.db_path)` for
33
+ * local sqlite, and a cheap `sqlite_master` probe (false, not a throw, when
34
+ * the `sessions` table isn't there yet) for d1 — so `spf estimate`'s
35
+ * documented cold-start path (report + exit 3) actually reaches on a fresh
36
+ * D1 repo instead of crashing.
25
37
  */
26
- export function openTraceIfExists(options) {
27
- const { dbPath, anchor, dataDir, cfg } = resolveTrace(options);
28
- return { db: existsSync(dbPath) ? new SfDb(dbPath) : null, anchor, dataDir, cfg };
38
+ export async function openTraceIfExists(options) {
39
+ const { dataPaths, anchor, dataDir, cfg } = resolveTrace(options);
40
+ const db = (await SfDb.exists(dataPaths)) ? await SfDb.open(dataPaths.db, dataPaths.sessions_dir) : null;
41
+ return { db, anchor, dataDir, cfg };
29
42
  }
@@ -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(() => { });
@@ -100,7 +100,7 @@ export function resolveIssueProvider(cfg) {
100
100
  console.error('GITHUB_TOKEN is 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.');
101
101
  return null;
102
102
  }
103
- return new GitHubProvider(repo, cfg.watch.label_prefix, token);
103
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token, cfg.watch.github.project_number, cfg.watch.github.status_map);
104
104
  }
105
105
  if (cfg.watch.issue_provider === "jira") {
106
106
  if (!cfg.watch.jira.base_url.trim() || !cfg.watch.jira.project_key.trim()) {
@@ -113,7 +113,7 @@ export function resolveIssueProvider(cfg) {
113
113
  console.error('JIRA_EMAIL and JIRA_API_TOKEN must both be 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.');
114
114
  return null;
115
115
  }
116
- return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
116
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map);
117
117
  }
118
118
  console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
119
119
  return null;
@@ -136,7 +136,7 @@ function resolveCodeHostProvider(cfg) {
136
136
  console.error('GITHUB_TOKEN is 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.');
137
137
  return null;
138
138
  }
139
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
139
+ return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token, cfg.watch.github.project_number, cfg.watch.github.status_map);
140
140
  }
141
141
  if (cfg.watch.code_host === "bitbucket") {
142
142
  const email = process.env["BITBUCKET_EMAIL"];
@@ -196,17 +196,35 @@ export async function watchInitCommand(argv) {
196
196
  if (checks.some((c) => !c.exists))
197
197
  return 1;
198
198
  }
199
+ if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider && Object.values(cfg.watch.jira.status_map).some(Boolean)) {
200
+ const checks = await provider.validateStatusMap();
201
+ console.log(`\nvalidating watch.jira.status_map against ${cfg.watch.jira.project_key}:`);
202
+ for (const c of checks) {
203
+ console.log(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.jiraStatus}${c.exists ? "" : ` — no status named "${c.jiraStatus}" in ${cfg.watch.jira.project_key}`}`);
204
+ }
205
+ if (checks.some((c) => !c.exists))
206
+ return 1;
207
+ }
208
+ if (cfg.watch.issue_provider === "github" && provider instanceof GitHubProvider && Object.values(cfg.watch.github.status_map).some(Boolean)) {
209
+ const checks = await provider.validateStatusMap();
210
+ console.log(`\nvalidating watch.github.status_map against Projects v2 #${cfg.watch.github.project_number}:`);
211
+ for (const c of checks) {
212
+ console.log(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.githubStatus}${c.exists ? "" : ` — no Status option named "${c.githubStatus}" on Projects v2 #${cfg.watch.github.project_number}`}`);
213
+ }
214
+ if (checks.some((c) => !c.exists))
215
+ return 1;
216
+ }
199
217
  return 0;
200
218
  }
201
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. */
202
- function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
220
+ async function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
203
221
  let detail = prefix;
204
222
  let db;
205
223
  try {
206
224
  const wtAnchor = paths.resolveAnchor(cwd);
207
225
  const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
208
- db = new SfDb(wtDataPaths.db_path);
209
- 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");
210
228
  if (failed)
211
229
  detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
212
230
  }
@@ -214,7 +232,7 @@ function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
214
232
  // best-effort — the generic message above still points at where to look
215
233
  }
216
234
  finally {
217
- db?.close();
235
+ await db?.close();
218
236
  }
219
237
  return detail;
220
238
  }
@@ -227,14 +245,13 @@ function detailFromFailedPhase(cfg, cwd, adwId, prefix) {
227
245
  * is a nice-to-have, not something that gets to block the PR-open flow
228
246
  * it's decorating. Top-level for the same reason as `detailFromFailedPhase`.
229
247
  */
230
- function reviewSummaryFor(cfg, cwd, adwId) {
248
+ async function reviewSummaryFor(cfg, cwd, adwId) {
231
249
  let db;
232
250
  try {
233
251
  const wtAnchor = paths.resolveAnchor(cwd);
234
252
  const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
235
- db = new SfDb(wtDataPaths.db_path);
236
- const envelope = db
237
- .envelopes(adwId)
253
+ db = await SfDb.open(wtDataPaths.db, wtDataPaths.sessions_dir);
254
+ const envelope = (await db.envelopes(adwId))
238
255
  .filter((e) => e.output_type === ReviewOutput.name)
239
256
  .at(-1); // the LATEST verdict — a revise loop can produce several
240
257
  if (!envelope?.payload_json)
@@ -246,7 +263,7 @@ function reviewSummaryFor(cfg, cwd, adwId) {
246
263
  return undefined; // best-effort — see the doc comment above
247
264
  }
248
265
  finally {
249
- db?.close();
266
+ await db?.close();
250
267
  }
251
268
  }
252
269
  /**
@@ -291,14 +308,14 @@ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
291
308
  * a shared handle in — the same per-call shape `detailFromFailedPhase`/
292
309
  * `reviewSummaryFor` above already use against the same WAL db.
293
310
  */
294
- const readMetrics = (adwId) => {
295
- if (!existsSync(dataPaths.db_path))
296
- return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
311
+ const readMetrics = async (adwId) => {
297
312
  let db;
298
313
  try {
299
- db = new SfDb(dataPaths.db_path);
300
- const gates = db.gates(adwId);
301
- 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);
302
319
  // `passed` is a SQLite integer boolean that CAN be NULL on a row an
303
320
  // older tracer wrote. Counted explicitly in both directions, never as
304
321
  // `!g.passed`: a NULL is unknown, and letting it read as a failure
@@ -314,7 +331,7 @@ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
314
331
  return { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
315
332
  }
316
333
  finally {
317
- db?.close();
334
+ await db?.close();
318
335
  }
319
336
  };
320
337
  /**
@@ -323,24 +340,25 @@ export function makeWatchFanoutDispatch(cfg, configPaths, dataPaths, chainDef) {
323
340
  * candidate id. Safe direction is FALSE (see `WatchFanoutDeps.adwIdsFree`'s
324
341
  * own doc comment): an unreadable db reports "taken" rather than "free".
325
342
  */
326
- const adwIdsFree = (adwIds) => {
327
- if (!existsSync(dataPaths.db_path))
328
- return true; // no db yet — nothing to collide with
343
+ const adwIdsFree = async (adwIds) => {
329
344
  let db;
330
345
  try {
331
- db = new SfDb(dataPaths.db_path);
332
- 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);
333
351
  }
334
352
  catch {
335
353
  return false;
336
354
  }
337
355
  finally {
338
- db?.close();
356
+ await db?.close();
339
357
  }
340
358
  };
341
- const reviewFor = (opts) => ({
359
+ const reviewFor = async (opts) => ({
342
360
  reviewRequired: resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer"),
343
- reviewSummary: reviewSummaryFor(cfg, opts.cwd, opts.adwId),
361
+ reviewSummary: await reviewSummaryFor(cfg, opts.cwd, opts.adwId),
344
362
  });
345
363
  return { runAttempt, readMetrics, adwIdsFree, reviewFor };
346
364
  }
@@ -396,6 +414,49 @@ export async function watchCommand(argv) {
396
414
  }
397
415
  }
398
416
  }
417
+ // watch.jira.status_map applies to every build-lane transition, independent
418
+ // of watch.refine.enabled — validated at startup for the same reason
419
+ // issue_types is: a bad status name should stop the daemon before it
420
+ // starts, not fail silently (well, log-and-skip — see jira_provider.ts's
421
+ // syncStatus()) on every single transition once it's running. Silent when
422
+ // status_map is empty (the default, opt-in feature) or has no mismatches.
423
+ if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
424
+ const configured = Object.values(cfg.watch.jira.status_map).some(Boolean);
425
+ if (configured) {
426
+ const checks = await provider.validateStatusMap();
427
+ const mismatches = checks.filter((c) => !c.exists);
428
+ if (mismatches.length > 0) {
429
+ console.error(`watch.jira.status_map has ${mismatches.length} mismatch(es) against ${cfg.watch.jira.project_key}:`);
430
+ for (const c of checks) {
431
+ console.error(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.jiraStatus}${c.exists ? "" : ` — no status named "${c.jiraStatus}" in ${cfg.watch.jira.project_key}`}`);
432
+ }
433
+ console.error(`Fix watch.jira.status_map, or the project's statuses, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
434
+ return 1;
435
+ }
436
+ }
437
+ }
438
+ // watch.github.status_map applies to every build-lane transition,
439
+ // independent of watch.refine.enabled — validated at startup for the same
440
+ // reason watch.jira.issue_types is: a bad Status option name should stop
441
+ // the daemon before it starts, not fail silently (well, log-and-skip —
442
+ // see github_provider.ts's syncStatus()) on every single transition once
443
+ // it's running. Silent when status_map is empty (the default, opt-in
444
+ // feature) or has no mismatches.
445
+ if (cfg.watch.issue_provider === "github" && provider instanceof GitHubProvider) {
446
+ const configured = Object.values(cfg.watch.github.status_map).some(Boolean);
447
+ if (configured) {
448
+ const checks = await provider.validateStatusMap();
449
+ const mismatches = checks.filter((c) => !c.exists);
450
+ if (mismatches.length > 0) {
451
+ console.error(`watch.github.status_map has ${mismatches.length} mismatch(es) against Projects v2 #${cfg.watch.github.project_number}:`);
452
+ for (const c of checks) {
453
+ console.error(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.githubStatus}${c.exists ? "" : ` — no Status option named "${c.githubStatus}" on Projects v2 #${cfg.watch.github.project_number}`}`);
454
+ }
455
+ console.error(`Fix watch.github.status_map (and watch.github.project_number), or the project's Status options, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
456
+ return 1;
457
+ }
458
+ }
459
+ }
399
460
  // watch.fanout.n > 1 startup gates — all guarded so watch.fanout.n: 1 (the
400
461
  // default) reaches none of them, and a bad configuration stops the daemon
401
462
  // before it starts rather than failing silently every tick.
@@ -525,9 +586,9 @@ export async function watchCommand(argv) {
525
586
  // is reflected here too, not just each chain's static/YAML default.
526
587
  const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
527
588
  if (code === 0) {
528
- 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) };
529
590
  }
530
- 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.`);
531
592
  return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
532
593
  };
533
594
  /**
@@ -559,7 +620,7 @@ export async function watchCommand(argv) {
559
620
  // comment for the KNOWN LIMITATION this fixes.
560
621
  const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
561
622
  if (code !== 0) {
562
- 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.`);
563
624
  return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [], split: [] };
564
625
  }
565
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. */