catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
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 +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- 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/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- 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/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- 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 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BILLING_MODES } from "../domain/cost.ts";
|
|
3
|
+
import { pairOf } from "../domain/catalog.ts";
|
|
4
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
5
|
+
import { catalogQuery, loadCatalog, type IndexedPair } from "./catalog-service.ts";
|
|
6
|
+
import { backendChecks, usedBackends } from "./doctor-backends.ts";
|
|
7
|
+
import { indexCheck } from "./doctor-checks.ts";
|
|
8
|
+
import type { Deps, ProfileSaved } from "./ports.ts";
|
|
9
|
+
import { profileFor } from "./profile-store.ts";
|
|
10
|
+
|
|
11
|
+
// The panel's Setup page (spec 2.0 decision 25): backends with their doctor status, and each role's rungs picked
|
|
12
|
+
// from the indexed pairs. Every write goes through the ProfilePort.
|
|
13
|
+
|
|
14
|
+
export const CheckSchema = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
label: z.string(),
|
|
17
|
+
state: z.enum(["ok", "warn", "fail", "skip", "info"]),
|
|
18
|
+
word: z.string(),
|
|
19
|
+
detail: z.string(),
|
|
20
|
+
fix: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
export const IssueSchema = z.object({ path: z.string(), message: z.string(), fix: z.string().optional() });
|
|
23
|
+
export const IndexedPairSchema = z.object({
|
|
24
|
+
rung: z.string(),
|
|
25
|
+
intelligence: z.number(),
|
|
26
|
+
price: z.object({ input: z.number(), output: z.number() }).nullable(),
|
|
27
|
+
speed: z.number().nullable(),
|
|
28
|
+
context: z.number().nullable(),
|
|
29
|
+
cost: z.object({
|
|
30
|
+
tier: z.union([z.literal(0), z.literal(1)]),
|
|
31
|
+
value: z.number().nullable(),
|
|
32
|
+
mode: z.enum(BILLING_MODES),
|
|
33
|
+
}),
|
|
34
|
+
evidence: z.string().nullable(),
|
|
35
|
+
});
|
|
36
|
+
export const ProfileSavedSchema = z.object({
|
|
37
|
+
saved: z.boolean(),
|
|
38
|
+
errors: z.array(IssueSchema),
|
|
39
|
+
warnings: z.array(IssueSchema),
|
|
40
|
+
diff: z.array(z.object({ path: z.string(), before: z.unknown(), after: z.unknown() })),
|
|
41
|
+
linked: z.array(z.string()),
|
|
42
|
+
pruned: z.array(z.string()),
|
|
43
|
+
newSessionNeededFor: z.array(z.string()),
|
|
44
|
+
});
|
|
45
|
+
export const SetupViewSchema = z.object({
|
|
46
|
+
repo: z.string().nullable(),
|
|
47
|
+
profile: z.object({ name: z.string(), active: z.string(), profiles: z.array(z.string()) }),
|
|
48
|
+
catalog: CheckSchema,
|
|
49
|
+
backends: z.array(CheckSchema),
|
|
50
|
+
issues: z.object({ errors: z.array(IssueSchema), warnings: z.array(IssueSchema) }),
|
|
51
|
+
roles: z.array(
|
|
52
|
+
z.object({
|
|
53
|
+
role: z.enum(ROLES),
|
|
54
|
+
enabled: z.boolean(),
|
|
55
|
+
rungs: z.array(z.object({ rung: z.string(), intelligence: z.number().nullable() })),
|
|
56
|
+
offered: z.array(IndexedPairSchema),
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
});
|
|
60
|
+
export type SetupView = z.infer<typeof SetupViewSchema>;
|
|
61
|
+
|
|
62
|
+
/** What the Setup page shows: the profile `repo` runs on (the active one without a repo). */
|
|
63
|
+
export async function setupView(deps: Deps, repo: string | null): Promise<SetupView> {
|
|
64
|
+
const g = deps.profiles.get(undefined, repo);
|
|
65
|
+
const p = profileFor(repo, deps.host.host);
|
|
66
|
+
const issues = deps.profiles.validate(undefined, repo);
|
|
67
|
+
const catalog = loadCatalog({ repo: repo ?? undefined });
|
|
68
|
+
const intelligence = (rung: string) => pairOf(catalog, rung)?.intelligence ?? null;
|
|
69
|
+
return {
|
|
70
|
+
repo,
|
|
71
|
+
profile: { name: g.here, active: g.active, profiles: g.profiles },
|
|
72
|
+
catalog: indexCheck(),
|
|
73
|
+
backends: await backendChecks(usedBackends([p]), [p]),
|
|
74
|
+
issues: { errors: issues.errors, warnings: issues.warnings },
|
|
75
|
+
roles: ROLES.map((role) => {
|
|
76
|
+
const r = g.profile.roles[role];
|
|
77
|
+
const offered = new Map<string, IndexedPair>();
|
|
78
|
+
for (const m of catalogQuery({ role, limit: 1000, repo: repo ?? undefined }, g.profile.billing).models)
|
|
79
|
+
for (const x of m.rungs) offered.set(x.rung, x);
|
|
80
|
+
return {
|
|
81
|
+
role,
|
|
82
|
+
enabled: r?.enabled ?? false,
|
|
83
|
+
rungs: (r?.rungs ?? []).map((rung) => ({ rung, intelligence: intelligence(rung) })),
|
|
84
|
+
offered: [...offered.values()].sort(
|
|
85
|
+
(a, b) => b.intelligence - a.intelligence || a.rung.localeCompare(b.rung),
|
|
86
|
+
),
|
|
87
|
+
};
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Unique, rising intelligence with unindexed last; ties keep the previous order, then the pick order. */
|
|
93
|
+
export function orderRungs(
|
|
94
|
+
rungs: string[],
|
|
95
|
+
previous: string[],
|
|
96
|
+
intelligence: (r: string) => number | null,
|
|
97
|
+
): string[] {
|
|
98
|
+
const at = (list: string[], r: string) => {
|
|
99
|
+
const i = list.indexOf(r);
|
|
100
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
101
|
+
};
|
|
102
|
+
return [...new Set(rungs)].sort(
|
|
103
|
+
(a, b) =>
|
|
104
|
+
(intelligence(a) ?? Infinity) - (intelligence(b) ?? Infinity) ||
|
|
105
|
+
at(previous, a) - at(previous, b) ||
|
|
106
|
+
at(rungs, a) - at(rungs, b),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Saves a role's rungs, in ladder order, through the profile service (its validation is the gate). */
|
|
111
|
+
export function setRoleRungs(deps: Deps, repo: string | null, role: Role, rungs: string[]): ProfileSaved {
|
|
112
|
+
const current = deps.profiles.get(undefined, repo).profile.roles[role]?.rungs ?? [];
|
|
113
|
+
const catalog = loadCatalog({ repo: repo ?? undefined });
|
|
114
|
+
const ordered = orderRungs(rungs, current, (r) => pairOf(catalog, r)?.intelligence ?? null);
|
|
115
|
+
return deps.profiles.set(undefined, { roles: { [role]: { rungs: ordered } } }, repo);
|
|
116
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import type { Span } from "../domain/util.ts";
|
|
4
|
+
import { dataDir } from "../infra/paths.ts";
|
|
5
|
+
import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
|
|
6
|
+
/** What a pause is checked against: a run, or a workspace step about to start one. */
|
|
7
|
+
type Covered = { meta: { workspace?: { id: string; step: string } } };
|
|
8
|
+
import { findWorkspace, workspaceDirectory } from "./workspace-store.ts";
|
|
9
|
+
|
|
10
|
+
// Spec 1.5 plan 25, "Group pause": one environment blocker (a VPN, Docker down) pauses every run on the
|
|
11
|
+
// machine, or every child of a workspace, at once: admission refuses with the reason until it is resumed.
|
|
12
|
+
|
|
13
|
+
/** One row per pause or resume, oldest first; `reason` null is a resume. */
|
|
14
|
+
interface PauseRow {
|
|
15
|
+
at: string;
|
|
16
|
+
reason: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A pause in force: whose (the machine, or one workspace), why and since when. */
|
|
20
|
+
export interface Pause {
|
|
21
|
+
scope: "machine" | "workspace";
|
|
22
|
+
/** the workspace id, for a workspace pause */
|
|
23
|
+
workspace?: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
since: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A stretch of time something was paused; `to` null while it still is. */
|
|
29
|
+
export interface PauseInterval {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const machinePauseFile = (): string => join(dataDir(), "pauses.jsonl");
|
|
35
|
+
export const workspacePauseFile = (id: string): string => join(workspaceDirectory(id), "pauses.jsonl");
|
|
36
|
+
|
|
37
|
+
const rows = (file: string): PauseRow[] =>
|
|
38
|
+
readJsonl<PauseRow>(file).rows.filter(
|
|
39
|
+
(r) => typeof r?.at === "string" && (r.reason === null || typeof r.reason === "string"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The pause in force in `file`, or null: its last row, when that row pauses. */
|
|
43
|
+
function current(file: string): { reason: string; since: string } | null {
|
|
44
|
+
const last = rows(file).at(-1);
|
|
45
|
+
return last && last.reason !== null ? { reason: last.reason, since: last.at } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every paused stretch `file` records, oldest first. */
|
|
49
|
+
export function pauseIntervals(file: string): PauseInterval[] {
|
|
50
|
+
const out: PauseInterval[] = [];
|
|
51
|
+
for (const r of rows(file)) {
|
|
52
|
+
const open = out.at(-1);
|
|
53
|
+
if (r.reason !== null && (!open || open.to !== null)) out.push({ from: r.at, to: null });
|
|
54
|
+
else if (r.reason === null && open && open.to === null) open.to = r.at;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function append(file: string, row: PauseRow): void {
|
|
60
|
+
ensureJsonlHeader(file, "pauses");
|
|
61
|
+
appendJsonl(file, row);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PAUSE_HINT = "push the reason to the owner once (PushNotification); every run it covers waits on it";
|
|
65
|
+
|
|
66
|
+
/** `catherd pause --machine <reason>`: every dispatch on this machine is refused until resumed. */
|
|
67
|
+
export function pauseMachine(now: number, reason: string): { pause: Pause; hints: string[] } {
|
|
68
|
+
if (!reason.trim())
|
|
69
|
+
throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
|
|
70
|
+
fix: 'catherd pause --machine "the VPN takes the default route"',
|
|
71
|
+
});
|
|
72
|
+
const at = new Date(now).toISOString();
|
|
73
|
+
append(machinePauseFile(), { at, reason });
|
|
74
|
+
return { pause: { scope: "machine", reason, since: at }, hints: [PAUSE_HINT] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `catherd resume --machine`: lifts the machine pause; the previous one, or null when none was in force. */
|
|
78
|
+
export function resumeMachine(now: number): { resumed: Pause | null } {
|
|
79
|
+
const was = current(machinePauseFile());
|
|
80
|
+
if (was) append(machinePauseFile(), { at: new Date(now).toISOString(), reason: null });
|
|
81
|
+
return { resumed: was ? { scope: "machine", ...was } : null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `workspace_pause(workspace, reason)`: every child of the workspace is refused admission until resumed. */
|
|
85
|
+
export function pauseWorkspace(
|
|
86
|
+
now: number,
|
|
87
|
+
i: { workspace: string; reason: string },
|
|
88
|
+
): { pause: Pause; hints: string[] } {
|
|
89
|
+
const workspace = findWorkspace(i.workspace);
|
|
90
|
+
if (!i.reason.trim())
|
|
91
|
+
throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
|
|
92
|
+
fix: 'workspace_pause(workspace, "the VPN takes the default route")',
|
|
93
|
+
});
|
|
94
|
+
const at = new Date(now).toISOString();
|
|
95
|
+
append(workspacePauseFile(workspace.id), { at, reason: i.reason });
|
|
96
|
+
return {
|
|
97
|
+
pause: { scope: "workspace", workspace: workspace.id, reason: i.reason, since: at },
|
|
98
|
+
hints: [PAUSE_HINT],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `workspace_resume(workspace)`: lifts the workspace's pause; the previous one, or null. */
|
|
103
|
+
export function resumeWorkspace(now: number, i: { workspace: string }): { resumed: Pause | null } {
|
|
104
|
+
const workspace = findWorkspace(i.workspace);
|
|
105
|
+
const file = workspacePauseFile(workspace.id);
|
|
106
|
+
const was = current(file);
|
|
107
|
+
if (was) append(file, { at: new Date(now).toISOString(), reason: null });
|
|
108
|
+
return { resumed: was ? { scope: "workspace", workspace: workspace.id, ...was } : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The pauses in force over `run`: the machine's, then its workspace's. */
|
|
112
|
+
export function pausesFor(run: Covered | null): Pause[] {
|
|
113
|
+
const out: Pause[] = [];
|
|
114
|
+
const machine = current(machinePauseFile());
|
|
115
|
+
if (machine) out.push({ scope: "machine", ...machine });
|
|
116
|
+
const id = run?.meta.workspace?.id;
|
|
117
|
+
const ws = id ? current(workspacePauseFile(id)) : null;
|
|
118
|
+
if (id && ws) out.push({ scope: "workspace", workspace: id, ...ws });
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every stretch the machine, or the run's workspace, was paused, in epoch ms: `land` leaves them out. */
|
|
123
|
+
export function pauseSpans(run: Covered): Span[] {
|
|
124
|
+
const id = run.meta.workspace?.id;
|
|
125
|
+
const files = [machinePauseFile(), ...(id ? [workspacePauseFile(id)] : [])];
|
|
126
|
+
return files.flatMap((f) =>
|
|
127
|
+
pauseIntervals(f).map((p) => ({ from: Date.parse(p.from), to: p.to === null ? null : Date.parse(p.to) })),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The pauses in force over any of `runs`, each once, the machine's first. */
|
|
132
|
+
export function pausesOver(runs: Covered[]): Pause[] {
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const out: Pause[] = [];
|
|
135
|
+
for (const p of [pausesFor(null), ...runs.map(pausesFor)].flat()) {
|
|
136
|
+
const key = `${p.scope} ${p.workspace ?? ""}`;
|
|
137
|
+
if (!seen.has(key)) {
|
|
138
|
+
seen.add(key);
|
|
139
|
+
out.push(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const describe = (p: Pause): string =>
|
|
146
|
+
`${p.scope === "machine" ? "the machine" : `workspace ${p.workspace}`} is paused since ${p.since}: ${p.reason}`;
|
|
147
|
+
|
|
148
|
+
/** One line per pause, for status and the admission refusal. */
|
|
149
|
+
export const pauseLine = describe;
|
|
150
|
+
|
|
151
|
+
/** Admission refuses while a pause covers the run, naming its reason and how to lift it. */
|
|
152
|
+
export function assertNotPaused(run: Covered): void {
|
|
153
|
+
const [p] = pausesFor(run);
|
|
154
|
+
if (!p) return;
|
|
155
|
+
throw new CatherdError("E_ADMIT_PAUSED", describe(p), {
|
|
156
|
+
fix:
|
|
157
|
+
p.scope === "machine"
|
|
158
|
+
? "when the blocker is gone, the owner runs: catherd resume --machine"
|
|
159
|
+
: `when the blocker is gone, call workspace_resume(${p.workspace})`,
|
|
160
|
+
});
|
|
161
|
+
}
|
package/src/services/peek.ts
CHANGED
|
@@ -49,6 +49,45 @@ export interface PeekRun extends Reentry {
|
|
|
49
49
|
native: { name: string; role: string; rung: string; status: string; at: string } | null;
|
|
50
50
|
/** the run's next step (state.md's last line) */
|
|
51
51
|
next: string;
|
|
52
|
+
/** plan 22, goal mode: whether anything in this run is the coordinator's to do now, and why (not) */
|
|
53
|
+
actionable: boolean;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Plan 22, goal mode: whether the run holds anything for the coordinator. An unread record is its to read; with
|
|
59
|
+
* roles live, a protocol step one of them is doing (lanes running, the reviewer or verifier step with that role
|
|
60
|
+
* live, the plan while an architect or researcher runs) or the owner's question is nobody's to act on, so the
|
|
61
|
+
* coordinator ends its turn with no tool call and the next catherd message wakes it.
|
|
62
|
+
*/
|
|
63
|
+
export function actionability(
|
|
64
|
+
live: { name: string; role: string }[],
|
|
65
|
+
unread: { name: string }[],
|
|
66
|
+
protocolNext: string,
|
|
67
|
+
): { actionable: boolean; reason: string } {
|
|
68
|
+
if (unread.length)
|
|
69
|
+
return {
|
|
70
|
+
actionable: true,
|
|
71
|
+
reason: `unread: ${unread.map((u) => `result(run, "${u.name}")`).join(", ")}`,
|
|
72
|
+
};
|
|
73
|
+
if (protocolNext.endsWith(" parked: wait for the owner"))
|
|
74
|
+
return {
|
|
75
|
+
actionable: false,
|
|
76
|
+
reason: `${protocolNext.replace(/: wait for the owner$/, "")}: the owner's answer comes first; end the turn with no tool call`,
|
|
77
|
+
};
|
|
78
|
+
const running = (role: string) => live.some((l) => l.role === role);
|
|
79
|
+
const covered =
|
|
80
|
+
/: lanes running \(/.test(protocolNext) ||
|
|
81
|
+
(protocolNext.endsWith(": reviewer") && running("reviewer")) ||
|
|
82
|
+
(protocolNext.endsWith(": verifier") && running("verifier")) ||
|
|
83
|
+
(protocolNext.startsWith("plan:") && (running("architect") || running("researcher"))) ||
|
|
84
|
+
(protocolNext.startsWith("finish:") && (running("verifier") || running("writer")));
|
|
85
|
+
if (live.length && covered)
|
|
86
|
+
return {
|
|
87
|
+
actionable: false,
|
|
88
|
+
reason: `only roles are live (${live.map((l) => l.name).join(", ")}): their results arrive as catherd messages; end the turn with no tool call`,
|
|
89
|
+
};
|
|
90
|
+
return { actionable: true, reason: protocolNext };
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
@@ -57,23 +96,31 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
57
96
|
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
58
97
|
const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
|
|
59
98
|
const native = agents.at(-1);
|
|
60
|
-
const
|
|
99
|
+
const notes = readNotes(run);
|
|
100
|
+
const re = reentry(run, now, notes);
|
|
101
|
+
const live = liveDispatches(run, now);
|
|
102
|
+
// the whole run decides it, whatever `name` narrows the view to
|
|
103
|
+
const act = actionability(
|
|
104
|
+
live.map((d) => ({ name: d.admit.name, role: d.admit.role })),
|
|
105
|
+
listDispatches(run).flatMap((d) =>
|
|
106
|
+
records.has(d.admit.dispatchId) && awaitsCollect(d.dir) ? [{ name: d.admit.name }] : [],
|
|
107
|
+
),
|
|
108
|
+
re.protocol.next,
|
|
109
|
+
);
|
|
61
110
|
return {
|
|
62
|
-
questions:
|
|
111
|
+
questions: re.questions,
|
|
63
112
|
run: run.id,
|
|
64
113
|
title: run.meta.title,
|
|
65
114
|
owner: runOwner(run)?.sessionId ?? null,
|
|
66
115
|
ownerHost: runOwner(run)?.host ?? null,
|
|
67
|
-
live:
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
lastEvent: lastActivity(d),
|
|
76
|
-
})),
|
|
116
|
+
live: live.filter(mine).map((d) => ({
|
|
117
|
+
name: d.admit.name,
|
|
118
|
+
role: d.admit.role,
|
|
119
|
+
rung: d.admit.rung,
|
|
120
|
+
state: d.state,
|
|
121
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
122
|
+
lastEvent: lastActivity(d),
|
|
123
|
+
})),
|
|
77
124
|
delivery: listDispatches(run)
|
|
78
125
|
.filter(mine)
|
|
79
126
|
.flatMap((d) =>
|
|
@@ -103,9 +150,10 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
103
150
|
native: native
|
|
104
151
|
? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
|
|
105
152
|
: null,
|
|
106
|
-
next:
|
|
107
|
-
protocol:
|
|
108
|
-
verifier:
|
|
153
|
+
next: notes.next,
|
|
154
|
+
protocol: re.protocol,
|
|
155
|
+
verifier: re.verifier,
|
|
156
|
+
...act,
|
|
109
157
|
};
|
|
110
158
|
}
|
|
111
159
|
|
|
@@ -117,7 +165,15 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
117
165
|
export async function peek(
|
|
118
166
|
deps: Deps,
|
|
119
167
|
i: { run?: string; name?: string },
|
|
120
|
-
): Promise<{
|
|
168
|
+
): Promise<{
|
|
169
|
+
/** plan 22, goal mode: false when no run holds anything for the coordinator; `reason` says why either way */
|
|
170
|
+
actionable: boolean;
|
|
171
|
+
reason: string;
|
|
172
|
+
runs: PeekRun[];
|
|
173
|
+
hints: string[];
|
|
174
|
+
host: HostContext;
|
|
175
|
+
queue: QueueCapability | null;
|
|
176
|
+
}> {
|
|
121
177
|
if (i.name !== undefined) assertId("role name", i.name);
|
|
122
178
|
let runs: Run[];
|
|
123
179
|
if (i.run) {
|
|
@@ -131,8 +187,14 @@ export async function peek(
|
|
|
131
187
|
runs = owned.length ? owned : all.slice(0, 1);
|
|
132
188
|
}
|
|
133
189
|
const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
|
|
190
|
+
const views = runs.map((r) => peekRun(deps, r, i.name));
|
|
191
|
+
const doing = views.filter((v) => v.actionable);
|
|
192
|
+
const shown = (vs: PeekRun[]) =>
|
|
193
|
+
vs.map((v) => (views.length > 1 ? `${v.title}: ${v.reason}` : v.reason)).join("; ");
|
|
134
194
|
return {
|
|
135
|
-
|
|
195
|
+
actionable: views.length === 0 || doing.length > 0,
|
|
196
|
+
reason: views.length === 0 ? "no runs yet" : shown(doing.length ? doing : views),
|
|
197
|
+
runs: views,
|
|
136
198
|
hints,
|
|
137
199
|
host: inspectionHost(deps.host),
|
|
138
200
|
queue: deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import { parseLaneHeader } from "../domain/lane.ts";
|
|
5
|
+
import {
|
|
6
|
+
coverageProblems,
|
|
7
|
+
depthProblems,
|
|
8
|
+
type PlanDoc,
|
|
9
|
+
type PlanTask,
|
|
10
|
+
parsePlanDoc,
|
|
11
|
+
planInputNames,
|
|
12
|
+
resolveTask,
|
|
13
|
+
} from "../domain/plan-doc.ts";
|
|
14
|
+
import type { Role } from "../domain/roles.ts";
|
|
15
|
+
import { writeTextAtomic } from "../infra/store.ts";
|
|
16
|
+
import { isDaemonRun, replayRun } from "./run-events-store.ts";
|
|
17
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
18
|
+
|
|
19
|
+
/** The run's copies of the owner's plan files: isolated roles read these, never the originals (plan 32). */
|
|
20
|
+
export const PLAN_INPUT_DIR = "plan-input";
|
|
21
|
+
/** Past this many characters of preambles, a role's `<context>` names the copies instead of repeating them. */
|
|
22
|
+
export const PLAN_CONTEXT_MAX = 100_000;
|
|
23
|
+
|
|
24
|
+
const invalid = (message: string, fix: string) => new CatherdError("E_INPUT_INVALID", message, { fix });
|
|
25
|
+
|
|
26
|
+
/** The absolute, deduped plan paths of a `run_start`; a relative one resolves against the repo. */
|
|
27
|
+
export function resolvePlanInputs(repo: string, paths: string[]): string[] {
|
|
28
|
+
const out: string[] = [];
|
|
29
|
+
for (const p of paths) {
|
|
30
|
+
const abs = isAbsolute(p) ? p : resolve(repo, p);
|
|
31
|
+
if (out.includes(abs)) continue;
|
|
32
|
+
if (!existsSync(abs))
|
|
33
|
+
throw invalid(`plan file ${abs} does not exist`, "pass the path of the reviewed plan file");
|
|
34
|
+
if (!statSync(abs).isFile())
|
|
35
|
+
throw invalid(`plan ${abs} is not a file`, "pass the path of the reviewed plan file");
|
|
36
|
+
out.push(abs);
|
|
37
|
+
}
|
|
38
|
+
if (out.length > 0 && !out.some((f) => parsePlanDoc(f, readFileSync(f, "utf8")).tasks.length > 0))
|
|
39
|
+
throw invalid(
|
|
40
|
+
`none of the plan files has a "### Task <n>:" heading: ${out.join(", ")}`,
|
|
41
|
+
"pass the reviewed plan, or no plan and the architect writes one",
|
|
42
|
+
);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The paths of an A-line `plan: a, b` (the label in any case). */
|
|
47
|
+
export function planInputPaths(aLines: string[]): string[] {
|
|
48
|
+
return aLines.flatMap((l) =>
|
|
49
|
+
(/^\s*plan:\s*(.*?)\s*$/i.exec(l)?.[1] ?? "")
|
|
50
|
+
.split(",")
|
|
51
|
+
.map((p) => p.trim())
|
|
52
|
+
.filter(Boolean),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `<run>/plan-input/<name>`, 0600, for each path; a file that has vanished since keeps its earlier copy. */
|
|
57
|
+
export function copyPlanInputs(run: Run, paths: string[]): void {
|
|
58
|
+
const names = planInputNames(paths);
|
|
59
|
+
for (const [i, p] of paths.entries())
|
|
60
|
+
if (existsSync(p))
|
|
61
|
+
writeTextAtomic(join(run.dir, PLAN_INPUT_DIR, names[i] as string), readFileSync(p, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const read = (file: string): string | null => {
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(file, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The plan files a role can read, by their path in the run folder: the copies, else plan.md. */
|
|
73
|
+
function planFiles(run: Run): { path: string; doc: PlanDoc }[] {
|
|
74
|
+
const plan = replayRun(run).plan;
|
|
75
|
+
const files = plan.length
|
|
76
|
+
? planInputNames(plan).map((n) => ({ path: `${PLAN_INPUT_DIR}/${n}`, name: n }))
|
|
77
|
+
: [{ path: "plan.md", name: "plan.md" }];
|
|
78
|
+
return files.flatMap((f) => {
|
|
79
|
+
const text = read(join(run.dir, f.path));
|
|
80
|
+
return text === null ? [] : [{ path: f.path, doc: parsePlanDoc(f.name, text) }];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The run's plan as documents: the copies in `run.started.plan` order; else plan.md; none when neither exists. */
|
|
85
|
+
export const planDocs = (run: Run): PlanDoc[] => planFiles(run).map((f) => f.doc);
|
|
86
|
+
|
|
87
|
+
/** Each plan file's preamble, which applies to every task, for every role but the architect and the judge. */
|
|
88
|
+
export function planContext(run: Run, role: Role): string | null {
|
|
89
|
+
if (role === "architect" || role === "judge" || !isDaemonRun(run)) return null;
|
|
90
|
+
const files = planFiles(run);
|
|
91
|
+
const parts = files.flatMap(({ doc }) => {
|
|
92
|
+
if (doc.tasks.length === 0)
|
|
93
|
+
return [`The plan file ${doc.name}, which every task follows:\n\n${doc.text.trim()}`];
|
|
94
|
+
const pre = doc.preamble.trim();
|
|
95
|
+
return pre ? [`The plan's preamble (${doc.name}), which applies to every task:\n\n${pre}`] : [];
|
|
96
|
+
});
|
|
97
|
+
if (parts.length === 0) return null;
|
|
98
|
+
const text = parts.join("\n\n");
|
|
99
|
+
return text.length > PLAN_CONTEXT_MAX
|
|
100
|
+
? `The run's plan files are too long to repeat here; read them before you start, with read_run_file: ${files.map((f) => f.path).join(", ")}`
|
|
101
|
+
: text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const natural = (a: string, b: string): number => a.localeCompare(b, "en", { numeric: true });
|
|
105
|
+
|
|
106
|
+
/** Every lane file's id, in lane order. */
|
|
107
|
+
function laneIds(run: Run): string[] {
|
|
108
|
+
const dir = runPaths(run.dir).lanes;
|
|
109
|
+
return (existsSync(dir) ? readdirSync(dir) : [])
|
|
110
|
+
.filter((f) => f.endsWith(".md"))
|
|
111
|
+
.map((f) => f.slice(0, -3))
|
|
112
|
+
.sort(natural);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const laneRefs = (run: Run, lane: string): string[] =>
|
|
116
|
+
parseLaneHeader(read(join(runPaths(run.dir).lanes, `${lane}.md`)) ?? "").tasks;
|
|
117
|
+
|
|
118
|
+
/** The plan tasks a lane's `Tasks:` line names; a ref that resolves to none is left out (planProblems names it). */
|
|
119
|
+
export function laneTasks(run: Run, lane: string): PlanTask[] {
|
|
120
|
+
const docs = planDocs(run);
|
|
121
|
+
return laneRefs(run, lane).flatMap((ref) => {
|
|
122
|
+
const t = resolveTask(docs, ref);
|
|
123
|
+
return typeof t === "string" ? [] : [t];
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A milestone's tasks: those of its lanes in lane order, each once. */
|
|
128
|
+
export function milestoneTasks(run: Run, milestone: string): PlanTask[] {
|
|
129
|
+
const seen = new Map<string, PlanTask>();
|
|
130
|
+
for (const id of laneIds(run).filter((l) => l.startsWith(`${milestone}.`)))
|
|
131
|
+
for (const t of laneTasks(run, id)) seen.set(`${t.doc}#${t.id}`, t);
|
|
132
|
+
return [...seen.values()];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What the architect can fix: a lane that names no task or a missing one, and with no plan input plan.md's depth.
|
|
137
|
+
* A task of the owner's plan with neither a signature nor a test is the owner's, and preflight refuses it.
|
|
138
|
+
*/
|
|
139
|
+
export function planProblems(run: Run): string[] {
|
|
140
|
+
const docs = planDocs(run);
|
|
141
|
+
const lanes = laneIds(run).map((id) => ({ id, tasks: laneRefs(run, id) }));
|
|
142
|
+
const out = coverageProblems(docs, lanes)
|
|
143
|
+
.filter((p) => p.lane !== "")
|
|
144
|
+
.map((p) => p.note);
|
|
145
|
+
if (replayRun(run).plan.length === 0) out.push(...depthProblems(docs[0] ?? parsePlanDoc("plan.md", "")));
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A plan task as a brief carries it: verbatim, in a tag naming its file and number. */
|
|
150
|
+
export const taskBlock = (t: PlanTask): string =>
|
|
151
|
+
`<plan-task file="${t.doc}" id="${t.id}">\n${t.text.trimEnd()}\n</plan-task>`;
|