@rtorcato/repo-tooling 3.45.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.
@@ -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
- }
@@ -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.