monkeyshape 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,21 +6,8 @@ import { parseJsonc } from "../core/coverage.ts";
6
6
  import { type Integration, isTsSource, type Snapshot } from "../core/model.ts";
7
7
  import type { ResolvedConfig } from "../schemas/config.ts";
8
8
  import { WorkspaceError } from "../schemas/errors.ts";
9
-
10
- const run = (root: string, command: ReadonlyArray<string>) =>
11
- Effect.tryPromise({
12
- try: async () => {
13
- const child = Bun.spawn([...command], { cwd: root, stdout: "pipe", stderr: "ignore" });
14
- const stdout = await new Response(child.stdout).text();
15
- return { stdout, code: await child.exited };
16
- },
17
- catch: (cause) =>
18
- new WorkspaceError({ operation: "run", path: command.join(" "), reason: String(cause) }),
19
- });
20
-
21
- /** For optional tools: a spawn failure is an exit code to report, not a reason to abort. */
22
- const runTool = (root: string, command: ReadonlyArray<string>) =>
23
- run(root, command).pipe(Effect.orElseSucceed(() => ({ stdout: "", code: -1 })));
9
+ import { run, runTool } from "./process.ts";
10
+ import { loadSkills } from "./skills.ts";
24
11
 
25
12
  export const readText = (root: string, path: string) =>
