@rtorcato/repo-tooling 3.21.0 → 3.22.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
@@ -180,20 +180,22 @@ ln -sf ../../node_modules/@rtorcato/repo-tooling/tooling/claude/repo-tooling.md
180
180
  ### Use with Claude Code (plugin)
181
181
 
182
182
  This repo is also a self-hosted Claude Code marketplace. Install the plugin to
183
- get three skills — `repo-tooling` (adopt/audit the presets via the CLI),
184
- `npm-publish` (never hand-cut a release) and `ai-issue-loop` (the label-driven
185
- issue → PR pipeline) — in any session:
183
+ get six skills — `repo-tooling` (adopt/audit the presets via the CLI),
184
+ `npm-publish` (never hand-cut a release), `ai-issue-loop` (the label-driven
185
+ issue → PR pipeline), `ai-workflow` (burst the `ai-ready` queue in parallel
186
+ worktrees), `ai-issue` (file agent-executable issues) and `ai-loop-status`
187
+ (read-only pipeline status) — in any session:
186
188
 
187
189
  ```
188
190
  /plugin marketplace add rtorcato/repo-tooling
189
191
  /plugin install repo-tooling@repo-tooling
190
192
  ```
191
193
 
192
- `ai-issue-loop` also installs on its own, user-globally, so every repo on the
193
- machine shares one copy:
194
+ The four `ai-*` skills also install on their own, user-globally, so every repo
195
+ on the machine shares one copy:
194
196
 
195
197
  ```bash
196
- npx @rtorcato/repo-tooling fix claude-skills # → ~/.claude/skills/ai-issue-loop/SKILL.md
198
+ npx @rtorcato/repo-tooling fix claude-skills # → ~/.claude/skills/{ai-issue-loop,ai-workflow,ai-issue,ai-loop-status}/SKILL.md
197
199
  ```
198
200
 
199
201
  It writes outside the repo, so it is opt-in: a bare `fix` skips it. See the
@@ -234,7 +236,10 @@ MIT — see [LICENSE](LICENSE).
234
236
  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
237
 
236
238
  ```bash
239
+ npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-issue'
237
240
  npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-issue-loop'
241
+ npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-loop-status'
242
+ npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-workflow'
238
243
  npx skills add https://github.com/rtorcato/repo-tooling --skill 'npm-publish'
239
244
  npx skills add https://github.com/rtorcato/repo-tooling --skill 'repo-tooling'
240
245
  ```
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { BLOCK_START, hasStaleAgentBlock } from '../cli/generators/agent-rules.js';
4
4
  import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
