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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * The decisions behind `mshape track`: which shared paths are allowed, which directory is a
3
+ * track, what `rm` may delete and in what order, and how `list` reads. Git and the filesystem
4
+ * are read by `src/adapters/tracks.ts`, which hands the facts here.
5
+ */
6
+ import type { ResolvedConfig } from "../schemas/config.ts";
7
+ import { beadsBlocker } from "./check.ts";
8
+ import {
9
+ AGENTS_HANDOFFS_LINK,
10
+ HANDOFFS_LINK,
11
+ TRACK_NAME,
12
+ trackBranch,
13
+ trackLabel,
14
+ trackOf,
15
+ WORKTREES_DIR,
16
+ worktreePath,
17
+ } from "./layout.ts";
18
+ import type { Integration } from "./model.ts";
19
+
20
+ /** A worktree as `git worktree list --porcelain` reports it. */
21
+ export interface Worktree {
22
+ readonly path: string;
23
+ /** Short name, e.g. `track/docs`; undefined when detached or bare. */
24
+ readonly branch: string | undefined;
25
+ /** Registered, but its directory is gone: `git worktree prune` drops it. */
26
+ readonly prunable: boolean;
27
+ }
28
+
29
+ export const parseWorktrees = (porcelain: string): ReadonlyArray<Worktree> =>
30
+ porcelain
31
+ .split(/\n\n+/)
32
+ .map((block) => block.split("\n"))
33
+ .flatMap((lines) => {
34
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
35
+ const ref = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length);
36
+ return path === undefined
37
+ ? []
38
+ : [
39
+ {
40
+ path,
41
+ branch: ref?.startsWith("refs/heads/") ? ref.slice(11) : ref,
42
+ prunable: lines.some((line) => line === "prunable" || line.startsWith("prunable ")),
43
+ },
44
+ ];
45
+ });
46
+
47
+ export const nameProblem = (track: string): string | undefined =>
48
+ TRACK_NAME.test(track)
49
+ ? undefined
50
+ : `\`${track}\` is not a track name: use lowercase words joined by hyphens, like \`docs\` or \`cross-module\``;
51
+
52
+ /**
53
+ * `tracks.sharedPaths` as a whole. The schema has already made each entry normal; here the
54
+ * entries must not repeat or nest, nor overlap what the track commands manage: `.git`, the
55
+ * handoff links and the worktrees directory, which linked into a worktree would contain itself.
56
+ */
57
+ export const sharedPathProblems = (paths: ReadonlyArray<string>): ReadonlyArray<string> => {
58
+ const within = (path: string, root: string) => path === root || path.startsWith(`${root}/`);
59
+ const problems: Array<string> = [];
60
+ paths.forEach((path, index) => {
61
+ if (paths.indexOf(path) !== index) problems.push(`\`${path}\` is listed twice`);
62
+ for (const reserved of [".git", HANDOFFS_LINK, AGENTS_HANDOFFS_LINK, WORKTREES_DIR])
63
+ if (within(path, reserved) || within(reserved, path))
64
+ problems.push(
65
+ `\`${path}\` overlaps \`${reserved}\`, which the track commands manage themselves: share something narrower`,
66
+ );
67
+ for (const other of paths)
68
+ if (other !== path && within(path, other))
69
+ problems.push(`\`${path}\` lies under \`${other}\`: list only \`${other}\``);
70
+ });
71
+ return problems.map((problem) => `tracks.sharedPaths in shape.config.ts: ${problem}`);
72
+ };
73
+
74
+ /** Why the directory `toplevel` is or is not a track: the name, or which check failed. */
75
+ export const currentTrack = (
76
+ repo: string,
77
+ toplevel: string,
78
+ worktrees: ReadonlyArray<Worktree>,
79
+ ): { readonly track: string } | { readonly reason: string } => {
80
+ const track = trackOf(repo, toplevel);
81
+ if (track === undefined)
82
+ return {
83
+ reason: `${toplevel} is not a track worktree: tracks live at ${worktreePath(repo, "<track>")}`,
84
+ };
85
+ const registered = worktrees.find((worktree) => worktree.path === toplevel);
86
+ if (registered === undefined)
87
+ return {
88
+ reason: `${toplevel} is named like track \`${track}\`, but git has no worktree registered there for ${repo}`,
89
+ };
90
+ if (registered.branch !== trackBranch(track))
91
+ return {
92
+ reason: `${toplevel} is on ${registered.branch === undefined ? "a detached HEAD" : `branch \`${registered.branch}\``}, not \`${trackBranch(track)}\``,
93
+ };
94
+ return { track };
95
+ };
96
+
97
+ /** A bead that isn't closed, as `bd list --json` reports it. */
98
+ export interface OpenBead {
99
+ readonly id: string;
100
+ readonly title: string;
101
+ readonly status: string;
102
+ }
103
+
104
+ /**
105
+ * Beads that aren't closed, by the track each `track:<name>` label names: a bead labelled for
106
+ * two tracks belongs to both. Rows that aren't beads are skipped.
107
+ */
108
+ export const openBeadsByTrack = (
109
+ rows: ReadonlyArray<unknown>,
110
+ ): ReadonlyMap<string, ReadonlyArray<OpenBead>> => {
111
+ const byTrack = new Map<string, Array<OpenBead>>();
112
+ for (const row of rows) {
113
+ if (typeof row !== "object" || row === null) continue;
114
+ const { id, title, status, labels } = row as Record<string, unknown>;
115
+ if (typeof id !== "string" || status === "closed" || !Array.isArray(labels)) continue;
116
+ const bead = {
117
+ id,
118
+ title: typeof title === "string" ? title : "",
119
+ status: typeof status === "string" ? status : "unknown",
120
+ };
121
+ for (const label of labels) {
122
+ const track = typeof label === "string" ? /^track:(.+)$/.exec(label)?.[1] : undefined;
123
+ if (track !== undefined && TRACK_NAME.test(track))
124
+ byTrack.set(track, [...(byTrack.get(track) ?? []), bead]);
125
+ }
126
+ }
127
+ return byTrack;
128
+ };
129
+
130
+ /**
131
+ * What beads said about a track's open work: the integration state `mshape check` uses, the
132
+ * config's `beads` setting, and, when the state is ok, the track's beads that aren't closed.
133
+ */
134
+ export interface BeadsCheck {
135
+ readonly integration: Integration;
136
+ readonly setting: ResolvedConfig["beads"];
137
+ readonly open: ReadonlyArray<OpenBead>;
138
+ }
139
+
140
+ /** What `track rm` found, before touching anything. */
141
+ export interface RmFacts {
142
+ readonly track: string;
143
+ readonly worktreePath: string;
144
+ /** The worktree git has registered at the track's path, if any. */
145
+ readonly worktree: Worktree | undefined;
146
+ /** Something is at the track's path on disk. */
147
+ readonly pathExists: boolean;
148
+ /** `git status --porcelain` lines in the worktree; empty when clean or gone. */
149
+ readonly changes: ReadonlyArray<string>;
150
+ /** Set when `git status` failed in the worktree: its changes can't be known. */
151
+ readonly statusError: string | undefined;
152
+ readonly branchExists: boolean;
153
+ /** Commits on the branch in none of `mergeTargets`. */
154
+ readonly unmerged: number;
155
+ /** The branch's work is in a target anyway: merged, rebased or squashed there. */
156
+ readonly merged: boolean;
157
+ /** What counts as merged, as people say them: `main`, and `origin/main` when it exists. */
158
+ readonly mergeTargets: ReadonlyArray<string>;
159
+ /** Other checkouts that have the branch checked out. */
160
+ readonly branchCheckedOutAt: ReadonlyArray<Worktree>;
161
+ readonly handoffPath: string;
162
+ readonly handoffExists: boolean;
163
+ /** Beads labelled `track:<track>` that aren't closed: removing the track would orphan them. */
164
+ readonly beads: BeadsCheck;
165
+ readonly force: boolean;
166
+ readonly keepHandoff: boolean;
167
+ }
168
+
169
+ export type RmStep =
170
+ | { readonly kind: "worktree"; readonly path: string; readonly force: boolean }
171
+ | { readonly kind: "branch"; readonly name: string }
172
+ | { readonly kind: "handoff"; readonly path: string };
173
+
174
+ export interface RmPlan {
175
+ /** Any refusal means nothing is touched. */
176
+ readonly refusals: ReadonlyArray<string>;
177
+ /** Said before the steps run: what `--force` is about to discard. */
178
+ readonly warnings: ReadonlyArray<string>;
179
+ /** In order: worktree, branch, handoff. `git worktree prune` follows them. */
180
+ readonly steps: ReadonlyArray<RmStep>;
181
+ /** What stays, and why, when nothing is wrong with keeping it. */
182
+ readonly kept: ReadonlyArray<string>;
183
+ /** Said whether or not it refuses: a check that couldn't run. */
184
+ readonly notes: ReadonlyArray<string>;
185
+ }
186
+
187
+ export const describeStep = (step: RmStep): string =>
188
+ step.kind === "worktree"
189
+ ? `worktree ${step.path}`
190
+ : step.kind === "branch"
191
+ ? `branch \`${step.name}\``
192
+ : `handoff ${step.path}`;
193
+
194
+ /**
195
+ * Only the track's own worktree, branch and handoff are ever candidates, so the main
196
+ * checkout, other tracks and the handoff store are out of reach by construction. `--force`
197
+ * overrides exactly two refusals: uncommitted changes and unmerged commits, both as listed.
198
+ * Changes that can't be read are not changes the user agreed to discard. Open beads are never
199
+ * overridden: closing or moving each one is a single command, and removing the track would
200
+ * leave them with no one to do them.
201
+ */
202
+ export const planRm = (facts: RmFacts): RmPlan => {
203
+ const branch = trackBranch(facts.track);
204
+ const refusals: Array<string> = [];
205
+ const warnings: Array<string> = [];
206
+ const kept: Array<string> = [];
207
+ const steps: Array<RmStep> = [];
208
+ const label = trackLabel(facts.track);
209
+ const notes: Array<string> = [];
210
+ const listBeads = `bd list --label ${label} --status open,in_progress,blocked,deferred`;
211
+ const { integration, setting, open } = facts.beads;
212
+ const blocker = beadsBlocker(setting, integration);
213
+ if (blocker === undefined && integration.state === "absent")
214
+ notes.push("this repo has no beads (no .beads/), so open beads were not checked");
215
+ if (blocker === undefined && integration.state === "unavailable")
216
+ notes.push(
217
+ `open beads were not checked: ${integration.reason}. Check by hand with \`${listBeads}\``,
218
+ );
219
+
220
+ if (
221
+ facts.worktree === undefined &&
222
+ !facts.pathExists &&
223
+ !facts.branchExists &&
224
+ !facts.handoffExists
225
+ )
226
+ return {
227
+ refusals: [
228
+ `there is no track \`${facts.track}\`: no worktree, no branch \`${branch}\`, no handoff`,
229
+ ],
230
+ warnings,
231
+ steps,
232
+ kept,
233
+ notes: [],
234
+ };
235
+
236
+ if (facts.worktree === undefined && facts.pathExists)
237
+ refusals.push(
238
+ `${facts.worktreePath} exists but git has no worktree registered there: look inside and remove it by hand`,
239
+ );
240
+ if (facts.worktree !== undefined && facts.worktree.branch !== branch)
241
+ refusals.push(
242
+ `the worktree at ${facts.worktreePath} is on ${facts.worktree.branch === undefined ? "a detached HEAD" : `\`${facts.worktree.branch}\``}, not \`${branch}\`: check it out there, or remove it by hand`,
243
+ );
244
+ for (const elsewhere of facts.branchCheckedOutAt)
245
+ refusals.push(
246
+ elsewhere.prunable
247
+ ? `\`${branch}\` is still registered as checked out at ${elsewhere.path}, which no longer exists: run \`git worktree prune\`, then retry`
248
+ : `\`${branch}\` is checked out at ${elsewhere.path}: switch that checkout to another branch first`,
249
+ );
250
+ if (integration.state === "ok" && open.length > 0)
251
+ refusals.push(
252
+ [
253
+ `\`${label}\` still labels ${open.length} bead(s) that aren't closed, and removing the track would orphan them:`,
254
+ ...open.map((bead) => ` ${bead.id} (${bead.status}) ${bead.title}`),
255
+ `close each one that's done (\`bd close <id>\`), or move it to the track that will do it (\`bd update <id> --remove-label ${label} --add-label track:<other>\`). --force doesn't override this`,
256
+ ].join("\n "),
257
+ );
258
+ // A failing bd hides the beads, so there is nothing for --force to agree to discard.
259
+ if (blocker === "failed" && integration.state === "failed")
260
+ refusals.push(
261
+ `bd failed, so the open beads labelled \`${label}\` can't be seen: ${integration.reason}. Get \`bd\` working, then retry; --force doesn't override this`,
262
+ );
263
+ if (blocker === "required")
264
+ refusals.push(
265
+ integration.state === "absent"
266
+ ? `shape.config.ts requires beads, but the repo has no .beads/config.yaml, so the open beads labelled \`${label}\` can't be checked: set beads up, or set \`beads: "optional"\``
267
+ : `shape.config.ts requires beads, but ${"reason" in integration ? integration.reason : "it didn't answer"}, so the open beads labelled \`${label}\` can't be checked: get \`bd\` working, then retry`,
268
+ );
269
+ if (facts.statusError !== undefined)
270
+ refusals.push(
271
+ `can't read the state of ${facts.worktreePath} (\`git status\` failed: ${facts.statusError}), so --force can't know what it would discard: inspect it by hand`,
272
+ );
273
+
274
+ if (facts.changes.length > 0) {
275
+ const shown = facts.changes.slice(0, 5).join(", ");
276
+ const more = facts.changes.length > 5 ? ` and ${facts.changes.length - 5} more` : "";
277
+ if (facts.force)
278
+ warnings.push(`discarding uncommitted changes in ${facts.worktreePath}: ${shown}${more}`);
279
+ else
280
+ refusals.push(
281
+ `${facts.worktreePath} has uncommitted or untracked changes (${shown}${more}): commit or discard them, or pass --force to discard them`,
282
+ );
283
+ }
284
+ if (facts.branchExists && !facts.merged) {
285
+ const targets = facts.mergeTargets.map((target) => `\`${target}\``).join(" or ");
286
+ if (facts.force)
287
+ warnings.push(`deleting \`${branch}\` with ${facts.unmerged} commit(s) not in ${targets}`);
288
+ else
289
+ refusals.push(
290
+ `\`${branch}\` has ${facts.unmerged} commit(s) not in ${targets}, and its work isn't there by a merge, a rebase or a squash either: merge it first, or pass --force to delete it`,
291
+ );
292
+ }
293
+
294
+ if (facts.worktree !== undefined)
295
+ steps.push({ kind: "worktree", path: facts.worktreePath, force: facts.force });
296
+ if (facts.branchExists) steps.push({ kind: "branch", name: branch });
297
+ if (facts.handoffExists) {
298
+ if (facts.keepHandoff) kept.push(`handoff ${facts.handoffPath} (--keep-handoff)`);
299
+ else steps.push({ kind: "handoff", path: facts.handoffPath });
300
+ }
301
+ return refusals.length > 0
302
+ ? { refusals, warnings: [], steps: [], kept: [], notes }
303
+ : { refusals, warnings, steps, kept, notes };
304
+ };
305
+
306
+ /** One line per track in `track list`. */
307
+ export interface TrackRow {
308
+ readonly track: string;
309
+ readonly status: "stub" | "active" | "invalid" | "none";
310
+ /** Beads labelled `track:<track>` that aren't closed; undefined when beads couldn't say. */
311
+ readonly beads: number | undefined;
312
+ /** Commits not in `main`; undefined without a branch. */
313
+ readonly ahead: number | undefined;
314
+ /** Merged, rebased or squashed into a target; undefined without a branch. */
315
+ readonly merged?: boolean | undefined;
316
+ /** Relative to the main checkout; undefined without a worktree. */
317
+ readonly worktree: string | undefined;
318
+ /** Set when the worktree is on another branch than `track/<track>`. */
319
+ readonly worktreeBranch?: string | undefined;
320
+ /** Why the handoff is `invalid`: `parseHandoff`'s problems, printed under the row. */
321
+ readonly problems?: ReadonlyArray<string> | undefined;
322
+ }
323
+
324
+ export const formatTracks = (rows: ReadonlyArray<TrackRow>): string => {
325
+ if (rows.length === 0) return "no tracks: start one with `mshape track new <name>`";
326
+ const table = [
327
+ ["track", "status", "beads", "ahead", "merged", "worktree"],
328
+ ...rows.map((row) => [
329
+ row.track,
330
+ row.status,
331
+ row.beads === undefined ? "-" : String(row.beads),
332
+ row.ahead === undefined ? "-" : String(row.ahead),
333
+ row.merged === undefined ? "-" : row.merged ? "yes" : "no",
334
+ row.worktree === undefined
335
+ ? "-"
336
+ : row.worktreeBranch === undefined
337
+ ? row.worktree
338
+ : `${row.worktree} (on ${row.worktreeBranch})`,
339
+ ]),
340
+ ];
341
+ const widths =
342
+ table[0]?.map((_, column) => Math.max(...table.map((cells) => cells[column]?.length ?? 0))) ??
343
+ [];
344
+ const line = (cells: ReadonlyArray<string>) =>
345
+ cells
346
+ .map((cell, column) =>
347
+ column === cells.length - 1 ? cell : cell.padEnd((widths[column] ?? 0) + 2),
348
+ )
349
+ .join("");
350
+ // Each problem names the file through the `.handoffs` link, which every checkout has.
351
+ return [
352
+ line(table[0] ?? []),
353
+ ...rows.flatMap((row, index) => [
354
+ line(table[index + 1] ?? []),
355
+ ...(row.problems ?? []).map((problem) => ` ${HANDOFFS_LINK}/${row.track}.md: ${problem}`),
356
+ ]),
357
+ ].join("\n");
358
+ };
@@ -36,6 +36,14 @@ export const RuleSetting = Schema.Struct({
36
36
  allow: Schema.optionalKey(Schema.Array(Allowance)),
37
37
  });
