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,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";
@@ -1,12 +1,26 @@
1
1
  /**
2
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; `repo`
4
- * is the main checkout's root.
3
+ * the CLI, the skills and the template can't disagree. Paths are absolute and POSIX.
5
4
  *
6
- * <parent>/<repo> main checkout
7
- * <parent>/<repo>@<track> the track's worktree, on branch `track/<track>`
8
- * <parent>/<repo>-state/handoffs/ one `<track>.md` per track, outside git
9
- * <worktree>/.handoffs symlink to the handoffs directory (gitignored)
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.
10
24
  *
11
25
  * Beads for a track carry the label `track:<track>`.
12
26
  */
@@ -16,36 +30,94 @@ export const TRACK_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
16
30
 
17
31
  export const HANDOFFS_LINK = ".handoffs";
18
32
 
19
- const split = (path: string): { parent: string; name: string } => {
20
- const trimmed = path.replace(/\/+$/, "");
21
- const index = trimmed.lastIndexOf("/");
22
- return { parent: trimmed.slice(0, index), name: trimmed.slice(index + 1) };
23
- };
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);
24
56
 
25
57
  export const trackBranch = (track: string): string => `track/${track}`;
26
58
 
27
59
  export const trackLabel = (track: string): string => `track:${track}`;
28
60
 
29
- export const worktreePath = (repo: string, track: string): string => {
30
- const { parent, name } = split(repo);
31
- return `${parent}/${name}@${track}`;
32
- };
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}`;
33
65
 
34
- export const stateDir = (repo: string): string => {
35
- const { parent, name } = split(repo);
36
- return `${parent}/${name}-state`;
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("/"));
37
70
  };
38
71
 
39
- export const handoffsDir = (repo: string): string => `${stateDir(repo)}/handoffs`;
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}`;
40
77
 
41
- export const handoffPath = (repo: string, track: string): string =>
42
- `${handoffsDir(repo)}/${track}.md`;
78
+ export const handoffPath = (common: string, track: string): string =>
79
+ `${handoffsStore(common)}/${track}.md`;
43
80
 
44
- /** The track a worktree belongs to, from its directory name, or undefined for the main checkout. */
81
+ /** The track a worktree belongs to, from its path, or undefined for the main checkout. */
45
82
  export const trackOf = (repo: string, worktree: string): string | undefined => {
46
- const main = split(repo);
47
- const here = split(worktree);
48
- if (here.parent !== main.parent || !here.name.startsWith(`${main.name}@`)) return undefined;
49
- const track = here.name.slice(main.name.length + 1);
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);
50
87
  return TRACK_NAME.test(track) ? track : undefined;
51
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
+ };
package/src/core/model.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** The data every check reads and the findings it returns. Pure: no I/O lives here. */
2
2
 
3
- export type CheckName = "arch" | "refs" | "budgets" | "coverage" | "rules";
3
+ export type CheckName = "arch" | "refs" | "budgets" | "coverage" | "rules" | "skills";
4
4
 
5
5
  export interface Violation {
6
6
  /**
@@ -32,6 +32,29 @@ export interface Snapshot {
32
32
  /** Files biome processes, as biome itself reports them, when biome ran. */
33
33
  readonly linted: ReadonlySet<string> | undefined;
34
34
  readonly integrations: Integrations;
35
+ readonly skills: SkillsSnapshot;
36
+ }
37
+
38
+ /** File bytes keyed by path relative to a skills directory: `<skill>/SKILL.md`. Bytes, so a
39
+ * skill's images or binaries are copied and compared exactly. */
40
+ export type SkillFiles = ReadonlyMap<string, Uint8Array>;
41
+
42
+ /** What sits at `.claude/skills`: `target` is the link's own text, unresolved. */
43
+ export type SkillsLink =
44
+ | { readonly kind: "missing" }
45
+ | { readonly kind: "link"; readonly target: string }
46
+ | { readonly kind: "directory" }
47
+ | { readonly kind: "other" };
48
+
49
+ /** The installed skills, read from disk, and the ones the running monkeyshape ships. */
50
+ export interface SkillsSnapshot {
51
+ readonly link: SkillsLink;
52
+ /** Files under `.agents/skills`. */
53
+ readonly installed: SkillFiles;
54
+ /** Files under `.claude/skills` when it is a real directory; empty otherwise. */
55
+ readonly claudeDirectory: SkillFiles;
56
+ /** Files under the package's `skills/`. */
57
+ readonly shipped: SkillFiles;
35
58
  }
