@jagreehal/shepherd 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.
Files changed (57) hide show
  1. package/AGENTS.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +445 -2
  4. package/THIRD_PARTY_NOTICES.md +41 -0
  5. package/commands/shepherd.md +18 -0
  6. package/examples/local-demo/baseline/src/config.ts +2 -0
  7. package/examples/local-demo/baseline/src/db.ts +5 -0
  8. package/examples/local-demo/baseline/src/notes.ts +15 -0
  9. package/examples/local-demo/baseline/src/users.ts +8 -0
  10. package/examples/local-demo/expected.json +12 -0
  11. package/examples/local-demo/planted/src/config.ts +2 -0
  12. package/examples/local-demo/planted/src/notes.ts +15 -0
  13. package/examples/local-demo/planted/src/search.ts +8 -0
  14. package/examples/local-demo/planted/src/users.ts +8 -0
  15. package/lenses/observability/SKILL.md +26 -0
  16. package/package.json +46 -4
  17. package/skills/ci-repair/SKILL.md +178 -0
  18. package/skills/code-review/SKILL.md +89 -0
  19. package/skills/code-review/agents/openai.yaml +3 -0
  20. package/skills/diagnosing-bugs/SKILL.md +138 -0
  21. package/skills/diagnosing-bugs/agents/openai.yaml +3 -0
  22. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  23. package/skills/garden/SKILL.md +144 -0
  24. package/skills/incident/SKILL.md +261 -0
  25. package/skills/pair/SKILL.md +111 -0
  26. package/skills/pr/CREDITS.md +3 -0
  27. package/skills/pr/SKILL.md +170 -0
  28. package/skills/pr/agents/openai.yaml +3 -0
  29. package/skills/retro/SKILL.md +44 -0
  30. package/skills/retro/agents/openai.yaml +5 -0
  31. package/skills/review-correctness/SKILL.md +80 -0
  32. package/skills/review-maintainability/SKILL.md +86 -0
  33. package/skills/review-security/SKILL.md +75 -0
  34. package/skills/review-simplicity/SKILL.md +87 -0
  35. package/skills/review-slop/SKILL.md +94 -0
  36. package/skills/review-spec/SKILL.md +57 -0
  37. package/skills/shepherd/SKILL.md +407 -0
  38. package/skills/shepherd/references/degradation.md +21 -0
  39. package/skills/shepherd/references/dispatch.md +117 -0
  40. package/skills/shepherd/references/formats.md +46 -0
  41. package/skills/shepherd/references/models.md +88 -0
  42. package/skills/shepherd/references/policy.md +110 -0
  43. package/skills/swarm/SKILL.md +521 -0
  44. package/skills/swarm/references/custom-lenses.md +129 -0
  45. package/skills/swarm/references/formats.md +91 -0
  46. package/skills/triage/SKILL.md +388 -0
  47. package/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
  48. package/skills/writing-for-agents/SKILL.md +81 -0
  49. package/skills/writing-for-agents/agents/openai.yaml +3 -0
  50. package/src/acceptance.ts +111 -0
  51. package/src/cli.ts +379 -0
  52. package/src/config.ts +439 -0
  53. package/src/doctor.ts +153 -0
  54. package/src/garden.ts +198 -0
  55. package/src/install.ts +86 -0
  56. package/src/lens.ts +178 -0
  57. package/src/local.ts +393 -0
