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,176 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { JudgeDecision, RuleChange } from "../domain/judge.ts";
|
|
4
|
+
import { type Finding, issueLine, type RunEvent } from "../domain/run-events.ts";
|
|
5
|
+
import { findingKey, type RunState } from "../domain/run-state.ts";
|
|
6
|
+
import { writeTextAtomic } from "../infra/store.ts";
|
|
7
|
+
import { readChecks } from "./milestone-check.ts";
|
|
8
|
+
import { readRecords, type Run } from "./run-store.ts";
|
|
9
|
+
|
|
10
|
+
const read = (file: string): string => {
|
|
11
|
+
try {
|
|
12
|
+
return readFileSync(file, "utf8").trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What a judge decided, in one line: the verb's own payload. */
|
|
19
|
+
function said(d: JudgeDecision): string {
|
|
20
|
+
switch (d.verb) {
|
|
21
|
+
case "route":
|
|
22
|
+
return d.routes.map((r) => `${r.finding} -> ${r.to}`).join(", ");
|
|
23
|
+
case "answer":
|
|
24
|
+
return d.answer;
|
|
25
|
+
case "ask_owner":
|
|
26
|
+
case "park":
|
|
27
|
+
return d.question;
|
|
28
|
+
case "rules":
|
|
29
|
+
return `${d.changes.length} change(s) proposed`;
|
|
30
|
+
default:
|
|
31
|
+
return d.why;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const quote = (text: string): string => `'${text.replace(/'/g, "'\\''")}'`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `## Rules review` section (plan 34): what the finished run's judge proposed, each change followed by the
|
|
39
|
+
* command that applies it. The run applies none; null when the run had no review (cancelled, failed).
|
|
40
|
+
*/
|
|
41
|
+
function rulesSection(failed: boolean, proposals: RuleChange[] | undefined): string[] | null {
|
|
42
|
+
if (failed) return ["## Rules review", "", "No rules review: the judge gave no readable answer.", ""];
|
|
43
|
+
if (!proposals) return null;
|
|
44
|
+
if (proposals.length === 0)
|
|
45
|
+
return ["## Rules review", "", "The judge proposed no change to the standing rules.", ""];
|
|
46
|
+
return [
|
|
47
|
+
"## Rules review",
|
|
48
|
+
"",
|
|
49
|
+
"Standing rules to review: apply any of these with the command under it.",
|
|
50
|
+
...proposals.flatMap((c, i) => [
|
|
51
|
+
`${i + 1}. ${c.op === "add" ? "ADD" : "REMOVE"} ${c.text}${c.why ? ` — ${c.why}` : ""}`,
|
|
52
|
+
` \`catherd knowledge ${c.op === "add" ? "add" : "rm"} ${quote(c.text)}\``,
|
|
53
|
+
]),
|
|
54
|
+
"",
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The "fixed" part of a milestone's `Rounds:` line shows at most this many findings. */
|
|
59
|
+
export const FIXED_LINES = 15;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The findings of each review round of milestone `m`: a `commit.made` opens a round, and every ok reviewer end
|
|
63
|
+
* (`reviewer`, `ui-reviewer`, the partial pass `reviewer-<M>-2`) adds to it. A round with no reviewer end is left out.
|
|
64
|
+
*/
|
|
65
|
+
export function reviewRounds(events: RunEvent[], m: string): Finding[][] {
|
|
66
|
+
const reviewers = new Set<string>();
|
|
67
|
+
const rounds: Finding[][] = [];
|
|
68
|
+
let open: Finding[] | null = null;
|
|
69
|
+
for (const e of events) {
|
|
70
|
+
if (e.type === "dispatch.started") {
|
|
71
|
+
if ((e.role === "reviewer" || e.role === "ui-reviewer") && e.unit === m) reviewers.add(e.dispatchId);
|
|
72
|
+
} else if (e.type === "commit.made" && e.milestone === m) open = null;
|
|
73
|
+
else if (
|
|
74
|
+
e.type === "dispatch.ended" &&
|
|
75
|
+
reviewers.has(e.dispatchId) &&
|
|
76
|
+
e.status === "ok" &&
|
|
77
|
+
e.replyStatus !== "blocked" &&
|
|
78
|
+
e.replyStatus !== "refused"
|
|
79
|
+
) {
|
|
80
|
+
if (!open) rounds.push((open = []));
|
|
81
|
+
open.push(...e.findings);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return rounds;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `Rounds: 3 review round(s) · fixed: <the BLOCKER and BUG lines an earlier round named and the last did not repeat>`. */
|
|
88
|
+
export function roundsLine(events: RunEvent[], m: string): string {
|
|
89
|
+
const rounds = reviewRounds(events, m);
|
|
90
|
+
const last = new Set((rounds.at(-1) ?? []).map((f) => findingKey(issueLine(f))));
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
const fixed: string[] = [];
|
|
93
|
+
for (const f of rounds.slice(0, -1).flat()) {
|
|
94
|
+
const key = findingKey(issueLine(f));
|
|
95
|
+
if (f.label === "NIT" || last.has(key) || seen.has(key)) continue;
|
|
96
|
+
seen.add(key);
|
|
97
|
+
fixed.push(issueLine(f));
|
|
98
|
+
}
|
|
99
|
+
const head = `Rounds: ${rounds.length} review round(s)`;
|
|
100
|
+
return fixed.length ? `${head} · fixed: ${fixed.slice(0, FIXED_LINES).join(" · ")}` : head;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Per command, the latest check record of this run and milestone that is no baseline; "Gate: not recorded" when none. */
|
|
104
|
+
export function gateLines(run: Run, m: string): string[] {
|
|
105
|
+
const latest = new Map<string, ReturnType<typeof readChecks>[number]>();
|
|
106
|
+
try {
|
|
107
|
+
for (const r of readChecks(run.meta.repo))
|
|
108
|
+
if (r.run === run.id && r.milestone === m && !r.baseline) latest.set(r.command, r);
|
|
109
|
+
} catch {
|
|
110
|
+
// a check store that cannot be read records nothing
|
|
111
|
+
}
|
|
112
|
+
return latest.size
|
|
113
|
+
? [...latest.values()].map(
|
|
114
|
+
(r) =>
|
|
115
|
+
`Gate: \`${r.command}\` exit ${r.exit} on ${r.head.slice(0, 12)} · ${Math.round(r.durationMs / 1000)} s · secret scan: ${r.secrets ?? "not run"}`,
|
|
116
|
+
)
|
|
117
|
+
: ["Gate: not recorded"];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const oneLine = (s: string): string => s.replace(/\s*\n\s*/g, " ").trim();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `<run>/report.md`, the body of the merge request: the run's title, each landed milestone's digest, the known
|
|
124
|
+
* issues verbatim by milestone, and the audit: every judge decision, and what each milestone's check passed on
|
|
125
|
+
* only through the base or a rerun (`audits`). Returns its path.
|
|
126
|
+
*/
|
|
127
|
+
export function writeRunReport(
|
|
128
|
+
run: Run,
|
|
129
|
+
s: RunState,
|
|
130
|
+
events: RunEvent[],
|
|
131
|
+
audits: Record<string, string[]> = {},
|
|
132
|
+
proposals?: RuleChange[],
|
|
133
|
+
): string {
|
|
134
|
+
const landed = events.filter((e) => e.type === "milestone.landed");
|
|
135
|
+
const decided = events.filter((e) => e.type === "judge.decided");
|
|
136
|
+
const lines = [`# ${s.title || run.meta.title}`, ""];
|
|
137
|
+
for (const e of landed) {
|
|
138
|
+
const title = s.milestones.find((m) => m.id === e.milestone)?.title;
|
|
139
|
+
const digest = read(e.digestPath.startsWith("/") ? e.digestPath : join(run.dir, e.digestPath));
|
|
140
|
+
lines.push(
|
|
141
|
+
`## ${e.milestone}${title && title !== e.milestone ? `: ${title}` : ""}`,
|
|
142
|
+
"",
|
|
143
|
+
digest || `Landed at ${e.commit.slice(0, 12)}.`,
|
|
144
|
+
"",
|
|
145
|
+
roundsLine(events, e.milestone),
|
|
146
|
+
...gateLines(run, e.milestone),
|
|
147
|
+
"",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (landed.length === 0) lines.push("No milestone landed.", "");
|
|
151
|
+
lines.push("## Known issues", "");
|
|
152
|
+
const issues = landed.flatMap((e) => e.knownIssues.map((k) => `- ${e.milestone}: ${k}`));
|
|
153
|
+
lines.push(...(issues.length ? issues : ["None."]), "");
|
|
154
|
+
// plan 34: the latest lint of each milestone's preflight
|
|
155
|
+
const lint = [
|
|
156
|
+
...new Set(events.flatMap((e) => (e.type === "preflight.done" ? [e.milestone] : []))),
|
|
157
|
+
].flatMap((m) => {
|
|
158
|
+
const latest = events.findLast((e) => e.type === "preflight.done" && e.milestone === m);
|
|
159
|
+
return latest?.type === "preflight.done" ? latest.lint.map((l) => `- ${m}: ${l}`) : [];
|
|
160
|
+
});
|
|
161
|
+
if (lint.length) lines.push("## Plan lint", "", ...lint, "");
|
|
162
|
+
lines.push("## Audit", "");
|
|
163
|
+
const audit = [
|
|
164
|
+
...decided.map((e) => `- ${e.unit} (${e.kind}): ${e.decision.verb}: ${oneLine(said(e.decision))}`),
|
|
165
|
+
...Object.entries(audits).flatMap(([m, lines]) => lines.map((l) => `- ${m} (check): ${oneLine(l)}`)),
|
|
166
|
+
// a worker's conflict with its plan task, in record order
|
|
167
|
+
...readRecords(run).records.flatMap((r) =>
|
|
168
|
+
(r.design ?? []).map((d) => `- ${r.name} DESIGN: ${oneLine(d)}`),
|
|
169
|
+
),
|
|
170
|
+
];
|
|
171
|
+
lines.push(...(audit.length ? audit : ["No judge decision."]), "");
|
|
172
|
+
lines.push(...(rulesSection(s.rulesReview?.failed === true, proposals) ?? []));
|
|
173
|
+
const file = join(run.dir, "report.md");
|
|
174
|
+
writeTextAtomic(file, lines.join("\n"));
|
|
175
|
+
return file;
|
|
176
|
+
}
|
|
@@ -1,38 +1,118 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { relative } from "node:path";
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
3
3
|
import { CatherdError } from "../domain/errors.ts";
|
|
4
4
|
import { assertId, parseRung } from "../domain/ids.ts";
|
|
5
|
+
import { assertLaneValues } from "../domain/lane.ts";
|
|
5
6
|
import type { RunRecord } from "../domain/record.ts";
|
|
6
|
-
import type
|
|
7
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
8
|
+
import { rulesFor, tagNames } from "../domain/rules.ts";
|
|
7
9
|
import { awaitsCollect, dispatchPaths, endCollect, tryCollect } from "../infra/dispatch-dir.ts";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
11
|
+
import { git, gitToplevel } from "../infra/git.ts";
|
|
12
|
+
import { writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
10
13
|
import {
|
|
11
14
|
dispatchState,
|
|
12
15
|
type DispatchState,
|
|
13
16
|
latestDispatch,
|
|
14
17
|
listDispatches,
|
|
18
|
+
liveDispatches,
|
|
15
19
|
recordHints,
|
|
16
20
|
} from "./dispatches.ts";
|
|
17
21
|
import type { Deps } from "./ports.ts";
|
|
18
22
|
import { assertNativeHost } from "./backends.ts";
|
|
23
|
+
import { writeRunIndex } from "./catalog-service.ts";
|
|
19
24
|
import {
|
|
20
25
|
type AgentRun,
|
|
21
26
|
appendAgentRun,
|
|
22
27
|
appendKnowledge,
|
|
23
28
|
createRun,
|
|
24
29
|
findRun,
|
|
25
|
-
|
|
30
|
+
knowledgeFor,
|
|
26
31
|
readRecords,
|
|
32
|
+
removeKnowledgeLine,
|
|
27
33
|
runFile,
|
|
34
|
+
type Run,
|
|
35
|
+
runPaths,
|
|
36
|
+
supersededBy,
|
|
37
|
+
supersededFile,
|
|
28
38
|
} from "./run-store.ts";
|
|
29
|
-
import { protocolView } from "./protocol.ts";
|
|
39
|
+
import { protocolNext, protocolView } from "./protocol.ts";
|
|
40
|
+
import { writePin } from "./run-pin.ts";
|
|
30
41
|
import { claimRun, currentSession } from "./sessions.ts";
|
|
31
|
-
import { refreshState } from "./state.ts";
|
|
42
|
+
import { readNotes, refreshState } from "./state.ts";
|
|
43
|
+
|
|
44
|
+
/** The run `id` was superseded by, or undefined: an open run, or one that cannot be found. */
|
|
45
|
+
function nextInChain(id: string): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
return supersededBy(findRun(id))?.by;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `runs supersede <run> --by <run>`: closes `run` with a pointer to the run that took it over (a planning
|
|
55
|
+
* run handed to the execution run in a worktree). Status hides it; dispatch refuses it. A run with live
|
|
56
|
+
* roles is refused: cancel them, or let them finish, first.
|
|
57
|
+
*/
|
|
58
|
+
export async function supersedeRun(
|
|
59
|
+
deps: Deps,
|
|
60
|
+
i: { run: string; by: string },
|
|
61
|
+
): Promise<{ run: string; by: string; at: string; hints?: string[] }> {
|
|
62
|
+
const run = findRun(i.run);
|
|
63
|
+
const by = findRun(i.by);
|
|
64
|
+
if (run.id === by.id)
|
|
65
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${run.id} cannot supersede itself`, {
|
|
66
|
+
fix: "pass the run that took over as --by",
|
|
67
|
+
});
|
|
68
|
+
// walk the chain the new pointer would join: any run it reaches back to `run` closes a cycle
|
|
69
|
+
const seen = new Set<string>([by.id]);
|
|
70
|
+
for (let hop = supersededBy(by)?.by; hop !== undefined && !seen.has(hop);) {
|
|
71
|
+
if (hop === run.id)
|
|
72
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${by.id} is itself superseded by ${run.id}`, {
|
|
73
|
+
fix: "supersede the older run by the newer one",
|
|
74
|
+
});
|
|
75
|
+
seen.add(hop);
|
|
76
|
+
hop = nextInChain(hop);
|
|
77
|
+
}
|
|
78
|
+
// under the run's admission lock: a dispatch admitted meanwhile is either live here, or sees the pointer
|
|
79
|
+
const at = await withFileLock(runPaths(run.dir).admission, () => {
|
|
80
|
+
assertNoLiveRoles(
|
|
81
|
+
deps,
|
|
82
|
+
run,
|
|
83
|
+
`cancel them (cancel(run, name)) or let them finish, then supersede ${run.id}`,
|
|
84
|
+
);
|
|
85
|
+
return writeSuperseded(deps, run, by.id);
|
|
86
|
+
});
|
|
87
|
+
return { run: run.id, by: by.id, at, ...(await supersededHints(run, by.id)) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Held under `run`'s admission lock: no role of it may be live when it closes. */
|
|
91
|
+
function assertNoLiveRoles(deps: Deps, run: Run, fix: string): void {
|
|
92
|
+
const live = liveDispatches(run, deps.now());
|
|
93
|
+
if (live.length)
|
|
94
|
+
throw new CatherdError(
|
|
95
|
+
"E_INPUT_INVALID",
|
|
96
|
+
`run ${run.id} still has live roles: ${live.map((d) => d.admit.name).join(", ")}`,
|
|
97
|
+
{ fix },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Held under `run`'s admission lock: the pointer to the run that took it over; its time. */
|
|
102
|
+
function writeSuperseded(deps: Deps, run: Run, by: string): string {
|
|
103
|
+
const when = new Date(deps.now()).toISOString();
|
|
104
|
+
writeJsonAtomic(supersededFile(run), { schema: 1, by, at: when });
|
|
105
|
+
return when;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function supersededHints(run: Run, by: string): Promise<{ hints?: string[] }> {
|
|
109
|
+
const { hints } = await refreshState(run, { next: `superseded by ${by}: continue there` });
|
|
110
|
+
return hints.length ? { hints } : {};
|
|
111
|
+
}
|
|
32
112
|
|
|
33
113
|
export async function startRun(
|
|
34
114
|
deps: Deps,
|
|
35
|
-
i: { repo: string; title: string; aLines: string[] },
|
|
115
|
+
i: { repo: string; title: string; aLines: string[]; from?: string; base?: string },
|
|
36
116
|
): Promise<{
|
|
37
117
|
run: string;
|
|
38
118
|
dir: string;
|
|
@@ -44,17 +124,53 @@ export async function startRun(
|
|
|
44
124
|
throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
|
|
45
125
|
fix: "pass the path of the repository to work in",
|
|
46
126
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
127
|
+
// a run starts only when every enabled role has an indexed rung; the index it checked is the one it pins
|
|
128
|
+
const start = await deps.routing.checkStart(deps.profiles.forRepo(top), top);
|
|
129
|
+
if (start.unusable.length > 0) {
|
|
130
|
+
const roles = start.unusable.join(", ");
|
|
131
|
+
throw new CatherdError(
|
|
132
|
+
"E_CONFIG_INVALID",
|
|
133
|
+
`the ${roles} role${start.unusable.length === 1 ? " has" : "s have"} no usable rung on the Slopalytics index`,
|
|
134
|
+
{
|
|
135
|
+
fix: `catherd profile validate, then pick indexed pairs: catherd catalog list --role ${start.unusable[0]}`,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// where the first milestone's commit range starts; a repo with no commit yet has none
|
|
140
|
+
const head = await git(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
141
|
+
const open = async (): Promise<Run> => {
|
|
142
|
+
const run = createRun({
|
|
143
|
+
repo: top,
|
|
144
|
+
title: i.title,
|
|
145
|
+
aLines: i.aLines,
|
|
146
|
+
version: deps.version,
|
|
147
|
+
startHead: head.kind === "ok" ? head.out.trim() || null : null,
|
|
148
|
+
...(i.base ? { base: i.base } : {}),
|
|
149
|
+
now: new Date(deps.now()),
|
|
150
|
+
startedBy: currentSession(deps),
|
|
151
|
+
});
|
|
152
|
+
// spec 1.5 "Pinned per run": what the run starts on stays what it dispatches on
|
|
153
|
+
writePin(deps, run);
|
|
154
|
+
writeRunIndex(run.dir, start.index);
|
|
155
|
+
await claimRun(deps, run);
|
|
156
|
+
return run;
|
|
157
|
+
};
|
|
158
|
+
// the run this one takes over must be free to close before anything is created, and stay so until its pointer
|
|
159
|
+
// is written: its admission lock is held from the check to the pointer, so no dispatch gets in between and
|
|
160
|
+
// leaves the new run open beside it (PR #50). It is the only lock taken first: the new run's are uncontended.
|
|
161
|
+
const from = i.from === undefined ? null : findRun(i.from);
|
|
162
|
+
const run = from
|
|
163
|
+
? await withFileLock(runPaths(from.dir).admission, async () => {
|
|
164
|
+
assertNoLiveRoles(deps, from, `cancel them or let them finish, then run_start with from: ${from.id}`);
|
|
165
|
+
const opened = await open();
|
|
166
|
+
writeSuperseded(deps, from, opened.id);
|
|
167
|
+
return opened;
|
|
168
|
+
})
|
|
169
|
+
: await open();
|
|
170
|
+
const superseded = from ? await supersededHints(from, run.id) : {};
|
|
56
171
|
// a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
|
|
57
172
|
const { hints } = await refreshState(run);
|
|
173
|
+
hints.push(...(superseded.hints ?? []));
|
|
58
174
|
// spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
|
|
59
175
|
return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
|
|
60
176
|
}
|
|
@@ -63,7 +179,22 @@ export function writeRunFile(i: { run: string; path: string; content: string }):
|
|
|
63
179
|
path: string;
|
|
64
180
|
bytes: number;
|
|
65
181
|
} {
|
|
66
|
-
const
|
|
182
|
+
const run = findRun(i.run);
|
|
183
|
+
const file = runFile(run, i.path, "write");
|
|
184
|
+
const rel = relative(run.dir, file).split(sep).join("/");
|
|
185
|
+
// macOS disks ignore case, so Plan-Input/ is the same folder
|
|
186
|
+
const low = rel.toLowerCase();
|
|
187
|
+
if (low === "plan-approved.md" || low === "plan-input" || low.startsWith("plan-input/"))
|
|
188
|
+
throw new CatherdError(
|
|
189
|
+
"E_INPUT_INVALID",
|
|
190
|
+
`${rel} holds the owner's approved plan; roles never write it`,
|
|
191
|
+
{
|
|
192
|
+
fix: "write plan.md or a lane file",
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
// spec 1.5 "Lane editing": a lane's header values are checked when it is written, not first at preflight
|
|
196
|
+
const lane = /^lanes\/([^/]+)\.md$/.exec(rel);
|
|
197
|
+
if (lane) assertLaneValues(i.content, `lanes/${lane[1]}.md`);
|
|
67
198
|
writeTextAtomic(file, i.content);
|
|
68
199
|
return { path: file, bytes: Buffer.byteLength(i.content) };
|
|
69
200
|
}
|
|
@@ -99,6 +230,28 @@ function capReply(reply: string, path: string): string {
|
|
|
99
230
|
return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
|
|
100
231
|
}
|
|
101
232
|
|
|
233
|
+
/** Whether `text` names `token` (a role name or lane id) as a whole word, a full stop after it allowed. */
|
|
234
|
+
export function names(text: string, token: string): boolean {
|
|
235
|
+
const t = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
236
|
+
return new RegExp(`(?<![\\w.-])${t}(?![\\w-]|\\.\\w)`).test(text);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Plan 22: state.md's Next never outlives the step it names. When it names a role whose record `result` returns
|
|
241
|
+
* (by its name or its lane), it moves on to the protocol's next step. A refresh that fails comes back as hints.
|
|
242
|
+
*/
|
|
243
|
+
async function advanceNext(run: Run, record: RunRecord): Promise<string[]> {
|
|
244
|
+
const stale = (next: string) =>
|
|
245
|
+
names(next, record.name) || (record.lane !== null && names(next, record.lane));
|
|
246
|
+
if (!stale(readNotes(run).next)) return [];
|
|
247
|
+
const { hints } = await refreshState(run, (n) =>
|
|
248
|
+
stale(n.next)
|
|
249
|
+
? { next: `after ${record.name} (${record.status}): ${protocolNext(run, n.parked ?? [])}` }
|
|
250
|
+
: {},
|
|
251
|
+
);
|
|
252
|
+
return hints;
|
|
253
|
+
}
|
|
254
|
+
|
|
102
255
|
/**
|
|
103
256
|
* A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
|
|
104
257
|
* finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
|
|
@@ -134,6 +287,7 @@ export async function result(
|
|
|
134
287
|
if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
|
|
135
288
|
}
|
|
136
289
|
if (record) for (const h of recordHints(run, d, record)) if (!hints.includes(h)) hints.push(h);
|
|
290
|
+
if (record) for (const h of await advanceNext(run, record)) if (!hints.includes(h)) hints.push(h);
|
|
137
291
|
const reply = dispatchPaths(d.dir).reply;
|
|
138
292
|
const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
|
|
139
293
|
return {
|
|
@@ -159,6 +313,10 @@ export function recordAgentRun(
|
|
|
159
313
|
durationMs?: number;
|
|
160
314
|
status?: AgentRun["status"];
|
|
161
315
|
lane?: string;
|
|
316
|
+
/** plan 23: a native verifier's first reply line, so a VERDICT: BLOCKED: environment is told from a FAIL */
|
|
317
|
+
verdict?: string;
|
|
318
|
+
/** plan 23: the STATUS word of the subagent's reply */
|
|
319
|
+
replyStatus?: NonNullable<AgentRun["replyStatus"]>;
|
|
162
320
|
},
|
|
163
321
|
): AgentRun {
|
|
164
322
|
const run = findRun(i.run);
|
|
@@ -180,6 +338,8 @@ export function recordAgentRun(
|
|
|
180
338
|
secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
|
|
181
339
|
status: i.status ?? "ok",
|
|
182
340
|
lane: i.lane ?? null,
|
|
341
|
+
...(i.verdict ? { verdict: i.verdict.trim().split("\n")[0] } : {}),
|
|
342
|
+
...(i.replyStatus ? { replyStatus: i.replyStatus } : {}),
|
|
183
343
|
};
|
|
184
344
|
appendAgentRun(run, row);
|
|
185
345
|
return row;
|
|
@@ -197,7 +357,7 @@ async function repoTop(repo: string): Promise<string> {
|
|
|
197
357
|
|
|
198
358
|
/** What past runs of the repo learned; `repo` may be any path inside it. */
|
|
199
359
|
export async function readKnowledge(repo: string): Promise<string> {
|
|
200
|
-
const file =
|
|
360
|
+
const file = await knowledgeFor(await repoTop(repo));
|
|
201
361
|
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
202
362
|
return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
|
|
203
363
|
}
|
|
@@ -205,7 +365,7 @@ export async function readKnowledge(repo: string): Promise<string> {
|
|
|
205
365
|
/** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
|
|
206
366
|
export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
|
|
207
367
|
const top = await repoTop(repo);
|
|
208
|
-
return { repo: top, path:
|
|
368
|
+
return { repo: top, path: await knowledgeFor(top) };
|
|
209
369
|
}
|
|
210
370
|
|
|
211
371
|
/** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */
|
|
@@ -218,12 +378,33 @@ export async function knowledgeLines(repo: string): Promise<{ repo: string; path
|
|
|
218
378
|
/** The source a line added by hand carries, where a landing puts its run's title and milestone. */
|
|
219
379
|
const BY_HAND = "by hand";
|
|
220
380
|
|
|
221
|
-
/**
|
|
222
|
-
export async function
|
|
381
|
+
/** The repo's standing rules for `role`: its knowledge.md as `rulesFor` gives it, "" when missing or blank. */
|
|
382
|
+
export async function standingRules(repo: string, role: Role): Promise<string> {
|
|
383
|
+
const { lines } = await knowledgeLines(repo);
|
|
384
|
+
return rulesFor(lines.join("\n"), role);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Appends one line to the repo's knowledge.md (a hand-written one by default), as `land`'s `learned` does; returns it. */
|
|
388
|
+
export async function addKnowledge(
|
|
389
|
+
repo: string,
|
|
390
|
+
text: string,
|
|
391
|
+
now: Date = new Date(),
|
|
392
|
+
source = BY_HAND,
|
|
393
|
+
): Promise<string> {
|
|
223
394
|
const top = await repoTop(repo);
|
|
224
395
|
if (!text.trim())
|
|
225
396
|
throw new CatherdError("E_INPUT_INVALID", "the knowledge line is empty", {
|
|
226
397
|
fix: 'pass one line of text, e.g. catherd knowledge add "the targeted test is bun test <file>"',
|
|
227
398
|
});
|
|
228
|
-
|
|
399
|
+
const bad = (tagNames(text) ?? []).find((n) => !(ROLES as readonly string[]).includes(n));
|
|
400
|
+
if (bad !== undefined)
|
|
401
|
+
throw new CatherdError("E_INPUT_INVALID", `"${bad}" is not a role: tag a rule with ${ROLES.join(", ")}`, {
|
|
402
|
+
fix: 'catherd knowledge add "[worker, reviewer] <rule>"',
|
|
403
|
+
});
|
|
404
|
+
return appendKnowledge(top, now, source, text);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Removes the one line of the repo's knowledge.md that contains `match`; returns it. */
|
|
408
|
+
export async function removeKnowledge(repo: string, match: string): Promise<string> {
|
|
409
|
+
return removeKnowledgeLine(await repoTop(repo), match);
|
|
229
410
|
}
|