monkeyshape 0.0.0-stage → 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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/config/biome.base.json +71 -0
  4. package/config/tsconfig.base.json +31 -0
  5. package/docs/workflow.md +131 -0
  6. package/package.json +68 -4
  7. package/skills/handoff/SKILL.md +54 -0
  8. package/skills/milestone/SKILL.md +107 -0
  9. package/skills/review-pr/SKILL.md +135 -0
  10. package/skills/roadmap/SKILL.md +66 -0
  11. package/skills/track/SKILL.md +75 -0
  12. package/src/adapters/baseline.ts +55 -0
  13. package/src/adapters/config.ts +25 -0
  14. package/src/adapters/init.ts +176 -0
  15. package/src/adapters/process.ts +38 -0
  16. package/src/adapters/reviews.ts +170 -0
  17. package/src/adapters/skills.ts +113 -0
  18. package/src/adapters/tracks.ts +616 -0
  19. package/src/adapters/workspace.ts +281 -0
  20. package/src/api/monkeyshape.ts +8 -0
  21. package/src/cli/main.ts +359 -0
  22. package/src/core/arch.ts +224 -0
  23. package/src/core/budgets.ts +187 -0
  24. package/src/core/check.ts +122 -0
  25. package/src/core/coverage.ts +118 -0
  26. package/src/core/decisions.ts +65 -0
  27. package/src/core/garden.ts +41 -0
  28. package/src/core/glob.ts +22 -0
  29. package/src/core/handoff.ts +183 -0
  30. package/src/core/hooks.ts +60 -0
  31. package/src/core/jsonc.ts +26 -0
  32. package/src/core/layout.ts +123 -0
  33. package/src/core/markdown.ts +93 -0
  34. package/src/core/model.ts +100 -0
  35. package/src/core/parse.ts +41 -0
  36. package/src/core/paths.ts +19 -0
  37. package/src/core/ratchet.ts +75 -0
  38. package/src/core/refs.ts +214 -0
  39. package/src/core/report.ts +67 -0
  40. package/src/core/rules/ast.ts +50 -0
  41. package/src/core/rules/decodeStrict.ts +60 -0
  42. package/src/core/rules/effectRunAtEdge.ts +33 -0
  43. package/src/core/rules/engine.ts +150 -0
  44. package/src/core/rules/noErrorSpread.ts +28 -0
  45. package/src/core/rules/noNondeterminism.ts +65 -0
  46. package/src/core/rules/noThrow.ts +79 -0
  47. package/src/core/rules/registry.ts +15 -0
  48. package/src/core/scaffold.ts +90 -0
  49. package/src/core/scope.ts +120 -0
  50. package/src/core/skills.ts +291 -0
  51. package/src/core/tracks.ts +358 -0
  52. package/src/helpers/decodeStrict.ts +8 -0
  53. package/src/schemas/config.ts +221 -0
  54. package/src/schemas/errors.ts +31 -0
  55. package/template/.github/pull_request_template.md +11 -0
  56. package/template/.github/workflows/ci.yml +22 -0
  57. package/template/AGENTS.md +37 -0
  58. package/template/CLAUDE.md +1 -0
  59. package/template/DECISIONS.md +8 -0
  60. package/template/README.md +14 -0
  61. package/template/docs/roadmap.md +14 -0
  62. package/template/gitignore +7 -0
  63. package/template/notes/.gitkeep +0 -0
  64. package/template/plan/.gitkeep +0 -0
  65. package/template/shape.config.ts +26 -0
  66. package/template/test/toolchain.test.ts +7 -0
