monkeyshape 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/config/biome.base.json +71 -0
- package/config/tsconfig.base.json +31 -0
- package/docs/workflow.md +131 -0
- package/package.json +68 -4
- package/skills/handoff/SKILL.md +54 -0
- package/skills/milestone/SKILL.md +107 -0
- package/skills/review-pr/SKILL.md +135 -0
- package/skills/roadmap/SKILL.md +66 -0
- package/skills/track/SKILL.md +75 -0
- package/src/adapters/baseline.ts +55 -0
- package/src/adapters/config.ts +25 -0
- package/src/adapters/init.ts +176 -0
- package/src/adapters/process.ts +38 -0
- package/src/adapters/reviews.ts +170 -0
- package/src/adapters/skills.ts +113 -0
- package/src/adapters/tracks.ts +616 -0
- package/src/adapters/workspace.ts +281 -0
- package/src/api/monkeyshape.ts +8 -0
- package/src/cli/main.ts +359 -0
- package/src/core/arch.ts +224 -0
- package/src/core/budgets.ts +187 -0
- package/src/core/check.ts +122 -0
- package/src/core/coverage.ts +118 -0
- package/src/core/decisions.ts +65 -0
- package/src/core/garden.ts +41 -0
- package/src/core/glob.ts +22 -0
- package/src/core/handoff.ts +183 -0
- package/src/core/hooks.ts +60 -0
- package/src/core/jsonc.ts +26 -0
- package/src/core/layout.ts +123 -0
- package/src/core/markdown.ts +93 -0
- package/src/core/model.ts +100 -0
- package/src/core/parse.ts +41 -0
- package/src/core/paths.ts +19 -0
- package/src/core/ratchet.ts +75 -0
- package/src/core/refs.ts +214 -0
- package/src/core/report.ts +67 -0
- package/src/core/rules/ast.ts +50 -0
- package/src/core/rules/decodeStrict.ts +60 -0
- package/src/core/rules/effectRunAtEdge.ts +33 -0
- package/src/core/rules/engine.ts +150 -0
- package/src/core/rules/noErrorSpread.ts +28 -0
- package/src/core/rules/noNondeterminism.ts +65 -0
- package/src/core/rules/noThrow.ts +79 -0
- package/src/core/rules/registry.ts +15 -0
- package/src/core/scaffold.ts +90 -0
- package/src/core/scope.ts +120 -0
- package/src/core/skills.ts +291 -0
- package/src/core/tracks.ts +358 -0
- package/src/helpers/decodeStrict.ts +8 -0
- package/src/schemas/config.ts +221 -0
- package/src/schemas/errors.ts +31 -0
- package/template/.github/pull_request_template.md +11 -0
- package/template/.github/workflows/ci.yml +22 -0
- package/template/AGENTS.md +37 -0
- package/template/CLAUDE.md +1 -0
- package/template/DECISIONS.md +8 -0
- package/template/README.md +14 -0
- package/template/docs/roadmap.md +14 -0
- package/template/gitignore +7 -0
- package/template/notes/.gitkeep +0 -0
- package/template/plan/.gitkeep +0 -0
- package/template/shape.config.ts +26 -0
- package/template/test/toolchain.test.ts +7 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DECISIONS.md` entries: `## D-<slug>: <title>` followed by fixed fields. Parsing is
|
|
3
|
+
* line-based on purpose, so a malformed entry is reported rather than skipped.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const FIELDS = [
|
|
7
|
+
"Decision",
|
|
8
|
+
"Tempting wrong fix",
|
|
9
|
+
"Evidence",
|
|
10
|
+
"Enforced by",
|
|
11
|
+
"Revisit when",
|
|
12
|
+
] as const;
|
|
13
|
+
export type Field = (typeof FIELDS)[number];
|
|
14
|
+
|
|
15
|
+
export const EVIDENCE_KINDS = ["measured", "verified", "policy"] as const;
|
|
16
|
+
|
|
17
|
+
export interface Decision {
|
|
18
|
+
readonly slug: string;
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly line: number;
|
|
21
|
+
readonly words: number;
|
|
22
|
+
readonly fields: ReadonlyMap<Field, string>;
|
|
23
|
+
readonly evidence: string | undefined;
|
|
24
|
+
/** First token of each comma-separated item in "Enforced by", backticks stripped. */
|
|
25
|
+
readonly enforcedBy: ReadonlyArray<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SLUG = /^D-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
29
|
+
const HEADING = /^## +(\S+?):? +(.*)$/;
|
|
30
|
+
|
|
31
|
+
export const parseDecisions = (text: string): ReadonlyArray<Decision> => {
|
|
32
|
+
const lines = text.split("\n");
|
|
33
|
+
const decisions: Array<Decision> = [];
|
|
34
|
+
let current: { slug: string; title: string; line: number; body: Array<string> } | undefined;
|
|
35
|
+
|
|
36
|
+
const finish = () => {
|
|
37
|
+
if (current === undefined) return;
|
|
38
|
+
const fields = new Map<Field, string>();
|
|
39
|
+
for (const line of current.body)
|
|
40
|
+
for (const field of FIELDS)
|
|
41
|
+
if (line.startsWith(`${field}:`)) fields.set(field, line.slice(field.length + 1).trim());
|
|
42
|
+
const evidence = /^[a-z]+/.exec((fields.get("Evidence") ?? "").toLowerCase())?.[0];
|
|
43
|
+
const enforcedBy = (fields.get("Enforced by") ?? "")
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((item) => (item.trim().split(/\s+/)[0] ?? "").replaceAll("`", ""))
|
|
46
|
+
.filter((item) => item.length > 0 && !/^(none|n\/a|-|—)$/i.test(item));
|
|
47
|
+
const words = current.body.join(" ").split(/\s+/).filter(Boolean).length;
|
|
48
|
+
decisions.push({ ...current, words, fields, evidence, enforcedBy });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
lines.forEach((line, index) => {
|
|
52
|
+
const heading = line.startsWith("## ") ? HEADING.exec(line) : null;
|
|
53
|
+
if (heading !== null || line.startsWith("# ")) {
|
|
54
|
+
finish();
|
|
55
|
+
current =
|
|
56
|
+
heading === null
|
|
57
|
+
? undefined
|
|
58
|
+
: { slug: heading[1] ?? "", title: heading[2] ?? "", line: index + 1, body: [] };
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
current?.body.push(line);
|
|
62
|
+
});
|
|
63
|
+
finish();
|
|
64
|
+
return decisions;
|
|
65
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The numbers the milestone-boundary sweep reviews. Reporting only; `budgets` enforces caps. */
|
|
2
|
+
import type { ResolvedConfig } from "../schemas/config.ts";
|
|
3
|
+
import { measure } from "./budgets.ts";
|
|
4
|
+
import { isTsSource, type Snapshot } from "./model.ts";
|
|
5
|
+
import type { Counts } from "./ratchet.ts";
|
|
6
|
+
import type { GardenReport } from "./report.ts";
|
|
7
|
+
|
|
8
|
+
const occurrences = (text: string, pattern: RegExp): number => text.match(pattern)?.length ?? 0;
|
|
9
|
+
|
|
10
|
+
export const garden = (
|
|
11
|
+
config: ResolvedConfig,
|
|
12
|
+
snapshot: Snapshot,
|
|
13
|
+
baseline: Counts,
|
|
14
|
+
): GardenReport => {
|
|
15
|
+
let shapeIgnores = 0;
|
|
16
|
+
let biomeIgnores = 0;
|
|
17
|
+
for (const file of snapshot.files) {
|
|
18
|
+
if (!isTsSource(file)) continue;
|
|
19
|
+
const text = snapshot.texts.get(file) ?? "";
|
|
20
|
+
shapeIgnores += occurrences(text, /^\s*\/\/\s*shape-ignore\s/gm);
|
|
21
|
+
biomeIgnores += occurrences(text, /^\s*\/\/\s*biome-ignore/gm);
|
|
22
|
+
}
|
|
23
|
+
const allowances =
|
|
24
|
+
Object.values(config.rules).reduce((sum, rule) => sum + (rule.allow?.length ?? 0), 0) +
|
|
25
|
+
config.budgets.largeFiles.length;
|
|
26
|
+
const ratcheted = Object.values(baseline)
|
|
27
|
+
.flatMap((files) => Object.values(files))
|
|
28
|
+
.reduce((sum, n) => sum + n, 0);
|
|
29
|
+
return {
|
|
30
|
+
metrics: measure(config, snapshot),
|
|
31
|
+
budgets: {
|
|
32
|
+
agentsLines: config.budgets.agentsLines,
|
|
33
|
+
readmeLines: config.budgets.readmeLines,
|
|
34
|
+
decisions: config.budgets.decisions,
|
|
35
|
+
memories: config.budgets.memories,
|
|
36
|
+
memoryBytes: config.budgets.memoryBytes,
|
|
37
|
+
},
|
|
38
|
+
escapeHatches: { shapeIgnores, biomeIgnores, allowances },
|
|
39
|
+
ratcheted,
|
|
40
|
+
};
|
|
41
|
+
};
|
package/src/core/glob.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Glob matching with compiled patterns cached; `Bun.Glob` is pure string matching. */
|
|
2
|
+
const cache = new Map<string, Bun.Glob>();
|
|
3
|
+
|
|
4
|
+
const compiled = (pattern: string): Bun.Glob => {
|
|
5
|
+
let glob = cache.get(pattern);
|
|
6
|
+
if (glob === undefined) {
|
|
7
|
+
glob = new Bun.Glob(pattern);
|
|
8
|
+
cache.set(pattern, glob);
|
|
9
|
+
}
|
|
10
|
+
return glob;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const matches = (path: string, pattern: string): boolean => compiled(pattern).match(path);
|
|
14
|
+
|
|
15
|
+
export const matchesAny = (path: string, patterns: ReadonlyArray<string>): boolean =>
|
|
16
|
+
patterns.some((pattern) => matches(path, pattern));
|
|
17
|
+
|
|
18
|
+
/** A pattern naming a directory also matches everything below it (`dist` ≡ `dist/**`). */
|
|
19
|
+
export const matchesPathOrBelow = (path: string, pattern: string): boolean => {
|
|
20
|
+
const trimmed = pattern.replace(/^\.\//, "").replace(/\/$/, "");
|
|
21
|
+
return matches(path, trimmed) || path.startsWith(`${trimmed}/`) || matches(path, `${trimmed}/**`);
|
|
22
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff: one Markdown file per track, written by `mshape track new` as a stub and then
|
|
3
|
+
* kept by the `track` and `handoff` skills. The shape, which `parseHandoff` enforces:
|
|
4
|
+
*
|
|
5
|
+
* # Track: <name>
|
|
6
|
+
* Status: stub | active
|
|
7
|
+
* Queue: bd ready --label track:<name>
|
|
8
|
+
*
|
|
9
|
+
* ## Charter what the track is for; empty in a stub
|
|
10
|
+
* ## State (<YYYY-MM-DD>) where the work stands; overwritten, not appended
|
|
11
|
+
* ## Lessons not yet promoted learned here, not yet moved into a check, a doc or a decision
|
|
12
|
+
* ## Traps what will bite the next session
|
|
13
|
+
* ## Messages to and from other tracks
|
|
14
|
+
*
|
|
15
|
+
* A stub has no charter: the next `/track` session interviews the human for one, writes it,
|
|
16
|
+
* and sets `Status: active`. Sections are in this order, each exactly once; their bodies are
|
|
17
|
+
* free Markdown, with `###` for headings inside a section (`## ` in a fenced block is fine).
|
|
18
|
+
*/
|
|
19
|
+
import { MAIN_BRANCH, TRACK_NAME, trackBranch, trackLabel } from "./layout.ts";
|
|
20
|
+
import { fencedLines } from "./markdown.ts";
|
|
21
|
+
|
|
22
|
+
export type HandoffStatus = "stub" | "active";
|
|
23
|
+
|
|
24
|
+
export const HANDOFF_SECTIONS = [
|
|
25
|
+
"Charter",
|
|
26
|
+
"State",
|
|
27
|
+
"Lessons not yet promoted",
|
|
28
|
+
"Traps",
|
|
29
|
+
"Messages",
|
|
30
|
+
] as const;
|
|
31
|
+
export type HandoffSection = (typeof HANDOFF_SECTIONS)[number];
|
|
32
|
+
|
|
33
|
+
export interface Handoff {
|
|
34
|
+
readonly track: string;
|
|
35
|
+
readonly status: HandoffStatus;
|
|
36
|
+
readonly queue: string;
|
|
37
|
+
/** The date in the State heading. */
|
|
38
|
+
readonly stateDate: string;
|
|
39
|
+
/** Each section's body, trimmed. */
|
|
40
|
+
readonly sections: Readonly<Record<HandoffSection, string>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type Problems = { readonly problems: ReadonlyArray<string> };
|
|
44
|
+
|
|
45
|
+
/** `YYYY-MM-DD` naming a real day: `2026-02-29` and `2026-13-45` don't. */
|
|
46
|
+
const isDate = (date: string): boolean => {
|
|
47
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
48
|
+
if (match === null) return false;
|
|
49
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
50
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
51
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
52
|
+
return days !== undefined && day >= 1 && day <= days;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const heading = (section: HandoffSection, date: string): string =>
|
|
56
|
+
section === "State" ? `## State (${date})` : `## ${section}`;
|
|
57
|
+
|
|
58
|
+
export const handoffQueue = (track: string): string => `bd ready --label ${trackLabel(track)}`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The handoff `track new` writes: `date` is today, `YYYY-MM-DD`; `worktree` is shown as given.
|
|
62
|
+
* Checked against the format here, so a bad name or date fails where it is passed in, not
|
|
63
|
+
* later in `track list` or `/track`.
|
|
64
|
+
*/
|
|
65
|
+
export const stubHandoff = (
|
|
66
|
+
track: string,
|
|
67
|
+
date: string,
|
|
68
|
+
worktree: string,
|
|
69
|
+
): { readonly text: string } | Problems => {
|
|
70
|
+
const bodies: Record<HandoffSection, string> = {
|
|
71
|
+
Charter: "",
|
|
72
|
+
State: `No charter yet: the next \`/track\` session interviews the human for one, writes it above and
|
|
73
|
+
sets \`Status: active\`. Worktree \`${worktree}\` on \`${trackBranch(track)}\`, branched from \`${MAIN_BRANCH}\`.`,
|
|
74
|
+
"Lessons not yet promoted": "None yet.",
|
|
75
|
+
Traps: "None yet.",
|
|
76
|
+
Messages: "None yet.",
|
|
77
|
+
};
|
|
78
|
+
const text = [
|
|
79
|
+
`# Track: ${track}\nStatus: stub\nQueue: ${handoffQueue(track)}`,
|
|
80
|
+
...HANDOFF_SECTIONS.map((section) =>
|
|
81
|
+
bodies[section] === ""
|
|
82
|
+
? heading(section, date)
|
|
83
|
+
: `${heading(section, date)}\n\n${bodies[section]}`,
|
|
84
|
+
),
|
|
85
|
+
].join("\n\n");
|
|
86
|
+
const parsed = parseHandoff(`${text}\n`);
|
|
87
|
+
return "problems" in parsed ? parsed : { text: `${text}\n` };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** `## ` lines outside fenced code blocks, since a body may quote Markdown, and the line of a fence still open at the end. */
|
|
91
|
+
const headingsOf = (lines: ReadonlyArray<string>) => {
|
|
92
|
+
const { fenced, unclosed } = fencedLines(lines);
|
|
93
|
+
const headings = lines.flatMap((line, index) =>
|
|
94
|
+
!fenced[index] && line.startsWith("## ") ? [{ name: line.slice(3).trim(), line: index }] : [],
|
|
95
|
+
);
|
|
96
|
+
return { headings, unclosedFence: unclosed };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The handoff, or every way the text departs from the format. */
|
|
100
|
+
export const parseHandoff = (text: string): Handoff | Problems => {
|
|
101
|
+
const problems: Array<string> = [];
|
|
102
|
+
const lines = text
|
|
103
|
+
.replace(/^\uFEFF/, "")
|
|
104
|
+
.replace(/\r\n/g, "\n")
|
|
105
|
+
.split("\n");
|
|
106
|
+
const { headings, unclosedFence } = headingsOf(lines);
|
|
107
|
+
if (unclosedFence !== undefined)
|
|
108
|
+
problems.push(
|
|
109
|
+
`a code fence opened on line ${unclosedFence + 1} is never closed, which hides every heading after it: close it with a matching fence`,
|
|
110
|
+
);
|
|
111
|
+
const header = lines
|
|
112
|
+
.slice(0, headings[0]?.line ?? lines.length)
|
|
113
|
+
.map((line) => line.trim())
|
|
114
|
+
.filter((line) => line !== "");
|
|
115
|
+
|
|
116
|
+
const title = /^# Track: (\S+)$/.exec(header[0] ?? "");
|
|
117
|
+
const track = title?.[1] ?? "";
|
|
118
|
+
if (title === null)
|
|
119
|
+
problems.push(`the first line must be \`# Track: <name>\`, found \`${header[0] ?? ""}\``);
|
|
120
|
+
else if (!TRACK_NAME.test(track))
|
|
121
|
+
problems.push(`track name \`${track}\` is not lowercase words joined by hyphens`);
|
|
122
|
+
|
|
123
|
+
const field = (name: string): string | undefined => {
|
|
124
|
+
const found = header.filter((line) => line.startsWith(`${name}:`));
|
|
125
|
+
if (found.length > 1) problems.push(`\`${name}:\` appears ${found.length} times; keep one`);
|
|
126
|
+
return found[0]?.slice(name.length + 1).trim();
|
|
127
|
+
};
|
|
128
|
+
const status = field("Status");
|
|
129
|
+
if (status !== "stub" && status !== "active")
|
|
130
|
+
problems.push(`\`Status:\` must be \`stub\` or \`active\`, found \`${status ?? "(missing)"}\``);
|
|
131
|
+
const queue = field("Queue");
|
|
132
|
+
if (title !== null && queue !== handoffQueue(track))
|
|
133
|
+
problems.push(
|
|
134
|
+
`the queue must be \`Queue: ${handoffQueue(track)}\`, found \`${queue ?? "(missing)"}\``,
|
|
135
|
+
);
|
|
136
|
+
const extra = header.slice(1).filter((line) => !/^(Status|Queue):/.test(line));
|
|
137
|
+
if (extra.length > 0)
|
|
138
|
+
problems.push(
|
|
139
|
+
`only \`Status:\` and \`Queue:\` go between the title and \`## Charter\`; found \`${extra[0]}\``,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
let stateDate: string | undefined;
|
|
143
|
+
const names = headings.map(({ name }) => {
|
|
144
|
+
const state = /^State(?:\s*\((.*)\))?$/.exec(name);
|
|
145
|
+
if (state === null) return name;
|
|
146
|
+
stateDate = state[1];
|
|
147
|
+
return "State";
|
|
148
|
+
});
|
|
149
|
+
const sections: Partial<Record<HandoffSection, string>> = {};
|
|
150
|
+
if (names.join("\n") !== HANDOFF_SECTIONS.join("\n"))
|
|
151
|
+
problems.push(
|
|
152
|
+
`sections must be exactly ${HANDOFF_SECTIONS.map((s) => `\`${heading(s, "YYYY-MM-DD")}\``).join(", ")}, in that order (use \`###\` for headings inside a section); found ${names.map((s) => `\`## ${s}\``).join(", ") || "none"}`,
|
|
153
|
+
);
|
|
154
|
+
else {
|
|
155
|
+
if (stateDate === undefined)
|
|
156
|
+
problems.push("the State heading needs a date: `## State (YYYY-MM-DD)`");
|
|
157
|
+
else if (!isDate(stateDate))
|
|
158
|
+
problems.push(
|
|
159
|
+
`the State heading's date must be a real day as \`YYYY-MM-DD\`, found \`${stateDate}\``,
|
|
160
|
+
);
|
|
161
|
+
headings.forEach((found, index) => {
|
|
162
|
+
const end = headings[index + 1]?.line ?? lines.length;
|
|
163
|
+
sections[HANDOFF_SECTIONS[index] as HandoffSection] = lines
|
|
164
|
+
.slice(found.line + 1, end)
|
|
165
|
+
.join("\n")
|
|
166
|
+
.trim();
|
|
167
|
+
});
|
|
168
|
+
const charter = sections.Charter ?? "";
|
|
169
|
+
if (status === "stub" && charter !== "")
|
|
170
|
+
problems.push("a stub has an empty charter: write the charter and set `Status: active`");
|
|
171
|
+
if (status === "active" && charter === "")
|
|
172
|
+
problems.push("an active track needs a charter; until it has one, its status is `stub`");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (problems.length > 0) return { problems };
|
|
176
|
+
return {
|
|
177
|
+
track,
|
|
178
|
+
status: status as HandoffStatus,
|
|
179
|
+
queue: queue ?? "",
|
|
180
|
+
stateDate: stateDate ?? "",
|
|
181
|
+
sections: sections as Record<HandoffSection, string>,
|
|
182
|
+
};
|
|
183
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code hooks a repo runs, and how a hook or a skill invokes `mshape`. `HOOKS` is the
|
|
3
|
+
* one list: the `hooks` key of `.claude/settings.json`, in this repo and in every repo
|
|
4
|
+
* `mshape init` creates, is rendered from it, and a test fails when this repo's drifts. The
|
|
5
|
+
* file's other keys belong to the repo. A new hook is one entry here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The hook events monkeyshape uses; add one when an entry needs it. */
|
|
9
|
+
export type HookEvent = "SessionStart" | "UserPromptSubmit";
|
|
10
|
+
|
|
11
|
+
export interface Hook {
|
|
12
|
+
readonly event: HookEvent;
|
|
13
|
+
/** Which sources trigger it (for SessionStart: startup, resume, clear, compact); "" is all. */
|
|
14
|
+
readonly matcher: string;
|
|
15
|
+
/** A shell command, run from the session's directory. */
|
|
16
|
+
readonly command: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `mshape <args>` as a shell command that works from a hook and from a skill (D-run-mshape):
|
|
21
|
+
* the package script `mshape`, which every repo has. bun puts every ancestor's
|
|
22
|
+
* `node_modules/.bin` on a script's PATH, so a track worktree without `node_modules` finds the
|
|
23
|
+
* main checkout's. `--silent` keeps bun from echoing the script to stderr.
|
|
24
|
+
*/
|
|
25
|
+
export const mshapeCommand = (args: ReadonlyArray<string>): string =>
|
|
26
|
+
["bun", "run", "--silent", "mshape", ...args.map(quote)].join(" ");
|
|
27
|
+
|
|
28
|
+
/** POSIX single quotes, only when needed: hook commands stay readable. */
|
|
29
|
+
const quote = (arg: string): string =>
|
|
30
|
+
/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
31
|
+
|
|
32
|
+
/** Every hook, in the order each event runs them. */
|
|
33
|
+
export const HOOKS: ReadonlyArray<Hook> = [
|
|
34
|
+
// bd's own: the beads primer, as hook JSON.
|
|
35
|
+
{ event: "SessionStart", matcher: "", command: "bd prime --hook-json" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** The `hooks` key of `.claude/settings.json`: each hook under its event, in list order. */
|
|
39
|
+
export const settingsHooks = (hooks: ReadonlyArray<Hook>): Record<string, Array<unknown>> => {
|
|
40
|
+
const events: Record<string, Array<unknown>> = {};
|
|
41
|
+
for (const hook of hooks)
|
|
42
|
+
events[hook.event] = [
|
|
43
|
+
...(events[hook.event] ?? []),
|
|
44
|
+
{ matcher: hook.matcher, hooks: [{ type: "command", command: hook.command }] },
|
|
45
|
+
];
|
|
46
|
+
return events;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `.claude/settings.json`'s text: `current` (the parsed file, or nothing for a new repo) with
|
|
51
|
+
* its `hooks` key replaced. The list owns `hooks` and nothing else, since Claude Code and the
|
|
52
|
+
* user keep `permissions`, `env` and the rest in the same file.
|
|
53
|
+
*/
|
|
54
|
+
export const renderSettings = (
|
|
55
|
+
hooks: ReadonlyArray<Hook>,
|
|
56
|
+
current: Readonly<Record<string, unknown>> = {},
|
|
57
|
+
): string => `${JSON.stringify({ ...current, hooks: settingsHooks(hooks) }, null, 2)}\n`;
|
|
58
|
+
|
|
59
|
+
/** Where Claude Code reads the project's hooks, repo-relative. */
|
|
60
|
+
export const SETTINGS_FILE = ".claude/settings.json";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** JSON with comments and trailing commas, as tsconfig and biome accept. */
|
|
2
|
+
|
|
3
|
+
export const stripJsonc = (text: string): string => {
|
|
4
|
+
let out = "";
|
|
5
|
+
let inString = false;
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
const char = text[i] ?? "";
|
|
8
|
+
const next = text[i + 1];
|
|
9
|
+
if (inString) {
|
|
10
|
+
out += char;
|
|
11
|
+
if (char === "\\") out += text[++i] ?? "";
|
|
12
|
+
else if (char === '"') inString = false;
|
|
13
|
+
} else if (char === '"') {
|
|
14
|
+
inString = true;
|
|
15
|
+
out += char;
|
|
16
|
+
} else if (char === "/" && next === "/") {
|
|
17
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
18
|
+
out += "\n";
|
|
19
|
+
} else if (char === "/" && next === "*") {
|
|
20
|
+
i += 2;
|
|
21
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
22
|
+
i++;
|
|
23
|
+
} else out += char;
|
|
24
|
+
}
|
|
25
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
26
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a track lives. Every track-aware command and skill derives these names from here, so
|
|
3
|
+
* the CLI, the skills and the template can't disagree. Paths are absolute and POSIX.
|
|
4
|
+
*
|
|
5
|
+
* `common` is git's common directory (`git rev-parse --path-format=absolute
|
|
6
|
+
* --git-common-dir`), the one fact that is the same from the main checkout and from every
|
|
7
|
+
* worktree; `repo`, the main checkout's root, is derived from it, never from the working
|
|
8
|
+
* directory. Everything a human sees stays under the repo's `.agents/`:
|
|
9
|
+
*
|
|
10
|
+
* <repo>/.agents/skills/ committed: the shared skills
|
|
11
|
+
* <repo>/.agents/worktrees/<name>@<track> gitignored: the track's worktree, on `track/<track>`
|
|
12
|
+
* <common>/monkeyshape/handoffs/<track>.md the handoffs themselves, inside git's directory
|
|
13
|
+
* <repo>/.agents/handoffs gitignored symlink to that store
|
|
14
|
+
* <checkout>/.handoffs gitignored symlink to that store, in every checkout
|
|
15
|
+
* <repo>/.agents/worktrees/<name>.pr-<n> gitignored: a pull request under review, detached
|
|
16
|
+
* <common>/monkeyshape/mail/<track>/<id>.json a track's unread message
|
|
17
|
+
* <common>/monkeyshape/mail/<track>/read/<id>.json moved there when read
|
|
18
|
+
* <common>/monkeyshape/sessions/<track>/<session>.json a live session of the track
|
|
19
|
+
*
|
|
20
|
+
* The stores live under `.git` because they are live state: `git clean -fdx` deletes
|
|
21
|
+
* ignored files, which would include a handoffs directory in the working tree, but it never
|
|
22
|
+
* touches `.git`. It can delete the symlinks, which the track commands recreate. Nothing here
|
|
23
|
+
* creates a directory: the command that first writes to one creates it.
|
|
24
|
+
*
|
|
25
|
+
* Beads for a track carry the label `track:<track>`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Lowercase words joined by hyphens: `docs`, `init`, `cross-module`. */
|
|
29
|
+
export const TRACK_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
30
|
+
|
|
31
|
+
export const HANDOFFS_LINK = ".handoffs";
|
|
32
|
+
|
|
33
|
+
/** Committed: the shared skills `mshape skills sync` installs, and the repo's own. */
|
|
34
|
+
export const SKILLS_DIR = ".agents/skills";
|
|
35
|
+
|
|
36
|
+
/** Inside `SKILLS_DIR`: the names of the shared skills sync installed, so a same-named skill
|
|
37
|
+
* of the repo's own is never taken for one, and a retired one can be removed. */
|
|
38
|
+
export const SKILLS_MANIFEST = "monkeyshape.json";
|
|
39
|
+
|
|
40
|
+
/** Committed symlink: Claude Code reads project skills only from here. */
|
|
41
|
+
export const CLAUDE_SKILLS_LINK = ".claude/skills";
|
|
42
|
+
|
|
43
|
+
/** The link's text, relative to `.claude/`, so it resolves in every clone and worktree. */
|
|
44
|
+
export const CLAUDE_SKILLS_TARGET = "../.agents/skills";
|
|
45
|
+
|
|
46
|
+
/** Repo-relative: where the worktrees live, and the main checkout's link to the handoffs. */
|
|
47
|
+
export const WORKTREES_DIR = ".agents/worktrees";
|
|
48
|
+
export const AGENTS_HANDOFFS_LINK = ".agents/handoffs";
|
|
49
|
+
|
|
50
|
+
/** Tracks branch from it, and `track rm` counts a branch as merged once it is contained in it. */
|
|
51
|
+
export const MAIN_BRANCH = "main";
|
|
52
|
+
|
|
53
|
+
const trim = (path: string): string => path.replace(/\/+$/, "");
|
|
54
|
+
|
|
55
|
+
const repoName = (repo: string): string => trim(repo).slice(trim(repo).lastIndexOf("/") + 1);
|
|
56
|
+
|
|
57
|
+
export const trackBranch = (track: string): string => `track/${track}`;
|
|
58
|
+
|
|
59
|
+
export const trackLabel = (track: string): string => `track:${track}`;
|
|
60
|
+
|
|
61
|
+
export const worktreesDir = (repo: string): string => `${trim(repo)}/${WORKTREES_DIR}`;
|
|
62
|
+
|
|
63
|
+
export const worktreePath = (repo: string, track: string): string =>
|
|
64
|
+
`${worktreesDir(repo)}/${repoName(repo)}@${track}`;
|
|
65
|
+
|
|
66
|
+
/** The main checkout: the directory holding git's common directory. */
|
|
67
|
+
export const mainCheckout = (common: string): string => {
|
|
68
|
+
const path = trim(common);
|
|
69
|
+
return path.slice(0, path.lastIndexOf("/"));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Where the handoffs are stored: inside git's directory, out of reach of `git clean`. */
|
|
73
|
+
export const handoffsStore = (common: string): string => `${trim(common)}/monkeyshape/handoffs`;
|
|
74
|
+
|
|
75
|
+
/** The user-facing link to the store, in the main checkout. */
|
|
76
|
+
export const handoffsLink = (repo: string): string => `${trim(repo)}/${AGENTS_HANDOFFS_LINK}`;
|
|
77
|
+
|
|
78
|
+
export const handoffPath = (common: string, track: string): string =>
|
|
79
|
+
`${handoffsStore(common)}/${track}.md`;
|
|
80
|
+
|
|
81
|
+
/** The track a worktree belongs to, from its path, or undefined for the main checkout. */
|
|
82
|
+
export const trackOf = (repo: string, worktree: string): string | undefined => {
|
|
83
|
+
const prefix = `${worktreesDir(repo)}/${repoName(repo)}@`;
|
|
84
|
+
const path = trim(worktree);
|
|
85
|
+
if (!path.startsWith(prefix)) return undefined;
|
|
86
|
+
const track = path.slice(prefix.length);
|
|
87
|
+
return TRACK_NAME.test(track) ? track : undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A pull request's number, as `mshape review` takes it. */
|
|
91
|
+
export const PR_NUMBER = /^[1-9][0-9]*$/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A review's worktree, beside the tracks. `.pr-<n>`, not `@`: `trackOf` must never read a review
|
|
95
|
+
* as a track, since a review has no branch, handoff or label.
|
|
96
|
+
*/
|
|
97
|
+
export const reviewPath = (repo: string, pr: string): string =>
|
|
98
|
+
`${worktreesDir(repo)}/${repoName(repo)}.pr-${pr}`;
|
|
99
|
+
|
|
100
|
+
/** Where the pull request's head is fetched: a ref of its own, so parallel reviews never race. */
|
|
101
|
+
export const reviewRef = (pr: string): string => `refs/monkeyshape/reviews/pr-${pr}`;
|
|
102
|
+
|
|
103
|
+
/** The mailbox: one directory per track, under git's directory beside the handoffs store. */
|
|
104
|
+
export const mailDir = (common: string): string => `${trim(common)}/monkeyshape/mail`;
|
|
105
|
+
|
|
106
|
+
/** A track's unread mail, one `<id>.json` per message. */
|
|
107
|
+
export const mailboxDir = (common: string, track: string): string => `${mailDir(common)}/${track}`;
|
|
108
|
+
|
|
109
|
+
/** Where reading a message moves it, by atomic rename. */
|
|
110
|
+
export const mailboxReadDir = (common: string, track: string): string =>
|
|
111
|
+
`${mailboxDir(common, track)}/read`;
|
|
112
|
+
|
|
113
|
+
export const messagePath = (common: string, track: string, id: string): string =>
|
|
114
|
+
`${mailboxDir(common, track)}/${id}.json`;
|
|
115
|
+
|
|
116
|
+
/** The session registry: the live sessions of each track, for notices. */
|
|
117
|
+
export const sessionsDir = (common: string): string => `${trim(common)}/monkeyshape/sessions`;
|
|
118
|
+
|
|
119
|
+
export const trackSessionsDir = (common: string, track: string): string =>
|
|
120
|
+
`${sessionsDir(common)}/${track}`;
|
|
121
|
+
|
|
122
|
+
export const sessionPath = (common: string, track: string, session: string): string =>
|
|
123
|
+
`${trackSessionsDir(common, track)}/${session}.json`;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Markdown structure the checks need, as CommonMark defines it: fenced code blocks and code
|
|
3
|
+
* spans. Both the refs check and the handoff parser read fences from here, so they can't
|
|
4
|
+
* disagree about where a block ends.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A list item's marker and the space after it: the item's content starts where this ends. */
|
|
8
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d{1,9}[.)])\s+/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* For each line, whether it belongs to a fenced code block, fence lines included; and the line
|
|
12
|
+
* of a fence still open at the end. A fence opens with at least three backticks or tildes (a
|
|
13
|
+
* backtick fence's info string has no backtick), and closes only on the same character, at
|
|
14
|
+
* least as long, with nothing after it. It may be indented at most three spaces past the
|
|
15
|
+
* content of the list item it sits in, as in CommonMark: four spaces at the top level is an
|
|
16
|
+
* indented code block, but five inside a nested item is still a fence.
|
|
17
|
+
*/
|
|
18
|
+
export const fencedLines = (
|
|
19
|
+
lines: ReadonlyArray<string>,
|
|
20
|
+
): { readonly fenced: ReadonlyArray<boolean>; readonly unclosed: number | undefined } => {
|
|
21
|
+
const fenced: Array<boolean> = [];
|
|
22
|
+
let open: { readonly marker: string; readonly line: number } | undefined;
|
|
23
|
+
// Where the enclosing list item's content starts; 0 outside a list. Approximate: a line
|
|
24
|
+
// indented less than that is taken to have left the item.
|
|
25
|
+
let content = 0;
|
|
26
|
+
lines.forEach((line, index) => {
|
|
27
|
+
const fence = /^(\s*)(`{3,}|~{3,})(.*)$/.exec(line);
|
|
28
|
+
const marker = fence?.[2];
|
|
29
|
+
const rest = fence?.[3] ?? "";
|
|
30
|
+
if (open === undefined) {
|
|
31
|
+
const indent = line.length - line.trimStart().length;
|
|
32
|
+
const opens =
|
|
33
|
+
marker !== undefined &&
|
|
34
|
+
indent - Math.min(content, indent) <= 3 &&
|
|
35
|
+
!(marker.startsWith("`") && rest.includes("`"));
|
|
36
|
+
if (opens) open = { marker, line: index };
|
|
37
|
+
else if (line.trim() !== "") {
|
|
38
|
+
const item = LIST_ITEM.exec(line);
|
|
39
|
+
content = item !== null ? item[0].length : Math.min(content, indent);
|
|
40
|
+
}
|
|
41
|
+
fenced.push(opens);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
fenced.push(true);
|
|
45
|
+
if (
|
|
46
|
+
marker !== undefined &&
|
|
47
|
+
marker[0] === open.marker[0] &&
|
|
48
|
+
marker.length >= open.marker.length &&
|
|
49
|
+
rest.trim() === ""
|
|
50
|
+
)
|
|
51
|
+
open = undefined;
|
|
52
|
+
});
|
|
53
|
+
return { fenced, unclosed: open?.line };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a line leaves a code span open, so the span would continue on the next line. A run
|
|
58
|
+
* of n backticks closes only at the next run of exactly n; a backslash-escaped backtick and
|
|
59
|
+
* HTML comments open nothing.
|
|
60
|
+
*/
|
|
61
|
+
export const opensCodeSpan = (line: string): boolean => {
|
|
62
|
+
const text = line.replace(/<!--.*?-->/g, "");
|
|
63
|
+
const runLength = (at: number) => {
|
|
64
|
+
let end = at;
|
|
65
|
+
while (text[end] === "`") end++;
|
|
66
|
+
return end - at;
|
|
67
|
+
};
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < text.length) {
|
|
70
|
+
if (text[i] === "\\") {
|
|
71
|
+
i += 2;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (text[i] !== "`") {
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const n = runLength(i);
|
|
79
|
+
let j = i + n;
|
|
80
|
+
let closed: number | undefined;
|
|
81
|
+
while (j < text.length && closed === undefined) {
|
|
82
|
+
if (text[j] !== "`") j++;
|
|
83
|
+
else {
|
|
84
|
+
const m = runLength(j);
|
|
85
|
+
if (m === n) closed = j + m;
|
|
86
|
+
j += m;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (closed === undefined) return true;
|
|
90
|
+
i = closed;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
};
|