@rtorcato/repo-tooling 3.28.0 → 3.30.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/AGENTS.md CHANGED
@@ -19,6 +19,8 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
19
19
  | `setup --config-schema` | ✅ | ✅ (JSON Schema) | Print the JSON Schema for `ProjectConfig`. Use to validate configs before scaffolding. |
20
20
  | `setup --dry-run` | ✅ | ✅ | Print resolved config + file list without writing. Pair with `--preset` or `--config`. |
21
21
  | `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing` / `declared`. |
22
+ | `doctor --rules-from <owner/repo>` | ✅ | ✅ | Report how this repo's `config`/`rules` differ from a reference repo's `.repo-tooling.json` (read over `gh`). Informational only — adds a `rulesReference` key to the JSON, never a check result, never affects the exit code. |
23
+ | `setup --from <owner/repo>` | ❌ (seeds the wizard) | `--dry-run` only | Seed the wizard's defaults from a reference repo's recorded config. Seeded, not skipped — every question is still asked. |
22
24
  | `fix --json --yes` | ✅ | ✅ | Walk every doctor finding, apply fixers. Returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`. |
23
25
  | `fix <target> --json --yes` | ✅ | ✅ | Apply one fixer. Targets from `list --json`. |
24
26
  | `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
@@ -31,15 +31,15 @@ export async function checkAgentUser(dir, agentUser, exec) {
31
31
  return {
32
32
  check: CHECK,
33
33
  status: 'ok',
34
- detail: 'not applicable — no aiLoop.agentUser in .repo-tooling.json',
34
+ detail: 'not applicable — no rules.aiLoop.agentUser in .repo-tooling.json',
35
35
  };
36
36
  }
37
37
  if (!LOGIN.test(agentUser)) {
38
38
  return {
39
39
  check: CHECK,
40
40
  status: 'drift',
41
- detail: `aiLoop.agentUser "${agentUser}" is not a valid GitHub login`,
42
- hint: 'Fix or remove aiLoop.agentUser in .repo-tooling.json',
41
+ detail: `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`,
42
+ hint: 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json',
43
43
  };
44
44
  }
45
45
  // Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
@@ -52,15 +52,15 @@ export async function checkAgentUser(dir, agentUser, exec) {
52
52
  return {
53
53
  check: CHECK,
54
54
  status: 'ok',
55
- detail: `aiLoop.agentUser "${agentUser}" is an assignable collaborator`,
55
+ detail: `rules.aiLoop.agentUser "${agentUser}" is an assignable collaborator`,
56
56
  };
57
57
  }
58
58
  if (/HTTP 404/.test(r.stderr)) {
59
59
  return {
60
60
  check: CHECK,
61
61
  status: 'drift',
62
- detail: `aiLoop.agentUser "${agentUser}" is not an assignable collaborator — the loop skills will silently assign nothing`,
63
- hint: `Add the account as a collaborator (\`gh api -X PUT repos/{owner}/{repo}/collaborators/${agentUser}\`) or remove aiLoop.agentUser from .repo-tooling.json`,
62
+ detail: `rules.aiLoop.agentUser "${agentUser}" is not an assignable collaborator — the loop skills will silently assign nothing`,
63
+ hint: `Add the account as a collaborator (\`gh api -X PUT repos/{owner}/{repo}/collaborators/${agentUser}\`) or remove rules.aiLoop.agentUser from .repo-tooling.json`,
64
64
  };
65
65
  }
66
66
  // Offline, unauthenticated, or gh missing — not evidence of drift.
@@ -19,6 +19,7 @@ import { checkMilestones } from '../../base/milestones.js';
19
19
  import { checkGitIdentity, checkGitIdentityHistory } from '../../base/git-identity.js';
20
20
  import { checkCopiedAssets } from '../utils/copied-assets.js';
21
21
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
22
+ import { compareRulesWithReference } from '../utils/reference-rules.js';
22
23
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
23
24
  import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
24
25
  import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
@@ -136,7 +137,9 @@ function checkLockfile(lock) {
136
137
  return {
137
138
  check: 'lockfile',
138
139
  status: 'ok',
139
- detail: `.repo-tooling.json v${lock.version} (written by ${lock.writtenBy})`,
140
+ // The stamp only ever describes the tool-written `record` subtree — the
141
+ // human-written `rules` half is deliberately unstamped (#559).
142
+ detail: `.repo-tooling.json v${lock.version} (record written by ${lock.record.writtenBy})`,
140
143
  };
141
144
  }
142
145
  // Lockfile-driven demotion: if the lock records an intentional opt-out for a
