@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/config.ts ADDED
@@ -0,0 +1,439 @@
1
+ // Team policy. `.shepherd/config.yml` (or the older `.shepherd/lenses.yml`) is layered in one fixed order:
2
+ // built-in defaults, then each pack it `extends`, then the file itself; a later layer replaces a scalar and
3
+ // merges a map by name. Every effective value carries the layer it came from. Personal settings
4
+ // (~/.config/shepherd/lenses.yml) are a preview: lenses that print locally, and a ladder or model pins that
5
+ // fill in only where the team sets none. Team files are read from the default branch, so a PR cannot change
6
+ // the policy it is reviewed under, and a pack from another repository is pinned to a commit.
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { z } from "zod";
11
+ import { parse } from "yaml";
12
+
13
+ const NAME = /^[a-z][a-z0-9-]*$/;
14
+
15
+ export const LensSchema = z
16
+ .object({
17
+ skill: z.string().min(1),
18
+ applies_to: z.array(z.string().min(1)).optional(),
19
+ description: z.string().min(1).optional(),
20
+ })
21
+ .strict();
22
+
23
+ export type Lens = z.infer<typeof LensSchema>;
24
+
25
+ // A model id is whatever the harness's agent tool accepts, passed through untouched: `haiku` on Claude Code,
26
+ // `provider/model` on OpenCode (`opencode-go/deepseek-v4-flash`). shepherd never maps one to another.
27
+ export const ModelId = z.string().min(1);
28
+
29
+ /** The steps a recipe can run: shepherd's own runners and stamp's verdict. */
30
+ export const STEPS = ["swarm", "triage", "simplify", "ci-repair", "stamp", "incident"] as const;
31
+
32
+ type Step = (typeof STEPS)[number];
33
+
34
+ // Steps that change code, and the steps that review it. Success needs a review after the last change.
35
+ const FIXERS = new Set<Step>(["triage", "simplify", "ci-repair", "incident"]);
36
+
37
+ const REVIEWS = new Set<Step>(["swarm", "stamp"]);
38
+
39
+ export const ConfigSchema = z
40
+ .object({
41
+ extends: z.array(z.string().min(1)).optional(),
42
+ ladder: z.array(ModelId).min(1).optional(), // cheapest first; replaces the harness's ladder
43
+ models: z.record(z.string().regex(NAME), ModelId).optional(), // a lens or runner name -> the model it always runs on
44
+ lenses: z.record(z.string().regex(NAME), LensSchema).default({}),
45
+ checks: z.record(z.string().regex(NAME), z.object({ command: z.string().min(1) }).strict()).optional(),
46
+ fixes: z.object({ mode: z.enum(["suggest", "local", "push"]) }).strict().optional(),
47
+ escalate: z.object({ paths: z.array(z.string().min(1)).min(1), reason: z.string().min(1).optional() }).strict().optional(),
48
+ recipes: z.record(z.string().regex(NAME), z.object({ steps: z.array(z.enum(STEPS)).min(1) }).strict()).optional(),
49
+ })
50
+ .strict();
51
+
52
+ export type Config = z.infer<typeof ConfigSchema>;
53
+
54
+ export const TEAM_FILES = [".shepherd/config.yml", ".shepherd/lenses.yml"];
55
+
56
+ const BUILT_IN: Config = {
57
+ lenses: {},
58
+ fixes: { mode: "push" },
59
+ recipes: {
60
+ review: { steps: ["swarm"] },
61
+ fix: { steps: ["swarm", "triage", "simplify", "swarm"] }, // runs under fixes.mode local: nothing it needs is pushed
62
+ pr: { steps: ["swarm", "triage", "simplify", "swarm", "ci-repair", "stamp"] },
63
+ incident: { steps: ["incident", "swarm", "triage", "simplify", "swarm", "ci-repair", "stamp"] }, // the fix PR gets the full loop
64
+ },
65
+ };
66
+
67
+ // `github:owner/repo/path/to/pack.yml@<40-hex commit>`. A branch or tag can move under the team; a commit cannot.
68
+ // Path characters are limited so nothing in it can reach the API URL as a query or fragment (`?ref=`, `#`).
69
+ export const PINNED = /^github:([\w.-]+)\/([\w.-]+)\/([\w./-]+)@([0-9a-f]{40})$/;
70
+
71
+ type Sourced<T> = { value: T; source: string };
72
+
73
+ /** One layer of policy and where it came from. */
74
+ type Layer = { source: string; config: Config };
75
+
76
+ export type Effective = {
77
+ ladder: Sourced<string[]> | null;
78
+ models: Record<string, Sourced<string>>;
79
+ lenses: Record<string, Sourced<Lens> & { scope: "team" | "personal" }>;
80
+ checks: Record<string, Sourced<{ command: string }>>;
81
+ fixes: Sourced<{ mode: "suggest" | "local" | "push" }>;
82
+ escalate: Sourced<{ paths: string[]; reason?: string }> | null;
83
+ recipes: Record<string, Sourced<{ steps: Step[] }>>;
84
+ /** Personal settings that would change team policy, so they were not applied. */
85
+ ignored: string[];
86
+ };
87
+
88
+ /** Reads a repository file (default branch or working tree); null when it does not exist. */
89
+ export type Read = (rel: string) => string | null;
90
+
91
+ /** Fetches a pinned pack from another repository. */
92
+ export type Fetch = (owner: string, repo: string, file: string, sha: string) => string;
93
+
94
+ const parseConfig = (text: string, source: string): Config => {
95
+ const result = ConfigSchema.safeParse(parse(text) ?? {});
96
+
97
+ if (!result.success) throw new Error(`${source}: ${result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
98
+
99
+ return result.data;
100
+ };
101
+
102
+ /** One `extends` entry as a layer. A pack extends nothing itself, so the order stays the one the file lists. */
103
+ function readPack(ref: string, read: Read, fetch: Fetch): Layer {
104
+ const pinned = PINNED.exec(ref);
105
+ let text: string | null;
106
+
107
+ if (pinned) text = fetch(pinned[1]!, pinned[2]!, pinned[3]!, pinned[4]!);
108
+ else if (ref.startsWith("github:")) throw new Error(`${ref}: pin a pack from another repository to a full commit sha (github:owner/repo/path@<40 hex>)`);
109
+ else if (pathEscapes(ref)) throw new Error(`${ref}: a pack path is repository-relative, with no leading / and no ..`);
110
+ else text = read(ref);
111
+
112
+ if (text === null) throw new Error(`${ref}: pack not found`);
113
+ const config = parseConfig(text, ref);
114
+
115
+ if (config.extends?.length) throw new Error(`${ref}: a pack cannot extend another pack; list both in the repository's extends`);
116
+
117
+ // A pinned pack's lens paths name files in the pack's repository at the same commit, so the lens travels with it.
118
+ if (pinned) {
119
+ for (const [name, lens] of Object.entries(config.lenses)) {
120
+ if (!lens.skill.includes("/")) continue;
121
+
122
+ if (pathEscapes(lens.skill)) throw new Error(`${ref}: lens ${name}: a skill path is relative to the pack's repository, with no leading / and no ..`);
123
+ lens.skill = `github:${pinned[1]}/${pinned[2]}/${lens.skill.replace(/\/+$/, "")}@${pinned[4]}`;
124
+ }
125
+ }
126
+
127
+ return { source: `pack ${ref}`, config };
128
+ }
129
+
130
+ const pathEscapes = (rel: string) => rel.startsWith("/") || rel.split("/").includes("..");
131
+
132
+ /** Merge layers in order: a later layer replaces a scalar and merges a map by name. */
133
+ function merge(layers: Layer[]): Effective {
134
+ const out: Effective = { ladder: null, models: {}, lenses: {}, checks: {}, fixes: { value: { mode: "push" }, source: "built-in" }, escalate: null, recipes: {}, ignored: [] };
135
+
136
+ for (const { source, config } of layers) {
137
+ const tag = <T>(value: T) => ({ value, source });
138
+
139
+ if (config.ladder) out.ladder = tag(config.ladder);
140
+
141
+ if (config.fixes) out.fixes = tag(config.fixes);
142
+
143
+ if (config.escalate) out.escalate = tag(config.escalate);
144
+
145
+ for (const [k, v] of Object.entries(config.models ?? {})) out.models[k] = tag(v);
146
+
147
+ for (const [k, v] of Object.entries(config.lenses)) out.lenses[k] = { ...tag(v), scope: "team" };
148
+
149
+ for (const [k, v] of Object.entries(config.checks ?? {})) out.checks[k] = tag(v);
150
+
151
+ for (const [k, v] of Object.entries(config.recipes ?? {})) out.recipes[k] = tag(v);
152
+ }
153
+
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * The effective policy: built-in defaults, the team file's packs in order, the team file, then the personal
159
+ * preview. `personal` is the parsed ~/.config/shepherd/lenses.yml, or null.
160
+ */
161
+ export function loadPolicy(read: Read, fetch: Fetch, personal: Config | null): Effective & { teamFile: string | null } {
162
+ const teamFile = TEAM_FILES.find((f) => read(f) !== null) ?? null;
163
+ const team = teamFile ? parseConfig(read(teamFile)!, teamFile) : null;
164
+ const packs = (team?.extends ?? []).map((ref) => readPack(ref, read, fetch));
165
+ const effective = merge([{ source: "built-in", config: BUILT_IN }, ...packs, ...(team ? [{ source: teamFile!, config: team }] : [])]);
166
+
167
+ // A lens may name a pinned pack directly; any skill spelled github: must be one that stays in the cache.
168
+ for (const [name, l] of [...Object.entries(effective.lenses), ...Object.entries(personal?.lenses ?? {}).map(([k, v]) => [k, { value: v }] as const)]) {
169
+ if (l.value.skill.startsWith("github:") && !safePinned(l.value.skill)) throw new Error(`lens ${name}: ${l.value.skill} is not github:owner/repo/<path>@<40-hex sha> with a path that stays in that repository`);
170
+ }
171
+
172
+ if (personal) {
173
+ const source = "personal";
174
+
175
+ // Personal fills in only what the team left open, and adds lenses that never post.
176
+ if (!effective.ladder && personal.ladder) effective.ladder = { value: personal.ladder, source };
177
+
178
+ for (const [k, v] of Object.entries(personal.models ?? {})) effective.models[k] ??= { value: v, source };
179
+
180
+ for (const [k, v] of Object.entries(personal.lenses)) effective.lenses[k] ??= { value: v, source, scope: "personal" };
181
+
182
+ for (const key of ["extends", "checks", "fixes", "escalate", "recipes"] as const) {
183
+ if (personal[key] !== undefined) effective.ignored.push(`personal ${key}: team policy, set it in ${TEAM_FILES[0]}`);
184
+ }
185
+ }
186
+
187
+ return { ...effective, teamFile };
188
+ }
189
+
190
+ type Mode = Effective["fixes"]["value"]["mode"];
191
+
192
+ /** `shepherd init --preset`: how far fixes go. */
193
+ export const PRESETS = { "review-only": "suggest", "local-fix": "local", push: "push" } as const satisfies Record<string, Mode>;
194
+
195
+ /** What is wrong with a recipe: an unknown order, a fix nothing reviews, or a step the fix mode forbids. */
196
+ export function recipeProblems(name: string, steps: Step[], mode: Mode): string[] {
197
+ const problems: string[] = [];
198
+ const at = (s: Step) => steps.indexOf(s);
199
+
200
+ if (steps.includes("incident") && at("incident") !== 0) problems.push(`recipe ${name}: incident opens the PR, so it must come first`);
201
+
202
+ if (steps.includes("stamp") && steps.lastIndexOf("stamp") !== steps.length - 1) problems.push(`recipe ${name}: stamp gives the verdict on the final head, so it must come last`);
203
+
204
+ if (steps.includes("triage") && !steps.slice(0, at("triage")).includes("swarm")) problems.push(`recipe ${name}: triage works the threads swarm posts, so a swarm must come before it`);
205
+
206
+ const lastFix = steps.map((s) => FIXERS.has(s)).lastIndexOf(true);
207
+
208
+ if (lastFix >= 0 && !steps.slice(lastFix + 1).some((s) => REVIEWS.has(s))) problems.push(`recipe ${name}: ${steps[lastFix]} changes code, so a swarm or stamp must review after it`);
209
+ // stamp reviews the pushed head; under local the fixes never reach it, so only a swarm of the working tree counts.
210
+ else if (mode === "local" && lastFix >= 0 && !steps.slice(lastFix + 1).includes("swarm")) problems.push(`recipe ${name}: fixes.mode is local, so stamp cannot see ${steps[lastFix]}'s fixes; a swarm must review after it`);
211
+
212
+ if (mode === "suggest") {
213
+ const fixers = steps.filter((s) => FIXERS.has(s));
214
+
215
+ if (fixers.length) problems.push(`recipe ${name}: fixes.mode is suggest, so it cannot run ${[...new Set(fixers)].join(", ")}`);
216
+ }
217
+
218
+ return problems;
219
+ }
220
+
221
+ /** Every problem in the effective policy that does not need the filesystem. A built-in recipe the fix mode rules out is unavailable, not a problem. */
222
+ export const policyProblems = (p: Effective): string[] =>
223
+ Object.entries(p.recipes).flatMap(([name, r]) => (r.source === "built-in" ? [] : recipeProblems(name, r.value.steps, p.fixes.value.mode)));
224
+
225
+ /** The recipe a plain `/shepherd` run matches under each fix mode. */
226
+ export const DEFAULT_RECIPE: Record<Mode, string> = { suggest: "review", local: "fix", push: "pr" };
227
+
228
+ /** How a recipe finishes, which is not always "ready to merge". */
229
+ export function completion(steps: Step[], mode: Mode): string {
230
+ if (!steps.some((s) => FIXERS.has(s))) return steps.at(-1) === "stamp" ? "ready when stamp approves this head, CI is green and no review thread is open" : "reviewed: findings posted for the author; says nothing about merge readiness";
231
+
232
+ if (mode === "local") return "fixes_uncommitted: reviewed in your working tree; commit, push and run again before it can be ready";
233
+
234
+ return steps.at(-1) === "stamp" ? "ready when stamp approves the final head, CI is green and no review thread is open; else pending" : "reviewed after the last fix; not ready until stamp approves, CI is green and no review thread is open";
235
+ }
236
+
237
+ // What each step can write. Swarm and stamp only post; the fixers' reach is the fix mode.
238
+ const WRITES: Record<Step, Record<Mode, string>> = {
239
+ swarm: { suggest: "posts review comments on the PR", local: "posts review comments on the PR", push: "posts review comments on the PR" },
240
+ stamp: { suggest: "posts a review (approve or request changes)", local: "posts a review (approve or request changes)", push: "posts a review (approve or request changes)" },
241
+ triage: { suggest: "replies on threads, changes no code", local: "edits your working tree", push: "commits and pushes to the PR branch, resolves threads" },
242
+ simplify: { suggest: "reports only", local: "edits your working tree", push: "commits and pushes to the PR branch" },
243
+ "ci-repair": { suggest: "reports only", local: "edits your working tree", push: "commits and pushes to the PR branch" },
244
+ incident: { suggest: "opens an incident issue", local: "opens an incident issue, edits your working tree", push: "opens an incident issue, pushes incident/<n>, opens a PR" },
245
+ };
246
+
247
+ // Built-in reviewers, and the ladder rung each runner starts on (skills/shepherd/references/models.md).
248
+ const BUILT_IN_REVIEWERS = ["correctness", "security", "simplicity", "maintainability", "slop", "spec"];
249
+
250
+ const RUNG = new Map([["router", 0], ["verifier", 1], ["triage", 1], ["simplify", 1], ["ci-repair", 1], ["validator", 2]]);
251
+
252
+ /** The model each runner uses: its pin, its rung of the team ladder, or the harness's own ladder. */
253
+ export function modelPlan(p: Effective): { runner: string; model: string; source: string }[] {
254
+ const names = ["router", ...BUILT_IN_REVIEWERS, ...Object.keys(p.lenses), "verifier", "triage", "simplify", "ci-repair", "validator"];
255
+
256
+ return [...new Set(names)].map((runner) => {
257
+ const pin = p.models[runner];
258
+ const rung = RUNG.get(runner) ?? 1; // a lens runs one rung above the router
259
+
260
+ if (pin) return { runner, model: pin.value, source: `pinned, ${pin.source}` };
261
+
262
+ if (p.ladder) return { runner, model: p.ladder.value[Math.min(rung, p.ladder.value.length - 1)]!, source: `rung ${rung}, ${p.ladder.source}` };
263
+
264
+ return { runner, model: `harness rung ${rung}`, source: "Claude Code: haiku > sonnet > opus; OpenCode with no ladder: the session model" };
265
+ });
266
+ }
267
+
268
+ const globMatch = (pattern: string, file: string) => new Bun.Glob(pattern).match(file);
269
+
270
+ /** What would run on these changed files: which lenses, which checks, how fixes land, what goes to a person. */
271
+ export function preview(p: Effective, files: string[], recipe?: string) {
272
+ const lenses = Object.entries(p.lenses).map(([name, l]) => {
273
+ const hits = l.value.applies_to ? files.filter((f) => l.value.applies_to!.some((g) => globMatch(g, f))) : null;
274
+
275
+ return { name, scope: l.scope, runs: hits === null ? "if the router calls it" : hits.length ? `on ${hits.length} file(s)` : "no matching files", files: hits ?? [] };
276
+ });
277
+
278
+ const escalated = p.escalate ? files.filter((f) => p.escalate!.value.paths.some((g) => globMatch(g, f))) : [];
279
+ const mode = p.fixes.value.mode;
280
+ const name = recipe ?? DEFAULT_RECIPE[mode];
281
+ const steps = p.recipes[name]?.value.steps;
282
+
283
+ if (!steps) throw new Error(`no recipe named ${name}; known: ${Object.keys(p.recipes).join(", ")}`);
284
+
285
+ return {
286
+ files,
287
+ lenses,
288
+ models: modelPlan(p),
289
+ checks: Object.keys(p.checks).length ? Object.entries(p.checks).map(([name, c]) => `${name}: ${c.value.command}`) : ["discovered by swarm from the repo's config"],
290
+ fixes: mode,
291
+ escalated,
292
+ recipe: name,
293
+ steps,
294
+ writes: [...new Set(steps)].map((s) => ({ step: s, writes: WRITES[s][mode] })),
295
+ unavailable: recipeProblems(name, steps, mode),
296
+ completes: completion(steps, mode),
297
+ };
298
+ }
299
+
300
+ const gitIn = (repoRoot: string) => (...args: string[]) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
301
+
302
+ /** The fetched ref of `origin`'s default branch. The name comes from the remote, since a CI checkout sets no origin/HEAD. */
303
+ export function defaultBranchRef(repoRoot: string): string {
304
+ const git = gitIn(repoRoot);
305
+ const branch = /^ref: refs\/heads\/(\S+)\s+HEAD/m.exec(git("ls-remote", "--symref", "origin", "HEAD"))?.[1];
306
+
307
+ if (!branch) throw new Error("cannot tell origin's default branch");
308
+ const ref = `refs/remotes/origin/${branch}`;
309
+
310
+ try {
311
+ git("rev-parse", "--verify", "--quiet", ref);
312
+ } catch {
313
+ throw new Error(`origin/${branch} is not fetched: run git fetch origin ${branch}`);
314
+ }
315
+
316
+ return ref;
317
+ }
318
+
319
+ /** A reader over the default branch of `origin`, the same source the skills use. */
320
+ export function defaultBranchReader(repoRoot: string): Read {
321
+ const git = gitIn(repoRoot);
322
+ const ref = defaultBranchRef(repoRoot);
323
+
324
+ return (rel) => {
325
+ try {
326
+ return git("show", `${ref}:${rel}`);
327
+ } catch {
328
+ return null;
329
+ }
330
+ };
331
+ }
332
+
333
+ /** Every file under a directory of a repository at a commit, as paths relative to that directory. */
334
+ export type FetchDir = (owner: string, repo: string, dir: string, sha: string) => { path: string; content: string }[];
335
+
336
+ /** Where a pinned lens directory is kept: by commit, so a cached copy never goes stale. */
337
+ export const lensCache = (home: string, ref: string): string | null => {
338
+ const m = safePinned(ref);
339
+
340
+ return m ? path.join(home, ".cache", "shepherd", "packs", m[1]!, m[2]!, m[4]!, m[3]!) : null;
341
+ };
342
+
343
+ /** A pinned reference whose every part stays inside the cache: no `.` or `..` owner or repo, no escaping path. */
344
+ const safePinned = (ref: string) => {
345
+ const m = PINNED.exec(ref);
346
+
347
+ return m && ![m[1], m[2]].some((part) => part === "." || part === "..") && !pathEscapes(m[3]!) && !m[3]!.split("/").includes("") && !m[3]!.includes("\\") ? m : null;
348
+ };
349
+
350
+ /**
351
+ * Fetch each pinned pack lens into the cache once. Each fetch writes a private temporary directory and
352
+ * renames it into place, so a reader never sees half a lens; a failed fetch leaves nothing behind, and when
353
+ * two fetch the same lens at once the first rename wins and the other's copy is dropped.
354
+ */
355
+ export function fetchPackLenses(p: Effective, home: string, fetchDir: FetchDir): void {
356
+ for (const { value } of Object.values(p.lenses)) {
357
+ const m = safePinned(value.skill);
358
+ const dest = lensCache(home, value.skill);
359
+
360
+ if (!m || !dest || existsSync(path.join(dest, "SKILL.md"))) continue;
361
+ mkdirSync(path.dirname(dest), { recursive: true });
362
+ sweepStale(dest);
363
+ const tmp = mkdtempSync(`${dest}.tmp-`);
364
+
365
+ try {
366
+ const files = fetchDir(m[1]!, m[2]!, m[3]!, m[4]!);
367
+
368
+ if (!files.some((f) => f.path === "SKILL.md")) throw new Error(`${value.skill}: no SKILL.md in that directory`);
369
+
370
+ for (const f of files) {
371
+ if (pathEscapes(f.path)) throw new Error(`${value.skill}: unsafe file path ${f.path}`);
372
+ mkdirSync(path.dirname(path.join(tmp, f.path)), { recursive: true });
373
+ writeFileSync(path.join(tmp, f.path), f.content);
374
+ }
375
+
376
+ // A directory without SKILL.md is no lens: an older, interrupted layout. Nothing reads it; replace it.
377
+ if (existsSync(dest) && !existsSync(path.join(dest, "SKILL.md"))) rmSync(dest, { recursive: true, force: true });
378
+
379
+ try {
380
+ renameSync(tmp, dest);
381
+ } catch (error) {
382
+ if (!existsSync(path.join(dest, "SKILL.md"))) throw error; // another fetch got there first otherwise
383
+ }
384
+ } finally {
385
+ rmSync(tmp, { recursive: true, force: true });
386
+ }
387
+ }
388
+ }
389
+
390
+ /** Remove temporary fetch directories for `dest` that a killed process left, once they are an hour old. */
391
+ function sweepStale(dest: string): void {
392
+ const prefix = `${path.basename(dest)}.tmp-`;
393
+
394
+ for (const name of readdirSync(path.dirname(dest))) {
395
+ const dir = path.join(path.dirname(dest), name);
396
+
397
+ if (name.startsWith(prefix) && Date.now() - statSync(dir).mtimeMs > 3_600_000) rmSync(dir, { recursive: true, force: true });
398
+ }
399
+ }
400
+
401
+ /** A directory through `gh`, recursively, at that commit. */
402
+ export const ghFetchDir: FetchDir = (owner, repo, dir, sha) => {
403
+ verifyPinned(owner, repo, sha);
404
+ const api = (p: string, raw: boolean) => execFileSync("gh", ["api", ...(raw ? ["-H", "Accept: application/vnd.github.raw"] : []), `repos/${owner}/${repo}/contents/${p}?ref=${sha}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
405
+ const Entries = z.array(z.object({ path: z.string(), type: z.string() }));
406
+
407
+ const walk = (p: string): { path: string; content: string }[] =>
408
+ Entries.parse(JSON.parse(api(p, false))).flatMap((e) => (e.type === "dir" ? walk(e.path) : e.type === "file" ? [{ path: path.posix.relative(dir, e.path), content: api(e.path, true) }] : []));
409
+
410
+ return walk(dir);
411
+ };
412
+
413
+ const gh = (...args: string[]) => execFileSync("gh", ["api", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
414
+
415
+ /**
416
+ * GitHub serves any commit in a repository's fork network through that repository, so a pin alone could name
417
+ * a commit someone pushed to a fork. A pin counts only when the commit is on the repository's default branch.
418
+ */
419
+ export function verifyPinned(owner: string, repo: string, sha: string): void {
420
+ const branch = gh(`repos/${owner}/${repo}`, "--jq", ".default_branch").trim();
421
+ let status: string;
422
+
423
+ try {
424
+ status = gh(`repos/${owner}/${repo}/compare/${branch}...${sha}`, "--jq", ".status").trim();
425
+ } catch (error) {
426
+ // An unrelated history (say a fork's orphan commit) has no merge base: GitHub answers 404.
427
+ if (!/No common ancestor/.test(String(Object(error).stderr))) throw error;
428
+ status = "unrelated";
429
+ }
430
+
431
+ if (status !== "behind" && status !== "identical") throw new Error(`github:${owner}/${repo}@${sha}: that commit is not on ${owner}/${repo}'s ${branch} branch; pin a commit from its history`);
432
+ }
433
+
434
+ /** A pinned pack through `gh`, as raw file contents at that commit. */
435
+ export const ghFetch: Fetch = (owner, repo, file, sha) => {
436
+ verifyPinned(owner, repo, sha);
437
+
438
+ return gh("-H", "Accept: application/vnd.github.raw", `repos/${owner}/${repo}/contents/${file}?ref=${sha}`);
439
+ };
package/src/doctor.ts ADDED
@@ -0,0 +1,153 @@
1
+ // `shepherd doctor`: is this machine and repository set up to run shepherd? Each check says what to do.
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { parse } from "yaml";
6
+ import { z } from "zod";
7
+ import { defaultBranchReader, defaultBranchRef, fetchPackLenses, ghFetch, ghFetchDir, loadPolicy, type Config } from "./config.ts";
8
+ import { bundledSkills } from "./install.ts";
9
+ import { lensProblems } from "./lens.ts";
10
+
11
+ export type Check = { check: string; status: "ok" | "warn" | "fail"; detail: string; fix?: string };
12
+
13
+ const run = (cmd: string, args: string[], cwd?: string) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
14
+
15
+ const attempt = <T>(f: () => T): T | Error => {
16
+ try {
17
+ return f();
18
+ } catch (error) {
19
+ return error instanceof Error ? error : new Error(String(error));
20
+ }
21
+ };
22
+
23
+ // Where a harness looks for skills: Claude Code, the shared .agents layout, Codex, OpenCode.
24
+ const SKILL_DIRS = (home: string, repo: string) => [
25
+ path.join(home, ".claude/skills"),
26
+ path.join(repo, ".claude/skills"),
27
+ path.join(home, ".agents/skills"),
28
+ path.join(repo, ".agents/skills"),
29
+ path.join(home, ".codex/skills"),
30
+ path.join(home, ".config/opencode/skills"),
31
+ ];
32
+
33
+ /**
34
+ * True when a workflow runs stamp on pull requests: a step that uses stamp's action, runs its package, or
35
+ * checks out its repository and then runs from that checkout. The word "stamp" alone is not enough.
36
+ */
37
+ export function runsStamp(text: string): boolean {
38
+ const parsed = Workflow.safeParse(attempt(() => parse(text)));
39
+
40
+ if (!parsed.success) return false;
41
+
42
+ if (!parsed.data.on.some((t) => t === "pull_request" || t === "pull_request_target")) return false;
43
+
44
+ return Object.values(parsed.data.jobs).some(({ steps }) => {
45
+ const checkouts = steps.filter((st) => st.with?.repository === "jagreehal/stamp").map((st) => st.with?.path ?? ".");
46
+
47
+ return steps.some((st) => st.uses?.startsWith("jagreehal/stamp") || st.run?.includes("@jagreehal/stamp") || checkouts.some((p) => p !== "." && st.run?.includes(`${p}/`)));
48
+ });
49
+ }
50
+
51
+ const Workflow = z.object({
52
+ on: z.union([z.string().transform((t) => [t]), z.array(z.string()), z.record(z.string(), z.unknown()).transform((o) => Object.keys(o))]),
53
+ jobs: z.record(z.string(), z.object({ steps: z.array(z.object({ uses: z.string().optional(), run: z.string().optional(), with: z.object({ repository: z.string().optional(), path: z.string().optional() }).passthrough().optional() }).passthrough()).default([]) }).passthrough()),
54
+ });
55
+
56
+ const CLAUDE_MODELS = /^(haiku|sonnet|opus|fable)$|^claude-/;
57
+
58
+ export function doctor(bundleRoot: string, home: string, repo: string, personal: Config | null): Check[] {
59
+ const checks: Check[] = [];
60
+
61
+ const add = (check: string, status: Check["status"], detail: string, fix?: string) => {
62
+ const c: Check = { check, status, detail };
63
+
64
+ if (fix) c.fix = fix;
65
+ checks.push(c);
66
+ };
67
+
68
+ const auth = attempt(() => run("gh", ["auth", "status"]));
69
+
70
+ if (auth instanceof Error) add("gh auth", "fail", "gh is missing or not signed in", "gh auth login (shepherd reads and writes GitHub through gh)");
71
+ else add("gh auth", "ok", "signed in");
72
+
73
+ // The skills call `shepherd` for local change capture and receipts, else `bunx @jagreehal/shepherd`.
74
+ if (attempt(() => run("sh", ["-c", "command -v shepherd"])) instanceof Error) {
75
+ add("shepherd cli", "warn", "not on PATH: the skills fall back to bunx @jagreehal/shepherd, which needs the package published on npm and network access on every call", "bun add -g @jagreehal/shepherd, or put a shepherd wrapper on PATH");
76
+ } else add("shepherd cli", "ok", "on PATH");
77
+
78
+ const read = attempt(() => defaultBranchReader(repo));
79
+
80
+ if (read instanceof Error) add("default branch", "fail", read.message, "git fetch origin <default branch>: policy and lenses are read from it");
81
+ else add("default branch", "ok", "fetched");
82
+
83
+ const missing = bundledSkills(path.join(bundleRoot, "skills")).filter((s) => !SKILL_DIRS(home, repo).some((d) => existsSync(path.join(d, s, "SKILL.md"))));
84
+
85
+ if (missing.length) add("skills", "fail", `not installed: ${missing.join(", ")}`, "bunx @jagreehal/shepherd install (or --project, or --target <your agent's skills dir>)");
86
+ else add("skills", "ok", "every bundled skill is installed");
87
+
88
+ if (read instanceof Error) return checks;
89
+
90
+ const policy = attempt(() => {
91
+ const p = loadPolicy(read, ghFetch, personal);
92
+
93
+ fetchPackLenses(p, home, ghFetchDir);
94
+
95
+ return p;
96
+ });
97
+
98
+ if (policy instanceof Error) {
99
+ const pack = /github:[\w.-]+\/[\w.-]+/.exec(policy.message)?.[0];
100
+
101
+ add("policy", "fail", policy.message.split("\n")[0]!, pack ? `gh must read ${pack.slice(7)}: sign gh in as an account with access, or set GH_TOKEN to a token that can read it` : "fix the file on the default branch; shepherd config validate checks your edits");
102
+
103
+ return checks;
104
+ }
105
+
106
+ add("policy", "ok", policy.teamFile ? `${policy.teamFile}, fixes.mode ${policy.fixes.value.mode}` : "no policy file: built-in defaults", policy.teamFile ? undefined : "bunx @jagreehal/shepherd init");
107
+
108
+ for (const [name, l] of Object.entries(policy.lenses)) {
109
+ const problems = lensProblems(l.value, home, repo);
110
+
111
+ if (problems.length) add(`lens ${name}`, "fail", problems.join("; "), `from ${l.source}: add the skill to this repository or install it for the agent (see "Packs and lens files" in policy.md)`);
112
+ }
113
+
114
+ const models = [...new Set([...(policy.ladder?.value ?? []), ...Object.values(policy.models).map((m) => m.value)])];
115
+ const opencode = models.some((m) => m.includes("/")) ? attempt(() => run("opencode", ["models"])) : null;
116
+
117
+ for (const model of models) {
118
+ if (CLAUDE_MODELS.test(model)) add(`model ${model}`, "ok", "a Claude Code model id");
119
+ else if (!model.includes("/")) add(`model ${model}`, "warn", "not a known id; it passes to the agent tool as written", "check the harness accepts it");
120
+ else if (opencode instanceof Error || opencode === null) add(`model ${model}`, "warn", "cannot list OpenCode models", "install opencode, or ignore if another harness runs it");
121
+ else if (opencode.split("\n").includes(model)) add(`model ${model}`, "ok", "listed by opencode models");
122
+ else add(`model ${model}`, "fail", "opencode models does not list it", "fix the id, or sign in to its provider (opencode auth login)");
123
+ }
124
+
125
+ if (!models.length) add("models", "ok", "no ladder set: the harness's own (shepherd models)");
126
+
127
+ const workflows = attempt(() => run("git", ["-C", repo, "ls-tree", "--name-only", "-r", defaultBranchRef(repo), ".github/workflows/"]));
128
+ const stamp = (workflows instanceof Error ? [] : workflows.split("\n").filter(Boolean)).find((f) => runsStamp(read(f) ?? ""));
129
+
130
+ if (!stamp) {
131
+ add("stamp workflow", "warn", "no default-branch workflow runs stamp on pull requests: shepherd can review and fix, but nothing gives the approval verdict", "bunx @jagreehal/stamp init, then add its backend secret");
132
+
133
+ return checks;
134
+ }
135
+
136
+ add("stamp workflow", "ok", `${stamp} runs stamp on pull requests`);
137
+ const last = attempt(() => run("gh", ["run", "list", "--workflow", path.basename(stamp), "--limit", "30", "--json", "conclusion,status,url,databaseId", "--jq", "[.[] | select(.conclusion != \"skipped\")][0] // empty | [.status, .conclusion, .url, .databaseId] | @tsv"], repo).trim());
138
+
139
+ if (last instanceof Error) add("stamp runs", "warn", "cannot read its runs through gh", "check gh can read this repository's Actions");
140
+ else if (!last) add("stamp runs", "warn", "configured, but no recent run did anything (all skipped, or none)", "open a PR, or comment /stamp on one, and check the run");
141
+ else {
142
+ const [status, conclusion, url, id] = last.split("\t");
143
+ // stamp exits 1 on a refusal or an escalation: the run failed, but stamp worked and gave its verdict.
144
+ const verdict = conclusion === "failure" ? /\b(REFUSED|ESCALATE): /.exec(String(attempt(() => run("gh", ["run", "view", id!, "--log-failed"], repo))))?.[1] : undefined;
145
+
146
+ if (conclusion === "success") add("stamp runs", "ok", "its latest run approved");
147
+ else if (verdict) add("stamp runs", "ok", `its latest run gave a verdict (${verdict}): ${url}`);
148
+ else if (conclusion === "action_required") add("stamp runs", "warn", `its latest run is waiting for a maintainer to approve it: ${url}`, "approve the run in the Actions tab (GitHub holds runs from first-time contributors and bots), or open a PR from a branch of this repository");
149
+ else add("stamp runs", "warn", `its latest run is ${conclusion || status}: ${url}`, "read the run log; a missing backend secret is the usual cause (stamp README, Backends)");
150
+ }
151
+
152
+ return checks;
153
+ }