@jagreehal/shepherd 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jagreehal/shepherd",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent skills that drive a PR to merge-ready: a review swarm, thread triage, CI repair, and a loop that hands the result to stamp.",
5
5
  "license": "MIT",
6
6
  "repository": "github:jagreehal/shepherd",
@@ -5,7 +5,8 @@ description: >
5
5
  GitHub record (reverted fixes, people replying in a thread, round caps, loops
6
6
  that stopped short or went quiet), then opens PRs: skill edits to shepherd for
7
7
  the patterns behind low scores, and lint rules for lens findings triage keeps
8
- fixing, so lint catches them before any model reads the diff. Use for "/garden", "garden shepherd", "what should shepherd learn", or
8
+ fixing, so lint catches them before any model reads the diff, and a rule for
9
+ each incident whose culprit PR stamp approved. Use for "/garden", "garden shepherd", "what should shepherd learn", or
9
10
  on a weekly schedule. Takes repos (owner/name) and an optional --since date.
10
11
  ---
11
12
 
@@ -41,7 +42,8 @@ person replied in, rounds, and `shepherd` status. `recurring` lists the lens
41
42
  rules **accepted** on at least 3 PRs: triage fixed them in a commit nobody
42
43
  reverted, named them in a `Shepherd-Fixes` trailer, on a PR by someone who can
43
44
  push, and no person argued in their threads. Triage resolves findings it judged
44
- wrong too, so a resolved thread alone counts for nothing.
45
+ wrong too, so a resolved thread alone counts for nothing. `escapes` lists the
46
+ incidents whose culprit PR stamp approved (Step 5b).
45
47
 
46
48
  | Signal | Means |
47
49
  |---|---|
@@ -69,7 +71,7 @@ the thread the author was left with was one triage should have fixed.
69
71
  ## Step 3: Score the window
70
72
 
71
73
  One table per repo: PRs shepherd touched, PRs with each signal, and the share
72
- of PRs with no signal. Read the previous garden PR's body
74
+ of PRs with no signal, plus the escapes count. Read the previous garden PR's body
73
75
  (`gh pr list -R jagreehal/shepherd --search "garden:" --state all --limit 1`)
74
76
  and give the change since then, so a merged edit shows whether it helped.
75
77
 
@@ -116,16 +118,39 @@ Open one PR per repo titled `garden: lint rules <date>`, with the trailer
116
118
  id it retires. Leave the lens rule in place: a person retires it once the
117
119
  lint rule has held.
118
120
 
121
+ ## Step 5b: A rule for each escaped regression
122
+
123
+ Each entry in `escapes` is an incident the `incident` skill traced to a PR
124
+ stamp approved. Read the incident issue's root cause, the culprit PR's diff
125
+ and stamp's review on it, and the fix PR with its reproducing test.
126
+
127
+ - **`cause: merge_skew`:** the PR passed on its own head, so no review rule
128
+ would have caught it. Put "require up-to-date branches, or a merge queue" in
129
+ the report and propose nothing.
130
+ - **Otherwise:** write the smallest rule that flags the culprit diff and passes
131
+ the fix. A rule one file's syntax decides goes through Step 5 as a lint rule.
132
+ Any other rule joins the repository's own stamp rule pack (`.stamp/rules/`)
133
+ or a team lens in `.shepherd/`, whichever already covers those paths; add to
134
+ an existing file before creating one. The rule names what to check, never
135
+ the incident ("a function that now dereferences an argument its callers can
136
+ pass as null needs a test with that input").
137
+
138
+ Rules only tighten. Open one PR per repository titled `garden: escapes <date>`,
139
+ with the trailer `Shepherd: garden`, and give each rule its incident, culprit
140
+ PR, fix PR and the line in the culprit diff it flags. stamp sends a PR that
141
+ edits `.stamp/` or `.shepherd/` to a person, which is the review a new rule
142
+ needs.
143
+
119
144
  ## Outbox mode
120
145
 
121
146
  When `GARDEN_OUTBOX` is set (the scheduled workflow), you hold no write
122
- token. Steps 4 and 5 make the same edits but never commit, push, or open a PR.
147
+ token. Steps 4, 5 and 5b make the same edits but never commit, push, or open a PR.
123
148
  For each PR you would open, make one directory:
124
149
 
125
150
  ```text
126
151
  $GARDEN_OUTBOX/<short-slug>/
127
152
  repo one line: owner/name the PR targets
128
- body.md the PR body, exactly as Steps 4 and 5 describe it
153
+ body.md the PR body, exactly as Steps 4, 5 and 5b describe it
129
154
  tree/ a fresh `gh repo clone` of that repo with your edits, uncommitted
130
155
  ```
131
156
 
@@ -135,7 +160,7 @@ The workflow turns each directory into a patch and opens the PR itself.
135
160
 
136
161
  ## Step 6: Report
137
162
 
138
- End with the score tables, links to the PRs opened, the causes with no
163
+ End with the score tables, the escapes and the rule proposed for each, links to the PRs opened, the causes with no
139
164
  proposal and why, and the judgment rules left to the lenses. Scheduled runs
140
165
  print it to the job log; the PR bodies carry everything a reviewer needs.
141
166
 
@@ -76,7 +76,9 @@ gh issue list --label incident --state open --search "shepherd-incident:<fingerp
76
76
 
77
77
  Hash the fingerprint to 12 hex chars: write it to a file with your file tool,
78
78
  then `shasum <file> | cut -c1-12`. An open issue with that marker already
79
- tracks it, and it is your issue.
79
+ tracks it, and it is your issue. Only a search that exits 0 says there is
80
+ none: when it fails, fix the cause and run it again before you open anything,
81
+ since a second issue splits the record.
80
82
 
81
83
  Otherwise create the label once and open the issue:
82
84
 
@@ -172,23 +174,30 @@ failing commit, and a later run can sit on a descendant).
172
174
  git bisect start <live commit> <good> && git bisect run sh -c '<probe body>'; git bisect reset