36
59
 
37
60
  /**
package/src/core/refs.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * References in prose must resolve, and prose may only point at things a check keeps true.
3
- * Docs: backticked paths, `bun run` scripts, decision slugs and relative links must exist;
3
+ * Docs: backticked paths, `bun run` scripts, decision slugs and relative links must exist, and
4
+ * each backticked span sits on one line, where it can be checked;
4
5
  * normative docs may not link into notes/, plan/ or handoffs. Code comments: no links into
5
6
  * those either, no `§N` citations, decision slugs must exist, bead ids only in `TODO(id)`.
6
7
  */
7
8
  import type { ResolvedConfig } from "../schemas/config.ts";
8
9
  import { parseDecisions, SLUG } from "./decisions.ts";
9
10
  import { matchesAny } from "./glob.ts";
11
+ import { fencedLines, opensCodeSpan } from "./markdown.ts";
10
12
  import { isTsSource, lineAt, pathIndex, type Snapshot, type Violation } from "./model.ts";
11
13
  import { parse } from "./parse.ts";
12
14
  import { basename, dirname, join } from "./paths.ts";
@@ -32,17 +34,9 @@ const violation = (rule: string, file: string, line: number, message: string): V
32
34
 
33
35
  /** Blanks fenced code blocks (keeping line numbers): examples there are not claims. */
34
36
  export const stripFences = (text: string): string => {
35
- let inFence = false;
36
- return text
37
- .split("\n")
38
- .map((line) => {
39
- if (/^\s*(```|~~~)/.test(line)) {
40
- inFence = !inFence;
41
- return "";
42
- }
43
- return inFence ? "" : line;
44
- })
45
- .join("\n");
37
+ const lines = text.split("\n");
38
+ const { fenced } = fencedLines(lines);
39
+ return lines.map((line, i) => (fenced[i] ? "" : line)).join("\n");
46
40
  };
47
41
 
48
42
  /** Anything shaped like `prefix-id` or `prefix-id.3`; only ids beads actually knows count. */
@@ -129,6 +123,17 @@ export const checkRefs = (config: ResolvedConfig, snapshot: Snapshot): ReadonlyA
129
123
  .forEach((line, i) => {
130
124
  const n = i + 1;
131
125
  checkCommon(doc, n, line);
126
+ // Lines are read one at a time, so a span split across two is never checked. A line
127
+ // indented four spaces may be an indented code block, where a backtick is literal.
128
+ if (!/^( {4}|\t)/.test(line) && opensCodeSpan(line))
129
+ violations.push(
130
+ violation(
131
+ "split-code-span",
132
+ doc,
133
+ n,
134
+ "a code span crosses a line break, so the refs check can't see it: keep each `...` span on one line.",
135
+ ),
136
+ );
132
137
  if (
133
138
  normative &&
134
139
  NON_NORMATIVE.test(line.replace(/`[^`]*`/g, (span) => span.replaceAll("`", " ")))
@@ -143,7 +148,8 @@ export const checkRefs = (config: ResolvedConfig, snapshot: Snapshot): ReadonlyA
143
148
  );
144
149
  for (const match of line.matchAll(/`([^`\n]+)`/g)) {
145
150
  const span = match[1] ?? "";
146
- const script = /^bun run ([\w:./-]+)/.exec(span)?.[1];
151
+ const script = /^bun run (?:--?[\w-]+ )*([\w:./-]+)/.exec(span)?.[1];
152
+ // Flags such as `--silent` come before the script.
147
153
  // `bun run path/to/file.ts` runs a file, which the path check below covers.
148
154
  if (script !== undefined && !script.includes("/") && !KNOWN_EXTENSION.test(script)) {
149
155
  if (!snapshot.packageScripts.has(script))