greprag 5.84.0 → 5.85.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/commands/agent-doctrine-file.js +5 -128
- package/dist/commands/agents.js +125 -846
- package/dist/commands/init.js +17 -29
- package/dist/commands/load.js +14 -36
- package/dist/commands/parity.js +1 -1
- package/dist/commands/skill-mirror-reminder.js +1 -18
- package/dist/commands/skill.js +137 -566
- package/dist/hook.js +30 -54
- package/dist/index.js +5 -5
- package/dist/native-adapter-refresh.js +15 -18
- package/dist/native-skill-mirror.js +11 -92
- package/dist/opencode-plugin.bundle.js +81 -283
- package/dist/skill-activation-manifest.js +14 -37
- package/dist/skill-git-ingest.js +297 -0
- package/dist/skill-landing.js +7 -173
- package/dist/skill-mirror-client.js +4 -280
- package/dist/skill-staleness.js +3 -6
- package/package.json +1 -1
- package/skill/mechanic/docs/skill-fix-conventions.md +1 -1
- package/skill/templates/os.md +1 -1
- package/skill/templates/prompt-audit.md +26 -49
- package/skill/templates/skill-change.md +15 -13
|
@@ -45,7 +45,6 @@ exports.readNativeSkillNames = readNativeSkillNames;
|
|
|
45
45
|
exports.nativeAdapterGap = nativeAdapterGap;
|
|
46
46
|
exports.buildMirroredSkillActivation = buildMirroredSkillActivation;
|
|
47
47
|
exports.nativeAdapterInstallCommand = nativeAdapterInstallCommand;
|
|
48
|
-
exports.localCopyCutoverCommand = localCopyCutoverCommand;
|
|
49
48
|
exports.activationFromApiRows = activationFromApiRows;
|
|
50
49
|
const fs = __importStar(require("fs"));
|
|
51
50
|
const path = __importStar(require("path"));
|
|
@@ -170,30 +169,23 @@ function readNativeSkillNames(params) {
|
|
|
170
169
|
}
|
|
171
170
|
return names;
|
|
172
171
|
}
|
|
173
|
-
/** Mirrored skills
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* packages in place. An external package is neither: the harness lists it, and
|
|
180
|
-
* its owner (a link target or another installer) is not GrepRAG. A bundled skill is never a gap; its canon is the bundle
|
|
181
|
-
* the installer writes as a real body. adr: adr/skill-bundle-shape.md */
|
|
172
|
+
/** Mirrored skills with no native package on this harness, so they never reach
|
|
173
|
+
* its own skill list. Codex and OpenCode close that gap with launchers; Claude
|
|
174
|
+
* Code has none to install: it reads skills from their git folders, and a
|
|
175
|
+
* mirrored skill with no git folder is archived history `greprag load` still
|
|
176
|
+
* serves. A bundled skill is never a gap; its canon is the bundle the
|
|
177
|
+
* installer writes as a real body. adr: adr/skill-ownership-git.md */
|
|
182
178
|
function nativeAdapterGap(platform, mirrored, native) {
|
|
183
179
|
const missing = [];
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
for (const { skillName, scope } of mirrored) {
|
|
180
|
+
if (platform !== 'codex' && platform !== 'opencode')
|
|
181
|
+
return { missing };
|
|
182
|
+
for (const { skillName } of mirrored) {
|
|
188
183
|
if (!skillName || skill_landing_1.BUNDLED_SKILLS.includes(skillName))
|
|
189
184
|
continue;
|
|
190
|
-
|
|
191
|
-
if (!kind)
|
|
185
|
+
if (!native.get(skillName.toLowerCase()))
|
|
192
186
|
missing.push(skillName);
|
|
193
|
-
else if (kind === 'local' && platform === 'claude-code' && scope !== 'project')
|
|
194
|
-
localCopies.push(skillName);
|
|
195
187
|
}
|
|
196
|
-
return { missing
|
|
188
|
+
return { missing };
|
|
197
189
|
}
|
|
198
190
|
/** Remove native-advertised duplicates while retaining the canonical count. */
|
|
199
191
|
function buildMirroredSkillActivation(entries, native, platform) {
|
|
@@ -208,27 +200,12 @@ function buildMirroredSkillActivation(entries, native, platform) {
|
|
|
208
200
|
...nativeAdapterGap(platform, valid, native),
|
|
209
201
|
};
|
|
210
202
|
}
|
|
211
|
-
/** The one command that
|
|
212
|
-
* skills
|
|
213
|
-
* are opt-in per name); codex/opencode sync their whole roster. Null when
|
|
214
|
-
* nothing is missing. */
|
|
203
|
+
/** The one command that installs the Codex/OpenCode launchers for mirrored
|
|
204
|
+
* skills. Null when nothing is missing or the harness takes no launchers. */
|
|
215
205
|
function nativeAdapterInstallCommand(platform, missingNames) {
|
|
216
206
|
if (missingNames.length === 0)
|
|
217
207
|
return null;
|
|
218
|
-
|
|
219
|
-
return `greprag skill mirror sync claude-code --only ${[...missingNames].sort().join(',')}`;
|
|
220
|
-
}
|
|
221
|
-
if (platform === 'codex' || platform === 'opencode') {
|
|
222
|
-
return `greprag skill mirror sync ${platform}`;
|
|
223
|
-
}
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
/** The command that replaces one hand-kept copy with its adapter, backing up
|
|
227
|
-
* the copy's SKILL.md first. Replacing a live copy is a per-skill call, so the
|
|
228
|
-
* command names one placeholder rather than the whole list. Null when there
|
|
229
|
-
* are none. */
|
|
230
|
-
function localCopyCutoverCommand(platform, localCopies) {
|
|
231
|
-
return localCopies.length > 0 ? nativeAdapterInstallCommand(platform, ['<name>']) : null;
|
|
208
|
+
return platform === 'codex' || platform === 'opencode' ? `greprag skill mirror sync ${platform}` : null;
|
|
232
209
|
}
|
|
233
210
|
/** Convert the mirror API response into a harness-specific activation signal. */
|
|
234
211
|
function activationFromApiRows(params) {
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Git-sourced skill mirror ingestion: the only writer of skill canon.
|
|
3
|
+
*
|
|
4
|
+
* A skill's one true copy is its git folder: dotclaude `skills/<name>/` for
|
|
5
|
+
* global skills, `<repo>/.claude/skills/<name>/` for project skills. This
|
|
6
|
+
* reads COMMITTED bytes (`HEAD`, never the working tree, so an uncommitted
|
|
7
|
+
* edit is served nowhere) and uploads a skill only when its committed content
|
|
8
|
+
* differs from the mirror. Each upload records `git <hash>: <subject>` as the
|
|
9
|
+
* revision reason.
|
|
10
|
+
*
|
|
11
|
+
* Cheap on every session start: one `git ls-tree` per source lists each skill
|
|
12
|
+
* folder's tree hash; only folders whose tree changed since the last run are
|
|
13
|
+
* read (one `git cat-file --batch` for all their files) and hashed.
|
|
14
|
+
*
|
|
15
|
+
* adr: adr/skill-ownership-git.md */
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.gitSkillPath = gitSkillPath;
|
|
51
|
+
exports.globalSkillSource = globalSkillSource;
|
|
52
|
+
exports.projectSkillSource = projectSkillSource;
|
|
53
|
+
exports.listCommittedSkills = listCommittedSkills;
|
|
54
|
+
exports.readCommittedSkillFiles = readCommittedSkillFiles;
|
|
55
|
+
exports.ingestCommittedSkills = ingestCommittedSkills;
|
|
56
|
+
exports.fetchMirrorHashes = fetchMirrorHashes;
|
|
57
|
+
exports.gitSkillSources = gitSkillSources;
|
|
58
|
+
const fs = __importStar(require("node:fs"));
|
|
59
|
+
const os = __importStar(require("node:os"));
|
|
60
|
+
const path = __importStar(require("node:path"));
|
|
61
|
+
const proc_1 = require("./proc");
|
|
62
|
+
const project_anchor_1 = require("./project-anchor");
|
|
63
|
+
const skill_landing_1 = require("./skill-landing");
|
|
64
|
+
const skill_mirror_files_1 = require("./skill-mirror-files");
|
|
65
|
+
/** The server's caps (SKILL_MIRROR_* in @greprag/core), restated: the CLI is core-free. */
|
|
66
|
+
const MAX_FILES = 20;
|
|
67
|
+
const MAX_FILE_CHARS = 100_000;
|
|
68
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
69
|
+
const STATE_FILE = path.join(os.homedir(), '.greprag', 'state', 'skill-git-ingest.json');
|
|
70
|
+
function git(repo, args, input) {
|
|
71
|
+
const r = (0, proc_1.safeSpawnSync)('git', args, { cwd: repo, encoding: 'utf8', timeout: 15_000, input, maxBuffer: 64 << 20 });
|
|
72
|
+
return r.status === 0 ? String(r.stdout || '') : null;
|
|
73
|
+
}
|
|
74
|
+
function gitTop(dir) {
|
|
75
|
+
const out = fs.existsSync(dir) ? git(dir, ['rev-parse', '--show-toplevel']) : null;
|
|
76
|
+
return out ? path.resolve(out.trim()) : null;
|
|
77
|
+
}
|
|
78
|
+
/** Where Claude Code finds a skill, and so where to edit it. */
|
|
79
|
+
function gitSkillPath(scope, name, projectRoot) {
|
|
80
|
+
return scope === 'project' && projectRoot
|
|
81
|
+
? path.join(projectRoot, '.claude', 'skills', name)
|
|
82
|
+
: path.join(os.homedir(), '.claude', 'skills', name);
|
|
83
|
+
}
|
|
84
|
+
function globalSkillSource(home = os.homedir()) {
|
|
85
|
+
const dir = path.join(home, '.claude', 'skills');
|
|
86
|
+
const repo = gitTop(dir);
|
|
87
|
+
if (!repo)
|
|
88
|
+
return null;
|
|
89
|
+
const prefix = path.relative(repo, dir).split(path.sep).join('/');
|
|
90
|
+
return { scope: 'global', repo, prefix };
|
|
91
|
+
}
|
|
92
|
+
/** The current repo's `.claude/skills`, unless the repo is the dotclaude repo
|
|
93
|
+
* itself (its skills are global) or the folder is absent. */
|
|
94
|
+
function projectSkillSource(cwd, home = os.homedir()) {
|
|
95
|
+
const repo = gitTop(cwd);
|
|
96
|
+
if (!repo)
|
|
97
|
+
return null;
|
|
98
|
+
const dotclaude = gitTop(path.join(home, '.claude'));
|
|
99
|
+
if (dotclaude && path.resolve(dotclaude) === repo)
|
|
100
|
+
return null;
|
|
101
|
+
if (!fs.existsSync(path.join(repo, '.claude', 'skills')))
|
|
102
|
+
return null;
|
|
103
|
+
const anchor = (0, project_anchor_1.readAnchor)(repo);
|
|
104
|
+
if (!anchor.initialized)
|
|
105
|
+
return null;
|
|
106
|
+
return { scope: 'project', repo, prefix: '.claude/skills', projectId: anchor.projectId, projectName: anchor.projectName };
|
|
107
|
+
}
|
|
108
|
+
/** Skill folders committed at HEAD under the source prefix, with tree hashes.
|
|
109
|
+
* A folder that is a link in the working tree belongs to the repo it points
|
|
110
|
+
* at and is ingested from there. */
|
|
111
|
+
function listCommittedSkills(source) {
|
|
112
|
+
const out = git(source.repo, ['ls-tree', 'HEAD', `${source.prefix}/`]);
|
|
113
|
+
if (!out)
|
|
114
|
+
return [];
|
|
115
|
+
const skills = [];
|
|
116
|
+
for (const line of out.split('\n')) {
|
|
117
|
+
const m = line.match(/^\d+ tree ([0-9a-f]+)\t(.+)$/);
|
|
118
|
+
if (!m)
|
|
119
|
+
continue;
|
|
120
|
+
const folder = m[2];
|
|
121
|
+
const name = folder.split('/').pop() || '';
|
|
122
|
+
if (!SKILL_NAME_RE.test(name))
|
|
123
|
+
continue;
|
|
124
|
+
// A bundled skill's source is packages/cli/skill/<name>/ and the bundle serves it.
|
|
125
|
+
if (source.scope === 'global' && skill_landing_1.BUNDLED_SKILLS.includes(name))
|
|
126
|
+
continue;
|
|
127
|
+
if ((0, skill_mirror_files_1.isLinkedSkillFolder)(path.join(source.repo, ...folder.split('/'))))
|
|
128
|
+
continue;
|
|
129
|
+
skills.push({ name, folder, tree: m[1] });
|
|
130
|
+
}
|
|
131
|
+
return skills;
|
|
132
|
+
}
|
|
133
|
+
/** Read the committed mirror file set of each skill: SKILL.md plus
|
|
134
|
+
* `docs/**\/*.md`, the mirror's file contract. One `git cat-file --batch`. */
|
|
135
|
+
function readCommittedSkillFiles(repo, skills) {
|
|
136
|
+
const wanted = [];
|
|
137
|
+
if (!skills.length)
|
|
138
|
+
return new Map();
|
|
139
|
+
// One listing for every folder: a spawn per skill costs seconds on Windows.
|
|
140
|
+
const byFolder = new Map();
|
|
141
|
+
const listing = git(repo, ['ls-tree', '-r', '--name-only', 'HEAD', '--', ...new Set(skills.map(s => `${s.folder}/`))]) || '';
|
|
142
|
+
for (const p of listing.split('\n').filter(Boolean)) {
|
|
143
|
+
const skill = skills.find(s => p.startsWith(`${s.folder}/`));
|
|
144
|
+
if (skill)
|
|
145
|
+
byFolder.set(skill.folder, [...(byFolder.get(skill.folder) || []), p]);
|
|
146
|
+
}
|
|
147
|
+
for (const skill of skills) {
|
|
148
|
+
const rels = (byFolder.get(skill.folder) || []).map(p => p.slice(skill.folder.length + 1))
|
|
149
|
+
.filter(rel => rel === 'SKILL.md' || (rel.startsWith('docs/') && rel.toLowerCase().endsWith('.md')))
|
|
150
|
+
.sort((a, b) => (a === 'SKILL.md' ? -1 : b === 'SKILL.md' ? 1 : a.localeCompare(b)))
|
|
151
|
+
.slice(0, MAX_FILES);
|
|
152
|
+
if (!rels.includes('SKILL.md'))
|
|
153
|
+
continue;
|
|
154
|
+
for (const rel of rels)
|
|
155
|
+
wanted.push({ skill: skill.name, rel, spec: `HEAD:${skill.folder}/${rel}` });
|
|
156
|
+
}
|
|
157
|
+
const result = new Map();
|
|
158
|
+
if (!wanted.length)
|
|
159
|
+
return result;
|
|
160
|
+
const r = (0, proc_1.safeSpawnSync)('git', ['cat-file', '--batch'], {
|
|
161
|
+
cwd: repo, input: wanted.map(w => w.spec).join('\n') + '\n', timeout: 30_000, maxBuffer: 256 << 20,
|
|
162
|
+
});
|
|
163
|
+
if (r.status !== 0 || !r.stdout)
|
|
164
|
+
return result;
|
|
165
|
+
const buf = r.stdout;
|
|
166
|
+
let at = 0;
|
|
167
|
+
for (const w of wanted) {
|
|
168
|
+
const nl = buf.indexOf(0x0a, at);
|
|
169
|
+
const header = buf.subarray(at, nl).toString('utf8');
|
|
170
|
+
at = nl + 1;
|
|
171
|
+
const m = header.match(/^[0-9a-f]+ blob (\d+)$/);
|
|
172
|
+
if (!m)
|
|
173
|
+
continue;
|
|
174
|
+
const size = Number(m[1]);
|
|
175
|
+
const content = buf.subarray(at, at + size).toString('utf8');
|
|
176
|
+
at += size + 1;
|
|
177
|
+
if (content.length > MAX_FILE_CHARS)
|
|
178
|
+
continue;
|
|
179
|
+
const files = result.get(w.skill) || [];
|
|
180
|
+
files.push({ path: w.rel, content });
|
|
181
|
+
result.set(w.skill, files);
|
|
182
|
+
}
|
|
183
|
+
// A committed launcher is never content (adr/skill-adapter-never-canon.md).
|
|
184
|
+
for (const [name, files] of result) {
|
|
185
|
+
const main = files.find(f => f.path === 'SKILL.md');
|
|
186
|
+
if (!main || (0, skill_mirror_files_1.isGeneratedSkillAdapter)(main.content))
|
|
187
|
+
result.delete(name);
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function lastCommit(repo, folder) {
|
|
192
|
+
const out = (git(repo, ['log', '-1', '--format=%h%x00%s', 'HEAD', '--', folder]) || '').trim();
|
|
193
|
+
const [hash = 'unknown', subject = ''] = out.split('\0');
|
|
194
|
+
return { hash, subject };
|
|
195
|
+
}
|
|
196
|
+
function readState() {
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function writeState(state) {
|
|
205
|
+
try {
|
|
206
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
207
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 1));
|
|
208
|
+
}
|
|
209
|
+
catch { /* best effort: the next run re-reads every folder */ }
|
|
210
|
+
}
|
|
211
|
+
function stateKey(source, name) {
|
|
212
|
+
return `${source.scope}:${source.projectId || ''}:${name}`;
|
|
213
|
+
}
|
|
214
|
+
/** Upload every committed skill whose content differs from the mirror.
|
|
215
|
+
* `mirrorHashes` maps `<scope>:<name>` to the mirror's contentHash. `force`
|
|
216
|
+
* re-reads folders whose tree hash is unchanged since the last run. */
|
|
217
|
+
async function ingestCommittedSkills(params) {
|
|
218
|
+
const result = { uploaded: [], current: 0, failed: [] };
|
|
219
|
+
const state = readState();
|
|
220
|
+
for (const source of params.sources) {
|
|
221
|
+
const all = listCommittedSkills(source);
|
|
222
|
+
const changed = params.force ? all : all.filter(s => state[stateKey(source, s.name)] !== s.tree);
|
|
223
|
+
result.current += all.length - changed.length;
|
|
224
|
+
const files = readCommittedSkillFiles(source.repo, changed);
|
|
225
|
+
for (const skill of changed) {
|
|
226
|
+
const set = files.get(skill.name);
|
|
227
|
+
if (!set) {
|
|
228
|
+
state[stateKey(source, skill.name)] = skill.tree;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const contentHash = (0, skill_mirror_files_1.computeSkillFileHash)(set);
|
|
232
|
+
if (params.mirrorHashes.get(`${source.scope}:${skill.name}`) === contentHash) {
|
|
233
|
+
state[stateKey(source, skill.name)] = skill.tree;
|
|
234
|
+
result.current++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const commit = lastCommit(source.repo, skill.folder);
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch(`${params.apiUrl}/v1/skillmirror/${encodeURIComponent(skill.name)}`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Authorization': `Bearer ${params.apiKey}`, 'Content-Type': 'application/json' },
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
files: set,
|
|
244
|
+
contentHash,
|
|
245
|
+
scope: source.scope,
|
|
246
|
+
...(source.scope === 'project'
|
|
247
|
+
? { projectId: source.projectId, projectName: source.projectName, replaces: [`global:${skill.name}`] }
|
|
248
|
+
: {}),
|
|
249
|
+
reason: `git ${commit.hash}: ${commit.subject}`.slice(0, 500),
|
|
250
|
+
author: {
|
|
251
|
+
via: 'git-ingest',
|
|
252
|
+
commit: commit.hash,
|
|
253
|
+
...(params.harness ? { harness: params.harness } : {}),
|
|
254
|
+
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
signal: AbortSignal.timeout(15_000),
|
|
258
|
+
});
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
const body = await res.json().catch(() => ({}));
|
|
261
|
+
throw new Error(`HTTP ${res.status} ${body.detail || body.error || ''}`.trim());
|
|
262
|
+
}
|
|
263
|
+
state[stateKey(source, skill.name)] = skill.tree;
|
|
264
|
+
result.uploaded.push(skill.name);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
result.failed.push({ name: skill.name, error: err instanceof Error ? err.message : String(err) });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
writeState(state);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
/** The mirror's content hashes, keyed `<scope>:<name>`, from the list route. */
|
|
275
|
+
async function fetchMirrorHashes(apiUrl, apiKey, projectId) {
|
|
276
|
+
// Two plain lists, not `effective`: effective refuses a same-name project
|
|
277
|
+
// skill that predates `replaces`, and ingestion is what fixes that.
|
|
278
|
+
const queries = ['?scope=global', ...(projectId ? [`?scope=project&projectId=${encodeURIComponent(projectId)}`] : [])];
|
|
279
|
+
const out = new Map();
|
|
280
|
+
for (const q of queries) {
|
|
281
|
+
const res = await fetch(`${apiUrl}/v1/skillmirror${q}`, {
|
|
282
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
283
|
+
signal: AbortSignal.timeout(10_000),
|
|
284
|
+
});
|
|
285
|
+
if (!res.ok)
|
|
286
|
+
throw new Error(`skill mirror list failed: HTTP ${res.status}`);
|
|
287
|
+
const body = await res.json();
|
|
288
|
+
for (const s of body.skills || [])
|
|
289
|
+
out.set(`${s.scope}:${s.skillName}`, s.contentHash || '');
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
/** Both sources for a working directory: dotclaude's global skills and, in a
|
|
294
|
+
* project repo, its `.claude/skills`. */
|
|
295
|
+
function gitSkillSources(cwd) {
|
|
296
|
+
return [globalSkillSource(), projectSkillSource(cwd)].filter((s) => !!s);
|
|
297
|
+
}
|
package/dist/skill-landing.js
CHANGED
|
@@ -1,35 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/** Skill-gain
|
|
3
|
-
*
|
|
2
|
+
/** Skill-gain artifacts on disk: where a skill's folder is, and the undo for
|
|
3
|
+
* gains the Skill Learning Loop landed before 2026-10-13.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* docs/learned/<slug>.md the laser-focused learning doc
|
|
10
|
-
* SKILL.md (EOF) one FUSE line: the one-liner + the schematic
|
|
11
|
-
* telling the NEXT invocation exactly what to do —
|
|
12
|
-
* expand the doc, fold it in where it belongs,
|
|
13
|
-
* delete the fuse and the doc.
|
|
14
|
-
*
|
|
15
|
-
* The fuse is self-applying because it loads WITH the skill: the next agent
|
|
16
|
-
* to invoke the skill reads it as part of the skill's own instructions, does
|
|
17
|
-
* the short tune with the whole skill in context (the only actor that ever
|
|
18
|
-
* legitimately has it), and the skill converges clean. If a run skips the
|
|
19
|
-
* tune, the fuse persists as ordinary progressive disclosure — the learning
|
|
20
|
-
* stays live either way (graceful degradation, no failure mode).
|
|
21
|
-
*
|
|
22
|
-
* Guards: CAP 3 undigested fuses per skill (overflow waits server-side —
|
|
23
|
-
* a skill drowning in briefcases isn't being used anyway); every artifact
|
|
24
|
-
* stamped `skillgain:<id>`; `greprag skillgain reject <id>` is the undo
|
|
25
|
-
* (removes fuse + doc). Landing routes through resolveSkillDir — the ONE
|
|
26
|
-
* seam that swaps to the greprag-load skill store when skills move into the
|
|
27
|
-
* CLI (the no-SKILL.md endgame).
|
|
28
|
-
*
|
|
29
|
-
* Write-target routing (the trap): greprag's bundled skills publish from the
|
|
30
|
-
* repo source (packages/cli/skill/<name>/) — ~/.claude/skills/<name>/ is an
|
|
31
|
-
* init-overwritten artifact. Bundled + repo present → repo source; otherwise
|
|
32
|
-
* the global skills dir; neither → the gain stays pending. */
|
|
5
|
+
* GrepRAG no longer lands gains into skill folders or into `greprag load`
|
|
6
|
+
* payloads: git owns skills and GrepRAG never edits their content
|
|
7
|
+
* (adr/skill-ownership-git.md). `greprag skillgain reject <id>` still removes
|
|
8
|
+
* a fuse line and learned doc that an earlier version wrote. */
|
|
33
9
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
34
10
|
if (k2 === undefined) k2 = k;
|
|
35
11
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -64,27 +40,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
64
40
|
};
|
|
65
41
|
})();
|
|
66
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
67
|
-
exports.
|
|
43
|
+
exports.LEARNINGS_RELPATH = exports.LEARNED_DIR_RELPATH = exports.BUNDLED_SKILLS = void 0;
|
|
68
44
|
exports.resolveSkillDir = resolveSkillDir;
|
|
69
45
|
exports.hasGainStamp = hasGainStamp;
|
|
70
|
-
exports.countFuses = countFuses;
|
|
71
|
-
exports.buildFuseLine = buildFuseLine;
|
|
72
|
-
exports.buildLoadFuseBlock = buildLoadFuseBlock;
|
|
73
|
-
exports.landBriefcase = landBriefcase;
|
|
74
46
|
exports.removeLandedGain = removeLandedGain;
|
|
75
|
-
exports.landPendingGains = landPendingGains;
|
|
76
47
|
const path = __importStar(require("path"));
|
|
77
48
|
const fs = __importStar(require("fs"));
|
|
78
|
-
const skill_mirror_files_1 = require("./skill-mirror-files");
|
|
79
49
|
/** Skills that ship FROM the greprag repo — their canonical source is
|
|
80
50
|
* packages/cli/skill/<name>/; the installed copy is overwritten at init. */
|
|
81
51
|
exports.BUNDLED_SKILLS = ['greprag', 'commander', 'content-advisor', 'mechanic'];
|
|
82
52
|
exports.LEARNED_DIR_RELPATH = path.join('docs', 'learned');
|
|
83
53
|
/** v1 legacy zone — reject still scans it for stamps landed before the recut. */
|
|
84
54
|
exports.LEARNINGS_RELPATH = path.join('docs', 'learnings.md');
|
|
85
|
-
/** Max undigested fuse lines a SKILL.md may carry; past this, new gains wait
|
|
86
|
-
* server-side. Operator-ratified (2026-07-01). */
|
|
87
|
-
exports.MAX_FUSES_PER_SKILL = 3;
|
|
88
55
|
/** Resolve the canonical directory for a skill. `existsFn` injectable for
|
|
89
56
|
* tests. Order: bundled repo source (when cwd IS the greprag repo) → global
|
|
90
57
|
* skills dir → null (defer). THE storage seam — the greprag-load skill-store
|
|
@@ -105,115 +72,6 @@ function resolveSkillDir(skill, cwd, homeDir, existsFn = fs.existsSync) {
|
|
|
105
72
|
function hasGainStamp(content, nodeId) {
|
|
106
73
|
return content.includes(`skillgain:${nodeId}`);
|
|
107
74
|
}
|
|
108
|
-
/** Count undigested fuse lines in a SKILL.md body (digested ones were deleted
|
|
109
|
-
* by the tune, so the count self-heals as briefcases get absorbed). PURE. */
|
|
110
|
-
function countFuses(skillMdContent) {
|
|
111
|
-
return (skillMdContent.match(/<!-- skillgain:[0-9a-f]{8}/g) || []).length;
|
|
112
|
-
}
|
|
113
|
-
/** The fuse line — the briefcase's button. One line, blind (the one-liner
|
|
114
|
-
* names the learning; the mass lives in the doc), carrying the exact
|
|
115
|
-
* schematic for the next invocation. PURE — unit-tested. */
|
|
116
|
-
function buildFuseLine(gain, docRelPath, projectName, dateISO) {
|
|
117
|
-
// The anchor is the attribution, shown. A gain claims THIS skill by quoting
|
|
118
|
-
// this skill's own purpose back; printing that quote turns "is this even
|
|
119
|
-
// mine?" into a one-glance check instead of a trip through the learned doc.
|
|
120
|
-
// The 2026-09-03 field audit found 7 of 8 fuses on the wrong skill, and
|
|
121
|
-
// nothing on the line said why any of them belonged there.
|
|
122
|
-
// adr: adr/skill-gain-attribution.md
|
|
123
|
-
const claim = gain.anchor
|
|
124
|
-
? `Claims this skill via its purpose: "${gain.anchor}". `
|
|
125
|
-
: '';
|
|
126
|
-
return (`⚙ SKILL UPDATE PENDING (${gain.gainType}) — ${gain.oneLiner} `
|
|
127
|
-
+ claim
|
|
128
|
-
+ `→ BEFORE working: read ${docRelPath.replace(/\\/g, '/')}, fold the learning into this `
|
|
129
|
-
+ `skill where it belongs (one small edit, keep it lean), then DELETE this line and the doc. `
|
|
130
|
-
+ `Doesn't belong to this skill, or bogus? \`greprag skillgain reject ${gain.nodeId}\` instead. `
|
|
131
|
-
+ `<!-- skillgain:${gain.nodeId} ${dateISO.slice(0, 10)} project:${projectName} -->`);
|
|
132
|
-
}
|
|
133
|
-
/** Render pending gains for a mirror-managed skill into its `greprag load`
|
|
134
|
-
* payload — the fuse's replacement for skills whose SKILL.md is a generated
|
|
135
|
-
* adapter nothing local may write to.
|
|
136
|
-
*
|
|
137
|
-
* Carries strictly more than the fuse line did: the learning doc is inlined
|
|
138
|
-
* rather than pointed at, because a managed skill has no local docs/learned/
|
|
139
|
-
* to read. Empty string when there is nothing pending, so the caller can
|
|
140
|
-
* append unconditionally. PURE — unit-tested. adr: adr/skill-fuse-delivery.md */
|
|
141
|
-
function buildLoadFuseBlock(skill, gains) {
|
|
142
|
-
const usable = gains.filter(g => g.oneLiner && g.skill === skill);
|
|
143
|
-
if (usable.length === 0)
|
|
144
|
-
return '';
|
|
145
|
-
const head = usable.length === 1
|
|
146
|
-
? `⚙ 1 PENDING SKILL UPDATE for ${skill} — fold it in before acting on this skill.`
|
|
147
|
-
: `⚙ ${usable.length} PENDING SKILL UPDATES for ${skill} — fold them in before acting on this skill.`;
|
|
148
|
-
const items = usable.map((g, i) => {
|
|
149
|
-
const claim = g.anchor
|
|
150
|
-
? `\n Claims this skill via its purpose: "${g.anchor}"`
|
|
151
|
-
: '';
|
|
152
|
-
const doc = g.docBody ? `\n\n${g.docBody.trim()}\n` : '';
|
|
153
|
-
return (`${i + 1}. (${g.gainType}) ${g.oneLiner}${claim}${doc}\n`
|
|
154
|
-
+ ` Digest: make the edit, then \`greprag skillgain done ${g.nodeId}\`.\n`
|
|
155
|
-
+ ` Doesn't belong to this skill, or bogus? \`greprag skillgain reject ${g.nodeId}\`.`);
|
|
156
|
-
});
|
|
157
|
-
return `\n---\n\n${head}\n\n${items.join('\n\n')}\n`;
|
|
158
|
-
}
|
|
159
|
-
/** A generated mirror adapter is machine-owned: regenerated from the mirror on
|
|
160
|
-
* any sync, hash-locked against edits in between. One recogniser for the whole
|
|
161
|
-
* CLI lives in `skill-mirror-files`. */
|
|
162
|
-
var skill_mirror_files_2 = require("./skill-mirror-files");
|
|
163
|
-
Object.defineProperty(exports, "isGeneratedAdapter", { enumerable: true, get: function () { return skill_mirror_files_2.isGeneratedSkillAdapter; } });
|
|
164
|
-
/** Land ONE briefcase: write the learned doc + append the fuse to SKILL.md.
|
|
165
|
-
* Deterministic file work only — never throws (failure defers the gain). */
|
|
166
|
-
function landBriefcase(gain, cwd, homeDir, projectName) {
|
|
167
|
-
try {
|
|
168
|
-
if (!gain.oneLiner || !gain.docSlug || !gain.docBody)
|
|
169
|
-
return 'deferred'; // payload-less legacy row
|
|
170
|
-
const dir = resolveSkillDir(gain.skill, cwd, homeDir);
|
|
171
|
-
if (!dir)
|
|
172
|
-
return 'deferred';
|
|
173
|
-
// A linked skill folder belongs to its target (a tracked repo file, another
|
|
174
|
-
// installer's store), so the fuse is not GrepRAG's to write there. The gain
|
|
175
|
-
// stays pending; a mirrored skill still gets it from `greprag load`.
|
|
176
|
-
// adr: adr/skill-fuse-delivery.md
|
|
177
|
-
if ((0, skill_mirror_files_1.isSkillFolderOwnedElsewhere)(dir))
|
|
178
|
-
return 'deferred';
|
|
179
|
-
const skillMd = path.join(dir, 'SKILL.md');
|
|
180
|
-
let body;
|
|
181
|
-
try {
|
|
182
|
-
body = fs.readFileSync(skillMd, 'utf-8');
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
return 'deferred';
|
|
186
|
-
}
|
|
187
|
-
if (hasGainStamp(body, gain.nodeId))
|
|
188
|
-
return 'already-landed';
|
|
189
|
-
// A mirror-managed skill's SKILL.md is a GENERATED adapter: the mirror
|
|
190
|
-
// regenerates it on every sync and hash-locks it in between. Appending a
|
|
191
|
-
// fuse there makes two writers own one file — the fuse is wiped by the next
|
|
192
|
-
// sync, or (worse) the divergence guard refuses the sync and the operator
|
|
193
|
-
// has to untangle it by hand. Happened twice on 2026-09-04.
|
|
194
|
-
//
|
|
195
|
-
// For these skills the fuse is served by `greprag load` instead, which is
|
|
196
|
-
// the payload the adapter tells the agent to read — same arrival moment,
|
|
197
|
-
// one writer. The gain stays PENDING so the load path keeps surfacing it
|
|
198
|
-
// until it is digested. adr: adr/skill-fuse-delivery.md
|
|
199
|
-
if ((0, skill_mirror_files_1.isGeneratedSkillAdapter)(body))
|
|
200
|
-
return 'served-by-load';
|
|
201
|
-
if (countFuses(body) >= exports.MAX_FUSES_PER_SKILL)
|
|
202
|
-
return 'capped';
|
|
203
|
-
// Doc first (a fuse pointing at a missing doc is the one broken state).
|
|
204
|
-
const docRel = path.join(exports.LEARNED_DIR_RELPATH, `${gain.docSlug}.md`);
|
|
205
|
-
const docAbs = path.join(dir, docRel);
|
|
206
|
-
fs.mkdirSync(path.dirname(docAbs), { recursive: true });
|
|
207
|
-
fs.writeFileSync(docAbs, gain.docBody.trimEnd()
|
|
208
|
-
+ `\n\n<!-- skillgain:${gain.nodeId} — auto-landed by the Skill Learning Loop; deleted when digested -->\n`);
|
|
209
|
-
const fuse = buildFuseLine(gain, docRel, projectName, new Date().toISOString());
|
|
210
|
-
fs.writeFileSync(skillMd, body + (body.endsWith('\n') ? '' : '\n') + fuse + '\n');
|
|
211
|
-
return 'landed';
|
|
212
|
-
}
|
|
213
|
-
catch {
|
|
214
|
-
return 'deferred';
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
75
|
/** Remove a gain's artifacts wherever they landed — the `skillgain reject`
|
|
218
76
|
* undo: the fuse line (SKILL.md), the learned doc, and any v1-legacy
|
|
219
77
|
* learnings.md line. Returns true if anything was removed. Never throws. */
|
|
@@ -254,27 +112,3 @@ function removeLandedGain(skill, nodeId, cwd, homeDir) {
|
|
|
254
112
|
return false;
|
|
255
113
|
}
|
|
256
114
|
}
|
|
257
|
-
/** Bound the file work per session start; overflow waits (still pending). */
|
|
258
|
-
const MAX_LANDS_PER_START = 5;
|
|
259
|
-
/** The full landing pass. Every gain with a payload lands as a briefcase
|
|
260
|
-
* (capped per skill + per start); payload-less/unroutable gains stay pending.
|
|
261
|
-
* Returns the report (announce) + the status updates to POST back. */
|
|
262
|
-
function landPendingGains(pending, cwd, homeDir, projectName) {
|
|
263
|
-
const report = { landed: [] };
|
|
264
|
-
const statusUpdates = [];
|
|
265
|
-
for (const gain of pending) {
|
|
266
|
-
if (report.landed.length >= MAX_LANDS_PER_START)
|
|
267
|
-
break;
|
|
268
|
-
const outcome = landBriefcase(gain, cwd, homeDir, projectName);
|
|
269
|
-
if (outcome === 'landed' || outcome === 'already-landed') {
|
|
270
|
-
statusUpdates.push({ nodeId: gain.nodeId, status: 'landed' });
|
|
271
|
-
if (outcome === 'landed') {
|
|
272
|
-
report.landed.push({
|
|
273
|
-
nodeId: gain.nodeId, skill: gain.skill, oneLiner: gain.oneLiner || gain.text,
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
// 'capped' / 'deferred' → stays pending, retried next session start
|
|
278
|
-
}
|
|
279
|
-
return { report, statusUpdates };
|
|
280
|
-
}
|