5
- import { claudeSkillStatus, SHIPPED_SKILL, skillDiffCommand, } from '../cli/generators/claude-skills.js';
5
+ import { claudeSkillStatus, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
6
6
  import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
7
7
  import { detectNestedLanguages } from '../cli/utils/detect-language.js';
8
8
  /**
@@ -477,48 +477,62 @@ export async function checkAiSetup(dir) {
477
477
  */
478
478
  export async function checkClaudeSkills(skillsDir) {
479
479
  const check = 'Claude skills';
480
- const hint = `Run \`npx @rtorcato/repo-tooling fix claude-skills\` to install the ${SHIPPED_SKILL} skill (writes outside the repo; opt-in, so \`fix\` alone skips it)`;
481
- const status = await claudeSkillStatus(SHIPPED_SKILL, skillsDir);
482
- if (!status.installed) {
480
+ const hint = `Run \`npx @rtorcato/repo-tooling fix claude-skills\` to install the ${SHIPPED_SKILLS.join(', ')} skills (writes outside the repo; opt-in, so \`fix\` alone skips it)`;
481
+ const statuses = [];
482
+ for (const name of SHIPPED_SKILLS)
483
+ statuses.push([name, await claudeSkillStatus(name, skillsDir)]);
484
+ if (statuses.every(([, s]) => s.file === null)) {
483
485
  return {
484
486
  check,
485
487
  status: 'optional-missing',
486
- detail: status.file
487
- ? `${SHIPPED_SKILL} skill is not installed`
488
- : `no ~/.claude/skills — the ${SHIPPED_SKILL} skill is not installed`,
488
+ detail: `no ~/.claude/skills — the ${SHIPPED_SKILLS.join(', ')} skills are not installed`,
489
489
  hint,
490
490
  };
491
491
  }
492
- if (status.needsInstall) {
493
- return {
494
- check,
495
- status: 'optional-missing',
496
- detail: `${SHIPPED_SKILL} skill is at ${status.installedVersion ?? 'an unstamped version'}; this package ships ${status.shippedVersion}`,
497
- hint,
498
- };
492
+ const missing = statuses.filter(([, s]) => !s.installed).map(([name]) => name);
493
+ const behind = statuses.filter(([, s]) => s.installed && s.needsInstall);
494
+ if (missing.length > 0 || behind.length > 0) {
495
+ const parts = [
496
+ missing.length > 0 ? `not installed: ${missing.join(', ')}` : null,
497
+ ...behind.map(([name, s]) => `${name} is at ${s.installedVersion ?? 'an unstamped version'}; this package ships ${s.shippedVersion}`),
498
+ ].filter((p) => p !== null);
499
+ return { check, status: 'optional-missing', detail: parts.join('; '), hint };
499
500
  }
500
501
  // A local fork is `ok` for the same reason a modified copied asset is (#448):
501
502
  // it is somebody's deliberate work, so it is named once and never nagged as
502
503
  // fixable — pointing at a `fix` that would refuse is worse than saying nothing.
503
504
  // `realFile` is set whenever `contentState` is — both mean something is
504
505
  // installed. The extra test is TypeScript's, not a real condition.
505
- if (status.realFile && status.contentState && status.contentState !== 'pristine') {
506
- const why = status.contentState === 'modified'
507
- ? `has local changes since ${status.installedVersion}`
508
- : 'carries no content record, so a fork cannot be told from a stale copy';
506
+ const forks = statuses.filter(([, s]) => s.realFile && s.contentState && s.contentState !== 'pristine');
507
+ if (forks.length > 0) {
508
+ const detail = forks
509
+ .map(([name, s]) => {
510
+ const why = s.contentState === 'modified'
511
+ ? `has local changes since ${s.installedVersion}`
512
+ : 'carries no content record, so a fork cannot be told from a stale copy';
513
+ return `${name} skill at ${s.file} ${why}; this package ships ${s.shippedVersion} and will not overwrite it`;
514
+ })
515
+ .join('; ');
516
+ // Name both paths: "diff it against the shipped copy" left the reader
517
+ // with nothing to diff, in the one case they most want to look (#484).
518
+ const diffs = forks
519
+ .map(([, s]) => s.realFile
520
+ ? `\`${skillDiffCommand({ realFile: s.realFile, shippedFile: s.shippedFile })}\``
521
+ : null)
522
+ .filter((d) => d !== null)
523
+ .join(', ');
509
524
  return {
510
525
  check,
511
526
  status: 'ok',
512
- detail: `${SHIPPED_SKILL} skill at ${status.file} ${why}; this package ships ${status.shippedVersion} and will not overwrite it`,
513
- // Name both paths: "diff it against the shipped copy" left the reader
514
- // with nothing to diff, in the one case they most want to look (#484).
515
- hint: `Diff it against the shipped copy — \`${skillDiffCommand({ realFile: status.realFile, shippedFile: status.shippedFile })}\` — then run \`npx @rtorcato/repo-tooling fix claude-skills --force-skills\` to take the shipped version`,
527
+ detail,
528
+ hint: `Diff against the shipped copy — ${diffs} — then run \`npx @rtorcato/repo-tooling fix claude-skills --force-skills\` to take the shipped version`,
516
529
  };
517
530
  }
531
+ const versions = new Set(statuses.map(([, s]) => s.installedVersion));
518
532
  return {
519
533
  check,
520
534
  status: 'ok',
521
- detail: `${SHIPPED_SKILL} skill installed at ${status.installedVersion}`,
535
+ detail: `${SHIPPED_SKILLS.length} skills installed at ${[...versions].join(', ')}`,
522
536
  };
523
537
  }
524
538
  /**
@@ -16,7 +16,7 @@ import path from 'node:path';
16
16
  import chalk from 'chalk';
17
17
  import inquirer from 'inquirer';
18
18
  import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
19
- import { installClaudeSkill, resolveSkillsDir, SHIPPED_SKILL, skillDiffCommand, } from '../cli/generators/claude-skills.js';
19
+ import { installClaudeSkill, resolveSkillsDir, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
20
20
  import { generateBrand } from '../cli/generators/brand.js';
21
21
  import { generateCommunityHealth } from '../cli/generators/community-health.js';
22
22
  import { generateCommitlintConfig } from '../cli/generators/git.js';
@@ -87,7 +87,7 @@ function describeSkillFork(result) {
87
87
  ? `its content has diverged from the ${result.installedVersion} release it was installed from`
88
88
  : 'it carries no content record, so a local fork and a stale copy are indistinguishable';
89
89
  return [
90
- `skipped — ${SHIPPED_SKILL} was not overwritten with ${result.shippedVersion}: ${why}`,
90
+ `skipped — ${result.name} was not overwritten with ${result.shippedVersion}: ${why}`,
91
91
  ` ${target}`,
92
92
  ` compare: ${skillDiffCommand(result)}`,
93
93
  ' overwrite anyway: fix claude-skills --force-skills',
@@ -348,9 +348,9 @@ export const BASE_FIXERS = [
348
348
  },
349
349
  {
350
350
  target: 'claude-skills',
351
- description: `Install the ${SHIPPED_SKILL} Claude Code skill into the user-level skills dir (~/.claude/skills, or --skills-dir). Writes outside the repo`,
351
+ description: `Install the ${SHIPPED_SKILLS.join(', ')} Claude Code skills into the user-level skills dir (~/.claude/skills, or --skills-dir). Writes outside the repo`,
352
352
  appliesTo: ['Claude skills'],
353
- outputs: [`~/.claude/skills/${SHIPPED_SKILL}/SKILL.md`],
353
+ outputs: SHIPPED_SKILLS.map((name) => `~/.claude/skills/${name}/SKILL.md`),
354
354
  // safe-add is load-bearing for the same reason it is on github-settings:
355
355
  // it exempts this fixer from the `--diff` shadow-run, which copies the repo
356
356
  // to tmp and *executes* run() — here that would write to the real home dir
@@ -362,26 +362,34 @@ export const BASE_FIXERS = [
362
362
  const dir = await resolveInstallDir(skillsDir, assumeYes);
363
363
  if (!dir)
364
364
  return { filesWritten: [] };
365
- const result = await installClaudeSkill(dir, SHIPPED_SKILL, { force: forceSkills });
366
- if (result.status === 'declined-downgrade') {
367
- console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
368
- return { filesWritten: [] };
369
- }
370
- if (result.status === 'declined-fork') {
371
- // Name `realFile`: through a stow symlink the overwrite would land in a
372
- // *second* repo's working tree, and that is the path to look at (#480).
373
- for (const line of describeSkillFork(result))
374
- console.error(chalk.yellow(` ${line}`));
375
- return { filesWritten: [] };
376
- }
377
- if (result.status === 'up-to-date')
378
- return { filesWritten: [] };
379
- // Report the resolved real path when the skill is a stow symlink: the bytes
380
- // landed in a dotfiles checkout, and that is where the user has to commit them.
381
- if (result.viaSymlink) {
382
- console.error(chalk.dim(` wrote through a symlink — commit ${result.realFile}`));
365
+ const filesWritten = [];
366
+ for (const name of SHIPPED_SKILLS) {
367
+ const result = await installClaudeSkill(dir, name, { force: forceSkills });
368
+ if (result.status === 'declined-downgrade') {
369
+ // Say what was compared, not what is newer: the version is a label,
370
+ // and on a git checkout it can understate the content behind it
371
+ // (#522). Naming the escape hatch matters for exactly that case.
372
+ console.error(chalk.yellow(` skipped — ${result.file} is stamped ${result.installedVersion}, above the ${result.shippedVersion} this package reports; not overwritten`));
373
+ console.error(chalk.yellow(' overwrite anyway: fix claude-skills --force-skills'));
374
+ continue;
375
+ }
376
+ if (result.status === 'declined-fork') {
377
+ // Name `realFile`: through a stow symlink the overwrite would land in a
378
+ // *second* repo's working tree, and that is the path to look at (#480).
379
+ for (const line of describeSkillFork(result))
380
+ console.error(chalk.yellow(` ${line}`));
381
+ continue;
382
+ }
383
+ if (result.status === 'up-to-date')
384
+ continue;
385
+ // Report the resolved real path when the skill is a stow symlink: the bytes
386
+ // landed in a dotfiles checkout, and that is where the user has to commit them.
387
+ if (result.viaSymlink) {
388
+ console.error(chalk.dim(` wrote through a symlink — commit ${result.realFile}`));
389
+ }
390
+ filesWritten.push(result.realFile);
383
391
  }
384
- return { filesWritten: [result.realFile] };
392
+ return { filesWritten };
385
393
  },
386
394
  },
387
395
  {
@@ -8,9 +8,15 @@ import { createHash } from 'node:crypto';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import fs from 'fs-extra';
11
+ import { realGitExec } from '../../base/git-identity.js';
11
12
  import { getPackageRoot } from '../utils/copy-preset.js';
12
13
  import { shellQuote } from '../utils/shell.js';
13
- /** Skills this package owns the content of and keeps up to date. */
14
+ /**
15
+ * Skills this package owns the content of and keeps up to date. The loop first —
16
+ * it is the pipeline; the other three are its drivers (burst, on-ramp, status).
17
+ */
18
+ export const SHIPPED_SKILLS = ['ai-issue-loop', 'ai-workflow', 'ai-issue', 'ai-loop-status'];
19
+ /** The primary skill — the default everywhere a single name is accepted. */
14
20
  export const SHIPPED_SKILL = 'ai-issue-loop';
15
21
  /**
16
22
  * Stamped into the installed copy's frontmatter so a second repo pinned to an
@@ -122,13 +128,41 @@ export function isNewerVersion(a, b) {
122
128
  }
123
129
  return false;
124
130
  }
131
+ /**
132
+ * The version to stamp, given what `package.json` claims.
133
+ *
134
+ * In a published tarball that field is authoritative — `@semantic-release/npm`
135
+ * rewrites it before packing. In a *git checkout* it is not: this repo runs
136
+ * semantic-release without `@semantic-release/git` (#417), so nothing ever
137
+ * writes the released version back and the field sits at whatever it was last
138
+ * hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
139
+ * ten minor versions of drift.
140
+ *
141
+ * That mattered because the stamp feeds the downgrade guard below: a skill
142
+ * installed from npm could not be updated from a local checkout, and the
143
+ * refusal claimed the installed copy was "newer" when only its *label* was.
144
+ *
145
+ * So when the package root is a git checkout, take the nearest tag as well and
146
+ * keep whichever is higher. Monotonic on purpose — this can only ever raise the
147
+ * answer, so a tagless, shallow, or git-less environment keeps today's
148
+ * behaviour rather than silently stamping something lower.
149
+ */
150
+ export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
151
+ if (!(await fs.pathExists(path.join(root, '.git'))))
152
+ return pkgVersion;
153
+ const described = await git(['describe', '--tags', '--abbrev=0'], root);
154
+ const tag = described?.trim().replace(/^v/, '');
155
+ if (!tag || !/^\d+\.\d+/.test(tag))
156
+ return pkgVersion;
157
+ return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
158
+ }
125
159
  /** The skill source and the package version that will be stamped into it. */
126
- export async function readShippedSkill(name = SHIPPED_SKILL) {
160
+ export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
127
161
  const root = getPackageRoot();
128
162
  const file = path.join(root, 'skills', name, 'SKILL.md');
129
163
  const content = await fs.readFile(file, 'utf8');
130
164
  const pkg = await fs.readJson(path.join(root, 'package.json'));
131
- return { content, version: String(pkg.version), file };
165
+ return { content, version: await resolveShippedVersion(root, String(pkg.version), git), file };
132
166
  }
133
167
  /**
134
168
  * The command a human runs to see what their fork changed, before deciding
@@ -182,7 +216,12 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { forc
182
216
  installedVersion,
183
217
  shippedVersion: shipped.version,
184
218
  };
185
- if (installedVersion && isNewerVersion(installedVersion, shipped.version)) {
219
+ // `force` overrides this too, not just the fork check below. It already
220
+ // overrides the *stronger* protection — overwriting content someone
221
+ // deliberately edited — so refusing on a version comparison while allowing
222
+ // that was backwards, and left no escape hatch at all when the comparison
223
+ // was wrong (#522).
224
+ if (!force && installedVersion && isNewerVersion(installedVersion, shipped.version)) {
186
225
  return { ...base, status: 'declined-downgrade' };
187
226
  }
188
227
  // Only `pristine` content is provably ours to replace. Anything else is a
@@ -66,13 +66,18 @@ export async function writeLockfile(dir, config, assets) {
66
66
  if (!valid) {
67
67
  throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
68
68
  }
69
- const carried = assets ?? (await readLockfile(dir))?.assets;
69
+ // One read, because everything not rebuilt from `config` has to be carried
70
+ // forward explicitly — this object is constructed from scratch, so any key
71
+ // not named here is dropped by the next `fix lockfile`.
72
+ const existing = await readLockfile(dir);
73
+ const carried = assets ?? existing?.assets;
70
74
  const filepath = path.join(dir, LOCKFILE_NAME);
71
75
  const lockfile = {
72
76
  $schema: LOCKFILE_SCHEMA_URL,
73
77
  version: LOCKFILE_VERSION,
74
78
  config,
75
79
  ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
80
+ ...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
76
81
  writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
77
82
  writtenAt: new Date().toISOString(),
78
83
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.21.0",
3
+ "version": "3.22.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": [
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: ai-issue
3
+ description: |
4
+ File a GitHub issue labelled `ai-ready` for the ai-issue-loop pipeline to pick
5
+ up and implement unattended. Use when the user says "file this for the loop",
6
+ "make this an AI issue", "queue this for an agent", or invokes `/ai-issue`.
7
+ For an ordinary issue a human will work on, use plain `gh issue create` with
8
+ no label instead. GitHub only (`gh`) — not GitLab.
9
+ ---
10
+
11
+ # ai-issue
12
+
13
+ File an issue an **agent will execute unattended**, labelled `ai-ready` so
14
+ `ai-issue-loop` picks it up. Arguments: $ARGUMENTS
15
+
16
+ This is only for work you intend a background agent to do without you. For an
17
+ ordinary issue, use `gh issue create` with no `ai-*` label.
18
+
19
+ ## Preflight
20
+
21
+ 1. `git remote get-url origin` — GitHub only. On GitLab, stop: the loop is
22
+ `gh`-based and nothing would ever pick the issue up.
23
+ 2. `gh label list --search ai-ready` — if the label is missing, this repo hasn't
24
+ been bootstrapped for the loop. Stop and point at the `ai-issue-loop` skill's
25
+ label block; creating a bare `ai-ready` label would produce an issue that
26
+ silently never runs.
27
+
28
+ ## Write it for an agent, not for yourself
29
+
30
+ The agent that picks this up **cannot ask a follow-up question**, and is
31
+ instructed to treat the body as untrusted data. Both change how it must read:
32
+
33
+ - **Describe, never instruct.** "The README claims X but Y is true" — not "go
34
+ update the README". Directive phrasing is exactly what the agent is told to
35
+ ignore, so an instruction-shaped issue reads as empty.
36
+ - **Name the files** you already know are involved. The two reviewing agents are
37
+ diff-scoped and won't explore the repo to judge whether the right thing was
38
+ touched.
39
+ - **State a done-condition a reviewer can check.** Those same agents review the
40
+ PR against this body; a vague issue produces a vague review on a PR you then
41
+ merge without having really vetted.
42
+ - **One PR's worth.** Split anything spanning several concerns — the loop runs
43
+ several issues in parallel, so splitting is free.
44
+
45
+ ## Refuse the ones that aren't ready
46
+
47
+ Say so, and file it unlabelled instead, when the task:
48
+
49
+ - needs a judgement call you'd normally make mid-PR,
50
+ - needs eyes on rendered output, a real device, or a running service,
51
+ - depends on context that lives in this conversation rather than the repo, or
52
+ - you couldn't write self-contained without "we can sort that out in review".
53
+
54
+ An `ai-ready` that stalls costs more than an issue you did yourself: it burns a
55
+ concurrency slot, two review passes, and up to two fix rounds before it lands as
56
+ `ai-blocked`.
57
+
58
+ ## Create
59
+
60
+ Draft title and body from `$ARGUMENTS` plus the conversation. The body opens
61
+ with a line saying an agent wrote it — everything you post appears under the
62
+ owner's own account:
63
+
64
+ ```bash
65
+ gh issue create --label ai-ready --title "TITLE" --body "$(cat <<'EOF'
66
+ 🤖 *Filed by Claude (AI agent) on the owner's behalf.*
67
+
68
+ BODY
69
+ EOF
70
+ )"
71
+ ```
72
+
73
+ Print the URL. Note that nothing happens until a tick runs — `/ai-issue-loop`
74
+ manually, or a recurring schedule if one is active.