catherd-cli 1.3.0 → 1.5.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 +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -21,9 +21,9 @@ const COMPAT = ["CLAUDE", "CURSOR"].flatMap((tool) =>
|
|
|
21
21
|
/**
|
|
22
22
|
* Spec 1.3 §5.4: an isolated run's env. GROK_HOME and the toggles alone still load the user's Claude Code
|
|
23
23
|
* plugins, agents and permission rules (research §3.8 [run]), so HOME moves too, and GROK_HOME lives in it.
|
|
24
|
+
* `home`: another root than the isolated runs' (doctor's sandbox check runs in a scratch one).
|
|
24
25
|
*/
|
|
25
|
-
export function grokHomeEnv(): Record<string, string> {
|
|
26
|
-
const home = isolatedGrokRoot();
|
|
26
|
+
export function grokHomeEnv(home = isolatedGrokRoot()): Record<string, string> {
|
|
27
27
|
return {
|
|
28
28
|
...movedHomeEnv(home),
|
|
29
29
|
GROK_HOME: join(home, ".grok"),
|
|
@@ -82,10 +82,47 @@ const tomlError = (text: string): string | null => {
|
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
/** The `read_write` roots of each profile in catherd's block as it stands, by profile name. */
|
|
86
|
+
function blockRoots(block: string): Record<string, string[]> {
|
|
87
|
+
try {
|
|
88
|
+
const p =
|
|
89
|
+
(Bun.TOML.parse(block) as { profiles?: Record<string, { read_write?: unknown }> }).profiles ?? {};
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.entries(p).map(([k, v]) => [
|
|
92
|
+
k,
|
|
93
|
+
Array.isArray(v.read_write) ? v.read_write.filter((r): r is string => typeof r === "string") : [],
|
|
94
|
+
]),
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `profiles` with each one's `read_write` merged into the block's current roots: those that still exist, in
|
|
103
|
+
* their order, then the new ones. Two catherd homes on one machine (another CATHERD_HOME, other lock dirs) then
|
|
104
|
+
* converge on one block instead of rewriting each other's.
|
|
105
|
+
*/
|
|
106
|
+
function mergedProfiles(
|
|
107
|
+
profiles: Record<string, Record<string, unknown>>,
|
|
108
|
+
was: Record<string, string[]>,
|
|
109
|
+
): Record<string, Record<string, unknown>> {
|
|
110
|
+
return Object.fromEntries(
|
|
111
|
+
Object.entries(profiles).map(([name, p]) => {
|
|
112
|
+
const mine = Array.isArray(p.read_write) ? (p.read_write as string[]) : [];
|
|
113
|
+
const kept = (was[name] ?? []).filter((r) => existsSync(r));
|
|
114
|
+
const roots = [...new Set([...kept, ...mine])];
|
|
115
|
+
return [name, { ...p, read_write: roots }];
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
/**
|
|
86
|
-
* `text` (a sandbox.toml) with catherd's marked block set to `profiles`:
|
|
87
|
-
*
|
|
88
|
-
*
|
|
121
|
+
* `text` (a sandbox.toml) with catherd's marked block set to `profiles`: rewritten where it stands, or appended
|
|
122
|
+
* when there is none. A rewrite in place keeps every line outside the block, and what each line belongs to: a
|
|
123
|
+
* bare key the user put after the block stays in the table it was in. The block's existing roots that still
|
|
124
|
+
* exist stay (mergedProfiles). `why` when catherd must not write: the file is not TOML, defines a `catherd-*`
|
|
125
|
+
* profile of its own, or has lost the block's end marker.
|
|
89
126
|
*/
|
|
90
127
|
export function withCatherdProfiles(
|
|
91
128
|
text: string,
|
|
@@ -95,21 +132,22 @@ export function withCatherdProfiles(
|
|
|
95
132
|
const end = start === -1 ? -1 : text.indexOf(END, start);
|
|
96
133
|
if (start !== -1 && end === -1)
|
|
97
134
|
return { why: `catherd's block has lost its end marker (${END}): delete the block` };
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
? text
|
|
102
|
-
: text.slice(0, start).replace(/\n\n$/, "\n") + text.slice(end + END.length).replace(/^\r?\n/, "");
|
|
135
|
+
const after = start === -1 ? "" : text.slice(end + END.length).replace(/^\r?\n/, "");
|
|
136
|
+
// the file without the block, for the checks: the block goes with the blank line catherd put before it
|
|
137
|
+
const rest = start === -1 ? text : text.slice(0, start).replace(/\n\n$/, "\n") + after;
|
|
103
138
|
const bad = tomlError(rest);
|
|
104
139
|
if (bad) return { why: `it is not valid TOML (${bad})` };
|
|
105
140
|
const own = Object.keys((Bun.TOML.parse(rest) as { profiles?: object }).profiles ?? {}).find((k) =>
|
|
106
141
|
k.startsWith("catherd-"),
|
|
107
142
|
);
|
|
108
143
|
if (own) return { why: `it has a [profiles.${own}] of its own: rename it, catherd's tables are catherd-*` };
|
|
144
|
+
const was = start === -1 ? {} : blockRoots(text.slice(start, end));
|
|
145
|
+
const block = `${HEADER}\n${Bun.TOML.stringify({ profiles: mergedProfiles(profiles, was) })}${END}\n`;
|
|
109
146
|
const head = rest.trimEnd();
|
|
110
|
-
const out =
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
const out =
|
|
148
|
+
start === -1 ? `${head ? `${head}\n\n` : ""}${block}` : `${text.slice(0, start)}${block}${after}`;
|
|
149
|
+
const broken = tomlError(out);
|
|
150
|
+
return broken ? { why: `it is not valid TOML with catherd's tables added (${broken})` } : { text: out };
|
|
113
151
|
}
|
|
114
152
|
|
|
115
153
|
/**
|
|
@@ -119,14 +157,31 @@ export function withCatherdProfiles(
|
|
|
119
157
|
*/
|
|
120
158
|
export function writeGrokProfiles(home: string): void {
|
|
121
159
|
const path = join(home, "sandbox.toml");
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const was = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
125
|
-
const next = withCatherdProfiles(was);
|
|
126
|
-
if ("why" in next)
|
|
127
|
-
throw new CatherdError("E_CONFIG_INVALID", `catherd will not edit ${file}: ${next.why}`, {
|
|
160
|
+
const refuse = (file: string, why: string) =>
|
|
161
|
+
new CatherdError("E_CONFIG_INVALID", `catherd will not edit ${file}: ${why}`, {
|
|
128
162
|
fix: `fix ${file}, or isolate grok (catherd profile set harness.grok.isolated true)`,
|
|
129
163
|
});
|
|
164
|
+
// a link (a dotfiles repo) stays a link: the atomic rename goes to the file it points at
|
|
165
|
+
let file = path;
|
|
166
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
167
|
+
try {
|
|
168
|
+
file = realpathSync(path);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
throw refuse(path, `it is a link catherd cannot follow (${(e as NodeJS.ErrnoException).code ?? e})`);
|
|
171
|
+
}
|
|
172
|
+
let was = "";
|
|
173
|
+
try {
|
|
174
|
+
was = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
175
|
+
} catch (e) {
|
|
176
|
+
throw refuse(file, `catherd cannot read it (${(e as NodeJS.ErrnoException).code ?? e})`);
|
|
177
|
+
}
|
|
178
|
+
const next = withCatherdProfiles(was);
|
|
179
|
+
if ("why" in next) throw refuse(file, next.why);
|
|
130
180
|
if (next.text === was) return;
|
|
131
|
-
|
|
181
|
+
try {
|
|
182
|
+
writeTextAtomic(file, next.text, { mode: existsSync(file) ? statSync(file).mode & 0o777 : 0o600 });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// a read-only store (a Nix home, say) or a folder catherd may not write
|
|
185
|
+
throw refuse(file, `catherd cannot write it (${(e as NodeJS.ErrnoException).code ?? e})`);
|
|
186
|
+
}
|
|
132
187
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { CatherdError } from "../../domain/errors.ts";
|
|
4
4
|
import type { Rung } from "../../domain/ids.ts";
|
|
5
5
|
import type { Access, RunStatus } from "../../domain/record.ts";
|
|
@@ -57,11 +57,13 @@ const NO_UPDATE = { GROK_DISABLE_AUTOUPDATER: "1" };
|
|
|
57
57
|
export const grokShell = { timeoutMs: 15_000 };
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* A fresh run's session id (`-s`), derived from its dispatch
|
|
61
|
-
* killed before grok's `end` (a timeout, a cancel) still records the session grok
|
|
60
|
+
* A fresh run's session id (`-s`), derived from its dispatch id (the dispatch dir's last part) so finalize knows
|
|
61
|
+
* it without the stream: a run killed before grok's `end` (a timeout, a cancel) still records the session grok
|
|
62
|
+
* saved (plan 16 final review). The id alone, not the full path: a data dir reached by another path (a link,
|
|
63
|
+
* `/private/var` for `/var`) gives the same session.
|
|
62
64
|
*/
|
|
63
65
|
export function sessionFor(r: Pick<RunRequest, "dispatchDir">): string {
|
|
64
|
-
const h = new Bun.CryptoHasher("sha256").update(r.dispatchDir).digest("hex");
|
|
66
|
+
const h = new Bun.CryptoHasher("sha256").update(basename(r.dispatchDir)).digest("hex");
|
|
65
67
|
const variant = ((Number.parseInt(h[16] as string, 16) & 0x3) | 0x8).toString(16);
|
|
66
68
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
67
69
|
}
|
|
@@ -125,11 +127,20 @@ function shippedOnGrok(): ShippedGrok {
|
|
|
125
127
|
return shipped;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
/** `grok models` under `env`: the user's GROK_HOME (its login) by default, catherd's isolated one with the key. */
|
|
131
|
+
async function listAs(env: Record<string, string>): Promise<DiscoveredModel[]> {
|
|
132
|
+
const r = await runCli("grok", ["models"], { ...grokShell, env });
|
|
130
133
|
return r?.ok ? parseGrokModels(r.out, shippedOnGrok()).models : [];
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
const listModels = (): Promise<DiscoveredModel[]> => listAs(NO_UPDATE);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The discovery cache an isolated run is judged by: the listing under catherd's own GROK_HOME, where only
|
|
140
|
+
* XAI_API_KEY signs grok in, apart from the native login's (1.3 follow-ups: the two accounts may differ).
|
|
141
|
+
*/
|
|
142
|
+
export const ISOLATED_LISTING = "grok-isolated";
|
|
143
|
+
|
|
133
144
|
/**
|
|
134
145
|
* Spec 1.3 §5.3–§5.4: an isolated run needs XAI_API_KEY (its home holds no login, and the user's auth.json is
|
|
135
146
|
* never copied: its refresh token rotates, research §3.9); a workspace-write run needs catherd-ws in the
|
|
@@ -150,7 +161,13 @@ async function prepare(req: {
|
|
|
150
161
|
if (req.isolated) ensurePrivateDir(home);
|
|
151
162
|
if (req.access === "workspace-write") writeGrokProfiles(home);
|
|
152
163
|
const { model, effort } = req.rung;
|
|
153
|
-
|
|
164
|
+
// an isolated run signs in with the key, so its model is checked against the key's own listing
|
|
165
|
+
const models = req.isolated
|
|
166
|
+
? await discovered(ISOLATED_LISTING, () => listAs({ ...NO_UPDATE, ...grokHomeEnv() }), {
|
|
167
|
+
maxAgeMs: DAY_MS,
|
|
168
|
+
need: model,
|
|
169
|
+
})
|
|
170
|
+
: await discovered("grok", listModels, { maxAgeMs: DAY_MS, need: model });
|
|
154
171
|
if (models.length === 0) return; // grok listed nothing: let the run itself say what is wrong
|
|
155
172
|
const m = models.find((x) => x.id === model);
|
|
156
173
|
if (!m)
|
|
@@ -241,7 +258,9 @@ async function sandboxInfo(): Promise<{ id: string; label: string; detail: strin
|
|
|
241
258
|
const r = await runCli(
|
|
242
259
|
"grok",
|
|
243
260
|
["-p", "hi", "--output-format", "streaming-json", "--sandbox", "read-only", "--no-auto-update"],
|
|
244
|
-
|
|
261
|
+
// a scratch HOME as well as GROK_HOME, with memory and the compat features off: nothing of the user's
|
|
262
|
+
// own Claude Code or Cursor setup loads into the check (1.3 follow-ups)
|
|
263
|
+
{ ...grokShell, env: { ...NO_UPDATE, ...grokHomeEnv(home), XAI_API_KEY: "" } },
|
|
245
264
|
);
|
|
246
265
|
if (!r) return null;
|
|
247
266
|
const refused = /could not apply the '?read-only'? sandbox profile/.test(r.err);
|
|
@@ -155,6 +155,20 @@ function finalize(run: FinishedRun): Outcome {
|
|
|
155
155
|
const f = foldOpencodeEvents(run.eventLines);
|
|
156
156
|
const stopped = run.exit.reason;
|
|
157
157
|
const tooOld = f.tooOld || OPENCODE_TOO_OLD.some((r) => r.test(run.stderr));
|
|
158
|
+
// plan 23: the provider kept failing; failover takes it as a usage limit, onto the next backend
|
|
159
|
+
if (stopped === "provider-unavailable")
|
|
160
|
+
return {
|
|
161
|
+
status: "limit",
|
|
162
|
+
thread: f.thread ?? run.request.thread,
|
|
163
|
+
tokens: f.tokens,
|
|
164
|
+
costUsd: f.costUsd,
|
|
165
|
+
images: [],
|
|
166
|
+
error: {
|
|
167
|
+
code: "provider-unavailable",
|
|
168
|
+
message: f.error?.message ?? "the provider retried with no progress",
|
|
169
|
+
},
|
|
170
|
+
reply: f.reply,
|
|
171
|
+
};
|
|
158
172
|
const status: RunStatus =
|
|
159
173
|
stopped === "cancelled"
|
|
160
174
|
? "cancelled"
|
|
@@ -201,6 +215,8 @@ function parse(line: string): EventDelta {
|
|
|
201
215
|
const activity = opencodeActivity(e);
|
|
202
216
|
if (activity) d.activity = activity;
|
|
203
217
|
if (typeof e.sessionID === "string") d.thread = e.sessionID;
|
|
218
|
+
// plan 23: each provider retry starts a step again with nothing between; the supervisor counts them
|
|
219
|
+
if (e.type === "step_start") d.step = true;
|
|
204
220
|
if (e.type === "step_finish") {
|
|
205
221
|
d.tokens = opencodeTokens(e.part?.tokens);
|
|
206
222
|
d.requestInput = d.tokens.input;
|
|
@@ -220,6 +236,21 @@ function parse(line: string): EventDelta {
|
|
|
220
236
|
* older messages keep their errors, so only the newest assistant message counts, and with `sinceMs` only
|
|
221
237
|
* if this run wrote it.
|
|
222
238
|
*/
|
|
239
|
+
/**
|
|
240
|
+
* Plan 23: the provider retry the newest assistant message this run wrote is waiting out (a 503, an
|
|
241
|
+
* overloaded backend), by its attempt number; null when it is not retrying.
|
|
242
|
+
*/
|
|
243
|
+
function providerRetryOf(messages: unknown, sinceMs: number): number | null {
|
|
244
|
+
if (!Array.isArray(messages)) return null;
|
|
245
|
+
const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
|
|
246
|
+
if (!m?.retry || !(m.time?.created >= sinceMs)) return null;
|
|
247
|
+
return typeof m.retry.attempt === "number" ? m.retry.attempt : 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function providerRetry(thread: string, sinceMs: number): Promise<number | null> {
|
|
251
|
+
return providerRetryOf((await opencodeApi("GET", `/api/session/${thread}/message`))?.data, sinceMs);
|
|
252
|
+
}
|
|
253
|
+
|
|
223
254
|
function limitRetry(messages: unknown, sinceMs?: number): string | null {
|
|
224
255
|
if (!Array.isArray(messages)) return null;
|
|
225
256
|
const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
|
|
@@ -265,7 +296,12 @@ async function isBusy(thread: string, sinceMs?: number): Promise<boolean> {
|
|
|
265
296
|
const active = (await opencodeApi("GET", "/api/session/active"))?.data;
|
|
266
297
|
if (!active || typeof active !== "object" || !(thread in active)) return false;
|
|
267
298
|
const messages = (await opencodeApi("GET", `/api/session/${thread}/message`))?.data;
|
|
268
|
-
|
|
299
|
+
// plan 23: a session only retrying its provider is not busy, so the idle timeout still runs
|
|
300
|
+
return (
|
|
301
|
+
Array.isArray(messages) &&
|
|
302
|
+
limitRetry(messages, sinceMs) === null &&
|
|
303
|
+
providerRetryOf(messages, sinceMs ?? 0) === null
|
|
304
|
+
);
|
|
269
305
|
}
|
|
270
306
|
|
|
271
307
|
/** Killing the v2 client does not stop its session; the service must be told (research §2.4). */
|
|
@@ -327,6 +363,7 @@ export const opencodeAdapter: BackendAdapter = {
|
|
|
327
363
|
resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
|
|
328
364
|
interrupt: (thread) => interrupt(thread),
|
|
329
365
|
isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
|
|
366
|
+
providerRetry: (thread, _cwd, sinceMs) => providerRetry(thread, sinceMs),
|
|
330
367
|
failoverFor,
|
|
331
368
|
graceAfterFinalMs: null,
|
|
332
369
|
reportsCost: true,
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { HOST_ARG } from "./entry/host-arg.ts";
|
|
2
3
|
import type { ArgsDef } from "citty";
|
|
3
4
|
import { errorMessage } from "./domain/errors.ts";
|
|
4
5
|
import { runtimeRefusal } from "./domain/runtime.ts";
|
|
@@ -12,7 +13,7 @@ if (refusal) {
|
|
|
12
13
|
|
|
13
14
|
const { realpathSync } = await import("node:fs");
|
|
14
15
|
const { fileURLToPath } = await import("node:url");
|
|
15
|
-
const { defineCommand, renderUsage, runCommand } = await import("citty");
|
|
16
|
+
const { defineCommand, renderUsage, runCommand, parseArgs } = await import("citty");
|
|
16
17
|
const { CatherdError, isCatherdError } = await import("./domain/errors.ts");
|
|
17
18
|
const { EXIT, exitCodeOf, printError, stripAnsi } = await import("./entry/cli-kit.ts");
|
|
18
19
|
const { VERSION } = await import("./infra/version.ts");
|
|
@@ -21,6 +22,7 @@ type Command = ReturnType<typeof defineCommand>;
|
|
|
21
22
|
|
|
22
23
|
/** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
|
|
23
24
|
const dashboardArgs: ArgsDef = {
|
|
25
|
+
...HOST_ARG,
|
|
24
26
|
plain: {
|
|
25
27
|
type: "boolean",
|
|
26
28
|
description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
|
|
@@ -48,7 +50,12 @@ export const main: Command = defineCommand({
|
|
|
48
50
|
doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
|
|
49
51
|
catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
|
|
50
52
|
knowledge: () => import("./entry/knowledge-command.ts").then((m) => m.knowledgeCommand),
|
|
53
|
+
workspace: () => import("./entry/workspace-command.ts").then((m) => m.workspaceCommand),
|
|
51
54
|
lock: () => import("./entry/lock-command.ts").then((m) => m.lockCommand),
|
|
55
|
+
"run-file": () => import("./entry/role-cli-command.ts").then((m) => m.runFileCommand),
|
|
56
|
+
gate: () => import("./entry/role-cli-command.ts").then((m) => m.gateCommand),
|
|
57
|
+
pause: () => import("./entry/pause-command.ts").then((m) => m.pauseCommand),
|
|
58
|
+
resume: () => import("./entry/pause-command.ts").then((m) => m.resumeCommand),
|
|
52
59
|
"capture-fixtures": () =>
|
|
53
60
|
import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
|
|
54
61
|
mcp: () => import("./entry/mcp/command.ts").then((m) => m.mcpCommand),
|
|
@@ -56,7 +63,7 @@ export const main: Command = defineCommand({
|
|
|
56
63
|
},
|
|
57
64
|
// citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
|
|
58
65
|
async run(ctx) {
|
|
59
|
-
if (
|
|
66
|
+
if (ctx.args._.length > 0) return;
|
|
60
67
|
await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
|
|
61
68
|
},
|
|
62
69
|
});
|
|
@@ -69,14 +76,24 @@ async function commandFor(argv: string[]): Promise<[Command, Command | undefined
|
|
|
69
76
|
let cmd = main;
|
|
70
77
|
let parent: Command | undefined;
|
|
71
78
|
const path: string[] = [];
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
let remaining = argv;
|
|
80
|
+
while (true) {
|
|
81
|
+
const defs = await resolve(cmd.args ?? {});
|
|
82
|
+
const navigationArgs: ArgsDef = Object.fromEntries(
|
|
83
|
+
Object.entries(defs).map(([key, def]) => [
|
|
84
|
+
key,
|
|
85
|
+
{ ...def, required: false, ...(def.type === "enum" ? { type: "string" as const } : {}) },
|
|
86
|
+
]),
|
|
87
|
+
) as ArgsDef;
|
|
88
|
+
const a = parseArgs(remaining, navigationArgs)._[0];
|
|
89
|
+
if (!a) break;
|
|
74
90
|
const subs = cmd.subCommands ? await resolve(cmd.subCommands) : undefined;
|
|
75
91
|
const next = subs?.[a];
|
|
76
92
|
if (!next) break;
|
|
77
93
|
parent = cmd;
|
|
78
94
|
cmd = (await resolve(next)) as Command;
|
|
79
95
|
path.push(a);
|
|
96
|
+
remaining = remaining.slice(remaining.indexOf(a) + 1);
|
|
80
97
|
}
|
|
81
98
|
return [cmd, parent, path];
|
|
82
99
|
}
|
|
@@ -123,22 +140,39 @@ export async function runCli(argv: string[]): Promise<number> {
|
|
|
123
140
|
console.log(`${await helpText(cmd, path)}\n`);
|
|
124
141
|
return EXIT.ok;
|
|
125
142
|
}
|
|
126
|
-
const command = own.find((a) => !a.startsWith("-"));
|
|
127
|
-
// a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
|
|
128
|
-
const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
|
|
129
|
-
// citty negates a boolean as `--no-<name>`
|
|
130
|
-
const known = (name: string) =>
|
|
131
|
-
Object.hasOwn(dashboardArgs, name) ||
|
|
132
|
-
(name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
|
|
133
|
-
const unknown = command === undefined ? own.find((a) => !known(flag(a))) : undefined;
|
|
134
|
-
if (unknown !== undefined) {
|
|
135
|
-
printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
|
|
136
|
-
return EXIT.usage;
|
|
137
|
-
}
|
|
138
|
-
// `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
|
|
139
|
-
// the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
|
|
140
|
-
if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
|
|
141
143
|
try {
|
|
144
|
+
const command = parseArgs(own, dashboardArgs)._[0];
|
|
145
|
+
// a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
|
|
146
|
+
const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
|
|
147
|
+
// citty negates a boolean as `--no-<name>`
|
|
148
|
+
const known = (name: string) =>
|
|
149
|
+
Object.hasOwn(dashboardArgs, name) ||
|
|
150
|
+
(name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
|
|
151
|
+
const unknown =
|
|
152
|
+
command === undefined
|
|
153
|
+
? own.find((a, i) => a.startsWith("-") && !known(flag(a)) && own[i - 1] !== "--host")
|
|
154
|
+
: undefined;
|
|
155
|
+
if (unknown !== undefined) {
|
|
156
|
+
printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
|
|
157
|
+
return EXIT.usage;
|
|
158
|
+
}
|
|
159
|
+
// `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
|
|
160
|
+
// the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
|
|
161
|
+
if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
|
|
162
|
+
const parsed = parseArgs(own, dashboardArgs);
|
|
163
|
+
if (parsed.host !== undefined) {
|
|
164
|
+
const forwarded = own.filter(
|
|
165
|
+
(a, i) => a !== "--host" && own[i - 1] !== "--host" && !a.startsWith("--host="),
|
|
166
|
+
);
|
|
167
|
+
rawArgs.splice(
|
|
168
|
+
0,
|
|
169
|
+
rawArgs.length,
|
|
170
|
+
...forwarded,
|
|
171
|
+
"--host",
|
|
172
|
+
String(parsed.host),
|
|
173
|
+
...(dashdash < 0 ? [] : argv.slice(dashdash)),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
142
176
|
await runCommand(main, { rawArgs });
|
|
143
177
|
return Number(process.exitCode ?? EXIT.ok);
|
|
144
178
|
} catch (e) {
|
package/src/domain/agents.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseRung, tryParseRung } from "./ids.ts";
|
|
2
2
|
import { agentName, type Profile } from "./profile.ts";
|
|
3
3
|
import type { Access } from "./record.ts";
|
|
4
|
-
import { nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
|
|
4
|
+
import { NOT_THE_ORCHESTRATOR, nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
|
|
5
5
|
import { ROLES, type Role } from "./roles.ts";
|
|
6
6
|
|
|
7
7
|
export interface AgentFile {
|
|
@@ -32,6 +32,8 @@ export function renderAgent(o: {
|
|
|
32
32
|
"",
|
|
33
33
|
rolePrompt(o.role, o.version),
|
|
34
34
|
"",
|
|
35
|
+
NOT_THE_ORCHESTRATOR,
|
|
36
|
+
"",
|
|
35
37
|
].join("\n");
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { replyContract } from "./role-prompts.ts";
|
|
2
|
+
import { ROLE_MCP_SERVER, roleMcpTools } from "./role-tools.ts";
|
|
3
|
+
import type { Role } from "./roles.ts";
|
|
4
|
+
|
|
5
|
+
/** What admission adds to a role's brief, besides its reply contract (spec 1.5 plan 21). */
|
|
6
|
+
export interface BriefContext {
|
|
7
|
+
run: string;
|
|
8
|
+
name: string;
|
|
9
|
+
role: Role;
|
|
10
|
+
/** the lane file as it stands at admission, header and body */
|
|
11
|
+
lane: { id: string; text: string } | null;
|
|
12
|
+
/** the role's scratch folder, its TMPDIR, on a backend that grants it */
|
|
13
|
+
scratch: string | null;
|
|
14
|
+
/** whether the role's harness lists the catherd_role tools */
|
|
15
|
+
roleServer: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The CLI form of each role server tool, which any role can run from its shell. */
|
|
19
|
+
function cliForm(tool: string, run: string): string {
|
|
20
|
+
switch (tool) {
|
|
21
|
+
case "read_run_file":
|
|
22
|
+
return `catherd run-file read ${run} <path>`;
|
|
23
|
+
case "write_run_file":
|
|
24
|
+
return `catherd run-file write ${run} <path> (the content on stdin)`;
|
|
25
|
+
case "read_knowledge":
|
|
26
|
+
return "catherd knowledge show";
|
|
27
|
+
case "gate_check":
|
|
28
|
+
return `catherd gate check ${run} --milestone <M> --item <item> --command <command> --paths <path,…>`;
|
|
29
|
+
case "gate_pass":
|
|
30
|
+
return `catherd gate pass ${run} --item <item> --command <command> --paths <path,…> --evidence <evidence>`;
|
|
31
|
+
default:
|
|
32
|
+
return tool;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LANE_OPEN = (id: string) => `<lane-file path="lanes/${id}.md">`;
|
|
37
|
+
const LANE_CLOSE = "</lane-file>";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The lane file inlined (spec 1.5 plan 21): an isolated headless worker cannot read the run folder, so its Owns,
|
|
41
|
+
* fast check and body travel in the brief itself.
|
|
42
|
+
*/
|
|
43
|
+
export function laneBlock(id: string, text: string): string {
|
|
44
|
+
return [
|
|
45
|
+
`Your lane file, lanes/${id}.md, as it stood when you were dispatched:`,
|
|
46
|
+
LANE_OPEN(id),
|
|
47
|
+
text.trimEnd(),
|
|
48
|
+
LANE_CLOSE,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Who the role is, where its temp files go, and how it reaches its run's files and gate. */
|
|
53
|
+
export function roleNotes(c: BriefContext): string {
|
|
54
|
+
const tools = roleMcpTools(c.role);
|
|
55
|
+
const lines = [`catherd: you are ${c.name} of run ${c.run}.`];
|
|
56
|
+
if (c.scratch)
|
|
57
|
+
lines.push(
|
|
58
|
+
`Your scratch folder is ${c.scratch}, which is your $TMPDIR: put temporary files, logs and builds there, never in /tmp. catherd removes it with the run, so anything the orchestrator must keep goes in your reply.`,
|
|
59
|
+
);
|
|
60
|
+
const cli = tools.map((t) => cliForm(t, c.run)).join("; ");
|
|
61
|
+
lines.push(
|
|
62
|
+
c.roleServer
|
|
63
|
+
? `Your catherd tools: ${tools.map((t) => `mcp__${ROLE_MCP_SERVER}__${t}`).join(", ")}. If they are not listed, run the same from your shell: ${cli}.`
|
|
64
|
+
: `Your catherd commands, from your shell: ${cli}.`,
|
|
65
|
+
);
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The brief admission writes: the orchestrator's text, the lane file, the role's notes, then its reply contract,
|
|
71
|
+
* each once. A failover stand-in that reruns an earlier brief gets the lane file as it stands now and its own notes,
|
|
72
|
+
* never two copies.
|
|
73
|
+
*/
|
|
74
|
+
export function composeBrief(brief: string, c: BriefContext): string {
|
|
75
|
+
const contract = replyContract(c.role);
|
|
76
|
+
let body = brief.trimEnd();
|
|
77
|
+
if (body.endsWith(contract)) body = body.slice(0, -contract.length).trimEnd();
|
|
78
|
+
if (c.lane) {
|
|
79
|
+
const open = LANE_OPEN(c.lane.id);
|
|
80
|
+
const start = body.lastIndexOf(open);
|
|
81
|
+
const end = body.indexOf(LANE_CLOSE, start);
|
|
82
|
+
if (start >= 0 && end >= 0) {
|
|
83
|
+
const lead = body.lastIndexOf("\n\n", start);
|
|
84
|
+
body = `${body.slice(0, lead >= 0 ? lead : start)}${body.slice(end + LANE_CLOSE.length)}`.trimEnd();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const notesStart = body.lastIndexOf("\n\ncatherd: you are ");
|
|
88
|
+
if (notesStart >= 0) body = body.slice(0, notesStart).trimEnd();
|
|
89
|
+
const parts = [body, ...(c.lane ? [laneBlock(c.lane.id, c.lane.text)] : []), roleNotes(c), contract];
|
|
90
|
+
return `${parts.filter(Boolean).join("\n\n")}\n`;
|
|
91
|
+
}
|
|
@@ -34,6 +34,13 @@ export const DIM_SOURCES: Record<Dim, { anchor: FieldRef | "shipped"; others: Fi
|
|
|
34
34
|
benchmark: "Artificial Analysis Coding Index",
|
|
35
35
|
},
|
|
36
36
|
{ source: "epoch", field: "frontiercode", benchmark: "FrontierCode (Epoch AI)" },
|
|
37
|
+
// spec 1.5 plan 24: per model and effort, it scores a new release the day it ships (the identity run's
|
|
38
|
+
// GPT-6.1 Sol: medium 47.8, about Astra low)
|
|
39
|
+
{
|
|
40
|
+
source: "artificial-analysis",
|
|
41
|
+
field: "artificial_analysis_intelligence_index",
|
|
42
|
+
benchmark: "Artificial Analysis Intelligence Index",
|
|
43
|
+
},
|
|
37
44
|
],
|
|
38
45
|
},
|
|
39
46
|
// plan 14 Ruling C-2: the vendors' Terminal-Bench 4.0 values, not Epoch's Terminal-Bench 2.0, which shares
|
package/src/domain/catalog.ts
CHANGED
|
@@ -84,6 +84,11 @@ const FamilySchema = z.looseObject({
|
|
|
84
84
|
notes: z.record(z.string(), z.string()).default({}),
|
|
85
85
|
/** the day the vendor released it (models.dev), a stand-in feature (spec 1.2 §6.3) */
|
|
86
86
|
releaseDate: z.iso.date().optional(),
|
|
87
|
+
/**
|
|
88
|
+
* spec 1.5 plan 24: the family this one succeeds in the same line (GPT-6.1 Sol after GPT-6 Sol): until a
|
|
89
|
+
* value of its own arrives, a rung takes at least its predecessor's value at the same effort
|
|
90
|
+
*/
|
|
91
|
+
predecessor: z.string().min(1).optional(),
|
|
87
92
|
/** a sync's speed facts (spec 1.2 §4.1), `<source>.<field>` → value; they never carry a bar */
|
|
88
93
|
speed: z.record(z.string(), z.number()).optional(),
|
|
89
94
|
});
|
|
@@ -211,6 +216,8 @@ export interface Catalog {
|
|
|
211
216
|
* whose value it uses as `inferred` (spec 1.2 §6.1); filled by the stand-in ranking (services/standins.ts)
|
|
212
217
|
*/
|
|
213
218
|
inferred: Record<string, Partial<Record<Dim, InferredStandIn>>>;
|
|
219
|
+
/** the thresholds the user's override set or removed, as `<kind>/<difficulty>/<dim>` (route says "your override") */
|
|
220
|
+
userBars?: string[];
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
/**
|
|
@@ -317,11 +324,14 @@ export function buildCatalog(o: {
|
|
|
317
324
|
for (const [rung, like] of Object.entries(o.override?.treatLike ?? {}))
|
|
318
325
|
treatLike[rung] = { like, source: "user" };
|
|
319
326
|
const bars = structuredClone(o.scores.bars) as Bars;
|
|
327
|
+
const userBars: string[] = [];
|
|
320
328
|
for (const kind of KINDS)
|
|
321
329
|
for (const d of DIFFICULTIES)
|
|
322
|
-
for (const [dim, min] of Object.entries(o.override?.bars[kind]?.[d] ?? {}) as [Dim, number | null][])
|
|
330
|
+
for (const [dim, min] of Object.entries(o.override?.bars[kind]?.[d] ?? {}) as [Dim, number | null][]) {
|
|
331
|
+
userBars.push(`${kind}/${d}/${dim}`);
|
|
323
332
|
if (min === null) delete bars[kind][d][dim];
|
|
324
333
|
else bars[kind][d][dim] = min;
|
|
334
|
+
}
|
|
325
335
|
return {
|
|
326
336
|
families: o.facts ? applyFacts(o.models.families, o.facts) : o.models.families,
|
|
327
337
|
backends: o.models.backends,
|
|
@@ -333,6 +343,7 @@ export function buildCatalog(o: {
|
|
|
333
343
|
secs: o.secs ?? {},
|
|
334
344
|
features: o.features ?? {},
|
|
335
345
|
inferred: {},
|
|
346
|
+
userBars,
|
|
336
347
|
};
|
|
337
348
|
}
|
|
338
349
|
|
package/src/domain/changes.ts
CHANGED
|
@@ -33,6 +33,28 @@ export function changedPaths(before: Snapshot, after: Snapshot): string[] {
|
|
|
33
33
|
.sort();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Spec 1.5 plan 21: a writer's implicit docs lane, when its brief names no Owns. */
|
|
37
|
+
export const DOCS_OWNS = ["docs/**", "*.md"];
|
|
38
|
+
|
|
39
|
+
/** The paths of an `Owns:` line in a brief (as a lane file writes it), or null when it has none. */
|
|
40
|
+
export function briefOwns(brief: string): string[] | null {
|
|
41
|
+
const line = /^Owns:\s*(.+)$/m.exec(brief)?.[1];
|
|
42
|
+
const paths = line
|
|
43
|
+
?.split(",")
|
|
44
|
+
.map((p) => p.trim().replace(/^`|`$/g, ""))
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
return paths?.length ? paths : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `path` is inside an Owns entry: a path covers what is under it, and the other way round (as lanes'
|
|
51
|
+
* Owns always did); `dir/**` covers what is under dir; `*.ext` (no slash) is any file with that extension.
|
|
52
|
+
*/
|
|
53
|
+
export function ownsPath(path: string, entry: string): boolean {
|
|
54
|
+
if (entry.startsWith("*.") && !entry.includes("/")) return path.endsWith(entry.slice(1));
|
|
55
|
+
return overlaps([path], [entry.replace(/\/\*\*$/, "")]).length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/**
|
|
37
59
|
* Spec §4.4: `changedOwned` are the changes inside the lane's Owns; `violations` the ones outside it
|
|
38
60
|
* that no overlapping dispatch owns either. `strict` is false for a laneless dispatch that may write
|
|
@@ -44,7 +66,7 @@ export function splitChanges(
|
|
|
44
66
|
others: string[],
|
|
45
67
|
strict: boolean,
|
|
46
68
|
): { changedOwned: string[]; violations: string[] } {
|
|
47
|
-
const inside = (p: string, paths: string[]) =>
|
|
69
|
+
const inside = (p: string, paths: string[]) => paths.some((entry) => ownsPath(p, entry));
|
|
48
70
|
return {
|
|
49
71
|
changedOwned: changed.filter((p) => inside(p, owns)),
|
|
50
72
|
violations: strict ? changed.filter((p) => !inside(p, owns) && !inside(p, others)) : [],
|
package/src/domain/cost.ts
CHANGED
|
@@ -51,19 +51,24 @@ export const CHATGPT_UNIT_USD = 0.019;
|
|
|
51
51
|
export interface Cost {
|
|
52
52
|
/** 0: paid from a subscription; 1: metered. Every tier-0 rung ranks before any tier-1 rung. */
|
|
53
53
|
tier: 0 | 1;
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* comparable across billing modes, in list-price dollars per task; 0 for an unpriced rung paid from a plan or
|
|
56
|
+
* subscription (it spends nothing beyond the flat fee); null for an unpriced metered one
|
|
57
|
+
*/
|
|
55
58
|
value: number | null;
|
|
56
59
|
mode: BillingMode;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
|
-
* Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other:
|
|
63
|
+
* Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other. Spec 1.5 plan 24:
|
|
64
|
+
* a rung catherd has no price for costs 0 within its tier when a plan or subscription pays for it, so it can
|
|
65
|
+
* start a lane (the profile's ladder order then breaks the tie); a metered one stays unknown, last in its tier.
|
|
61
66
|
* chatgpt-plan scales the official quota weight (Luna 1, Sol 20, Astra 60) by the effort factor;
|
|
62
67
|
* claude-plan uses API prices as the proxy, with Fable metered (catherd cannot tell Pro from Max);
|
|
63
68
|
* Go's share of its monthly dollar limit is dollars / limit, which orders the same as dollars.
|
|
64
69
|
*/
|
|
65
70
|
export function costOf(family: Family | null, effort: string, mode: BillingMode): Cost {
|
|
66
|
-
if (!family) return
|
|
71
|
+
if (!family) return mode === "metered" ? { tier: 1, value: null, mode } : { tier: 0, value: 0, mode };
|
|
67
72
|
const usd = taskUsd(family.price, effort);
|
|
68
73
|
switch (mode) {
|
|
69
74
|
case "chatgpt-plan":
|