173
175
  ```
174
176
 
177
+ Name the culprit only from the probe: the commit `git bisect run` reports,
178
+ or the one commit of a single-commit range after `probe` fails on it and
179
+ passes on its parent. A culprit read off a diff or the signal is a guess; run
180
+ the bisection even when the diff looks obvious.
181
+
175
182
  When the range is a single commit, that commit is the culprit; no bisection
176
183
  is needed. Skip bisection when no good end exists within 64 commits or the test cannot
177
184
  run on older commits; say so. For the culprit commit, find its PR:
178
185
  `gh api repos/{owner}/{repo}/commits/<sha>/pulls --jq '.[0].number'`, and
179
- whether stamp approved it (a review carrying `<!-- stamp-run:`). Add
186
+ whether stamp approved it: `gh api repos/{owner}/{repo}/pulls/<pr>/reviews --jq '[.[] | select(.body | contains("<!-- stamp-run:"))] | last | .state'`
187
+ gives `APPROVED` for approved, empty for not reviewed, and anything else for
188
+ held for a human. Add this line to the issue body, exactly, with one of the
189
+ three values (garden parses it):
180
190
  `Regressed-by: #<pr> (<sha>), stamp: <approved|not reviewed|held for a human>`
181
- to the issue body.
182
191
 
183
192
  Then tell merge skew from a coverage gap, with the probe, not from CI's
184
193
  colour. Run `probe` on the PR's last head commit (`gh pr view <pr> --json
