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,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
|
+
};
|
package/src/schemas/config.ts
CHANGED
|
@@ -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
|
package/src/schemas/errors.ts
CHANGED
|
@@ -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,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.
|
|
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
|
+
});
|