@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
@@ -0,0 +1,111 @@
1
+ // Runtime acceptance: run an agent under a fix mode and check what it actually did, from evidence, not from
2
+ // its report. Under suggest nothing changes. Under local the working tree may change, and only that: no
3
+ // commit, no push, no resolved thread, and the last review covered the edits as they were left.
4
+ import { execFileSync } from "node:child_process";
5
+ import { lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import { git, receiptStatus } from "./local.ts";
9
+
10
+ export type Snapshot = { head: string; remote: string; tree: string; resolved: string[] };
11
+
12
+ /** Thread ids already resolved on a PR; [] without one. */
13
+ export type Threads = () => string[];
14
+
15
+ /**
16
+ * The whole working tree as one tree id; the real index is untouched. It starts from HEAD's tree, not the real
17
+ * index, so nothing an agent sets there (assume-unchanged, skip-worktree, stat data) can hide an edit. Then
18
+ * every path HEAD or the index tracks is added, ignored or not, plus every untracked file that .gitignore
19
+ * alone does not exclude, plus every .gitignore: an agent cannot hide a file through .git/info/exclude, its own
20
+ * excludes file, or a new .gitignore.
21
+ */
22
+ function treeId(repo: string): string {
23
+ const dir = mkdtempSync(path.join(tmpdir(), "shepherd-acceptance-"));
24
+ const env = { GIT_INDEX_FILE: path.join(dir, "index"), GIT_LITERAL_PATHSPECS: "1" };
25
+ const list = (args: string[]) => git(repo, args).split("\0").filter(Boolean);
26
+
27
+ try {
28
+ let inHead = new Set<string>();
29
+
30
+ try {
31
+ git(repo, ["read-tree", "HEAD"], env);
32
+ inHead = new Set(list(["ls-tree", "-r", "-z", "--name-only", "HEAD"]));
33
+ } catch {
34
+ // No commit yet: start empty.
35
+ }
36
+
37
+ // Every .gitignore too, ignored ones included: a new one that ignores itself (`*`) would hide its directory.
38
+ const ignoreFiles = list(["ls-files", "-z", "-o", "-i", "--exclude-per-directory=.gitignore"]).filter((p) => path.posix.basename(p) === ".gitignore");
39
+
40
+ const paths = [...new Set([...inHead, ...list(["ls-files", "-z"]), ...list(["ls-files", "-z", "-o", "--exclude-per-directory=.gitignore"]), ...ignoreFiles])].filter(
41
+ (p) => inHead.has(p) || lstatSync(path.join(repo, p), { throwIfNoEntry: false }) !== undefined,
42
+ );
43
+
44
+ if (paths.length) {
45
+ writeFileSync(path.join(dir, "paths"), paths.join("\0"));
46
+ git(repo, ["add", "-A", "-f", `--pathspec-from-file=${path.join(dir, "paths")}`, "--pathspec-file-nul"], env);
47
+ }
48
+
49
+ return git(repo, ["write-tree"], env).trim();
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Every URL the repository can push to, fetch and push, for every remote, with insteadOf applied. Taken
57
+ * before the run and reused after it: the agent can rewrite remotes, so the after snapshot must not ask them.
58
+ */
59
+ export const pushTargets = (repo: string): string[] => {
60
+ const remotes = git(repo, ["remote"]).split("\n").filter(Boolean);
61
+
62
+ return [...new Set(remotes.flatMap((r) => [...git(repo, ["remote", "get-url", "--all", r]).split("\n"), ...git(repo, ["remote", "get-url", "--push", "--all", r]).split("\n")]).filter(Boolean))].sort();
63
+ };
64
+
65
+ /** Refs at each URL, read from outside the repository so its config (insteadOf, a new pushurl) plays no part. */
66
+ function remoteRefs(urls: string[]): string {
67
+ const outside = mkdtempSync(path.join(tmpdir(), "shepherd-acceptance-"));
68
+
69
+ try {
70
+ return urls.map((u) => `${u}\n${execFileSync("git", ["ls-remote", u], { cwd: outside, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })}`).join("\n");
71
+ } finally {
72
+ rmSync(outside, { recursive: true, force: true });
73
+ }
74
+ }
75
+
76
+ export const snapshot = (repo: string, threads: Threads, targets = pushTargets(repo)): Snapshot & { targets: string[] } => ({
77
+ head: git(repo, ["rev-parse", "HEAD"]).trim(),
78
+ remote: remoteRefs(targets),
79
+ tree: treeId(repo),
80
+ resolved: threads().sort(),
81
+ targets,
82
+ });
83
+
84
+ /** What the run did that its mode forbids, or failed to do that it requires. Empty means it held. */
85
+ export function verdict(repo: string, mode: "suggest" | "local", before: Snapshot, after: Snapshot): string[] {
86
+ const problems: string[] = [];
87
+
88
+ if (after.head !== before.head) problems.push(`committed: HEAD moved ${before.head.slice(0, 7)} -> ${after.head.slice(0, 7)}`);
89
+
90
+ if (after.remote !== before.remote) problems.push("pushed: a remote's refs changed");
91
+ const newlyResolved = after.resolved.filter((id) => !before.resolved.includes(id));
92
+
93
+ if (newlyResolved.length) problems.push(`resolved ${newlyResolved.length} thread(s): ${newlyResolved.join(", ")}`);
94
+
95
+ if (mode === "suggest" && after.tree !== before.tree) problems.push("changed code under fixes.mode suggest");
96
+
97
+ if (mode === "local") {
98
+ if (after.tree === before.tree) problems.push("made no fixes in the working tree");
99
+ else if (receiptStatus(repo).status !== "reviewed") problems.push("the last review did not cover the uncommitted edits (shepherd local status is not reviewed)");
100
+ }
101
+
102
+ return problems;
103
+ }
104
+
105
+ /** Resolved review threads on a PR, through gh, every page of them. */
106
+ export const ghThreads = (pr: number): Threads => () => {
107
+ const query = "query($owner:String!,$name:String!,$pr:Int!,$endCursor:String){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100,after:$endCursor){nodes{id isResolved} pageInfo{hasNextPage endCursor}}}}}";
108
+ const out = execFileSync("gh", ["api", "graphql", "--paginate", "-F", `pr=${pr}`, "-F", "owner={owner}", "-F", "name={repo}", "-f", `query=${query}`, "--jq", ".data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved) | .id"], { encoding: "utf8" });
109
+
110
+ return out.split("\n").filter(Boolean);
111
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env bun
2
+ // shepherd install [--project | --target <dir>] [--link] [--force]
3
+ // shepherd uninstall [--project | --target <dir>]
4
+ // shepherd lens new <name> [--from <template>] [--applies <glob>]... [--description <text>] (templates: lenses/)
5
+ // shepherd lens add <skill> [--name <name>] [--applies <glob>]... [--description <text>] [--global]
6
+ // shepherd lens list
7
+ // shepherd models [ladder <model>... | pin <lens-or-runner> <model>] [--global]
8
+ // shepherd opencode-agents [--file <lenses.yml>] (agents for OpenCode 1.x, from the default branch's lenses.yml)
9
+ // shepherd garden <owner/repo>... [--since YYYY-MM-DD] [--min-prs <n>]
10
+ // shepherd local change [--base <ref>] (the change before a PR: unpushed commits, staged, unstaged, untracked)
11
+ // shepherd local finish <record.json> [--base <ref>] (check a local review against that change; complete writes a receipt)
12
+ // shepherd local status [--base <ref>] (is the change on disk the one a complete review covered?)
13
+ // shepherd local demo [<dir>] (a repository with planted bugs to try /shepherd --local on)
14
+ // shepherd init [--preset review-only|local-fix|push] [--from <pack>] (scaffold .shepherd/config.yml)
15
+ // shepherd config validate (the working tree's policy: exits 1 on a problem)
16
+ // shepherd config explain [--json] [--working-tree] (effective policy and where each setting came from)
17
+ // shepherd preview [--recipe <name>] [--base <ref>] [--working-tree] (what would run on your change)
18
+ // shepherd doctor [--json] (is this machine and repository set up? each problem says what to do)
19
+ // shepherd acceptance <suggest|local> [--pr <n>] -- <agent command>... (run it; check it kept to the mode)
20
+ //
21
+ // Default: skills to ~/.claude/skills, the /shepherd command to ~/.claude/commands.
22
+ // --project installs into ./.claude of the current repository instead.
23
+ // --target <dir> installs skills only, into any agent's skills directory (e.g. ~/.codex/skills, ./.agents/skills).
24
+ // A lens wraps any skill as a swarm reviewer: .shepherd/lenses.yml for the repository, --global for a
25
+ // personal preview that prints locally and never posts. Model ids pass straight to the harness's agent
26
+ // tool (`haiku` on Claude Code, `provider/model` on OpenCode); --model on a lens pins it to one.
27
+ import { execFileSync, spawnSync } from "node:child_process";
28
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { homedir, tmpdir } from "node:os";
30
+ import path from "node:path";
31
+ import { parseArgs } from "node:util";
32
+ import { parse as parseYaml, stringify } from "yaml";
33
+ import { type Config, ConfigSchema, defaultBranchReader, type Effective, fetchPackLenses, ghFetch, ghFetchDir, loadPolicy, policyProblems, PRESETS, preview, type Read, TEAM_FILES } from "./config.ts";
34
+ import { ghThreads, snapshot, verdict } from "./acceptance.ts";
35
+ import { doctor } from "./doctor.ts";
36
+ import { collect, score } from "./garden.ts";
37
+ import { install, uninstall, type Outcome } from "./install.ts";
38
+ import { buildDemo, captureChange, demoBugs, finish, receiptStatus } from "./local.ts";
39
+ import { addLens, BUILT_IN_LENSES, lensTemplates, globalLensFile, lensProblems, newLens, opencodeAgents, pinModel, repoLensFile, resolveSkill, setLadder, skillDescription, type Lens } from "./lens.ts";
40
+
41
+ const BUNDLE_ROOT = path.resolve(import.meta.dir, "..");
42
+
43
+ const USAGE = `usage: shepherd install [--project | --target <dir>] [--link] [--force]
44
+ shepherd uninstall [--project | --target <dir>]
45
+ shepherd lens new <name> [--from <template>] [--applies <glob>]... [--description <text>]
46
+ shepherd lens add <skill> [--name <name>] [--applies <glob>]... [--description <text>] [--global]
47
+ shepherd lens list
48
+ shepherd models [ladder <model>... | pin <lens-or-runner> <model>] [--global]
49
+ shepherd opencode-agents [--file <lenses.yml>]
50
+ shepherd garden <owner/repo>... [--since YYYY-MM-DD] [--min-prs <n>]
51
+ shepherd local change [--base <ref>]
52
+ shepherd local finish <record.json> [--base <ref>]
53
+ shepherd local status [--base <ref>]
54
+ shepherd local demo [<dir>]
55
+ shepherd init [--preset review-only|local-fix|push] [--from <pack>]
56
+ shepherd config validate
57
+ shepherd config explain [--json] [--working-tree]
58
+ shepherd preview [--recipe <name>] [--base <ref>] [--working-tree]
59
+ shepherd doctor [--json]
60
+ shepherd acceptance <suggest|local> [--pr <n>] -- <agent command>...`;
61
+
62
+ const { values: opts, positionals } = parseArgs({
63
+ allowPositionals: true,
64
+ options: {
65
+ project: { type: "boolean", default: false },
66
+ target: { type: "string" },
67
+ link: { type: "boolean", default: false }, // symlink instead of copy: for a local clone you edit, never for bunx
68
+ force: { type: "boolean", default: false },
69
+ name: { type: "string" },
70
+ applies: { type: "string", multiple: true },
71
+ description: { type: "string" },
72
+ global: { type: "boolean", default: false },
73
+ model: { type: "string" },
74
+ since: { type: "string" },
75
+ file: { type: "string" }, // opencode-agents: a lenses.yml to read instead of the default branch's
76
+ "min-prs": { type: "string", default: "3" },
77
+ base: { type: "string" }, // local: the ref the change is measured from
78
+ from: { type: "string" }, // lens new: a starter lens in lenses/; init: the pack to extend
79
+ json: { type: "boolean", default: false },
80
+ "working-tree": { type: "boolean", default: false }, // explain, preview: your edits instead of the default branch
81
+ recipe: { type: "string" },
82
+ preset: { type: "string" }, // init: how far fixes go
83
+ pr: { type: "string" }, // acceptance: the PR whose threads must stay as they were
84
+ },
85
+ });
86
+
87
+ const base = opts.project ? path.join(process.cwd(), ".claude") : path.join(homedir(), ".claude");
88
+
89
+ const skillsDest = opts.target ? path.resolve(opts.target) : path.join(base, "skills");
90
+
91
+ const commandsDest = opts.target ? null : path.join(base, "commands");
92
+
93
+ const repoRoot = (() => {
94
+ try {
95
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
96
+ } catch {
97
+ return process.cwd();
98
+ }
99
+ })();
100
+
101
+ const report = (outcomes: Outcome[]) => {
102
+ for (const o of outcomes) console.log(`${o.action.padEnd(9)} ${o.name.padEnd(24)} ${o.dest}`);
103
+
104
+ return outcomes.some((o) => o.action === "skipped");
105
+ };
106
+
107
+ const [command, sub, skillRef, ...rest] = positionals;
108
+
109
+ /** Print a lens error as one line and exit 1. */
110
+ const orExit = <T>(run: () => T): T => {
111
+ try {
112
+ return run();
113
+ } catch (error) {
114
+ console.error(error instanceof Error ? error.message : String(error));
115
+ process.exit(1);
116
+ }
117
+ };
118
+
119
+ const MERGE_NOTE = "shepherd reads .shepherd/ from the default branch: commit and merge it before it takes effect.";
120
+
121
+ if (command === "install") {
122
+ if (report(install(BUNDLE_ROOT, skillsDest, commandsDest, opts.link, opts.force))) {
123
+ console.log("\nskipped: something this installer did not create is in the way; rerun with --force to replace it.");
124
+ }
125
+
126
+ console.log("\nNext: in a repository with an open PR, run /shepherd <pr> (or /swarm, /triage, /ci-repair on their own).\nBefore a PR: /shepherd --local. To try it: shepherd local demo.");
127
+ } else if (command === "uninstall") {
128
+ report(uninstall(BUNDLE_ROOT, skillsDest, commandsDest));
129
+ } else if (command === "lens" && sub === "add" && skillRef) {
130
+ const found = resolveSkill(skillRef, homedir(), repoRoot);
131
+
132
+ if (!found) {
133
+ console.error(`no SKILL.md found for "${skillRef}": install the skill, or pass a path to its directory`);
134
+ process.exit(1);
135
+ }
136
+
137
+ const name = opts.name ?? path.basename(found).toLowerCase();
138
+ const file = opts.global ? globalLensFile(homedir()) : repoLensFile(repoRoot);
139
+
140
+ const lens: Lens = { skill: skillRef };
141
+
142
+ if (opts.applies?.length) lens.applies_to = opts.applies;
143
+
144
+ if (opts.description) lens.description = opts.description;
145
+ const problems = lensProblems(lens, homedir(), repoRoot);
146
+
147
+ if (problems.length) {
148
+ for (const p of problems) console.error(p);
149
+ process.exit(1);
150
+ }
151
+
152
+ orExit(() => addLens(file, name, lens));
153
+
154
+ if (opts.model) pinModel(file, name, opts.model);
155
+ console.log(`lens "${name}" -> ${found}\nwritten to ${file}`);
156
+
157
+ console.log(opts.global ? "personal lens: swarm prints its findings locally and never posts them." : MERGE_NOTE);
158
+ } else if (command === "lens" && sub === "new" && skillRef) {
159
+ const lens: Omit<Lens, "skill"> = {};
160
+
161
+ if (opts.applies?.length) lens.applies_to = opts.applies;
162
+
163
+ if (opts.description) lens.description = opts.description;
164
+ const dir = orExit(() => newLens(repoRoot, skillRef, lens, opts.from));
165
+
166
+ if (opts.model) pinModel(repoLensFile(repoRoot), skillRef, opts.model);
167
+
168
+ console.log(`lens "${skillRef}" -> ${path.join(dir, "SKILL.md")}\n${opts.from ? `started from the ${opts.from} template: edit its rules to fit` : "fill in its Review and Fix sections"}, try it with /swarm --preview, then: ${MERGE_NOTE}`);
169
+ } else if (command === "lens" && sub === "list") {
170
+ for (const name of BUILT_IN_LENSES) console.log(`${name.padEnd(18)} built-in`);
171
+
172
+ console.log(`${"".padEnd(18)} starter templates (lens new <name> --from <template>): ${lensTemplates().join(", ")}`);
173
+
174
+ // The effective policy, packs included, as your working tree has it.
175
+ for (const [name, { value: lens, source, scope }] of Object.entries(policy(true).lenses)) {
176
+ const found = resolveSkill(lens.skill, homedir(), repoRoot);
177
+ const about = lens.description ?? (found ? skillDescription(found) : null) ?? "";
178
+
179
+ console.log(`${name.padEnd(18)} ${scope === "personal" ? "personal preview " : ""}${found ?? `MISSING ${lens.skill}`} [${lens.applies_to?.join(", ") ?? "router picks"}] (${source}) ${about.slice(0, 80)}`);
180
+
181
+ for (const p of lensProblems(lens, homedir(), repoRoot)) {
182
+ console.log(`${"".padEnd(18)} problem: ${p}`);
183
+ process.exitCode = 1;
184
+ }
185
+ }
186
+ } else if (command === "models" && (sub === undefined || (sub === "ladder" && skillRef) || (sub === "pin" && skillRef && rest.length === 1))) {
187
+ const file = opts.global ? globalLensFile(homedir()) : repoLensFile(repoRoot);
188
+
189
+ if (sub === "ladder") orExit(() => setLadder(file, [skillRef!, ...rest]));
190
+
191
+ if (sub === "pin") orExit(() => pinModel(file, skillRef!, rest[0]!));
192
+ const { ladder, models } = choice(policy(true));
193
+
194
+ console.log(`ladder ${ladder?.join(" -> ") ?? "the harness's own (skills/shepherd/references/models.md)"}`);
195
+
196
+ for (const [name, model] of Object.entries(models)) console.log(`pin ${name.padEnd(18)} ${model}`);
197
+
198
+ if (sub) console.log(`\nwritten to ${file}${opts.global ? "" : `\n${MERGE_NOTE}`}`);
199
+ } else if (command === "opencode-agents") {
200
+ // The models come from the default branch, never the working tree: on a PR checkout that is PR content.
201
+ // Packs count: a ladder an organisation pack sets is the ladder. With --file, its local packs still come
202
+ // from the default branch: the working tree of a PR checkout is the PR's to write.
203
+ const branch = orExit(() => defaultBranchReader(repoRoot));
204
+ const read: Read = opts.file ? ((text) => (rel: string) => (rel === TEAM_FILES[0] ? text : branch(rel)))(readFileSync(path.resolve(opts.file), "utf8")) : branch;
205
+
206
+ console.log(JSON.stringify(opencodeAgents(choice(orExit(() => loadPolicy(read, ghFetch, personal()))))));
207
+ } else if (command === "garden" && sub) {
208
+ // Default window: the last seven days, which the weekly gardener covers exactly.
209
+ const since = opts.since ?? new Date(Date.now() - 7 * 864e5).toISOString().slice(0, 10);
210
+ const repos = [sub, ...(skillRef ? [skillRef] : []), ...rest];
211
+
212
+ console.log(JSON.stringify(repos.map((repo) => ({ repo, since, ...score(orExit(() => collect(repo, since)), Number(opts["min-prs"])) })), null, 2));
213
+ } else if (command === "local" && sub === "change") {
214
+ console.log(JSON.stringify(orExit(() => captureChange(repoRoot, opts.base)), null, 2));
215
+ } else if (command === "local" && sub === "finish" && skillRef) {
216
+ const result = orExit(() => finish(repoRoot, readFileSync(skillRef, "utf8"), opts.base));
217
+
218
+ console.log(JSON.stringify(result, null, 2));
219
+
220
+ if (result.status !== "complete") process.exitCode = 2;
221
+ } else if (command === "local" && sub === "status") {
222
+ const result = orExit(() => receiptStatus(repoRoot, opts.base));
223
+
224
+ console.log(JSON.stringify(result, null, 2));
225
+
226
+ if (result.status !== "reviewed") process.exitCode = 1;
227
+ } else if (command === "local" && sub === "demo") {
228
+ const dir = skillRef ? path.resolve(skillRef) : mkdtempSync(path.join(tmpdir(), "shepherd-demo-"));
229
+ const app = orExit(() => buildDemo(dir));
230
+
231
+ console.log(`demo repository: ${app}\n\nplanted bugs:`);
232
+
233
+ for (const b of demoBugs()) console.log(` ${`${b.file}:${b.lines[0]}`.padEnd(18)} ${b.class} (${b.enters_as})`);
234
+
235
+ console.log(`\nNext: cd ${app}, open your coding agent there, and run /shepherd --local.`);
236
+ } else if (command === "init") {
237
+ const file = path.join(repoRoot, TEAM_FILES[0]!);
238
+
239
+ if (existsSync(file)) orExit(() => { throw new Error(`${TEAM_FILES[0]} already exists; edit it, then run shepherd config validate`); });
240
+ const legacy = workingTree(TEAM_FILES[1]!);
241
+ const seed = legacy ? orExit(() => ConfigSchema.parse(parseYaml(legacy) ?? {})) : { lenses: {} };
242
+ // A new repository starts review-only. A migration keeps pushing, as it did, and says so in the file; a pack keeps the pack's mode.
243
+ const preset = opts.preset ?? (opts.from || seed.fixes ? undefined : legacy ? "push" : "review-only");
244
+
245
+ const mode = Object.entries(PRESETS).find(([name]) => name === preset)?.[1];
246
+
247
+ if (preset && !mode) orExit(() => { throw new Error(`--preset is one of ${Object.keys(PRESETS).join(", ")}`); });
248
+ const config: Config = { ...seed };
249
+
250
+ if (opts.from) config.extends = [opts.from, ...(seed.extends ?? [])];
251
+
252
+ if (mode) config.fixes = { mode };
253
+ mkdirSync(path.dirname(file), { recursive: true });
254
+ writeFileSync(file, `# shepherd team policy. Presets: review-only (fixes.mode suggest), local-fix (local), push.\n${stringify(config)}`);
255
+ console.log(`wrote ${TEAM_FILES[0]}${preset ? ` (preset ${preset})` : ""}${legacy ? ` from ${TEAM_FILES[1]} (delete it: ${TEAM_FILES[0]} now wins)` : ""}`);
256
+ validate();
257
+ console.log(`\nBefore you merge it:\n shepherd config explain --working-tree every setting and where it came from\n shepherd preview --working-tree what would run on your change, and what it can write\n${MERGE_NOTE}`);
258
+ } else if (command === "config" && sub === "validate") {
259
+ validate();
260
+ } else if (command === "config" && sub === "explain") {
261
+ const p = policy(opts["working-tree"]);
262
+
263
+ // Each lens's skill directory on disk, for the skills: a pinned pack lens is read from its cached copy.
264
+ if (opts.json) console.log(JSON.stringify({ ...p, lenses: Object.fromEntries(Object.entries(p.lenses).map(([k, l]) => [k, { ...l, path: resolveSkill(l.value.skill, homedir(), repoRoot) }])) }, null, 2));
265
+ else explain(p);
266
+ } else if (command === "preview") {
267
+ const p = policy(opts["working-tree"]);
268
+ const change = orExit(() => captureChange(repoRoot, opts.base));
269
+ const plan = orExit(() => preview(p, change.files, opts.recipe));
270
+
271
+ console.log(`changed ${plan.files.length} file(s) against ${change.base_sha.slice(0, 7)}`);
272
+
273
+ for (const l of plan.lenses) console.log(`lens ${l.name.padEnd(18)} ${l.runs}${l.scope === "personal" ? " (personal preview, never posts)" : ""}`);
274
+
275
+ for (const m of plan.models) console.log(`model ${m.runner.padEnd(18)} ${m.model} (${m.source})`);
276
+
277
+ for (const c of plan.checks) console.log(`check ${c}`);
278
+
279
+ console.log(`fixes ${plan.fixes}`);
280
+
281
+ for (const f of plan.escalated) console.log(`escalate ${f}: a person decides any change here`);
282
+
283
+ console.log(`recipe ${plan.recipe}: ${plan.steps.join(" > ")}${opts.recipe ? "" : " (what /shepherd runs under this fix mode)"}`);
284
+
285
+ for (const w of plan.writes) console.log(`writes ${w.step.padEnd(18)} ${w.writes}`);
286
+
287
+ for (const u of plan.unavailable) console.log(`blocked ${u}`);
288
+
289
+ console.log(`completes ${plan.unavailable.length ? "cannot run under this policy" : plan.completes}`);
290
+ } else if (command === "doctor") {
291
+ const checks = doctor(BUNDLE_ROOT, homedir(), repoRoot, orExit(personal));
292
+
293
+ if (opts.json) console.log(JSON.stringify(checks, null, 2));
294
+ else for (const c of checks) console.log(`${c.status.padEnd(5)} ${c.check.padEnd(28)} ${c.detail}${c.fix ? `\n${"".padEnd(34)} -> ${c.fix}` : ""}`);
295
+
296
+ if (checks.some((c) => c.status === "fail")) process.exitCode = 1;
297
+ } else if (command === "acceptance" && (sub === "suggest" || sub === "local") && skillRef) {
298
+ const threads = opts.pr ? ghThreads(Number(opts.pr)) : () => [];
299
+ const before = orExit(() => snapshot(repoRoot, threads));
300
+ const run = spawnSync(skillRef, rest, { cwd: repoRoot, stdio: "inherit" });
301
+ const problems = orExit(() => verdict(repoRoot, sub, before, snapshot(repoRoot, threads, before.targets)));
302
+
303
+ // A run that never finished proves nothing: an unchanged tree is what a missing binary leaves too.
304
+ const crashed = run.error ? `the agent did not start: ${run.error.message}` : run.status !== 0 ? `the agent ${run.signal ? `was killed by ${run.signal}` : `exited ${run.status}`}` : null;
305
+
306
+ for (const m of problems) console.log(`fail: ${m}`);
307
+
308
+ if (crashed) console.log(`error: ${crashed}`);
309
+ console.log(problems.length ? `failed fixes.mode ${sub}` : crashed ? "inconclusive: the agent did not finish" : `ok: the run kept to fixes.mode ${sub}`);
310
+ process.exitCode = problems.length ? 1 : crashed ? 2 : 0;
311
+ } else {
312
+ console.error(USAGE);
313
+ process.exit(2);
314
+ }
315
+
316
+ function workingTree(rel: string): string | null {
317
+ const file = path.join(repoRoot, rel);
318
+
319
+ return existsSync(file) ? readFileSync(file, "utf8") : null;
320
+ }
321
+
322
+ function personal(): Config | null {
323
+ const file = globalLensFile(homedir());
324
+
325
+ return existsSync(file) ? ConfigSchema.parse(parseYaml(readFileSync(file, "utf8")) ?? {}) : null;
326
+ }
327
+
328
+ /** Team policy from the default branch, the source swarm reviews under, or from your working tree. */
329
+ function policy(fromTree: boolean) {
330
+ const read: Read = fromTree ? workingTree : orExit(() => defaultBranchReader(repoRoot));
331
+
332
+ const p = orExit(() => loadPolicy(read, ghFetch, personal()));
333
+
334
+ orExit(() => fetchPackLenses(p, homedir(), ghFetchDir));
335
+
336
+ return p;
337
+ }
338
+
339
+ function choice(p: Effective) {
340
+ return { ladder: p.ladder?.value ?? null, models: Object.fromEntries(Object.entries(p.models).map(([k, v]) => [k, v.value])) };
341
+ }
342
+
343
+ /** The working tree's policy, as you are about to merge it: every problem on one line, exit 1 if any. */
344
+ function validate(): void {
345
+ const p = policy(true);
346
+
347
+ const problems = [
348
+ ...policyProblems(p),
349
+ ...Object.entries(p.lenses).flatMap(([name, l]) => lensProblems(l.value, homedir(), repoRoot).map((m) => `lens ${name}: ${m}`)),
350
+ ...(workingTree(TEAM_FILES[0]!) && workingTree(TEAM_FILES[1]!) ? [`${TEAM_FILES[1]} is ignored while ${TEAM_FILES[0]} exists; move its settings over and delete it`] : []),
351
+ ];
352
+
353
+ for (const m of [...problems, ...p.ignored]) console.log(`problem: ${m}`);
354
+
355
+ if (problems.length) process.exitCode = 1;
356
+ else console.log(`ok: ${p.teamFile ?? "no team policy file, built-in defaults"}`);
357
+ }
358
+
359
+ function explain(p: Effective & { teamFile: string | null }): void {
360
+ const row = (key: string, value: string, source: string) => console.log(`${key.padEnd(24)} ${value.padEnd(48)} ${source}`);
361
+
362
+ console.log(`policy file: ${p.teamFile ?? "none (built-in defaults)"}${opts["working-tree"] ? ", working tree" : ", default branch"}\n`);
363
+ row("fixes.mode", p.fixes.value.mode, p.fixes.source);
364
+ row("ladder", p.ladder?.value.join(" -> ") ?? "the harness's own", p.ladder?.source ?? "built-in");
365
+
366
+ for (const [k, v] of Object.entries(p.models)) row(`models.${k}`, v.value, v.source);
367
+
368
+ for (const [k, v] of Object.entries(p.lenses)) row(`lenses.${k}`, `${v.value.skill} [${v.value.applies_to?.join(", ") ?? "router picks"}]`, v.scope === "personal" ? "personal (preview, never posts)" : v.source);
369
+
370
+ for (const [k, v] of Object.entries(p.checks)) row(`checks.${k}`, v.value.command, v.source);
371
+
372
+ if (!Object.keys(p.checks).length) row("checks", "discovered by swarm from the repo's config", "built-in");
373
+
374
+ if (p.escalate) row("escalate.paths", p.escalate.value.paths.join(", "), p.escalate.source);
375
+
376
+ for (const [k, v] of Object.entries(p.recipes)) row(`recipes.${k}`, v.value.steps.join(" > "), v.source);
377
+
378
+ for (const m of p.ignored) console.log(`\nignored: ${m}`);
379
+ }