185
194
  headRefOid`):
186
195
 
187
196
  - **Passes on the PR head, fails on the merged culprit:** the base moved under
188
- it. Add `Merge skew: passes on <head sha>, fails merged`; the cure is an
197
+ it. Add the line `Merge skew: passes on <head sha>, fails merged`; the cure is an
189
198
  up-to-date-branch rule or a merge queue, not a stricter review.
190
199
  - **Fails on the PR head too:** the PR shipped the failure and its tests did
191
- not cover it. Add `Coverage gap: fails on <head sha>`.
200
+ not cover it. Add the line `Coverage gap: fails on <head sha>`.
192
201
  - **Head commit gone or probe cannot run there:** record neither.
193
202
 
194
203
  Then return, popping only this run's entry, never the developer's:
package/src/cli.ts CHANGED
@@ -33,7 +33,7 @@ import { parse as parseYaml, stringify } from "yaml";
33
33
  import { type Config, ConfigSchema, defaultBranchReader, type Effective, fetchPackLenses, ghFetch, ghFetchDir, loadPolicy, policyProblems, PRESETS, preview, type Read, TEAM_FILES } from "./config.ts";
34
34
  import { ghThreads, snapshot, verdict } from "./acceptance.ts";
35
35
  import { doctor } from "./doctor.ts";
36
- import { collect, score } from "./garden.ts";
36
+ import { collect, collectEscapes, score } from "./garden.ts";
37
37
  import { install, uninstall, type Outcome } from "./install.ts";
38
38
  import { buildDemo, captureChange, demoBugs, finish, receiptStatus } from "./local.ts";
39
39
  import { addLens, BUILT_IN_LENSES, lensTemplates, globalLensFile, lensProblems, newLens, opencodeAgents, pinModel, repoLensFile, resolveSkill, setLadder, skillDescription, type Lens } from "./lens.ts";
@@ -209,7 +209,7 @@ if (command === "install") {
209
209
  const since = opts.since ?? new Date(Date.now() - 7 * 864e5).toISOString().slice(0, 10);
210
210
  const repos = [sub, ...(skillRef ? [skillRef] : []), ...rest];
211
211
 
212
- console.log(JSON.stringify(repos.map((repo) => ({ repo, since, ...score(orExit(() => collect(repo, since)), Number(opts["min-prs"])) })), null, 2));
212
+ console.log(JSON.stringify(repos.map((repo) => ({ repo, since, ...score(orExit(() => collect(repo, since)), Number(opts["min-prs"])), escapes: orExit(() => collectEscapes(repo, since)) })), null, 2));
213
213
  } else if (command === "local" && sub === "change") {
214
214
  console.log(JSON.stringify(orExit(() => captureChange(repoRoot, opts.base)), null, 2));
215
215
  } else if (command === "local" && sub === "finish" && skillRef) {
package/src/garden.ts CHANGED
@@ -128,6 +128,22 @@ export function parseCommit(sha: string, message: string, date = ""): Commit {
128
128
  return { sha, headline: message.split("\n")[0] ?? "", skill: trailer("Shepherd"), lens: trailer("Shepherd-Lens"), fixes, date };
129
129
  }
130
130
 
131
+ /** An incident whose culprit PR stamp approved: the review let a regression through. */
132
+ export type Escape = { incident: number; title: string; url: string; pr: number; sha: string; cause: "coverage_gap" | "merge_skew" | null };
133
+
134
+ const REGRESSED = /Regressed-by: #(\d+) \(([0-9a-f]{7,40})\), stamp: approved\b/;
135
+
136
+ /** The escape an incident issue records, from the lines the incident skill writes; null unless stamp approved the culprit. */
137
+ export function parseEscape(issue: { number: number; title: string; url: string; body: string }): Escape | null {
138
+ if (!issue.body.includes("<!-- shepherd-incident:")) return null;
139
+ const m = REGRESSED.exec(issue.body);
140
+
141
+ if (!m) return null;
142
+ const cause = /^`?Coverage gap: /m.test(issue.body) ? "coverage_gap" : /^`?Merge skew: /m.test(issue.body) ? "merge_skew" : null;
143
+
144
+ return { incident: issue.number, title: issue.title, url: issue.url, pr: Number(m[1]), sha: m[2]!, cause };
145
+ }
146
+
131
147
  export const roundsOf = (summaryBody: string | undefined) => (summaryBody ? Number(ROUND.exec(summaryBody)?.[1] ?? 0) || null : null);
132
148
 
133
149
  const gh = (args: string[]) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "pipe"] }); // stderr rides on the thrown error
@@ -160,6 +176,15 @@ const Statuses = z.object({ statuses: z.array(z.object({ context: z.string(), st
160
176
 
161
177
  const Comments = z.array(z.object({ body: z.string() }));
162
178
 
179
+ const Issues = z.array(z.object({ number: z.number(), title: z.string(), url: z.string(), body: z.string() }));
180
+
181
+ /** Incidents updated since `since` whose culprit PR stamp approved. */
182
+ export function collectEscapes(repo: string, since: string): Escape[] {
183
+ const issues = Issues.parse(JSON.parse(gh(["issue", "list", "-R", repo, "--label", "incident", "--state", "all", "--search", `updated:>=${since}`, "--limit", "100", "--json", "number,title,url,body"])));
184
+
185
+ return issues.flatMap((i) => parseEscape(i) ?? []);
186
+ }
187
+
163
188
  /** Read shepherd's record on the PRs updated since `since` (YYYY-MM-DD). */
164
189
  export function collect(repo: string, since: string): PrRecord[] {
165
190
  const [owner, name] = repo.split("/");