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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: track
|
|
3
|
+
description: Use at the start of a chat in a track's worktree, when the human says /track or asks to continue a track's work. Finds the track, reads its handoff and queue, interviews the human first if the handoff is a stub, then works the queue and opens PRs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Track: the short horizon
|
|
7
|
+
|
|
8
|
+
A track is one line of parallel work with its own worktree, branch, beads label and handoff.
|
|
9
|
+
The handoff is the whole of what the last session knew; the queue is what is left to do.
|
|
10
|
+
Your job is to pick up exactly where it stopped.
|
|
11
|
+
|
|
12
|
+
## 1. Which track
|
|
13
|
+
|
|
14
|
+
Run `mshape track current`. It prints the track's name. If it exits 1, stop: tell the human
|
|
15
|
+
the reason it gave for this directory not being a track, and suggest
|
|
16
|
+
`mshape track list` to find the worktree they meant, or `mshape track new <name>` if this is
|
|
17
|
+
new work. Never work a track from outside its worktree.
|
|
18
|
+
|
|
19
|
+
## 2. Read
|
|
20
|
+
|
|
21
|
+
- The handoff, `.handoffs/<name>.md`: Messages first (they may change everything else),
|
|
22
|
+
then State, Traps and Charter.
|
|
23
|
+
- AGENTS.md, and whatever the charter points at (often plan/MILESTONE.md, for the
|
|
24
|
+
interfaces this track owns or consumes).
|
|
25
|
+
- The queue: `bd list --label track:<name>` for every open bead, including the one the last
|
|
26
|
+
session left in progress and those blocked on other tracks;
|
|
27
|
+
`bd ready --label track:<name>` for what can start now; `bd show` for each bead you take.
|
|
28
|
+
- Where the code is: `git status`, `git log --oneline main..HEAD`, open PRs from this
|
|
29
|
+
branch (`gh pr list --head track/<name>`), and the gate as AGENTS.md says to run it.
|
|
30
|
+
|
|
31
|
+
Text is a hypothesis: when the handoff or a bead disagrees with the code, trust the code,
|
|
32
|
+
fix the text, and carry on.
|
|
33
|
+
|
|
34
|
+
## 3. A stub: interview first
|
|
35
|
+
|
|
36
|
+
`Status: stub` means nobody has said what this track is for. Before any code, interview the
|
|
37
|
+
human, proposing answers rather than asking open questions:
|
|
38
|
+
|
|
39
|
+
- What is the work, and why now?
|
|
40
|
+
- What does done look like, on real data or a real target?
|
|
41
|
+
- What is out of scope?
|
|
42
|
+
- What does it touch, and does that collide with another track? (`mshape track list` and
|
|
43
|
+
the other handoffs say what is in flight.)
|
|
44
|
+
- What will need the human: spend, data, anything public?
|
|
45
|
+
|
|
46
|
+
Then write the charter, set `Status: active`, write a dated State, and create the beads with
|
|
47
|
+
`--labels track:<name>`, each an intent and an outcome, not a spec. Show the human the
|
|
48
|
+
charter and the beads; start once they agree.
|
|
49
|
+
|
|
50
|
+
## 4. Work
|
|
51
|
+
|
|
52
|
+
- Claim a bead (`bd update <id> --claim`) before starting it, and commit as you go.
|
|
53
|
+
- **Batch beads into PRs** sized as the working-loop conventions say (docs/workflow.md in the
|
|
54
|
+
monkeyshape package): one theme, gate green, its body as AGENTS.md says. Close the beads
|
|
55
|
+
when it merges.
|
|
56
|
+
- **One open PR at a time**, from `track/<name>`: the branch is the PR's head, so a commit
|
|
57
|
+
pushed while it is in review joins it. While it waits, answer its review and prepare the
|
|
58
|
+
next beads without committing. If waiting would stall the track, tell the human.
|
|
59
|
+
- **Diverging from the milestone plan:** say so in the handoff's State. If it changes
|
|
60
|
+
another track's work or the milestone's goal, stop and tell the human.
|
|
61
|
+
- **Talking to another track:** add to its handoff's Messages, saying who it is from and the
|
|
62
|
+
date. Edit the file in place; never rewrite another track's handoff. Read your own
|
|
63
|
+
Messages again after each PR.
|
|
64
|
+
- **A mechanical step with no command:** don't script it into prose; file a bead for the CLI.
|
|
65
|
+
- **The process got in the way:** `bd create --labels process` saying what broke.
|
|
66
|
+
|
|
67
|
+
Update the handoff's State when a PR opens or merges, so a crash loses nothing. When the
|
|
68
|
+
human says the context is running out, run `/handoff`. When this track's `beads` column
|
|
69
|
+
in `mshape track list` shows 0 and the PRs are merged, tell the human the track is done (an
|
|
70
|
+
empty `bd ready` is not enough: it hides beads in progress or blocked). A `-` there means
|
|
71
|
+
beads weren't counted: act on the note `track list` prints, and don't call the track done.
|
|
72
|
+
Removing it is the human's or `/milestone`'s.
|
|
73
|
+
|
|
74
|
+
Review this skill when a fresh chat running it had to ask the human something the handoff
|
|
75
|
+
should have said.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** `mshape init`: writes a new repo from the package's template, installs it and commits it. */
|
|
2
|
+
import { mkdir, readdir, realpath, symlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, relative } from "node:path";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { HANDOFFS_LINK, handoffsLink, handoffsStore } from "../core/layout.ts";
|
|
6
|
+
import { scaffold, type Toolchain } from "../core/scaffold.ts";
|
|
7
|
+
import { resolveConfig } from "../schemas/config.ts";
|
|
8
|
+
import { WorkspaceError } from "../schemas/errors.ts";
|
|
9
|
+
import { runTool } from "./process.ts";
|
|
10
|
+
import { installSkills, readTree } from "./skills.ts";
|
|
11
|
+
|
|
12
|
+
export interface InitResult {
|
|
13
|
+
/** What was done, one line each. */
|
|
14
|
+
readonly done: ReadonlyArray<string>;
|
|
15
|
+
/** What was skipped or failed and how to finish it by hand; the repo still exists. */
|
|
16
|
+
readonly notes: ReadonlyArray<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const fail = (path: string, reason: string) =>
|
|
20
|
+
new WorkspaceError({ operation: "init", path, reason });
|
|
21
|
+
|
|
22
|
+
/** A command's exit code and combined output; one that can't start exits -1 with the reason. */
|
|
23
|
+
const run = (cwd: string, command: ReadonlyArray<string>) =>
|
|
24
|
+
Effect.map(runTool(cwd, command), (ran) => ({
|
|
25
|
+
code: ran.code,
|
|
26
|
+
output: `${ran.stdout}${ran.stderr}`.trim(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
/** The last lines of a tool's output: enough to say why it failed. */
|
|
30
|
+
const tail = (output: string): string => output.split("\n").slice(-3).join("\n ");
|
|
31
|
+
|
|
32
|
+
const isEmptyOrMissing = (dir: string) =>
|
|
33
|
+
Effect.promise(() =>
|
|
34
|
+
readdir(dir).then(
|
|
35
|
+
(entries) => entries.length === 0,
|
|
36
|
+
(error: NodeJS.ErrnoException) => error.code === "ENOENT",
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The handoffs store under git's common directory, and the two links to it. Relative links,
|
|
42
|
+
* so the repo still works after it is moved. A failure is a note with the commands to finish:
|
|
43
|
+
* by now the repo exists, and init refuses to run over it again.
|
|
44
|
+
*/
|
|
45
|
+
const stateDirectory = (target: string, name: string) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const byHand = `In ${name}/ run \`mkdir -p .agents .git/monkeyshape/handoffs && ln -s ../.git/monkeyshape/handoffs .agents/handoffs && ln -s .git/monkeyshape/handoffs ${HANDOFFS_LINK}\``;
|
|
48
|
+
const common = yield* run(target, [
|
|
49
|
+
"git",
|
|
50
|
+
"rev-parse",
|
|
51
|
+
"--path-format=absolute",
|
|
52
|
+
"--git-common-dir",
|
|
53
|
+
]);
|
|
54
|
+
if (common.code !== 0)
|
|
55
|
+
return `the handoffs store is not set up: git did not name its directory. ${byHand}:\n ${tail(common.output)}`;
|
|
56
|
+
const store = handoffsStore(common.output);
|
|
57
|
+
return yield* Effect.promise(async () => {
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(store, { recursive: true });
|
|
60
|
+
// git answers with real paths; a link computed from a symlinked path would miss.
|
|
61
|
+
const repo = await realpath(target);
|
|
62
|
+
for (const link of [handoffsLink(repo), `${repo}/${HANDOFFS_LINK}`]) {
|
|
63
|
+
await mkdir(dirname(link), { recursive: true });
|
|
64
|
+
await symlink(relative(dirname(link), store), link);
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
return `the handoffs store is not fully set up (${String(cause)}). ${byHand}, skipping what exists.`;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `bd init` without its AGENTS.md block, and prime capped at the budgets `mshape check` holds.
|
|
75
|
+
* prime caps characters and the budget counts UTF-8 bytes, so the character cap is only a
|
|
76
|
+
* backstop: while the check passes, memories are within the byte budget, hence also within
|
|
77
|
+
* the same number of characters, and prime never truncates them.
|
|
78
|
+
*/
|
|
79
|
+
const beads = (target: string, name: string) =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const { memories, memoryBytes } = resolveConfig({}).budgets;
|
|
82
|
+
const steps = [
|
|
83
|
+
["bd", "init", "--skip-agents", "--non-interactive", "--quiet", "--prefix", name],
|
|
84
|
+
["bd", "config", "set", "prime.max-memories", String(memories)],
|
|
85
|
+
["bd", "config", "set", "prime.max-memory-chars", String(memoryBytes)],
|
|
86
|
+
];
|
|
87
|
+
const later = steps.map((step) => step.join(" ")).join(" && ");
|
|
88
|
+
if (Bun.which("bd") === null)
|
|
89
|
+
return `bd is not on PATH, so beads is not set up. Install bd, then in ${name}/ run \`${later}\` and commit .beads/`;
|
|
90
|
+
for (const step of steps) {
|
|
91
|
+
const result = yield* run(target, step);
|
|
92
|
+
if (result.code !== 0)
|
|
93
|
+
return `\`${step.join(" ")}\` failed, so beads is not fully set up. In ${name}/ run \`${later}\`:\n ${tail(result.output)}`;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** `packageRoot` is the installed monkeyshape: its `template/` and `skills/` are copied. */
|
|
99
|
+
export const initRepo = (target: string, name: string, packageRoot: string, toolchain: Toolchain) =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
if (!(yield* isEmptyOrMissing(target)))
|
|
102
|
+
return yield* Effect.fail(fail(target, "exists and is not empty; init only creates repos"));
|
|
103
|
+
/** Past this point the directory exists, and init won't run over it again. */
|
|
104
|
+
const partial = (reason: string) =>
|
|
105
|
+
fail(target, `${reason}. Delete ${target} and run mshape init again`);
|
|
106
|
+
const decoder = new TextDecoder();
|
|
107
|
+
const template = new Map(
|
|
108
|
+
[...(yield* readTree(`${packageRoot}/template`))].map(
|
|
109
|
+
([path, bytes]) => [path, decoder.decode(bytes)] as const,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
if (template.size === 0)
|
|
113
|
+
return yield* Effect.fail(fail(packageRoot, "has no template/; reinstall monkeyshape"));
|
|
114
|
+
const files = scaffold(name, template, toolchain);
|
|
115
|
+
yield* Effect.tryPromise({
|
|
116
|
+
try: async () => {
|
|
117
|
+
for (const [path, text] of files) {
|
|
118
|
+
await mkdir(dirname(`${target}/${path}`), { recursive: true });
|
|
119
|
+
await Bun.write(`${target}/${path}`, text);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
catch: (cause) => partial(`cannot write the template: ${String(cause)}`),
|
|
123
|
+
});
|
|
124
|
+
const done = [`wrote ${files.size} files`];
|
|
125
|
+
const notes: Array<string> = [];
|
|
126
|
+
const skills = yield* installSkills(`${packageRoot}/skills`, target).pipe(
|
|
127
|
+
Effect.mapError((error) => partial(`cannot install the skills: ${error.reason}`)),
|
|
128
|
+
);
|
|
129
|
+
done.push(`${skills.sync.added.length} shared skills`);
|
|
130
|
+
|
|
131
|
+
const git = yield* run(target, ["git", "init", "--quiet", "--initial-branch=main"]);
|
|
132
|
+
if (git.code !== 0) return yield* Effect.fail(partial(`git init failed: ${git.output}`));
|
|
133
|
+
done.push("git init (branch main)");
|
|
134
|
+
|
|
135
|
+
const stateNote = yield* stateDirectory(target, name);
|
|
136
|
+
if (stateNote === undefined) done.push("handoffs store");
|
|
137
|
+
else notes.push(stateNote);
|
|
138
|
+
|
|
139
|
+
const beadsNote = yield* beads(target, name);
|
|
140
|
+
if (beadsNote === undefined) done.push("bd init");
|
|
141
|
+
else notes.push(beadsNote);
|
|
142
|
+
|
|
143
|
+
// The current bun, not `bun` from PATH: it is the one running mshape, so it exists.
|
|
144
|
+
const install = yield* run(target, [process.execPath, "install"]);
|
|
145
|
+
if (install.code === 0) {
|
|
146
|
+
done.push("bun install");
|
|
147
|
+
// The generated JSON is laid out by JSON.stringify; the repo's own biome has the last word.
|
|
148
|
+
const format = yield* run(target, [
|
|
149
|
+
process.execPath,
|
|
150
|
+
"node_modules/.bin/biome",
|
|
151
|
+
"format",
|
|
152
|
+
"--write",
|
|
153
|
+
".",
|
|
154
|
+
]);
|
|
155
|
+
if (format.code !== 0)
|
|
156
|
+
notes.push(`biome format failed; run \`bun run fix\`:\n ${tail(format.output)}`);
|
|
157
|
+
} else
|
|
158
|
+
notes.push(
|
|
159
|
+
`bun install failed, so bun.lock is not committed. Run \`bun install\` in ${name}/ and commit bun.lock:\n ${tail(install.output)}`,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// `bd init` commits its own files; fold that into the one first commit, message and all.
|
|
163
|
+
const bdCommit = yield* run(target, ["git", "rev-parse", "--verify", "--quiet", "HEAD"]);
|
|
164
|
+
if (bdCommit.code === 0) yield* run(target, ["git", "update-ref", "-d", "HEAD"]);
|
|
165
|
+
const add = yield* run(target, ["git", "add", "--all"]);
|
|
166
|
+
const commit =
|
|
167
|
+
add.code === 0
|
|
168
|
+
? yield* run(target, ["git", "commit", "--quiet", "-m", `Start ${name} with mshape init`])
|
|
169
|
+
: add;
|
|
170
|
+
if (commit.code === 0) done.push("first commit");
|
|
171
|
+
else
|
|
172
|
+
notes.push(
|
|
173
|
+
`the first commit failed; set your git identity, then \`git add --all && git commit\`:\n ${tail(commit.output)}`,
|
|
174
|
+
);
|
|
175
|
+
return { done, notes } satisfies InitResult;
|
|
176
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Running a command: the one spawn helper every adapter uses. */
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { WorkspaceError } from "../schemas/errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface Ran {
|
|
6
|
+
readonly code: number;
|
|
7
|
+
readonly stdout: string;
|
|
8
|
+
readonly stderr: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Runs `command` in `cwd` to completion. A command that can't start fails with the reason. */
|
|
12
|
+
export const run = (cwd: string, command: ReadonlyArray<string>) =>
|
|
13
|
+
Effect.try({
|
|
14
|
+
try: () => Bun.spawn([...command], { cwd, stdout: "pipe", stderr: "pipe" }),
|
|
15
|
+
catch: (cause) =>
|
|
16
|
+
new WorkspaceError({ operation: "run", path: command.join(" "), reason: String(cause) }),
|
|
17
|
+
}).pipe(
|
|
18
|
+
Effect.flatMap((child) =>
|
|
19
|
+
// Both pipes at once: a tool that fills one while we wait on the other would hang.
|
|
20
|
+
Effect.all(
|
|
21
|
+
[
|
|
22
|
+
Effect.promise(() => new Response(child.stdout).text()),
|
|
23
|
+
Effect.promise(() => new Response(child.stderr).text()),
|
|
24
|
+
Effect.promise(() => child.exited),
|
|
25
|
+
],
|
|
26
|
+
{ concurrency: "unbounded" },
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
Effect.map(([stdout, stderr, code]): Ran => ({ code, stdout, stderr })),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** For optional tools: a spawn failure is exit code -1 with the reason on stderr, not an abort. */
|
|
33
|
+
export const runTool = (cwd: string, command: ReadonlyArray<string>) =>
|
|
34
|
+
run(cwd, command).pipe(
|
|
35
|
+
Effect.catchTag("WorkspaceError", (error) =>
|
|
36
|
+
Effect.succeed<Ran>({ code: -1, stdout: "", stderr: error.reason }),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
@@ -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
|
+
});
|