@rtorcato/repo-tooling 3.28.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.
- package/dist/base/agent-user.js +6 -6
- package/dist/cli/commands/doctor.js +9 -7
- package/dist/cli/commands/fix-targets.js +2 -2
- package/dist/cli/commands/fix.js +3 -3
- package/dist/cli/commands/setup.js +6 -4
- package/dist/cli/utils/copied-assets.js +1 -1
- package/dist/cli/utils/lockfile.js +115 -77
- package/dist/languages/js/fixers.js +2 -2
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +4 -3
- package/skills/ai-workflow/SKILL.md +1 -1
- package/skills/repo-tooling/SKILL.md +1 -1
package/dist/base/agent-user.js
CHANGED
|
@@ -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.
|
|
@@ -136,7 +136,9 @@ function checkLockfile(lock) {
|
|
|
136
136
|
return {
|
|
137
137
|
check: 'lockfile',
|
|
138
138
|
status: 'ok',
|
|
139
|
-
|
|
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));
|
|
@@ -221,7 +223,7 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
221
223
|
// ai-issue-loop label colours/descriptions (#446) — same seam, same self-skip.
|
|
222
224
|
results.push(await checkLoopLabels(dir));
|
|
223
225
|
// aiLoop.agentUser assignability (#530) — same seam, same self-skip.
|
|
224
|
-
results.push(await checkAgentUser(dir, lock?.aiLoop?.agentUser));
|
|
226
|
+
results.push(await checkAgentUser(dir, lock?.rules?.aiLoop?.agentUser));
|
|
225
227
|
results.push(await checkGitLabCI(dir));
|
|
226
228
|
results.push(await checkCodeowners(dir));
|
|
227
229
|
results.push(await checkCommunityHealth(dir));
|
|
@@ -231,13 +233,13 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
231
233
|
results.push(await checkClaudeSkills(opts.skillsDir));
|
|
232
234
|
// #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
|
|
233
235
|
// 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));
|
|
236
|
+
if (lock?.rules?.aiLoop && lock.rules.requiredSkills?.length) {
|
|
237
|
+
results.push(await checkRequiredSkills(lock.rules.requiredSkills, opts.skillsDir));
|
|
236
238
|
}
|
|
237
239
|
// #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
|
|
238
240
|
// about MCP, which is not a finding.
|
|
239
|
-
if (lock?.mcp?.recommended?.length) {
|
|
240
|
-
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));
|
|
241
243
|
}
|
|
242
244
|
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
|
|
243
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')
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -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
|
|
80
|
-
// separate
|
|
81
|
-
const candidate = typeof raw === 'object' && raw !== null && '
|
|
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
|
-
|
|
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} —
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
format: 'date-time',
|
|
89
|
+
description: 'ISO 8601 timestamp of the last record write.',
|
|
81
90
|
},
|
|
82
91
|
},
|
|
83
92
|
},
|
|
84
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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(
|
|
145
|
-
if (
|
|
146
|
-
return
|
|
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
|
-
...
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
@@ -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
|
-
|
|
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`.
|