@rtorcato/repo-tooling 3.27.0 → 3.29.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.
@@ -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.
@@ -149,3 +149,72 @@ export async function checkGitIdentity(dir, exec) {
149
149
  hint: HINT,
150
150
  };
151
151
  }
152
+ const HISTORY_CHECK = 'Git author history';
153
+ /**
154
+ * Bounded so doctor stays cheap on every invocation; the output always names
155
+ * how many commits were actually examined so "0 found" is never read as "all
156
+ * history is clean".
157
+ */
158
+ export const HISTORY_SCAN_LIMIT = 200;
159
+ /** How many offending commits to name; the count carries the rest. */
160
+ const HISTORY_SAMPLE = 3;
161
+ const HISTORY_HINT = 'These commits are already made: the address can never be verified as a secondary email, so they cannot be re-linked to a forge account without a history rewrite (#327). Fix the identity going forward (see the Git identity check) and treat the history as recorded loss.';
162
+ /**
163
+ * The retrospective half of `checkGitIdentity` (#557): that check reads the
164
+ * identity the *next* commit will use, this one scans recent history for
165
+ * commits already made with a bad one. Same classifier — `classifyGitEmail` —
166
+ * so the two can never drift. Same STATUS reasoning too: history is not
167
+ * something the repo's author can fix by editing the repo, so a finding is
168
+ * `optional-missing`, never `drift`/`missing`, and the exit code is untouched.
169
+ * Detection only — there is deliberately no fixer, because the only "fix" is a
170
+ * history rewrite no tool should offer unprompted.
171
+ */
172
+ export async function checkGitIdentityHistory(dir, exec) {
173
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
174
+ return { check: HISTORY_CHECK, status: 'ok', detail: 'not a git repository' };
175
+ }
176
+ if (process.env.CI) {
177
+ return { check: HISTORY_CHECK, status: 'ok', detail: 'skipped on CI' };
178
+ }
179
+ const git = exec ?? ((args) => realGitExec(args, dir));
180
+ // A shallow clone has almost no history; scanning its stub and reporting
181
+ // "0 found" would be a false all-clear.
182
+ if ((await git(['rev-parse', '--is-shallow-repository'])) === 'true') {
183
+ return {
184
+ check: HISTORY_CHECK,
185
+ status: 'ok',
186
+ detail: 'shallow clone — commit history is not available to scan',
187
+ };
188
+ }
189
+ // %h %ae: abbreviated hash + author email, one commit per line.
190
+ const log = await git(['log', `-${HISTORY_SCAN_LIMIT}`, '--format=%h %ae']);
191
+ if (log === null || log === '') {
192
+ return { check: HISTORY_CHECK, status: 'ok', detail: 'no commits to scan' };
193
+ }
194
+ const commits = log.split('\n').map((line) => {
195
+ const sp = line.indexOf(' ');
196
+ return { hash: line.slice(0, sp), email: line.slice(sp + 1) };
197
+ });
198
+ const bad = commits.filter(({ email }) => {
199
+ const verdict = classifyGitEmail(email);
200
+ return verdict === 'placeholder' || verdict === 'generated';
201
+ });
202
+ const scanned = `the last ${commits.length} commit${commits.length === 1 ? '' : 's'}`;
203
+ if (bad.length === 0) {
204
+ return {
205
+ check: HISTORY_CHECK,
206
+ status: 'ok',
207
+ detail: `no placeholder or machine-derived author emails in ${scanned}`,
208
+ };
209
+ }
210
+ const sample = bad
211
+ .slice(0, HISTORY_SAMPLE)
212
+ .map(({ hash, email }) => `${hash} (${email})`)
213
+ .join(', ');
214
+ return {
215
+ check: HISTORY_CHECK,
216
+ status: 'optional-missing',
217
+ detail: `${bad.length} of ${scanned} carry a placeholder or machine-derived author email — e.g. ${sample}`,
218
+ hint: HISTORY_HINT,
219
+ };
220
+ }
@@ -16,7 +16,7 @@ import { checkAgentUser } from '../../base/agent-user.js';
16
16
  import { checkGitHubSettings } from '../../base/github-settings.js';
