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.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/config/biome.base.json +71 -0
- package/config/tsconfig.base.json +31 -0
- package/docs/workflow.md +131 -0
- package/package.json +68 -4
- 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/baseline.ts +55 -0
- package/src/adapters/config.ts +25 -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 +281 -0
- package/src/api/monkeyshape.ts +8 -0
- package/src/cli/main.ts +359 -0
- package/src/core/arch.ts +224 -0
- package/src/core/budgets.ts +187 -0
- package/src/core/check.ts +122 -0
- package/src/core/coverage.ts +118 -0
- package/src/core/decisions.ts +65 -0
- package/src/core/garden.ts +41 -0
- package/src/core/glob.ts +22 -0
- package/src/core/handoff.ts +183 -0
- package/src/core/hooks.ts +60 -0
- package/src/core/jsonc.ts +26 -0
- package/src/core/layout.ts +123 -0
- package/src/core/markdown.ts +93 -0
- package/src/core/model.ts +100 -0
- package/src/core/parse.ts +41 -0
- package/src/core/paths.ts +19 -0
- package/src/core/ratchet.ts +75 -0
- package/src/core/refs.ts +214 -0
- package/src/core/report.ts +67 -0
- package/src/core/rules/ast.ts +50 -0
- package/src/core/rules/decodeStrict.ts +60 -0
- package/src/core/rules/effectRunAtEdge.ts +33 -0
- package/src/core/rules/engine.ts +150 -0
- package/src/core/rules/noErrorSpread.ts +28 -0
- package/src/core/rules/noNondeterminism.ts +65 -0
- package/src/core/rules/noThrow.ts +79 -0
- package/src/core/rules/registry.ts +15 -0
- package/src/core/scaffold.ts +90 -0
- package/src/core/scope.ts +120 -0
- package/src/core/skills.ts +291 -0
- package/src/core/tracks.ts +358 -0
- package/src/helpers/decodeStrict.ts +8 -0
- package/src/schemas/config.ts +221 -0
- package/src/schemas/errors.ts +31 -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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mshape review <n>` and `mshape review rm <n>`: a pull request's head in a detached worktree
|
|
3
|
+
* beside the tracks, with `tracks.sharedPaths` linked in, for the `review-pr` skill. A review is
|
|
4
|
+
* not a track: no branch, no handoff, no label. Its state lives on the pull request, and the
|
|
5
|
+
* worktree is scratch, removed when the review ends. Paths come from `src/core/layout.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { posix } from "node:path";
|
|
8
|
+
import { Effect } from "effect";
|
|
9
|
+
import { PR_NUMBER, reviewPath, reviewRef } from "../core/layout.ts";
|
|
10
|
+
import { sharedPathProblems } from "../core/tracks.ts";
|
|
11
|
+
import { loadConfig } from "./config.ts";
|
|
12
|
+
import { git, gitOk, ignored, lexists, linkTo, locate, refuse, worktrees } from "./tracks.ts";
|
|
13
|
+
|
|
14
|
+
/** Pull requests are fetched from here, as GitHub publishes them: `refs/pull/<n>/head`. */
|
|
15
|
+
const REMOTE = "origin";
|
|
16
|
+
|
|
17
|
+
const numberProblem = (pr: string) =>
|
|
18
|
+
PR_NUMBER.test(pr)
|
|
19
|
+
? Effect.void
|
|
20
|
+
: refuse(`give the pull request's number, e.g. \`mshape review 12\` (got '${pr}')`);
|
|
21
|
+
|
|
22
|
+
/** Fetch the head into the review's own ref; the commit it names. */
|
|
23
|
+
const fetchHead = (repo: string, pr: string) =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const ref = reviewRef(pr);
|
|
26
|
+
const fetched = yield* git(repo, [
|
|
27
|
+
"fetch",
|
|
28
|
+
"--quiet",
|
|
29
|
+
"--no-write-fetch-head",
|
|
30
|
+
REMOTE,
|
|
31
|
+
`+refs/pull/${pr}/head:${ref}`,
|
|
32
|
+
]);
|
|
33
|
+
if (fetched.code !== 0)
|
|
34
|
+
return yield* refuse(
|
|
35
|
+
`could not fetch pull request #${pr} from \`${REMOTE}\` (as \`refs/pull/${pr}/head\`, which GitHub publishes): ${fetched.stderr || `exit ${fetched.code}`}`,
|
|
36
|
+
);
|
|
37
|
+
return yield* gitOk(repo, ["rev-parse", `${ref}^{commit}`]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `tracks.sharedPaths` linked into the review's worktree, after a create or a move alike: a path
|
|
42
|
+
* the head tracks is the PR's own (git put it there) and only noted; a missing link is made.
|
|
43
|
+
*/
|
|
44
|
+
const linkShared = (repo: string, path: string, pr: string, shared: ReadonlyArray<string>) =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const said: Array<string> = [];
|
|
47
|
+
for (const entry of shared) {
|
|
48
|
+
const target = `${repo}/${entry}`;
|
|
49
|
+
if ((yield* git(path, ["cat-file", "-e", `HEAD:${entry}`])).code === 0)
|
|
50
|
+
said.push(
|
|
51
|
+
`note: shared path \`${entry}\` is tracked at the head of #${pr}; not linked. A shared path must be gitignored, never tracked`,
|
|
52
|
+
);
|
|
53
|
+
else if (yield* lexists(`${path}/${entry}`)) continue;
|
|
54
|
+
else if (!(yield* lexists(target)))
|
|
55
|
+
said.push(`note: shared path \`${entry}\` is missing in ${repo}; not linked`);
|
|
56
|
+
else {
|
|
57
|
+
yield* linkTo(repo, `${path}/${entry}`, target);
|
|
58
|
+
said.push(`linked ${path}/${entry} -> ${target}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return said;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Puts an existing review worktree exactly at `head`. It is scratch, so edits to tracked files
|
|
66
|
+
* are discarded, after `say` lists them; untracked probes stay, unless `head` tracks the same
|
|
67
|
+
* path. Never git's "commit or stash" advice: the stash is shared by every worktree.
|
|
68
|
+
*/
|
|
69
|
+
const resetTo = (path: string, head: string, say: (line: string) => Effect.Effect<void>) =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const status = yield* git(path, ["status", "--porcelain", "--untracked-files=all"]);
|
|
72
|
+
for (const line of status.stdout.split("\n").filter((line) => line.trim() !== "")) {
|
|
73
|
+
const file = line.slice(3);
|
|
74
|
+
if (!line.startsWith("??")) yield* say(`discarding in ${path}: ${line.trim()}`);
|
|
75
|
+
else if ((yield* git(path, ["cat-file", "-e", `${head}:${file}`])).code === 0)
|
|
76
|
+
yield* say(`overwriting in ${path}: ${file}, which the head tracks`);
|
|
77
|
+
}
|
|
78
|
+
yield* gitOk(path, ["checkout", "--quiet", "--force", "--detach", head]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates the review's worktree at the pull request's head, or puts an existing one there,
|
|
83
|
+
* printing the previous head: a re-review checks a regression test against both.
|
|
84
|
+
*/
|
|
85
|
+
export const reviewNew = (cwd: string, pr: string, say: (line: string) => Effect.Effect<void>) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
yield* numberProblem(pr);
|
|
88
|
+
const { repo } = yield* locate(cwd);
|
|
89
|
+
const shared = (yield* loadConfig(repo)).tracks.sharedPaths;
|
|
90
|
+
const problems = sharedPathProblems(shared);
|
|
91
|
+
if (problems.length > 0) return yield* refuse(problems.join("\n"));
|
|
92
|
+
const path = reviewPath(repo, pr);
|
|
93
|
+
const registered = (yield* worktrees(repo)).some((worktree) => worktree.path === path);
|
|
94
|
+
const present = yield* lexists(path);
|
|
95
|
+
if (registered && !present)
|
|
96
|
+
return yield* refuse(
|
|
97
|
+
`git still has a worktree registered at ${path}: run \`git worktree prune\`, then this again`,
|
|
98
|
+
);
|
|
99
|
+
if (!registered && present)
|
|
100
|
+
return yield* refuse(
|
|
101
|
+
`${path} exists but git has no worktree there: remove it, then this again`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const head = yield* fetchHead(repo, pr);
|
|
105
|
+
if (registered) {
|
|
106
|
+
const before = yield* gitOk(path, ["rev-parse", "HEAD"]);
|
|
107
|
+
yield* resetTo(path, head, say);
|
|
108
|
+
const linked = yield* linkShared(repo, path, pr, shared);
|
|
109
|
+
return [
|
|
110
|
+
...(before === head
|
|
111
|
+
? [`${path} is at the head of #${pr}: ${head}`]
|
|
112
|
+
: [`moved ${path} to the head of #${pr}: ${head}`, `previous head: ${before}`]),
|
|
113
|
+
...linked,
|
|
114
|
+
`next: cd ${path}`,
|
|
115
|
+
].join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const notes: Array<string> = [];
|
|
119
|
+
if (!(yield* ignored(repo, posix.relative(repo, path))))
|
|
120
|
+
notes.push(
|
|
121
|
+
`note: the worktrees directory (\`${posix.relative(repo, path)}\`) is not gitignored; add it to .gitignore`,
|
|
122
|
+
);
|
|
123
|
+
yield* gitOk(repo, ["worktree", "add", "--quiet", "--detach", path, head]);
|
|
124
|
+
const created = `created ${path} at the head of #${pr}: ${head}`;
|
|
125
|
+
const linked = yield* linkShared(repo, path, pr, shared).pipe(
|
|
126
|
+
Effect.catch((error) =>
|
|
127
|
+
refuse(
|
|
128
|
+
[
|
|
129
|
+
`review ${pr}: stopped: ${error.message}`,
|
|
130
|
+
`done: ${created}`,
|
|
131
|
+
`undo with \`mshape review rm ${pr}\``,
|
|
132
|
+
].join("\n "),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
return [...notes, created, ...linked, `next: cd ${path}`].join("\n");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Removes the review's worktree and its fetched ref. Everything in a review worktree is scratch,
|
|
141
|
+
* so changes in it are discarded, after `say` lists them. Shared data is only ever linked, and
|
|
142
|
+
* removing the worktree removes the links, never their targets.
|
|
143
|
+
*/
|
|
144
|
+
export const reviewRm = (cwd: string, pr: string, say: (line: string) => Effect.Effect<void>) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
yield* numberProblem(pr);
|
|
147
|
+
const { repo } = yield* locate(cwd);
|
|
148
|
+
const path = reviewPath(repo, pr);
|
|
149
|
+
const registered = (yield* worktrees(repo)).some((worktree) => worktree.path === path);
|
|
150
|
+
const present = yield* lexists(path);
|
|
151
|
+
if (!registered && present)
|
|
152
|
+
return yield* refuse(
|
|
153
|
+
`${path} exists but git has no worktree there, so it isn't a review's: left alone`,
|
|
154
|
+
);
|
|
155
|
+
const removed: Array<string> = [];
|
|
156
|
+
if (registered && present) {
|
|
157
|
+
const status = yield* gitOk(path, ["status", "--porcelain", "--untracked-files=all"]);
|
|
158
|
+
const changes = status.split("\n").filter((line) => line.trim() !== "");
|
|
159
|
+
yield* Effect.forEach(changes, (line) => say(`discarding in ${path}: ${line.trim()}`));
|
|
160
|
+
yield* gitOk(repo, ["worktree", "remove", "--force", path]);
|
|
161
|
+
removed.push(`removed ${path}`);
|
|
162
|
+
}
|
|
163
|
+
const ref = reviewRef(pr);
|
|
164
|
+
if ((yield* git(repo, ["show-ref", "--verify", "--quiet", ref])).code === 0) {
|
|
165
|
+
yield* gitOk(repo, ["update-ref", "-d", ref]);
|
|
166
|
+
removed.push(`removed ${ref}`);
|
|
167
|
+
}
|
|
168
|
+
yield* gitOk(repo, ["worktree", "prune"]);
|
|
169
|
+
return removed.length > 0 ? removed.join("\n") : `no review of #${pr} here; nothing removed`;
|
|
170
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Installs the package's shared skills into a repo, and links them where Claude Code looks. */
|
|
2
|
+
import { lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import {
|
|
6
|
+
CLAUDE_SKILLS_LINK,
|
|
7
|
+
CLAUDE_SKILLS_TARGET,
|
|
8
|
+
SKILLS_DIR,
|
|
9
|
+
SKILLS_MANIFEST,
|
|
10
|
+
} from "../core/layout.ts";
|
|
11
|
+
import type { SkillFiles, SkillsLink, SkillsSnapshot } from "../core/model.ts";
|
|
12
|
+
import { parseManifest, type SkillSync, syncSkills } from "../core/skills.ts";
|
|
13
|
+
import { WorkspaceError } from "../schemas/errors.ts";
|
|
14
|
+
|
|
15
|
+
/** Whether anything is at `path`, a dangling link included. */
|
|
16
|
+
const present = (path: string): Promise<boolean> =>
|
|
17
|
+
lstat(path).then(
|
|
18
|
+
() => true,
|
|
19
|
+
() => false,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every file under `dir` as bytes, by relative path; a missing directory has none. Symlinks
|
|
24
|
+
* are followed, so a skill linked in from elsewhere is read like any other.
|
|
25
|
+
*/
|
|
26
|
+
export const readTree = (dir: string) =>
|
|
27
|
+
Effect.tryPromise({
|
|
28
|
+
try: async () => {
|
|
29
|
+
const files = new Map<string, Uint8Array>();
|
|
30
|
+
if (!(await present(dir))) return files as SkillFiles;
|
|
31
|
+
const glob = new Bun.Glob("**").scan({ cwd: dir, dot: true, followSymlinks: true });
|
|
32
|
+
for await (const path of glob) files.set(path, await Bun.file(`${dir}/${path}`).bytes());
|
|
33
|
+
return files as SkillFiles;
|
|
34
|
+
},
|
|
35
|
+
catch: (cause) => new WorkspaceError({ operation: "read", path: dir, reason: String(cause) }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export interface SkillsInstall {
|
|
39
|
+
readonly sync: SkillSync;
|
|
40
|
+
/** Whether `.claude/skills` had to be created. */
|
|
41
|
+
readonly linked: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Brings `<repo>/.agents/skills` up to the package's `skills/`, and creates the
|
|
46
|
+
* `.claude/skills` link if it is missing. An existing link or directory there is left alone:
|
|
47
|
+
* `mshape check` says what is wrong with it.
|
|
48
|
+
*/
|
|
49
|
+
export const installSkills = (shippedDir: string, repo: string) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const installedDir = `${repo}/${SKILLS_DIR}`;
|
|
52
|
+
const installed = yield* readTree(installedDir);
|
|
53
|
+
const manifest = installed.get(SKILLS_MANIFEST);
|
|
54
|
+
// An unreadable manifest can't tell shared skills from the repo's own: touch nothing.
|
|
55
|
+
if (manifest !== undefined && parseManifest(manifest) === undefined)
|
|
56
|
+
return yield* Effect.fail(
|
|
57
|
+
new WorkspaceError({
|
|
58
|
+
operation: "sync skills",
|
|
59
|
+
path: `${SKILLS_DIR}/${SKILLS_MANIFEST}`,
|
|
60
|
+
reason: `is not the list sync writes, so nothing was changed. Restore it with \`git checkout -- ${SKILLS_DIR}/${SKILLS_MANIFEST}\``,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
const sync = syncSkills(yield* readTree(shippedDir), installed);
|
|
64
|
+
const link = `${repo}/${CLAUDE_SKILLS_LINK}`;
|
|
65
|
+
const linked = yield* Effect.tryPromise({
|
|
66
|
+
try: async () => {
|
|
67
|
+
await mkdir(installedDir, { recursive: true });
|
|
68
|
+
for (const path of sync.remove) await rm(`${installedDir}/${path}`);
|
|
69
|
+
for (const skill of sync.retired) {
|
|
70
|
+
const target = resolve(installedDir, skill);
|
|
71
|
+
// Belt and braces: syncSkills only retires installed skill directories.
|
|
72
|
+
if (dirname(target) !== resolve(installedDir))
|
|
73
|
+
throw new Error(`refusing to remove ${target}: not a skill directory`);
|
|
74
|
+
await rm(target, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
for (const [path, bytes] of sync.write) {
|
|
77
|
+
await mkdir(dirname(`${installedDir}/${path}`), { recursive: true });
|
|
78
|
+
await Bun.write(`${installedDir}/${path}`, bytes);
|
|
79
|
+
}
|
|
80
|
+
if (await present(link)) return false;
|
|
81
|
+
await mkdir(dirname(link), { recursive: true });
|
|
82
|
+
await symlink(CLAUDE_SKILLS_TARGET, link);
|
|
83
|
+
return true;
|
|
84
|
+
},
|
|
85
|
+
catch: (cause) =>
|
|
86
|
+
new WorkspaceError({ operation: "install skills", path: repo, reason: String(cause) }),
|
|
87
|
+
});
|
|
88
|
+
return { sync, linked } satisfies SkillsInstall;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const linkAt = (path: string): Promise<SkillsLink> =>
|
|
92
|
+
lstat(path).then(
|
|
93
|
+
async (info): Promise<SkillsLink> =>
|
|
94
|
+
info.isSymbolicLink()
|
|
95
|
+
? { kind: "link", target: await readlink(path) }
|
|
96
|
+
: info.isDirectory()
|
|
97
|
+
? { kind: "directory" }
|
|
98
|
+
: { kind: "other" },
|
|
99
|
+
(): SkillsLink => ({ kind: "missing" }),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/** What `mshape check` needs to verify the install, read from disk as agents will see it. */
|
|
103
|
+
export const loadSkills = (shippedDir: string, repo: string) =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const link = yield* Effect.promise(() => linkAt(`${repo}/${CLAUDE_SKILLS_LINK}`));
|
|
106
|
+
return {
|
|
107
|
+
link,
|
|
108
|
+
installed: yield* readTree(`${repo}/${SKILLS_DIR}`),
|
|
109
|
+
claudeDirectory:
|
|
110
|
+
link.kind === "directory" ? yield* readTree(`${repo}/${CLAUDE_SKILLS_LINK}`) : new Map(),
|
|
111
|
+
shipped: yield* readTree(shippedDir),
|
|
112
|
+
} satisfies SkillsSnapshot;
|
|
113
|
+
});
|