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
package/src/domain/lane.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CatherdError } from "./errors.ts";
|
|
2
|
+
import { TASK_REF } from "./plan-doc.ts";
|
|
2
3
|
|
|
3
4
|
export const KINDS = ["repo_code", "terminal", "ui", "prose", "research"] as const;
|
|
4
5
|
export type Kind = (typeof KINDS)[number];
|
|
@@ -11,18 +12,49 @@ export interface LaneHeader {
|
|
|
11
12
|
fastCheck: string | null;
|
|
12
13
|
kind: Kind | null;
|
|
13
14
|
difficulty: Difficulty | null;
|
|
15
|
+
/** `After: M1.L1, M1.L2`: lanes that must finish first (protocol.next and admission keep the order) */
|
|
16
|
+
after: string[];
|
|
17
|
+
/** `Allow: path[:line], …` under the check: hits of the lane's absence check that are allowed exceptions */
|
|
18
|
+
allow: string[];
|
|
19
|
+
/** `Tasks: 3, plan.md#4`: the plan tasks the lane implements (catherd inlines them into its worker's brief) */
|
|
20
|
+
tasks: string[];
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
/** An After: entry: a lane id (Mx.Ly, the shape lanes/<id>.md takes) or a whole milestone (Mx). */
|
|
24
|
+
const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
25
|
+
|
|
26
|
+
/** An Allow: entry: a repo-relative path, optionally `:<line>`. */
|
|
27
|
+
const ALLOW_ENTRY = /^([^\s:]+)(?::(\d+))?$/;
|
|
28
|
+
|
|
16
29
|
const unquote = (s: string) =>
|
|
17
30
|
s
|
|
18
31
|
.trim()
|
|
19
32
|
.replace(/^`+|`+$/g, "")
|
|
20
33
|
.trim();
|
|
21
34
|
|
|
22
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The lane's header block, as [start, end) line indexes: the lines under the `# ` title (blank lines right
|
|
37
|
+
* after it skipped) up to the first blank line or heading. Header lines are read only here, so a body's prose
|
|
38
|
+
* (`After: the handler returns 404`) is never a header value.
|
|
39
|
+
*/
|
|
40
|
+
export function headerBlock(lines: string[]): [number, number] {
|
|
41
|
+
const title = lines.findIndex((l) => /^#\s+/.test(l));
|
|
42
|
+
let start = title + 1;
|
|
43
|
+
while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
|
|
44
|
+
let end = start;
|
|
45
|
+
while (end < lines.length && (lines[end] ?? "").trim() !== "" && !(lines[end] ?? "").startsWith("#")) end++;
|
|
46
|
+
return [start, end];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A header line `Label:`, allowing `**Label:**` and `_Label_:` emphasis; group 1 is its value. */
|
|
50
|
+
export const headerLine = (label: string) => new RegExp(`^\\s*[*_]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, "i");
|
|
51
|
+
|
|
52
|
+
/** The value of the first `Label:` line of the header block. */
|
|
23
53
|
function field(text: string, label: string): string | null {
|
|
24
|
-
const re =
|
|
25
|
-
|
|
54
|
+
const re = headerLine(label);
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
const [start, end] = headerBlock(lines);
|
|
57
|
+
for (const line of lines.slice(start, end)) {
|
|
26
58
|
const m = re.exec(line);
|
|
27
59
|
if (m) return (m[1] ?? "").trim();
|
|
28
60
|
}
|
|
@@ -51,15 +83,97 @@ export function parseLaneHeader(text: string): LaneHeader {
|
|
|
51
83
|
const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? null;
|
|
52
84
|
const owns = (field(text, "owns") ?? "").split(",").map(unquote).filter(Boolean).map(normalizeOwned);
|
|
53
85
|
const check = field(text, "fast check");
|
|
86
|
+
const list = (label: string) => (field(text, label) ?? "").split(",").map(unquote).filter(Boolean);
|
|
54
87
|
return {
|
|
55
88
|
title,
|
|
56
89
|
owns,
|
|
57
90
|
fastCheck: check ? unquote(check) || null : null,
|
|
58
91
|
kind: oneOf(field(text, "kind"), KINDS),
|
|
59
92
|
difficulty: oneOf(field(text, "difficulty"), DIFFICULTIES),
|
|
93
|
+
after: list("after"),
|
|
94
|
+
allow: list("allow"),
|
|
95
|
+
tasks: list("tasks"),
|
|
60
96
|
};
|
|
61
97
|
}
|
|
62
98
|
|
|
99
|
+
/** The milestones, other than `m`, that `m`'s lanes wait on (After: entries, lane or milestone), in numeric order. */
|
|
100
|
+
export function milestoneAfter(lanes: { id: string; after: string[] }[], m: string): string[] {
|
|
101
|
+
const out = new Set<string>();
|
|
102
|
+
for (const l of lanes)
|
|
103
|
+
if (l.id.split(".")[0] === m)
|
|
104
|
+
for (const a of l.after) {
|
|
105
|
+
const dep = a.split(".")[0] as string;
|
|
106
|
+
if (dep !== m) out.add(dep);
|
|
107
|
+
}
|
|
108
|
+
return [...out].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What is wrong with the After: and Allow: entries, one line each; none when they are well formed. */
|
|
112
|
+
function orderProblems(h: LaneHeader): string[] {
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
for (const a of h.after)
|
|
115
|
+
if (!LANE_ID.test(a)) out.push(`After "${a}" is not a lane or milestone id like M1.L1 or M1`);
|
|
116
|
+
for (const t of h.tasks)
|
|
117
|
+
if (!TASK_REF.test(t)) out.push(`Tasks "${t}" is not a task number like 3 or plan.md#3`);
|
|
118
|
+
for (const a of h.allow) {
|
|
119
|
+
const m = ALLOW_ENTRY.exec(a);
|
|
120
|
+
try {
|
|
121
|
+
if (!m) throw new Error();
|
|
122
|
+
normalizeOwned(m[1] as string);
|
|
123
|
+
} catch {
|
|
124
|
+
out.push(`Allow "${a}" is not a repo path, or path:line`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Spec 1.5 "Lane editing": `write_run_file` refuses a lane whose header values are present but wrong (a Kind
|
|
132
|
+
* or Difficulty the catalog does not know, an Owns path that leaves the repo, a malformed After: or Allow:),
|
|
133
|
+
* so a typo is caught when the lane is written, not at preflight. A missing line is left to routing.
|
|
134
|
+
*/
|
|
135
|
+
export function assertLaneValues(text: string, where: string): LaneHeader {
|
|
136
|
+
let h: LaneHeader;
|
|
137
|
+
try {
|
|
138
|
+
h = parseLaneHeader(text);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new CatherdError("E_LANE_INVALID", `${where}: ${(e as Error).message}`, {
|
|
141
|
+
fix: "write Owns: paths like src/foo/ or src/bar.ts",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const problems: string[] = [];
|
|
145
|
+
const kind = field(text, "kind");
|
|
146
|
+
const difficulty = field(text, "difficulty");
|
|
147
|
+
if (kind !== null && h.kind === null) problems.push(`Kind "${unquote(kind)}" is not one the catalog knows`);
|
|
148
|
+
if (difficulty !== null && h.difficulty === null)
|
|
149
|
+
problems.push(`Difficulty "${unquote(difficulty)}" is not one the catalog knows`);
|
|
150
|
+
problems.push(...orderProblems(h));
|
|
151
|
+
if (problems.length)
|
|
152
|
+
throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, {
|
|
153
|
+
fix: `${LANE_HEADER_FIX}; After: M1.L1, M1.L2; Allow: path or path:line`,
|
|
154
|
+
});
|
|
155
|
+
return h;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether every line a failing check printed is a `path:line` hit its lane allows: an absence check (a grep
|
|
160
|
+
* that must find nothing) whose only hits are the lane's declared exceptions has passed.
|
|
161
|
+
*/
|
|
162
|
+
export function onlyAllowedHits(lines: string[], allow: string[]): boolean {
|
|
163
|
+
const hits = lines.map((l) => /^([^\s:]+):(\d+)(?::|$)/.exec(l.trim()));
|
|
164
|
+
if (allow.length === 0 || hits.length === 0 || hits.some((m) => !m)) return false;
|
|
165
|
+
return hits.every((m) =>
|
|
166
|
+
allow.some((entry) => {
|
|
167
|
+
const [, path, line] = ALLOW_ENTRY.exec(entry) ?? [];
|
|
168
|
+
if (!path) return false;
|
|
169
|
+
const file = (m as RegExpExecArray)[1] as string;
|
|
170
|
+
const at = (m as RegExpExecArray)[2];
|
|
171
|
+
const inPath = file === path || (path.endsWith("/") && file.startsWith(path));
|
|
172
|
+
return inPath && (line === undefined || line === at);
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
63
177
|
/** The fix every lane-header refusal carries: the values the catalog knows. */
|
|
64
178
|
export const LANE_HEADER_FIX = `write the lane's header lines as Kind: ${KINDS.join("|")} and Difficulty: ${DIFFICULTIES.join("|")}`;
|
|
65
179
|
|
|
@@ -78,6 +192,7 @@ export function assertLaneHeader(text: string, where: string): LaneHeader {
|
|
|
78
192
|
};
|
|
79
193
|
check("Kind", field(text, "kind"), h.kind !== null);
|
|
80
194
|
check("Difficulty", field(text, "difficulty"), h.difficulty !== null);
|
|
195
|
+
problems.push(...orderProblems(h));
|
|
81
196
|
if (problems.length)
|
|
82
197
|
throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, { fix: LANE_HEADER_FIX });
|
|
83
198
|
return h;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan 35 D6: how many milestones of a base run may work side by side. A new one starts when none is working,
|
|
3
|
+
* else when the memory the machine has free, less a reserve and less what the ramping milestones are still to take,
|
|
4
|
+
* stays above a floor. The three megabyte numbers are the run profile's `limits.memory`.
|
|
5
|
+
*/
|
|
6
|
+
export const MEMORY_LIMITS = {
|
|
7
|
+
reserveMb: 2048,
|
|
8
|
+
perMilestoneMb: 3072,
|
|
9
|
+
floorMb: 3072,
|
|
10
|
+
/** a milestone counts at `perMilestoneMb` for this long after its tree is ready: its lanes have not reached their memory yet */
|
|
11
|
+
rampMs: 300_000,
|
|
12
|
+
recheckMs: 120_000,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type MemoryLimits = {
|
|
16
|
+
reserveMb: number;
|
|
17
|
+
perMilestoneMb: number;
|
|
18
|
+
floorMb: number;
|
|
19
|
+
rampMs: number;
|
|
20
|
+
recheckMs: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The profile's own memory limits over the defaults. */
|
|
24
|
+
export function memoryLimitsOf(p: {
|
|
25
|
+
limits?: { memory?: Partial<Pick<MemoryLimits, "reserveMb" | "perMilestoneMb" | "floorMb">> };
|
|
26
|
+
}): MemoryLimits {
|
|
27
|
+
return { ...MEMORY_LIMITS, ...p.limits?.memory };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Milestones working (a tree, not landed, merged or parked) and those of them still ramping up. */
|
|
31
|
+
export function memoryLoad(
|
|
32
|
+
ms: { tree: string | null; status: string; treeAt: string | null }[],
|
|
33
|
+
nowMs: number,
|
|
34
|
+
l: MemoryLimits = MEMORY_LIMITS,
|
|
35
|
+
): { working: number; ramping: number } {
|
|
36
|
+
const working = ms.filter((m) => m.tree !== null && !["landed", "merged", "parked"].includes(m.status));
|
|
37
|
+
return {
|
|
38
|
+
working: working.length,
|
|
39
|
+
ramping: working.filter((m) => m.treeAt !== null && Date.parse(m.treeAt) > nowMs - l.rampMs).length,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The memory a deferral waits for: the reserve, what the ramping milestones will take, and the floor. */
|
|
44
|
+
export const neededMb = (ramping: number, l: MemoryLimits = MEMORY_LIMITS): number =>
|
|
45
|
+
l.reserveMb + ramping * l.perMilestoneMb + l.floorMb;
|
|
46
|
+
|
|
47
|
+
/** Whether one more milestone may start: always when none is working, so a run always moves. */
|
|
48
|
+
export function mayStartMilestone(
|
|
49
|
+
i: { availableMb: number; working: number; ramping: number },
|
|
50
|
+
l: MemoryLimits = MEMORY_LIMITS,
|
|
51
|
+
): boolean {
|
|
52
|
+
return i.working === 0 || i.availableMb - l.reserveMb - i.ramping * l.perMilestoneMb >= l.floorMb;
|
|
53
|
+
}
|
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;
|
|
@@ -17,6 +20,8 @@ export interface Notice {
|
|
|
17
20
|
role: string;
|
|
18
21
|
/** the lane it worked, when it had one: the multi-role preview names it */
|
|
19
22
|
lane: string | null;
|
|
23
|
+
/** the role's thread, so a fix round resumes it without reading runs.jsonl; null before the CLI named one */
|
|
24
|
+
thread: string | null;
|
|
20
25
|
rung: string;
|
|
21
26
|
/** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
|
|
22
27
|
status: string;
|
|
@@ -34,15 +39,20 @@ export const REPLY_CAP = 2_000;
|
|
|
34
39
|
|
|
35
40
|
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
36
41
|
|
|
42
|
+
/** `thread: <id>`, or `thread: none` before the CLI named one: every message's first line carries it. */
|
|
43
|
+
const threadWord = (n: Notice): string => `thread: ${n.thread ?? "none"}`;
|
|
44
|
+
|
|
37
45
|
/** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
|
|
38
46
|
export function noticeHeader(n: Notice): string {
|
|
47
|
+
if (n.kind === "report") return `catherd · ${n.runTitle} · ${n.status} · ${n.name}`;
|
|
39
48
|
const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
|
|
40
|
-
if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
|
|
49
|
+
if (n.kind === "stalled") return `${head} · running ${n.secs}s · ${threadWord(n)}`;
|
|
41
50
|
return [
|
|
42
51
|
head,
|
|
43
52
|
n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
|
|
44
53
|
`${n.secs}s`,
|
|
45
54
|
plural(n.changedOwned, "owned file changed", "owned files changed"),
|
|
55
|
+
threadWord(n),
|
|
46
56
|
].join(" · ");
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -62,6 +72,15 @@ export function capNoticeReply(reply: string, cap = REPLY_CAP): string {
|
|
|
62
72
|
const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2 $3");
|
|
63
73
|
|
|
64
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");
|
|
65
84
|
const call = `run: "${n.runId}", name: "${n.name}"`;
|
|
66
85
|
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
|
|
67
86
|
const reply = capNoticeReply(n.reply);
|
|
@@ -74,11 +93,16 @@ function block(n: Notice): string {
|
|
|
74
93
|
function previewOf(ns: Notice[]): string {
|
|
75
94
|
const titles = [...new Set(ns.map((n) => n.runTitle))];
|
|
76
95
|
const where = titles.length === 1 ? titles[0] : plural(titles.length, "run", "runs");
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)})`,
|
|
82
106
|
);
|
|
83
107
|
return `catherd · ${where} · ${what}: ${each.join(", ")}`;
|
|
84
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
|
+
}
|