@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.
- package/README.md +51 -9
- package/assets/skill/references/config.md +12 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +121 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +91 -30
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +95 -5
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +356 -15
- package/dist/core/data_types.js +180 -7
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/issues/github_provider.d.ts +66 -2
- package/dist/core/issues/github_provider.js +161 -2
- package/dist/core/issues/jira_provider.d.ts +50 -9
- package/dist/core/issues/jira_provider.js +62 -2
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/refine.js +2 -2
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
- package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
- package/web/index.html +33 -2
- package/web/logo.svg +4 -4
- package/web/assets/index-C7nF068F.css +0 -1
- package/web/assets/index-mzSArcnQ.js +0 -11
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- 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: {
|
|
47
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
/**
|
|
7
|
-
|
|
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 {
|
|
16
|
+
return { dataPaths, anchor, dataDir: dataPaths.data_dir, cfg };
|
|
12
17
|
}
|
|
13
|
-
export function openTrace(options) {
|
|
14
|
-
const {
|
|
15
|
-
return { db:
|
|
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
|
|
19
|
-
* in that one slot instead. `anchor`/`dataDir`/`cfg`
|
|
20
|
-
* SAME `resolveTrace` call either way — a cold start
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 {
|
|
28
|
-
|
|
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
|
}
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -6,23 +6,33 @@ import { runUi } from "../../ui/server/serve.js";
|
|
|
6
6
|
export async function uiCommand(argv) {
|
|
7
7
|
const { options, flags } = parseCli(argv, ["cwd", "config", "db", "port"], ["no-open"]);
|
|
8
8
|
const anchor = paths.resolveAnchor(options["cwd"]);
|
|
9
|
-
let
|
|
9
|
+
let db;
|
|
10
|
+
let sessionsDir;
|
|
11
|
+
let label;
|
|
10
12
|
if (options["db"]) {
|
|
11
|
-
|
|
13
|
+
// `--db <path>` always names a local sqlite file — there is no D1
|
|
14
|
+
// equivalent of "point at this one file" from the CLI yet.
|
|
15
|
+
const dbPath = path.resolve(anchor.cwd, options["db"]);
|
|
16
|
+
db = { kind: "sqlite", path: dbPath };
|
|
17
|
+
label = dbPath;
|
|
12
18
|
}
|
|
13
19
|
else {
|
|
14
20
|
const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
|
|
15
|
-
|
|
21
|
+
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
22
|
+
db = dataPaths.db;
|
|
23
|
+
sessionsDir = dataPaths.sessions_dir;
|
|
24
|
+
label = db.kind === "sqlite" ? db.path : `d1:${db.database_id}`;
|
|
16
25
|
}
|
|
17
26
|
try {
|
|
18
27
|
const handle = await runUi({
|
|
19
|
-
|
|
28
|
+
db,
|
|
29
|
+
sessionsDir,
|
|
20
30
|
webDir: paths.WEB_DIR,
|
|
21
31
|
port: options["port"] ? Number.parseInt(options["port"], 10) : undefined,
|
|
22
32
|
open: !flags["no-open"],
|
|
23
33
|
});
|
|
24
34
|
console.log(`[spf] ui ${handle.url}`);
|
|
25
|
-
console.log(`[spf] db ${
|
|
35
|
+
console.log(`[spf] db ${label}`);
|
|
26
36
|
// runUi() already registers SIGINT/SIGTERM handlers that exit the
|
|
27
37
|
// process; just keep this call from returning until then.
|
|
28
38
|
await new Promise(() => { });
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
package/dist/cli/interview.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface DetectedContext {
|
|
|
6
6
|
gitName?: string;
|
|
7
7
|
scripts: Record<string, string>;
|
|
8
8
|
claudeOnPath: boolean;
|
|
9
|
+
opencodeOnPath: boolean;
|
|
9
10
|
/** Whatever's already in `.env` — shown masked so a re-run can offer "keep current" instead of asking blind. */
|
|
10
11
|
existingEnv: Map<string, string>;
|
|
11
12
|
/** The packaged roster's agent names (planner, builder, scout, reviewer, documenter today) — read from the built-in config so a 6th agent added there needs no interview change. */
|