catherd-cli 0.2.1 → 1.1.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 +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
5
|
+
import "../adapters/all.ts";
|
|
6
|
+
import { CatherdError, UPGRADE } from "../domain/errors.ts";
|
|
7
|
+
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
8
|
+
import {
|
|
9
|
+
assertProfileName,
|
|
10
|
+
defaultProfileDoc,
|
|
11
|
+
PROFILE_NAME,
|
|
12
|
+
type Profile,
|
|
13
|
+
type ProfileDoc,
|
|
14
|
+
ProfileDocSchema,
|
|
15
|
+
resolveProfile,
|
|
16
|
+
} from "../domain/profile.ts";
|
|
17
|
+
import { type Validation, validateProfile } from "../domain/profile-rules.ts";
|
|
18
|
+
import type { Access } from "../domain/record.ts";
|
|
19
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
20
|
+
import { configDir } from "../infra/paths.ts";
|
|
21
|
+
import { readJsonFile } from "../infra/store.ts";
|
|
22
|
+
import { backendOfKey, loadCatalog } from "./catalog-service.ts";
|
|
23
|
+
|
|
24
|
+
// Spec §7.3, the read side of the profile service: where profiles, config.json and projects.json live,
|
|
25
|
+
// and reading them. Every write goes through profile-service.ts.
|
|
26
|
+
|
|
27
|
+
export const profilesDir = (): string => join(configDir(), "profiles");
|
|
28
|
+
export const configFile = (): string => join(configDir(), "config.json");
|
|
29
|
+
export const projectsFile = (): string => join(configDir(), "projects.json");
|
|
30
|
+
/** `<config>/agents/<profile>/<agent>.md`: catherd's own agent files, which the links point at. */
|
|
31
|
+
export const agentsRoot = (): string => join(configDir(), "agents");
|
|
32
|
+
export const profileFile = (name: string): string => join(profilesDir(), `${name}.json`);
|
|
33
|
+
|
|
34
|
+
const ConfigSchema = z.looseObject({ schema: z.literal(1), activeProfile: z.string().optional() });
|
|
35
|
+
const ProjectsSchema = z.looseObject({
|
|
36
|
+
schema: z.literal(1),
|
|
37
|
+
bindings: z.record(z.string(), z.string()).default({}),
|
|
38
|
+
});
|
|
39
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
40
|
+
export type Projects = z.infer<typeof ProjectsSchema>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads a schema-1 file. A file without `schema` was written by catherd 0.x, which 1.0 does not read
|
|
44
|
+
* (spec D2): `catherd init` moves those aside.
|
|
45
|
+
*/
|
|
46
|
+
function readV1<T>(file: string, schema: z.ZodType<T>): T {
|
|
47
|
+
const raw = readJsonFile(file);
|
|
48
|
+
const found = (raw as { schema?: unknown } | null)?.schema;
|
|
49
|
+
if (found === undefined)
|
|
50
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} is from catherd 0.x`, {
|
|
51
|
+
fix: "run catherd init, which moves 0.x files aside and writes 1.0 ones",
|
|
52
|
+
});
|
|
53
|
+
if (typeof found === "number" && found > 1)
|
|
54
|
+
throw new CatherdError(
|
|
55
|
+
"E_CONFIG_NEWER_SCHEMA",
|
|
56
|
+
`${file} has schema ${found}, newer than this catherd (1)`,
|
|
57
|
+
{
|
|
58
|
+
fix: `upgrade catherd: ${UPGRADE}`,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
const r = schema.safeParse(raw);
|
|
62
|
+
if (!r.success)
|
|
63
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} is invalid:\n${z.prettifyError(r.error)}`, {
|
|
64
|
+
fix: `fix it with catherd profile set, or delete ${file}`,
|
|
65
|
+
});
|
|
66
|
+
return r.data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const readConfig = (): Config =>
|
|
70
|
+
existsSync(configFile()) ? readV1(configFile(), ConfigSchema) : { schema: 1 };
|
|
71
|
+
export const readProjects = (): Projects =>
|
|
72
|
+
existsSync(projectsFile()) ? readV1(projectsFile(), ProjectsSchema) : { schema: 1, bindings: {} };
|
|
73
|
+
|
|
74
|
+
/** Every profile name: the files in `profiles/`, and `default`, which exists even before its file does. */
|
|
75
|
+
export function listProfiles(): string[] {
|
|
76
|
+
const onDisk = existsSync(profilesDir())
|
|
77
|
+
? readdirSync(profilesDir())
|
|
78
|
+
.filter((f) => f.endsWith(".json"))
|
|
79
|
+
.map((f) => f.slice(0, -".json".length))
|
|
80
|
+
.filter((n) => PROFILE_NAME.test(n))
|
|
81
|
+
: [];
|
|
82
|
+
return [...new Set(["default", ...onDisk])].sort();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const profileExists = (name: string): boolean => listProfiles().includes(name);
|
|
86
|
+
|
|
87
|
+
/** The stored document; `default` without a file is the built-in default profile. */
|
|
88
|
+
export function readProfileDoc(name: string): ProfileDoc {
|
|
89
|
+
assertProfileName(name);
|
|
90
|
+
if (existsSync(profileFile(name))) return readV1(profileFile(name), ProfileDocSchema);
|
|
91
|
+
if (name === "default") return defaultProfileDoc();
|
|
92
|
+
throw new CatherdError("E_CONFIG_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const getProfile = (name: string): Profile => resolveProfile(readProfileDoc(name), name);
|
|
96
|
+
|
|
97
|
+
/** A profile name the user typed: refused as bad input (exit 2) when no such profile exists. */
|
|
98
|
+
export function requireProfile(name: string): string {
|
|
99
|
+
if (!profileExists(assertProfileName(name)))
|
|
100
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The profile bound to `repo` (a git toplevel), else the active one, else `default`. */
|
|
105
|
+
export function activeName(repo: string | null = null): string {
|
|
106
|
+
const bound = repo === null ? undefined : readProjects().bindings[repo];
|
|
107
|
+
return bound ?? readConfig().activeProfile ?? "default";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const profileFor = (repo: string | null): Profile => getProfile(activeName(repo));
|
|
111
|
+
|
|
112
|
+
/** The backends catherd can run: every registered adapter, and the native `claude` path. */
|
|
113
|
+
export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter((id) => adapterFor(id))];
|
|
114
|
+
|
|
115
|
+
/** Whether a profile's billing or harness key belongs to one of `backends` (default: runnableBackends()). */
|
|
116
|
+
export const keyRunnable = (key: string, backends: string[] = runnableBackends()): boolean =>
|
|
117
|
+
backends.includes(backendOfKey(key));
|
|
118
|
+
|
|
119
|
+
/** `repo`: the git toplevel whose listing route reads (opencode lists its models per repository). */
|
|
120
|
+
export function validateNamed(name?: string, repo: string | null = null): Validation {
|
|
121
|
+
const n = name ?? activeName(repo);
|
|
122
|
+
const doc = readProfileDoc(n);
|
|
123
|
+
const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
|
|
124
|
+
return validateProfile(resolveProfile(doc, n), catalog, runnableBackends(), doc);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Spec D10: how strongly the backend holds a role to its access mode. */
|
|
128
|
+
export function enforcementOf(rung: string, access: Access): "enforced" | "advisory" {
|
|
129
|
+
const r = tryParseRung(rung);
|
|
130
|
+
return (r && adapterFor(r.backend)?.enforcement[access]) ?? "advisory";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Each role's weakest enforcement over its rungs, for `profile show` and profile_get. */
|
|
134
|
+
export function roleEnforcement(p: Profile): Record<Role, "enforced" | "advisory"> {
|
|
135
|
+
const out = {} as Record<Role, "enforced" | "advisory">;
|
|
136
|
+
for (const role of ROLES) {
|
|
137
|
+
const rc = p.roles[role];
|
|
138
|
+
out[role] = rc.rungs.every((r) => enforcementOf(r, rc.access) === "enforced") ? "enforced" : "advisory";
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
4
|
+
import type { RouteRow } from "../domain/route.ts";
|
|
5
|
+
import { ensurePrivateDir, readJsonl, writeTextAtomic } from "../infra/store.ts";
|
|
6
|
+
import { type Dispatch, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
7
|
+
import type { VerifierStep } from "./gate-service.ts";
|
|
8
|
+
import {
|
|
9
|
+
landedMilestones,
|
|
10
|
+
milestoneReviewer,
|
|
11
|
+
milestoneVerifier,
|
|
12
|
+
milestoneStart,
|
|
13
|
+
namesMilestone,
|
|
14
|
+
reviewerPassed,
|
|
15
|
+
verifierPassed,
|
|
16
|
+
} from "./milestones.ts";
|
|
17
|
+
import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
|
|
18
|
+
|
|
19
|
+
// Spec 1.1 §10: the protocol's next step, derived from the run's own files so a session that lost its
|
|
20
|
+
// context (a compaction, a restart) re-enters the milestone loop where it stands; and each landed
|
|
21
|
+
// milestone's digest.
|
|
22
|
+
|
|
23
|
+
/** The milestone loop, six lines, as run_start and peek return it. */
|
|
24
|
+
export const PROTOCOL_CHECKLIST = [
|
|
25
|
+
"1. route and preflight the milestone's lanes (dispatch routes a lane not routed yet)",
|
|
26
|
+
"2. dispatch every independent lane one after another, then end your turn: results arrive as catherd messages",
|
|
27
|
+
"3. per result: result(run, name), its STATUS and changedOwned, then its fast check once",
|
|
28
|
+
"4. reviewer-<M> once over the milestone diff, then one fix round",
|
|
29
|
+
"5. the verifier in the foreground, with gate_check and gate_pass, recorded as record_agent_run(name: verifier-<M>)",
|
|
30
|
+
"6. commit, then land(run, <M>, …); an owner question parks the milestone (park) and the run goes on",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const milestoneOf = (lane: string): string => lane.split(".")[0] as string;
|
|
34
|
+
const byNumber = (a: string, b: string) => a.localeCompare(b, "en", { numeric: true });
|
|
35
|
+
|
|
36
|
+
/** The run's lanes, from lanes/*.md, in milestone and lane order. */
|
|
37
|
+
function laneIds(run: Run): string[] {
|
|
38
|
+
const dir = runPaths(run.dir).lanes;
|
|
39
|
+
if (!existsSync(dir)) return [];
|
|
40
|
+
return readdirSync(dir)
|
|
41
|
+
.filter((f) => f.endsWith(".md"))
|
|
42
|
+
.map((f) => f.slice(0, -".md".length))
|
|
43
|
+
.filter((l) => l.includes("."))
|
|
44
|
+
.sort(byNumber);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether `lane` needs no dispatch now: its latest dispatch or native agent run since its latest climb is
|
|
49
|
+
* still running, or ended ok with no blocked or refused reply. A lane whose last try failed, hit a limit,
|
|
50
|
+
* was blocked, or was climbed since goes back to dispatch; a plain re-route leaves an ok try standing.
|
|
51
|
+
*/
|
|
52
|
+
function laneDone(run: Run, lane: string, routes: RouteRow[], live: Dispatch[]): boolean {
|
|
53
|
+
const from = Math.max(
|
|
54
|
+
...routes.filter((r) => r.lane === lane && r.source === "climb").map((r) => Date.parse(r.at)),
|
|
55
|
+
);
|
|
56
|
+
// a dispatch routes its lane first, so a try at the route's own time counts as after it
|
|
57
|
+
const after = (t: string) => !(Date.parse(t) < from);
|
|
58
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
59
|
+
const running = new Set(live.map((d) => d.admit.dispatchId));
|
|
60
|
+
const tries = [
|
|
61
|
+
...listDispatches(run)
|
|
62
|
+
.filter((d) => d.admit.lane === lane && after(d.admit.admittedAt))
|
|
63
|
+
.map((d) => {
|
|
64
|
+
const r = records.get(d.admit.dispatchId);
|
|
65
|
+
const ok = r
|
|
66
|
+
? r.status === "ok" && r.replyStatus !== "blocked" && r.replyStatus !== "refused"
|
|
67
|
+
: running.has(d.admit.dispatchId);
|
|
68
|
+
return { at: Date.parse(d.admit.admittedAt), ok };
|
|
69
|
+
}),
|
|
70
|
+
...readAgentRuns(run)
|
|
71
|
+
.filter((a) => a.lane === lane && after(a.at))
|
|
72
|
+
.map((a) => ({ at: Date.parse(a.at), ok: a.status === "ok" })),
|
|
73
|
+
];
|
|
74
|
+
const latest = tries.reduce<(typeof tries)[number] | null>(
|
|
75
|
+
(best, t) => (best === null || t.at >= best.at ? t : best),
|
|
76
|
+
null,
|
|
77
|
+
);
|
|
78
|
+
return latest?.ok ?? false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The step the milestone loop is at: the first milestone neither landed nor parked, and within it the
|
|
83
|
+
* first of route, dispatch, collect, reviewer, verifier and land that is still to do.
|
|
84
|
+
*/
|
|
85
|
+
export function protocolNext(run: Run, parked: string[], now = Date.now()): string {
|
|
86
|
+
const lanes = laneIds(run);
|
|
87
|
+
if (lanes.length === 0)
|
|
88
|
+
return "plan: the architect writes plan.md and lanes/Mx.Ly.md (or you, for a fix run)";
|
|
89
|
+
const landed = new Set(landedMilestones(run));
|
|
90
|
+
const milestones = [...new Set(lanes.map(milestoneOf))].sort(byNumber);
|
|
91
|
+
const m = milestones.find((x) => !landed.has(x) && !parked.includes(x));
|
|
92
|
+
if (!m)
|
|
93
|
+
return parked.length
|
|
94
|
+
? `${parked.join(", ")} parked: wait for the owner`
|
|
95
|
+
: "finish: the final gate, then the report";
|
|
96
|
+
const mine = lanes.filter((l) => milestoneOf(l) === m);
|
|
97
|
+
const routes = readRoutes(run);
|
|
98
|
+
const routed = new Set(routes.map((r) => r.lane));
|
|
99
|
+
if (mine.some((l) => !routed.has(l))) return `route and preflight ${m}'s lanes`;
|
|
100
|
+
const live = liveDispatches(run, now);
|
|
101
|
+
const notDone = mine.filter((l) => !laneDone(run, l, routes, live));
|
|
102
|
+
// spec 1.1 §9: a climb past the top rung (from === rung) leaves nothing to climb to; that is a decision
|
|
103
|
+
const spent = notDone.filter((l) => {
|
|
104
|
+
const last = routes.findLast((r) => r.lane === l);
|
|
105
|
+
return last?.source === "climb" && last.from === last.rung;
|
|
106
|
+
});
|
|
107
|
+
const waiting = notDone.filter((l) => !spent.includes(l));
|
|
108
|
+
if (waiting.length) return `dispatch ${waiting.join(", ")}`;
|
|
109
|
+
if (spent.length) return `${spent.join(", ")}: out of rungs — ask finding, then the architect or park ${m}`;
|
|
110
|
+
const running = live.filter((d) => d.admit.lane !== null && mine.includes(d.admit.lane));
|
|
111
|
+
if (running.length) return `${m}: lanes running (${running.map((d) => d.admit.name).join(", ")})`;
|
|
112
|
+
const start = milestoneStart(run, m);
|
|
113
|
+
if (!reviewerPassed(run, m, start)) return `${m}: reviewer`;
|
|
114
|
+
if (!verifierPassed(run, m, start)) return `${m}: verifier`;
|
|
115
|
+
return `land ${m}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What run_start and peek return beside the step: the six-line loop. */
|
|
119
|
+
export const protocolView = (run: Run, parked: string[], now?: number) => ({
|
|
120
|
+
next: protocolNext(run, parked, now),
|
|
121
|
+
checklist: PROTOCOL_CHECKLIST,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
|
|
125
|
+
|
|
126
|
+
function findingCounts(run: Run, r: RunRecord | undefined): string {
|
|
127
|
+
if (!r?.replyPath) return "no reply";
|
|
128
|
+
const file = join(run.dir, r.replyPath);
|
|
129
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
130
|
+
const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
|
|
131
|
+
for (const line of text.split("\n")) {
|
|
132
|
+
const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
|
|
133
|
+
if (k) n[k]++;
|
|
134
|
+
}
|
|
135
|
+
const total = n.BLOCKER + n.BUG + n.NIT;
|
|
136
|
+
return `${total} finding(s): ${n.BLOCKER} BLOCKER, ${n.BUG} BUG, ${n.NIT} NIT`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const k = (x: number) => (x >= 1000 ? `${Math.round(x / 1000)}k` : String(x));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Spec 1.1 §10: `R/digests/<m>.md`, written by `land`: the A-lines it names, the commit, the lanes with
|
|
143
|
+
* their rungs and climbs, the reviewer's findings, the verifier's verdict with its carried items, minutes
|
|
144
|
+
* and tokens. Returns its path relative to the run folder.
|
|
145
|
+
*/
|
|
146
|
+
export function writeDigest(
|
|
147
|
+
run: Run,
|
|
148
|
+
i: { milestone: string; what: string; commit: string; evidence: string; minutes: number; at: string },
|
|
149
|
+
): string {
|
|
150
|
+
const m = i.milestone;
|
|
151
|
+
const start = milestoneStart(run, m);
|
|
152
|
+
const inM = (lane: string | null | undefined) => typeof lane === "string" && lane.startsWith(`${m}.`);
|
|
153
|
+
const named = new Set([...`${i.what} ${i.evidence}`.matchAll(/\bA(\d+)\b/g)].map((x) => `A${x[1]}`));
|
|
154
|
+
const aLines = run.meta.aLines.filter((a) => named.has(/^\s*(A\d+)/.exec(a)?.[1] ?? ""));
|
|
155
|
+
const routes = readRoutes(run).filter((r) => inM(r.lane));
|
|
156
|
+
const lanes = [...new Set(routes.map((r) => r.lane))].sort(byNumber).map((lane) => {
|
|
157
|
+
const rows = routes.filter((r) => r.lane === lane);
|
|
158
|
+
const climbs = rows.filter((r) => r.source === "climb" && r.from !== r.rung);
|
|
159
|
+
const first = rows[0]?.rung ?? "?";
|
|
160
|
+
const last = rows.at(-1)?.rung ?? first;
|
|
161
|
+
return `- ${lane} · ${first}${last !== first ? ` → ${last}` : ""}${climbs.length ? ` · climbs: ${climbs.map((c) => c.reason ?? "").join("; ")}` : ""}`;
|
|
162
|
+
});
|
|
163
|
+
const records = readRecords(run).records;
|
|
164
|
+
// the reviewer the gate counted: since the milestone's lanes started, a dispatch or a native subagent
|
|
165
|
+
const reviewer = milestoneReviewer(run, m, start);
|
|
166
|
+
const agents = readAgentRuns(run);
|
|
167
|
+
// the verdict the gate counted: the latest attempt, native or headless, a FAIL shown as such
|
|
168
|
+
const verdict = milestoneVerifier(run, m, start);
|
|
169
|
+
// the milestone's carried items: once any step names its milestone (gate_check's milestone), only the steps
|
|
170
|
+
// naming this one; before that, every step since the milestone's lanes started
|
|
171
|
+
const allSteps = readJsonl<VerifierStep>(join(run.dir, "verifier.jsonl")).rows.filter(
|
|
172
|
+
(s) => typeof s?.item === "string",
|
|
173
|
+
);
|
|
174
|
+
const byMilestone = allSteps.some((s) => typeof s.milestone === "string");
|
|
175
|
+
const steps = allSteps.filter(
|
|
176
|
+
(s) =>
|
|
177
|
+
s.carried &&
|
|
178
|
+
(start === null || Date.parse(s.at) >= Date.parse(start)) &&
|
|
179
|
+
(!byMilestone || s.milestone === m),
|
|
180
|
+
);
|
|
181
|
+
const mineRecords = records.filter((r) => inM(r.lane) || namesMilestone(r.name, m));
|
|
182
|
+
const tokens = mineRecords.reduce(
|
|
183
|
+
(t, r) => ({
|
|
184
|
+
input: t.input + r.tokens.input,
|
|
185
|
+
cached: t.cached + r.tokens.cached,
|
|
186
|
+
output: t.output + r.tokens.output,
|
|
187
|
+
}),
|
|
188
|
+
{ input: 0, cached: 0, output: 0 },
|
|
189
|
+
);
|
|
190
|
+
const reported = agents
|
|
191
|
+
.filter((a) => inM(a.lane) || namesMilestone(a.name, m))
|
|
192
|
+
.reduce((n, a) => n + a.totalTokens, 0);
|
|
193
|
+
const text = [
|
|
194
|
+
`# ${m} — ${i.what}`,
|
|
195
|
+
"",
|
|
196
|
+
`Commit ${i.commit} · ${i.minutes} min · landed ${i.at}`,
|
|
197
|
+
`A-lines: ${aLines.length ? aLines.join("; ") : "none named in what or evidence"}`,
|
|
198
|
+
"",
|
|
199
|
+
"Lanes:",
|
|
200
|
+
...(lanes.length ? lanes : ["- none routed"]),
|
|
201
|
+
"",
|
|
202
|
+
`Reviewer: ${reviewer ? `${reviewer.name} · ${reviewer.record ? findingCounts(run, reviewer.record) : "a Claude subagent (findings in its reply)"}` : "none"}`,
|
|
203
|
+
`Verifier: ${verdict ? `${verdict.passed ? "PASS" : `FAIL: ${verdict.verdict}`} (${verdict.name}${verdict.headless ? ", headless" : ""})` : "none"}${steps.length ? ` · carried: ${steps.map((s) => `${s.item}${s.commit ? ` from ${s.commit}` : ""}`).join(", ")}` : ""}`,
|
|
204
|
+
`Evidence: ${i.evidence}`,
|
|
205
|
+
`Tokens: ${k(tokens.input)} in (${k(tokens.cached)} cached) · ${k(tokens.output)} out · Claude subagents ${k(reported)} (reported)`,
|
|
206
|
+
"",
|
|
207
|
+
].join("\n");
|
|
208
|
+
const dir = join(run.dir, "digests");
|
|
209
|
+
ensurePrivateDir(dir);
|
|
210
|
+
writeTextAtomic(join(dir, `${m}.md`), text);
|
|
211
|
+
return `digests/${m}.md`;
|
|
212
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import { assertId } from "../domain/ids.ts";
|
|
4
|
+
import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
|
|
5
|
+
import type { Deps } from "./ports.ts";
|
|
6
|
+
import { findRun, type Run } from "./run-store.ts";
|
|
7
|
+
import { type Notes, readNotes, refreshState } from "./state.ts";
|
|
8
|
+
|
|
9
|
+
// Spec 1.1 §8: an owner question parks its milestone instead of stopping the run.
|
|
10
|
+
|
|
11
|
+
interface QuestionRow {
|
|
12
|
+
at: string;
|
|
13
|
+
milestone: string;
|
|
14
|
+
kind: "question" | "answer";
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OpenQuestion {
|
|
19
|
+
milestone: string;
|
|
20
|
+
question: string;
|
|
21
|
+
at: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const questionsFile = (run: Run): string => join(run.dir, "questions.jsonl");
|
|
25
|
+
|
|
26
|
+
const rows = (run: Run): QuestionRow[] =>
|
|
27
|
+
readJsonl<QuestionRow>(questionsFile(run)).rows.filter(
|
|
28
|
+
(r) => typeof r?.milestone === "string" && typeof r.text === "string",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** The questions not answered yet, oldest first; a milestone answered after its question is no longer open. */
|
|
32
|
+
export function openQuestions(run: Run): OpenQuestion[] {
|
|
33
|
+
const open = new Map<string, OpenQuestion>();
|
|
34
|
+
for (const r of rows(run)) {
|
|
35
|
+
if (r.kind === "question") open.set(r.milestone, { milestone: r.milestone, question: r.text, at: r.at });
|
|
36
|
+
else open.delete(r.milestone);
|
|
37
|
+
}
|
|
38
|
+
return [...open.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function append(run: Run, row: QuestionRow): void {
|
|
42
|
+
const file = questionsFile(run);
|
|
43
|
+
ensureJsonlHeader(file, "questions");
|
|
44
|
+
appendJsonl(file, row);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `park`: the milestone waits on the owner; the rest of the run goes on. */
|
|
48
|
+
export async function park(
|
|
49
|
+
deps: Deps,
|
|
50
|
+
i: { run: string; milestone: string; question: string },
|
|
51
|
+
): Promise<{ parked: string[]; hints: string[] }> {
|
|
52
|
+
const run = findRun(i.run);
|
|
53
|
+
assertId("milestone", i.milestone);
|
|
54
|
+
append(run, {
|
|
55
|
+
at: new Date(deps.now()).toISOString(),
|
|
56
|
+
milestone: i.milestone,
|
|
57
|
+
kind: "question",
|
|
58
|
+
text: i.question,
|
|
59
|
+
});
|
|
60
|
+
let parked: string[] = [];
|
|
61
|
+
const { hints } = await refreshState(run, (n: Notes) => {
|
|
62
|
+
parked = [...new Set([...(n.parked ?? []), i.milestone])];
|
|
63
|
+
return { parked };
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
parked,
|
|
67
|
+
hints: [
|
|
68
|
+
`push the full question to the owner now (PushNotification): ${i.milestone}: ${i.question}`,
|
|
69
|
+
`continue with the milestones and runs that do not depend on ${i.milestone}; when the owner answers, call answer(run, "${i.milestone}", <their answer>)`,
|
|
70
|
+
...hints,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `answer`: records the owner's answer and unparks the milestone. */
|
|
76
|
+
export async function answer(
|
|
77
|
+
deps: Deps,
|
|
78
|
+
i: { run: string; milestone: string; answer: string },
|
|
79
|
+
): Promise<{ milestone: string; parked: string[]; hints?: string[] }> {
|
|
80
|
+
const run = findRun(i.run);
|
|
81
|
+
assertId("milestone", i.milestone);
|
|
82
|
+
const open = openQuestions(run).some((q) => q.milestone === i.milestone);
|
|
83
|
+
// answered already, but the state save that unparks it failed: this call only unparks
|
|
84
|
+
const stillParked = !open && (readNotes(run).parked ?? []).includes(i.milestone);
|
|
85
|
+
if (!open && !stillParked)
|
|
86
|
+
throw new CatherdError("E_INPUT_INVALID", `${i.milestone} has no open question`, {
|
|
87
|
+
fix: "status(run) lists the open questions; park(run, milestone, question) opens one",
|
|
88
|
+
});
|
|
89
|
+
if (open)
|
|
90
|
+
append(run, {
|
|
91
|
+
at: new Date(deps.now()).toISOString(),
|
|
92
|
+
milestone: i.milestone,
|
|
93
|
+
kind: "answer",
|
|
94
|
+
text: i.answer,
|
|
95
|
+
});
|
|
96
|
+
let parked: string[] = [];
|
|
97
|
+
const { hints } = await refreshState(run, (n: Notes) => {
|
|
98
|
+
parked = (n.parked ?? []).filter((m) => m !== i.milestone);
|
|
99
|
+
return { parked, next: `${i.milestone}: the owner answered; continue it` };
|
|
100
|
+
});
|
|
101
|
+
return { milestone: i.milestone, parked, ...(hints.length ? { hints } : {}) };
|
|
102
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
3
|
+
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
4
|
+
import { log } from "../infra/log.ts";
|
|
5
|
+
import { writeJsonAtomic } from "../infra/store.ts";
|
|
6
|
+
import { settle, stallPoll, unsettledLimits, watching } from "./dispatch-service.ts";
|
|
7
|
+
import { type Dispatch, listDispatches, pendingDispatches } from "./dispatches.ts";
|
|
8
|
+
import { finalizeDispatch, waitForFinish } from "./finalize.ts";
|
|
9
|
+
import type { Deps } from "./ports.ts";
|
|
10
|
+
import { listRuns, type Run } from "./run-store.ts";
|
|
11
|
+
import { ownsRun } from "./sessions.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds before the spec.json fix (plan-2 review I1) wrote every env value of the MCP server into
|
|
15
|
+
* spec.json, readable by others. A spec is read once, when its supervisor starts, so such a file loses
|
|
16
|
+
* its env and becomes 0600. Returns how many it scrubbed.
|
|
17
|
+
*/
|
|
18
|
+
function scrubOldSpecs(run: Run): number {
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (const d of listDispatches(run)) {
|
|
21
|
+
const file = dispatchPaths(d.dir).spec;
|
|
22
|
+
try {
|
|
23
|
+
if ((statSync(file).mode & 0o077) === 0) continue;
|
|
24
|
+
const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
25
|
+
writeJsonAtomic(file, { ...doc, env: {} }, { mode: 0o600 });
|
|
26
|
+
n++;
|
|
27
|
+
} catch {
|
|
28
|
+
// no spec.json, or one that cannot be read: nothing to scrub
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReconcileReport {
|
|
35
|
+
finalized: string[];
|
|
36
|
+
watching: string[];
|
|
37
|
+
warnings: string[];
|
|
38
|
+
/** settles once every watched dispatch is finalized */
|
|
39
|
+
done: Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Waits for a live dispatch this process did not start, then finalizes and settles it (plan 10). A state.md
|
|
44
|
+
* refresh that fails rejects, after the record is written, with a message that says so.
|
|
45
|
+
*/
|
|
46
|
+
async function watchAndFinalize(deps: Deps, run: Run, d: Dispatch): Promise<void> {
|
|
47
|
+
watching.add(d.admit.dispatchId);
|
|
48
|
+
try {
|
|
49
|
+
await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now, onPoll: stallPoll(run, d) });
|
|
50
|
+
const record = await finalizeDispatch(run, d);
|
|
51
|
+
// a limit of a run this session does not own waits, unread, for the session that claims the run
|
|
52
|
+
if (record.status === "limit" && !ownsRun(deps, run)) return;
|
|
53
|
+
const s = await settle(deps, run, d, record);
|
|
54
|
+
if (s.stateHints[0]) throw new Error(s.stateHints[0]);
|
|
55
|
+
} finally {
|
|
56
|
+
watching.delete(d.admit.dispatchId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Spec §4.7: on server start, finalize and settle each finished dispatch that has no record, settle each
|
|
62
|
+
* unread usage limit that was never failed over (its server died between the two), and watch each live one
|
|
63
|
+
* until it finishes. A run that cannot be read is skipped with a warning; it never stops the server.
|
|
64
|
+
*
|
|
65
|
+
* Plan 10 fix-round ruling: a usage limit fails over here only in a run this session owns. A limit of a run
|
|
66
|
+
* another session owns, or no session does (a 1.0 run, a run started outside Claude Code), is only recorded:
|
|
67
|
+
* the session that claims the run settles it then (`claim`), so no stale run starts a stand-in on its own.
|
|
68
|
+
*/
|
|
69
|
+
export async function reconcileAll(deps: Deps): Promise<ReconcileReport> {
|
|
70
|
+
const { runs, corrupt } = listRuns();
|
|
71
|
+
const report: Omit<ReconcileReport, "done"> = {
|
|
72
|
+
finalized: [],
|
|
73
|
+
watching: [],
|
|
74
|
+
warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`),
|
|
75
|
+
};
|
|
76
|
+
const watchers: Promise<void>[] = [];
|
|
77
|
+
const warn = (run: Run, e: unknown): void => {
|
|
78
|
+
const w = `${run.id}: ${errorMessage(e)}`;
|
|
79
|
+
if (!report.warnings.includes(w)) report.warnings.push(w);
|
|
80
|
+
};
|
|
81
|
+
for (const run of runs) {
|
|
82
|
+
let pending;
|
|
83
|
+
try {
|
|
84
|
+
const scrubbed = scrubOldSpecs(run);
|
|
85
|
+
if (scrubbed) log("warn", "reconcile", { run: run.id, scrubbedSpecs: scrubbed });
|
|
86
|
+
pending = pendingDispatches(run, deps.now());
|
|
87
|
+
} catch (e) {
|
|
88
|
+
warn(run, e);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const mine = ownsRun(deps, run);
|
|
92
|
+
for (const d of pending) {
|
|
93
|
+
if (d.state !== "finished") {
|
|
94
|
+
report.watching.push(d.admit.dispatchId);
|
|
95
|
+
watchers.push(watchAndFinalize(deps, run, d).catch((e: unknown) => warn(run, e)));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
let hints: string[] = [];
|
|
99
|
+
try {
|
|
100
|
+
const record = await finalizeDispatch(run, d);
|
|
101
|
+
if (record.status !== "limit" || mine) hints = (await settle(deps, run, d, record)).stateHints;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
warn(run, e);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
report.finalized.push(d.admit.dispatchId);
|
|
107
|
+
for (const h of hints) warn(run, h);
|
|
108
|
+
}
|
|
109
|
+
let limits: ReturnType<typeof unsettledLimits> = [];
|
|
110
|
+
try {
|
|
111
|
+
if (mine) limits = unsettledLimits(run);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
warn(run, e);
|
|
114
|
+
}
|
|
115
|
+
for (const { d, record } of limits) {
|
|
116
|
+
try {
|
|
117
|
+
for (const h of (await settle(deps, run, d, record)).stateHints) warn(run, h);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
warn(run, e);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
log("info", "reconcile", {
|
|
124
|
+
runs: runs.length,
|
|
125
|
+
finalized: report.finalized.length,
|
|
126
|
+
watching: report.watching.length,
|
|
127
|
+
warnings: report.warnings,
|
|
128
|
+
});
|
|
129
|
+
return { ...report, done: Promise.all(watchers).then(() => undefined) };
|
|
130
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
|
|
2
|
+
import { protocolView } from "./protocol.ts";
|
|
3
|
+
import { type OpenQuestion, openQuestions } from "./questions.ts";
|
|
4
|
+
import type { Run } from "./run-store.ts";
|
|
5
|
+
import { readNotes } from "./state.ts";
|
|
6
|
+
|
|
7
|
+
// Spec 1.1 §8, §7 and §10: what a session re-entering a run needs first, as peek returns it: the owner questions still open, the protocol's next step with its checklist,
|
|
8
|
+
// and the verifier's latest step.
|
|
9
|
+
|
|
10
|
+
export interface Reentry {
|
|
11
|
+
questions: OpenQuestion[];
|
|
12
|
+
protocol: { next: string; checklist: string[] };
|
|
13
|
+
verifier: VerifierStep | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function reentry(run: Run, now?: number): Reentry {
|
|
17
|
+
return {
|
|
18
|
+
questions: openQuestions(run),
|
|
19
|
+
protocol: protocolView(run, readNotes(run).parked ?? [], now),
|
|
20
|
+
verifier: latestVerifierStep(run),
|
|
21
|
+
};
|
|
22
|
+
}
|