17
17
  import { checkLoopLabels } from '../../base/labels.js';
18
18
  import { checkMilestones } from '../../base/milestones.js';
19
- import { checkGitIdentity } from '../../base/git-identity.js';
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
22
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
@@ -136,7 +136,9 @@ function checkLockfile(lock) {
136
136
  return {
137
137
  check: 'lockfile',
138
138
  status: 'ok',
139
- detail: `.repo-tooling.json v${lock.version} (written by ${lock.writtenBy})`,
139
+ // The stamp only ever describes the tool-written `record` subtree — the
140
+ // human-written `rules` half is deliberately unstamped (#559).
141
+ detail: `.repo-tooling.json v${lock.version} (record written by ${lock.record.writtenBy})`,
140
142
  };
141
143
  }
142
144
  // Lockfile-driven demotion: if the lock records an intentional opt-out for a
@@ -162,7 +164,7 @@ function demoteDeclined(results, lock) {
162
164
  // otherwise a typo silently does nothing and a check rename silently
163
165
  // un-suppresses a finding, and both are invisible.
164
166
  function applyExceptions(results, lock) {
165
- const exceptions = lock?.exceptions;
167
+ const exceptions = lock?.rules?.exceptions;
166
168
  if (!exceptions)
167
169
  return results;
168
170
  const known = new Set(results.map((r) => r.check));
@@ -202,6 +204,8 @@ async function runBaseChecks(dir, lock, opts) {
202
204
  results.push(await checkCopiedAssets(dir));
203
205
  results.push(await checkNestedLanguages(dir, opts.language));
204
206
  results.push(await checkGitIdentity(dir));
207
+ // The retrospective half (#557): commits already made with a bad identity.
208
+ results.push(await checkGitIdentityHistory(dir));
205
209
  results.push(await checkEditorConfig(dir));
206
210
  results.push(await checkFile(dir, COMMITLINT_FILE_CHECK));
207
211
  if (opts.hooks) {
@@ -219,7 +223,7 @@ async function runBaseChecks(dir, lock, opts) {
219
223
  // ai-issue-loop label colours/descriptions (#446) — same seam, same self-skip.
220
224
  results.push(await checkLoopLabels(dir));
221
225
  // aiLoop.agentUser assignability (#530) — same seam, same self-skip.
222
- results.push(await checkAgentUser(dir, lock?.aiLoop?.agentUser));
226
+ results.push(await checkAgentUser(dir, lock?.rules?.aiLoop?.agentUser));
223
227
  results.push(await checkGitLabCI(dir));
224
228
  results.push(await checkCodeowners(dir));
225
229
  results.push(await checkCommunityHealth(dir));
@@ -229,13 +233,13 @@ async function runBaseChecks(dir, lock, opts) {
229
233
  results.push(await checkClaudeSkills(opts.skillsDir));
230
234
  // #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
231
235
  // signal, so a repo that doesn't gets no line at all rather than an empty one.
232
- if (lock?.aiLoop && lock.requiredSkills?.length) {
233
- results.push(await checkRequiredSkills(lock.requiredSkills, opts.skillsDir));
236
+ if (lock?.rules?.aiLoop && lock.rules.requiredSkills?.length) {
237
+ results.push(await checkRequiredSkills(lock.rules.requiredSkills, opts.skillsDir));
234
238
  }
235
239
  // #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
236
240
  // about MCP, which is not a finding.
237
- if (lock?.mcp?.recommended?.length) {
238
- results.push(await checkRecommendedMcp(dir, lock.mcp.recommended));
241
+ if (lock?.rules?.mcp?.recommended?.length) {
242
+ results.push(await checkRecommendedMcp(dir, lock.rules.mcp.recommended));
239
243
  }
240
244
  results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
241
245
  results.push(await checkCoverageUpload(dir));
@@ -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
  }
@@ -76,10 +76,12 @@ async function resolveConfig(options) {
76
76
  const raw = await fs.readJson(configPath);
77
77
  // Accept the `.repo-tooling.json` lockfile itself as the config source, so a
78
78
  // 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
79
+ // ProjectConfig — under `record.config` since v4, top-level `config` before
80
+ // (#559). Unwrap it here rather than requiring a separate config file (#271).
81
+ const candidate = typeof raw === 'object' && raw !== null && 'version' in raw
82
+ ? (raw.record?.config ??
83
+ raw.config ??
84
+ raw)
83
85
  : raw;
84
86
  const { valid, errors } = validateProjectConfig(candidate);
85
87
  if (!valid) {
@@ -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.
@@ -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: {
83
+ type: 'string',
84
+ description: 'Package name and version that last wrote the record subtree.',
85
+ },
86
+ writtenAt: {
79
87
  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.',
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,15 +157,31 @@ 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
  }
153
187
  export async function readLockfile(dir) {
@@ -166,7 +200,11 @@ export async function readLockfile(dir) {
166
200
  const obj = raw;
167
201
  if (typeof obj.version !== 'number')
168
202
  return null;
169
- if (typeof obj.config !== 'object' || obj.config === null)
203
+ // v4+ keeps config under `record`; v1–v3 keep it at the top level (#559).
204
+ const config = obj.version >= LOCKFILE_VERSION
205
+ ? obj.record?.config
206
+ : obj.config;
207
+ if (typeof config !== 'object' || config === null)
170
208
  return null;
171
209
  return migrate(obj);
172
210
  }
@@ -186,21 +224,21 @@ export async function writeLockfile(dir, config, assets) {
186
224
  }
187
225
  // One read, because everything not rebuilt from `config` has to be carried
188
226
  // forward explicitly — this object is constructed from scratch, so any key
189
- // not named here is dropped by the next `fix lockfile`.
227
+ // not named here is dropped by the next `fix lockfile`. `rules` rides along
228
+ // verbatim: it is the human's subtree, and the stamp says nothing about it.
190
229
  const existing = await readLockfile(dir);
191
- const carried = assets ?? existing?.assets;
230
+ const carried = assets ?? existing?.record.assets;
192
231
  const filepath = path.join(dir, LOCKFILE_NAME);
193
232
  const lockfile = {
194
233
  $schema: LOCKFILE_SCHEMA_URL,
195
234
  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(),
235
+ record: {
236
+ config,
237
+ ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
238
+ writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
239
+ writtenAt: new Date().toISOString(),
240
+ },
241
+ ...(existing?.rules ? { rules: existing.rules } : {}),
204
242
  };
205
243
  await fs.writeJson(filepath, lockfile, { spaces: 2 });
206
244
  // Migrate a pre-rename repo to the new name: now that the canonical file is
@@ -218,7 +256,7 @@ export async function updateLockfileConfig(dir, patch) {
218
256
  const existing = await readLockfile(dir);
219
257
  if (!existing)
220
258
  return false;
221
- const merged = { ...existing.config, ...patch };
259
+ const merged = { ...existing.record.config, ...patch };
222
260
  await writeLockfile(dir, merged);
223
261
  return true;
224
262
  }
@@ -232,6 +270,6 @@ export async function recordAssetHash(dir, preset, hash) {
232
270
  const existing = await readLockfile(dir);
233
271
  if (!existing)
234
272
  return false;
235
- await writeLockfile(dir, existing.config, { ...existing.assets, [preset]: hash });
273
+ await writeLockfile(dir, existing.record.config, { ...existing.record.assets, [preset]: hash });
236
274
  return true;
237
275
  }
@@ -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.27.0",
3
+ "version": "3.29.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`.