greprag 5.80.0 → 5.82.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.
Files changed (63) hide show
  1. package/dist/capture-manifest.js +2 -1
  2. package/dist/codex-fast-hook.js +6 -0
  3. package/dist/codex-steering.js +1 -1
  4. package/dist/commands/app-model.js +0 -1
  5. package/dist/commands/arm-reminder.js +9 -7
  6. package/dist/commands/collision-check.js +7 -6
  7. package/dist/commands/corpus/client.js +13 -3
  8. package/dist/commands/delivery-reminder.js +35 -14
  9. package/dist/commands/deploy-gate.js +55 -0
  10. package/dist/commands/deploy-lock.js +100 -0
  11. package/dist/commands/deploy-record.js +145 -0
  12. package/dist/commands/deploy-verify.js +111 -0
  13. package/dist/commands/inbox-primer-reminder.js +5 -5
  14. package/dist/commands/inbox-watch.js +2 -4
  15. package/dist/commands/init.js +82 -0
  16. package/dist/commands/load.js +40 -0
  17. package/dist/commands/loadout-reminder.js +1 -1
  18. package/dist/commands/merge-guard.js +419 -0
  19. package/dist/commands/merge-lock.js +176 -0
  20. package/dist/commands/parity-reminder.js +53 -0
  21. package/dist/commands/persona-reminder.js +11 -0
  22. package/dist/commands/persona.js +50 -0
  23. package/dist/commands/procedure.js +77 -6
  24. package/dist/commands/reminder-registry.js +21 -5
  25. package/dist/commands/repodoc.js +433 -0
  26. package/dist/commands/search.js +149 -0
  27. package/dist/commands/skillgain.js +33 -25
  28. package/dist/delivery-lifecycle.js +16 -1
  29. package/dist/deploy-gate.js +355 -0
  30. package/dist/deploy-locks.js +339 -0
  31. package/dist/deploy-verify.js +209 -0
  32. package/dist/env-redaction.js +157 -0
  33. package/dist/harness-limits.js +17 -0
  34. package/dist/hook-runtime.js +11 -1
  35. package/dist/hook.js +170 -88
  36. package/dist/index.js +593 -567
  37. package/dist/inline-atom-episode.js +15 -7
  38. package/dist/inline-atom.js +8 -2
  39. package/dist/native-skill-adoption.js +11 -0
  40. package/dist/native-skill-mirror.js +8 -1
  41. package/dist/node-identity.bundle.js +1166 -0
  42. package/dist/opencode-plugin.bundle.js +307 -119
  43. package/dist/procedure-enabled.js +55 -0
  44. package/dist/procedure-runtime.js +6 -0
  45. package/dist/procedure-scope.js +190 -0
  46. package/dist/procedure-watch.js +29 -16
  47. package/dist/procedure.js +111 -5
  48. package/dist/project-anchor.js +1 -14
  49. package/dist/reminder-injector.js +11 -10
  50. package/dist/repodoc-client.js +296 -0
  51. package/dist/session-id.js +7 -8
  52. package/dist/skill-landing.js +57 -2
  53. package/dist/skill-mirror-client.js +14 -0
  54. package/dist/skill-mirror-files.js +18 -0
  55. package/package.json +2 -2
  56. package/scripts/bundle-node-identity.mjs +47 -0
  57. package/skill/templates/chip-spawn.md +7 -1
  58. package/skill/templates/delivery.md +105 -0
  59. package/skill/templates/prompt-audit.md +196 -0
  60. package/skill/templates/skill-change.md +25 -2
  61. package/dist/assistant-doctrine.js +0 -85
  62. package/dist/commands/assistant-reminder.js +0 -19
  63. package/dist/commands/assistant.js +0 -95
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ /** Keep a repository's chunk index rebuilt as its documents change.
3
+ *
4
+ * The chunks are a DERIVED index, not a second copy of the truth: the document
5
+ * is authoritative, the node graph beside it records what the document looked
6
+ * like last time, and the chunks are regenerated from those two. A derived
7
+ * thing maintained by hand drifts from its source and starts behaving like a
8
+ * second truth, which is what `greprag repodoc push` was on its way to
9
+ * becoming — correct only for as long as someone remembered to run it.
10
+ *
11
+ * So this rides the Stop hook, exactly as the skill mirror and doc pointers do.
12
+ * No new hook: the hook already knows every file the turn wrote, so a markdown
13
+ * write is already an event. For each touched document the graph decides
14
+ * whether anything moved (its body hash plus the fingerprints), and only what
15
+ * moved is rebuilt and shipped. An unchanged document costs one read and one
16
+ * hash, and no network at all.
17
+ *
18
+ * Best-effort throughout. A failure here must never fail a turn — a stale chunk
19
+ * index is a worse search result, while a thrown Stop hook is lost work.
20
+ *
21
+ * adr: adr/node-identity-port.md
22
+ */
23
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ var desc = Object.getOwnPropertyDescriptor(m, k);
26
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
27
+ desc = { enumerable: true, get: function() { return m[k]; } };
28
+ }
29
+ Object.defineProperty(o, k2, desc);
30
+ }) : (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ o[k2] = m[k];
33
+ }));
34
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
36
+ }) : function(o, v) {
37
+ o["default"] = v;
38
+ });
39
+ var __importStar = (this && this.__importStar) || (function () {
40
+ var ownKeys = function(o) {
41
+ ownKeys = Object.getOwnPropertyNames || function (o) {
42
+ var ar = [];
43
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
44
+ return ar;
45
+ };
46
+ return ownKeys(o);
47
+ };
48
+ return function (mod) {
49
+ if (mod && mod.__esModule) return mod;
50
+ var result = {};
51
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
52
+ __setModuleDefault(result, mod);
53
+ return result;
54
+ };
55
+ })();
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.isGeneratedAdapter = void 0;
58
+ exports.isRepoDocEligible = isRepoDocEligible;
59
+ exports.maybeRebuildChunks = maybeRebuildChunks;
60
+ const child_process_1 = require("child_process");
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ const skill_mirror_files_1 = require("./skill-mirror-files");
64
+ let cachedEngine;
65
+ /** Loaded lazily and at most once. A missing bundle means a partial build, not
66
+ * a reason to fail a turn, so it resolves to null and every caller no-ops. */
67
+ function engine() {
68
+ if (cachedEngine !== undefined)
69
+ return cachedEngine;
70
+ try {
71
+ const p = path.join(__dirname, 'node-identity.bundle.js');
72
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
73
+ cachedEngine = fs.existsSync(p) ? require(p) : null;
74
+ }
75
+ catch {
76
+ cachedEngine = null;
77
+ }
78
+ return cachedEngine;
79
+ }
80
+ // ---------- repo gating -------------------------------------------------------
81
+ /** The directory holding a repository's node graphs. Its EXISTENCE is the
82
+ * opt-in: a repository nobody has indexed is never touched, so an agent
83
+ * wandering through an unrelated checkout cannot start writing graphs into it.
84
+ * Opting in stays a deliberate `greprag repodoc index <path> --write`. */
85
+ const GRAPH_DIR = path.join('.greprag', 'node-identity');
86
+ function findRepoRoot(start) {
87
+ let dir = path.resolve(start);
88
+ for (;;) {
89
+ if (fs.existsSync(path.join(dir, '.git')))
90
+ return dir;
91
+ const parent = path.dirname(dir);
92
+ if (parent === dir)
93
+ return null;
94
+ dir = parent;
95
+ }
96
+ }
97
+ /** Repository name, from git's COMMON directory so a worktree writes into the
98
+ * same store as the checkout it was cut from. Mirrors `commands/repodoc.ts`. */
99
+ function repoName(repoRoot) {
100
+ try {
101
+ const common = (0, child_process_1.execFileSync)('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
102
+ cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
103
+ }).trim();
104
+ if (common)
105
+ return path.basename(path.dirname(common));
106
+ }
107
+ catch { /* not a checkout, or git absent */ }
108
+ return path.basename(repoRoot);
109
+ }
110
+ /** THE eligibility filter. `commands/repodoc.ts` imports this rather than
111
+ * keeping its own, because the two disagreeing is a drift generator: the
112
+ * command would index a document the hook then refuses to maintain, leaving it
113
+ * frozen at whatever it said the day it was indexed. Whatever can be indexed
114
+ * must be maintainable, and that is one predicate, in one place.
115
+ *
116
+ * Rejects the same never-index classes the doc-pointer filter does: harness and
117
+ * scratch directories, dependency and build trees, and CLAUDE.md / MEMORY.md,
118
+ * which are injected into every session anyway. */
119
+ function isRepoDocEligible(relPath) {
120
+ const p = relPath.replace(/\\/g, '/');
121
+ if (!p.toLowerCase().endsWith('.md'))
122
+ return false;
123
+ const segments = p.split('/');
124
+ const base = segments[segments.length - 1];
125
+ if (base === 'CLAUDE.md' || base === 'MEMORY.md')
126
+ return false;
127
+ for (const seg of segments.slice(0, -1)) {
128
+ if (seg.startsWith('.'))
129
+ return false;
130
+ if (seg === 'node_modules' || seg === 'dist' || seg === 'build' || seg === 'vendor')
131
+ return false;
132
+ }
133
+ return true;
134
+ }
135
+ /** A generated launcher, not skill content.
136
+ *
137
+ * About half the skills on disk are disposable adapters that greprag itself
138
+ * writes — a few lines telling the harness to run `greprag load <name>`, with
139
+ * the real body served from the skill mirror. Indexing them would fill the
140
+ * store with near-identical stubs that match almost any query about skills
141
+ * while containing no skill, and re-generating one would churn its chunks for
142
+ * no reason. One recogniser, shared with the mirror's upload and load paths. */
143
+ var skill_mirror_files_2 = require("./skill-mirror-files");
144
+ Object.defineProperty(exports, "isGeneratedAdapter", { enumerable: true, get: function () { return skill_mirror_files_2.isGeneratedSkillAdapter; } });
145
+ // ---------- rebuild -----------------------------------------------------------
146
+ /** Ceiling on documents rebuilt per turn. A turn that rewrote hundreds of files
147
+ * is a bulk edit; that is `greprag repodoc push`'s job, with its progress
148
+ * output, rather than something to do silently inside a Stop hook. */
149
+ const MAX_DOCS_PER_TURN = 25;
150
+ const NOTHING = { rebuilt: 0, unchanged: 0, deferred: 0 };
151
+ /** Rebuild the chunk index for whichever documents this turn actually changed.
152
+ *
153
+ * Returns counts for logging. Never throws. */
154
+ async function maybeRebuildChunks(params) {
155
+ try {
156
+ if (!params.apiKey || params.filesTouched.length === 0)
157
+ return NOTHING;
158
+ const eng = engine();
159
+ if (!eng)
160
+ return NOTHING;
161
+ // Group by each file's OWN repository, not the session's.
162
+ //
163
+ // A turn routinely writes outside the directory it started in: editing a
164
+ // skill under ~/.claude while working in a project is the ordinary case, and
165
+ // that is exactly the content most worth keeping fresh. Resolving one root
166
+ // from cwd would discard every such write as "outside the repo", so skills
167
+ // would go stale the moment they were indexed — the failure this whole
168
+ // mechanism exists to prevent.
169
+ const byRepo = new Map();
170
+ for (const abs of params.filesTouched) {
171
+ const root = findRepoRoot(path.dirname(abs));
172
+ if (!root)
173
+ continue;
174
+ // The opt-in gate, per repository. No graphs, no involvement.
175
+ if (!fs.existsSync(path.join(root, GRAPH_DIR)))
176
+ continue;
177
+ const group = byRepo.get(root);
178
+ if (group)
179
+ group.push(abs);
180
+ else
181
+ byRepo.set(root, [abs]);
182
+ }
183
+ if (byRepo.size === 0)
184
+ return NOTHING;
185
+ const totals = { rebuilt: 0, unchanged: 0, deferred: 0 };
186
+ for (const [root, files] of byRepo) {
187
+ const one = await rebuildOne(eng, root, files, params.apiUrl, params.apiKey);
188
+ totals.rebuilt += one.rebuilt;
189
+ totals.unchanged += one.unchanged;
190
+ totals.deferred += one.deferred;
191
+ }
192
+ return totals;
193
+ }
194
+ catch {
195
+ return NOTHING;
196
+ }
197
+ }
198
+ /** One repository's share of a turn. Isolated so a failure in one repository
199
+ * cannot cost another its rebuild. */
200
+ async function rebuildOne(eng, repoRoot, filesTouched, apiUrl, apiKey) {
201
+ try {
202
+ const documents = [];
203
+ const pending = [];
204
+ let unchanged = 0;
205
+ let deferred = 0;
206
+ const seen = new Set();
207
+ for (const abs of filesTouched) {
208
+ const rel = path.relative(repoRoot, abs).replace(/\\/g, '/');
209
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
210
+ continue;
211
+ if (!isRepoDocEligible(rel) || seen.has(rel))
212
+ continue;
213
+ seen.add(rel);
214
+ if (documents.length >= MAX_DOCS_PER_TURN) {
215
+ deferred++;
216
+ continue;
217
+ }
218
+ const sidecarRel = eng.sidecarPathFor(rel);
219
+ if (!sidecarRel)
220
+ continue;
221
+ let body;
222
+ try {
223
+ body = fs.readFileSync(abs, 'utf-8');
224
+ }
225
+ catch {
226
+ // Written then deleted within the turn. The store keeps its chunks until
227
+ // the document is retired explicitly; see the ADR's known gap.
228
+ continue;
229
+ }
230
+ if ((0, skill_mirror_files_1.isGeneratedSkillAdapter)(body))
231
+ continue;
232
+ const sidecarAbs = path.join(repoRoot, sidecarRel);
233
+ let stored = null;
234
+ try {
235
+ if (fs.existsSync(sidecarAbs)) {
236
+ stored = eng.parseNodeGraph(fs.readFileSync(sidecarAbs, 'utf-8'));
237
+ }
238
+ }
239
+ catch {
240
+ stored = null;
241
+ }
242
+ const result = eng.reconcile(stored, eng.markdownToBlocks(body), { path: rel, body });
243
+ // The change gate. Nothing moved means nothing to write and nothing to
244
+ // ship — the whole point of storing a graph rather than re-deriving one.
245
+ if (!result.changed) {
246
+ unchanged++;
247
+ continue;
248
+ }
249
+ documents.push({ path: rel, blocks: result.blocks });
250
+ pending.push({ sidecar: sidecarAbs, text: eng.serializeNodeGraph(result.graph) });
251
+ }
252
+ if (documents.length === 0)
253
+ return { rebuilt: 0, unchanged, deferred };
254
+ // Ship first, then persist the graphs. If the push fails the graphs stay as
255
+ // they were, so the next turn sees the same documents as still-changed and
256
+ // tries again. Writing the graphs first would mark them current against a
257
+ // store that never received them, and the drift would be permanent.
258
+ await pushDocuments(apiUrl, apiKey, repoName(repoRoot), documents);
259
+ for (const p of pending) {
260
+ try {
261
+ fs.mkdirSync(path.dirname(p.sidecar), { recursive: true });
262
+ fs.writeFileSync(p.sidecar, p.text, 'utf-8');
263
+ }
264
+ catch { /* unwritable — next turn retries */ }
265
+ }
266
+ return { rebuilt: documents.length, unchanged, deferred };
267
+ }
268
+ catch {
269
+ return NOTHING;
270
+ }
271
+ }
272
+ async function pushDocuments(apiUrl, apiKey, project, documents) {
273
+ const res = await fetch(`${apiUrl}/v1/repodoc/${encodeURIComponent(project)}`, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
276
+ body: JSON.stringify({
277
+ documents: documents.map(d => ({
278
+ path: d.path,
279
+ blocks: d.blocks.map(b => ({
280
+ id: b.id,
281
+ position: b.position,
282
+ type: b.type,
283
+ text: b.text,
284
+ parentPosition: b.parentPosition,
285
+ ordinalInParent: b.ordinalInParent,
286
+ ...(b.level !== undefined ? { level: b.level } : {}),
287
+ ...(b.language !== undefined ? { language: b.language } : {}),
288
+ headingPath: b.headingPath,
289
+ })),
290
+ })),
291
+ }),
292
+ });
293
+ if (!res.ok) {
294
+ throw new Error(`repodoc push ${res.status}`);
295
+ }
296
+ }
@@ -205,8 +205,8 @@ function buildSessionIdContext(short, alias = null) {
205
205
  * made the wrapper unsafe before. The layers: this loop relaunches a dead
206
206
  * SUPERVISOR; the supervisor respawns a dead SSE CHILD; EPIPE-terminal stops
207
207
  * everything when the consumer leaves. adr: adr/monitor-resilience.md */
208
- function armMonitorCommand(short, ownerPid, assistant = false, mechanic = false, platform) {
209
- const role = mechanic ? ' --mechanic' : (assistant ? ' --assistant' : '');
208
+ function armMonitorCommand(short, ownerPid, mechanic = false, platform) {
209
+ const role = mechanic ? ' --mechanic' : '';
210
210
  // Grok monitor merges stderr into wake events and does not restart on exit.
211
211
  // Bare quiet watch: JSON mail on stdout only. No bash wrapper, no --owner-pid.
212
212
  // adr: adr/grok-platform.md
@@ -217,10 +217,9 @@ function armMonitorCommand(short, ownerPid, assistant = false, mechanic = false,
217
217
  }
218
218
  // --owner-pid is stamped for audit continuity only; the count-cap never reads it.
219
219
  const owner = ownerPid ? ` --owner-pid ${ownerPid}` : '';
220
- // --assistant elevates the watcher to the tenant's Assistant subscription
221
- // (session ∪ inbound-email arrivals) — added ONLY for the designated assistant
222
- // project (isAssistantProject), so a normal session arms a stock watcher.
223
- // adr: adr/assistant-role.md
220
+ // --mechanic elevates the watcher to the live-Mechanic subscription (session ∪
221
+ // tenant mechanic_friction) — added ONLY for the designated mechanic project,
222
+ // so a normal session arms a stock watcher. adr: adr/assistant-role.md
224
223
  const watch = `greprag inbox watch --session ${short} --json${owner}${role}`;
225
224
  // Break on 0 (consumer-gone / clean / signal) and 64 (FATAL: bad key) — both are
226
225
  // intentional terminals; relaunch on any other code (a crash). `sleep 1` floors
@@ -230,9 +229,9 @@ function armMonitorCommand(short, ownerPid, assistant = false, mechanic = false,
230
229
  /** Backward-compatible copyable watcher directive for older hook consumers.
231
230
  * Current hooks use the detection-gated reminder registry, but keeping this
232
231
  * pure formatter exported preserves the public helper and older integrations. */
233
- function buildArmDirective(short, alias = null, ownerPid, assistant = false, mechanic = false) {
232
+ function buildArmDirective(short, alias = null, ownerPid, mechanic = false) {
234
233
  const reply = `${alias || '<handle>'}@greprag.com/${short}`;
235
- const command = armMonitorCommand(short, ownerPid, assistant, mechanic, undefined);
234
+ const command = armMonitorCommand(short, ownerPid, mechanic, undefined);
236
235
  return 'STOP IMMEDIATELY AND READ THIS. '
237
236
  + `No live inbox watcher is armed for ${reply}. `
238
237
  + 'ToolSearch query "select:Monitor" and arm a persistent:true Monitor '
@@ -64,16 +64,18 @@ var __importStar = (this && this.__importStar) || (function () {
64
64
  };
65
65
  })();
66
66
  Object.defineProperty(exports, "__esModule", { value: true });
67
- exports.MAX_FUSES_PER_SKILL = exports.LEARNINGS_RELPATH = exports.LEARNED_DIR_RELPATH = exports.BUNDLED_SKILLS = void 0;
67
+ exports.isGeneratedAdapter = exports.MAX_FUSES_PER_SKILL = exports.LEARNINGS_RELPATH = exports.LEARNED_DIR_RELPATH = exports.BUNDLED_SKILLS = void 0;
68
68
  exports.resolveSkillDir = resolveSkillDir;
69
69
  exports.hasGainStamp = hasGainStamp;
70
70
  exports.countFuses = countFuses;
71
71
  exports.buildFuseLine = buildFuseLine;
72
+ exports.buildLoadFuseBlock = buildLoadFuseBlock;
72
73
  exports.landBriefcase = landBriefcase;
73
74
  exports.removeLandedGain = removeLandedGain;
74
75
  exports.landPendingGains = landPendingGains;
75
76
  const path = __importStar(require("path"));
76
77
  const fs = __importStar(require("fs"));
78
+ const skill_mirror_files_1 = require("./skill-mirror-files");
77
79
  /** Skills that ship FROM the greprag repo — their canonical source is
78
80
  * packages/cli/skill/<name>/; the installed copy is overwritten at init. */
79
81
  exports.BUNDLED_SKILLS = ['greprag', 'commander', 'content-advisor', 'mechanic'];
@@ -112,12 +114,53 @@ function countFuses(skillMdContent) {
112
114
  * names the learning; the mass lives in the doc), carrying the exact
113
115
  * schematic for the next invocation. PURE — unit-tested. */
114
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
+ : '';
115
126
  return (`⚙ SKILL UPDATE PENDING (${gain.gainType}) — ${gain.oneLiner} `
127
+ + claim
116
128
  + `→ BEFORE working: read ${docRelPath.replace(/\\/g, '/')}, fold the learning into this `
117
129
  + `skill where it belongs (one small edit, keep it lean), then DELETE this line and the doc. `
118
- + `Bogus? \`greprag skillgain reject ${gain.nodeId}\` instead. `
130
+ + `Doesn't belong to this skill, or bogus? \`greprag skillgain reject ${gain.nodeId}\` instead. `
119
131
  + `<!-- skillgain:${gain.nodeId} ${dateISO.slice(0, 10)} project:${projectName} -->`);
120
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; } });
121
164
  /** Land ONE briefcase: write the learned doc + append the fuse to SKILL.md.
122
165
  * Deterministic file work only — never throws (failure defers the gain). */
123
166
  function landBriefcase(gain, cwd, homeDir, projectName) {
@@ -137,6 +180,18 @@ function landBriefcase(gain, cwd, homeDir, projectName) {
137
180
  }
138
181
  if (hasGainStamp(body, gain.nodeId))
139
182
  return 'already-landed';
183
+ // A mirror-managed skill's SKILL.md is a GENERATED adapter: the mirror
184
+ // regenerates it on every sync and hash-locks it in between. Appending a
185
+ // fuse there makes two writers own one file — the fuse is wiped by the next
186
+ // sync, or (worse) the divergence guard refuses the sync and the operator
187
+ // has to untangle it by hand. Happened twice on 2026-09-04.
188
+ //
189
+ // For these skills the fuse is served by `greprag load` instead, which is
190
+ // the payload the adapter tells the agent to read — same arrival moment,
191
+ // one writer. The gain stays PENDING so the load path keeps surfacing it
192
+ // until it is digested. adr: adr/skill-fuse-delivery.md
193
+ if ((0, skill_mirror_files_1.isGeneratedSkillAdapter)(body))
194
+ return 'served-by-load';
140
195
  if (countFuses(body) >= exports.MAX_FUSES_PER_SKILL)
141
196
  return 'capped';
142
197
  // Doc first (a fuse pointing at a missing doc is the one broken state).
@@ -295,6 +295,20 @@ async function maybeMirrorSkill(params) {
295
295
  const files = collectSkillFiles(dir);
296
296
  if (!files)
297
297
  return 'skipped';
298
+ // The disk copy of an already-mirrored skill is the generated ADAPTER that
299
+ // `materializeLocally` wrote below — a launcher whose whole body says "run
300
+ // `greprag load <name>`". Uploading it makes it canon, and `greprag load`
301
+ // then serves the launcher back to the agent that was told to run it: the
302
+ // skill's real instructions are gone and nothing errors. That loop closed
303
+ // here, one turn after every first mirror, and had already eaten the
304
+ // canonical body of skills including anti-ai, chrome, gmail and root-cause.
305
+ //
306
+ // An adapter is never a mirror source. Canon changes through the guarded
307
+ // edit path (`greprag skill edit` / `skill mirror apply`), never by
308
+ // shadowing a file greprag itself generated. adr: adr/skill-adapter-never-canon.md
309
+ const main = files.find(file => file.path === 'SKILL.md');
310
+ if (main && (0, skill_mirror_files_1.isGeneratedSkillAdapter)(main.content))
311
+ return 'skipped';
298
312
  const hash = computeMirrorHash(files);
299
313
  const state = readMirrorState();
300
314
  const localLease = state.skills[params.skill]?.hash;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.computeSkillFileHash = computeSkillFileHash;
37
+ exports.isGeneratedSkillAdapter = isGeneratedSkillAdapter;
37
38
  exports.isSafeMirroredSkillFile = isSafeMirroredSkillFile;
38
39
  const crypto = __importStar(require("node:crypto"));
39
40
  function computeSkillFileHash(files) {
@@ -43,6 +44,23 @@ function computeSkillFileHash(files) {
43
44
  }
44
45
  return hash.digest('hex');
45
46
  }
47
+ /** The heading every generated mirror adapter carries. A file with this in its
48
+ * header is machine-owned: a disposable launcher that tells the agent to run
49
+ * `greprag load <name>`, never canonical skill content. */
50
+ const GENERATED_ADAPTER_MARKER = '## GENERATED ADAPTER — NEVER EDIT';
51
+ /** True when this SKILL.md is a generated adapter rather than a real skill body.
52
+ *
53
+ * Checked against the HEAD of the body, after the YAML frontmatter: the marker
54
+ * is a header, and a document that merely discusses adapters further down is
55
+ * real content. Frontmatter must be stripped first — an adapter's `description`
56
+ * block alone can run past any fixed byte window, which is how 53 adapters
57
+ * slipped past a raw first-400-bytes check. PURE.
58
+ *
59
+ * adr: adr/skill-adapter-never-canon.md */
60
+ function isGeneratedSkillAdapter(skillMdBody) {
61
+ const body = skillMdBody.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
62
+ return body.slice(0, 400).includes(GENERATED_ADAPTER_MARKER);
63
+ }
46
64
  function isSafeMirroredSkillFile(file) {
47
65
  if (file.path === 'SKILL.md')
48
66
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "greprag",
3
- "version": "5.80.0",
3
+ "version": "5.82.0",
4
4
  "description": "Private GrepRAG client for existing Claude Code, Codex, OpenCode, and Grok Build tenants.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "greprag-codex-hook": "dist/codex-fast-hook.js"
10
10
  },
11
11
  "scripts": {
12
- "build": "rimraf dist && tsc && node scripts/bundle-opencode-plugin.mjs",
12
+ "build": "rimraf dist && tsc && node scripts/bundle-opencode-plugin.mjs && node scripts/bundle-node-identity.mjs",
13
13
  "clean": "rimraf dist",
14
14
  "postinstall": "node scripts/postinstall.js"
15
15
  },
@@ -0,0 +1,47 @@
1
+ /**
2
+ * bundle-node-identity.mjs — compile the edit-surviving matcher into a single
3
+ * self-contained artifact the CLI can require at runtime.
4
+ *
5
+ * WHY THIS EXISTS — the CLI is a zero-dependency tsc artifact and cannot import
6
+ * @greprag/core (see the note at the top of commands/crush.ts, which had to
7
+ * hand-copy core logic for the same reason and now carries the drift risk that
8
+ * creates). node-identity is pure TypeScript with no runtime imports outside
9
+ * itself, so it can simply be built from core's source on every CLI build. That
10
+ * is strictly better than a copy: there is one source of truth, and a change to
11
+ * the matcher cannot silently fail to reach the CLI.
12
+ *
13
+ * The entry is inline rather than a file in core, because what the CLI needs is
14
+ * a CLI concern — the matcher plus the walker that feeds it — and core should
15
+ * not carry a module that exists only to shape someone else's bundle.
16
+ *
17
+ * Nothing but Node builtins is external because nothing else is reachable: the
18
+ * whole import graph is node-graph + node-identity, both dependency-free.
19
+ *
20
+ * adr: adr/node-identity-port.md
21
+ */
22
+ import { build } from 'esbuild';
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ const cliRoot = path.resolve(__dirname, '..');
28
+ const coreSrc = path.resolve(cliRoot, '..', 'core', 'src');
29
+
30
+ await build({
31
+ stdin: {
32
+ contents: `
33
+ export { markdownToBlocks } from ${JSON.stringify(path.join(coreSrc, 'node-graph', 'index'))};
34
+ export * from ${JSON.stringify(path.join(coreSrc, 'node-identity', 'index'))};
35
+ `,
36
+ resolveDir: coreSrc,
37
+ loader: 'ts',
38
+ },
39
+ outfile: path.join(cliRoot, 'dist', 'node-identity.bundle.js'),
40
+ bundle: true,
41
+ platform: 'node',
42
+ format: 'cjs',
43
+ target: 'node18',
44
+ logLevel: 'warning',
45
+ });
46
+
47
+ console.log('bundled node-identity -> dist/node-identity.bundle.js');
@@ -53,6 +53,10 @@ Your parent session is named `<parent-name>`. When you finish, report to it with
53
53
  ---
54
54
  ````
55
55
 
56
+ **Get `<parent-name>` from `ListAgents` BEFORE composing.** It prints "This session is `<name>`" — that name is the chip's reply address. It is not your greprag 8-hex.
57
+
58
+ **Worktree launch failed? START THE CHIP LOCALLY instead.** The harness creates the worktree before your Block 1 ever runs, and when that fails the spawning session sees nothing — no error, no exit code, only the operator reporting it is stuck. A local start is safe and self-correcting: Block 1 creates the worktree itself as its first action, so nothing is lost and nothing is done twice. If the same slug fails again, change the slug — you cannot see harness-side worktree errors, so elimination is the only tool you have.
59
+
56
60
  **Multi-chip mission (`greprag load chip-leader`)?** The spawn title alone does NOT reach FleetView's watcher registry — that title is auto-generated by gpt-5.4-nano from your opening turn. So the leader will have you **stamp your registry title in Block 1**, right after the `cd` into the worktree:
57
61
 
58
62
  ```bash
@@ -75,6 +79,8 @@ Close the task body with this (substitute `<slug>` + `<parent-name>`):
75
79
 
76
80
  The parent is a live session on this machine — no greprag address, no watcher. If `SendMessage` errors because the parent has ended, stop and leave the commit on the branch; the parent picks it up at merge.
77
81
 
82
+ **Then close yourself:** once the report has landed, run `/close`. It takes the chip path automatically (it recognises a `chip/` branch), so it sends no question and touches nothing outside this worktree — it commits any stragglers, confirms the report, and archives the session, which removes this worktree. Your branch survives for the parent to merge. If the report failed because the parent is gone, do NOT close — leave the worktree standing.
83
+
78
84
  **Cleanup discipline (HARD RULE):** chip prompts forbid `git clean`, `git reset --hard`, `git worktree remove`, `git checkout <other>`, raw `rm -rf` outside the worktree's tracked files.
79
85
  ````
80
86
 
@@ -94,6 +100,6 @@ Chips never `npm run deploy` (or Wrangler / another provider deploy) from the wo
94
100
 
95
101
  ## Parent merge discipline — prune at the merge (HARD RULE)
96
102
 
97
- When you (the parent) merge a chip branch — through the canonical commit path or a profile-declared merge — prune in the same breath: junction guard (`~/.claude/skills/commit/guard-junctions.sh <worktree>`), `git worktree remove .claude/worktrees/<slug>`, and `git branch -d chip/<slug>` once merged. Deferred cleanup = stale worktrees piling up (field state 2026-06-10: 3 leftovers). Multi-chip missions: worktree dies at the integration-branch merge; the branch lives until the configured default-branch merge (chip-leader Phase 4).
103
+ When you (the parent) merge a chip branch — through the canonical commit path or a profile-declared merge — prune in the same breath: junction guard (`~/.claude/skills/commit/guard-junctions.sh <worktree>`), `git worktree remove .claude/worktrees/<slug>`, and `git branch -d chip/<slug>` once merged. A chip that closed itself has already removed its own worktree, so that path will be gone — delete the branch and move on, don't go hunting for it. Deferred cleanup = stale worktrees piling up (field state 2026-06-10: 3 leftovers). Multi-chip missions: worktree dies at the integration-branch merge; the branch lives until the configured default-branch merge (chip-leader Phase 4).
98
104
 
99
105
  **FIX chips report to the mission owner.** A `greprag fix spawn` mission is the source of truth for FIX behavior. `fix spawn` detects usable Git history and emits `workspaceMode`: Git uses an isolated worktree; non-Git, unavailable Git, or no commit uses the project-local task with serialized writes. Dispatch honors that mode without trying a worktree first. The FIX chip identifies the exact friction, makes the smallest durable root-cause fix, verifies and commits useful work, then reports the commit/result and cleanup parameters to the parent mission owner. It creates no human landing gate. If no live parent exists and the chip carries the full-goal mission, it follows the repo profile as mission owner.