@rtorcato/repo-tooling 3.19.2 → 3.20.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 -->
@@ -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.20.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`**.