@rtorcato/repo-tooling 3.20.0 → 3.21.1

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';
@@ -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
  /**
@@ -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',
@@ -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',
@@ -328,9 +348,9 @@ export const BASE_FIXERS = [
328
348
  },
329
349
  {
330
350
  target: 'claude-skills',
331
- 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`,
332
352
  appliesTo: ['Claude skills'],
333
- outputs: [`~/.claude/skills/${SHIPPED_SKILL}/SKILL.md`],
353
+ outputs: SHIPPED_SKILLS.map((name) => `~/.claude/skills/${name}/SKILL.md`),
334
354
  // safe-add is load-bearing for the same reason it is on github-settings:
335
355
  // it exempts this fixer from the `--diff` shadow-run, which copies the repo
336
356
  // to tmp and *executes* run() — here that would write to the real home dir
@@ -342,26 +362,30 @@ export const BASE_FIXERS = [
342
362
  const dir = await resolveInstallDir(skillsDir, assumeYes);
343
363
  if (!dir)
344
364
  return { filesWritten: [] };
345
- const result = await installClaudeSkill(dir, SHIPPED_SKILL, { force: forceSkills });
346
- if (result.status === 'declined-downgrade') {
347
- console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
348
- return { filesWritten: [] };
349
- }
350
- if (result.status === 'declined-fork') {
351
- // Name `realFile`: through a stow symlink the overwrite would land in a
352
- // *second* repo's working tree, and that is the path to look at (#480).
353
- for (const line of describeSkillFork(result))
354
- console.error(chalk.yellow(` ${line}`));
355
- return { filesWritten: [] };
356
- }
357
- if (result.status === 'up-to-date')
358
- return { filesWritten: [] };
359
- // Report the resolved real path when the skill is a stow symlink: the bytes
360
- // landed in a dotfiles checkout, and that is where the user has to commit them.
361
- if (result.viaSymlink) {
362
- 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
+ console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
370
+ continue;
371
+ }
372
+ if (result.status === 'declined-fork') {
373
+ // Name `realFile`: through a stow symlink the overwrite would land in a
374
+ // *second* repo's working tree, and that is the path to look at (#480).
375
+ for (const line of describeSkillFork(result))
376
+ console.error(chalk.yellow(` ${line}`));
377
+ continue;
378
+ }
379
+ if (result.status === 'up-to-date')
380
+ continue;
381
+ // Report the resolved real path when the skill is a stow symlink: the bytes
382
+ // landed in a dotfiles checkout, and that is where the user has to commit them.
383
+ if (result.viaSymlink) {
384
+ console.error(chalk.dim(` wrote through a symlink — commit ${result.realFile}`));
385
+ }
386
+ filesWritten.push(result.realFile);
363
387
  }
364
- return { filesWritten: [result.realFile] };
388
+ return { filesWritten };
365
389
  },
366
390
  },
367
391
  {
@@ -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
+ }
@@ -10,7 +10,12 @@ import path from 'node:path';
10
10
  import fs from 'fs-extra';
11
11
  import { getPackageRoot } from '../utils/copy-preset.js';
12
12
  import { shellQuote } from '../utils/shell.js';
13
- /** Skills this package owns the content of and keeps up to date. */
13
+ /**
14
+ * Skills this package owns the content of and keeps up to date. The loop first —
15
+ * it is the pipeline; the other three are its drivers (burst, on-ramp, status).
16
+ */
17
+ export const SHIPPED_SKILLS = ['ai-issue-loop', 'ai-workflow', 'ai-issue', 'ai-loop-status'];
18
+ /** The primary skill — the default everywhere a single name is accepted. */
14
19
  export const SHIPPED_SKILL = 'ai-issue-loop';
15
20
  /**
16
21
  * Stamped into the installed copy's frontmatter so a second repo pinned to an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.20.0",
3
+ "version": "3.21.1",
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.