@rtorcato/repo-tooling 3.16.3 → 3.18.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/checks.js +14 -0
- package/dist/base/fixers.js +26 -2
- package/dist/base/labels.js +7 -2
- package/dist/cli/commands/fix.js +2 -0
- package/dist/cli/generators/claude-skills.js +86 -19
- package/dist/cli/index.js +4 -0
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +45 -6
package/dist/base/checks.js
CHANGED
|
@@ -492,6 +492,20 @@ export async function checkClaudeSkills() {
|
|
|
492
492
|
hint,
|
|
493
493
|
};
|
|
494
494
|
}
|
|
495
|
+
// A local fork is `ok` for the same reason a modified copied asset is (#448):
|
|
496
|
+
// it is somebody's deliberate work, so it is named once and never nagged as
|
|
497
|
+
// fixable — pointing at a `fix` that would refuse is worse than saying nothing.
|
|
498
|
+
if (status.contentState && status.contentState !== 'pristine') {
|
|
499
|
+
const why = status.contentState === 'modified'
|
|
500
|
+
? `has local changes since ${status.installedVersion}`
|
|
501
|
+
: 'carries no content record, so a fork cannot be told from a stale copy';
|
|
502
|
+
return {
|
|
503
|
+
check,
|
|
504
|
+
status: 'ok',
|
|
505
|
+
detail: `${SHIPPED_SKILL} skill at ${status.file} ${why}; this package ships ${status.shippedVersion} and will not overwrite it`,
|
|
506
|
+
hint: `Diff it against the shipped copy, then run \`npx @rtorcato/repo-tooling fix claude-skills --force-skills\` to take the shipped version`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
495
509
|
return {
|
|
496
510
|
check,
|
|
497
511
|
status: 'ok',
|
package/dist/base/fixers.js
CHANGED
|
@@ -76,6 +76,23 @@ async function resolveInstallDir(explicit, assumeYes) {
|
|
|
76
76
|
const trimmed = typeof answer === 'string' ? answer.trim() : '';
|
|
77
77
|
return trimmed ? path.resolve(trimmed) : null;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Why the install refused, and what to do about it. A bare "skipped" would be
|
|
81
|
+
* its own failure mode: the user still wants the update, and nothing on screen
|
|
82
|
+
* would say how to get it or what they would be giving up (#480).
|
|
83
|
+
*/
|
|
84
|
+
function describeSkillFork(result) {
|
|
85
|
+
const target = result.viaSymlink ? `${result.file} → ${result.realFile}` : result.realFile;
|
|
86
|
+
const why = result.contentState === 'modified'
|
|
87
|
+
? `its content has diverged from the ${result.installedVersion} release it was installed from`
|
|
88
|
+
: 'it carries no content record, so a local fork and a stale copy are indistinguishable';
|
|
89
|
+
return [
|
|
90
|
+
`skipped — ${SHIPPED_SKILL} was not overwritten with ${result.shippedVersion}: ${why}`,
|
|
91
|
+
` ${target}`,
|
|
92
|
+
` compare: diff "${result.realFile}" "${result.shippedFile}"`,
|
|
93
|
+
' overwrite anyway: fix claude-skills --force-skills',
|
|
94
|
+
];
|
|
95
|
+
}
|
|
79
96
|
export const BASE_FIXERS = [
|
|
80
97
|
{
|
|
81
98
|
target: 'copied-assets',
|
|
@@ -321,15 +338,22 @@ export const BASE_FIXERS = [
|
|
|
321
338
|
riskLevel: 'safe-add',
|
|
322
339
|
explicitOnly: true,
|
|
323
340
|
canFixDrift: true,
|
|
324
|
-
async run({ skillsDir, assumeYes }) {
|
|
341
|
+
async run({ skillsDir, forceSkills, assumeYes }) {
|
|
325
342
|
const dir = await resolveInstallDir(skillsDir, assumeYes);
|
|
326
343
|
if (!dir)
|
|
327
344
|
return { filesWritten: [] };
|
|
328
|
-
const result = await installClaudeSkill(dir);
|
|
345
|
+
const result = await installClaudeSkill(dir, SHIPPED_SKILL, { force: forceSkills });
|
|
329
346
|
if (result.status === 'declined-downgrade') {
|
|
330
347
|
console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
|
|
331
348
|
return { filesWritten: [] };
|
|
332
349
|
}
|
|
350
|
+
if (result.status === 'declined-fork') {
|
|
351
|
+
// Name `realFile`: through a stow symlink the overwrite would land in a
|
|
352
|
+
// *second* repo's working tree, and that is the path to look at (#480).
|
|
353
|
+
for (const line of describeSkillFork(result))
|
|
354
|
+
console.error(chalk.yellow(` ${line}`));
|
|
355
|
+
return { filesWritten: [] };
|
|
356
|
+
}
|
|
333
357
|
if (result.status === 'up-to-date')
|
|
334
358
|
return { filesWritten: [] };
|
|
335
359
|
// Report the resolved real path when the skill is a stow symlink: the bytes
|
package/dist/base/labels.js
CHANGED
|
@@ -39,10 +39,15 @@ export const LOOP_LABELS = [
|
|
|
39
39
|
color: 'fbca04',
|
|
40
40
|
description: 'Passed, but a reviewer left something to read before merging',
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
name: 'ai-suggested',
|
|
44
|
+
color: 'c2e0c6',
|
|
45
|
+
description: 'Follow-up surfaced by an agent review — triage queue, never auto-picked',
|
|
46
|
+
},
|
|
42
47
|
];
|
|
43
48
|
/**
|
|
44
49
|
* How many of the set have to exist before this repo counts as running the
|
|
45
|
-
* loop. A repo with none has opted out, not drifted — creating
|
|
50
|
+
* loop. A repo with none has opted out, not drifted — creating twelve labels it
|
|
46
51
|
* will never use is the nag this threshold exists to prevent. One alone is the
|
|
47
52
|
* observed half-state (`cf-common` has only `ai-ready`, applied by hand), which
|
|
48
53
|
* is likewise not evidence the pipeline runs there.
|
|
@@ -141,7 +146,7 @@ export async function checkLoopLabels(dir, exec) {
|
|
|
141
146
|
* Repairs colour and description with `gh label edit`, and creates the labels
|
|
142
147
|
* the set is missing. Only on a repo already running the loop (the same
|
|
143
148
|
* `IN_USE_THRESHOLD` gate the check uses) — otherwise a plain `fix --yes` would
|
|
144
|
-
* push
|
|
149
|
+
* push twelve labels into every repo it touches.
|
|
145
150
|
*
|
|
146
151
|
* Idempotent: an aligned repo is a no-op, and a label whose only difference is
|
|
147
152
|
* the hex case is not touched at all.
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -413,6 +413,7 @@ export async function fixCommand(target, options = {}) {
|
|
|
413
413
|
// branch and records a skip, since one refusal must not abandon the rest.
|
|
414
414
|
const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
|
|
415
415
|
skillsDir: options.skillsDir,
|
|
416
|
+
forceSkills: options.forceSkills,
|
|
416
417
|
assumeYes,
|
|
417
418
|
}).catch((err) => {
|
|
418
419
|
if (!(err instanceof FixerAbort))
|
|
@@ -488,6 +489,7 @@ export async function fixCommand(target, options = {}) {
|
|
|
488
489
|
try {
|
|
489
490
|
outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
|
|
490
491
|
skillsDir: options.skillsDir,
|
|
492
|
+
forceSkills: options.forceSkills,
|
|
491
493
|
assumeYes,
|
|
492
494
|
});
|
|
493
495
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* project on the machine. That difference drives all three rules below — the
|
|
5
5
|
* version stamp, the symlink handling, and the fixer's opt-in `explicitOnly`.
|
|
6
6
|
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import fs from 'fs-extra';
|
|
@@ -17,6 +18,19 @@ export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
|
17
18
|
* is wrong to do so.
|
|
18
19
|
*/
|
|
19
20
|
export const VERSION_KEY = 'repo-tooling-version';
|
|
21
|
+
/**
|
|
22
|
+
* The pristine sha256 of the content we wrote, stamped beside the version — the
|
|
23
|
+
* skills half of #448. The version alone cannot tell a stale copy from a
|
|
24
|
+
* deliberate local fork: a fork that is merely older than the package looks
|
|
25
|
+
* exactly like a copy waiting for an update, and gets overwritten (#480).
|
|
26
|
+
* With the hash, "installed content still matches what some release of this
|
|
27
|
+
* package shipped" is a fact rather than an inference.
|
|
28
|
+
*
|
|
29
|
+
* It lives in the file instead of `.repo-tooling.json` because skills are
|
|
30
|
+
* user-global — no one repo owns the record.
|
|
31
|
+
*/
|
|
32
|
+
export const HASH_KEY = 'repo-tooling-hash';
|
|
33
|
+
const STAMP_KEYS = [VERSION_KEY, HASH_KEY];
|
|
20
34
|
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
21
35
|
/**
|
|
22
36
|
* Where to install. `explicit` is `--skills-dir`; otherwise the user-level
|
|
@@ -35,25 +49,64 @@ export async function resolveSkillsDir(explicit, home = os.homedir()) {
|
|
|
35
49
|
return { dir: userDir, source: 'user' };
|
|
36
50
|
return { dir: null, source: 'none' };
|
|
37
51
|
}
|
|
52
|
+
function readStamp(content, key) {
|
|
53
|
+
return content.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null;
|
|
54
|
+
}
|
|
38
55
|
/** The version recorded in an installed copy, or null if it predates the stamp. */
|
|
39
56
|
export function readSkillVersion(content) {
|
|
40
|
-
return content
|
|
57
|
+
return readStamp(content, VERSION_KEY);
|
|
58
|
+
}
|
|
59
|
+
/** The pristine hash recorded in an installed copy, or null if it predates it. */
|
|
60
|
+
export function readSkillHash(content) {
|
|
61
|
+
return readStamp(content, HASH_KEY);
|
|
41
62
|
}
|
|
42
63
|
/**
|
|
43
|
-
* Replace (or add) the
|
|
64
|
+
* Replace (or add) the stamp lines in the frontmatter. Appending them last is
|
|
44
65
|
* safe even after a multi-line `description: |` block: an unindented key ends
|
|
45
|
-
* the block scalar, which is exactly what
|
|
66
|
+
* the block scalar, which is exactly what these lines are.
|
|
67
|
+
*
|
|
68
|
+
* With no stamps this is the exact inverse of stamping, so a file we wrote
|
|
69
|
+
* strips back to the bytes we were given — which is what makes the hash
|
|
70
|
+
* comparable.
|
|
46
71
|
*/
|
|
47
|
-
|
|
48
|
-
const stamp = `${VERSION_KEY}: ${version}`;
|
|
72
|
+
function setStamps(content, stamps) {
|
|
49
73
|
const match = content.match(FRONTMATTER);
|
|
50
74
|
if (!match)
|
|
51
|
-
return `---\n${
|
|
52
|
-
const
|
|
75
|
+
return stamps.length === 0 ? content : `---\n${stamps.join('\n')}\n---\n\n${content}`;
|
|
76
|
+
const kept = (match[1] ?? '')
|
|
53
77
|
.split('\n')
|
|
54
|
-
.filter((line) => !line.startsWith(`${
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
.filter((line) => !STAMP_KEYS.some((key) => line.startsWith(`${key}:`)));
|
|
79
|
+
return `---\n${[...kept, ...stamps].join('\n')}\n---\n${content.slice(match[0].length)}`;
|
|
80
|
+
}
|
|
81
|
+
/** An installed copy with this package's own bookkeeping lines removed. */
|
|
82
|
+
export function stripSkillStamps(content) {
|
|
83
|
+
return setStamps(content, []);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The version stamp on its own — the shape releases before #480 wrote, and what
|
|
87
|
+
* `classifySkillContent` sees as `unknown`. Not the write path; `stampSkill` is.
|
|
88
|
+
*/
|
|
89
|
+
export function stampSkillVersion(content, version) {
|
|
90
|
+
return setStamps(content, [`${VERSION_KEY}: ${version}`]);
|
|
91
|
+
}
|
|
92
|
+
/** sha256 of the content this package shipped, ignoring the stamps it adds. */
|
|
93
|
+
export function hashSkillContent(content) {
|
|
94
|
+
return createHash('sha256').update(stripSkillStamps(content)).digest('hex');
|
|
95
|
+
}
|
|
96
|
+
/** The version + hash stamps, as written to disk. */
|
|
97
|
+
export function stampSkill(content, version) {
|
|
98
|
+
return setStamps(content, [
|
|
99
|
+
`${VERSION_KEY}: ${version}`,
|
|
100
|
+
`${HASH_KEY}: ${hashSkillContent(content)}`,
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
export function classifySkillContent(installed, shipped) {
|
|
104
|
+
if (stripSkillStamps(installed) === stripSkillStamps(shipped))
|
|
105
|
+
return 'pristine';
|
|
106
|
+
const recorded = readSkillHash(installed);
|
|
107
|
+
if (!recorded)
|
|
108
|
+
return 'unknown';
|
|
109
|
+
return recorded === hashSkillContent(installed) ? 'pristine' : 'modified';
|
|
57
110
|
}
|
|
58
111
|
function versionParts(version) {
|
|
59
112
|
return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
@@ -71,9 +124,10 @@ export function isNewerVersion(a, b) {
|
|
|
71
124
|
/** The skill source and the package version that will be stamped into it. */
|
|
72
125
|
export async function readShippedSkill(name = SHIPPED_SKILL) {
|
|
73
126
|
const root = getPackageRoot();
|
|
74
|
-
const
|
|
127
|
+
const file = path.join(root, 'skills', name, 'SKILL.md');
|
|
128
|
+
const content = await fs.readFile(file, 'utf8');
|
|
75
129
|
const pkg = await fs.readJson(path.join(root, 'package.json'));
|
|
76
|
-
return { content, version: String(pkg.version) };
|
|
130
|
+
return { content, version: String(pkg.version), file };
|
|
77
131
|
}
|
|
78
132
|
/** Whether `file` is itself a symlink, as opposed to merely resolving through one. */
|
|
79
133
|
async function isSymlink(file) {
|
|
@@ -94,7 +148,7 @@ async function isSymlink(file) {
|
|
|
94
148
|
* rename *replaces* the symlink with a real file, orphaning the dotfiles copy
|
|
95
149
|
* with no error at all, which is the split-brain this feature exists to end.
|
|
96
150
|
*/
|
|
97
|
-
export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
|
|
151
|
+
export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { force = false } = {}) {
|
|
98
152
|
const shipped = await readShippedSkill(name);
|
|
99
153
|
const file = path.join(skillsDir, name, 'SKILL.md');
|
|
100
154
|
const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : null;
|
|
@@ -102,8 +156,10 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
|
|
|
102
156
|
const viaSymlink = await isSymlink(file);
|
|
103
157
|
const base = {
|
|
104
158
|
name,
|
|
159
|
+
contentState: existing === null ? null : classifySkillContent(existing, shipped.content),
|
|
105
160
|
file,
|
|
106
161
|
viaSymlink,
|
|
162
|
+
shippedFile: shipped.file,
|
|
107
163
|
realFile: viaSymlink ? await fs.realpath(file) : file,
|
|
108
164
|
installedVersion,
|
|
109
165
|
shippedVersion: shipped.version,
|
|
@@ -111,7 +167,13 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
|
|
|
111
167
|
if (installedVersion && isNewerVersion(installedVersion, shipped.version)) {
|
|
112
168
|
return { ...base, status: 'declined-downgrade' };
|
|
113
169
|
}
|
|
114
|
-
|
|
170
|
+
// Only `pristine` content is provably ours to replace. Anything else is a
|
|
171
|
+
// fork (or unprovable, which for a destructive write is the same thing) and
|
|
172
|
+
// stays a human decision — the same rule `fix copied-assets` follows (#448).
|
|
173
|
+
if (!force && base.contentState !== null && base.contentState !== 'pristine') {
|
|
174
|
+
return { ...base, status: 'declined-fork' };
|
|
175
|
+
}
|
|
176
|
+
const next = stampSkill(shipped.content, shipped.version);
|
|
115
177
|
if (existing === next)
|
|
116
178
|
return { ...base, status: 'up-to-date' };
|
|
117
179
|
await fs.ensureDir(path.dirname(file));
|
|
@@ -120,12 +182,13 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
|
|
|
120
182
|
}
|
|
121
183
|
/** Read-only counterpart of `installClaudeSkill`, for doctor. */
|
|
122
184
|
export async function claudeSkillStatus(name = SHIPPED_SKILL, explicit) {
|
|
123
|
-
const
|
|
185
|
+
const shipped = await readShippedSkill(name);
|
|
124
186
|
const { dir } = await resolveSkillsDir(explicit);
|
|
125
187
|
const absent = {
|
|
126
188
|
installed: false,
|
|
127
189
|
installedVersion: null,
|
|
128
|
-
shippedVersion: version,
|
|
190
|
+
shippedVersion: shipped.version,
|
|
191
|
+
contentState: null,
|
|
129
192
|
needsInstall: true,
|
|
130
193
|
};
|
|
131
194
|
if (!dir)
|
|
@@ -133,12 +196,16 @@ export async function claudeSkillStatus(name = SHIPPED_SKILL, explicit) {
|
|
|
133
196
|
const file = path.join(dir, name, 'SKILL.md');
|
|
134
197
|
if (!(await fs.pathExists(file)))
|
|
135
198
|
return { file, ...absent };
|
|
136
|
-
const
|
|
199
|
+
const content = await fs.readFile(file, 'utf8');
|
|
200
|
+
const installedVersion = readSkillVersion(content);
|
|
201
|
+
const contentState = classifySkillContent(content, shipped.content);
|
|
202
|
+
const behind = installedVersion === null || isNewerVersion(shipped.version, installedVersion);
|
|
137
203
|
return {
|
|
138
204
|
file,
|
|
139
205
|
installed: true,
|
|
140
206
|
installedVersion,
|
|
141
|
-
shippedVersion: version,
|
|
142
|
-
|
|
207
|
+
shippedVersion: shipped.version,
|
|
208
|
+
contentState,
|
|
209
|
+
needsInstall: behind && contentState === 'pristine',
|
|
143
210
|
};
|
|
144
211
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -329,6 +329,9 @@ program
|
|
|
329
329
|
.option('--resync', 'Re-scaffold every file recorded in .repo-tooling.json')
|
|
330
330
|
.option('--diff', 'Show a unified diff of each change before confirming')
|
|
331
331
|
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Required with --yes/--json when that directory does not exist')
|
|
332
|
+
// Deliberately not folded into --yes: unattended runs pass --yes, and this is
|
|
333
|
+
// the one overwrite that destroys work living outside the repo (#480).
|
|
334
|
+
.option('--force-skills', 'Let `fix claude-skills` overwrite a locally modified skill instead of refusing')
|
|
332
335
|
.action((target, options) => fixCommand(target, {
|
|
333
336
|
directory: options.directory,
|
|
334
337
|
yes: options.yes,
|
|
@@ -338,6 +341,7 @@ program
|
|
|
338
341
|
resync: options.resync,
|
|
339
342
|
diff: options.diff,
|
|
340
343
|
skillsDir: options.skillsDir,
|
|
344
|
+
forceSkills: options.forceSkills,
|
|
341
345
|
}));
|
|
342
346
|
program.hook('preAction', async (_, actionCommand) => {
|
|
343
347
|
const name = actionCommand.name();
|
package/package.json
CHANGED
|
@@ -60,6 +60,7 @@ drift with a second copy to maintain.
|
|
|
60
60
|
|---|---|
|
|
61
61
|
| Clean and ready | **None.** `ai-ok-code, ai-ok-sec` + assigned + no `ai-review` already says it. |
|
|
62
62
|
| `ai-notes` | ≤10 lines; link the reviewer's `### Before merging`. |
|
|
63
|
+
| Follow-up found | One line — `Follow-up: #<new>`. The issue carries the context. |
|
|
63
64
|
| `ai-changes`, CI red, `ai-blocked` | ≤10 lines, action first, then the specific cause. |
|
|
64
65
|
| Reviewer verdict | `### Before merging` plus ≤600 characters above it. |
|
|
65
66
|
| Declining an issue | The one exception — a hard handoff needs its reasoning; see Pass 4. |
|
|
@@ -78,6 +79,7 @@ drift with a second copy to maintain.
|
|
|
78
79
|
| `ai-ok-sec` | PR | `security-expert` passed. |
|
|
79
80
|
| `ai-changes` | PR | A reviewer requested changes. Reviewers never apply it to a Dependabot PR. |
|
|
80
81
|
| `ai-notes` | PR | Passed, but a reviewer left something to read before merging. |
|
|
82
|
+
| `ai-suggested` | issue | Follow-up a reviewer filed. A triage queue, never auto-picked. |
|
|
81
83
|
| `holding` | issue | A gate — closes on human judgement, never picked up. |
|
|
82
84
|
|
|
83
85
|
**`ai-notes` is advisory and never blocks.** It rides *alongside* a pass label,
|
|
@@ -87,12 +89,19 @@ so `ai-notes` is the hold itself: it suppresses auto-merge and routes the PR to
|
|
|
87
89
|
the human. It exists because a pass label currently means both "clean" and
|
|
88
90
|
"I found something real but would not hold the PR over it", and those two are
|
|
89
91
|
indistinguishable in the *Assigned to you* view where merges actually happen.
|
|
90
|
-
The bar is a finding that **changes what a human would do**: a
|
|
91
|
-
implication, a deliberate omission, a
|
|
92
|
+
The bar is a finding that **changes what a human would do at merge time**: a
|
|
93
|
+
semver implication, a deliberate omission, a question only they can answer. Not
|
|
92
94
|
observations, not praise, not restating the diff. `ai-notes` on every PR is the
|
|
93
95
|
failure mode — it trains the reader to ignore it, which is worse than not having
|
|
94
96
|
it.
|
|
95
97
|
|
|
98
|
+
**Follow-up work is an issue, not a note.** A finding that clears that bar *and*
|
|
99
|
+
is work someone would plausibly do gets filed as its own issue labelled
|
|
100
|
+
`ai-suggested`, by the reviewer that found it; the PR comment keeps one line and
|
|
101
|
+
a link. It does **not** earn `ai-notes` — later work does not decide this merge.
|
|
102
|
+
An observation is not a follow-up. Prose in a merged PR's comments is
|
|
103
|
+
archaeology, which is how every follow-up left there so far has died on merge.
|
|
104
|
+
|
|
96
105
|
First run in a repo, create any that are missing (`gh label create` is a no-op
|
|
97
106
|
error if it exists — ignore that):
|
|
98
107
|
|
|
@@ -108,6 +117,7 @@ gh label create ai-ok-code -c '#0e8a16' -d 'code-reviewer passed'
|
|
|
108
117
|
gh label create ai-ok-sec -c '#0e8a16' -d 'security-expert passed'
|
|
109
118
|
gh label create ai-changes -c '#d93f0b' -d 'Reviewer requested changes'
|
|
110
119
|
gh label create ai-notes -c '#fbca04' -d 'Passed, but a reviewer left something to read before merging'
|
|
120
|
+
gh label create ai-suggested -c '#c2e0c6' -d 'Follow-up surfaced by an agent review — triage queue, never auto-picked'
|
|
111
121
|
```
|
|
112
122
|
|
|
113
123
|
Bootstrap only. `gh label create` **cannot repair a label that already exists** —
|
|
@@ -539,10 +549,28 @@ Reviewer prompt template:
|
|
|
539
549
|
> narrate only where the PR is **wrong** or **silent**. Never list what you
|
|
540
550
|
> checked and found clean, and never confirm a claim the PR body already makes —
|
|
541
551
|
> agreement is what the pass label is for, so a review that agrees is nearly
|
|
542
|
-
> empty. The bar is a finding that **changes what a human would do
|
|
543
|
-
>
|
|
544
|
-
>
|
|
545
|
-
>
|
|
552
|
+
> empty. The bar is a finding that **changes what a human would do at merge
|
|
553
|
+
> time**: a semver implication, a deliberate omission. Writing `Nothing.` is a
|
|
554
|
+
> real verdict and the common one — say it plainly rather than padding to look
|
|
555
|
+
> thorough.
|
|
556
|
+
>
|
|
557
|
+
> **Follow-up work is an issue, and you file it — it does not go in that
|
|
558
|
+
> section.** When a finding clears that bar but is work someone would plausibly
|
|
559
|
+
> do *later* rather than something that decides this merge:
|
|
560
|
+
>
|
|
561
|
+
> ```bash
|
|
562
|
+
> gh issue create --label ai-suggested --title "<what to do>" --body "🤖 *Automated — \`<your agent type>\` via ai-issue-loop.*
|
|
563
|
+
>
|
|
564
|
+
> Surfaced reviewing #<N>. <What, and why it matters. A few lines.>"
|
|
565
|
+
> ```
|
|
566
|
+
>
|
|
567
|
+
> Then put `Follow-up: #<new>` on one line in the body above `### Before
|
|
568
|
+
> merging` and keep it out of that section, so it does not pull `ai-notes` in —
|
|
569
|
+
> later work is not a merge gate. GitHub cross-links the two, so the trail
|
|
570
|
+
> survives the merge in both directions; the comment prose does not. Filing is
|
|
571
|
+
> the alternative to blocking, not a precondition for it. An observation is not
|
|
572
|
+
> a follow-up — do not file one, and a trade-off that changes nothing a human
|
|
573
|
+
> does is one line of body and nothing else.
|
|
546
574
|
>
|
|
547
575
|
> Then apply exactly one verdict label, **clearing your claim label in the same
|
|
548
576
|
> command**:
|
|
@@ -655,6 +683,12 @@ package/from/to table survives because it sits at the top; classify from that.
|
|
|
655
683
|
> unattended. A major, a package that ships to consumers, or a truncated body you
|
|
656
684
|
> could not fully read **is** worth a note; restating the version table on a
|
|
657
685
|
> routine dev-only patch bump is not.
|
|
686
|
+
>
|
|
687
|
+
> **Follow-up work is an issue here too** — same `gh issue create --label
|
|
688
|
+
> ai-suggested` as the generic prompt, same `Follow-up: #<new>` one-liner in the
|
|
689
|
+
> body, never in `### Before merging`. That separation matters more on this arm
|
|
690
|
+
> than the other: a note here costs a human the merge, so routing "someone should
|
|
691
|
+
> pin this transitive dep one day" to an issue is what keeps auto-merge usable.
|
|
658
692
|
|
|
659
693
|
Be honest about what this buys: an agent reading a version table catches majors,
|
|
660
694
|
production-dependency creep, and a renamed or newly-added package. It does **not**
|
|
@@ -727,6 +761,7 @@ gh api "repos/$OWNER_REPO/issues?labels=ai-ready&state=open" \
|
|
|
727
761
|
| select([.labels[].name] | index("ai-wip") == null)
|
|
728
762
|
| select([.labels[].name] | index("ai-blocked") == null)
|
|
729
763
|
| select([.labels[].name] | index("holding") == null)
|
|
764
|
+
| select([.labels[].name] | index("ai-suggested") == null)
|
|
730
765
|
| select(.author_association=="OWNER" or .author_association=="MEMBER" or .author_association=="COLLABORATOR")
|
|
731
766
|
| {number, title}'
|
|
732
767
|
```
|
|
@@ -741,6 +776,10 @@ the first place, but then mislabelling it costs nothing. Unlike `ai-blocked` (an
|
|
|
741
776
|
agent tried and got stuck), `holding` says *no agent should ever start*, and it
|
|
742
777
|
shows up in the issue list so a human triaging does not re-litigate it either.
|
|
743
778
|
|
|
779
|
+
`ai-suggested` is excluded for a harder reason: it is an agent's own suggestion,
|
|
780
|
+
so picking one up would let the loop feed itself work — promoting one is a human
|
|
781
|
+
act, which is what makes that label a triage queue rather than a backlog.
|
|
782
|
+
|
|
744
783
|
**Declining an issue is a visible act — comment, never just skip.** Whenever an
|
|
745
784
|
agent decides an issue should *not* go to the pipeline — triaging which issues to
|
|
746
785
|
label `ai-ready`, or dropping one that is already labelled — say so on the issue
|