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,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";
|
package/src/core/layout.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 = (
|
|
42
|
-
`${
|
|
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
|
|
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
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
const track =
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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))
|