@rtorcato/repo-tooling 3.19.2 → 3.21.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/README.md CHANGED
@@ -234,8 +234,8 @@ MIT — see [LICENSE](LICENSE).
234
234
  Any agent that supports the [`skills`](https://www.npmjs.com/package/skills) CLI can install this repo's skills straight from GitHub — no clone, no package install:
235
235
 
236
236
  ```bash
237
- npx skills add https://github.com/rtorcato/repo-tooling --skill ai-issue-loop
238
- npx skills add https://github.com/rtorcato/repo-tooling --skill npm-publish
239
- npx skills add https://github.com/rtorcato/repo-tooling --skill repo-tooling
237
+ npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-issue-loop'
238
+ npx skills add https://github.com/rtorcato/repo-tooling --skill 'npm-publish'
239
+ npx skills add https://github.com/rtorcato/repo-tooling --skill 'repo-tooling'
240
240
  ```
241
241
  <!-- js-tooling:skills:end -->
@@ -26,7 +26,7 @@ import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
26
26
  import { copyPreset } from '../cli/utils/copy-preset.js';
27
27
  import { detectLanguage } from '../cli/utils/detect-language.js';
28
28
  import { resolveLanguageModule } from '../languages/registry.js';
29
- import { applyGithubSettings } from './github-settings.js';
29
+ import { applyGithubSettings, applyReleaseEnvironment, RELEASE_ENV_CHECK, RELEASE_GATE_CHECK, } from './github-settings.js';
30
30
  import { applyLoopLabels } from './labels.js';
31
31
  import { closeCompletedMilestones } from './milestones.js';
32
32
  /**
@@ -210,6 +210,26 @@ export const BASE_FIXERS = [
210
210
  return { filesWritten: await applyGithubSettings(targetDir) };
211
211
  },
212
212
  },
213
+ {
214
+ target: 'release-environment',
215
+ description: 'Create the `release` environment (authenticated user as required reviewer) and wire `environment: release` into the publishing job — the gate between merging and publishing (#429)',
216
+ appliesTo: [RELEASE_GATE_CHECK, RELEASE_ENV_CHECK],
217
+ outputs: [
218
+ 'GitHub `release` environment (remote, via gh api)',
219
+ '.github/workflows/<the publishing workflow>',
220
+ ],
221
+ // safe-add for the same shadow-run reason as github-settings, and
222
+ // explicitOnly because arming the gate changes what a merge *does*: the
223
+ // next release run sits `waiting` for an approval instead of publishing.
224
+ // That is the point, but it must be a chosen rollout per repo, not a side
225
+ // effect of a bare `fix`.
226
+ riskLevel: 'safe-add',
227
+ explicitOnly: true,
228
+ canFixDrift: true,
229
+ async run({ targetDir }) {
230
+ return { filesWritten: await applyReleaseEnvironment(targetDir) };
231
+ },
232
+ },
213
233
  {
214
234
  target: 'milestones',
215
235
  description: 'Close 100%-complete open milestones on GitHub via gh api (mutates the remote repo, not files). Never deletes or creates one',
@@ -51,8 +51,8 @@ export const GITHUB_STANDARD = {
51
51
  requiredContexts: ['lint', 'typecheck', 'build', 'test'],
52
52
  };
53
53
  const CODE_SCANNING_CHECK = 'Code-scanning gate';
54
- const RELEASE_GATE_CHECK = 'Release gate';
55
- const RELEASE_ENV_CHECK = 'Release environment';
54
+ export const RELEASE_GATE_CHECK = 'Release gate';
55
+ export const RELEASE_ENV_CHECK = 'Release environment';
56
56
  const CHECK_NAMES = [
57
57
  'Branch protection',
58
58
  'Merge settings',
@@ -438,7 +438,7 @@ const RELEASE_ENVIRONMENT = 'release';
438
438
  * `semantic-release` counts on its own — the shipped preset publishes with it.
439
439
  */
440
440
  const PUBLISH_COMMAND = /semantic-release|changesets\/action|(?:npm|pnpm|yarn)\s+publish/;
441
- const GATE_HINT = 'Create a `release` environment with required reviewers (Settings → Environments) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
441
+ const GATE_HINT = 'Run `npx @rtorcato/repo-tooling fix release-environment` to create the `release` environment with required reviewers (you) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
442
442
  const ENV_HINT = 'Add `environment: release` to the publishing job, or delete the environment — whichever was meant. An environment nothing references still lists under Settings → Environments as though it gates something';
443
443
  const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
444
444
  /**
@@ -604,9 +604,9 @@ async function readEnvironments(gh, nwo) {
604
604
  * environment" is the gate check's; "no `environment:` but a `release`
605
605
  * environment exists" is the environment check's, and the gate check defers.
606
606
  *
607
- * A repo that publishes nothing is `ok` — not applicable, not drift. There is
608
- * no fixer: creating the environment needs a `required_reviewers` list only a
609
- * human can supply, so both hints describe the manual step.
607
+ * A repo that publishes nothing is `ok` — not applicable, not drift. The fixer
608
+ * is `fix release-environment` (`applyReleaseEnvironment` below), which uses
609
+ * the authenticated user as the required reviewer.
610
610
  */
611
611
  async function checkReleaseGate(gh, nwo, dir) {
612
612
  const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
@@ -836,3 +836,129 @@ export async function applyGithubSettings(dir, exec) {
836
836
  console.error(chalk.gray(' already configured — nothing to apply'));
837
837
  return applied;
838
838
  }
839
+ // --- Release environment scaffolding (#429) --------------------------------
840
+ /**
841
+ * Insert `environment: <env>` as the first key of job `jobId`. Line-based, same
842
+ * reasoning as `workflowJobs`: this package ships no YAML dependency, and jobs
843
+ * sit one indent level under `jobs:` with their keys one level below. Returns
844
+ * null when the job cannot be found. The caller only reaches this when
845
+ * `jobEnvironment()` was null, so it never doubles an existing key.
846
+ */
847
+ export function addJobEnvironment(yaml, jobId, env) {
848
+ const lines = yaml.split('\n');
849
+ const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
850
+ if (start === -1)
851
+ return null;
852
+ const indentOf = (l) => l.length - l.trimStart().length;
853
+ // The job headers' indent — from the first real line after `jobs:`, exactly
854
+ // as workflowJobs derives it, so the two agree on what counts as a header.
855
+ let jobIndent = -1;
856
+ for (let i = start + 1; i < lines.length; i++) {
857
+ const line = lines[i] ?? '';
858
+ if (line.trim() === '' || line.trimStart().startsWith('#'))
859
+ continue;
860
+ if (indentOf(line) === 0)
861
+ return null;
862
+ jobIndent = indentOf(line);
863
+ break;
864
+ }
865
+ if (jobIndent === -1)
866
+ return null;
867
+ for (let i = start + 1; i < lines.length; i++) {
868
+ const line = lines[i] ?? '';
869
+ if (line.trim() === '' || line.trimStart().startsWith('#'))
870
+ continue;
871
+ if (indentOf(line) === 0)
872
+ return null; // left the jobs block
873
+ if (indentOf(line) !== jobIndent || !line.trim().startsWith(`${jobId}:`))
874
+ continue;
875
+ // Key indent = the job's first real line, so the insert matches whatever
876
+ // indentation the file already uses.
877
+ for (let j = i + 1; j < lines.length; j++) {
878
+ const next = lines[j] ?? '';
879
+ if (next.trim() === '' || next.trimStart().startsWith('#'))
880
+ continue;
881
+ lines.splice(j, 0, `${' '.repeat(indentOf(next))}environment: ${env}`);
882
+ return lines.join('\n');
883
+ }
884
+ return null;
885
+ }
886
+ return null;
887
+ }
888
+ /**
889
+ * Scaffold the release environment gate — the fixer half of #429 (the checks
890
+ * shipped with #449). Two writes, each skipped when already in place:
891
+ *
892
+ * 1. `PUT /repos/{nwo}/environments/release` with the **authenticated user** as
893
+ * the required reviewer. On a solo-maintained repo they are the only human
894
+ * there is; add or swap reviewers afterwards under Settings → Environments.
895
+ * An environment that already carries a `required_reviewers` rule is never
896
+ * touched — whoever set it up made a richer decision than this default.
897
+ * 2. `environment: release` on the publishing job, so the merge leaves the run
898
+ * `waiting` instead of publishing.
899
+ *
900
+ * A job already behind some *other* environment is a warning, not a rename —
901
+ * this fixer scaffolds the standard, it does not migrate a custom setup.
902
+ */
903
+ export async function applyReleaseEnvironment(dir, exec) {
904
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
905
+ console.error(chalk.gray(' skipped — not a git repository'));
906
+ return [];
907
+ }
908
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
909
+ const probe = await probeRepo(gh);
910
+ if ('skip' in probe) {
911
+ console.error(chalk.gray(` skipped — ${probe.skip}`));
912
+ return [];
913
+ }
914
+ const nwo = probe.info.nwo;
915
+ const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
916
+ if (publish === 'skip') {
917
+ console.error(chalk.yellow(' skipped — could not read .github/workflows'));
918
+ return [];
919
+ }
920
+ if (!publish) {
921
+ console.error(chalk.gray(' skipped — no workflow job publishes to a registry'));
922
+ return [];
923
+ }
924
+ if (publish.environment !== null && publish.environment !== RELEASE_ENVIRONMENT) {
925
+ console.error(chalk.yellow(` skipped — ${publish.file} \`${publish.job}\` already runs behind \`${publish.environment}\`; not renaming it`));
926
+ return [];
927
+ }
928
+ const envs = await readEnvironments(gh, nwo);
929
+ if (envs === 'skip') {
930
+ console.error(chalk.yellow(' skipped — could not read environments'));
931
+ return [];
932
+ }
933
+ const applied = [];
934
+ if (envs.get(RELEASE_ENVIRONMENT) !== true) {
935
+ const user = await gh(['api', 'user', '--jq', '.id']);
936
+ const id = user.ok ? Number.parseInt(user.stdout.trim(), 10) : Number.NaN;
937
+ if (Number.isNaN(id)) {
938
+ console.error(chalk.yellow(` could not resolve the authenticated user for required_reviewers: ${user.stderr.trim() || 'gh error'}`));
939
+ return applied;
940
+ }
941
+ const label = `\`${RELEASE_ENVIRONMENT}\` environment with the authenticated user as required reviewer`;
942
+ const r = await gh(['api', '-X', 'PUT', `repos/${nwo}/environments/${RELEASE_ENVIRONMENT}`, '--input', '-'], JSON.stringify({ reviewers: [{ type: 'User', id }] }));
943
+ if (r.ok)
944
+ applied.push(label);
945
+ else {
946
+ console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
947
+ return applied;
948
+ }
949
+ }
950
+ if (publish.environment === null) {
951
+ const file = path.join(dir, '.github', 'workflows', publish.file);
952
+ const updated = addJobEnvironment(await fs.readFile(file, 'utf-8'), publish.job, RELEASE_ENVIRONMENT);
953
+ if (updated === null) {
954
+ console.error(chalk.yellow(` could not add \`environment:\` to ${publish.file} \`${publish.job}\` — add it by hand`));
955
+ }
956
+ else {
957
+ await fs.writeFile(file, updated);
958
+ applied.push(`environment: ${RELEASE_ENVIRONMENT} on ${publish.file} \`${publish.job}\``);
959
+ }
960
+ }
961
+ if (applied.length === 0)
962
+ console.error(chalk.gray(' already configured — nothing to apply'));
963
+ return applied;
964
+ }
@@ -9,6 +9,7 @@ import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import fs from 'fs-extra';
11
11
  import { getPackageRoot } from '../utils/copy-preset.js';
12
+ import { shellQuote } from '../utils/shell.js';
12
13
  /** Skills this package owns the content of and keeps up to date. */
13
14
  export const SHIPPED_SKILL = 'ai-issue-loop';
14
15
  /**
@@ -129,15 +130,6 @@ export async function readShippedSkill(name = SHIPPED_SKILL) {
129
130
  const pkg = await fs.readJson(path.join(root, 'package.json'));
130
131
  return { content, version: String(pkg.version), file };
131
132
  }
132
- /**
133
- * POSIX single-quote escaping — the only form correct for arbitrary bytes.
134
- * Inside single quotes every character is literal, so `'` is the sole one
135
- * needing care: end the quote, escape it, start a new one. Double quotes are
136
- * not a substitute; `$(...)`, backticks and `\` all still expand inside them.
137
- */
138
- function shellQuote(value) {
139
- return `'${value.replaceAll("'", "'\\''")}'`;
140
- }
141
133
  /**
142
134
  * The command a human runs to see what their fork changed, before deciding
143
135
  * whether to take the shipped copy. One builder, because `doctor` and `fix`
@@ -3,6 +3,7 @@
3
3
  // repo (its skills dir + package.json `repository`) so it works for any consumer.
4
4
  import path from 'node:path';
5
5
  import fs from 'fs-extra';
6
+ import { shellQuote } from '../utils/shell.js';
6
7
  import { upsertBlock } from './agent-rules.js';
7
8
  import { parseRepository } from './badges.js';
8
9
  export const SKILLS_DOCS_START = '<!-- js-tooling:skills:start -->';
@@ -25,12 +26,18 @@ export async function findSkills(targetDir) {
25
26
  /**
26
27
  * Build the README section body (inner content, no delimiters) listing one
27
28
  * `npx skills add` command per skill. Returns '' when there are no skills.
29
+ *
30
+ * The skill name is a directory basename — `findSkills` takes whatever is under
31
+ * `skills/`, and nothing constrains it — so it is quoted (#498). `owner`/`repo`
32
+ * are not: `parseRepository` already matches them against `[\w.-]+`. Unlike the
33
+ * doctor hint of #493 this line is committed to a README and rendered on GitHub
34
+ * and npm, where it gets pasted by people who did not create the directory.
28
35
  */
29
36
  export function buildSkillsInstallBody(owner, repo, skills) {
30
37
  if (skills.length === 0)
31
38
  return '';
32
39
  const commands = skills
33
- .map((s) => `npx skills add https://github.com/${owner}/${repo} --skill ${s}`)
40
+ .map((s) => `npx skills add https://github.com/${owner}/${repo} --skill ${shellQuote(s)}`)
34
41
  .join('\n');
35
42
  const plural = skills.length > 1 ? 's' : '';
36
43
  return [
@@ -38,11 +45,21 @@ export function buildSkillsInstallBody(owner, repo, skills) {
38
45
  '',
39
46
  `Any agent that supports the [\`skills\`](https://www.npmjs.com/package/skills) CLI can install this repo's skill${plural} straight from GitHub — no clone, no package install:`,
40
47
  '',
41
- '```bash',
48
+ `${fenceFor(commands)}bash`,
42
49
  commands,
43
- '```',
50
+ fenceFor(commands),
44
51
  ].join('\n');
45
52
  }
53
+ /**
54
+ * A fence longer than any backtick run in the content, per CommonMark. Quoting
55
+ * cannot help here: a name may contain backticks *and* a newline, which puts
56
+ * arbitrary text at the start of a line inside the block and would otherwise
57
+ * close it early, mangling the rendered README.
58
+ */
59
+ function fenceFor(content) {
60
+ const longestRun = Math.max(0, ...[...content.matchAll(/`+/g)].map((m) => m[0].length));
61
+ return '`'.repeat(Math.max(3, longestRun + 1));
62
+ }
46
63
  /**
47
64
  * Add or refresh a "Install the skill" block in README.md. No-op (returns null)
48
65
  * when the repo ships no skills or package.json has no GitHub `repository` to
@@ -0,0 +1,13 @@
1
+ /**
2
+ * POSIX single-quote escaping — the only form correct for arbitrary bytes.
3
+ * Inside single quotes every character is literal, so `'` is the sole one
4
+ * needing care: end the quote, escape it, start a new one. Double quotes are
5
+ * not a substitute; `$(...)`, backticks and `\` all still expand inside them.
6
+ *
7
+ * One escaper for every generator that emits a copy-pasteable command line
8
+ * (#498) — two independently-built ones drift, which is the argument #492 made
9
+ * for a single `skillDiffCommand`.
10
+ */
11
+ export function shellQuote(value) {
12
+ return `'${value.replaceAll("'", "'\\''")}'`;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.19.2",
3
+ "version": "3.21.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -200,6 +200,44 @@ pass.** A session can be pinned to a worktree, so the orchestrator can find itse
200
200
  inside one it did not choose. `--git-common-dir` resolves to the main checkout's
201
201
  `.git` from anywhere, including a worktree, so `ROOT` is correct either way.
202
202
 
203
+ **Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
204
+ `core.bare = true` on its own, repeatedly — four times in one session, some occurrences
205
+ immediately after a `worktree remove` and some with nothing removed at all. The trigger
206
+ is unidentified, so this is detection and repair only:
207
+
208
+ ```bash
209
+ if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
210
+ echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
211
+ git -C "$ROOT" config core.bare false || {
212
+ echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
213
+ fi
214
+ ```
215
+
216
+ **It corrupts commits — this is not a cosmetic error message.** A bare main checkout
217
+ wipes a worktree's index while every file sits untouched on disk, and the next commit
218
+ faithfully records the whole repository as deleted. PR #500 died that way: a diff of
219
+ `0 additions, 67703 deletions` across 359 files, not one of which had moved.
220
+
221
+ **Test stdout, not the exit code.** `rev-parse --is-inside-work-tree` exits `0` either
222
+ way and only *prints* the answer, so an exit-code probe is dead code. Verified on git
223
+ 2.55.0:
224
+
225
+ | repo state | `--is-inside-work-tree` | `.git` |
226
+ |---|---|---|
227
+ | healthy checkout | `true`, exit 0 | directory |
228
+ | **wrongly bare** | `false`, **exit 0** | directory |
229
+ | genuinely bare | `false`, exit 0 | absent |
230
+ | linked worktree | `true`, exit 0 | file |
231
+
232
+ **`.git` must be a directory before repairing.** A genuinely bare repo prints `false`
233
+ too, and nothing else separates the two — this skill ships to users' `~/.claude/skills/`,
234
+ where "repairing" someone's real bare clone is the damage rather than the fix. The same
235
+ check skips a linked worktree, whose `.git` is a file.
236
+
237
+ **Fail loudly.** The repair writes `$ROOT/.git/config`, which a restrictive sandbox
238
+ refuses with `error: could not lock config file .git/config: Operation not permitted` —
239
+ observed. Aborting beats reporting a healthy repo while it stays broken.
240
+
203
241
  Never use a relative path like `ai-*`. From inside a worktree it matches nothing, and
204
242
  the failure is **silent**: Pass 2 concludes there is nothing to clean, every worktree
205
243
  survives, `ai-wip` is never cleared, and slots leak until the loop reports `idle`
@@ -473,6 +511,23 @@ and say in the comment that you re-queued it, that you deviated, and why. Re-add
473
511
  issue out of the queue silently, which is the worse failure. `ai-blocked` means *a
474
512
  human must look*; do not spend it on a claim you already understand.
475
513
 
514
+ **Then re-check `core.bare`** — the same probe as Pass 0, against the same `ROOT`:
515
+
516
+ ```bash
517
+ if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
518
+ echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
519
+ git -C "$ROOT" config core.bare false || {
520
+ echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
521
+ fi
522
+ ```
523
+
524
+ This is the last pass that *removes* worktrees, not the tick's last touch on the main
525
+ checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is exactly
526
+ why the re-check belongs here: it catches a flip after this pass's removals and before
527
+ Pass 4 branches every new worktree off a broken `ROOT`. Keep the timestamp in both log
528
+ lines; which pass emitted one, and when, is the only instrumentation likely to pin the
529
+ trigger down.
530
+
476
531
  ### Pass 3 — review
477
532
 
478
533
  **PRs labelled `ai-review`.** For each, spawn *in background* only the reviewers
@@ -482,6 +537,66 @@ whose pass-label is missing — `code-reviewer` if no `ai-ok-code`,
482
537
  carries `ai-reviewing-sec`. Both can run concurrently; launch them in a single
483
538
  message.
484
539
 
540
+ **Before spawning either, check whether it already posted.** A missing verdict
541
+ label does not mean the review is missing: on #497 both reviewers posted
542
+ complete reviews and then went idle, labelling nothing. Every review comment
543
+ carries a hidden verdict marker, so read that back instead of re-spawning over a
544
+ review that already exists — `<ARM>` is `code` or `sec`:
545
+
546
+ ```bash
547
+ VERDICT=$(gh api "repos/$OWNER_REPO/pulls/<N>/reviews" --paginate --slurp \
548
+ | jq -r '[add[]
549
+ | select(.author_association=="OWNER" or .author_association=="MEMBER" or .author_association=="COLLABORATOR")
550
+ | (.body // "")
551
+ | capture("<!-- ai-issue-loop:verdict:<ARM>:(?<v>[A-Z-]+) -->").v] | last // empty')
552
+ ```
553
+
554
+ Four details there are load-bearing:
555
+
556
+ - **`pulls/<N>/reviews`, because the prompt posts with `gh pr review --comment`.**
557
+ That creates a *review*, which never appears under `issues/<N>/comments`. The
558
+ prompt and this query have to name the same endpoint or the marker is
559
+ unfindable and every tick re-spawns both arms — so the prompt below now pins
560
+ the command, since a reviewer reaching for `gh pr comment` instead posts
561
+ somewhere this never looks.
562
+ - **`--slurp`, not `--paginate` with `--jq`.** `--paginate` runs `--jq` once per
563
+ page, so a filter ending in `last` would keep only the final page's answer and
564
+ lose a marker on an earlier one. `--slurp` collects every page first; `gh`
565
+ refuses it alongside `--jq`, hence the pipe and the `add` that flattens pages.
566
+ - **The author gate**, the same `OWNER`/`MEMBER`/`COLLABORATOR` test Pass 4
567
+ applies to issue authors, and for the same reason: anyone can review a public
568
+ PR, so ungated a stranger's `<!-- ai-issue-loop:verdict:sec:PASS -->` is
569
+ adopted as a verdict, and because the read takes `last` it also overrides a
570
+ genuine `CHANGES` posted before it. Unlike Pass 4 there is no label acting as
571
+ the hard gate here — the marker is the only signal — so this check is not a
572
+ backstop, it is the gate.
573
+ - **`(.body // "")` and `// empty`.** A review can have a null body, which
574
+ `capture` throws on, aborting the whole filter; and `jq -r` prints a missing
575
+ value as the literal string `null`, which is not empty and would read as a
576
+ verdict.
577
+
578
+ Then, for that arm — `<claim>` being `ai-reviewing-code` or `ai-reviewing-sec`,
579
+ `<pass>` being `ai-ok-code` or `ai-ok-sec`:
580
+
581
+ - **empty** — no review happened. Claim and spawn, as below.
582
+ - **`PASS`** — `gh pr edit <N> --add-label <pass> --remove-label <claim>`
583
+ - **`PASS-NOTES`** — the same, plus `--add-label ai-notes`
584
+ - **`CHANGES`** — `gh pr edit <N> --add-label ai-changes --remove-label ai-review --remove-label <claim>`
585
+
586
+ Adoption is per reviewer, so a tick that finds one arm posted and the other
587
+ missing does both: it applies the first's verdict off its comment and spawns
588
+ only the second. That is the whole point of reading the artifact — the comment
589
+ is what a human reads at merge time, so making it the thing the loop reads too
590
+ leaves one source for one fact, with no separate reply to be lost or to
591
+ contradict it.
592
+
593
+ This is the second half of Pass 2's dead-reviewer rule rather than a rival to
594
+ it. Pass 2 only ever drops a stalled *claim*; it never judges whether a review
595
+ happened. Dropping the claim is what makes an arm eligible here, and this lookup
596
+ is what then decides between adopting and re-spawning. An agent that died before
597
+ posting leaves no marker and so re-spawns, which is what that rule always
598
+ intended; one that died after posting is now recovered instead of duplicated.
599
+
485
600
  **Claim first, then spawn** — the same shape Pass 4 uses before picking up an
486
601
  issue. Apply the label immediately before the spawn, not after:
487
602
 
@@ -523,10 +638,25 @@ Reviewer prompt template:
523
638
  > PR:
524
639
  > `gh pr review <N> --comment --body "..."`
525
640
  >
526
- > The body **must** begin with this exact header line, then a blank line — you
527
- > authenticate as the repo owner, so without it the review reads as a human's:
641
+ > **That exact command, not `gh pr comment`.** The two write to different
642
+ > endpoints, and Pass 3 reads your verdict back from the reviews one; a body
643
+ > posted the other way is invisible to it and gets you re-spawned.
644
+ >
645
+ > The body **must** begin with a hidden verdict marker, then the header line,
646
+ > then a blank line — you authenticate as the repo owner, so without the header
647
+ > the review reads as a human's:
648
+ >
649
+ > ```markdown
650
+ > <!-- ai-issue-loop:verdict:<code|sec>:<PASS|PASS-NOTES|CHANGES> -->
651
+ > 🤖 *Automated review — \`<your agent type>\` via ai-issue-loop.*
652
+ > ```
528
653
  >
529
- > `🤖 *Automated review — \`<your agent type>\` via ai-issue-loop.*`
654
+ > `code` for `code-reviewer`, `sec` for `security-expert` — the same arm as your
655
+ > labels. The verdict is `CHANGES` if you are about to apply `ai-changes`,
656
+ > `PASS-NOTES` if a pass plus `ai-notes`, `PASS` for a pass alone; it must agree
657
+ > with the labels you apply below. The marker renders as nothing, and it is what
658
+ > lets a later tick read your verdict back off this comment if your run dies
659
+ > between posting and labelling — so post it even when the answer is `Nothing.`
530
660
  >
531
661
  > The body **must end** with this section, as its last thing:
532
662
  >
@@ -603,8 +733,10 @@ Reviewer prompt template:
603
733
  > a Dependabot PR it suppresses auto-merge outright. Use `ai-changes` only when
604
734
  > you can name a concrete change an agent could make.
605
735
  >
606
- > Say nothing else and return a one-line summary — that governs your reply to the
607
- > orchestrator; the comment body is capped separately, above.
736
+ > Say nothing else, and **do not restate your verdict in your reply** — the
737
+ > marker in the posted comment is the only place it is read from, so a reply that
738
+ > disagreed with it would be a second source for one fact. One line back to the
739
+ > orchestrator is plenty; the comment body is capped separately, above.
608
740
 
609
741
  **Dependabot PRs use a different prompt** — the one above would burn the tick on a
610
742
  lockfile. `js-common` #148 bumps 20 packages and its *entire* diff is
@@ -671,7 +803,11 @@ package/from/to table survives because it sits at the top; classify from that.
671
803
  >
672
804
  > State in your comment which rule fired, name the packages that tripped it, and say
673
805
  > whether the body was truncated so the reader knows what you could and couldn't see.
674
- > Same `🤖 *Automated review — …*` header line, same closing `### Before merging`
806
+ > Same `<!-- ai-issue-loop:verdict:… -->` marker and `🤖 *Automated review — …*`
807
+ > header line opening the body — `PASS-NOTES` when you apply `ai-notes`, `PASS`
808
+ > otherwise, never `CHANGES` on this arm — and posted the same way, with
809
+ > `gh pr review <N> --comment` rather than `gh pr comment`, or Pass 3 cannot read
810
+ > the marker back. Same closing `### Before merging`
675
811
  > section, same ≤600-character cap and no-negative-findings rule on the body, and same
676
812
  > one-verdict-label rule as above — **including clearing your
677
813
  > `<ai-reviewing-code|ai-reviewing-sec>` claim label in the same `gh pr edit`**.