26
13
  Effect.tryPromise({
@@ -70,34 +57,57 @@ const jsonRows = (stdout: string): ReadonlyArray<unknown> | undefined => {
70
57
  return Array.isArray(rows) ? rows : undefined;
71
58
  };
72
59
 
60
+ /** Whether bd can be asked: absent without `.beads/config.yaml`, unavailable without bd. */
61
+ const beadsUnreachable = (root: string) =>
62
+ Effect.gen(function* () {
63
+ if (!(yield* exists(root, ".beads/config.yaml")))
64
+ return { state: "absent" } satisfies Integration;
65
+ if (Bun.which("bd") === null)
66
+ return { state: "unavailable", reason: "bd is not on PATH" } satisfies Integration;
67
+ return undefined;
68
+ });
69
+
70
+ /**
71
+ * Every bead `bd list --all <filters> --json` prints, with the integration state `check` and
72
+ * `track` both read: absent, unavailable, failed (bd ran and failed, its stderr kept) or ok.
73
+ */
74
+ export const beadRows = (root: string, filters: ReadonlyArray<string>) =>
75
+ Effect.gen(function* () {
76
+ const unreachable = yield* beadsUnreachable(root);
77
+ if (unreachable !== undefined)
78
+ return { integration: unreachable, value: undefined } satisfies Loaded<
79
+ ReadonlyArray<unknown>
80
+ >;
81
+ const command = ["bd", "list", "--all", ...filters, "--limit", "0", "--json"];
82
+ // A spawn that throws is bd failing to run: reported as failed, never an abort.
83
+ const result = yield* Effect.try(() =>
84
+ Bun.spawnSync(command, { cwd: root, stdout: "pipe", stderr: "pipe" }),
85
+ ).pipe(Effect.orElseSucceed(() => undefined));
86
+ const rows = result?.exitCode === 0 ? jsonRows(result.stdout.toString()) : undefined;
87
+ if (rows === undefined) {
88
+ const stderr = result?.stderr.toString().trim() ?? "";
89
+ return failed<ReadonlyArray<unknown>>(
90
+ `\`${command.join(" ")}\` exited ${result?.exitCode ?? "without running"} or printed no rows${stderr === "" ? "" : `: ${stderr}`}`,
91
+ );
92
+ }
93
+ return { integration: { state: "ok" }, value: rows } satisfies Loaded<ReadonlyArray<unknown>>;
94
+ });
95
+
73
96
  /** bd memories and every bead id. A repo without `.beads` is absent; bd missing is unavailable. */
74
97
  const beads = (root: string) =>
75
98
  Effect.gen(function* () {
76
99
  type Value = { memories: ReadonlyMap<string, string>; ids: ReadonlySet<string> };
77
- if (!(yield* exists(root, ".beads/config.yaml")))
78
- return { integration: { state: "absent" }, value: undefined } satisfies Loaded<Value>;
79
- if (Bun.which("bd") === null)
80
- return {
81
- integration: { state: "unavailable", reason: "bd is not on PATH" },
82
- value: undefined,
83
- } satisfies Loaded<Value>;
100
+ const unreachable = yield* beadsUnreachable(root);
101
+ if (unreachable !== undefined)
102
+ return { integration: unreachable, value: undefined } satisfies Loaded<Value>;
84
103
  const memories = yield* runTool(root, ["bd", "memories", "--json"]);
85
104
  const parsedMemories = memories.code === 0 ? parseJsonc(memories.stdout) : undefined;
86
105
  if (typeof parsedMemories !== "object" || parsedMemories === null)
87
106
  return failed<Value>(`\`bd memories --json\` exited ${memories.code} or printed no object`);
88
- const list = yield* runTool(root, [
89
- "bd",
90
- "list",
91
- "--all",
92
- "--brief",
93
- "--skip-labels",
94
- "--limit",
95
- "0",
96
- "--json",
97
- ]);
98
- const rows = list.code === 0 ? jsonRows(list.stdout) : undefined;
107
+ const list = yield* beadRows(root, ["--brief", "--skip-labels"]);
108
+ const rows = list.value;
99
109
  if (rows === undefined)
100
- return failed<Value>(`\`bd list --json\` exited ${list.code} or printed no rows`);
110
+ return { integration: list.integration, value: undefined } satisfies Loaded<Value>;
101
111
  const value: Value = {
102
112
  memories: new Map(
103
113
  Object.entries(parsedMemories).filter(
@@ -219,7 +229,8 @@ const linted = (root: string) =>
219
229
  return { integration: { state: "ok" }, value: files } satisfies Loaded<ReadonlySet<string>>;
220
230
  });
221
231
 
222
- export const loadSnapshot = (root: string, config: ResolvedConfig) =>
232
+ /** `shippedSkills` is the running monkeyshape's `skills/`, to tell whether a sync is due. */
233
+ export const loadSnapshot = (root: string, config: ResolvedConfig, shippedSkills: string) =>
223
234
  Effect.gen(function* () {
224
235
  const listed = yield* listFiles(root);
225
236
  // A file deleted but not yet staged is still listed; it isn't part of the repo now.
@@ -250,6 +261,7 @@ export const loadSnapshot = (root: string, config: ResolvedConfig) =>
250
261
  const loadedBeads = yield* beads(root);
251
262
  const loadedTsc = yield* typechecked(root, realRoot, config.coverage.tsconfigs);
252
263
  const loadedBiome = yield* linted(root);
264
+ const skills = yield* loadSkills(shippedSkills, root);
253
265
  const snapshot: Snapshot = {
254
266
  files: present,
255
267
  texts,
@@ -263,6 +275,7 @@ export const loadSnapshot = (root: string, config: ResolvedConfig) =>
263
275
  tsc: loadedTsc.integration,
264
276
  biome: loadedBiome.integration,
265
277
  },
278
+ skills,
266
279
  };
267
280
  return snapshot;
268
281
  });
package/src/cli/main.ts CHANGED
@@ -1,31 +1,55 @@
1
1
  #!/usr/bin/env bun
2
2
  /** The `mshape` executable: the composition root, and the only place effects are run. */
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { Effect } from "effect";
4
6
  import pkg from "../../package.json" with { type: "json" };
5
7
  import { BASELINE_FILE, readBaseline, writeBaseline } from "../adapters/baseline.ts";
6
8
  import { loadConfig } from "../adapters/config.ts";
9
+ import { initRepo } from "../adapters/init.ts";
10
+ import { reviewNew, reviewRm } from "../adapters/reviews.ts";
11
+ import { installSkills } from "../adapters/skills.ts";
12
+ import { trackCurrent, trackList, trackNew, trackRm } from "../adapters/tracks.ts";
7
13
  import { loadSnapshot } from "../adapters/workspace.ts";
8
14
  import { CHECKS, notices, prerequisites, runChecks, unevaluated } from "../core/check.ts";
9
15
  import { garden } from "../core/garden.ts";
16
+ import { CLAUDE_SKILLS_LINK } from "../core/layout.ts";
10
17
  import type { CheckName } from "../core/model.ts";
11
18
  import { applyBaseline, count, tighten } from "../core/ratchet.ts";
12
19
  import { formatCheck, formatGarden, formatViolation } from "../core/report.ts";
13
20
  import { RULES } from "../core/rules/registry.ts";
21
+ import { nextStep, REPO_NAME } from "../core/scaffold.ts";
22
+ import { formatCollisions, formatSync } from "../core/skills.ts";
23
+ import type { ConfigError, TrackError, WorkspaceError } from "../schemas/errors.ts";
14
24
 
15
- const USAGE = `mshape ${pkg.version}
25
+ /** What a command returns: the text to print, and the exit code (0 prints to stdout). */
26
+ interface Outcome {
27
+ readonly output: string;
28
+ readonly code: number;
29
+ }
16
30
 
17
- mshape check [--only arch,refs,budgets,coverage,rules]
18
- Run the checks; violations above .shape/baseline.json fail.
19
- mshape baseline --tighten
20
- Lower baseline counts to what is found now. Never raises one.
21
- mshape baseline --init
22
- Grandfather every current violation. For adopting a repo; review the diff.
23
- mshape garden
24
- Print budgets and escape hatches for the milestone-boundary sweep.
25
- mshape rules
26
- List the custom rules and the bug class each one removes.
31
+ /**
32
+ * One `mshape` command. A new command is one entry in `COMMANDS`, and the help is generated
33
+ * from the table, so a command can't ship without its usage and no two tracks edit one switch.
34
+ */
35
+ interface Command {
36
+ readonly name: string;
37
+ /** Its lines in the help: each form indented two spaces, what it does six. */
38
+ readonly usage: string;
39
+ readonly run: (
40
+ root: string,
41
+ args: ReadonlyArray<string>,
42
+ ) => Effect.Effect<Outcome, ConfigError | WorkspaceError | TrackError>;
43
+ }
44
+
45
+ const FLAGS = ` --config <path> For check, baseline and garden: use this config instead of ./shape.config.ts
46
+ (trying a repo before adopting).`;
47
+
48
+ /** Every command's usage, in table order. A function, since the table is declared below. */
49
+ export const help = (): string =>
50
+ [`mshape ${pkg.version}`, "", ...COMMANDS.map((command) => command.usage), "", FLAGS].join("\n");
27
51
 
28
- --config <path> Use this config instead of ./shape.config.ts (trying a repo before adopting).`;
52
+ const usage = (): Outcome => ({ output: help(), code: 2 });
29
53
 
30
54
  const parseOnly = (args: ReadonlyArray<string>): ReadonlyArray<CheckName> | string => {
31
55
  const index = args.indexOf("--only");
@@ -37,23 +61,128 @@ const parseOnly = (args: ReadonlyArray<string>): ReadonlyArray<CheckName> | stri
37
61
  : (names as ReadonlyArray<CheckName>);
38
62
  };
39
63
 
40
- const program = (root: string, args: ReadonlyArray<string>) =>
64
+ /** The package's own directory: `template/` and `skills/` ship beside `src/`. */
65
+ const PACKAGE_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, "");
66
+
67
+ const WORKFLOW_URL = `${pkg.homepage.replace(/#.*$/, "")}/blob/main/docs/workflow.md`;
68
+
69
+ const init = (root: string, args: ReadonlyArray<string>) =>
41
70
  Effect.gen(function* () {
42
- const [command, ...rest] = args;
43
- if (command === "--version") return { output: pkg.version, code: 0 };
44
- if (command === "rules")
71
+ const fromFlag = args.indexOf("--from");
72
+ const from = fromFlag === -1 ? undefined : args[fromFlag + 1];
73
+ // A forgotten value would silently install from the registry, which --from exists to avoid.
74
+ if (fromFlag !== -1 && (from === undefined || from.startsWith("--")))
45
75
  return {
46
- output: RULES.map((r) => `${r.id.padEnd(22)}${r.layer.padEnd(9)}${r.bugClass}`).join("\n"),
76
+ output: "mshape init: --from needs a value: a tarball path or a spec bun install accepts",
77
+ code: 2,
78
+ };
79
+ const positional = args.filter(
80
+ (_, i) => fromFlag === -1 || (i !== fromFlag && i !== fromFlag + 1),
81
+ );
82
+ const [name, ...extra] = positional;
83
+ if (name === undefined || !REPO_NAME.test(name) || extra.length > 0)
84
+ return {
85
+ output: `mshape init: give the new repo's name in lowercase words joined by hyphens, e.g. \`mshape init my-app\` (got ${positional.length === 0 ? "nothing" : positional.map((arg) => `'${arg}'`).join(" ")})`,
86
+ code: 2,
87
+ };
88
+ // A bare path is a tarball; bun would resolve it from the new repo, not from here.
89
+ const spec = from === undefined || from.includes(":") ? from : `file:${resolve(root, from)}`;
90
+ const dev = pkg.devDependencies;
91
+ const result = yield* initRepo(`${root}/${name}`, name, PACKAGE_ROOT, {
92
+ bun: Bun.version,
93
+ monkeyshape: spec ?? pkg.version,
94
+ effect: dev.effect,
95
+ biome: dev["@biomejs/biome"],
96
+ typescript: dev.typescript,
97
+ typesBun: dev["@types/bun"],
98
+ });
99
+ return {
100
+ output: [
101
+ `Created ${name}/: ${result.done.join(", ")}.`,
102
+ ...result.notes.map((note) => `note: ${note}`),
103
+ "",
104
+ nextStep(name, WORKFLOW_URL),
105
+ ].join("\n"),
106
+ code: 0,
107
+ };
108
+ });
109
+
110
+ /** Today in local time, `YYYY-MM-DD`: the date a new handoff's State carries. */
111
+ const today = (): string => {
112
+ const now = new Date();
113
+ const pad = (n: number) => String(n).padStart(2, "0");
114
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
115
+ };
116
+
117
+ const track = (root: string, args: ReadonlyArray<string>) =>
118
+ Effect.gen(function* () {
119
+ const [sub, ...rest] = args;
120
+ const flags = rest.filter((arg) => arg.startsWith("--"));
121
+ const names = rest.filter((arg) => !arg.startsWith("--"));
122
+ const allowed = sub === "rm" ? ["--force", "--keep-handoff"] : [];
123
+ const takesName = sub === "new" || sub === "rm";
124
+ if (flags.some((flag) => !allowed.includes(flag))) return usage();
125
+ if (names.length !== (takesName ? 1 : 0)) return usage();
126
+ const name = names[0] ?? "";
127
+ if (sub === "new") return { output: yield* trackNew(root, name, today()), code: 0 };
128
+ if (sub === "list") return { output: yield* trackList(root), code: 0 };
129
+ if (sub === "current") return { output: yield* trackCurrent(root), code: 0 };
130
+ if (sub === "rm")
131
+ return {
132
+ output: yield* trackRm(
133
+ root,
134
+ name,
135
+ {
136
+ force: flags.includes("--force"),
137
+ keepHandoff: flags.includes("--keep-handoff"),
138
+ // rm fetches main's upstream; with no terminal to answer, a prompt would hang it.
139
+ fetchEnv: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
140
+ },
141
+ (line) => Effect.sync(() => console.error(line)),
142
+ ),
143
+ code: 0,
144
+ };
145
+ return usage();
146
+ });
147
+
148
+ const review = (root: string, args: ReadonlyArray<string>) =>
149
+ Effect.gen(function* () {
150
+ if (args.length === 1 && args[0] !== "rm")
151
+ return {
152
+ output: yield* reviewNew(root, args[0] ?? "", (line) =>
153
+ Effect.sync(() => console.error(line)),
154
+ ),
47
155
  code: 0,
48
156
  };
49
- if (command !== "check" && command !== "baseline" && command !== "garden")
157
+ if (args.length === 2 && args[0] === "rm")
50
158
  return {
51
- output: USAGE,
52
- code: command === undefined || command === "help" || command === "--help" ? 0 : 2,
159
+ output: yield* reviewRm(root, args[1] ?? "", (line) =>
160
+ Effect.sync(() => console.error(line)),
161
+ ),
162
+ code: 0,
53
163
  };
164
+ return usage();
165
+ });
166
+
167
+ const skills = (root: string, args: ReadonlyArray<string>) =>
168
+ Effect.gen(function* () {
169
+ if (args[0] !== "sync") return usage();
170
+ const { sync, linked } = yield* installSkills(`${PACKAGE_ROOT}/skills`, root);
171
+ return {
172
+ output: [
173
+ formatSync(sync),
174
+ ...(linked ? [`${CLAUDE_SKILLS_LINK}: linked`] : []),
175
+ ...formatCollisions(sync),
176
+ ].join("\n"),
177
+ code: sync.collisions.length > 0 ? 1 : 0,
178
+ };
179
+ });
54
180
 
55
- const configFlag = rest.indexOf("--config");
56
- const configPath = configFlag === -1 ? undefined : rest[configFlag + 1];
181
+ /** What `check`, `baseline` and `garden` all read: the config, the snapshot, the baseline. */
182
+ const load = (root: string, args: ReadonlyArray<string>) =>
183
+ Effect.gen(function* () {
184
+ const configFlag = args.indexOf("--config");
185
+ const configPath = configFlag === -1 ? undefined : args[configFlag + 1];
57
186
  const config = yield* loadConfig(
58
187
  root,
59
188
  configPath === undefined
@@ -62,39 +191,16 @@ const program = (root: string, args: ReadonlyArray<string>) =>
62
191
  ? configPath
63
192
  : `${root}/${configPath}`,
64
193
  );
65
- const snapshot = yield* loadSnapshot(root, config);
194
+ const snapshot = yield* loadSnapshot(root, config, `${PACKAGE_ROOT}/skills`);
66
195
  const baseline = yield* readBaseline(root);
196
+ return { config, snapshot, baseline };
197
+ });
67
198
 
68
- if (command === "garden")
69
- return { output: formatGarden(garden(config, snapshot, baseline)), code: 0 };
70
-
199
+ const check = (root: string, args: ReadonlyArray<string>) =>
200
+ Effect.gen(function* () {
201
+ const { config, snapshot, baseline } = yield* load(root, args);
71
202
  const blocked = prerequisites(config, snapshot);
72
- if (command === "baseline") {
73
- // A baseline written without a required integration would grandfather an incomplete count.
74
- if (blocked.length > 0)
75
- return {
76
- output: [
77
- ...blocked.map(formatViolation),
78
- "mshape baseline: not written, because a prerequisite fails",
79
- ].join("\n\n"),
80
- code: 1,
81
- };
82
- const violations = runChecks(config, snapshot);
83
- if (rest.includes("--init")) {
84
- yield* writeBaseline(root, count(violations));
85
- return {
86
- output: `${BASELINE_FILE}: grandfathered ${violations.length} violation(s)`,
87
- code: 0,
88
- };
89
- }
90
- if (rest.includes("--tighten")) {
91
- yield* writeBaseline(root, tighten(baseline, violations, unevaluated(snapshot)));
92
- return { output: `${BASELINE_FILE}: tightened`, code: 0 };
93
- }
94
- return { output: USAGE, code: 2 };
95
- }
96
-
97
- const only = parseOnly(rest);
203
+ const only = parseOnly(args);
98
204
  if (typeof only === "string") return { output: only, code: 2 };
99
205
  const ratcheted = applyBaseline(
100
206
  runChecks(config, snapshot, only),
@@ -109,14 +215,145 @@ const program = (root: string, args: ReadonlyArray<string>) =>
109
215
  };
110
216
  });
111
217
 
112
- const outcome = await Effect.runPromise(
113
- program(process.cwd(), process.argv.slice(2)).pipe(
114
- Effect.catchTags({
115
- ConfigError: (error) => Effect.succeed({ output: `mshape: ${error.message}`, code: 2 }),
116
- WorkspaceError: (error) => Effect.succeed({ output: `mshape: ${error.message}`, code: 2 }),
117
- }),
118
- ),
119
- );
120
- if (outcome.code === 0) console.log(outcome.output);
121
- else console.error(outcome.output);
122
- process.exitCode = outcome.code;
218
+ const baseline = (root: string, args: ReadonlyArray<string>) =>
219
+ Effect.gen(function* () {
220
+ const loaded = yield* load(root, args);
221
+ const { config, snapshot } = loaded;
222
+ const blocked = prerequisites(config, snapshot);
223
+ // A baseline written without a required integration would grandfather an incomplete count.
224
+ if (blocked.length > 0)
225
+ return {
226
+ output: [
227
+ ...blocked.map(formatViolation),
228
+ "mshape baseline: not written, because a prerequisite fails",
229
+ ].join("\n\n"),
230
+ code: 1,
231
+ };
232
+ const violations = runChecks(config, snapshot);
233
+ if (args.includes("--init")) {
234
+ yield* writeBaseline(root, count(violations));
235
+ return {
236
+ output: `${BASELINE_FILE}: grandfathered ${violations.length} violation(s)`,
237
+ code: 0,
238
+ };
239
+ }
240
+ if (args.includes("--tighten")) {
241
+ yield* writeBaseline(root, tighten(loaded.baseline, violations, unevaluated(snapshot)));
242
+ return { output: `${BASELINE_FILE}: tightened`, code: 0 };
243
+ }
244
+ return usage();
245
+ });
246
+
247
+ const gardenCommand = (root: string, args: ReadonlyArray<string>) =>
248
+ Effect.map(load(root, args), ({ config, snapshot, baseline }) => ({
249
+ output: formatGarden(garden(config, snapshot, baseline)),
250
+ code: 0,
251
+ }));
252
+
253
+ const rules = () =>
254
+ Effect.succeed({
255
+ output: RULES.map((r) => `${r.id.padEnd(22)}${r.layer.padEnd(9)}${r.bugClass}`).join("\n"),
256
+ code: 0,
257
+ });
258
+
259
+ /** Every command, in the order the help lists them. */
260
+ export const COMMANDS: ReadonlyArray<Command> = [
261
+ {
262
+ name: "init",
263
+ usage: ` mshape init <name> [--from <spec>]
264
+ Create ./<name>: a new repo whose gate passes, with one starter test. --from installs
265
+ monkeyshape from <spec> (a tarball path) instead of the registry, to try a release first.`,
266
+ run: init,
267
+ },
268
+ {
269
+ name: "skills",
270
+ usage: ` mshape skills sync
271
+ Refresh the shared skills in .agents/skills from the installed package, after a bump.
272
+ Skills the package doesn't ship are the repo's own and are left alone.`,
273
+ run: skills,
274
+ },
275
+ {
276
+ name: "check",
277
+ usage: ` mshape check [--only arch,refs,budgets,coverage,rules,skills]
278
+ Run the checks; violations above .shape/baseline.json fail.`,
279
+ run: check,
280
+ },
281
+ {
282
+ name: "baseline",
283
+ usage: ` mshape baseline --tighten
284
+ Lower baseline counts to what is found now. Never raises one.
285
+ mshape baseline --init
286
+ Grandfather every current violation. For adopting a repo; review the diff.`,
287
+ run: baseline,
288
+ },
289
+ {
290
+ name: "garden",
291
+ usage: ` mshape garden
292
+ Print budgets and escape hatches for the milestone-boundary sweep.`,
293
+ run: gardenCommand,
294
+ },
295
+ {
296
+ name: "rules",
297
+ usage: ` mshape rules
298
+ List the custom rules and the bug class each one removes.`,
299
+ run: rules,
300
+ },
301
+ {
302
+ name: "track",
303
+ usage: ` mshape track new <name>
304
+ Start a track: worktree .agents/worktrees/<repo>@<name> on track/<name> from main,
305
+ the shared handoffs and tracks.sharedPaths linked in, and a stub handoff. Shared paths
306
+ come from the main checkout's shape.config.ts, from whichever checkout this runs.
307
+ mshape track list
308
+ Every track: handoff status, open beads, commits in neither main nor origin/main,
309
+ worktree. Restores missing handoff links; a track label with open beads and no track
310
+ left is listed too. Offline: run git fetch to see merges done on the remote.
311
+ mshape track current
312
+ The track of this directory, name only; exit 1, saying why, outside a track.
313
+ mshape track rm <name> [--force] [--keep-handoff]
314
+ Remove a finished track: worktree, branch, handoff. Fetches main's upstream first, then
315
+ refuses on uncommitted changes or work in neither main nor origin/main (by merge,
316
+ rebase or squash); --force discards them. Also refuses while beads labelled
317
+ track:<name> aren't closed, which --force doesn't override. Never touches main or other tracks.`,
318
+ run: track,
319
+ },
320
+ {
321
+ name: "review",
322
+ usage: ` mshape review <n>
323
+ Pull request <n>'s head from origin in a detached worktree .agents/worktrees/<repo>.pr-<n>,
324
+ tracks.sharedPaths linked in. Again later: put it exactly at the new head, printing the
325
+ old one; edits to tracked files there are listed, then discarded.
326
+ mshape review rm <n>
327
+ Remove that worktree, listing what it discards, and its fetched ref. Never the shared data.`,
328
+ run: review,
329
+ },
330
+ ];
331
+
332
+ const HELP: ReadonlyArray<string | undefined> = [undefined, "help", "--help"];
333
+
334
+ export const program = (root: string, args: ReadonlyArray<string>) =>
335
+ Effect.gen(function* () {
336
+ const [name, ...rest] = args;
337
+ if (name === "--version") return { output: pkg.version, code: 0 };
338
+ if (HELP.includes(name)) return { output: help(), code: 0 };
339
+ const command = COMMANDS.find((entry) => entry.name === name);
340
+ if (command === undefined)
341
+ return { output: `mshape: unknown command '${name}'\n\n${help()}`, code: 2 };
342
+ return yield* command.run(root, rest);
343
+ });
344
+
345
+ // Only as the executable: tests import the table and the help without running a command.
346
+ if (import.meta.main) {
347
+ const outcome = await Effect.runPromise(
348
+ program(process.cwd(), process.argv.slice(2)).pipe(
349
+ Effect.catchTags({
350
+ ConfigError: (error) => Effect.succeed({ output: `mshape: ${error.message}`, code: 2 }),
351
+ WorkspaceError: (error) => Effect.succeed({ output: `mshape: ${error.message}`, code: 2 }),
352
+ TrackError: (error) => Effect.succeed({ output: `mshape: ${error.message}`, code: 1 }),
353
+ }),
354
+ ),
355
+ );
356
+ if (outcome.code === 0) console.log(outcome.output);
357
+ else console.error(outcome.output);
358
+ process.exitCode = outcome.code;
359
+ }
package/src/core/check.ts CHANGED
@@ -3,12 +3,20 @@ import type { ResolvedConfig } from "../schemas/config.ts";
3
3
  import { checkArch } from "./arch.ts";
4
4
  import { checkBudgets } from "./budgets.ts";
5
5
  import { checkCoverage } from "./coverage.ts";
6
- import type { CheckName, Snapshot, Violation } from "./model.ts";
6
+ import type { CheckName, Integration, Snapshot, Violation } from "./model.ts";
7
7
  import { checkRefs } from "./refs.ts";
8
8
  import { checkRules } from "./rules/engine.ts";
9
9
  import { RULES } from "./rules/registry.ts";
10
+ import { checkSkills } from "./skills.ts";
10
11
 
11
- export const CHECKS: ReadonlyArray<CheckName> = ["arch", "refs", "budgets", "coverage", "rules"];
12
+ export const CHECKS: ReadonlyArray<CheckName> = [
13
+ "arch",
14
+ "refs",
15
+ "budgets",
16
+ "coverage",
17
+ "rules",
18
+ "skills",
19
+ ];
12
20
 
13
21
  const run: Record<
14
22
  CheckName,
@@ -19,6 +27,7 @@ const run: Record<
19
27
  budgets: checkBudgets,
20
28
  coverage: checkCoverage,
21
29
  rules: (config, snapshot) => checkRules(config, snapshot, RULES),
30
+ skills: checkSkills,
22
31
  };
23
32
 
24
33
  const BEADS_RULES = ["refs/bead-in-code", "budgets/memories-count", "budgets/memories-bytes"];
@@ -64,6 +73,21 @@ const prerequisite = (rule: string, message: string): Violation => ({
64
73
  message,
65
74
  });
66
75
 
76
+ /**
77
+ * When beads can't be trusted, whatever asks it: `failed` when bd ran and failed, whatever the
78
+ * config says; `required` when `beads: "required"` and beads isn't ok. `check` and
79
+ * `track rm` both decide with this, so a beads failure means one thing everywhere.
80
+ */
81
+ export const beadsBlocker = (
82
+ setting: ResolvedConfig["beads"],
83
+ beads: Integration,
84
+ ): "failed" | "required" | undefined =>
85
+ beads.state === "failed"
86
+ ? "failed"
87
+ : setting === "required" && beads.state !== "ok"
88
+ ? "required"
89
+ : undefined;
90
+
67
91
  /**
68
92
  * Integrations that must work for a result to mean anything: bd failing outright, or bd
69
93
  * unavailable when `beads: "required"`. These are facts about the environment, not the code,
@@ -74,11 +98,12 @@ export const prerequisites = (
74
98
  snapshot: Snapshot,
75
99
  ): ReadonlyArray<Violation> => {
76
100
  const beads = snapshot.integrations.beads;
77
- if (beads.state === "failed")
101
+ const blocker = beadsBlocker(config.beads, beads);
102
+ if (blocker === "failed" && beads.state === "failed")
78
103
  return [
79
104
  prerequisite("beads-failed", `bd failed, so beads checks did not run: ${beads.reason}`),
80
105
  ];
81
- if (config.beads === "required" && beads.state !== "ok")
106
+ if (blocker === "required" && beads.state !== "ok")
82
107
  return [
83
108
  prerequisite(
84
109
  "beads-required",