@rtorcato/repo-tooling 3.44.0 → 4.0.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 +3 -23
- package/README.md +13 -16
- package/dist/base/checks.js +0 -132
- package/dist/base/fixers.js +0 -123
- package/dist/cli/commands/doctor.js +3 -23
- package/dist/cli/commands/fix-targets.js +4 -7
- package/dist/cli/commands/fix.js +1 -9
- package/dist/cli/index.js +14 -93
- package/dist/cli/utils/lockfile.js +5 -7
- package/package.json +1 -1
- package/dist/base/agent-user.js +0 -68
- package/dist/base/ai-loop-identity.js +0 -80
- package/dist/base/labels.js +0 -220
- package/dist/cli/commands/loop-cleanup.js +0 -100
- package/dist/cli/commands/loop-env.js +0 -76
- package/dist/cli/commands/loop-guard.js +0 -232
- package/dist/cli/commands/loop-marker.js +0 -169
- package/dist/cli/commands/loop-reap.js +0 -196
- package/dist/cli/commands/loop-worktree.js +0 -132
- package/dist/cli/generators/claude-skills.js +0 -240
- package/skills/ai-issue/SKILL.md +0 -74
- package/skills/ai-issue-loop/SKILL.md +0 -1342
- package/skills/ai-loop-status/SKILL.md +0 -122
- package/skills/ai-workflow/SKILL.md +0 -337
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import fs from 'fs-extra';
|
|
4
|
-
import { realGitExec } from '../../base/git-identity.js';
|
|
5
|
-
import { defaultWorktreeRoot } from './loop-guard.js';
|
|
6
|
-
/**
|
|
7
|
-
* `repo-tooling loop worktree add` — the ai-issue-loop skill's Pass 4
|
|
8
|
-
* worktree creation, moved into code (#616): `git worktree add`, linking every
|
|
9
|
-
* `worktree.symlinkDirectories` entry, the `.git/info/exclude` line, and the
|
|
10
|
-
* assertion that nothing was left unlinked.
|
|
11
|
-
*
|
|
12
|
-
* The prose version iterated `for d in $DIRS`, which zsh does not word-split,
|
|
13
|
-
* so it silently linked nothing (#585). An array has no such failure mode.
|
|
14
|
-
*
|
|
15
|
-
* Exit `0` means the worktree is ready for an implementer. Exit `1` means do
|
|
16
|
-
* not spawn one — either the worktree was not created, or an entry that exists
|
|
17
|
-
* in the main checkout has no link in the worktree. `needsInstall` (exit `0`)
|
|
18
|
-
* means the repo declares no symlink list, so nothing was linked and a real
|
|
19
|
-
* install in the worktree is safe.
|
|
20
|
-
*/
|
|
21
|
-
/** `ai-<issue>-<kebab words>` — it becomes a branch name and a path segment. */
|
|
22
|
-
export const SLUG = /^ai-\d+-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
23
|
-
/**
|
|
24
|
-
* Relative and inside the checkout, or dropped: the list comes from a
|
|
25
|
-
* committed file, and `../x` would otherwise link outside both trees.
|
|
26
|
-
*/
|
|
27
|
-
function safeEntry(entry) {
|
|
28
|
-
if (typeof entry !== 'string' || entry.trim() === '')
|
|
29
|
-
return false;
|
|
30
|
-
const n = path.normalize(entry);
|
|
31
|
-
return !path.isAbsolute(n) && n !== '..' && !n.startsWith(`..${path.sep}`);
|
|
32
|
-
}
|
|
33
|
-
export async function symlinkDirectories(root) {
|
|
34
|
-
const settings = await fs.readJson(path.join(root, '.claude', 'settings.json')).catch(() => null);
|
|
35
|
-
const dirs = settings?.worktree?.symlinkDirectories;
|
|
36
|
-
return Array.isArray(dirs) ? dirs.filter(safeEntry) : [];
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* `node_modules/` with a trailing slash does not match a symlink, so a
|
|
40
|
-
* `git add -A` would commit every link. No slash matches at any depth. The
|
|
41
|
-
* exclude file is shared by every worktree and never committed.
|
|
42
|
-
*/
|
|
43
|
-
async function ensureExcluded(root) {
|
|
44
|
-
const file = path.join(root, '.git', 'info', 'exclude');
|
|
45
|
-
const current = await fs.readFile(file, 'utf8').catch(() => '');
|
|
46
|
-
if (current.split('\n').includes('node_modules'))
|
|
47
|
-
return false;
|
|
48
|
-
await fs.outputFile(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}node_modules\n`);
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
async function isSymlink(p) {
|
|
52
|
-
return fs
|
|
53
|
-
.lstat(p)
|
|
54
|
-
.then((s) => s.isSymbolicLink())
|
|
55
|
-
.catch(() => false);
|
|
56
|
-
}
|
|
57
|
-
export async function runLoopWorktreeAdd(slug, options = {}) {
|
|
58
|
-
const root = path.resolve(options.root ?? process.cwd());
|
|
59
|
-
const worktreeRoot = options.worktreeRoot
|
|
60
|
-
? path.resolve(options.worktreeRoot)
|
|
61
|
-
: defaultWorktreeRoot(root);
|
|
62
|
-
const worktree = path.join(worktreeRoot, slug);
|
|
63
|
-
// A checkout of a real repo takes longer than realGitExec's 5s default.
|
|
64
|
-
const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
|
|
65
|
-
const result = {
|
|
66
|
-
root,
|
|
67
|
-
worktree,
|
|
68
|
-
branch: slug,
|
|
69
|
-
linked: [],
|
|
70
|
-
missing: [],
|
|
71
|
-
needsInstall: false,
|
|
72
|
-
exitCode: 1,
|
|
73
|
-
messages: [],
|
|
74
|
-
};
|
|
75
|
-
if (!SLUG.test(slug)) {
|
|
76
|
-
result.messages.push(`⚠ '${slug}' is not ai-<issue>-<kebab-slug>`);
|
|
77
|
-
return result;
|
|
78
|
-
}
|
|
79
|
-
const base = options.base ?? 'origin/main';
|
|
80
|
-
// Goes to git as an argument; a leading dash would be read as an option.
|
|
81
|
-
if (base.startsWith('-')) {
|
|
82
|
-
result.messages.push(`⚠ '${base}' is not a ref`);
|
|
83
|
-
return result;
|
|
84
|
-
}
|
|
85
|
-
await fs.ensureDir(worktreeRoot);
|
|
86
|
-
if ((await git(['worktree', 'add', worktree, '-b', slug, base])) === null) {
|
|
87
|
-
result.messages.push(`⚠ git worktree add failed — does branch ${slug} or ${worktree} already exist?`);
|
|
88
|
-
return result;
|
|
89
|
-
}
|
|
90
|
-
result.messages.push(`created ${worktree} on ${slug}`);
|
|
91
|
-
// Before linking, so the links are never momentarily untracked-and-addable.
|
|
92
|
-
if (await ensureExcluded(root))
|
|
93
|
-
result.messages.push('added node_modules to .git/info/exclude');
|
|
94
|
-
const dirs = await symlinkDirectories(root);
|
|
95
|
-
if (dirs.length === 0) {
|
|
96
|
-
result.needsInstall = true;
|
|
97
|
-
result.messages.push('no worktree.symlinkDirectories — run a real install in the worktree (`fix ai` restores the fast path)');
|
|
98
|
-
}
|
|
99
|
-
for (const d of dirs) {
|
|
100
|
-
const source = path.join(root, d);
|
|
101
|
-
// An entry pointing at nothing links nothing, and is not missing.
|
|
102
|
-
if (!(await fs.pathExists(source)))
|
|
103
|
-
continue;
|
|
104
|
-
const target = path.join(worktree, d);
|
|
105
|
-
await fs.ensureDir(path.dirname(target));
|
|
106
|
-
await fs.symlink(source, target).catch(() => { });
|
|
107
|
-
if (await isSymlink(target))
|
|
108
|
-
result.linked.push(d);
|
|
109
|
-
else
|
|
110
|
-
result.missing.push(d);
|
|
111
|
-
}
|
|
112
|
-
if (result.missing.length > 0) {
|
|
113
|
-
result.messages.push(`⚠ FATAL: no symlink for ${result.missing.join(', ')} — do not spawn an implementer`);
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
116
|
-
if (result.linked.length > 0)
|
|
117
|
-
result.messages.push(`linked ${result.linked.join(', ')}`);
|
|
118
|
-
result.exitCode = 0;
|
|
119
|
-
return result;
|
|
120
|
-
}
|
|
121
|
-
export async function loopWorktreeAddCommand(slug, options) {
|
|
122
|
-
const result = await runLoopWorktreeAdd(slug, options);
|
|
123
|
-
if (options.json) {
|
|
124
|
-
console.log(JSON.stringify(result, null, 2));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
for (const line of result.messages) {
|
|
128
|
-
console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
process.exitCode = result.exitCode;
|
|
132
|
-
}
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* User-global agent skills (#404). Every other generator writes inside the repo;
|
|
3
|
-
* this one writes to `~/.claude/skills/<name>/SKILL.md`, which is shared by every
|
|
4
|
-
* project on the machine. That difference drives all three rules below — the
|
|
5
|
-
* version stamp, the symlink handling, and the fixer's opt-in `explicitOnly`.
|
|
6
|
-
*/
|
|
7
|
-
import { createHash } from 'node:crypto';
|
|
8
|
-
import os from 'node:os';
|
|
9
|
-
import path from 'node:path';
|
|
10
|
-
import fs from 'fs-extra';
|
|
11
|
-
import { realGitExec } from '../../base/git-identity.js';
|
|
12
|
-
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
13
|
-
import { shellQuote } from '../utils/shell.js';
|
|
14
|
-
import { isNewerVersion, resolveShippedVersion } from '../utils/version.js';
|
|
15
|
-
/**
|
|
16
|
-
* Skills this package owns the content of and keeps up to date. The loop first —
|
|
17
|
-
* it is the pipeline; the next three are its drivers (burst, on-ramp, status).
|
|
18
|
-
* `dogfood` stands apart: it tests the consuming repo's own tooling rather than
|
|
19
|
-
* driving the loop, and it is the only one that writes nothing outside a temp dir.
|
|
20
|
-
*/
|
|
21
|
-
export const SHIPPED_SKILLS = [
|
|
22
|
-
'ai-issue-loop',
|
|
23
|
-
'ai-workflow',
|
|
24
|
-
'ai-issue',
|
|
25
|
-
'ai-loop-status',
|
|
26
|
-
'dogfood',
|
|
27
|
-
];
|
|
28
|
-
/** The primary skill — the default everywhere a single name is accepted. */
|
|
29
|
-
export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
30
|
-
/**
|
|
31
|
-
* Stamped into the installed copy's frontmatter so a second repo pinned to an
|
|
32
|
-
* older release can tell it would be a downgrade and skip. Without it two repos
|
|
33
|
-
* on different versions overwrite each other's skill on every `fix`, and neither
|
|
34
|
-
* is wrong to do so.
|
|
35
|
-
*/
|
|
36
|
-
export const VERSION_KEY = 'repo-tooling-version';
|
|
37
|
-
/**
|
|
38
|
-
* The pristine sha256 of the content we wrote, stamped beside the version — the
|
|
39
|
-
* skills half of #448. The version alone cannot tell a stale copy from a
|
|
40
|
-
* deliberate local fork: a fork that is merely older than the package looks
|
|
41
|
-
* exactly like a copy waiting for an update, and gets overwritten (#480).
|
|
42
|
-
* With the hash, "installed content still matches what some release of this
|
|
43
|
-
* package shipped" is a fact rather than an inference.
|
|
44
|
-
*
|
|
45
|
-
* It lives in the file instead of `.repo-tooling.json` because skills are
|
|
46
|
-
* user-global — no one repo owns the record.
|
|
47
|
-
*/
|
|
48
|
-
export const HASH_KEY = 'repo-tooling-hash';
|
|
49
|
-
const STAMP_KEYS = [VERSION_KEY, HASH_KEY];
|
|
50
|
-
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
51
|
-
/**
|
|
52
|
-
* Where to install. `explicit` is `--skills-dir`; otherwise the user-level
|
|
53
|
-
* `~/.claude/skills` when it already exists. A machine with neither resolves to
|
|
54
|
-
* null rather than creating `~/.claude` uninvited.
|
|
55
|
-
*
|
|
56
|
-
* There is deliberately no separate "symlinked" case: a stow-managed
|
|
57
|
-
* `SKILL.md` symlink lives *inside* that same directory, and writing through it
|
|
58
|
-
* is what `installClaudeSkill` already does. See its note.
|
|
59
|
-
*/
|
|
60
|
-
export async function resolveSkillsDir(explicit, home = os.homedir()) {
|
|
61
|
-
if (explicit)
|
|
62
|
-
return { dir: path.resolve(explicit), source: 'explicit' };
|
|
63
|
-
const userDir = path.join(home, '.claude', 'skills');
|
|
64
|
-
if (await fs.pathExists(userDir))
|
|
65
|
-
return { dir: userDir, source: 'user' };
|
|
66
|
-
return { dir: null, source: 'none' };
|
|
67
|
-
}
|
|
68
|
-
function readStamp(content, key) {
|
|
69
|
-
return content.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null;
|
|
70
|
-
}
|
|
71
|
-
/** The version recorded in an installed copy, or null if it predates the stamp. */
|
|
72
|
-
export function readSkillVersion(content) {
|
|
73
|
-
return readStamp(content, VERSION_KEY);
|
|
74
|
-
}
|
|
75
|
-
/** The pristine hash recorded in an installed copy, or null if it predates it. */
|
|
76
|
-
export function readSkillHash(content) {
|
|
77
|
-
return readStamp(content, HASH_KEY);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Replace (or add) the stamp lines in the frontmatter. Appending them last is
|
|
81
|
-
* safe even after a multi-line `description: |` block: an unindented key ends
|
|
82
|
-
* the block scalar, which is exactly what these lines are.
|
|
83
|
-
*
|
|
84
|
-
* With no stamps this is the exact inverse of stamping, so a file we wrote
|
|
85
|
-
* strips back to the bytes we were given — which is what makes the hash
|
|
86
|
-
* comparable.
|
|
87
|
-
*/
|
|
88
|
-
function setStamps(content, stamps) {
|
|
89
|
-
const match = content.match(FRONTMATTER);
|
|
90
|
-
if (!match)
|
|
91
|
-
return stamps.length === 0 ? content : `---\n${stamps.join('\n')}\n---\n\n${content}`;
|
|
92
|
-
const kept = (match[1] ?? '')
|
|
93
|
-
.split('\n')
|
|
94
|
-
.filter((line) => !STAMP_KEYS.some((key) => line.startsWith(`${key}:`)));
|
|
95
|
-
return `---\n${[...kept, ...stamps].join('\n')}\n---\n${content.slice(match[0].length)}`;
|
|
96
|
-
}
|
|
97
|
-
/** An installed copy with this package's own bookkeeping lines removed. */
|
|
98
|
-
export function stripSkillStamps(content) {
|
|
99
|
-
return setStamps(content, []);
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* The version stamp on its own — the shape releases before #480 wrote, and what
|
|
103
|
-
* `classifySkillContent` sees as `unknown`. Not the write path; `stampSkill` is.
|
|
104
|
-
*/
|
|
105
|
-
export function stampSkillVersion(content, version) {
|
|
106
|
-
return setStamps(content, [`${VERSION_KEY}: ${version}`]);
|
|
107
|
-
}
|
|
108
|
-
/** sha256 of the content this package shipped, ignoring the stamps it adds. */
|
|
109
|
-
export function hashSkillContent(content) {
|
|
110
|
-
return createHash('sha256').update(stripSkillStamps(content)).digest('hex');
|
|
111
|
-
}
|
|
112
|
-
/** The version + hash stamps, as written to disk. */
|
|
113
|
-
export function stampSkill(content, version) {
|
|
114
|
-
return setStamps(content, [
|
|
115
|
-
`${VERSION_KEY}: ${version}`,
|
|
116
|
-
`${HASH_KEY}: ${hashSkillContent(content)}`,
|
|
117
|
-
]);
|
|
118
|
-
}
|
|
119
|
-
export function classifySkillContent(installed, shipped) {
|
|
120
|
-
if (stripSkillStamps(installed) === stripSkillStamps(shipped))
|
|
121
|
-
return 'pristine';
|
|
122
|
-
const recorded = readSkillHash(installed);
|
|
123
|
-
if (!recorded)
|
|
124
|
-
return 'unknown';
|
|
125
|
-
return recorded === hashSkillContent(installed) ? 'pristine' : 'modified';
|
|
126
|
-
}
|
|
127
|
-
/** The skill source and the package version that will be stamped into it. */
|
|
128
|
-
export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
|
|
129
|
-
const root = getPackageRoot();
|
|
130
|
-
const file = path.join(root, 'skills', name, 'SKILL.md');
|
|
131
|
-
const content = await fs.readFile(file, 'utf8');
|
|
132
|
-
const pkg = await fs.readJson(path.join(root, 'package.json'));
|
|
133
|
-
return { content, version: await resolveShippedVersion(root, String(pkg.version), git), file };
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* The command a human runs to see what their fork changed, before deciding
|
|
137
|
-
* whether to take the shipped copy. One builder, because `doctor` and `fix`
|
|
138
|
-
* both print it and two independently-built strings drift (#484). Always
|
|
139
|
-
* `realFile`: through a stow symlink the nominal path is the link, and the
|
|
140
|
-
* bytes worth reading are in the dotfiles checkout it points at.
|
|
141
|
-
*
|
|
142
|
-
* Both paths are user-influenced — `--skills-dir` is an argument (#490) and
|
|
143
|
-
* `realFile` is wherever a symlink happens to point — and this line exists to
|
|
144
|
-
* be pasted into a shell, so it is quoted rather than merely interpolated
|
|
145
|
-
* (#493). Quoting beats refusing to emit a command: single quotes make an odd
|
|
146
|
-
* path *more* visible, not less, and keep the hint usable in the odd case
|
|
147
|
-
* instead of only the ordinary one.
|
|
148
|
-
*/
|
|
149
|
-
export function skillDiffCommand(paths) {
|
|
150
|
-
return `diff ${shellQuote(paths.realFile)} ${shellQuote(paths.shippedFile)}`;
|
|
151
|
-
}
|
|
152
|
-
/** Whether `file` is itself a symlink, as opposed to merely resolving through one. */
|
|
153
|
-
async function isSymlink(file) {
|
|
154
|
-
return await fs
|
|
155
|
-
.lstat(file)
|
|
156
|
-
.then((stat) => stat.isSymbolicLink())
|
|
157
|
-
.catch(() => false);
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Install (or refresh) one shipped skill under `skillsDir`.
|
|
161
|
-
*
|
|
162
|
-
* **Writes through a symlink on purpose.** stow symlinks dotfiles at *file*
|
|
163
|
-
* level, so `~/.claude/skills/ai-issue-loop/SKILL.md` is routinely a link into a
|
|
164
|
-
* dotfiles checkout while its parent directories are real. `fs.writeFile`
|
|
165
|
-
* follows the link and updates the dotfiles copy in place, which is the whole
|
|
166
|
-
* point — the skill stays version-controlled with the rest of the Claude config.
|
|
167
|
-
* Never swap this for an atomic-rename helper (`write-file-atomic` and friends):
|
|
168
|
-
* rename *replaces* the symlink with a real file, orphaning the dotfiles copy
|
|
169
|
-
* with no error at all, which is the split-brain this feature exists to end.
|
|
170
|
-
*/
|
|
171
|
-
export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { force = false } = {}) {
|
|
172
|
-
const shipped = await readShippedSkill(name);
|
|
173
|
-
const file = path.join(skillsDir, name, 'SKILL.md');
|
|
174
|
-
const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : null;
|
|
175
|
-
const installedVersion = existing ? readSkillVersion(existing) : null;
|
|
176
|
-
const viaSymlink = await isSymlink(file);
|
|
177
|
-
const base = {
|
|
178
|
-
name,
|
|
179
|
-
contentState: existing === null ? null : classifySkillContent(existing, shipped.content),
|
|
180
|
-
file,
|
|
181
|
-
viaSymlink,
|
|
182
|
-
shippedFile: shipped.file,
|
|
183
|
-
realFile: viaSymlink ? await fs.realpath(file) : file,
|
|
184
|
-
installedVersion,
|
|
185
|
-
shippedVersion: shipped.version,
|
|
186
|
-
};
|
|
187
|
-
// `force` overrides this too, not just the fork check below. It already
|
|
188
|
-
// overrides the *stronger* protection — overwriting content someone
|
|
189
|
-
// deliberately edited — so refusing on a version comparison while allowing
|
|
190
|
-
// that was backwards, and left no escape hatch at all when the comparison
|
|
191
|
-
// was wrong (#522).
|
|
192
|
-
if (!force && installedVersion && isNewerVersion(installedVersion, shipped.version)) {
|
|
193
|
-
return { ...base, status: 'declined-downgrade' };
|
|
194
|
-
}
|
|
195
|
-
// Only `pristine` content is provably ours to replace. Anything else is a
|
|
196
|
-
// fork (or unprovable, which for a destructive write is the same thing) and
|
|
197
|
-
// stays a human decision — the same rule `fix copied-assets` follows (#448).
|
|
198
|
-
if (!force && base.contentState !== null && base.contentState !== 'pristine') {
|
|
199
|
-
return { ...base, status: 'declined-fork' };
|
|
200
|
-
}
|
|
201
|
-
const next = stampSkill(shipped.content, shipped.version);
|
|
202
|
-
if (existing === next)
|
|
203
|
-
return { ...base, status: 'up-to-date' };
|
|
204
|
-
await fs.ensureDir(path.dirname(file));
|
|
205
|
-
await fs.writeFile(file, next);
|
|
206
|
-
return { ...base, status: existing === null ? 'installed' : 'updated' };
|
|
207
|
-
}
|
|
208
|
-
/** Read-only counterpart of `installClaudeSkill`, for doctor. */
|
|
209
|
-
export async function claudeSkillStatus(name = SHIPPED_SKILL, explicit) {
|
|
210
|
-
const shipped = await readShippedSkill(name);
|
|
211
|
-
const { dir } = await resolveSkillsDir(explicit);
|
|
212
|
-
const absent = {
|
|
213
|
-
realFile: null,
|
|
214
|
-
shippedFile: shipped.file,
|
|
215
|
-
installed: false,
|
|
216
|
-
installedVersion: null,
|
|
217
|
-
shippedVersion: shipped.version,
|
|
218
|
-
contentState: null,
|
|
219
|
-
needsInstall: true,
|
|
220
|
-
};
|
|
221
|
-
if (!dir)
|
|
222
|
-
return { file: null, ...absent };
|
|
223
|
-
const file = path.join(dir, name, 'SKILL.md');
|
|
224
|
-
if (!(await fs.pathExists(file)))
|
|
225
|
-
return { file, ...absent };
|
|
226
|
-
const content = await fs.readFile(file, 'utf8');
|
|
227
|
-
const installedVersion = readSkillVersion(content);
|
|
228
|
-
const contentState = classifySkillContent(content, shipped.content);
|
|
229
|
-
const behind = installedVersion === null || isNewerVersion(shipped.version, installedVersion);
|
|
230
|
-
return {
|
|
231
|
-
file,
|
|
232
|
-
realFile: (await isSymlink(file)) ? await fs.realpath(file) : file,
|
|
233
|
-
shippedFile: shipped.file,
|
|
234
|
-
installed: true,
|
|
235
|
-
installedVersion,
|
|
236
|
-
shippedVersion: shipped.version,
|
|
237
|
-
contentState,
|
|
238
|
-
needsInstall: behind && contentState === 'pristine',
|
|
239
|
-
};
|
|
240
|
-
}
|
package/skills/ai-issue/SKILL.md
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ai-issue
|
|
3
|
-
description: |
|
|
4
|
-
File a GitHub issue labelled `ai-ready` for the ai-issue-loop pipeline to pick
|
|
5
|
-
up and implement unattended. Use when the user says "file this for the loop",
|
|
6
|
-
"make this an AI issue", "queue this for an agent", or invokes `/ai-issue`.
|
|
7
|
-
For an ordinary issue a human will work on, use plain `gh issue create` with
|
|
8
|
-
no label instead. GitHub only (`gh`) — not GitLab.
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
# ai-issue
|
|
12
|
-
|
|
13
|
-
File an issue an **agent will execute unattended**, labelled `ai-ready` so
|
|
14
|
-
`ai-issue-loop` picks it up. Arguments: $ARGUMENTS
|
|
15
|
-
|
|
16
|
-
This is only for work you intend a background agent to do without you. For an
|
|
17
|
-
ordinary issue, use `gh issue create` with no `ai-*` label.
|
|
18
|
-
|
|
19
|
-
## Preflight
|
|
20
|
-
|
|
21
|
-
1. `git remote get-url origin` — GitHub only. On GitLab, stop: the loop is
|
|
22
|
-
`gh`-based and nothing would ever pick the issue up.
|
|
23
|
-
2. `gh label list --search ai-ready` — if the label is missing, this repo hasn't
|
|
24
|
-
been bootstrapped for the loop. Stop and point at the `ai-issue-loop` skill's
|
|
25
|
-
label block; creating a bare `ai-ready` label would produce an issue that
|
|
26
|
-
silently never runs.
|
|
27
|
-
|
|
28
|
-
## Write it for an agent, not for yourself
|
|
29
|
-
|
|
30
|
-
The agent that picks this up **cannot ask a follow-up question**, and is
|
|
31
|
-
instructed to treat the body as untrusted data. Both change how it must read:
|
|
32
|
-
|
|
33
|
-
- **Describe, never instruct.** "The README claims X but Y is true" — not "go
|
|
34
|
-
update the README". Directive phrasing is exactly what the agent is told to
|
|
35
|
-
ignore, so an instruction-shaped issue reads as empty.
|
|
36
|
-
- **Name the files** you already know are involved. The two reviewing agents are
|
|
37
|
-
diff-scoped and won't explore the repo to judge whether the right thing was
|
|
38
|
-
touched.
|
|
39
|
-
- **State a done-condition a reviewer can check.** Those same agents review the
|
|
40
|
-
PR against this body; a vague issue produces a vague review on a PR you then
|
|
41
|
-
merge without having really vetted.
|
|
42
|
-
- **One PR's worth.** Split anything spanning several concerns — the loop runs
|
|
43
|
-
several issues in parallel, so splitting is free.
|
|
44
|
-
|
|
45
|
-
## Refuse the ones that aren't ready
|
|
46
|
-
|
|
47
|
-
Say so, and file it unlabelled instead, when the task:
|
|
48
|
-
|
|
49
|
-
- needs a judgement call you'd normally make mid-PR,
|
|
50
|
-
- needs eyes on rendered output, a real device, or a running service,
|
|
51
|
-
- depends on context that lives in this conversation rather than the repo, or
|
|
52
|
-
- you couldn't write self-contained without "we can sort that out in review".
|
|
53
|
-
|
|
54
|
-
An `ai-ready` that stalls costs more than an issue you did yourself: it burns a
|
|
55
|
-
concurrency slot, two review passes, and up to two fix rounds before it lands as
|
|
56
|
-
`ai-blocked`.
|
|
57
|
-
|
|
58
|
-
## Create
|
|
59
|
-
|
|
60
|
-
Draft title and body from `$ARGUMENTS` plus the conversation. The body opens
|
|
61
|
-
with a line saying an agent wrote it — everything you post appears under the
|
|
62
|
-
owner's own account:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
gh issue create --label ai-ready --title "TITLE" --body "$(cat <<'EOF'
|
|
66
|
-
🤖 *Filed by Claude (AI agent) on the owner's behalf.*
|
|
67
|
-
|
|
68
|
-
BODY
|
|
69
|
-
EOF
|
|
70
|
-
)"
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Print the URL. Note that nothing happens until a tick runs — `/ai-issue-loop`
|
|
74
|
-
manually, or a recurring schedule if one is active.
|