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,135 @@
1
+ ---
2
+ name: review-pr
3
+ description: Use when asked to review a pull request (a number or URL), to re-review one after its author replied or pushed fixes, or to review a follow-up PR that answers an earlier review's findings. Runs the change on real data in a scratch worktree, probes what it claims to enforce, posts every finding on the PR with a severity, and re-reviews until it can say "LGTM".
4
+ ---
5
+
6
+ # Review a pull request
7
+
8
+ A review is judged by what it **ran and read**, not by what it guessed from the diff. Every
9
+ finding carries its evidence: the input that breaks it, a real-data output, or the line of
10
+ code it rests on. Read AGENTS.md first: it holds the rules the PR is reviewed against.
11
+
12
+ **Ground rules**
13
+ - **The PR is the review's only home.** Findings, evidence, replies and what is still open
14
+ all go on it; nothing about the review is kept anywhere else. A fresh chat re-reviewing
15
+ reads the thread.
16
+ - **Don't fix the PR yourself**: the author fixes findings in the same PR.
17
+ - **Hold the PR to 95%, not 100%.** A blocker is a defect the PR would ship and someone
18
+ would hit, shown by a concrete failing input or a real-data result. "Could be better", or
19
+ an edge nobody has, is a nit, in any round. Style never blocks: the linter owns style.
20
+ - **A nit stays open until it's settled:** fixed in this PR, declined with a reason you
21
+ accept, or filed by the author as a bead when it is real work beyond the PR. Never call a
22
+ blocker a nit to close a review.
23
+ - **As many rounds as that takes.** If each fix keeps opening a new blocker, tell the human:
24
+ the design may need rethinking more than another patch. The human can step in at any point.
25
+ - Real data may be read and run freely; spend past the repo's cap, or writes to shared data,
26
+ need the human first (see AGENTS.md).
27
+
28
+ ## 1. A worktree at the PR's head
29
+
30
+ ```bash
31
+ N=<pr number>
32
+ gh pr view $N --json title,body,files,commits
33
+ gh api repos/{owner}/{repo}/pulls/$N -q .head.sha # the head you review and anchor to
34
+ mshape review $N # detached worktree at that head, shared data linked; prints its path
35
+ cd <that path> && bun install --frozen-lockfile
36
+ ```
37
+
38
+ Never review in your own checkout. If the review's head and the API's differ, the author
39
+ pushed meanwhile: run `mshape review $N` again.
40
+
41
+ **A follow-up PR** that answers an earlier review: read that PR's thread first
42
+ (`gh pr view <earlier> --comments`, and its inline comments as in step 5). Each finding it
43
+ claims to answer is a re-review item (step 5), on top of reviewing its own diff.
44
+
45
+ ## 2. Read the diff
46
+
47
+ Read the PR body and every changed file. Then go through it for:
48
+
49
+ - **The invariants it claims.** If a schema, check or test says it enforces something, list
50
+ the inputs that should fail. Those are your probes in step 3.
51
+ - **Docs it touches, or should have.** Do AGENTS.md, DECISIONS.md, the skills and the
52
+ handoffs still agree with the change? Grep for prose it made stale.
53
+ - **The usual misses.** New code outside the gate (a tsconfig `include`, a test glob)? A
54
+ check that a free-text field walks around? A check-then-write that isn't atomic? A path
55
+ that breaks in a worktree, a symlink or a fresh clone? An error message that doesn't say
56
+ what to do?
57
+
58
+ ## 3. Run it
59
+
60
+ From cheapest to most expensive:
61
+
62
+ 1. **Run what the PR adds**, on real data, in every mode it has. Do the numbers in the PR
63
+ body reproduce? Does each exit code mean what its doc says?
64
+ 2. **Probe checks adversarially**: throwaway scripts that build inputs which should be
65
+ rejected, one variation each, from a real artifact where there is one. **A bad input
66
+ that passes is a finding**, and the probe's input and output are its evidence on the PR.
67
+ Probes are scratch and are never committed or kept: one worth keeping is a regression
68
+ test, and that is the author's to add (step 4). **When the PR adds a check**, also probe
69
+ it with valid input it must pass: a false failure reaches everyone who runs the check,
70
+ and is a blocker.
71
+ 3. **Run the real workflow** when the claim is about what it produces end to end.
72
+
73
+ Don't rerun the gate on the first pass if the PR body shows it green; do on a re-review whose
74
+ fix touched code.
75
+
76
+ ## 4. Post on the PR
77
+
78
+ Post **one review**, inline comments anchored to the head commit, built as a JSON file so
79
+ the quoting survives:
80
+
81
+ ```bash
82
+ # review.json: {commit_id, event: "COMMENT", body, comments: [{path, line, side: "RIGHT", body}]}
83
+ gh api repos/{owner}/{repo}/pulls/$N/reviews --input review.json -q .html_url
84
+ ```
85
+
86
+ Anchor each comment to a head-commit line that is in the diff; a finding with no such line
87
+ goes in the review body. Each comment opens with a **bold one-sentence finding** and its
88
+ severity (blocker or nit), then the evidence, then a suggested fix.
89
+
90
+ - **A blocker in code asks for a regression test** in the PR: one that fails on this head
91
+ and passes once fixed. A blocker in prose (two docs that disagree, a wrong instruction)
92
+ asks for the fix; there is nothing to test.
93
+ - **If a check could rule out the whole class**, say so: the PR body's "Class closed by"
94
+ line is where the author answers it.
95
+
96
+ The body says what was run and what came out, the blockers, the nits with no line, and what
97
+ looked good. Use `COMMENT`: GitHub refuses approve and request-changes on your own
98
+ account's PR. Then tell the human in a few lines: what you ran, each finding and its
99
+ severity, the review link. **Stop and wait** for the author.
100
+
101
+ ## 5. Re-review
102
+
103
+ When told the author responded, read the thread: every reply, the new commits and the fix
104
+ diff (`gh pr view $N --comments`, `gh api repos/{owner}/{repo}/pulls/$N/comments`). The head
105
+ you reviewed last is your previous review's `commit_id`
106
+ (`gh api repos/{owner}/{repo}/pulls/$N/reviews`). Run `mshape review $N` to move the
107
+ worktree to the new head; it prints the previous one too.
108
+
109
+ 1. **"Fixed" is a claim.** For each code blocker, find its regression test and run it on
110
+ both heads. It must fail on the old one and pass on the new:
111
+
112
+ ```bash
113
+ git switch --detach <old head> && git checkout <new head> -- <test file> <files it needs>
114
+ bun test <test file> # must fail
115
+ git reset -q --hard && git switch --detach <new head>
116
+ bun test <test file> # must pass
117
+ ```
118
+
119
+ The failure on the old head must be the test's own assertion, saying what the finding
120
+ said: a load or import error means it never reached the bug, so check out the helpers
121
+ and fixtures it imports too, and rerun `bun install` if the dependencies changed. A test
122
+ that passes on the old head doesn't test the bug: that is a finding. For a prose
123
+ blocker, read the fix. Rerun the real-data runs the findings rested on.
124
+ 2. Look for what the fix opened: a new unchecked field, the mirror case of a new check.
125
+ 3. **Pushback is an argument, not a refusal.** If it holds, say so and drop the point; if it
126
+ doesn't, answer with evidence.
127
+ 4. Reply in the threads, then post a short summary review: what was re-verified, what is
128
+ resolved, what is still open. Tell the human.
129
+
130
+ **When nothing is open, say "LGTM"** explicitly, on the PR and to the human: every blocker
131
+ resolved and verified, every nit settled. Never while a finding is open or unverified.
132
+ Merging is the human's call. When the review is over, `mshape review rm $N`: it lists what
133
+ it discards, which in a review worktree is only scratch, then removes it.
134
+
135
+ Review this skill when a blocker it should have caught reaches `main`.
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: roadmap
3
+ description: Use when the human brings a brain-dump about where the repo is heading (new goals, a change of direction, a fresh repo after mshape init), or asks to rewrite the roadmap. Interviews until the goals are clear, writes docs/roadmap.md with a status table first, keeps one deferred beads epic per goal, and opens a PR.
4
+ ---
5
+
6
+ # Roadmap: the long horizon
7
+
8
+ The roadmap is direction, not a spec: months ahead, coarse, rewritten in place, never cited
9
+ by code or normative docs. Your job is to turn a brain-dump into goals clear enough that a
10
+ later `/milestone` can pick one and give it acceptance on real data.
11
+
12
+ Run this in the main checkout, on an up-to-date `main`, in a fresh chat.
13
+
14
+ ## 1. Read what exists
15
+
16
+ - docs/roadmap.md, if there is one, and AGENTS.md.
17
+ - The direction epics: `bd list --type epic --all`. Deferred ones are goals not yet pulled
18
+ into a milestone; open ones are in flight; closed ones shipped.
19
+ - If a milestone is running, plan/MILESTONE.md, only to know what is already committed.
20
+
21
+ ## 2. Interview
22
+
23
+ Ask in small batches, and propose answers rather than asking open questions: "I read this as
24
+ X; is that right?" is cheaper for the human than "what do you mean?". Keep going until you
25
+ could write, for every goal:
26
+
27
+ - **The consumer:** who or what uses it first. No goal without one.
28
+ - **What done looks like** on real data or a real target, not a feature list.
29
+ - **Why now, or why later**, and what it depends on.
30
+ - **What is out of scope**, said out loud, so the next agent doesn't build it.
31
+ - **Spend or authority** it will need from the human (money, public releases, data).
32
+
33
+ Push back where the brain-dump conflicts with the code, with the repo's principles in
34
+ AGENTS.md, or with itself. The human owns priorities; you own pointing out that two of them
35
+ collide. Stop interviewing when another question would not change what you write.
36
+
37
+ ## 3. Write docs/roadmap.md
38
+
39
+ Rewrite it in place; git has the history, so keep no revision log and no frozen copy.
40
+
41
+ - **Open with a status table**: one row per goal, with status `Shipped`, `Milestone N` (in
42
+ progress), `Later` or `Idea`, and the epic id. A reader learns what exists without reading
43
+ the design.
44
+ - Then scope (who it's for, what it is not), then a section per goal: the intent, the
45
+ consumer, what done looks like, and what was learned so far. Far-off detail belongs here,
46
+ not in beads.
47
+ - No dated promises, no restating AGENTS.md or DECISIONS.md, and nothing other docs would
48
+ need to cite: if something must be enforced, it becomes a check, a test or a decision.
49
+
50
+ ## 4. One deferred epic per goal
51
+
52
+ For each goal without an epic: `bd create --type epic --title "<goal>" --description "..."`
53
+ with the goal, two or three lines of why, and the roadmap section it comes from, then
54
+ `bd defer <id>`. **No child stories:** a milestone creates those when it pulls the epic in.
55
+ For goals that changed, rewrite the epic; for goals dropped, close it with the reason. Put
56
+ each epic's id in the status table.
57
+
58
+ ## 5. PR
59
+
60
+ Branch from `main`, commit, push, and open a PR whose body says what changed in the
61
+ direction and why, which epics were created, rewritten or closed, and what the interview
62
+ left open. Gate green first (`bun run check`). The human merges.
63
+
64
+ Then tell the human the next step: `/milestone` in a fresh chat, with their priorities.
65
+
66
+ Review this skill when a roadmap written with it was hard for `/milestone` to plan from.
@@ -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,55 @@
1
+ /** `.shape/baseline.json`: grandfathered violation counts per rule and file. */
2
+ import { Effect, Schema } from "effect";
3
+ import type { Counts } from "../core/ratchet.ts";
4
+ import { decodeStrict } from "../helpers/decodeStrict.ts";
5
+ import { WorkspaceError } from "../schemas/errors.ts";
6
+ import { readText } from "./workspace.ts";
7
+
8
+ export const BASELINE_FILE = ".shape/baseline.json";
9
+
10
+ const Baseline = Schema.Struct({
11
+ version: Schema.Literal(1),
12
+ counts: Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Int)),
13
+ });
14
+
15
+ export const readBaseline = (root: string) =>
16
+ Effect.gen(function* () {
17
+ if (!(yield* Effect.promise(() => Bun.file(`${root}/${BASELINE_FILE}`).exists())))
18
+ return {} as Counts;
19
+ const text = yield* readText(root, BASELINE_FILE);
20
+ const json = yield* Effect.try({
21
+ try: (): unknown => JSON.parse(text),
22
+ catch: () =>
23
+ new WorkspaceError({ operation: "parse", path: BASELINE_FILE, reason: "not valid JSON" }),
24
+ });
25
+ const baseline = yield* decodeStrict(Baseline)(json).pipe(
26
+ Effect.mapError(
27
+ (error) =>
28
+ new WorkspaceError({ operation: "decode", path: BASELINE_FILE, reason: error.message }),
29
+ ),
30
+ );
31
+ return baseline.counts;
32
+ });
33
+
34
+ const sorted = (counts: Counts): Counts =>
35
+ Object.fromEntries(
36
+ Object.keys(counts)
37
+ .sort()
38
+ .map((rule) => [
39
+ rule,
40
+ Object.fromEntries(
41
+ Object.entries(counts[rule] ?? {}).sort(([a], [b]) => a.localeCompare(b)),
42
+ ),
43
+ ]),
44
+ );
45
+
46
+ export const writeBaseline = (root: string, counts: Counts) =>
47
+ Effect.tryPromise({
48
+ try: () =>
49
+ Bun.write(
50
+ `${root}/${BASELINE_FILE}`,
51
+ `${JSON.stringify({ version: 1, counts: sorted(counts) }, null, 2)}\n`,
52
+ ),
53
+ catch: (cause) =>
54
+ new WorkspaceError({ operation: "write", path: BASELINE_FILE, reason: String(cause) }),
55
+ });
@@ -0,0 +1,25 @@
1
+ /** Loads `shape.config.ts` by importing it, then decodes it strictly. */
2
+ import { Effect } from "effect";
3
+ import { decodeStrict } from "../helpers/decodeStrict.ts";
4
+ import { resolveConfig, ShapeConfig } from "../schemas/config.ts";
5
+ import { ConfigError } from "../schemas/errors.ts";
6
+
7
+ export const CONFIG_FILE = "shape.config.ts";
8
+
9
+ /** `path` overrides the repo's own config, e.g. to try monkeyshape on a repo before adopting it. */
10
+ export const loadConfig = (root: string, path = `${root}/${CONFIG_FILE}`) =>
11
+ Effect.gen(function* () {
12
+ if (!(yield* Effect.promise(() => Bun.file(path).exists())))
13
+ return yield* Effect.fail(
14
+ new ConfigError({ path: CONFIG_FILE, reason: "not found; every adopting repo has one" }),
15
+ );
16
+ const module = yield* Effect.tryPromise({
17
+ try: () => import(path) as Promise<{ readonly default?: unknown }>,
18
+ catch: (cause) =>
19
+ new ConfigError({ path: CONFIG_FILE, reason: `could not load: ${String(cause)}` }),
20
+ });
21
+ const config = yield* decodeStrict(ShapeConfig)(module.default).pipe(
22
+ Effect.mapError((error) => new ConfigError({ path: CONFIG_FILE, reason: error.message })),
23
+ );
24
+ return resolveConfig(config);
25
+ });
@@ -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
+ );