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.
- package/README.md +13 -37
- package/docs/workflow.md +131 -0
- package/package.json +5 -1
- package/skills/handoff/SKILL.md +54 -0
- package/skills/milestone/SKILL.md +107 -0
- package/skills/review-pr/SKILL.md +135 -0
- package/skills/roadmap/SKILL.md +66 -0
- package/skills/track/SKILL.md +75 -0
- package/src/adapters/init.ts +176 -0
- package/src/adapters/process.ts +38 -0
- package/src/adapters/reviews.ts +170 -0
- package/src/adapters/skills.ts +113 -0
- package/src/adapters/tracks.ts +616 -0
- package/src/adapters/workspace.ts +48 -35
- package/src/cli/main.ts +300 -63
- package/src/core/check.ts +29 -4
- package/src/core/handoff.ts +183 -0
- package/src/core/hooks.ts +60 -0
- package/src/core/layout.ts +98 -26
- package/src/core/markdown.ts +93 -0
- package/src/core/model.ts +24 -1
- package/src/core/refs.ts +19 -13
- package/src/core/scaffold.ts +90 -0
- package/src/core/skills.ts +291 -0
- package/src/core/tracks.ts +358 -0
- package/src/schemas/config.ts +20 -0
- package/src/schemas/errors.ts +9 -0
- package/template/.github/pull_request_template.md +11 -0
- package/template/.github/workflows/ci.yml +22 -0
- package/template/AGENTS.md +37 -0
- package/template/CLAUDE.md +1 -0
- package/template/DECISIONS.md +8 -0
- package/template/README.md +14 -0
- package/template/docs/roadmap.md +14 -0
- package/template/gitignore +7 -0
- package/template/notes/.gitkeep +0 -0
- package/template/plan/.gitkeep +0 -0
- package/template/shape.config.ts +26 -0
- package/template/test/toolchain.test.ts +7 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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*
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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 (
|
|
157
|
+
if (args.length === 2 && args[0] === "rm")
|
|
50
158
|
return {
|
|
51
|
-
output:
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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> = [
|
|
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
|
-
|
|
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 (
|
|
106
|
+
if (blocker === "required" && beads.state !== "ok")
|
|
82
107
|
return [
|
|
83
108
|
prerequisite(
|
|
84
109
|
"beads-required",
|