catherd-cli 1.5.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 +164 -208
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -247
- 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 -421
- package/plugin/skills/catherd-setup/SKILL.md +20 -19
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +1 -2
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +4 -4
- package/src/cli.ts +22 -27
- 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 +21 -6
- package/src/domain/catalog.ts +69 -324
- package/src/domain/cost.ts +15 -30
- package/src/domain/errors.ts +2 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -87
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +1 -0
- 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 +22 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +25 -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 +101 -160
- package/src/domain/profile.ts +116 -63
- package/src/domain/record.ts +53 -2
- package/src/domain/role-prompts.ts +131 -45
- package/src/domain/role-scope.ts +4 -34
- package/src/domain/role-tools.ts +2 -2
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +17 -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 +151 -146
- package/src/domain/slopalytics.ts +72 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +25 -174
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +1 -1
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/init-command.ts +18 -68
- package/src/entry/knowledge-command.ts +45 -4
- package/src/entry/lock-command.ts +6 -7
- package/src/entry/mcp/role-server.ts +18 -0
- package/src/entry/mcp/server.ts +13 -40
- 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/profile-command.ts +10 -14
- package/src/entry/role-cli-command.ts +47 -3
- package/src/entry/runs-command.ts +3 -19
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/infra/env.ts +0 -2
- package/src/infra/git.ts +64 -0
- package/src/infra/heavy-lock.ts +45 -7
- package/src/infra/launch.ts +63 -7
- 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 +2 -2
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/services/admission.ts +24 -9
- package/src/services/backends.ts +4 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -262
- package/src/services/credentials.ts +4 -20
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +5 -1
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor.ts +4 -9
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +12 -0
- package/src/services/gate-env.ts +1 -2
- package/src/services/gate-service.ts +21 -6
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-service.ts +34 -3
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +35 -8
- package/src/services/notifier.ts +69 -2
- package/src/services/notify-service.ts +26 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +30 -4
- package/src/services/preflight.ts +82 -8
- package/src/services/profile-service.ts +30 -3
- package/src/services/profile-store.ts +8 -3
- package/src/services/protocol.ts +4 -0
- package/src/services/provenance.ts +31 -90
- package/src/services/questions.ts +9 -0
- package/src/services/reconcile.ts +11 -0
- package/src/services/routing-service.ts +26 -15
- 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-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +55 -6
- package/src/services/run-store.ts +54 -7
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/summary.ts +27 -3
- package/src/services/tree-path.ts +27 -0
- package/src/services/workspace-admission.ts +5 -13
- package/src/services/workspace-service.ts +5 -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 -44
- 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 -335
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -187
- package/src/adapters/grok/index.ts +0 -358
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -132
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -131
- package/src/entry/mcp/protocol-tools.ts +0 -63
- package/src/entry/mcp/push-tools.ts +0 -16
- package/src/entry/mcp/run-tools.ts +0 -164
- package/src/entry/mcp/setup-tools.ts +0 -109
- package/src/entry/mcp/workspace-tools.ts +0 -142
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -385
- 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 -400
- 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 -619
- package/src/entry/tui/views/save-dialog.tsx +0 -344
- package/src/entry/tui/views/status.tsx +0 -213
- 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 -95
- package/src/infra/sources/artificial-analysis.ts +0 -119
- package/src/infra/sources/cache.ts +0 -126
- 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 -290
- package/src/services/source-derive.ts +0 -397
- package/src/services/source-sync.ts +0 -312
- package/src/services/standins.ts +0 -238
- package/src/services/treat-likes.ts +0 -113
package/src/domain/notice.ts
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
/** Spec §3.6: `later` waits for the receiver's turn to end, `next` drains at its next tool round. Never `now`. */
|
|
7
7
|
export type NoticePriority = "later" | "next";
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* One role's news: it finished (`record`), or an event while it runs (`stall`); of a 2.0 run, one of its
|
|
11
|
+
* reports (`report`: `name` is the unit, `status` the report's kind, `reply` its text).
|
|
12
|
+
*/
|
|
10
13
|
export interface Notice {
|
|
11
|
-
kind: "finished" | "stalled";
|
|
14
|
+
kind: "finished" | "stalled" | "report";
|
|
12
15
|
eventId: string;
|
|
13
16
|
runId: string;
|
|
14
17
|
runTitle: string;
|
|
@@ -41,6 +44,7 @@ const threadWord = (n: Notice): string => `thread: ${n.thread ?? "none"}`;
|
|
|
41
44
|
|
|
42
45
|
/** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
|
|
43
46
|
export function noticeHeader(n: Notice): string {
|
|
47
|
+
if (n.kind === "report") return `catherd · ${n.runTitle} · ${n.status} · ${n.name}`;
|
|
44
48
|
const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
|
|
45
49
|
if (n.kind === "stalled") return `${head} · running ${n.secs}s · ${threadWord(n)}`;
|
|
46
50
|
return [
|
|
@@ -68,6 +72,15 @@ export function capNoticeReply(reply: string, cap = REPLY_CAP): string {
|
|
|
68
72
|
const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2 $3");
|
|
69
73
|
|
|
70
74
|
function block(n: Notice): string {
|
|
75
|
+
if (n.kind === "report")
|
|
76
|
+
return [
|
|
77
|
+
noticeHeader(n),
|
|
78
|
+
...(n.reply ? [n.reply] : []),
|
|
79
|
+
n.status === "parked"
|
|
80
|
+
? `Answer: answer(run: "${n.runId}", unit: "${n.name}", answer: "<your answer>")`
|
|
81
|
+
: `Status: status(run: "${n.runId}")`,
|
|
82
|
+
`Event: ${n.eventId}`,
|
|
83
|
+
].join("\n");
|
|
71
84
|
const call = `run: "${n.runId}", name: "${n.name}"`;
|
|
72
85
|
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
|
|
73
86
|
const reply = capNoticeReply(n.reply);
|
|
@@ -80,12 +93,16 @@ function block(n: Notice): string {
|
|
|
80
93
|
function previewOf(ns: Notice[]): string {
|
|
81
94
|
const titles = [...new Set(ns.map((n) => n.runTitle))];
|
|
82
95
|
const where = titles.length === 1 ? titles[0] : plural(titles.length, "run", "runs");
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
`${
|
|
96
|
+
const reports = ns.every((n) => n.kind === "report");
|
|
97
|
+
const what = reports
|
|
98
|
+
? plural(ns.length, "report", "reports")
|
|
99
|
+
: ns.every((n) => n.kind === "finished")
|
|
100
|
+
? `${ns.length} roles finished`
|
|
101
|
+
: `${plural(ns.length, "role", "roles")} to look at`;
|
|
102
|
+
const each = ns.map((n) =>
|
|
103
|
+
n.kind === "report"
|
|
104
|
+
? `${n.name} ${n.status}`
|
|
105
|
+
: `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]} (${threadWord(n)})`,
|
|
89
106
|
);
|
|
90
107
|
return `catherd · ${where} · ${what}: ${each.join(", ")}`;
|
|
91
108
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { NotifyMoment } from "./profile.ts";
|
|
2
|
+
|
|
3
|
+
export interface NotifyMessage {
|
|
4
|
+
title: string;
|
|
5
|
+
message: string;
|
|
6
|
+
tags: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The phone message of a moment (spec 2.0): the run, the unit and the command that answers it, nothing else.
|
|
11
|
+
* Never the question's text, never code or secrets: the owner opens catherd to read it.
|
|
12
|
+
*/
|
|
13
|
+
export function notifyMessage(i: {
|
|
14
|
+
runId: string;
|
|
15
|
+
runTitle: string;
|
|
16
|
+
unit: string;
|
|
17
|
+
moment: NotifyMoment;
|
|
18
|
+
}): NotifyMessage {
|
|
19
|
+
const title = `catherd · ${i.runTitle}`;
|
|
20
|
+
switch (i.moment) {
|
|
21
|
+
case "blocked":
|
|
22
|
+
return {
|
|
23
|
+
title,
|
|
24
|
+
message: `${i.unit} needs your answer. Answer: catherd answer ${i.runId} ${i.unit} "<your answer>"`,
|
|
25
|
+
tags: ["question"],
|
|
26
|
+
};
|
|
27
|
+
case "milestone":
|
|
28
|
+
return { title, message: `${i.unit} landed.`, tags: ["white_check_mark"] };
|
|
29
|
+
case "finish":
|
|
30
|
+
return { title, message: `The run finished: catherd status ${i.runId}`, tags: ["checkered_flag"] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Plan files as a preamble and tasks (plan 32): what a lane's `Tasks:` line points at, and what an approval reads. */
|
|
2
|
+
|
|
3
|
+
export interface PlanTask {
|
|
4
|
+
doc: string;
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
text: string;
|
|
8
|
+
signature: boolean;
|
|
9
|
+
test: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface PlanDoc {
|
|
12
|
+
name: string;
|
|
13
|
+
preamble: string;
|
|
14
|
+
tasks: PlanTask[];
|
|
15
|
+
/** the whole file, for the section checks (a section may follow the tasks) */
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const TASK_HEADING = /^#{2,4}[ \t]+Task[ \t]+(\d+[a-z]?)\b[ \t]*[:.—–-]?[ \t]*(.*)$/gim;
|
|
20
|
+
export const TASK_REF = /^(?:([^\s#,]+)#)?(\d+[a-z]?)$/;
|
|
21
|
+
|
|
22
|
+
const SIGNATURE = [
|
|
23
|
+
/^[ \t]*(?:[-*][ \t]*)?\**(?:Interfaces|Produces|Signatures?)\**[ \t]*:/im,
|
|
24
|
+
/^[ \t]*```/m,
|
|
25
|
+
/`[^`\n]*\w\([^`\n]*\)[^`\n]*`/,
|
|
26
|
+
];
|
|
27
|
+
const TEST = [
|
|
28
|
+
/^[ \t]*(?:[-*][ \t]*)?\**Tests?\**[ \t]*:/im,
|
|
29
|
+
/[\w./-]*(?:\.test\.|_test\.|\.spec\.|\btest_)[\w.]*/,
|
|
30
|
+
/\b(?:it|test|describe)\(\s*["'`]|\bfunc Test\w+|\bdef test_\w+|#\[test\]/,
|
|
31
|
+
/^[ \t]*(?:[-*][ \t]*)?\**Expected\**[ \t]*:/im,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function parsePlanDoc(name: string, text: string): PlanDoc {
|
|
35
|
+
const heads = [...text.matchAll(TASK_HEADING)];
|
|
36
|
+
const tasks: PlanTask[] = heads.map((m, i) => {
|
|
37
|
+
const next = heads[i + 1]?.index ?? text.length;
|
|
38
|
+
const body = text.slice(m.index, next);
|
|
39
|
+
const nl = body.indexOf("\n");
|
|
40
|
+
// a task ends at the next task heading, or at the next `## ` heading after its own line
|
|
41
|
+
const rest = nl < 0 ? -1 : body.slice(nl).search(/^##[ \t]+(?!#)/m);
|
|
42
|
+
const taskText = rest < 0 ? body : body.slice(0, nl + rest);
|
|
43
|
+
return {
|
|
44
|
+
doc: name,
|
|
45
|
+
id: m[1] as string,
|
|
46
|
+
title: (m[2] ?? "").trim(),
|
|
47
|
+
text: taskText,
|
|
48
|
+
signature: SIGNATURE.some((r) => r.test(taskText)),
|
|
49
|
+
test: TEST.some((r) => r.test(taskText)),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
const milestone = /^##[ \t]+M\d+\b/m.exec(text)?.index ?? text.length;
|
|
53
|
+
return { name, preamble: text.slice(0, Math.min(milestone, heads[0]?.index ?? text.length)), tasks, text };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const and = (names: string[]) =>
|
|
57
|
+
names.length === 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
58
|
+
|
|
59
|
+
/** The task a lane's `Tasks:` ref names, or why it names none. */
|
|
60
|
+
export function resolveTask(docs: PlanDoc[], ref: string): PlanTask | string {
|
|
61
|
+
const m = TASK_REF.exec(ref);
|
|
62
|
+
if (!m) return `${ref} is not a task number like 3 or plan.md#3`;
|
|
63
|
+
const file = m[1];
|
|
64
|
+
const id = m[2] as string;
|
|
65
|
+
if (file !== undefined) {
|
|
66
|
+
const doc = docs.find((d) => d.name === file);
|
|
67
|
+
if (!doc) return `no plan file ${file}`;
|
|
68
|
+
return pick(doc, id) ?? `no task ${id} in ${file}`;
|
|
69
|
+
}
|
|
70
|
+
const owners = docs.filter((d) => d.tasks.some((t) => t.id === id));
|
|
71
|
+
if (owners.length === 0) return `no task ${id} in ${docs.map((d) => d.name).join(", ")}`;
|
|
72
|
+
if (owners.length > 1) {
|
|
73
|
+
const names = owners.map((d) => d.name);
|
|
74
|
+
return `task ${id} is in ${and(names)}: write ${names[0]}#${id}`;
|
|
75
|
+
}
|
|
76
|
+
return pick(owners[0] as PlanDoc, id) as PlanTask | string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The one task `id` names in `doc`, or why it names none: a file that holds a number twice cannot say which. */
|
|
80
|
+
function pick(doc: PlanDoc, id: string): PlanTask | string | undefined {
|
|
81
|
+
const held = doc.tasks.filter((t) => t.id === id);
|
|
82
|
+
return held.length > 1 ? `${doc.name} holds Task ${id} twice: renumber one of them` : held[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What is wrong between a milestone's lanes and the plan: a lane with no task, a bad ref, a task with nothing to build or test. */
|
|
86
|
+
export function coverageProblems(
|
|
87
|
+
docs: PlanDoc[],
|
|
88
|
+
lanes: { id: string; tasks: string[] }[],
|
|
89
|
+
): { lane: string; note: string }[] {
|
|
90
|
+
const out: { lane: string; note: string }[] = [];
|
|
91
|
+
const named = new Set<PlanTask>();
|
|
92
|
+
for (const l of lanes) {
|
|
93
|
+
if (l.tasks.length === 0) {
|
|
94
|
+
out.push({
|
|
95
|
+
lane: l.id,
|
|
96
|
+
note: `lanes/${l.id}.md has no Tasks: line: name the plan tasks it implements`,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const ref of l.tasks) {
|
|
101
|
+
const r = resolveTask(docs, ref);
|
|
102
|
+
if (typeof r === "string") out.push({ lane: l.id, note: `lanes/${l.id}.md: Tasks: ${ref} — ${r}` });
|
|
103
|
+
else named.add(r);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// only a task a lane builds needs something to build or test: the rest of the plan is the owner's
|
|
107
|
+
for (const t of named)
|
|
108
|
+
if (!t.signature && !t.test)
|
|
109
|
+
out.push({
|
|
110
|
+
lane: "",
|
|
111
|
+
note: `${t.doc} Task ${t.id} has neither a signature nor a test: add its signatures (a code block or an Interfaces: line) or a Test: line`,
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const DEPTH_SECTIONS = ["What already exists", "Contracts", "Decisions"] as const;
|
|
117
|
+
|
|
118
|
+
/** The text under a `## <name>` heading (case-insensitive prefix), up to the next `## ` heading; null when absent. */
|
|
119
|
+
function section(text: string, name: string): string | null {
|
|
120
|
+
const lines = text.split("\n");
|
|
121
|
+
const at = lines.findIndex((l) => new RegExp(`^##[ \\t]+${name}`, "i").test(l));
|
|
122
|
+
if (at < 0) return null;
|
|
123
|
+
const end = lines.findIndex((l, i) => i > at && /^##[ \t]+(?!#)/.test(l));
|
|
124
|
+
return lines.slice(at + 1, end < 0 ? undefined : end).join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DECISION = /^[ \t]*(?:[-*][ \t]*)?\**(D\d+)\b/;
|
|
128
|
+
|
|
129
|
+
/** What a plan the architect wrote lacks, to the depth the owner reviews: one line per gap. */
|
|
130
|
+
export function depthProblems(doc: PlanDoc): string[] {
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
const full = doc.text;
|
|
133
|
+
for (const s of DEPTH_SECTIONS) if (section(full, s) === null) out.push(`plan.md has no "## ${s}" section`);
|
|
134
|
+
if (doc.tasks.length === 0) out.push('plan.md has no "### Task <n>:" heading');
|
|
135
|
+
for (const t of doc.tasks)
|
|
136
|
+
if (!t.test) out.push(`plan.md Task ${t.id} has no test: add a Test: line naming its test file and case`);
|
|
137
|
+
for (const line of (section(full, "Decisions") ?? "").split("\n")) {
|
|
138
|
+
const id = DECISION.exec(line)?.[1];
|
|
139
|
+
if (id && !/rejected/i.test(line))
|
|
140
|
+
out.push(
|
|
141
|
+
`plan.md ${id} names no rejected alternative: write ${id} — <choice> — rejected: <alternative> — <why>`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const exists = section(full, "What already exists");
|
|
145
|
+
if (exists !== null && !/`[^`\n]*(?:\/|\.\w+)[^`\n]*`/.test(exists))
|
|
146
|
+
out.push("plan.md's What already exists lists no path");
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const APPROVE_WORDS = ["approve", "approved", "yes", "ok", "lgtm"] as const;
|
|
151
|
+
|
|
152
|
+
/** A bare approving word, case and a trailing `.`/`!` ignored; "ok but rename X" is a change request. */
|
|
153
|
+
export function isApproval(answer: string): boolean {
|
|
154
|
+
const w = answer
|
|
155
|
+
.trim()
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.replace(/[.!]+$/, "");
|
|
158
|
+
return (APPROVE_WORDS as readonly string[]).includes(w);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const approvalQuestion = (runId: string) =>
|
|
162
|
+
`The architect's plan is ready for your review: catherd run-file read ${runId} plan.md. Answer approve, or say what to change.`;
|
|
163
|
+
|
|
164
|
+
export const reapprovalQuestion = (runId: string, ids: string[]) =>
|
|
165
|
+
`The architect changed approved decisions ${ids.join(", ")} of plan.md: catherd run-file read ${runId} plan.md. Answer approve, or say what to change.`;
|
|
166
|
+
|
|
167
|
+
/** Each `D<n>` line of the Decisions section, keyed by its id, whitespace collapsed. */
|
|
168
|
+
export function decisionLines(text: string): Map<string, string> {
|
|
169
|
+
const out = new Map<string, string>();
|
|
170
|
+
for (const line of (section(text, "Decisions") ?? "").split("\n")) {
|
|
171
|
+
const id = DECISION.exec(line)?.[1];
|
|
172
|
+
if (id) out.set(id, line.trim().replace(/\s+/g, " "));
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The `D<n>` ids added, removed or edited between two plan texts, in number order. */
|
|
178
|
+
export function changedDecisions(approved: string, now: string): string[] {
|
|
179
|
+
const a = decisionLines(approved);
|
|
180
|
+
const b = decisionLines(now);
|
|
181
|
+
return [...new Set([...a.keys(), ...b.keys()])]
|
|
182
|
+
.filter((id) => a.get(id) !== b.get(id))
|
|
183
|
+
.sort((x, y) => Number(x.slice(1)) - Number(y.slice(1)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The copies' file names: basenames, a repeat numbered `-2` before its extension. */
|
|
187
|
+
export function planInputNames(paths: string[]): string[] {
|
|
188
|
+
const used = new Set<string>();
|
|
189
|
+
return paths.map((p) => {
|
|
190
|
+
const base = p.split("/").at(-1) as string;
|
|
191
|
+
const dot = base.lastIndexOf(".");
|
|
192
|
+
const [stem, ext] = dot > 0 ? [base.slice(0, dot), base.slice(dot)] : [base, ""];
|
|
193
|
+
let name = base;
|
|
194
|
+
for (let n = 2; used.has(name); n++) name = `${stem}-${n}${ext}`;
|
|
195
|
+
used.add(name);
|
|
196
|
+
return name;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type LaneHeader, parseLaneHeader } from "./lane.ts";
|
|
2
|
+
|
|
3
|
+
export interface PlanLint {
|
|
4
|
+
/** advice only: preflight reports it and goes on */
|
|
5
|
+
warnings: string[];
|
|
6
|
+
/** an `After:` that can never finish: the lane (or the cycle's first lane) never starts */
|
|
7
|
+
stuck: { lane: string; note: string }[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const milestoneOf = (id: string): string => id.split(".")[0] as string;
|
|
11
|
+
const isFolder = (p: string): boolean => p.endsWith("/");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lint of the lanes in scope. `known` is every lane id of the run (a bare milestone `Mx` is known when one of its lanes is), the landed milestones' too, so an `After:` on a
|
|
15
|
+
* lane that already landed is no error. `finished` is the milestones that landed (merged, in a base run): an `After:`
|
|
16
|
+
* on a lane of any other milestone can never finish, as that milestone does not start before this one is done. Never throws: a lane whose header cannot be read is left out.
|
|
17
|
+
*/
|
|
18
|
+
export function lintLanes(
|
|
19
|
+
lanes: { id: string; text: string }[],
|
|
20
|
+
known: string[],
|
|
21
|
+
finished: string[] = [],
|
|
22
|
+
): PlanLint {
|
|
23
|
+
const warnings: string[] = [];
|
|
24
|
+
const stuck: PlanLint["stuck"] = [];
|
|
25
|
+
const headers = new Map<string, LaneHeader>();
|
|
26
|
+
for (const l of lanes) {
|
|
27
|
+
try {
|
|
28
|
+
headers.set(l.id, parseLaneHeader(l.text));
|
|
29
|
+
} catch {
|
|
30
|
+
// a header that cannot be read is preflight's refusal to make, not lint's
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const inScope = lanes.filter((l) => headers.has(l.id));
|
|
34
|
+
const milestones = new Set(known.map(milestoneOf));
|
|
35
|
+
|
|
36
|
+
for (const { id } of inScope) {
|
|
37
|
+
const h = headers.get(id) as LaneHeader;
|
|
38
|
+
const folders = h.owns.filter(isFolder);
|
|
39
|
+
if (folders.length)
|
|
40
|
+
warnings.push(
|
|
41
|
+
`${id}: Owns names whole folders (${folders.join(", ")}): name the files, unless the lane creates the folder`,
|
|
42
|
+
);
|
|
43
|
+
for (const a of h.after) {
|
|
44
|
+
if (a === id) stuck.push({ lane: id, note: `${id}: After: names the lane itself` });
|
|
45
|
+
else if (!a.includes(".") && a === milestoneOf(id))
|
|
46
|
+
stuck.push({ lane: id, note: `${id}: After: ${a} is its own milestone: this lane never starts` });
|
|
47
|
+
else if (a.includes(".") ? !known.includes(a) : !milestones.has(a))
|
|
48
|
+
stuck.push({ lane: id, note: `${id}: After: ${a} has no lane file` });
|
|
49
|
+
else if (milestoneOf(a) !== milestoneOf(id) && !finished.includes(milestoneOf(a)))
|
|
50
|
+
stuck.push({
|
|
51
|
+
lane: id,
|
|
52
|
+
note: `${id}: After: ${a} is in milestone ${milestoneOf(a)}, which has not finished: this lane never starts`,
|
|
53
|
+
});
|
|
54
|
+
else {
|
|
55
|
+
const other = headers.get(a);
|
|
56
|
+
if (
|
|
57
|
+
other &&
|
|
58
|
+
h.kind !== "research" &&
|
|
59
|
+
other.kind !== "research" &&
|
|
60
|
+
h.owns.length &&
|
|
61
|
+
other.owns.length &&
|
|
62
|
+
!h.owns.some(isFolder) &&
|
|
63
|
+
!other.owns.some(isFolder) &&
|
|
64
|
+
!h.owns.some((p) => other.owns.includes(p))
|
|
65
|
+
)
|
|
66
|
+
warnings.push(
|
|
67
|
+
`${id}: After: ${a} shares no file with it: drop it unless ${id} needs ${a}'s output`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const edges = (id: string): string[] =>
|
|
74
|
+
(headers.get(id)?.after ?? []).filter((a) => a !== id && headers.has(a));
|
|
75
|
+
|
|
76
|
+
const state = new Map<string, "visiting" | "done">();
|
|
77
|
+
const path: string[] = [];
|
|
78
|
+
const visit = (id: string): void => {
|
|
79
|
+
state.set(id, "visiting");
|
|
80
|
+
path.push(id);
|
|
81
|
+
for (const a of edges(id)) {
|
|
82
|
+
if (state.get(a) === "visiting") {
|
|
83
|
+
const loop = [...path.slice(path.indexOf(a)), a];
|
|
84
|
+
stuck.push({ lane: a, note: `After: cycle ${loop.join(" → ")}: these lanes never start` });
|
|
85
|
+
} else if (!state.has(a)) visit(a);
|
|
86
|
+
}
|
|
87
|
+
path.pop();
|
|
88
|
+
state.set(id, "done");
|
|
89
|
+
};
|
|
90
|
+
for (const { id } of inScope) if (!state.has(id)) visit(id);
|
|
91
|
+
|
|
92
|
+
const depth = new Map<string, number>();
|
|
93
|
+
const depthOf = (id: string): number => {
|
|
94
|
+
const seen = depth.get(id);
|
|
95
|
+
if (seen !== undefined) return seen;
|
|
96
|
+
depth.set(id, 1);
|
|
97
|
+
const d =
|
|
98
|
+
1 +
|
|
99
|
+
Math.max(
|
|
100
|
+
0,
|
|
101
|
+
...edges(id)
|
|
102
|
+
.filter((a) => milestoneOf(a) === milestoneOf(id))
|
|
103
|
+
.map(depthOf),
|
|
104
|
+
);
|
|
105
|
+
depth.set(id, d);
|
|
106
|
+
return d;
|
|
107
|
+
};
|
|
108
|
+
const byMilestone = new Map<string, string[]>();
|
|
109
|
+
for (const { id } of inScope)
|
|
110
|
+
byMilestone.set(milestoneOf(id), [...(byMilestone.get(milestoneOf(id)) ?? []), id]);
|
|
111
|
+
for (const [m, ids] of byMilestone) {
|
|
112
|
+
if (ids.length < 2 || !ids.some((id) => edges(id).some((a) => milestoneOf(a) === m))) continue;
|
|
113
|
+
const k = Math.max(...ids.map(depthOf));
|
|
114
|
+
warnings.push(
|
|
115
|
+
`info: ${m} critical path ${k} of ${ids.length} lanes${k === ids.length ? ": fully serial" : ""}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { warnings, stuck };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What keeps the milestones from ever starting: an `after` naming no milestone of the plan, or a wait cycle. */
|
|
122
|
+
export function milestoneProblems(milestones: { id: string; after: string[] }[]): string[] {
|
|
123
|
+
const after = new Map(milestones.map((m) => [m.id, m.after]));
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
for (const m of milestones)
|
|
126
|
+
for (const a of m.after) if (!after.has(a)) out.push(`${m.id}: After: ${a} is no milestone of the plan`);
|
|
127
|
+
const done = new Set<string>();
|
|
128
|
+
const path: string[] = [];
|
|
129
|
+
const visit = (id: string): void => {
|
|
130
|
+
path.push(id);
|
|
131
|
+
for (const a of after.get(id) ?? []) {
|
|
132
|
+
if (path.includes(a))
|
|
133
|
+
out.push(`milestones wait on each other (cycle ${[...path.slice(path.indexOf(a)), a].join(" → ")})`);
|
|
134
|
+
else if (!done.has(a) && after.has(a)) visit(a);
|
|
135
|
+
}
|
|
136
|
+
path.pop();
|
|
137
|
+
done.add(id);
|
|
138
|
+
};
|
|
139
|
+
for (const m of milestones) if (!done.has(m.id)) visit(m.id);
|
|
140
|
+
return out;
|
|
141
|
+
}
|