@@ -0,0 +1,281 @@
1
+ /** Loads the repo into a `Snapshot`: git's file list, the texts checks read, and what bd, tsc and biome report. */
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { posix } from "node:path";
4
+ import { Effect } from "effect";
5
+ import { parseJsonc } from "../core/coverage.ts";
6
+ import { type Integration, isTsSource, type Snapshot } from "../core/model.ts";
7
+ import type { ResolvedConfig } from "../schemas/config.ts";
8
+ import { WorkspaceError } from "../schemas/errors.ts";
9
+ import { run, runTool } from "./process.ts";
10
+ import { loadSkills } from "./skills.ts";
11
+
12
+ export const readText = (root: string, path: string) =>
13
+ Effect.tryPromise({
14
+ try: () => Bun.file(`${root}/${path}`).text(),
15
+ catch: (cause) => new WorkspaceError({ operation: "read", path, reason: String(cause) }),
16
+ });
17
+
18
+ /** Files and directories alike: git lists a submodule as one entry, and it is a directory. */
19
+ const exists = (root: string, path: string) =>
20
+ Effect.promise(() =>
21
+ stat(`${root}/${path}`).then(
22
+ () => true,
23
+ () => false,
24
+ ),
25
+ );
26
+
27
+ const listFiles = (root: string) =>
28
+ Effect.gen(function* () {
29
+ const result = yield* run(root, [
30
+ "git",
31
+ "ls-files",
32
+ "--cached",
33
+ "--others",
34
+ "--exclude-standard",
35
+ "-z",
36
+ ]);
37
+ if (result.code !== 0)
38
+ return yield* Effect.fail(
39
+ new WorkspaceError({ operation: "list", path: root, reason: "not a git repository" }),
40
+ );
41
+ // Nested repositories and worktrees show up as a directory entry; they are not ours.
42
+ return result.stdout.split("\0").filter((file) => file !== "" && !file.endsWith("/"));
43
+ });
44
+
45
+ type Loaded<A> = { readonly integration: Integration; readonly value: A | undefined };
46
+
47
+ const failed = <A>(reason: string): Loaded<A> => ({
48
+ integration: { state: "failed", reason },
49
+ value: undefined,
50
+ });
51
+
52
+ const jsonRows = (stdout: string): ReadonlyArray<unknown> | undefined => {
53
+ const parsed = parseJsonc(stdout);
54
+ // `bd list --limit 0 --json` wraps the rows as `{ issues: [...] }`; limited lists are bare.
55
+ const rows =
56
+ typeof parsed === "object" && parsed !== null && "issues" in parsed ? parsed.issues : parsed;
57
+ return Array.isArray(rows) ? rows : undefined;
58
+ };
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
+
96
+ /** bd memories and every bead id. A repo without `.beads` is absent; bd missing is unavailable. */
97
+ const beads = (root: string) =>
98
+ Effect.gen(function* () {
99
+ type Value = { memories: ReadonlyMap<string, string>; ids: ReadonlySet<string> };
100
+ const unreachable = yield* beadsUnreachable(root);
101
+ if (unreachable !== undefined)
102
+ return { integration: unreachable, value: undefined } satisfies Loaded<Value>;
103
+ const memories = yield* runTool(root, ["bd", "memories", "--json"]);
104
+ const parsedMemories = memories.code === 0 ? parseJsonc(memories.stdout) : undefined;
105
+ if (typeof parsedMemories !== "object" || parsedMemories === null)
106
+ return failed<Value>(`\`bd memories --json\` exited ${memories.code} or printed no object`);
107
+ const list = yield* beadRows(root, ["--brief", "--skip-labels"]);
108
+ const rows = list.value;
109
+ if (rows === undefined)
110
+ return { integration: list.integration, value: undefined } satisfies Loaded<Value>;
111
+ const value: Value = {
112
+ memories: new Map(
113
+ Object.entries(parsedMemories).filter(
114
+ (entry): entry is [string, string] => typeof entry[1] === "string",
115
+ ),
116
+ ),
117
+ ids: new Set(
118
+ rows.flatMap((issue) =>
119
+ typeof issue === "object" &&
120
+ issue !== null &&
121
+ "id" in issue &&
122
+ typeof issue.id === "string"
123
+ ? [issue.id]
124
+ : [],
125
+ ),
126
+ ),
127
+ };
128
+ return { integration: { state: "ok" }, value } satisfies Loaded<Value>;
129
+ });
130
+
131
+ /**
132
+ * A tool installed in the repo, as a command. Run through the current bun: the bin shims
133
+ * start with `#!/usr/bin/env node`, and a Bun-only machine has no node.
134
+ */
135
+ const localBin = (root: string, name: string) =>
136
+ Effect.map(exists(root, `node_modules/.bin/${name}`), (found) =>
137
+ found ? [process.execPath, `${root}/node_modules/.bin/${name}`] : undefined,
138
+ );
139
+
140
+ /**
141
+ * The tsconfigs a project's references point at. A reference names a tsconfig file or a
142
+ * directory holding `tsconfig.json`; references are not inherited through `extends`, so the
143
+ * file's own JSON is the whole answer. Unreadable files yield none; tsc reports them itself.
144
+ */
145
+ const referencesOf = (root: string, tsconfig: string) =>
146
+ Effect.gen(function* () {
147
+ const text = yield* readText(root, tsconfig).pipe(Effect.orElseSucceed(() => "{}"));
148
+ const parsed = parseJsonc(text);
149
+ const references =
150
+ typeof parsed === "object" && parsed !== null && "references" in parsed
151
+ ? parsed.references
152
+ : undefined;
153
+ if (!Array.isArray(references)) return [];
154
+ const out: Array<string> = [];
155
+ for (const reference of references) {
156
+ if (typeof reference !== "object" || reference === null || !("path" in reference)) continue;
157
+ if (typeof reference.path !== "string") continue;
158
+ const target = posix.normalize(posix.join(posix.dirname(tsconfig), reference.path));
159
+ const isDirectory = yield* Effect.promise(() =>
160
+ stat(`${root}/${target}`).then(
161
+ (info) => info.isDirectory(),
162
+ () => false,
163
+ ),
164
+ );
165
+ out.push(isDirectory ? posix.join(target, "tsconfig.json") : target);
166
+ }
167
+ return out;
168
+ });
169
+
170
+ /**
171
+ * The union of every configured tsconfig's program and, transitively, of every project it
172
+ * references, as tsc itself resolves each one. `tsc -p` checks one project and `--build`
173
+ * won't list files, so the reference graph is walked here, each project once.
174
+ */
175
+ const typechecked = (root: string, realRoot: string, tsconfigs: ReadonlyArray<string>) =>
176
+ Effect.gen(function* () {
177
+ const tsc = yield* localBin(root, "tsc");
178
+ if (tsc === undefined)
179
+ return {
180
+ integration: { state: "unavailable", reason: "typescript is not installed" },
181
+ value: undefined,
182
+ } satisfies Loaded<ReadonlySet<string>>;
183
+ const files = new Set<string>();
184
+ const seen = new Set<string>();
185
+ const queue = tsconfigs.map((tsconfig) => posix.normalize(tsconfig));
186
+ for (let tsconfig = queue.shift(); tsconfig !== undefined; tsconfig = queue.shift()) {
187
+ if (seen.has(tsconfig)) continue;
188
+ seen.add(tsconfig);
189
+ const result = yield* runTool(root, [...tsc, "-p", tsconfig, "--listFilesOnly"]);
190
+ if (result.code !== 0)
191
+ return failed<ReadonlySet<string>>(
192
+ `\`tsc -p ${tsconfig} --listFilesOnly\` exited ${result.code}`,
193
+ );
194
+ for (const line of result.stdout.split("\n"))
195
+ if (line.startsWith(`${realRoot}/`)) files.add(line.slice(realRoot.length + 1));
196
+ queue.push(...(yield* referencesOf(root, tsconfig)));
197
+ }
198
+ return { integration: { state: "ok" }, value: files } satisfies Loaded<ReadonlySet<string>>;
199
+ });
200
+
201
+ /** The files biome processes, from its verbose report, with `extends` and ignores resolved. */
202
+ const linted = (root: string) =>
203
+ Effect.gen(function* () {
204
+ const biome = yield* localBin(root, "biome");
205
+ if (biome === undefined)
206
+ return {
207
+ integration: { state: "unavailable", reason: "@biomejs/biome is not installed" },
208
+ value: undefined,
209
+ } satisfies Loaded<ReadonlySet<string>>;
210
+ const result = yield* runTool(root, [
211
+ ...biome,
212
+ "lint",
213
+ "--verbose",
214
+ "--colors=off",
215
+ "--max-diagnostics=0",
216
+ ".",
217
+ ]);
218
+ const report = result.stdout.split("Files processed:")[1];
219
+ if (report === undefined)
220
+ return failed<ReadonlySet<string>>(
221
+ `\`biome lint --verbose\` printed no "Files processed:" list (exit ${result.code})`,
222
+ );
223
+ const files = new Set(
224
+ report.split("\n").flatMap((line) => {
225
+ const match = /^\s+- (.+)$/.exec(line);
226
+ return match?.[1] === undefined ? [] : [match[1].trim()];
227
+ }),
228
+ );
229
+ return { integration: { state: "ok" }, value: files } satisfies Loaded<ReadonlySet<string>>;
230
+ });
231
+
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) =>
234
+ Effect.gen(function* () {
235
+ const listed = yield* listFiles(root);
236
+ // A file deleted but not yet staged is still listed; it isn't part of the repo now.
237
+ const found = yield* Effect.forEach(listed, (file) => exists(root, file), { concurrency: 32 });
238
+ const present = listed.filter((_, i) => found[i] === true);
239
+ const configs = new Set([...config.coverage.tsconfigs, config.coverage.biome, "package.json"]);
240
+ const wanted = present.filter(
241
+ (file) => isTsSource(file) || file.endsWith(".md") || configs.has(file),
242
+ );
243
+ const texts = new Map(
244
+ yield* Effect.forEach(
245
+ wanted,
246
+ (file) => Effect.map(readText(root, file), (text) => [file, text] as const),
247
+ {
248
+ concurrency: 32,
249
+ },
250
+ ),
251
+ );
252
+ const manifest = parseJsonc(texts.get("package.json") ?? "{}");
253
+ const scripts =
254
+ typeof manifest === "object" &&
255
+ manifest !== null &&
256
+ "scripts" in manifest &&
257
+ typeof manifest.scripts === "object"
258
+ ? Object.keys(manifest.scripts ?? {})
259
+ : [];
260
+ const realRoot = yield* Effect.promise(() => realpath(root));
261
+ const loadedBeads = yield* beads(root);
262
+ const loadedTsc = yield* typechecked(root, realRoot, config.coverage.tsconfigs);
263
+ const loadedBiome = yield* linted(root);
264
+ const skills = yield* loadSkills(shippedSkills, root);
265
+ const snapshot: Snapshot = {
266
+ files: present,
267
+ texts,
268
+ packageScripts: new Set(scripts),
269
+ memories: loadedBeads.value?.memories,
270
+ beadIds: loadedBeads.value?.ids,
271
+ typechecked: loadedTsc.value,
272
+ linted: loadedBiome.value,
273
+ integrations: {
274
+ beads: loadedBeads.integration,
275
+ tsc: loadedTsc.integration,
276
+ biome: loadedBiome.integration,
277
+ },
278
+ skills,
279
+ };
280
+ return snapshot;
281
+ });
@@ -0,0 +1,8 @@
1
+ /** The public surface: what an adopter imports from `monkeyshape`. */
2
+ import type { ShapeConfig } from "../schemas/config.ts";
3
+
4
+ export { decodeStrict } from "../helpers/decodeStrict.ts";
5
+ export type { Allowance, ModuleRule, ShapeConfig } from "../schemas/config.ts";
6
+
7
+ /** Identity, for type-checking `shape.config.ts`: `export default defineConfig({ ... })`. */
8
+ export const defineConfig = (config: ShapeConfig): ShapeConfig => config;
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env bun
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";
5
+ import { Effect } from "effect";
6
+ import pkg from "../../package.json" with { type: "json" };
7
+ import { BASELINE_FILE, readBaseline, writeBaseline } from "../adapters/baseline.ts";
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";
13
+ import { loadSnapshot } from "../adapters/workspace.ts";
14
+ import { CHECKS, notices, prerequisites, runChecks, unevaluated } from "../core/check.ts";
15
+ import { garden } from "../core/garden.ts";
16
+ import { CLAUDE_SKILLS_LINK } from "../core/layout.ts";
17
+ import type { CheckName } from "../core/model.ts";
18
+ import { applyBaseline, count, tighten } from "../core/ratchet.ts";
19
+ import { formatCheck, formatGarden, formatViolation } from "../core/report.ts";
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";
24
+
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
+ }
30
+
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");
51
+
52
+ const usage = (): Outcome => ({ output: help(), code: 2 });
53
+
54
+ const parseOnly = (args: ReadonlyArray<string>): ReadonlyArray<CheckName> | string => {
55
+ const index = args.indexOf("--only");
56
+ if (index === -1) return CHECKS;
57
+ const names = (args[index + 1] ?? "").split(",").filter(Boolean);
58
+ const unknown = names.filter((name) => !(CHECKS as ReadonlyArray<string>).includes(name));
59
+ return unknown.length > 0 || names.length === 0
60
+ ? `unknown check(s): ${unknown.join(", ") || "(none given)"}`
61
+ : (names as ReadonlyArray<CheckName>);
62
+ };
63
+
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>) =>
70
+ Effect.gen(function* () {
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("--")))
75
+ return {
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
+ ),
155
+ code: 0,
156
+ };
157
+ if (args.length === 2 && args[0] === "rm")
158
+ return {
159
+ output: yield* reviewRm(root, args[1] ?? "", (line) =>
160
+ Effect.sync(() => console.error(line)),
161
+ ),
162
+ code: 0,
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
+ });
180
+
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];
186
+ const config = yield* loadConfig(
187
+ root,
188
+ configPath === undefined
189
+ ? undefined
190
+ : configPath.startsWith("/")
191
+ ? configPath
192
+ : `${root}/${configPath}`,
193
+ );
194
+ const snapshot = yield* loadSnapshot(root, config, `${PACKAGE_ROOT}/skills`);
195
+ const baseline = yield* readBaseline(root);
196
+ return { config, snapshot, baseline };
197
+ });
198
+
199
+ const check = (root: string, args: ReadonlyArray<string>) =>
200
+ Effect.gen(function* () {
201
+ const { config, snapshot, baseline } = yield* load(root, args);
202
+ const blocked = prerequisites(config, snapshot);
203
+ const only = parseOnly(args);
204
+ if (typeof only === "string") return { output: only, code: 2 };
205
+ const ratcheted = applyBaseline(
206
+ runChecks(config, snapshot, only),
207
+ baseline,
208
+ unevaluated(snapshot, only, Object.keys(baseline)),
209
+ );
210
+ const result = { ...ratcheted, failing: [...blocked, ...ratcheted.failing] };
211
+ const said = notices(config, snapshot).map((notice) => `note: ${notice}`);
212
+ return {
213
+ output: [...said, formatCheck(result)].join("\n"),
214
+ code: result.failing.length + result.loose.length > 0 ? 1 : 0,
215
+ };
216
+ });
217
+
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
+ }