package/src/garden.ts ADDED
@@ -0,0 +1,198 @@
1
+ // The gardener's evidence: what shepherd did on a repository's recent PRs, read from GitHub (the record,
2
+ // never a session), and scored by fixed signals. The `garden` skill turns the scores into skill edits and
3
+ // the recurring lens findings into lint-rule proposals.
4
+ import { execFileSync } from "node:child_process";
5
+ import { z } from "zod";
6
+
7
+ export const HEADER = "🤖 Automated comment by";
8
+
9
+ export type Thread = { tag: string; severity: string; path: string; resolved: boolean; humanReplied: boolean };
10
+
11
+ export type Commit = { sha: string; headline: string; skill: string | null; lens: string | null; fixes: string[]; date: string };
12
+
13
+ export type PrRecord = {
14
+ number: number;
15
+ title: string;
16
+ state: string;
17
+ threads: Thread[];
18
+ commits: Commit[];
19
+ status: { state: string; description: string; updatedAt: string } | null;
20
+ rounds: number | null;
21
+ /** The author can push to the repository. Only then do the PR's Shepherd-Fixes trailers count. */
22
+ trusted: boolean;
23
+ };
24
+
25
+ const DAY = 864e5;
26
+
27
+ export type Signal = "reverted_fix" | "human_replied" | "round_cap" | "stopped_short" | "loop_stalled" | "churn";
28
+
29
+ const TAG = /\*\*\[(?:convergent: )?([^\]]+)\]\*\*\s*\S+\s*(CRITICAL|HIGH|MEDIUM|LOW|NIT)/;
30
+
31
+ const ROUND = /\(round (\d+) @/;
32
+
33
+ /** The lens rules in a finding's tag, `[convergent: a/b + c/d]` giving both. A bare lens name is no rule, and
34
+ * `checks/*` already comes from a tool, so neither is a candidate for a new lint rule. */
35
+ const rulesOf = (tag: string) => tag.split(" + ").map((t) => t.trim()).filter((t) => t.includes("/") && !t.startsWith("checks/"));
36
+
37
+ const isReverted = (commit: Commit, commits: Commit[]) => commits.some((r) => r.headline === `Revert "${commit.headline}"`);
38
+
39
+ /** True when shepherd left any mark on the PR: a thread, a commit, a status, or a swarm summary. */
40
+ const touched = (pr: PrRecord) => pr.threads.length > 0 || pr.commits.some((c) => c.skill) || pr.status !== null || pr.rounds !== null;
41
+
42
+ /**
43
+ * Fixed signals per PR shepherd touched, and the lens rules triage fixed (Shepherd-Fixes) on at least `minPrs` PRs. A reply is
44
+ * only `human_replied`: whether it corrected shepherd is the gardener's reading. `loop_stalled` needs a day of
45
+ * silence, so a run in flight never counts.
46
+ */
47
+ export function score(all: PrRecord[], minPrs = 3, now = Date.now()) {
48
+ const prs = all.filter(touched);
49
+
50
+ const scored = prs.map((pr) => {
51
+ const shepherdCommits = pr.commits.filter((c) => c.skill);
52
+ const reverted = shepherdCommits.filter((c) => isReverted(c, pr.commits));
53
+ const replied = pr.threads.filter((t) => t.humanReplied);
54
+ const lastActivity = Math.max(Date.parse(pr.status?.updatedAt ?? "") || 0, ...shepherdCommits.map((c) => Date.parse(c.date) || 0));
55
+ const final = pr.status?.state === "success" || pr.status?.state === "failure";
56
+ const signals: Signal[] = [];
57
+
58
+ if (reverted.length) signals.push("reverted_fix");
59
+
60
+ if (replied.length) signals.push("human_replied");
61
+
62
+ if ((pr.rounds ?? 0) >= 4) signals.push("round_cap");
63
+
64
+ if (pr.status?.state === "failure") signals.push("stopped_short");
65
+
66
+ if (shepherdCommits.length && !final && now - lastActivity > DAY) signals.push("loop_stalled");
67
+
68
+ if (shepherdCommits.length > 10) signals.push("churn");
69
+
70
+ return {
71
+ number: pr.number,
72
+ title: pr.title,
73
+ signals,
74
+ shepherdCommits: shepherdCommits.length,
75
+ reverted: reverted.map((c) => ({ sha: c.sha, headline: c.headline, skill: c.skill, lens: c.lens })),
76
+ replied: replied.map((t) => `${t.path} [${t.tag}]`),
77
+ threads: { posted: pr.threads.length, open: pr.threads.filter((t) => !t.resolved).length },
78
+ status: pr.status,
79
+ rounds: pr.rounds,
80
+ };
81
+ });
82
+
83
+ const byRule = new Map<string, { prs: Set<number>; paths: Set<string> }>();
84
+
85
+ // Accepted means triage fixed it and said so in a Shepherd-Fixes trailer. Triage also resolves findings it
86
+ // judged wrong, so a resolved thread proves nothing; and a thread a person argued in never counts.
87
+ for (const pr of prs.filter((p) => p.trusted)) {
88
+ const acceptedCommits = pr.commits.filter((c) => !isReverted(c, pr.commits));
89
+
90
+ for (const rule of new Set(acceptedCommits.flatMap((c) => c.fixes).flatMap(rulesOf))) {
91
+ const argued = pr.threads.some((t) => t.humanReplied && rulesOf(t.tag).includes(rule));
92
+
93
+ if (argued) continue;
94
+ const entry = byRule.get(rule) ?? { prs: new Set(), paths: new Set() };
95
+ entry.prs.add(pr.number);
96
+
97
+ for (const t of pr.threads.filter((x) => rulesOf(x.tag).includes(rule))) entry.paths.add(t.path);
98
+ byRule.set(rule, entry);
99
+ }
100
+ }
101
+
102
+ const recurring = [...byRule]
103
+ .flatMap(([rule, e]) => (e.prs.size >= minPrs ? [{ rule, prs: [...e.prs].sort((a, b) => a - b), paths: [...e.paths].sort() }] : []))
104
+ .sort((a, b) => b.prs.length - a.prs.length || a.rule.localeCompare(b.rule));
105
+
106
+ return { prs: scored, recurring };
107
+ }
108
+
109
+ /** Parse one shepherd thread's first comment and its replies; null when the thread is not shepherd's. */
110
+ export function parseThread(path: string, resolved: boolean, comments: { author: string; bot: boolean; body: string }[]): Thread | null {
111
+ const [first, ...replies] = comments;
112
+
113
+ if (!first?.body.includes(HEADER)) return null;
114
+ const m = TAG.exec(first.body);
115
+
116
+ if (!m) return null;
117
+ const humanReplied = replies.some((c) => !c.body.includes(HEADER) && !c.bot);
118
+
119
+ return { tag: m[1]!, severity: m[2]!, path, resolved, humanReplied };
120
+ }
121
+
122
+ /** One commit's shepherd trailers, from its full message. */
123
+ export function parseCommit(sha: string, message: string, date = ""): Commit {
124
+ const trailer = (key: string) => new RegExp(`^${key}: (\\S+)`, "m").exec(message)?.[1] ?? null;
125
+
126
+ const fixes = [...message.matchAll(/^Shepherd-Fixes: (\S+)/gm)].map((m) => m[1]!);
127
+
128
+ return { sha, headline: message.split("\n")[0] ?? "", skill: trailer("Shepherd"), lens: trailer("Shepherd-Lens"), fixes, date };
129
+ }
130
+
131
+ export const roundsOf = (summaryBody: string | undefined) => (summaryBody ? Number(ROUND.exec(summaryBody)?.[1] ?? 0) || null : null);
132
+
133
+ const gh = (args: string[]) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "pipe"] }); // stderr rides on the thrown error
134
+
135
+ const PrList = z.array(z.object({ number: z.number(), title: z.string(), state: z.string(), headRefOid: z.string(), author: z.object({ login: z.string() }) }));
136
+
137
+ const Permission = z.object({ permission: z.string() });
138
+
139
+ const Threads = z.object({
140
+ data: z.object({
141
+ repository: z.object({
142
+ pullRequest: z.object({
143
+ reviewThreads: z.object({
144
+ nodes: z.array(
145
+ z.object({
146
+ isResolved: z.boolean(),
147
+ path: z.string(),
148
+ comments: z.object({ nodes: z.array(z.object({ author: z.object({ login: z.string(), __typename: z.string() }).nullable(), body: z.string() })) }),
149
+ }),
150
+ ),
151
+ }),
152
+ }),
153
+ }),
154
+ }),
155
+ });
156
+
157
+ const Commits = z.array(z.object({ sha: z.string(), commit: z.object({ message: z.string(), committer: z.object({ date: z.string() }).nullable() }) }));
158
+
159
+ const Statuses = z.object({ statuses: z.array(z.object({ context: z.string(), state: z.string(), description: z.string().nullable(), updated_at: z.string() })) });
160
+
161
+ const Comments = z.array(z.object({ body: z.string() }));
162
+
163
+ /** Read shepherd's record on the PRs updated since `since` (YYYY-MM-DD). */
164
+ export function collect(repo: string, since: string): PrRecord[] {
165
+ const [owner, name] = repo.split("/");
166
+ const prs = PrList.parse(JSON.parse(gh(["pr", "list", "-R", repo, "--state", "all", "--search", `updated:>=${since}`, "--limit", "100", "--json", "number,title,state,headRefOid,author"])));
167
+ const canPush = new Map<string, boolean>();
168
+
169
+ const trusted = (login: string) => {
170
+ if (!canPush.has(login)) {
171
+ let permission = "none";
172
+
173
+ try {
174
+ permission = Permission.parse(JSON.parse(gh(["api", `repos/${repo}/collaborators/${login}/permission`]))).permission;
175
+ } catch {
176
+ // Not a collaborator, or the token cannot say: treat as untrusted.
177
+ }
178
+
179
+ canPush.set(login, ["admin", "maintain", "write"].includes(permission));
180
+ }
181
+
182
+ return canPush.get(login) === true;
183
+ };
184
+
185
+ return prs.map((pr) => {
186
+ const query = "query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved path comments(first:50){nodes{author{login __typename} body}}}}}}}";
187
+
188
+ const threads = Threads.parse(JSON.parse(gh(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `n=${pr.number}`])))
189
+ .data.repository.pullRequest.reviewThreads.nodes.flatMap((t) => parseThread(t.path, t.isResolved, t.comments.nodes.map((c) => ({ author: c.author?.login ?? "ghost", bot: c.author?.__typename === "Bot", body: c.body }))) ?? []);
190
+
191
+ const commits = Commits.parse(JSON.parse(gh(["api", "--paginate", "--slurp", `repos/${repo}/pulls/${pr.number}/commits`])).flat()).map((c) => parseCommit(c.sha, c.commit.message, c.commit.committer?.date ?? ""));
192
+
193
+ const status = Statuses.parse(JSON.parse(gh(["api", `repos/${repo}/commits/${pr.headRefOid}/status`]))).statuses.find((s) => s.context === "shepherd");
194
+ const summary = Comments.parse(JSON.parse(gh(["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr.number}/comments`])).flat()).find((c) => c.body.includes("<!-- shepherd-swarm-summary -->"));
195
+
196
+ return { number: pr.number, title: pr.title, state: pr.state, threads, commits, status: status ? { state: status.state, description: status.description ?? "", updatedAt: status.updated_at } : null, rounds: roundsOf(summary?.body), trusted: trusted(pr.author.login) };
197
+ });
198
+ }
package/src/install.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Copies (or links) the bundled skills and commands into an agent's skills directory.
2
+ // A destination this installer did not create is never replaced without --force: it
3
+ // may be the user's own skill that happens to share a name.
4
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import path from "node:path";
6
+
7
+ export const MARKER = ".shepherd-installed";
8
+
9
+ // Command files are single files, so they carry the marker as a comment line instead.
10
+ const COMMAND_MARKER = "<!-- shepherd-installed -->";
11
+
12
+ export type Outcome = { name: string; dest: string; action: "installed" | "replaced" | "skipped" | "removed" };
13
+
14
+ export const bundledSkills = (skillsRoot: string) =>
15
+ readdirSync(skillsRoot, { withFileTypes: true })
16
+ .filter((d) => d.isDirectory() && existsSync(path.join(skillsRoot, d.name, "SKILL.md")))
17
+ .map((d) => d.name)
18
+ .sort();
19
+
20
+ /** True when `dest` came from this installer: a link into our bundle, or a copy carrying our marker. */
21
+ function ours(dest: string, bundleRoot: string): boolean {
22
+ const stat = lstatSync(dest, { throwIfNoEntry: false });
23
+
24
+ if (!stat) return false;
25
+
26
+ if (stat.isSymbolicLink()) return path.resolve(path.dirname(dest), readlinkSync(dest)).startsWith(path.resolve(bundleRoot) + path.sep);
27
+
28
+ return stat.isDirectory() ? existsSync(path.join(dest, MARKER)) : readFileSync(dest, "utf8").includes(COMMAND_MARKER);
29
+ }
30
+
31
+ function place(src: string, dest: string, bundleRoot: string, link: boolean, force: boolean): Outcome["action"] {
32
+ const present = lstatSync(dest, { throwIfNoEntry: false }) !== undefined;
33
+
34
+ if (present && !ours(dest, bundleRoot) && !force) return "skipped";
35
+
36
+ if (present) rmSync(dest, { recursive: true, force: true });
37
+
38
+ mkdirSync(path.dirname(dest), { recursive: true });
39
+
40
+ if (link) symlinkSync(src, dest);
41
+ else {
42
+ cpSync(src, dest, { recursive: true });
43
+
44
+ if (lstatSync(dest).isDirectory()) writeFileSync(path.join(dest, MARKER), "installed by @jagreehal/shepherd; safe to delete with `shepherd uninstall`\n");
45
+ }
46
+
47
+ return present ? "replaced" : "installed";
48
+ }
49
+
50
+ /** Install every bundled skill into `skillsDest`, and every command into `commandsDest` when given. */
51
+ export function install(bundleRoot: string, skillsDest: string, commandsDest: string | null, link: boolean, force: boolean): Outcome[] {
52
+ const skillsRoot = path.join(bundleRoot, "skills");
53
+
54
+ const outcomes = bundledSkills(skillsRoot).map((name) => {
55
+ const dest = path.join(skillsDest, name);
56
+
57
+ return { name, dest, action: place(path.join(skillsRoot, name), dest, bundleRoot, link, force) };
58
+ });
59
+
60
+ if (commandsDest === null) return outcomes;
61
+ const commandsRoot = path.join(bundleRoot, "commands");
62
+
63
+ for (const file of existsSync(commandsRoot) ? readdirSync(commandsRoot).filter((f) => f.endsWith(".md")) : []) {
64
+ const dest = path.join(commandsDest, file);
65
+ outcomes.push({ name: `/${path.basename(file, ".md")}`, dest, action: place(path.join(commandsRoot, file), dest, bundleRoot, link, force) });
66
+ }
67
+
68
+ return outcomes;
69
+ }
70
+
71
+ /** Remove what this installer placed; anything else with a bundled name stays. */
72
+ export function uninstall(bundleRoot: string, skillsDest: string, commandsDest: string | null): Outcome[] {
73
+ const targets = bundledSkills(path.join(bundleRoot, "skills")).map((name) => ({ name, dest: path.join(skillsDest, name) }));
74
+ const commandsRoot = path.join(bundleRoot, "commands");
75
+
76
+ if (commandsDest !== null && existsSync(commandsRoot)) {
77
+ for (const file of readdirSync(commandsRoot).filter((f) => f.endsWith(".md"))) targets.push({ name: `/${path.basename(file, ".md")}`, dest: path.join(commandsDest, file) });
78
+ }
79
+
80
+ return targets.map(({ name, dest }) => {
81
+ if (!ours(dest, bundleRoot)) return { name, dest, action: "skipped" as const };
82
+ rmSync(dest, { recursive: true, force: true });
83
+
84
+ return { name, dest, action: "removed" as const };
85
+ });
86
+ }
package/src/lens.ts ADDED
@@ -0,0 +1,178 @@
1
+ // Custom lenses: any skill, wrapped by swarm as a review-only reviewer whose `## Fix` section steers
2
+ // triage. This file reads and writes the lens files and finds skills on disk; swarm itself reads the
3
+ // repository's file from the default branch at review time (skills/swarm/references/custom-lenses.md).
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { parse, stringify } from "yaml";
7
+ import { z } from "zod";
8
+ import { type Config, ConfigSchema, type Lens, LensSchema, lensCache, ModelId, TEAM_FILES } from "./config.ts";
9
+
10
+ export const BUILT_IN_LENSES = ["correctness", "security", "simplicity", "maintainability", "slop", "spec"];
11
+
12
+ const LENS_NAME = /^[a-z][a-z0-9-]*$/;
13
+
14
+ // The lens file is the team policy file: lens commands write lenses, ladder and pins and keep every other key.
15
+ const LensFileSchema = ConfigSchema;
16
+
17
+ type LensFile = Config;
18
+
19
+ export type { Lens };
20
+
21
+ export const globalLensFile = (home: string) => path.join(home, ".config", "shepherd", "lenses.yml");
22
+
23
+ /** The repository's policy file: `.shepherd/config.yml`, or the older `.shepherd/lenses.yml` when only that exists. */
24
+ export const repoLensFile = (repoRoot: string) => {
25
+ const [config, legacy] = TEAM_FILES.map((f) => path.join(repoRoot, f));
26
+
27
+ return existsSync(config!) || !existsSync(legacy!) ? config! : legacy!;
28
+ };
29
+
30
+ const readFile = (file: string): LensFile => (existsSync(file) ? LensFileSchema.parse(parse(readFileSync(file, "utf8")) ?? {}) : { lenses: {} });
31
+
32
+ export const readLenses = (file: string): Record<string, Lens> => readFile(file).lenses;
33
+
34
+ /**
35
+ * OpenCode agents for a harness whose subagent tool takes no model (OpenCode 1.x): one per rung,
36
+ * `shepherd-r<i>`, and one per pin, `shepherd-pin-<name>`, each fixed to its model. Merge the result into
37
+ * OPENCODE_CONFIG_CONTENT; swarm and the loop then dispatch by agent name.
38
+ */
39
+ export function opencodeAgents(choice: { ladder: string[] | null; models: Record<string, string> }) {
40
+ const agent = (model: string, about: string) => ({ mode: "subagent", model, description: `shepherd runner on ${model} (${about})` });
41
+
42
+ return {
43
+ agent: Object.fromEntries([
44
+ ...(choice.ladder ?? []).map((model, i) => [`shepherd-r${i}`, agent(model, `rung ${i}`)] as const),
45
+ ...Object.entries(choice.models).map(([name, model]) => [`shepherd-pin-${name}`, agent(model, `pinned for ${name}`)] as const),
46
+ ]),
47
+ };
48
+ }
49
+
50
+ /** Pin `name` (a lens or runner) to `model`, keeping everything else in the file. */
51
+ export function pinModel(file: string, name: string, model: string): void {
52
+ if (!LENS_NAME.test(name)) throw new Error(`"${name}" must be lowercase letters, digits, and dashes`);
53
+ const current = readFile(file);
54
+
55
+ write(file, { ...current, models: { ...current.models, [name]: ModelId.parse(model) } });
56
+ }
57
+
58
+ /** Replace the ladder, cheapest model first. */
59
+ export function setLadder(file: string, ladder: string[]): void {
60
+ write(file, { ...readFile(file), ladder: z.array(ModelId).min(1).parse(ladder) });
61
+ }
62
+
63
+ function write(file: string, contents: LensFile): void {
64
+ mkdirSync(path.dirname(file), { recursive: true });
65
+ writeFileSync(file, stringify(contents));
66
+ }
67
+
68
+ /** Add or replace one lens, keeping every other entry in the file. */
69
+ export function addLens(file: string, name: string, lens: Lens): void {
70
+ if (!LENS_NAME.test(name)) throw new Error(`lens name "${name}" must be lowercase letters, digits, and dashes`);
71
+
72
+ if (BUILT_IN_LENSES.includes(name)) throw new Error(`"${name}" is a built-in lens; pick another name`);
73
+ const current = readFile(file);
74
+
75
+ write(file, { ...current, lenses: { ...current.lenses, [name]: LensSchema.parse(lens) } });
76
+ }
77
+
78
+ const lensTemplate = (name: string, description: string) => `---
79
+ ${stringify({ name, description })}---
80
+
81
+ # ${name}
82
+
83
+ ## Review
84
+
85
+ What to flag, most important first. Give each rule an id so findings read
86
+ \`[${name}/<id>]\`.
87
+
88
+ - **<id>**: <the rule>. Flag: <what breaking it looks like>. Why: <the cost>.
89
+
90
+ ## Fix
91
+
92
+ How triage should fix a \`[${name}/...]\` finding. Optional; without it triage
93
+ fixes by its own rules. Nothing here can loosen shepherd's rules.
94
+
95
+ - Prefer: <the pattern or existing helper to reach for>.
96
+ - Never touch: <files or APIs a fix must leave alone>.
97
+ - Escalate: <changes that go to the author instead of being fixed>.
98
+ `;
99
+
100
+ const RULE_ID = /^- \*\*([a-z0-9][a-z0-9-]*)\*\*/gm;
101
+
102
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
103
+
104
+ /** What would stop swarm or triage using a lens, one line each; empty when it is ready. Section rules apply to lenses under .shepherd/lenses/. */
105
+ export function lensProblems(lens: Lens, home: string, repoRoot: string): string[] {
106
+ const dir = resolveSkill(lens.skill, home, repoRoot);
107
+
108
+ if (!dir) return [lensCache(home, lens.skill) ? `"${lens.skill}" is not fetched yet: shepherd config explain fetches pinned pack lenses` : `no SKILL.md found for "${lens.skill}"`];
109
+ const file = path.join(dir, "SKILL.md");
110
+ const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
111
+ const front = FRONTMATTER.exec(text)?.[1];
112
+ const problems: string[] = [];
113
+
114
+ if (front === undefined || !skillDescription(dir)) problems.push(`${file}: frontmatter needs a description`);
115
+
116
+ if (!path.posix.normalize(lens.skill.replaceAll("\\", "/")).startsWith(".shepherd/lenses/")) return problems;
117
+ const review = /^## Review\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(text)?.[1];
118
+
119
+ if (review === undefined) return [...problems, `${file}: add a "## Review" section`];
120
+ const ids = [...review.matchAll(RULE_ID)].map((m) => m[1]);
121
+
122
+ if (ids.length === 0) problems.push(`${file}: "## Review" has no rules; write each as "- **<id>**: <rule>", the id lowercase with dashes`);
123
+
124
+ for (const id of new Set(ids.filter((id, i) => ids.indexOf(id) !== i))) problems.push(`${file}: rule id "${id}" is used more than once`);
125
+
126
+ return problems;
127
+ }
128
+
129
+ /** Scaffold a lens skill at .shepherd/lenses/<name> and register it. Returns the skill directory. */
130
+ /** Starter lenses shipped with shepherd, copied into a repository by `lens new --from`. */
131
+ export const TEMPLATES_DIR = path.resolve(import.meta.dir, "..", "lenses");
132
+
133
+ export const lensTemplates = (): string[] => readdirSync(TEMPLATES_DIR).filter((d) => existsSync(path.join(TEMPLATES_DIR, d, "SKILL.md"))).sort();
134
+
135
+ /** A starter lens's SKILL.md, renamed to `name` so its findings read `[<name>/<id>]`. */
136
+ function fromTemplate(template: string, name: string): string {
137
+ if (!lensTemplates().includes(template)) throw new Error(`no lens template "${template}"; available: ${lensTemplates().join(", ")}`);
138
+
139
+ return readFileSync(path.join(TEMPLATES_DIR, template, "SKILL.md"), "utf8").replace(/^name: .*$/m, `name: ${name}`).replace(/^# .*$/m, `# ${name}`);
140
+ }
141
+
142
+ export function newLens(repoRoot: string, name: string, lens: Omit<Lens, "skill">, template?: string): string {
143
+ const skill = path.posix.join(".shepherd", "lenses", name); // lenses.yml paths are posix on every OS
144
+ const dir = path.join(repoRoot, skill);
145
+
146
+ if (existsSync(dir)) throw new Error(`${skill} already exists`);
147
+ const text = template ? fromTemplate(template, name) : lensTemplate(name, lens.description ?? `Reviews code against the team's ${name} rules.`);
148
+
149
+ addLens(repoLensFile(repoRoot), name, { skill, ...lens });
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(path.join(dir, "SKILL.md"), text);
152
+
153
+ return dir;
154
+ }
155
+
156
+ /** The directory holding the skill's SKILL.md: a path against the repository, or a name in the usual skills directories. */
157
+ export function resolveSkill(ref: string, home: string, repoRoot: string): string | null {
158
+ const pinned = lensCache(home, ref);
159
+
160
+ if (pinned) return existsSync(path.join(pinned, "SKILL.md")) ? pinned : null;
161
+ const expanded = ref.startsWith("~/") ? path.join(home, ref.slice(2)) : ref;
162
+
163
+ const candidates = expanded.includes("/")
164
+ ? [path.resolve(repoRoot, expanded)]
165
+ : [".claude/skills", ".agents/skills", ".codex/skills"].map((d) => path.join(home, d, expanded)).concat([".claude/skills", ".agents/skills"].map((d) => path.join(repoRoot, d, expanded)));
166
+
167
+ return candidates.find((dir) => existsSync(path.join(dir, "SKILL.md"))) ?? null;
168
+ }
169
+
170
+ /** The `description` from a skill's frontmatter, folded to one line. */
171
+ export function skillDescription(skillDir: string): string | null {
172
+ const front = FRONTMATTER.exec(readFileSync(path.join(skillDir, "SKILL.md"), "utf8"))?.[1];
173
+
174
+ if (front === undefined) return null;
175
+ const parsed = z.object({ description: z.string() }).passthrough().safeParse(parse(front));
176
+
177
+ return parsed.success ? parsed.data.description.replace(/\s+/g, " ").trim() : null;
178
+ }