38
38
 
39
+ /**
40
+ * Repo-relative and already normal: segments joined by single `/`, none empty, `.` or `..`.
41
+ * So `cases` and `data/large` pass; `/abs`, `../x`, `.`, `./x`, `data//large`, `cases/` don't.
42
+ */
43
+ export const SharedPath = Schema.String.check(
44
+ Schema.isPattern(/^(?!\.\.?(?:\/|$))[^/\0]+(?:\/(?!\.\.?(?:\/|$))[^/\0]+)*$/),
45
+ );
46
+
39
47
  export const ForbiddenPattern = Schema.Struct({
40
48
  /** A regular expression source, matched against docs and code comments. */
41
49
  pattern: Schema.String,
@@ -61,6 +69,16 @@ export const ShapeConfig = Schema.Struct({
61
69
  rules: Schema.optionalKey(Schema.Record(Schema.String, RuleSetting)),
62
70
  /** `required` fails the check when bd can't answer, so a green CI run means beads was checked. */
63
71
  beads: Schema.optionalKey(Schema.Literals(["optional", "required"])),
72
+ tracks: Schema.optionalKey(
73
+ Schema.Struct({
74
+ /**
75
+ * Gitignored paths every track worktree shares with the main checkout, such as a large
76
+ * dataset: repo-relative, no `..`. `mshape track new` links `<worktree>/<path>` to
77
+ * `<main>/<path>`. The semantics are in plan/MILESTONE.md, "Interfaces the tracks share".
78
+ */
79
+ sharedPaths: Schema.optionalKey(Schema.Array(SharedPath)),
80
+ }),
81
+ ),
64
82
  refs: Schema.optionalKey(
65
83
  Schema.Struct({
66
84
  docs: Schema.optionalKey(Globs),
@@ -101,6 +119,7 @@ export interface ResolvedConfig {
101
119
  };
102
120
  readonly entry: ReadonlyArray<string>;
103
121
  readonly beads: "optional" | "required";
122
+ readonly tracks: { readonly sharedPaths: ReadonlyArray<string> };
104
123
  readonly arch:
105
124
  | {
106
125
  readonly root: string;
@@ -154,6 +173,7 @@ export const resolveConfig = (config: ShapeConfig): ResolvedConfig => ({
154
173
  },
155
174
  entry: config.entry ?? ["src/cli/**", "tools/**"],
156
175
  beads: config.beads ?? "optional",
176
+ tracks: { sharedPaths: config.tracks?.sharedPaths ?? [] },
157
177
  arch:
158
178
  config.arch === undefined
159
179
  ? undefined
@@ -20,3 +20,12 @@ export class ConfigError extends Schema.TaggedError<ConfigError>()("ConfigError"
20
20
  return `${this.path}: ${this.reason}`;
21
21
  }
22
22
  }
23
+
24
+ /** A track command refused, or stopped partway; the message says which, and what to do. */
25
+ export class TrackError extends Schema.TaggedError<TrackError>()("TrackError", {
26
+ reason: Schema.String,
27
+ }) {
28
+ override get message(): string {
29
+ return this.reason;
30
+ }
31
+ }
@@ -0,0 +1,11 @@
1
+ ## What changed, and why
2
+
3
+ ## Gate
4
+
5
+ <!-- The tail of `bun run check`. -->
6
+
7
+ ## Bug class a new check closes
8
+
9
+ <!-- Or "none". -->
10
+
11
+ ## Not done
@@ -0,0 +1,22 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ check:
14
+ runs-on: ubuntu-latest
15
+ timeout-minutes: 10
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: oven-sh/setup-bun@v2
19
+ with:
20
+ bun-version-file: package.json
21
+ - run: bun install --frozen-lockfile
22
+ - run: bun run check
@@ -0,0 +1,37 @@
1
+ # {{name}}
2
+
3
+ What this repo is, in two sentences. `/roadmap` fills this in from the first brain-dump.
4
+
5
+ ## Commands
6
+
7
+ | Command | What |
8
+ |---|---|
9
+ | `bun install --frozen-lockfile` | Install (pinned toolchain) |
10
+ | `bun run check` | The gate: typecheck, biome, `mshape check`, tests. Green before every PR. |
11
+ | `bun run fix` | Apply biome formatting and safe fixes |
12
+ | `bd ready` | Work that is ready to pick up |
13
+
14
+ ## Where to start
15
+
16
+ - `shape.config.ts`: the module map. It is the layout; nothing else restates it.
17
+ - `DECISIONS.md`: live decisions, each with the check that enforces it.
18
+ - `docs/roadmap.md`: direction, with a status table first. Direction, not rules: never cite
19
+ its sections from code or here.
20
+ - `plan/`: the current milestone. In a track worktree, your handoff is `.handoffs/<track>.md`.
21
+
22
+ ## Gotchas
23
+
24
+ None yet. Add one when a mistake is worth a line, and replace it with a check when it can be.
25
+
26
+ ## Decision authority
27
+
28
+ Needs the maintainer: priorities, publishing, a new runtime dependency, anything public
29
+ beyond a PR. Agents decide everything else and explain it in the PR body.
30
+
31
+ ## Working agreements
32
+
33
+ - Written text is a hypothesis; code and tests are the truth. When a doc, decision or bead
34
+ disagrees with the code, fix the text and carry on.
35
+ - One fact, one home. Link to `DECISIONS.md` entries by slug; never restate them.
36
+ - Agents commit, push and open PRs; the maintainer merges. The PR body is the narrative.
37
+ - Work is tracked in beads (`bd ready`). A bead is a pointer to intent, not a spec.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,8 @@
1
+ # Decisions
2
+
3
+ Live decisions only. Overwrite an entry to change it; git keeps the history.
4
+
5
+ Each entry is a heading `## D-<slug>: <the decision, as a sentence>` followed by five lines:
6
+ `Decision:` what we do; `Tempting wrong fix:` what looks right and isn't, and why;
7
+ `Evidence:` how it was verified, or `policy`; `Enforced by:` the check or test that keeps it
8
+ true; `Revisit when:` the condition that would change it.
@@ -0,0 +1,14 @@
1
+ # {{name}}
2
+
3
+ What it is and who it is for, in a paragraph.
4
+
5
+ ## Develop
6
+
7
+ ```sh
8
+ bun install --frozen-lockfile
9
+ bun run check
10
+ ```
11
+
12
+ Built with [monkeyshape](https://github.com/clinsky/monkeyshape): where it's going is in
13
+ [docs/roadmap.md](docs/roadmap.md), and how humans and agents work here is in
14
+ [AGENTS.md](AGENTS.md).
@@ -0,0 +1,14 @@
1
+ # Roadmap
2
+
3
+ Where {{name}} is heading, and how far it has got. The status table answers "does X exist
4
+ yet?". This file is direction, not a rule source: rewrite it in place when a piece ships.
5
+
6
+ ## Status
7
+
8
+ | Piece | Status |
9
+ |---|---|
10
+ | The gate: typecheck, biome, `mshape check`, tests | **Shipped** |
11
+
12
+ ## Direction
13
+
14
+ Not written yet. Run `/roadmap` with a brain-dump of what you want to build.
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .private/
3
+
4
+ # Tracks and reviews: worktrees, and links to the handoffs store under .git (no trailing slash on links)
5
+ .agents/worktrees/
6
+ .agents/handoffs
7
+ .handoffs
File without changes
File without changes
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from "monkeyshape";
2
+
3
+ export default defineConfig({
4
+ // The module map is the layout: every directory under src/ is a module, and nothing else
5
+ // restates it. Add modules as the code grows; `mshape check` keeps imports to these edges.
6
+ arch: {
7
+ modules: {
8
+ core: { description: "Deterministic logic over plain data. No host I/O." },
9
+ adapters: {
10
+ description: "Host I/O (disk, network, processes) behind services.",
11
+ mayImport: ["core"],
12
+ hostAccess: true,
13
+ },
14
+ cli: {
15
+ description: "The entry point and composition root: where effects run.",
16
+ mayImport: ["core", "adapters"],
17
+ hostAccess: true,
18
+ },
19
+ },
20
+ },
21
+ tracks: {
22
+ // Gitignored data every track worktree shares with the main checkout, such as a large
23
+ // dataset: repo-relative paths like "data/large". `mshape track new` links each one.
24
+ sharedPaths: [],
25
+ },
26
+ });
@@ -0,0 +1,7 @@
1
+ /** The first test: bun test, TypeScript and effect run together. Replace it with your own. */
2
+ import { expect, test } from "bun:test";
3
+ import { Effect } from "effect";
4
+
5
+ test("the toolchain runs an effect", () => {
6
+ expect(Effect.runSync(Effect.succeed(1 + 1))).toBe(2);
7
+ });