catherd-cli 1.4.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 +93 -30
- 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 +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- 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/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 +5 -0
- 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/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -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 +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -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 +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- 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/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- 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/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 +107 -16
- package/src/services/admission.ts +110 -23
- 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 +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- 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 +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- 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 +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- 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 +49 -9
- package/src/services/treat-likes.ts +19 -3
- 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
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
import { type Difficulty, type Kind, parseLaneHeader } from "../domain/lane.ts";
|
|
14
14
|
import type { Role } from "../domain/roles.ts";
|
|
15
15
|
import type { RouteJev } from "../domain/route.ts";
|
|
16
|
+
import type { Catalog } from "../domain/catalog.ts";
|
|
16
17
|
import {
|
|
17
18
|
candidates,
|
|
18
19
|
defaultDifficulty,
|
|
19
20
|
defaultLadder,
|
|
20
21
|
type Pick,
|
|
22
|
+
quotaUsage,
|
|
21
23
|
type RoutingProfile,
|
|
22
24
|
select,
|
|
23
25
|
} from "../domain/select.ts";
|
|
@@ -26,11 +28,17 @@ import { catalogQuery, freshenDiscovery, loadCatalog } from "./catalog-service.t
|
|
|
26
28
|
import { type Asked, askJev, type JevOpts, jevQuestions, logJev } from "./jev-service.ts";
|
|
27
29
|
import type { ProfileView, RouteAnswer, RouteRequest, RoutingPort, Verdict } from "./ports.ts";
|
|
28
30
|
import { provenanceOf } from "./provenance.ts";
|
|
29
|
-
import { runEvidence } from "./run-evidence.ts";
|
|
31
|
+
import { type EvidenceTable, runEvidence } from "./run-evidence.ts";
|
|
30
32
|
|
|
31
|
-
function routingProfile(
|
|
33
|
+
function routingProfile(
|
|
34
|
+
v: ProfileView,
|
|
35
|
+
role: Role,
|
|
36
|
+
spentFraction: number,
|
|
37
|
+
usage: Record<string, number> = {},
|
|
38
|
+
): RoutingProfile {
|
|
32
39
|
const rc = v.roles[role];
|
|
33
40
|
return {
|
|
41
|
+
usage,
|
|
34
42
|
// spec §4.6: from 80 % of the budget on, start at the cheapest rung that clears the bar
|
|
35
43
|
objective: spentFraction >= BUDGET_CHEAP_AT ? "cost" : v.objective,
|
|
36
44
|
billing: v.billing,
|
|
@@ -50,14 +58,60 @@ const answer = (
|
|
|
50
58
|
asked: Asked | null,
|
|
51
59
|
jev: RouteJev | null,
|
|
52
60
|
): RouteAnswer => ({
|
|
53
|
-
|
|
61
|
+
rung: pick.rung,
|
|
62
|
+
ladder: pick.ladder,
|
|
54
63
|
source,
|
|
55
64
|
kind,
|
|
56
65
|
difficulty,
|
|
57
66
|
questionSet: asked?.answers ? asked.meta.questionSet : null,
|
|
58
67
|
jev,
|
|
68
|
+
jevSaid: null,
|
|
69
|
+
why: "",
|
|
70
|
+
...(pick.noClear ? { noClear: pick.noClear } : {}),
|
|
71
|
+
...(pick.tie ? { tie: pick.tie } : {}),
|
|
59
72
|
});
|
|
60
73
|
|
|
74
|
+
/** Spec 1.5 plan 24: what Jev said when it differs from the kind and difficulty the route used, else null. */
|
|
75
|
+
function jevSaid(
|
|
76
|
+
judged: ReturnType<typeof judgeRoute> | null,
|
|
77
|
+
kind: Kind | null,
|
|
78
|
+
difficulty: Difficulty | null,
|
|
79
|
+
): string | null {
|
|
80
|
+
if (!judged) return null;
|
|
81
|
+
const k = judged.kind;
|
|
82
|
+
const d = judged.track ? judged.difficulty : null;
|
|
83
|
+
if ((k === null || k === kind) && (d === null || d === difficulty)) return null;
|
|
84
|
+
return `Jev said ${k ?? "no sure kind"}/${d ?? "no sure difficulty"}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Spec 1.5 plan 24: the route's one-line why, for the coordinator; the provenance goes to routes.jsonl. */
|
|
88
|
+
function whyOf(a: RouteAnswer, hasLane: boolean): string {
|
|
89
|
+
const what = `${a.kind}/${a.difficulty}`;
|
|
90
|
+
const head =
|
|
91
|
+
a.source === "lane"
|
|
92
|
+
? `the lane's Kind/Difficulty, ${what}`
|
|
93
|
+
: a.source === "jev"
|
|
94
|
+
? `Jev: ${what}`
|
|
95
|
+
: a.source === "jev-kind"
|
|
96
|
+
? `Jev's kind with ${what}`
|
|
97
|
+
: hasLane
|
|
98
|
+
? "the role's default rung: neither Jev nor the lane file gave a kind and difficulty"
|
|
99
|
+
: "the role's default rung";
|
|
100
|
+
const start =
|
|
101
|
+
a.noClear ?? (a.source === "default" ? null : "the first rung in objective order that clears it");
|
|
102
|
+
return [head, a.jevSaid, start, a.tie].filter((x): x is string => Boolean(x)).join("; ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Spec 1.2 §8 evidence is display only: an unreadable run on this machine never fails a route. */
|
|
106
|
+
function evidenceOrNone(c: Catalog): EvidenceTable | null {
|
|
107
|
+
try {
|
|
108
|
+
return runEvidence(c);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
log("debug", "route", { evidence: errorMessage(e) });
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
61
115
|
/** How long `route` waits on the daily discovery refresh before routing on the cached listing. */
|
|
62
116
|
const DISCOVERY_BUDGET_MS = 5_000;
|
|
63
117
|
|
|
@@ -79,53 +133,77 @@ async function freshenWithin(rungs: string[], repo: string, ms: number): Promise
|
|
|
79
133
|
clearTimeout(timer);
|
|
80
134
|
}
|
|
81
135
|
|
|
136
|
+
/** What every route of one call shares: the role's routing profile and the catalog. */
|
|
137
|
+
interface Prepared {
|
|
138
|
+
p: RoutingProfile;
|
|
139
|
+
c: Catalog;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function prepare(req: RouteRequest, o: RoutingOpts): Promise<Prepared> {
|
|
143
|
+
const p = routingProfile(req.profile, req.role, req.spentFraction, req.usage);
|
|
144
|
+
await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
|
|
145
|
+
return { p, c: loadCatalog({ repo: req.repo }) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Jev's answer about a lane; null when the route reads no lane, empty when it asks nothing (Jev off, one usable rung). */
|
|
149
|
+
interface Judged {
|
|
150
|
+
asked: Asked | null;
|
|
151
|
+
judged: ReturnType<typeof judgeRoute> | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function judge(req: RouteRequest, { p, c }: Prepared, o: RoutingOpts): Promise<Judged | null> {
|
|
155
|
+
if (req.laneText === null) return null;
|
|
156
|
+
// one usable rung: nothing for Jev to choose, but the lane's header still names its kind and difficulty
|
|
157
|
+
if (req.profile.jev.use === "off" || candidates(c, p, req.role).length <= 1)
|
|
158
|
+
return { asked: null, judged: null };
|
|
159
|
+
const asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
|
|
160
|
+
const judged = asked.answers ? judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers) : null;
|
|
161
|
+
return { asked, judged };
|
|
162
|
+
}
|
|
163
|
+
|
|
82
164
|
/**
|
|
83
|
-
* Spec §5.4: kind and difficulty from
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* usable rung never asks
|
|
165
|
+
* Spec §5.4: kind and difficulty from the lane file's `Kind:`/`Difficulty:` when it declares both (spec 1.5
|
|
166
|
+
* plan 24: a declared header wins, and `jevSaid` names where Jev disagreed), else from Jev (§5.5's rule), else
|
|
167
|
+
* the role's default rung. A kind Jev is sure of survives a difficulty in the dead band: the difficulty then
|
|
168
|
+
* comes from the lane, else the role's default difficulty (`jev-kind`). A role with one usable rung never asks
|
|
169
|
+
* Jev.
|
|
87
170
|
*/
|
|
88
|
-
|
|
89
|
-
const p = routingProfile(req.profile, req.role, req.spentFraction);
|
|
90
|
-
await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
|
|
91
|
-
const c = loadCatalog({ repo: req.repo });
|
|
171
|
+
function decide(req: RouteRequest, { p, c }: Prepared, j: Judged | null): RouteAnswer {
|
|
92
172
|
const fallback = () => defaultLadder(c, p, req.role);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
173
|
+
const finish = (out: RouteAnswer): RouteAnswer => {
|
|
174
|
+
out.why = whyOf(out, req.laneText !== null);
|
|
175
|
+
out.provenance = provenanceOf(c, out.rung, out.kind, out.difficulty, p.billing, evidenceOrNone(c));
|
|
176
|
+
return out;
|
|
177
|
+
};
|
|
178
|
+
if (req.laneText === null || j === null)
|
|
179
|
+
return finish(answer(fallback(), "default", null, null, null, null));
|
|
97
180
|
const lane = parseLaneHeader(req.laneText);
|
|
98
|
-
|
|
99
|
-
let judged: ReturnType<typeof judgeRoute> | null = null;
|
|
100
|
-
if (req.profile.jev.use !== "off") {
|
|
101
|
-
asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
|
|
102
|
-
if (asked.answers) judged = judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers);
|
|
103
|
-
}
|
|
181
|
+
const { asked, judged } = j;
|
|
104
182
|
const jev: RouteJev | null = judged
|
|
105
183
|
? { pKind: judged.pKind, pA: judged.pA, pB: judged.pB, nouls: judged.nouls }
|
|
106
184
|
: null;
|
|
107
185
|
let out: RouteAnswer;
|
|
108
|
-
if (
|
|
109
|
-
const kind = judged.kind ?? lane.kind ?? "repo_code";
|
|
110
|
-
out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
|
|
111
|
-
} else if (judged?.kind) {
|
|
112
|
-
const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, judged.kind);
|
|
186
|
+
if (lane.kind && lane.difficulty) {
|
|
113
187
|
out = answer(
|
|
114
|
-
select(c, p, req.role,
|
|
115
|
-
"
|
|
116
|
-
|
|
117
|
-
difficulty,
|
|
188
|
+
select(c, p, req.role, lane.kind, lane.difficulty),
|
|
189
|
+
"lane",
|
|
190
|
+
lane.kind,
|
|
191
|
+
lane.difficulty,
|
|
118
192
|
asked,
|
|
119
193
|
jev,
|
|
120
194
|
);
|
|
195
|
+
} else if (judged?.track && judged.difficulty) {
|
|
196
|
+
const kind = lane.kind ?? judged.kind ?? "repo_code";
|
|
197
|
+
out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
|
|
198
|
+
} else if (judged?.kind || (judged && lane.kind)) {
|
|
199
|
+
const kind = (lane.kind ?? judged.kind) as Kind;
|
|
200
|
+
const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, kind);
|
|
201
|
+
out = answer(select(c, p, req.role, kind, difficulty), "jev-kind", kind, difficulty, asked, jev);
|
|
121
202
|
} else {
|
|
122
|
-
|
|
123
|
-
out =
|
|
124
|
-
kind && lane.difficulty
|
|
125
|
-
? answer(select(c, p, req.role, kind, lane.difficulty), "lane", kind, lane.difficulty, asked, jev)
|
|
126
|
-
: answer(fallback(), "default", null, null, asked, jev);
|
|
203
|
+
out = answer(fallback(), "default", null, null, asked, jev);
|
|
127
204
|
}
|
|
128
|
-
out.
|
|
205
|
+
out.jevSaid = out.source === "default" ? null : jevSaid(judged, out.kind, out.difficulty);
|
|
206
|
+
finish(out);
|
|
129
207
|
if (asked) {
|
|
130
208
|
logJev(req.runDir, {
|
|
131
209
|
...asked.meta,
|
|
@@ -143,6 +221,31 @@ async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
|
|
|
143
221
|
return out;
|
|
144
222
|
}
|
|
145
223
|
|
|
224
|
+
async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
|
|
225
|
+
const prep = await prepare(req, o);
|
|
226
|
+
return decide(req, prep, await judge(req, prep, o));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Spec 1.5 plan 24, batch `route`: the lanes of one role (one run, one profile) share one discovery refresh and
|
|
231
|
+
* one catalog, and Jev is asked about all of them at once (Ruling 6: one request per lane, all in flight
|
|
232
|
+
* together, so each keeps its own cache key and jev.jsonl row). Then each lane is decided in order, every
|
|
233
|
+
* routed start counting as a use of its quota, so lanes that tie spread over the quotas.
|
|
234
|
+
*/
|
|
235
|
+
async function routeMany(reqs: RouteRequest[], o: RoutingOpts): Promise<RouteAnswer[]> {
|
|
236
|
+
const first = reqs[0];
|
|
237
|
+
if (!first) return [];
|
|
238
|
+
const prep = await prepare(first, o);
|
|
239
|
+
const judged = await Promise.all(reqs.map((r) => judge(r, prep, o)));
|
|
240
|
+
const usage: Record<string, number> = { ...first.usage };
|
|
241
|
+
return reqs.map((req, i) => {
|
|
242
|
+
const out = decide(req, { ...prep, p: { ...prep.p, usage: { ...usage } } }, judged[i] ?? null);
|
|
243
|
+
const q = quotaUsage([out.rung]);
|
|
244
|
+
for (const [k, n] of Object.entries(q)) usage[k] = (usage[k] ?? 0) + n;
|
|
245
|
+
return out;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
146
249
|
async function verdict<T extends string>(
|
|
147
250
|
runDir: string,
|
|
148
251
|
set: Extract<SetName, "finding" | "same-defect">,
|
|
@@ -177,6 +280,11 @@ export function routingService(o: RoutingOpts = {}): RoutingPort {
|
|
|
177
280
|
assertNativeHost(result.rung, req.host ?? "unknown");
|
|
178
281
|
return result;
|
|
179
282
|
},
|
|
283
|
+
routeMany: async (reqs) => {
|
|
284
|
+
const results = await routeMany(reqs, o);
|
|
285
|
+
results.forEach((r, i) => assertNativeHost(r.rung, reqs[i]?.host ?? "unknown"));
|
|
286
|
+
return results;
|
|
287
|
+
},
|
|
180
288
|
finding: (runDir, laneText, finding, use) =>
|
|
181
289
|
verdict(
|
|
182
290
|
runDir,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
5
|
+
import { ACCESS } from "../domain/record.ts";
|
|
6
|
+
import { ROLES } from "../domain/roles.ts";
|
|
7
|
+
import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
8
|
+
import type { Deps, ProfileView } from "./ports.ts";
|
|
9
|
+
import { findRun, type Run } from "./run-store.ts";
|
|
10
|
+
import { refreshState } from "./state.ts";
|
|
11
|
+
|
|
12
|
+
// Spec 1.5 "Pinned per run": run_start records the profile the run started on, each role's access and each
|
|
13
|
+
// backend's isolation. A later change (another active profile, a toggled isolation, a version that changed a
|
|
14
|
+
// default) is logged in state.md and status, and dispatch keeps the pinned values until the owner re-pins.
|
|
15
|
+
|
|
16
|
+
const PinSchema = z.looseObject({
|
|
17
|
+
schema: z.literal(1),
|
|
18
|
+
at: z.string(),
|
|
19
|
+
profile: z.string(),
|
|
20
|
+
access: z.partialRecord(z.enum(ROLES), z.enum(ACCESS)),
|
|
21
|
+
isolated: z.record(z.string(), z.boolean()),
|
|
22
|
+
});
|
|
23
|
+
export type Pin = z.infer<typeof PinSchema>;
|
|
24
|
+
|
|
25
|
+
export const pinFile = (run: Run): string => join(run.dir, "pin.json");
|
|
26
|
+
|
|
27
|
+
/** The run's pin, or null: a run from before 1.5, or a pin that cannot be read (the profile then rules). */
|
|
28
|
+
export function readPin(run: Run): Pin | null {
|
|
29
|
+
if (!existsSync(pinFile(run))) return null;
|
|
30
|
+
try {
|
|
31
|
+
return readVersioned(pinFile(run), PinSchema, 1);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pinOf = (view: ProfileView, at: string): Pin => ({
|
|
38
|
+
schema: 1,
|
|
39
|
+
at,
|
|
40
|
+
profile: view.name,
|
|
41
|
+
access: Object.fromEntries(
|
|
42
|
+
Object.entries(view.roles).flatMap(([role, rc]) => (rc ? [[role, rc.access]] : [])),
|
|
43
|
+
),
|
|
44
|
+
isolated: Object.fromEntries(Object.entries(view.isolated).map(([k, v]) => [k, v === true])),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Records what the run runs on now: the profile its repo runs on, each role's access, each backend's isolation. */
|
|
48
|
+
export function writePin(deps: Deps, run: Run): Pin {
|
|
49
|
+
const pin = pinOf(deps.profiles.forRepo(run.meta.repo), new Date(deps.now()).toISOString());
|
|
50
|
+
writeJsonAtomic(pinFile(run), pin);
|
|
51
|
+
return pin;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The pinned profile's view, or null with why when it can no longer be read (deleted, invalid). */
|
|
55
|
+
function pinnedView(deps: Deps, run: Run, pin: Pin): { view: ProfileView | null; why: string | null } {
|
|
56
|
+
const current = deps.profiles.forRepo(run.meta.repo);
|
|
57
|
+
if (current.name === pin.profile) return { view: current, why: null };
|
|
58
|
+
try {
|
|
59
|
+
return { view: deps.profiles.get(pin.profile, run.meta.repo).profile, why: null };
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { view: null, why: errorMessage(e) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The profile a run's dispatch, routing and failover read: its pinned profile, with its pinned access and
|
|
67
|
+
* isolation laid over it. A run with no pin, or whose pinned profile is gone, reads its repo's profile.
|
|
68
|
+
*/
|
|
69
|
+
export function runProfile(deps: Deps, run: Run): ProfileView {
|
|
70
|
+
const pin = readPin(run);
|
|
71
|
+
if (!pin) return deps.profiles.forRepo(run.meta.repo);
|
|
72
|
+
const view = pinnedView(deps, run, pin).view ?? deps.profiles.forRepo(run.meta.repo);
|
|
73
|
+
const roles: ProfileView["roles"] = {};
|
|
74
|
+
for (const [role, rc] of Object.entries(view.roles) as [
|
|
75
|
+
keyof ProfileView["roles"],
|
|
76
|
+
NonNullable<ProfileView["roles"][keyof ProfileView["roles"]]>,
|
|
77
|
+
][])
|
|
78
|
+
roles[role] = { ...rc, access: pin.access[role] ?? rc.access };
|
|
79
|
+
// a backend the pin does not name ran with no isolation when the run started
|
|
80
|
+
const backends = new Set([...Object.keys(view.isolated), ...Object.keys(pin.isolated)]);
|
|
81
|
+
const isolated = Object.fromEntries([...backends].map((k) => [k, pin.isolated[k] === true]));
|
|
82
|
+
return { ...view, roles, isolated };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What changed since the pin, one line each, in words; none while the run's repo still runs as pinned. */
|
|
86
|
+
export function pinChanges(deps: Deps, run: Run): string[] {
|
|
87
|
+
const pin = readPin(run);
|
|
88
|
+
if (!pin) return [];
|
|
89
|
+
let now: ProfileView;
|
|
90
|
+
try {
|
|
91
|
+
now = deps.profiles.forRepo(run.meta.repo);
|
|
92
|
+
} catch {
|
|
93
|
+
// a profile that cannot be read is doctor's and profile validate's to report, not a pin change
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
if (now.name !== pin.profile) {
|
|
98
|
+
const { why } = pinnedView(deps, run, pin);
|
|
99
|
+
out.push(
|
|
100
|
+
`profile: pinned ${pin.profile}, the repo now runs on ${now.name}${why ? ` (${pin.profile} cannot be read: ${why}; dispatch reads ${now.name})` : ""}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
for (const [role, access] of Object.entries(pin.access)) {
|
|
104
|
+
const was = now.roles[role as keyof ProfileView["roles"]]?.access;
|
|
105
|
+
if (was !== undefined && was !== access) out.push(`${role} access: pinned ${access}, now ${was}`);
|
|
106
|
+
}
|
|
107
|
+
for (const backend of new Set([...Object.keys(pin.isolated), ...Object.keys(now.isolated)])) {
|
|
108
|
+
const isolated = pin.isolated[backend] === true;
|
|
109
|
+
const was = now.isolated[backend] === true;
|
|
110
|
+
if (was !== isolated) out.push(`${backend} isolated: pinned ${isolated}, now ${was}`);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `run_pin`: the owner re-pins the run to what its repo runs on now. Returns the new pin and what it changed. */
|
|
116
|
+
export async function repin(
|
|
117
|
+
deps: Deps,
|
|
118
|
+
i: { run: string },
|
|
119
|
+
): Promise<{ pin: Pin; changed: string[]; hints?: string[] }> {
|
|
120
|
+
const run = findRun(i.run);
|
|
121
|
+
const changed = pinChanges(deps, run);
|
|
122
|
+
const pin = writePin(deps, run);
|
|
123
|
+
const { hints } = await refreshState(run, { pinChanges: [] });
|
|
124
|
+
return { pin, changed, ...(hints.length ? { hints } : {}) };
|
|
125
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { relative } from "node:path";
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
3
3
|
import { CatherdError } from "../domain/errors.ts";
|
|
4
4
|
import { assertId, parseRung } from "../domain/ids.ts";
|
|
5
|
+
import { assertLaneValues } from "../domain/lane.ts";
|
|
5
6
|
import type { RunRecord } from "../domain/record.ts";
|
|
6
7
|
import type { Role } from "../domain/roles.ts";
|
|
7
8
|
import { awaitsCollect, dispatchPaths, endCollect, tryCollect } from "../infra/dispatch-dir.ts";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
10
|
+
import { git, gitToplevel } from "../infra/git.ts";
|
|
11
|
+
import { writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
10
12
|
import {
|
|
11
13
|
dispatchState,
|
|
12
14
|
type DispatchState,
|
|
13
15
|
latestDispatch,
|
|
14
16
|
listDispatches,
|
|
17
|
+
liveDispatches,
|
|
15
18
|
recordHints,
|
|
16
19
|
} from "./dispatches.ts";
|
|
17
20
|
import type { Deps } from "./ports.ts";
|
|
@@ -22,17 +25,91 @@ import {
|
|
|
22
25
|
appendKnowledge,
|
|
23
26
|
createRun,
|
|
24
27
|
findRun,
|
|
25
|
-
|
|
28
|
+
knowledgeFor,
|
|
26
29
|
readRecords,
|
|
27
30
|
runFile,
|
|
31
|
+
type Run,
|
|
32
|
+
runPaths,
|
|
33
|
+
supersededBy,
|
|
34
|
+
supersededFile,
|
|
28
35
|
} from "./run-store.ts";
|
|
29
|
-
import { protocolView } from "./protocol.ts";
|
|
36
|
+
import { protocolNext, protocolView } from "./protocol.ts";
|
|
37
|
+
import { writePin } from "./run-pin.ts";
|
|
30
38
|
import { claimRun, currentSession } from "./sessions.ts";
|
|
31
|
-
import { refreshState } from "./state.ts";
|
|
39
|
+
import { readNotes, refreshState } from "./state.ts";
|
|
40
|
+
|
|
41
|
+
/** The run `id` was superseded by, or undefined: an open run, or one that cannot be found. */
|
|
42
|
+
function nextInChain(id: string): string | undefined {
|
|
43
|
+
try {
|
|
44
|
+
return supersededBy(findRun(id))?.by;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `runs supersede <run> --by <run>`: closes `run` with a pointer to the run that took it over (a planning
|
|
52
|
+
* run handed to the execution run in a worktree). Status hides it; dispatch refuses it. A run with live
|
|
53
|
+
* roles is refused: cancel them, or let them finish, first.
|
|
54
|
+
*/
|
|
55
|
+
export async function supersedeRun(
|
|
56
|
+
deps: Deps,
|
|
57
|
+
i: { run: string; by: string },
|
|
58
|
+
): Promise<{ run: string; by: string; at: string; hints?: string[] }> {
|
|
59
|
+
const run = findRun(i.run);
|
|
60
|
+
const by = findRun(i.by);
|
|
61
|
+
if (run.id === by.id)
|
|
62
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${run.id} cannot supersede itself`, {
|
|
63
|
+
fix: "pass the run that took over as --by",
|
|
64
|
+
});
|
|
65
|
+
// walk the chain the new pointer would join: any run it reaches back to `run` closes a cycle
|
|
66
|
+
const seen = new Set<string>([by.id]);
|
|
67
|
+
for (let hop = supersededBy(by)?.by; hop !== undefined && !seen.has(hop);) {
|
|
68
|
+
if (hop === run.id)
|
|
69
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${by.id} is itself superseded by ${run.id}`, {
|
|
70
|
+
fix: "supersede the older run by the newer one",
|
|
71
|
+
});
|
|
72
|
+
seen.add(hop);
|
|
73
|
+
hop = nextInChain(hop);
|
|
74
|
+
}
|
|
75
|
+
// under the run's admission lock: a dispatch admitted meanwhile is either live here, or sees the pointer
|
|
76
|
+
const at = await withFileLock(runPaths(run.dir).admission, () => {
|
|
77
|
+
assertNoLiveRoles(
|
|
78
|
+
deps,
|
|
79
|
+
run,
|
|
80
|
+
`cancel them (cancel(run, name)) or let them finish, then supersede ${run.id}`,
|
|
81
|
+
);
|
|
82
|
+
return writeSuperseded(deps, run, by.id);
|
|
83
|
+
});
|
|
84
|
+
return { run: run.id, by: by.id, at, ...(await supersededHints(run, by.id)) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Held under `run`'s admission lock: no role of it may be live when it closes. */
|
|
88
|
+
function assertNoLiveRoles(deps: Deps, run: Run, fix: string): void {
|
|
89
|
+
const live = liveDispatches(run, deps.now());
|
|
90
|
+
if (live.length)
|
|
91
|
+
throw new CatherdError(
|
|
92
|
+
"E_INPUT_INVALID",
|
|
93
|
+
`run ${run.id} still has live roles: ${live.map((d) => d.admit.name).join(", ")}`,
|
|
94
|
+
{ fix },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Held under `run`'s admission lock: the pointer to the run that took it over; its time. */
|
|
99
|
+
function writeSuperseded(deps: Deps, run: Run, by: string): string {
|
|
100
|
+
const when = new Date(deps.now()).toISOString();
|
|
101
|
+
writeJsonAtomic(supersededFile(run), { schema: 1, by, at: when });
|
|
102
|
+
return when;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function supersededHints(run: Run, by: string): Promise<{ hints?: string[] }> {
|
|
106
|
+
const { hints } = await refreshState(run, { next: `superseded by ${by}: continue there` });
|
|
107
|
+
return hints.length ? { hints } : {};
|
|
108
|
+
}
|
|
32
109
|
|
|
33
110
|
export async function startRun(
|
|
34
111
|
deps: Deps,
|
|
35
|
-
i: { repo: string; title: string; aLines: string[] },
|
|
112
|
+
i: { repo: string; title: string; aLines: string[]; from?: string },
|
|
36
113
|
): Promise<{
|
|
37
114
|
run: string;
|
|
38
115
|
dir: string;
|
|
@@ -44,17 +121,39 @@ export async function startRun(
|
|
|
44
121
|
throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
|
|
45
122
|
fix: "pass the path of the repository to work in",
|
|
46
123
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
124
|
+
// where the first milestone's commit range starts; a repo with no commit yet has none
|
|
125
|
+
const head = await git(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
126
|
+
const open = async (): Promise<Run> => {
|
|
127
|
+
const run = createRun({
|
|
128
|
+
repo: top,
|
|
129
|
+
title: i.title,
|
|
130
|
+
aLines: i.aLines,
|
|
131
|
+
version: deps.version,
|
|
132
|
+
startHead: head.kind === "ok" ? head.out.trim() || null : null,
|
|
133
|
+
now: new Date(deps.now()),
|
|
134
|
+
startedBy: currentSession(deps),
|
|
135
|
+
});
|
|
136
|
+
// spec 1.5 "Pinned per run": what the run starts on stays what it dispatches on
|
|
137
|
+
writePin(deps, run);
|
|
138
|
+
await claimRun(deps, run);
|
|
139
|
+
return run;
|
|
140
|
+
};
|
|
141
|
+
// the run this one takes over must be free to close before anything is created, and stay so until its pointer
|
|
142
|
+
// is written: its admission lock is held from the check to the pointer, so no dispatch gets in between and
|
|
143
|
+
// leaves the new run open beside it (PR #50). It is the only lock taken first: the new run's are uncontended.
|
|
144
|
+
const from = i.from === undefined ? null : findRun(i.from);
|
|
145
|
+
const run = from
|
|
146
|
+
? await withFileLock(runPaths(from.dir).admission, async () => {
|
|
147
|
+
assertNoLiveRoles(deps, from, `cancel them or let them finish, then run_start with from: ${from.id}`);
|
|
148
|
+
const opened = await open();
|
|
149
|
+
writeSuperseded(deps, from, opened.id);
|
|
150
|
+
return opened;
|
|
151
|
+
})
|
|
152
|
+
: await open();
|
|
153
|
+
const superseded = from ? await supersededHints(from, run.id) : {};
|
|
56
154
|
// a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
|
|
57
155
|
const { hints } = await refreshState(run);
|
|
156
|
+
hints.push(...(superseded.hints ?? []));
|
|
58
157
|
// spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
|
|
59
158
|
return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
|
|
60
159
|
}
|
|
@@ -63,7 +162,11 @@ export function writeRunFile(i: { run: string; path: string; content: string }):
|
|
|
63
162
|
path: string;
|
|
64
163
|
bytes: number;
|
|
65
164
|
} {
|
|
66
|
-
const
|
|
165
|
+
const run = findRun(i.run);
|
|
166
|
+
const file = runFile(run, i.path, "write");
|
|
167
|
+
// spec 1.5 "Lane editing": a lane's header values are checked when it is written, not first at preflight
|
|
168
|
+
const lane = /^lanes\/([^/]+)\.md$/.exec(relative(run.dir, file).split(sep).join("/"));
|
|
169
|
+
if (lane) assertLaneValues(i.content, `lanes/${lane[1]}.md`);
|
|
67
170
|
writeTextAtomic(file, i.content);
|
|
68
171
|
return { path: file, bytes: Buffer.byteLength(i.content) };
|
|
69
172
|
}
|
|
@@ -99,6 +202,28 @@ function capReply(reply: string, path: string): string {
|
|
|
99
202
|
return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
|
|
100
203
|
}
|
|
101
204
|
|
|
205
|
+
/** Whether `text` names `token` (a role name or lane id) as a whole word, a full stop after it allowed. */
|
|
206
|
+
export function names(text: string, token: string): boolean {
|
|
207
|
+
const t = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
208
|
+
return new RegExp(`(?<![\\w.-])${t}(?![\\w-]|\\.\\w)`).test(text);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Plan 22: state.md's Next never outlives the step it names. When it names a role whose record `result` returns
|
|
213
|
+
* (by its name or its lane), it moves on to the protocol's next step. A refresh that fails comes back as hints.
|
|
214
|
+
*/
|
|
215
|
+
async function advanceNext(run: Run, record: RunRecord): Promise<string[]> {
|
|
216
|
+
const stale = (next: string) =>
|
|
217
|
+
names(next, record.name) || (record.lane !== null && names(next, record.lane));
|
|
218
|
+
if (!stale(readNotes(run).next)) return [];
|
|
219
|
+
const { hints } = await refreshState(run, (n) =>
|
|
220
|
+
stale(n.next)
|
|
221
|
+
? { next: `after ${record.name} (${record.status}): ${protocolNext(run, n.parked ?? [])}` }
|
|
222
|
+
: {},
|
|
223
|
+
);
|
|
224
|
+
return hints;
|
|
225
|
+
}
|
|
226
|
+
|
|
102
227
|
/**
|
|
103
228
|
* A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
|
|
104
229
|
* finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
|
|
@@ -134,6 +259,7 @@ export async function result(
|
|
|
134
259
|
if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
|
|
135
260
|
}
|
|
136
261
|
if (record) for (const h of recordHints(run, d, record)) if (!hints.includes(h)) hints.push(h);
|
|
262
|
+
if (record) for (const h of await advanceNext(run, record)) if (!hints.includes(h)) hints.push(h);
|
|
137
263
|
const reply = dispatchPaths(d.dir).reply;
|
|
138
264
|
const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
|
|
139
265
|
return {
|
|
@@ -159,6 +285,10 @@ export function recordAgentRun(
|
|
|
159
285
|
durationMs?: number;
|
|
160
286
|
status?: AgentRun["status"];
|
|
161
287
|
lane?: string;
|
|
288
|
+
/** plan 23: a native verifier's first reply line, so a VERDICT: BLOCKED: environment is told from a FAIL */
|
|
289
|
+
verdict?: string;
|
|
290
|
+
/** plan 23: the STATUS word of the subagent's reply */
|
|
291
|
+
replyStatus?: NonNullable<AgentRun["replyStatus"]>;
|
|
162
292
|
},
|
|
163
293
|
): AgentRun {
|
|
164
294
|
const run = findRun(i.run);
|
|
@@ -180,6 +310,8 @@ export function recordAgentRun(
|
|
|
180
310
|
secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
|
|
181
311
|
status: i.status ?? "ok",
|
|
182
312
|
lane: i.lane ?? null,
|
|
313
|
+
...(i.verdict ? { verdict: i.verdict.trim().split("\n")[0] } : {}),
|
|
314
|
+
...(i.replyStatus ? { replyStatus: i.replyStatus } : {}),
|
|
183
315
|
};
|
|
184
316
|
appendAgentRun(run, row);
|
|
185
317
|
return row;
|
|
@@ -197,7 +329,7 @@ async function repoTop(repo: string): Promise<string> {
|
|
|
197
329
|
|
|
198
330
|
/** What past runs of the repo learned; `repo` may be any path inside it. */
|
|
199
331
|
export async function readKnowledge(repo: string): Promise<string> {
|
|
200
|
-
const file =
|
|
332
|
+
const file = await knowledgeFor(await repoTop(repo));
|
|
201
333
|
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
202
334
|
return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
|
|
203
335
|
}
|
|
@@ -205,7 +337,7 @@ export async function readKnowledge(repo: string): Promise<string> {
|
|
|
205
337
|
/** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
|
|
206
338
|
export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
|
|
207
339
|
const top = await repoTop(repo);
|
|
208
|
-
return { repo: top, path:
|
|
340
|
+
return { repo: top, path: await knowledgeFor(top) };
|
|
209
341
|
}
|
|
210
342
|
|
|
211
343
|
/** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */
|