@@ -162,7 +165,7 @@ function demoteDeclined(results, lock) {
162
165
  // otherwise a typo silently does nothing and a check rename silently
163
166
  // un-suppresses a finding, and both are invisible.
164
167
  function applyExceptions(results, lock) {
165
- const exceptions = lock?.exceptions;
168
+ const exceptions = lock?.rules?.exceptions;
166
169
  if (!exceptions)
167
170
  return results;
168
171
  const known = new Set(results.map((r) => r.check));
@@ -221,7 +224,7 @@ async function runBaseChecks(dir, lock, opts) {
221
224
  // ai-issue-loop label colours/descriptions (#446) — same seam, same self-skip.
222
225
  results.push(await checkLoopLabels(dir));
223
226
  // aiLoop.agentUser assignability (#530) — same seam, same self-skip.
224
- results.push(await checkAgentUser(dir, lock?.aiLoop?.agentUser));
227
+ results.push(await checkAgentUser(dir, lock?.rules?.aiLoop?.agentUser));
225
228
  results.push(await checkGitLabCI(dir));
226
229
  results.push(await checkCodeowners(dir));
227
230
  results.push(await checkCommunityHealth(dir));
@@ -231,13 +234,13 @@ async function runBaseChecks(dir, lock, opts) {
231
234
  results.push(await checkClaudeSkills(opts.skillsDir));
232
235
  // #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
233
236
  // signal, so a repo that doesn't gets no line at all rather than an empty one.
234
- if (lock?.aiLoop && lock.requiredSkills?.length) {
235
- results.push(await checkRequiredSkills(lock.requiredSkills, opts.skillsDir));
237
+ if (lock?.rules?.aiLoop && lock.rules.requiredSkills?.length) {
238
+ results.push(await checkRequiredSkills(lock.rules.requiredSkills, opts.skillsDir));
236
239
  }
237
240
  // #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
238
241
  // about MCP, which is not a finding.
239
- if (lock?.mcp?.recommended?.length) {
240
- results.push(await checkRecommendedMcp(dir, lock.mcp.recommended));
242
+ if (lock?.rules?.mcp?.recommended?.length) {
243
+ results.push(await checkRecommendedMcp(dir, lock.rules.mcp.recommended));
241
244
  }
242
245
  results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
243
246
  results.push(await checkCoverageUpload(dir));
@@ -463,11 +466,43 @@ export function summarize(results) {
463
466
  declared: results.filter((r) => r.status === 'declared').length,
464
467
  };
465
468
  }
469
+ /**
470
+ * Print the `--rules-from` comparison (#563). Deliberately not a CheckResult:
471
+ * two repos legitimately differ, so a difference is never a finding about either
472
+ * one, and keeping it out of `results` is what makes "never affects the exit
473
+ * code" structural rather than a promise the next check has to remember.
474
+ */
475
+ function printRulesComparison(comparison) {
476
+ console.log(chalk.cyan(`\n📐 Rules vs ${comparison.reference}`), chalk.gray('(informational — differences are not drift and do not affect the exit code)\n'));
477
+ if (!comparison.compared) {
478
+ console.log(` ${chalk.gray('➖')} ${chalk.gray(`not compared — ${comparison.reason}`)}\n`);
479
+ return;
480
+ }
481
+ const differences = comparison.differences ?? [];
482
+ if (differences.length === 0) {
483
+ console.log(` ${chalk.green('✅')} ${chalk.gray('identical — no differences to report')}\n`);
484
+ return;
485
+ }
486
+ const show = (v) => (v === undefined ? chalk.dim('(absent)') : JSON.stringify(v));
487
+ for (const d of differences) {
488
+ console.log(` ${chalk.bold(d.path)}`);
489
+ console.log(` ${chalk.gray('here:')} ${show(d.local)}`);
490
+ console.log(` ${chalk.gray(`${comparison.reference}:`)} ${show(d.reference)}`);
491
+ }
492
+ console.log(chalk.gray(`\n ${differences.length} difference(s).\n`));
493
+ }
466
494
  export async function doctorCommand(options = {}) {
467
495
  const dir = options.directory ?? process.cwd();
468
496
  const results = await runDoctor(dir, options.skillsDir);
497
+ const comparison = options.rulesFrom
498
+ ? await compareRulesWithReference(dir, options.rulesFrom)
499
+ : null;
469
500
  if (options.json) {
470
- console.log(JSON.stringify({ directory: path.resolve(dir), results }, null, 2));
501
+ console.log(JSON.stringify({
502
+ directory: path.resolve(dir),
503
+ results,
504
+ ...(comparison ? { rulesReference: comparison } : {}),
505
+ }, null, 2));
471
506
  }
472
507
  else {
473
508
  console.log(chalk.cyan(`\n🩺 Diagnosing ${path.resolve(dir)} against ${PACKAGE} presets...\n`));
@@ -489,6 +524,8 @@ export async function doctorCommand(options = {}) {
489
524
  }
490
525
  console.log();
491
526
  }
527
+ if (comparison)
528
+ printRulesComparison(comparison);
492
529
  }
493
530
  const summary = summarize(results);
494
531
  const exitCode = summary.drift > 0 || summary.missing > 0 ? 1 : 0;
@@ -124,7 +124,7 @@ export function getFixTargetForCheck(checkName, language) {
124
124
  export function declinedInLock(lock, checkName) {
125
125
  if (!lock)
126
126
  return false;
127
- const c = lock.config;
127
+ const c = lock.record.config;
128
128
  switch (checkName) {
129
129
  case 'TypeScript':
130
130
  return c.typescript?.enabled === false;
@@ -186,7 +186,7 @@ export function declinedInLock(lock, checkName) {
186
186
  * the lockfile already reflects the change.
187
187
  */
188
188
  export function lockfilePatchForTarget(target, lock) {
189
- const c = lock.config;
189
+ const c = lock.record.config;
190
190
  switch (target) {
191
191
  case 'biome':
192
192
  if (c.linting.tool === 'biome' || c.linting.tool === 'both')
@@ -291,7 +291,7 @@ export async function fixCommand(target, options = {}) {
291
291
  }
292
292
  process.exit(1);
293
293
  }
294
- const files = computeFileList(resyncLock.config);
294
+ const files = computeFileList(resyncLock.record.config);
295
295
  if (!silent) {
296
296
  console.log(chalk.cyan(`\n🔄 Resync from ${LOCKFILE_NAME} (${files.length} files in scope)\n`));
297
297
  }
@@ -320,8 +320,8 @@ export async function fixCommand(target, options = {}) {
320
320
  return;
321
321
  }
322
322
  }
323
- await generateConfigs(resyncLock.config, targetDir);
324
- await writeLockfile(targetDir, resyncLock.config);
323
+ await generateConfigs(resyncLock.record.config, targetDir);
324
+ await writeLockfile(targetDir, resyncLock.record.config);
325
325
  if (json) {
326
326
  console.log(JSON.stringify({ directory: targetDir, mode: 'resync', dryRun: false, files }, null, 2));
327
327
  }
@@ -8,6 +8,7 @@ import { detectLanguage } from '../utils/detect-language.js';
8
8
  import { formatGeneratedFiles } from '../utils/format.js';
9
9
  import { installDependencies } from '../utils/install.js';
10
10
  import { LOCKFILE_NAME, writeLockfile } from '../utils/lockfile.js';
11
+ import { fetchReferenceLockfile } from '../utils/reference-rules.js';
11
12
  import { buildPresetConfig, computeFileList, CONFIG_SCHEMA, PRESET_NAMES, validateProjectConfig, } from './setup-presets.js';
12
13
  /**
13
14
  * The opinionated extras a preset turns on, in the order they're offered for
@@ -63,11 +64,36 @@ async function reviewPresetConfig(config) {
63
64
  }
64
65
  return reviewed;
65
66
  }
67
+ /**
68
+ * Read a reference repo's recorded config to seed the wizard (#563). Only
69
+ * `record.config` — the answers — crosses over: `assets` and the
70
+ * `writtenBy`/`writtenAt` stamps describe the reference's own history, and this
71
+ * repo's lockfile gets its own on the first write. `rules` is left behind too,
72
+ * since an `exceptions` entry excuses a deviation in the repo that wrote it.
73
+ *
74
+ * `null` means the reference could not be read — reported plainly and declined,
75
+ * the way `doctor --rules-from` reports "not compared". Falling through to an
76
+ * unseeded wizard would be worse: the user asked for that repo's answers, and
77
+ * generic defaults would be written as though they had confirmed them.
78
+ */
79
+ async function seedFromReference(reference) {
80
+ const result = await fetchReferenceLockfile(reference);
81
+ if (!result.ok) {
82
+ console.error(chalk.red(`\n❌ Cannot seed from ${reference} — ${result.reason}.`));
83
+ console.error(chalk.gray(' Re-run without --from to answer the wizard from scratch.\n'));
84
+ return null;
85
+ }
86
+ console.log(chalk.cyan(`\n🌱 Seeding defaults from ${reference} — every answer is still yours to change.\n`));
87
+ return result.lockfile.record.config;
88
+ }
66
89
  /** `null` means the run was declined, not that it failed — see promptForConfig. */
67
90
  async function resolveConfig(options) {
68
91
  if (options.config && options.preset) {
69
92
  console.warn(chalk.yellow('⚠️ Both --config and --preset given; --config wins.\n'));
70
93
  }
94
+ if (options.from && (options.config || options.preset)) {
95
+ console.warn(chalk.yellow('⚠️ --from seeds the wizard, which --config/--preset skip; ignoring --from.\n'));
96
+ }
71
97
  if (options.config) {
72
98
  const configPath = path.resolve(options.config);
73
99
  if (!(await fs.pathExists(configPath))) {
@@ -76,10 +102,12 @@ async function resolveConfig(options) {
76
102
  const raw = await fs.readJson(configPath);
77
103
  // Accept the `.repo-tooling.json` lockfile itself as the config source, so a
78
104
  // repo needs only one file: the lockfile already embeds the full
79
- // ProjectConfig under `config`. Unwrap it here rather than requiring a
80
- // separate hand-authored config file (#271).
81
- const candidate = typeof raw === 'object' && raw !== null && 'config' in raw && 'version' in raw
82
- ? raw.config
105
+ // ProjectConfig — under `record.config` since v4, top-level `config` before
106
+ // (#559). Unwrap it here rather than requiring a separate config file (#271).
107
+ const candidate = typeof raw === 'object' && raw !== null && 'version' in raw
108
+ ? (raw.record?.config ??
109
+ raw.config ??
110
+ raw)
83
111
  : raw;
84
112
  const { valid, errors } = validateProjectConfig(candidate);
85
113
  if (!valid) {
@@ -97,7 +125,14 @@ async function resolveConfig(options) {
97
125
  return config;
98
126
  return reviewPresetConfig(config);
99
127
  }
100
- return promptForConfig(path.resolve(options.directory));
128
+ let seed;
129
+ if (options.from) {
130
+ const seeded = await seedFromReference(options.from);
131
+ if (seeded === null)
132
+ return null;
133
+ seed = seeded;
134
+ }
135
+ return promptForConfig(path.resolve(options.directory), seed);
101
136
  }
102
137
  export async function setupProject(options) {
103
138
  if (options.configSchema) {
@@ -163,7 +198,7 @@ const SCAFFOLDABLE = ['js', 'swift'];
163
198
  * like. Languages setup can't scaffold yet are still offered — hiding them reads
164
199
  * as "repo-tooling is JS-only" when the honest answer is "not yet" (#139).
165
200
  */
166
- async function promptForLanguage(targetDir) {
201
+ async function promptForLanguage(targetDir, seed) {
167
202
  const detected = await detectLanguage(targetDir);
168
203
  const { language } = await inquirer.prompt([
169
204
  {
@@ -176,8 +211,10 @@ async function promptForLanguage(targetDir) {
176
211
  : `${module.label} (${module.supported ? 'doctor/fix only — no setup preset yet' : 'setup lands with its module'})`,
177
212
  value: module.id,
178
213
  })),
214
+ // What this dir already looks like beats the reference's language: the
215
+ // files on disk are evidence, the reference is only a suggestion.
179
216
  // 'unknown' is a bare dir mid-setup — JS is the historical default.
180
- default: detected === 'unknown' ? 'js' : detected,
217
+ default: detected !== 'unknown' ? detected : (seed?.language ?? 'js'),
181
218
  },
182
219
  ]);
183
220
  return LANGUAGES[language];
@@ -194,9 +231,16 @@ function explainNotScaffoldable(module) {
194
231
  console.log(chalk.gray(` ${module.label} setup presets are tracked at\n` +
195
232
  ' https://github.com/rtorcato/repo-tooling/issues/139\n'));
196
233
  }
197
- /** `null` when the chosen language has no scaffolding yet; nothing is written. */
198
- async function promptForConfig(targetDir) {
199
- const language = await promptForLanguage(targetDir);
234
+ /**
235
+ * `null` when the chosen language has no scaffolding yet; nothing is written.
236
+ *
237
+ * @param seed A reference repo's recorded config (#563). It moves the *defaults*
238
+ * only — every question is still asked, so seeding can never write an answer
239
+ * the user did not see. `projectName` is deliberately never seeded: a new repo
240
+ * is not the reference repo.
241
+ */
242
+ async function promptForConfig(targetDir, seed) {
243
+ const language = await promptForLanguage(targetDir, seed);
200
244
  // Gate on SCAFFOLDABLE, not `module.supported` — Swift is supported (#286)
201
245
  // but has no preset yet (#288), and falling through here would write a
202
246
  // package.json into a Swift repo.
@@ -226,6 +270,18 @@ async function promptForConfig(targetDir) {
226
270
  // Turborepo only makes sense in a pnpm-workspace monorepo, so the prompt is
227
271
  // only offered when one is already present in the target dir.
228
272
  const hasWorkspace = await fs.pathExists(path.join(targetDir, 'pnpm-workspace.yaml'));
273
+ // Two questions ask for something the config stores as a set of booleans, so
274
+ // their seeded default has to be derived rather than read.
275
+ const seededReleaseTool = !seed
276
+ ? 'semantic-release'
277
+ : seed.semanticRelease
278
+ ? 'semantic-release'
279
+ : seed.changesets
280
+ ? 'changesets'
281
+ : seed.releasePlease
282
+ ? 'release-please'
283
+ : 'none';
284
+ const seededOrchestrator = !seed ? 'turbo' : seed.turborepo ? 'turbo' : seed.nx ? 'nx' : 'none';
229
285
  const answers = await inquirer.prompt([
230
286
  {
231
287
  type: 'input',
@@ -238,6 +294,7 @@ async function promptForConfig(targetDir) {
238
294
  type: 'select',
239
295
  name: 'projectType',
240
296
  message: '🏗️ What type of project are you building?',
297
+ default: seed?.projectType,
241
298
  choices: [
242
299
  { name: '📚 Library/Package', value: 'library' },
243
300
  { name: '🌐 Web Application', value: 'web-app' },
@@ -250,7 +307,7 @@ async function promptForConfig(targetDir) {
250
307
  type: 'confirm',
251
308
  name: 'useTypeScript',
252
309
  message: '📘 Do you want to use TypeScript?',
253
- default: true,
310
+ default: seed?.typescript.enabled ?? true,
254
311
  },
255
312
  {
256
313
  type: 'select',
@@ -275,6 +332,7 @@ async function promptForConfig(targetDir) {
275
332
  }
276
333
  return baseChoices;
277
334
  },
335
+ default: seed?.typescript.config,
278
336
  when: (answers) => answers.useTypeScript,
279
337
  },
280
338
  {
@@ -287,7 +345,7 @@ async function promptForConfig(targetDir) {
287
345
  { name: '🔥 Both Biome + ESLint', value: 'both' },
288
346
  { name: '❌ None', value: 'none' },
289
347
  ],
290
- default: 'biome',
348
+ default: seed?.linting.tool ?? 'biome',
291
349
  },
292
350
  {
293
351
  type: 'select',
@@ -300,13 +358,14 @@ async function promptForConfig(targetDir) {
300
358
  }
301
359
  return choices;
302
360
  },
361
+ default: seed?.linting.eslintConfig,
303
362
  when: (answers) => answers.lintingTool === 'eslint' || answers.lintingTool === 'both',
304
363
  },
305
364
  {
306
365
  type: 'confirm',
307
366
  name: 'oxlint',
308
367
  message: '🦀 Also run Oxlint alongside (50–100× faster than ESLint)?',
309
- default: false,
368
+ default: seed?.oxlint ?? false,
310
369
  when: (answers) => answers.lintingTool !== 'none',
311
370
  },
312
371
  {
@@ -320,7 +379,7 @@ async function promptForConfig(targetDir) {
320
379
  { name: '🌲 Cypress (E2E)', value: 'cypress' },
321
380
  { name: '❌ None', value: 'none' },
322
381
  ],
323
- default: 'vitest',
382
+ default: seed?.testing.framework ?? 'vitest',
324
383
  },
325
384
  {
326
385
  type: 'select',
@@ -332,19 +391,19 @@ async function promptForConfig(targetDir) {
332
391
  { name: '🔄 Both', value: 'both' },
333
392
  ],
334
393
  when: (answers) => answers.testingFramework === 'jest',
335
- default: 'node',
394
+ default: seed?.testing.environment ?? 'node',
336
395
  },
337
396
  {
338
397
  type: 'confirm',
339
398
  name: 'gitHooks',
340
399
  message: '🪝 Set up Git hooks (Husky + lint-staged)?',
341
- default: true,
400
+ default: seed?.gitHooks ?? true,
342
401
  },
343
402
  {
344
403
  type: 'confirm',
345
404
  name: 'commitLint',
346
405
  message: '📝 Set up conventional commit linting?',
347
- default: true,
406
+ default: seed?.commitLint ?? true,
348
407
  when: (answers) => answers.gitHooks,
349
408
  },
350
409
  {
@@ -357,40 +416,40 @@ async function promptForConfig(targetDir) {
357
416
  { name: '🙏 Release Please (Google, release-PR-driven)', value: 'release-please' },
358
417
  { name: '❌ None', value: 'none' },
359
418
  ],
360
- default: 'semantic-release',
419
+ default: seededReleaseTool,
361
420
  when: (answers) => answers.projectType === 'library',
362
421
  },
363
422
  {
364
423
  type: 'confirm',
365
424
  name: 'treeshakeCheck',
366
425
  message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
367
- default: false,
426
+ default: seed?.treeshakeCheck ?? false,
368
427
  when: (answers) => answers.projectType === 'library',
369
428
  },
370
429
  {
371
430
  type: 'confirm',
372
431
  name: 'publint',
373
432
  message: '📦 Add publint to lint your package before publishing?',
374
- default: true,
433
+ default: seed?.publint ?? true,
375
434
  when: (answers) => answers.projectType === 'library',
376
435
  },
377
436
  {
378
437
  type: 'confirm',
379
438
  name: 'badges',
380
439
  message: '🔖 Add status badges (CI, npm, coverage, license) to the README?',
381
- default: true,
440
+ default: seed?.badges ?? true,
382
441
  },
383
442
  {
384
443
  type: 'confirm',
385
444
  name: 'securityAutomation',
386
445
  message: '🛡️ Include security automation (Dependabot + CodeQL)?',
387
- default: true,
446
+ default: seed?.securityAutomation ?? true,
388
447
  },
389
448
  {
390
449
  type: 'confirm',
391
450
  name: 'aiSetup',
392
451
  message: '🤖 Add AI agent rules (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill)?',
393
- default: true,
452
+ default: seed?.aiSetup ?? true,
394
453
  },
395
454
  {
396
455
  type: 'select',
@@ -401,14 +460,14 @@ async function promptForConfig(targetDir) {
401
460
  { name: '🔷 Nx (nx.json)', value: 'nx' },
402
461
  { name: '❌ None', value: 'none' },
403
462
  ],
404
- default: 'turbo',
463
+ default: seededOrchestrator,
405
464
  when: () => hasWorkspace,
406
465
  },
407
466
  {
408
467
  type: 'confirm',
409
468
  name: 'tailwind',
410
469
  message: '🎨 Add Tailwind CSS v4 (PostCSS plugin + globals.css)?',
411
- default: true,
470
+ default: seed?.tailwind ?? true,
412
471
  // Only meaningful for frontend project types.
413
472
  when: (answers) => answers.projectType === 'web-app' ||
414
473
  answers.projectType === 'react-app' ||
@@ -431,13 +490,14 @@ async function promptForConfig(targetDir) {
431
490
  }
432
491
  return choices;
433
492
  },
493
+ default: seed?.bundler,
434
494
  when: (answers) => answers.projectType !== 'nextjs-app', // Next.js has its own bundler
435
495
  },
436
496
  {
437
497
  type: 'confirm',
438
498
  name: 'bun',
439
499
  message: '🥟 Target the Bun runtime (bunfig.toml + Bun-typed tsconfig)?',
440
- default: false,
500
+ default: seed?.bun ?? false,
441
501
  // A runtime flag, not a project type — offer it for Node-compatible
442
502
  // library/API targets, not the browser-framework types.
443
503
  when: (answers) => answers.projectType === 'library' || answers.projectType === 'node-api',
package/dist/cli/index.js CHANGED
@@ -34,6 +34,8 @@ program
34
34
  .option('--config <path>', 'Skip prompts; read a ProjectConfig or .repo-tooling.json lockfile from <path>')
35
35
  .option('--dry-run', 'Print the resolved config and file list, write nothing')
36
36
  .option('--config-schema', 'Print the JSON Schema for ProjectConfig and exit')
37
+ // Seeds the wizard, never skips it (#563) — every answer is still yours.
38
+ .option('--from <owner/repo>', "Seed the wizard's defaults from that repo's .repo-tooling.json instead of the built-in ones (read via `gh`)")
37
39
  .action(setupProject);
38
40
  program
39
41
  .command('copy <config>')
@@ -321,6 +323,9 @@ program
321
323
  // The read side of `fix --skills-dir` (#485). No --yes/--json requirement
322
324
  // here: doctor never writes, so an unresolved directory is just reported.
323
325
  .option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Pass the same path `fix` was given, or the skill reports as not installed')
326
+ // Rules are per-repo (#563): this compares them against another repo's, and
327
+ // only reports. Never drift, never a fixer, never part of the exit code.
328
+ .option('--rules-from <owner/repo>', "Report how this repo's config and rules differ from that repo's .repo-tooling.json (informational; read via `gh`)")
324
329
  .action(doctorCommand);
325
330
  program
326
331
  .command('fix [target]')
@@ -33,7 +33,7 @@ export async function classifyCopiedAssets(dir) {
33
33
  const current = await hashFile(path.join(dir, preset.target));
34
34
  if (current === null)
35
35
  continue;
36
- const recorded = lock?.assets?.[name];
36
+ const recorded = lock?.record.assets?.[name];
37
37
  // Unmodified since the copy, so whether it's stale is purely a question of
38
38
  // what this package ships now. A source we can't read (shouldn't happen)
39
39
  // falls back to the recorded hash — "no news", not drift.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A JSON Schema validator covering exactly the keyword subset `lockfileSchema()`
3
+ * and `CONFIG_SCHEMA` use: type (object/array/string/integer/boolean), enum,
4
+ * minLength, required, properties, additionalProperties (`false` or a schema)
5
+ * and items. Returns one message per problem; an empty array means valid.
6
+ *
7
+ * Hand-rolled because the repo has no JSON Schema validator and the schemas lean
8
+ * on seven keywords — not enough to justify an ajv dependency. It lives in src
9
+ * rather than in a test because a reference repo's lockfile is untrusted input
10
+ * that has to be schema-checked before it is read (#563).
11
+ */
12
+ // ponytail: `format: date-time` is not checked, and neither are composition
13
+ // keywords — none appear in either schema. Reach for ajv the day one does.
14
+ export function validateAgainstSchema(value, schema, path = '$') {
15
+ const errors = [];
16
+ if (schema.enum && !schema.enum.includes(value)) {
17
+ errors.push(`${path}: ${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
18
+ }
19
+ if (schema.type === 'array') {
20
+ if (!Array.isArray(value))
21
+ return [`${path}: expected array`];
22
+ if (schema.items) {
23
+ for (const [i, item] of value.entries()) {
24
+ errors.push(...validateAgainstSchema(item, schema.items, `${path}[${i}]`));
25
+ }
26
+ }
27
+ return errors;
28
+ }
29
+ if (schema.type === 'object') {
30
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
31
+ return [`${path}: expected object`];
32
+ }
33
+ const obj = value;
34
+ for (const key of schema.required ?? []) {
35
+ if (!(key in obj))
36
+ errors.push(`${path}: missing required property "${key}"`);
37
+ }
38
+ for (const [key, child] of Object.entries(obj)) {
39
+ const property = schema.properties?.[key];
40
+ if (property)
41
+ errors.push(...validateAgainstSchema(child, property, `${path}.${key}`));
42
+ else if (schema.additionalProperties === false) {
43
+ errors.push(`${path}: unknown property "${key}"`);
44
+ }
45
+ else if (typeof schema.additionalProperties === 'object') {
46
+ errors.push(...validateAgainstSchema(child, schema.additionalProperties, `${path}.${key}`));
47
+ }
48
+ }
49
+ return errors;
50
+ }
51
+ if (schema.type === 'integer') {
52
+ if (!Number.isInteger(value))
53
+ errors.push(`${path}: expected integer`);
54
+ }
55
+ else if (schema.type && typeof value !== schema.type) {
56
+ errors.push(`${path}: expected ${schema.type}, got ${typeof value}`);
57
+ }
58
+ if (schema.minLength !== undefined &&
59
+ typeof value === 'string' &&
60
+ value.length < schema.minLength) {
61
+ errors.push(`${path}: shorter than minLength ${schema.minLength}`);
62
+ }
63
+ return errors;
64
+ }
@@ -16,7 +16,10 @@ export const LEGACY_LOCKFILE_NAME = `.${LEGACY_TOOL_NAME}.json`;
16
16
  // migrated to v2 on read, defaulting language to 'js'.
17
17
  // v3 added `assets` — the pristine hash of each copied preset (#428). Older
18
18
  // files carry no hashes, which reads as "not tracked", never as drift.
19
- export const LOCKFILE_VERSION = 3;
19
+ // v4 split the file into two subtrees with documented ownership (#559):
20
+ // `record` (tool-written, stamped) and `rules` (human-written, unstamped).
21
+ // Nothing was renamed or dropped — the flat v3 fields just moved into them.
22
+ export const LOCKFILE_VERSION = 4;
20
23
  const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/lockfile.json';
21
24
  /**
22
25
  * How much of the repo's workflow assumes a recommended MCP server (#534).
@@ -48,10 +51,10 @@ export function lockfileSchema() {
48
51
  $schema: 'https://json-schema.org/draft/2020-12/schema',
49
52
  $id: LOCKFILE_SCHEMA_URL,
50
53
  title: 'Lockfile',
51
- description: `${LOCKFILE_NAME} — the committed record of what @rtorcato/repo-tooling set up in this repo. Written by \`setup\` and \`fix\`, read by \`doctor\`.`,
54
+ description: `${LOCKFILE_NAME} — two documents sharing one file: \`record\` is written by @rtorcato/repo-tooling (\`setup\` and \`fix\`) and stamped with provenance; \`rules\` is written by humans, reviewed in PRs, and never stamped. Both are read by \`doctor\`.`,
52
55
  type: 'object',
53
56
  additionalProperties: false,
54
- required: ['version', 'config', 'writtenBy', 'writtenAt'],
57
+ required: ['version', 'record'],
55
58
  properties: {
56
59
  $schema: {
57
60
  type: 'string',
@@ -59,78 +62,93 @@ export function lockfileSchema() {
59
62
  },
60
63
  version: {
61
64
  type: 'integer',
62
- description: `Lockfile format version (current: ${LOCKFILE_VERSION}). v2 added config.language, v3 added assets; older files are migrated on read.`,
65
+ description: `Lockfile format version (current: ${LOCKFILE_VERSION}). v2 added config.language, v3 added assets, v4 split the file into record/rules subtrees; older files are migrated on read.`,
63
66
  },
64
- config: {
65
- ...projectConfigSchema,
66
- description: 'The resolved setup configuration this repo was scaffolded or audited with.',
67
- },
68
- assets: {
69
- type: 'object',
70
- additionalProperties: { type: 'string' },
71
- description: "Preset name → sha256 of the asset's pristine content at copy time. Lets doctor tell a deliberate local fork (file differs from this hash) from a copy the package has since moved past (file still matches, shipped asset doesn't). A preset with no entry is untracked, never drifted.",
72
- },
73
- aiLoop: {
67
+ record: {
74
68
  type: 'object',
75
69
  additionalProperties: false,
76
- description: 'Settings for the ai-issue-loop skills. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
70
+ required: ['config', 'writtenBy', 'writtenAt'],
71
+ description: 'The tool-written record of what setup/fix last did. Only the tool writes here — the writtenBy/writtenAt stamps are provenance claims about exactly this subtree.',
77
72
  properties: {
78
- agentUser: {
73
+ config: {
74
+ ...projectConfigSchema,
75
+ description: 'The resolved setup configuration this repo was scaffolded or audited with.',
76
+ },
77
+ assets: {
78
+ type: 'object',
79
+ additionalProperties: { type: 'string' },
80
+ description: "Preset name → sha256 of the asset's pristine content at copy time. Lets doctor tell a deliberate local fork (file differs from this hash) from a copy the package has since moved past (file still matches, shipped asset doesn't). A preset with no entry is untracked, never drifted.",
81
+ },
82
+ writtenBy: {
79
83
  type: 'string',
80
- description: 'Login that in-flight work is assigned to, so `assignee` says whose turn it is. Must be an assignable collaborator; the skills verify that at runtime.',
84
+ description: 'Package name and version that last wrote the record subtree.',
85
+ },
86
+ writtenAt: {
87
+ type: 'string',
88
+ format: 'date-time',
89
+ description: 'ISO 8601 timestamp of the last record write.',
81
90
  },
82
91
  },
83
92
  },
84
- requiredSkills: {
85
- type: 'array',
86
- items: { type: 'string', enum: SHIPPED_SKILLS },
87
- description: 'Agent skills this repo\'s workflows depend on. doctor compares each installed copy\'s stamped hash against the shipped one and reports a missing or stale skill — always as "not configured", never drift, because it probes the machine rather than the repo. It never runs the fixer for you.',
88
- },
89
- mcp: {
93
+ rules: {
90
94
  type: 'object',
91
95
  additionalProperties: false,
92
- description: "Advisory MCP metadata: names, importance and reasons only, never an install directive. Executable server config belongs in the native .mcp.json, which carries Claude Code's own first-use consent prompt.",
96
+ description: "The human-written ruleset: the repo's stated intent, edited by hand and reviewed in PRs. The tool carries it forward verbatim on every write and never stamps it.",
93
97
  properties: {
94
- recommended: {
98
+ aiLoop: {
99
+ type: 'object',
100
+ additionalProperties: false,
101
+ description: 'Settings for the ai-issue-loop skills. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
102
+ properties: {
103
+ agentUser: {
104
+ type: 'string',
105
+ description: 'Login that in-flight work is assigned to, so `assignee` says whose turn it is. Must be an assignable collaborator; the skills verify that at runtime.',
106
+ },
107
+ },
108
+ },
109
+ requiredSkills: {
95
110
  type: 'array',
96
- description: "MCP servers this repo's workflow assumes. doctor reports which of them .mcp.json does not declare, informationally — it never installs or enables one.",
97
- items: {
98
- type: 'object',
99
- additionalProperties: false,
100
- required: ['name', 'importance', 'why'],
101
- properties: {
102
- name: {
103
- type: 'string',
104
- description: 'The server name as it would appear in .mcp.json.',
105
- },
106
- importance: {
107
- type: 'string',
108
- enum: MCP_IMPORTANCE,
109
- description: "How much of the repo's workflow assumes the server.",
110
- },
111
- why: {
112
- type: 'string',
113
- description: 'One line on what the server is for — the thing .mcp.json structurally cannot say.',
111
+ items: { type: 'string', enum: SHIPPED_SKILLS },
112
+ description: 'Agent skills this repo\'s workflows depend on. doctor compares each installed copy\'s stamped hash against the shipped one and reports a missing or stale skill — always as "not configured", never drift, because it probes the machine rather than the repo. It never runs the fixer for you.',
113
+ },
114
+ mcp: {
115
+ type: 'object',
116
+ additionalProperties: false,
117
+ description: "Advisory MCP metadata: names, importance and reasons only, never an install directive. Executable server config belongs in the native .mcp.json, which carries Claude Code's own first-use consent prompt.",
118
+ properties: {
119
+ recommended: {
120
+ type: 'array',
121
+ description: "MCP servers this repo's workflow assumes. doctor reports which of them .mcp.json does not declare, informationally — it never installs or enables one.",
122
+ items: {
123
+ type: 'object',
124
+ additionalProperties: false,
125
+ required: ['name', 'importance', 'why'],
126
+ properties: {
127
+ name: {
128
+ type: 'string',
129
+ description: 'The server name as it would appear in .mcp.json.',
130
+ },
131
+ importance: {
132
+ type: 'string',
133
+ enum: MCP_IMPORTANCE,
134
+ description: "How much of the repo's workflow assumes the server.",
135
+ },
136
+ why: {
137
+ type: 'string',
138
+ description: 'One line on what the server is for — the thing .mcp.json structurally cannot say.',
139
+ },
140
+ },
114
141
  },
115
142
  },
116
143
  },
117
144
  },
145
+ exceptions: {
146
+ type: 'object',
147
+ additionalProperties: { type: 'string', minLength: 1 },
148
+ description: 'Declared exceptions: doctor check name → the reason this repo deliberately deviates. The reason is mandatory and non-empty — doctor shows the check as `declared` with it (never hidden) and stops failing the run for it. An entry naming a check doctor does not run is itself reported as drift.',
149
+ },
118
150
  },
119
151
  },
120
- exceptions: {
121
- type: 'object',
122
- additionalProperties: { type: 'string', minLength: 1 },
123
- description: 'Declared exceptions: doctor check name → the reason this repo deliberately deviates. The reason is mandatory and non-empty — doctor shows the check as `declared` with it (never hidden) and stops failing the run for it. An entry naming a check doctor does not run is itself reported as drift.',
124
- },
125
- writtenBy: {
126
- type: 'string',
127
- description: 'Package name and version that last wrote this file.',
128
- },
129
- writtenAt: {
130
- type: 'string',
131
- format: 'date-time',
132
- description: 'ISO 8601 timestamp of the last write.',
133
- },
134
152
  },
135
153
  };
136
154
  }
@@ -139,17 +157,53 @@ export function lockfileSchema() {
139
157
  * current version, so a newer-than-supported file is left as-is for
140
158
  * checkLockfile to flag. `version` stays at the on-disk value — bumping it here
141
159
  * hid every older file from doctor's older-than-current check (#531); the write
142
- * path stamps LOCKFILE_VERSION anyway, so the file is v3 next time it's saved.
160
+ * path stamps LOCKFILE_VERSION anyway, so the file is v4 next time it's saved.
161
+ *
162
+ * v1–v3 are flat: nest the fields into record/rules (#559), default language
163
+ * to 'js' (v1, #140) and assets to {} (pre-v3, #428). Nothing is renamed.
143
164
  */
144
- function migrate(lock) {
145
- if (lock.version >= LOCKFILE_VERSION)
146
- return lock;
165
+ function migrate(raw) {
166
+ if (raw.version >= LOCKFILE_VERSION)
167
+ return raw;
168
+ const flat = raw;
169
+ const rules = {
170
+ ...(flat.aiLoop ? { aiLoop: flat.aiLoop } : {}),
171
+ ...(flat.requiredSkills ? { requiredSkills: flat.requiredSkills } : {}),
172
+ ...(flat.mcp ? { mcp: flat.mcp } : {}),
173
+ ...(flat.exceptions ? { exceptions: flat.exceptions } : {}),
174
+ };
147
175
  return {
148
- ...lock,
149
- config: { language: 'js', ...lock.config },
150
- assets: lock.assets ?? {},
176
+ ...(flat.$schema ? { $schema: flat.$schema } : {}),
177
+ version: flat.version,
178
+ record: {
179
+ config: { language: 'js', ...flat.config },
180
+ assets: flat.assets ?? {},
181
+ writtenBy: flat.writtenBy,
182
+ writtenAt: flat.writtenAt,
183
+ },
184
+ ...(Object.keys(rules).length > 0 ? { rules } : {}),
151
185
  };
152
186
  }
187
+ /**
188
+ * Normalize already-parsed JSON into a Lockfile, or null when it isn't one.
189
+ * Split out of readLockfile so a lockfile fetched from somewhere other than the
190
+ * filesystem — a reference repo, over `gh` — goes through the same migration
191
+ * before anything reads it (#563).
192
+ */
193
+ export function parseLockfile(raw) {
194
+ if (typeof raw !== 'object' || raw === null)
195
+ return null;
196
+ const obj = raw;
197
+ if (typeof obj.version !== 'number')
198
+ return null;
199
+ // v4+ keeps config under `record`; v1–v3 keep it at the top level (#559).
200
+ const config = obj.version >= LOCKFILE_VERSION
201
+ ? obj.record?.config
202
+ : obj.config;
203
+ if (typeof config !== 'object' || config === null)
204
+ return null;
205
+ return migrate(obj);
206
+ }
153
207
  export async function readLockfile(dir) {
154
208
  let filepath = path.join(dir, LOCKFILE_NAME);
155
209
  if (!(await fs.pathExists(filepath))) {
@@ -160,15 +214,7 @@ export async function readLockfile(dir) {
160
214
  filepath = legacy;
161
215
  }
162
216
  try {
163
- const raw = (await fs.readJson(filepath));
164
- if (typeof raw !== 'object' || raw === null)
165
- return null;
166
- const obj = raw;
167
- if (typeof obj.version !== 'number')
168
- return null;
169
- if (typeof obj.config !== 'object' || obj.config === null)
170
- return null;
171
- return migrate(obj);
217
+ return parseLockfile((await fs.readJson(filepath)));
172
218
  }
173
219
  catch {
174
220
  return null;
@@ -186,21 +232,21 @@ export async function writeLockfile(dir, config, assets) {
186
232
  }
187
233
  // One read, because everything not rebuilt from `config` has to be carried
188
234
  // forward explicitly — this object is constructed from scratch, so any key
189
- // not named here is dropped by the next `fix lockfile`.
235
+ // not named here is dropped by the next `fix lockfile`. `rules` rides along
236
+ // verbatim: it is the human's subtree, and the stamp says nothing about it.
190
237
  const existing = await readLockfile(dir);
191
- const carried = assets ?? existing?.assets;
238
+ const carried = assets ?? existing?.record.assets;
192
239
  const filepath = path.join(dir, LOCKFILE_NAME);
193
240
  const lockfile = {
194
241
  $schema: LOCKFILE_SCHEMA_URL,
195
242
  version: LOCKFILE_VERSION,
196
- config,
197
- ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
198
- ...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
199
- ...(existing?.requiredSkills ? { requiredSkills: existing.requiredSkills } : {}),
200
- ...(existing?.mcp ? { mcp: existing.mcp } : {}),
201
- ...(existing?.exceptions ? { exceptions: existing.exceptions } : {}),
202
- writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
203
- writtenAt: new Date().toISOString(),
243
+ record: {
244
+ config,
245
+ ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
246
+ writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
247
+ writtenAt: new Date().toISOString(),
248
+ },
249
+ ...(existing?.rules ? { rules: existing.rules } : {}),
204
250
  };
205
251
  await fs.writeJson(filepath, lockfile, { spaces: 2 });
206
252
  // Migrate a pre-rename repo to the new name: now that the canonical file is
@@ -218,7 +264,7 @@ export async function updateLockfileConfig(dir, patch) {
218
264
  const existing = await readLockfile(dir);
219
265
  if (!existing)
220
266
  return false;
221
- const merged = { ...existing.config, ...patch };
267
+ const merged = { ...existing.record.config, ...patch };
222
268
  await writeLockfile(dir, merged);
223
269
  return true;
224
270
  }
@@ -232,6 +278,6 @@ export async function recordAssetHash(dir, preset, hash) {
232
278
  const existing = await readLockfile(dir);
233
279
  if (!existing)
234
280
  return false;
235
- await writeLockfile(dir, existing.config, { ...existing.assets, [preset]: hash });
281
+ await writeLockfile(dir, existing.record.config, { ...existing.record.assets, [preset]: hash });
236
282
  return true;
237
283
  }
@@ -0,0 +1,125 @@
1
+ import { realGhExec } from '../../base/github-settings.js';
2
+ import { validateAgainstSchema } from './json-schema.js';
3
+ import { LOCKFILE_NAME, lockfileSchema, parseLockfile, readLockfile, } from './lockfile.js';
4
+ /**
5
+ * Rules are per-repo (#563): there is no guideline package, so sharing a
6
+ * guideline means pointing at another repo. This module is the one fetcher both
7
+ * halves of that share — `doctor --rules-from` reads a reference repo's rules to
8
+ * report differences, and `setup --from` reads the same file to seed the wizard.
9
+ *
10
+ * The reference is untrusted input from end to end: the `owner/repo` string is
11
+ * shape-checked before it reaches an API path, the response is size-capped, and
12
+ * the JSON is validated against the published schema before a single field is
13
+ * read. Nothing here ever writes, and nothing is ever applied.
14
+ */
15
+ /**
16
+ * `owner/repo`. The injection boundary — the reference is interpolated into the
17
+ * gh API path below, so anything with a slash, `..` or a shell metacharacter in
18
+ * it has to be rejected here rather than defended against later.
19
+ */
20
+ const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
21
+ /**
22
+ * Generous by four orders of magnitude — a real lockfile is ~1 KB. This is not a
23
+ * tuning knob, it is the "huge file" guard: the response is fully buffered by the
24
+ * time we see it, so the cap stops us parsing and diffing a repo's 40 MB joke.
25
+ */
26
+ const MAX_BYTES = 128 * 1024;
27
+ /**
28
+ * The GitHub arm of the fetch. Another forge is a second function of this shape
29
+ * dispatched from fetchReferenceLockfile — everything downstream (size cap,
30
+ * parse, schema validation, diff) is forge-agnostic and already shared.
31
+ */
32
+ async function fetchFromGitHub(reference, exec) {
33
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin));
34
+ // `Accept: raw` returns the file itself rather than the base64-in-JSON envelope.
35
+ const r = await gh([
36
+ 'api',
37
+ `repos/${reference}/contents/${LOCKFILE_NAME}`,
38
+ '-H',
39
+ 'Accept: application/vnd.github.raw',
40
+ ]);
41
+ if (r.ok)
42
+ return { ok: true, text: r.stdout };
43
+ if (/HTTP 404/.test(r.stderr)) {
44
+ return { ok: false, reason: `${reference} has no ${LOCKFILE_NAME} (or is not visible to you)` };
45
+ }
46
+ const detail = r.stderr.trim().split('\n').pop() ?? 'gh failed';
47
+ return { ok: false, reason: `could not read ${reference}: ${detail}` };
48
+ }
49
+ function parseReference(reference, text) {
50
+ if (Buffer.byteLength(text) > MAX_BYTES) {
51
+ return {
52
+ ok: false,
53
+ reason: `${reference}'s ${LOCKFILE_NAME} is larger than ${MAX_BYTES} bytes`,
54
+ };
55
+ }
56
+ let raw;
57
+ try {
58
+ raw = JSON.parse(text);
59
+ }
60
+ catch {
61
+ return { ok: false, reason: `${reference}'s ${LOCKFILE_NAME} is not valid JSON` };
62
+ }
63
+ // Migrate first, then validate: an older reference is flat on disk and would
64
+ // fail the current schema for a reason that says nothing about its rules.
65
+ const lockfile = parseLockfile(raw);
66
+ if (!lockfile) {
67
+ return { ok: false, reason: `${reference}'s ${LOCKFILE_NAME} is not a recognisable lockfile` };
68
+ }
69
+ const errors = validateAgainstSchema(lockfile, lockfileSchema());
70
+ if (errors.length > 0) {
71
+ return {
72
+ ok: false,
73
+ reason: `${reference}'s ${LOCKFILE_NAME} fails the published schema: ${errors.slice(0, 3).join('; ')}`,
74
+ };
75
+ }
76
+ return { ok: true, lockfile };
77
+ }
78
+ export async function fetchReferenceLockfile(reference, exec) {
79
+ if (!REFERENCE.test(reference)) {
80
+ return { ok: false, reason: `"${reference}" is not an owner/repo reference` };
81
+ }
82
+ const fetched = await fetchFromGitHub(reference, exec);
83
+ if (!fetched.ok)
84
+ return fetched;
85
+ return parseReference(reference, fetched.text);
86
+ }
87
+ /**
88
+ * The comparable half of a lockfile: the resolved config plus the human-written
89
+ * rules. `assets` hashes and the `writtenBy`/`writtenAt` stamps are excluded on
90
+ * purpose — they differ between any two repos by construction, so diffing them
91
+ * would bury every difference that means something.
92
+ */
93
+ function rulesView(lock) {
94
+ return { config: lock.record.config, ...(lock.rules ?? {}) };
95
+ }
96
+ function flatten(value, prefix = '', out = new Map()) {
97
+ // Arrays are leaves: `requiredSkills` differing by one entry is one difference
98
+ // about the list, not a per-index report that shifts when the order does.
99
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
100
+ for (const [key, child] of Object.entries(value)) {
101
+ flatten(child, prefix ? `${prefix}.${key}` : key, out);
102
+ }
103
+ return out;
104
+ }
105
+ out.set(prefix, value);
106
+ return out;
107
+ }
108
+ export function diffRules(local, reference) {
109
+ const mine = local ? flatten(rulesView(local)) : new Map();
110
+ const theirs = flatten(rulesView(reference));
111
+ return [...new Set([...mine.keys(), ...theirs.keys()])]
112
+ .sort()
113
+ .filter((p) => JSON.stringify(mine.get(p)) !== JSON.stringify(theirs.get(p)))
114
+ .map((p) => ({ path: p, local: mine.get(p), reference: theirs.get(p) }));
115
+ }
116
+ export async function compareRulesWithReference(dir, reference, exec) {
117
+ const result = await fetchReferenceLockfile(reference, exec);
118
+ if (!result.ok)
119
+ return { reference, compared: false, reason: result.reason };
120
+ return {
121
+ reference,
122
+ compared: true,
123
+ differences: diffRules(await readLockfile(dir), result.lockfile),
124
+ };
125
+ }
@@ -864,10 +864,10 @@ export const FIXERS = [
864
864
  console.error(chalk.yellow(' no package.json found — skipping'));
865
865
  return { filesWritten: [] };
866
866
  }
867
- const config = lock ? lock.config : inferProjectConfig(pkg);
867
+ const config = lock ? lock.record.config : inferProjectConfig(pkg);
868
868
  // Recorded hashes win: they capture the pristine content at copy time,
869
869
  // which a byte-match against today's shipped asset can only approximate.
870
- const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.assets };
870
+ const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.record.assets };
871
871
  await writeLockfile(targetDir, config, assets);
872
872
  return { filesWritten: [LOCKFILE_NAME] };
873
873
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.28.0",
3
+ "version": "3.30.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": [
@@ -219,8 +219,9 @@ behaves exactly as it did before this existed.
219
219
 
220
220
  ```bash
221
221
  # Repo config first — committed, so it travels with the repo and survives a new
222
- # machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile.
223
- AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
222
+ # machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile. The flat
223
+ # `.aiLoop` fallback reads a pre-v4 lockfile that hasn't migrated yet (#559).
224
+ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
224
225
  # A typo would fail every `gh` edit for the whole tick, so prove it is assignable
225
226
  # once, here. 204 = yes, 404 = no; push access is what qualifies an account.
226
227
  [ -n "$AGENT_USER" ] && { gh api "repos/$OWNER_REPO/assignees/$AGENT_USER" --silent 2>/dev/null || {
@@ -235,7 +236,7 @@ being that assignment quietly stops. In `.repo-tooling.json` it is committed,
235
236
  reviewable, and carried forward by `fix lockfile`:
236
237
 
237
238
  ```json
238
- { "aiLoop": { "agentUser": "your-bot-account" } }
239
+ { "rules": { "aiLoop": { "agentUser": "your-bot-account" } } }
239
240
  ```
240
241
 
241
242
  Every later use is `${AGENT_USER:+--add-assignee "$AGENT_USER"}`, which expands
@@ -53,7 +53,7 @@ git -C "$ROOT" fetch --prune
53
53
  # Optional: the account in-flight work is assigned to, so `assignee` says whose
54
54
  # turn it is. Unset → nothing below assigns, exactly as before. See the
55
55
  # ai-issue-loop skill's Pass 0 for why this is repo config rather than an env var.
56
- AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
56
+ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
57
57
  [ -n "$AGENT_USER" ] && { gh api "repos/$R/assignees/$AGENT_USER" --silent 2>/dev/null || AGENT_USER=""; }
58
58
  ```
59
59
 
@@ -30,7 +30,7 @@ npx @rtorcato/repo-tooling doctor --json # confirm clean
30
30
  prompt to **No**; `--yes` is required to overwrite. Show `fix <target> --diff` first.
31
31
  - `missing` — required and absent → fix it.
32
32
  - `optional-missing` — opt-in tool not configured. Only fix if the user wants that tool.
33
- - `declared` — a real deviation the repo's `.repo-tooling.json` `exceptions` records on
33
+ - `declared` — a real deviation the repo's `.repo-tooling.json` `rules.exceptions` records on
34
34
  purpose, with its reason. Leave it alone; it doesn't fail the run.
35
35
 
36
36
  `fix` returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`.