@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 +1 -1
- package/skills/garden/SKILL.md +31 -6
- package/skills/incident/SKILL.md +14 -5
- package/src/cli.ts +2 -2
- package/src/garden.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jagreehal/shepherd",
|
|
3
|
-
"version": "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",
|
package/skills/garden/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
package/skills/incident/SKILL.md
CHANGED
|
@@ -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 (
|
|
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("/");
|