greprag 5.83.2 → 5.84.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.
@@ -292,7 +292,7 @@ async function buildAppModel(cwd, requestedProjectId, homeDir = os.homedir()) {
292
292
  group: 'Other',
293
293
  }),
294
294
  enabled: !disabled.has(module.id),
295
- surface: module.source === 'command' ? 'Before sensitive actions' : 'Agent guidance',
295
+ surface: 'Agent guidance',
296
296
  harnesses: module.harnesses || ['claude-code', 'codex', 'opencode', 'grok'],
297
297
  })),
298
298
  },
@@ -50,7 +50,7 @@ function buildDeliveryAnnounce(env) {
50
50
  `[DELIVERY — ${project}]`,
51
51
  'Commit useful, passing work. Only committed state delivers; ignore uncommitted and untracked work in every checkout.',
52
52
  coordinationLine(env.platform),
53
- 'Land every ready same-repo commit: update the default branch, rebase your worktree onto its tip, retest there, take greprag merge-lock in the canonical checkout, recheck the tip, merge --ff-only, release --landed. Tip moved? Free the lock and prepare again. Never rebase a shared default branch.',
53
+ 'Land every ready same-repo commit: update the default branch, rebase your worktree onto its tip, retest there, take greprag merge-lock in the canonical checkout, recheck the tip, merge --ff-only, release --landed. Tip moved? Free the lock and prepare again. A chip\'s branch arrives rebased by the chip: only fast-forward it; refused, free the lock and send it back. Never rebase in another session\'s worktree or a shared default branch.',
54
54
  'Deploy through this repo\'s delivery profile, from its canonical primary checkout unless the profile says otherwise. Worktrees build; they do not deploy. Then verify production serves the commit you shipped.',
55
55
  'THE GOAL ALREADY AUTHORIZES ALL OF THIS. Do not stop to ask permission to commit, merge, or deploy. Separate approval is for effects outside code delivery: destructive data, secrets, money, customer-facing messages.',
56
56
  'Why each rule, and the details: `greprag load delivery`.',
@@ -1,21 +1,21 @@
1
1
  "use strict";
2
- /** greprag discord <pair|unpair|me|handoff|unhandoff> — Discord bot CLI.
2
+ /** greprag discord <pair|unpair|me|typing> — Discord bot CLI.
3
3
  *
4
4
  * pair POSTs /v1/discord/pair with the current project anchor; prints
5
5
  * the 6-char code + DM-the-bot instructions.
6
6
  * unpair DELETEs /v1/discord/pair.
7
- * me GETs /v1/discord/me — pairing + handoff state.
8
- * handoff POSTs /v1/discord/handoff — pins DMs to (current project,
9
- * current session) for a TTL window. Bot DMs a confirmation.
10
- * unhandoff DELETEs /v1/discord/handoff.
7
+ * me GETs /v1/discord/me — pairing state.
8
+ * typing POSTs /v1/discord/typing — refreshes the typing indicator.
9
+ *
10
+ * Sessions DM the operator with `greprag send --to discord:<snowflake>`.
11
+ * Nothing reads DMs sent to the bot: the Front Desk and the handoff pin were
12
+ * retired 2026-10-06, so the bot answers each one with a fixed line.
11
13
  */
12
14
  Object.defineProperty(exports, "__esModule", { value: true });
13
15
  exports.DISCORD_HELP = void 0;
14
16
  exports.callDiscordPair = callDiscordPair;
15
17
  exports.callDiscordUnpair = callDiscordUnpair;
16
18
  exports.callDiscordMe = callDiscordMe;
17
- exports.callDiscordHandoff = callDiscordHandoff;
18
- exports.callDiscordUnhandoff = callDiscordUnhandoff;
19
19
  exports.callDiscordTyping = callDiscordTyping;
20
20
  async function callDiscordPair(deps, projectId) {
21
21
  const f = deps.fetchImpl ?? fetch;
@@ -45,30 +45,6 @@ async function callDiscordMe(deps) {
45
45
  });
46
46
  return (await res.json());
47
47
  }
48
- async function callDiscordHandoff(deps, args) {
49
- const f = deps.fetchImpl ?? fetch;
50
- const res = await f(`${deps.apiUrl}/v1/discord/handoff`, {
51
- method: 'POST',
52
- headers: {
53
- 'Authorization': `Bearer ${deps.apiKey}`,
54
- 'Content-Type': 'application/json',
55
- },
56
- body: JSON.stringify({
57
- project_id: args.projectId,
58
- session_id: args.sessionId,
59
- ttl_minutes: args.ttlMinutes,
60
- }),
61
- });
62
- return (await res.json());
63
- }
64
- async function callDiscordUnhandoff(deps) {
65
- const f = deps.fetchImpl ?? fetch;
66
- const res = await f(`${deps.apiUrl}/v1/discord/handoff`, {
67
- method: 'DELETE',
68
- headers: { 'Authorization': `Bearer ${deps.apiKey}` },
69
- });
70
- return (await res.json());
71
- }
72
48
  async function callDiscordTyping(deps) {
73
49
  const f = deps.fetchImpl ?? fetch;
74
50
  const res = await f(`${deps.apiUrl}/v1/discord/typing`, {
@@ -78,44 +54,19 @@ async function callDiscordTyping(deps) {
78
54
  return (await res.json());
79
55
  }
80
56
  exports.DISCORD_HELP = `
81
- greprag discord — Discord DM bridge to your greprag inbox.
57
+ greprag discord — let your sessions DM you on Discord.
82
58
 
83
- Pair once, then DM the bot to talk to the agents listening in your project.
84
- Handoff temporarily redirects DMs to a specific session (e.g. while you step
85
- away from the desk and want to keep the same thread alive on your phone).
59
+ Pair once; then any session can message you (see Outbound below). The bot does
60
+ not read DMs you send it: the Front Desk was retired 2026-10-06.
86
61
 
87
62
  Subcommands:
88
63
  greprag discord pair Generate a 15-min code; DM @greprag-bot
89
64
  /pair <code> to link this Discord account
90
65
  to the current project.
91
66
  greprag discord unpair Remove the pairing.
92
- greprag discord me Show current pairing + handoff state.
93
- greprag discord handoff [--ttl N] Pin DMs to (current project, current
94
- session) for N minutes (default 60,
95
- max 1440). Bot DMs you a confirmation,
96
- then this command BLOCKS and streams
97
- inbound DMs to stdout (one per line)
98
- until killed. Run it under Claude's
99
- Monitor tool: each line = one
100
- notification right back into the
101
- agent's turn. Sliding TTL: each
102
- inbound DM extends the pin by >=30 min.
103
- [--session <8-hex>] Override session id (default: CLAUDE_SESSION_ID).
104
- [--project <name>] Override project (default: cwd anchor).
105
- [--orchestrator] Tenant-wide watcher — sees inbox traffic
106
- from any project under this tenant. The
107
- DM pin still routes here, but the agent
108
- has portfolio-wide eyes. Use when this
109
- session is the user's orchestrator
110
- (/business-advisor loaded, etc.).
111
- [--json] Emit raw JSON per line (for Monitor).
112
- [--no-watch] Pin and exit — skip the inline watcher.
113
- For scripted use; loses the live DM stream.
114
- greprag discord unhandoff Clear the active handoff — DMs revert
115
- to default project.
67
+ greprag discord me Show current pairing state.
116
68
  greprag discord typing Refresh the "typing..." indicator on
117
- Discord for ~10s. Auto-fires on every
118
- inbound DM; call manually during long
69
+ Discord for ~10s. Call it during long
119
70
  agent turns so the user sees activity
120
71
  instead of silence.
121
72
 
@@ -251,19 +251,6 @@ function printMessage(msg) {
251
251
  function emitDmReplyDirective(msg, session) {
252
252
  if (!session)
253
253
  return;
254
- // Front Desk Switchboard relay: the operator reached THIS session through the
255
- // receptionist and `references.reply_to` is the rail to answer on. Fires for
256
- // ANY message_type (the relay uses plain 'message'), and takes precedence over
257
- // the legacy Discord-DM path. Without this the session falls back to the bare
258
- // sender handle (travis@greprag.com) — the reply lands in MAIN, never reaching
259
- // the operator on Discord/SMS. Live failure 2026-06-08. spec:
260
- // docs/front-desk-switchboard.md §4 (return leg).
261
- const replyTo = msg.references?.reply_to;
262
- if (replyTo) {
263
- console.log(`[greprag] Operator via front desk — answer now, even mid-task: greprag send --to ${replyTo} "…" ` +
264
- `(exact addr; bare handle is lost). If they reply inline here, don't echo to Discord/SMS.`);
265
- return;
266
- }
267
254
  if (msg.message_type !== 'discord_dm')
268
255
  return;
269
256
  const snowflake = msg.references?.discord?.snowflake ?? '<snowflake>';
@@ -453,9 +453,11 @@ async function printMirroredSkill(name, args) {
453
453
  const identityArg = args.skillId ? ` --skill-id ${args.skillId}` : '';
454
454
  console.error(`Mirrored skill "${name}" has no canonical body — the mirror is holding the generated adapter.\n` +
455
455
  `Refusing to serve it: the adapter is the launcher that sent you here, not the skill.\n\n` +
456
- `Restore the real body from the pre-adoption snapshot:\n` +
456
+ `Restore the real body from an earlier revision or the pre-adoption snapshot:\n` +
457
+ ` greprag skill history ${name}${identityArg}\n` +
458
+ ` greprag skill revert ${name} --to <rev>${identityArg} --reason "<why>" --expected-hash <current-hash>\n` +
457
459
  ` greprag skill mirror snapshot list ${name}${identityArg}\n` +
458
- ` greprag skill mirror snapshot promote ${name} --name <snapshot>${identityArg} --expected-hash <current-hash>\n`);
460
+ ` greprag skill mirror snapshot promote ${name} --name <snapshot>${identityArg} --expected-hash <current-hash> --reason "<why>"\n`);
459
461
  process.exitCode = 1;
460
462
  return true;
461
463
  }
@@ -5,7 +5,7 @@
5
5
  * PURE over ReminderEnv — the hook assembles env (i/o) and emits the returned lines;
6
6
  * a broken module never blocks a turn (fail-open per module). */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.ANNOUNCE_POINTER_RESERVE = exports.ANNOUNCE_HARNESS_CAP = exports.ANNOUNCE_INLINE_BUDGET = exports.compactReannounceModules = exports.commandModules = exports.promptModules = exports.harnessModules = exports.REGISTRY = void 0;
8
+ exports.ANNOUNCE_POINTER_RESERVE = exports.ANNOUNCE_HARNESS_CAP = exports.ANNOUNCE_INLINE_BUDGET = exports.compactReannounceModules = exports.harnessModules = exports.REGISTRY = void 0;
9
9
  exports.collectReminders = collectReminders;
10
10
  exports.bootOrder = bootOrder;
11
11
  exports.collectAnnounces = collectAnnounces;
@@ -66,13 +66,6 @@ exports.REGISTRY = [
66
66
  * update a parallel id list. (Root-cause repair, chip/opencode-fix-chip-system-parity.) */
67
67
  const harnessModules = (harness, registry = exports.REGISTRY) => registry.filter((m) => !m.harnesses || m.harnesses.includes(harness));
68
68
  exports.harnessModules = harnessModules;
69
- /** Split the registry by READ surface (docs/reminder-interrupt.md). UserPromptSubmit
70
- * evaluates `prompt`-source modules (the default); PreToolUse evaluates `command`-source
71
- * modules (they read env.toolCommand). Announces are always prompt-side (SessionStart). */
72
- const promptModules = (registry = exports.REGISTRY) => registry.filter((m) => (m.source ?? 'prompt') === 'prompt');
73
- exports.promptModules = promptModules;
74
- const commandModules = (registry = exports.REGISTRY) => registry.filter((m) => m.source === 'command');
75
- exports.commandModules = commandModules;
76
69
  /** PostCompact re-announce set (docs/reminder-interrupt.md). A compaction wipes the
77
70
  * SessionStart announces from context, so the announce modules re-present afterward —
78
71
  * by DEFAULT all of them; a module opts out with `reannounceOnCompact: false`. The
@@ -4,8 +4,8 @@
4
4
  * retitle <session-8hex> "<title>" — force-set a session's registry title,
5
5
  * overriding the gpt-5.4-nano auto-title. Used by the multi-chip /chip-leader
6
6
  * convention: a chip stamps its own label-aligned title ("Chip A — …") in
7
- * Block 1 so the FleetView watcher list / Discord /switch line up with the
8
- * leader's chip labels. Thin HTTP client over POST /v1/memory/title. */
7
+ * Block 1 so the FleetView watcher list lines up with the leader's chip
8
+ * labels. Thin HTTP client over POST /v1/memory/title. */
9
9
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
10
  if (k2 === undefined) k2 = k;
11
11
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -90,7 +90,6 @@ function buildSkillMirrorAnnounce(m, platform) {
90
90
  }
91
91
  exports.skillMirrorAnnounceModule = {
92
92
  id: 'skill-mirror-announce',
93
- source: 'prompt',
94
93
  dependsOn: ['load-primer'], // references `greprag load` — the primer establishes it
95
94
  detect: (_env) => ({ tier: 'silent' }), // announce-only
96
95
  announce: (env) => buildSkillMirrorAnnounce(env.mirroredSkills, env.platform),
@@ -83,21 +83,30 @@ USAGE
83
83
  greprag skill list Show bundled skills and install status
84
84
  greprag skill install <name> Install a bundled advisor skill
85
85
  greprag skill uninstall <name> Remove a previously-installed advisor
86
- greprag skill edit <name> [--skill-id <id>] [--scope project --project-id <id>]
86
+ greprag skill edit <name> --reason "<why>" [--skill-id <id>] [--scope project --project-id <id>]
87
87
  Export/edit/apply helper; opens $EDITOR unless --no-open or Codex
88
+ greprag skill history <name> [--skill-id <id>] [--limit <n>]
89
+ Every canon revision: date, author, reason, changes
90
+ greprag skill diff <name> <revA> [<revB>]
91
+ Unified diff between two revisions (revB defaults to current)
92
+ greprag skill revert <name> --to <rev> --reason "<why>" --expected-hash <current-hash>
93
+ Make an old revision current again as a new revision
88
94
  greprag skill mirror sync [harness] Install native GrepRAG skill adapters
89
95
  claude-code --cutover-lifecycle Cut over commit/deploy/release only
90
96
  claude-code --only <a,b> Install adapters for named mirrored skills
91
97
  (closes the gaps greprag load marks with *)
92
98
  greprag skill mirror export <name> --to <dir>
93
99
  Export canonical skill bytes for editing
94
- greprag skill mirror apply <dir> --skill <name> --expected-hash <hash>
95
- Promote edited bytes with stale-write guard
100
+ greprag skill mirror apply <dir> --skill <name> --expected-hash <hash> --reason "<why>" [--allow-remove]
101
+ Promote edited bytes with stale-write guard;
102
+ the folder must come from export (it records
103
+ the revision it holds), and a missing file
104
+ is refused unless --allow-remove
96
105
  greprag skill mirror snapshot create <name> --name <snapshot>
97
106
  greprag skill mirror snapshot list <name>
98
107
  greprag skill mirror snapshot show <name> --name <snapshot>
99
108
  greprag skill mirror snapshot restore <name> --name <snapshot> --to <dir>
100
- greprag skill mirror snapshot promote <name> --name <snapshot> --expected-hash <hash>
109
+ greprag skill mirror snapshot promote <name> --name <snapshot> --expected-hash <hash> --reason "<why>"
101
110
  Promote snapshot bytes as the newest revision
102
111
  Named package rollback points
103
112
  greprag skill source stamp <name> --project <p> --path <rel> [--path <rel>...]
@@ -114,6 +123,9 @@ NOTES
114
123
  • Mirrored skill edits use export/apply semantics. \`skill edit\` only wraps
115
124
  that flow: export current bytes, open an editor, show a diff summary, then
116
125
  apply with the exported content hash.
126
+ • Every canon write needs --reason "<one line>". GrepRAG keeps each
127
+ revision's bytes with its reason, author and date; read them with
128
+ \`skill history\`, compare with \`skill diff\`, undo with \`skill revert\`.
117
129
  • Codex agents must use a non-interactive path: \`skill mirror export\`, patch
118
130
  files, then \`skill mirror apply\`. In Codex, \`skill edit\` skips the editor
119
131
  and prints the guarded apply command; \`--no-open\` does the same explicitly.
@@ -368,10 +380,50 @@ function writeSkillPackage(targetDir, files, opts) {
368
380
  for (const file of files) {
369
381
  const destination = safePackagePath(target, file.path);
370
382
  fs.mkdirSync(path.dirname(destination), { recursive: true });
371
- fs.writeFileSync(destination, file.content.endsWith('\n') ? file.content : file.content + '\n', 'utf8');
383
+ fs.writeFileSync(destination, file.content, 'utf8');
372
384
  }
373
385
  return true;
374
386
  }
387
+ /** The record an export leaves beside the files it wrote: which revision the
388
+ * folder holds and the bytes of every file in it. Apply takes its base from
389
+ * here, never from a hash the caller fetched separately, so an old folder
390
+ * cannot be paired with a newer revision's hash and written over it.
391
+ * adr: adr/skill-bundle-shape.md */
392
+ const MIRROR_BASE_FILE = '.greprag-mirror-base.json';
393
+ function fileDigest(content) {
394
+ return (0, skill_mirror_client_1.computeMirrorHash)([{ path: '', content }]);
395
+ }
396
+ function writeMirrorBase(targetDir, base) {
397
+ const record = {
398
+ skill: base.skill,
399
+ revision: base.revision,
400
+ contentHash: base.contentHash,
401
+ files: Object.fromEntries(base.files.map(file => [file.path, fileDigest(file.content)])),
402
+ };
403
+ fs.writeFileSync(path.join(path.resolve(targetDir), MIRROR_BASE_FILE), JSON.stringify(record, null, 2) + '\n', 'utf8');
404
+ }
405
+ function readMirrorBase(dir) {
406
+ try {
407
+ const raw = JSON.parse(fs.readFileSync(path.join(path.resolve(dir), MIRROR_BASE_FILE), 'utf8'));
408
+ if (typeof raw.skill !== 'string' || typeof raw.contentHash !== 'string' || !raw.files || typeof raw.files !== 'object')
409
+ return null;
410
+ return { skill: raw.skill, revision: Number(raw.revision || 0), contentHash: raw.contentHash, files: raw.files };
411
+ }
412
+ catch {
413
+ return null;
414
+ }
415
+ }
416
+ /** Read the folder back and fail unless it holds exactly the files the mirror
417
+ * sent, byte for byte. Catches a partial write and any file the package
418
+ * reader would skip (size or count limits) before the user edits on top. */
419
+ function verifyWrittenPackage(targetDir, files) {
420
+ const written = (0, skill_mirror_client_1.collectSkillFiles)(path.resolve(targetDir)) || [];
421
+ const diff = diffSkillPackages(files, written);
422
+ if (!diff.length)
423
+ return;
424
+ const detail = diff.map(item => ` ${item.status === 'removed' ? 'missing' : item.status} ${item.path}`).join('\n');
425
+ throw new Error(`export did not write the revision it fetched to ${path.resolve(targetDir)}:\n${detail}`);
426
+ }
375
427
  function printSkillMirrorConflict(params) {
376
428
  const local = new Map(params.localFiles.map(file => [file.path, file.content]));
377
429
  const remote = new Map(params.remoteFiles.map(file => [file.path, file.content]));
@@ -394,14 +446,12 @@ async function runSkillMirrorExport(args) {
394
446
  const to = getArg(args, '--to');
395
447
  if (!name || !to)
396
448
  throw new Error('Usage: greprag skill mirror export <name> --to <dir> [--force] [--check]');
397
- const data = await mirrorApiJson('GET', `/v1/skillmirror/${encodeURIComponent(name)}${mirrorLookupQuery(args)}`);
398
- const files = (data.files || [])
399
- .filter((file) => typeof file.path === 'string' && typeof file.content === 'string');
400
- if (!files.length)
401
- throw new Error(`No mirrored files returned for ${name}.`);
402
- if (!writeSkillPackage(to, files, { force: args.includes('--force'), check: args.includes('--check'), exact: true }))
449
+ const loaded = await loadMirrorPackage(name, args);
450
+ if (!writeSkillPackage(to, loaded.files, { force: args.includes('--force'), check: args.includes('--check'), exact: true }))
403
451
  return;
404
- console.log(`Exported ${name} revision ${Number(data.revision || 0)} (${String(data.contentHash || '')})`);
452
+ verifyWrittenPackage(to, loaded.files);
453
+ writeMirrorBase(to, { skill: name, ...loaded });
454
+ console.log(`Exported ${name} revision ${loaded.revision} (${loaded.contentHash})`);
405
455
  }
406
456
  async function loadMirrorPackage(skill, args) {
407
457
  const data = await mirrorApiJson('GET', `/v1/skillmirror/${encodeURIComponent(skill)}${mirrorLookupQuery(args)}`);
@@ -441,6 +491,16 @@ function refuseBundledSkillWrite(skill) {
441
491
  return;
442
492
  throw new Error(`${skill} ships with the greprag CLI, so its canon is the bundle, not the skill mirror. Edit packages/cli/skill/${skill}/ in the greprag repo.`);
443
493
  }
494
+ /** Every canon write carries a one-line reason into the skill's revision
495
+ * history; the server refuses a write without one, so refuse it here first,
496
+ * naming the flag. adr: adr/skill-revision-history.md */
497
+ function requireReason(args, usage) {
498
+ const reason = (getArg(args, '--reason') || '').trim();
499
+ if (!reason || reason.startsWith('--')) {
500
+ throw new Error(`Refusing: --reason "<one line>" is required. GrepRAG records it in the skill's revision history.\nUsage: ${usage}`);
501
+ }
502
+ return reason;
503
+ }
444
504
  async function applySkillPackage(params) {
445
505
  refuseBundledSkillWrite(params.skill);
446
506
  const hash = (0, skill_mirror_client_1.computeMirrorHash)(params.files);
@@ -448,6 +508,8 @@ async function applySkillPackage(params) {
448
508
  files: params.files,
449
509
  contentHash: hash,
450
510
  ...mirrorBodyScope(params.args),
511
+ reason: params.reason,
512
+ author: (0, skill_mirror_client_1.skillRevisionAuthor)(params.via),
451
513
  expectedHash: params.expectedHash,
452
514
  ...(typeof params.expectedRevision === 'number' ? { expectedRevision: params.expectedRevision } : {}),
453
515
  };
@@ -477,11 +539,16 @@ async function assertCurrentMirrorHash(params) {
477
539
  }
478
540
  async function runSkillEdit(args) {
479
541
  const skill = args[0];
542
+ const usage = 'greprag skill edit <name> --reason "<why>" [--dir <dir>] [--no-open] [--force] [--keep]';
480
543
  if (!skill)
481
- throw new Error('Usage: greprag skill edit <name> [--dir <dir>] [--no-open] [--force] [--keep]');
544
+ throw new Error(`Usage: ${usage}`);
482
545
  refuseBundledSkillWrite(skill);
483
546
  const explicitDir = getArg(args, '--dir') || getArg(args, '--to');
484
547
  const noOpen = args.includes('--no-open');
548
+ const skipEditor = noOpen || codexHarnessDetected();
549
+ // The editor path applies in this run, so it needs its reason before export;
550
+ // --no-open only prints the apply command, which carries the reason instead.
551
+ const reason = skipEditor ? (getArg(args, '--reason') || '').trim() : requireReason(args, usage);
485
552
  const keep = args.includes('--keep') || Boolean(explicitDir);
486
553
  const workDir = explicitDir
487
554
  ? path.resolve(explicitDir)
@@ -491,15 +558,16 @@ async function runSkillEdit(args) {
491
558
  force: args.includes('--force') || !explicitDir,
492
559
  exact: true,
493
560
  });
561
+ verifyWrittenPackage(workDir, loaded.files);
562
+ writeMirrorBase(workDir, { skill, ...loaded });
494
563
  console.log(`Editing ${skill} revision ${loaded.revision} (${loaded.contentHash})`);
495
564
  console.log(`Work dir: ${workDir}`);
496
- const skipEditor = noOpen || codexHarnessDetected();
497
565
  if (skipEditor) {
498
566
  console.log(noOpen
499
567
  ? 'Editor skipped.'
500
568
  : 'Codex harness detected; editor skipped to avoid launching an interactive editor.');
501
569
  const forward = forwardedMirrorArgs(args);
502
- console.log(`Apply after editing: greprag skill mirror apply ${shellArg(workDir)} --skill ${skill}${forward ? ` ${forward}` : ''} --expected-hash ${loaded.contentHash}`);
570
+ console.log(`Apply after editing: greprag skill mirror apply ${shellArg(workDir)} --skill ${skill}${forward ? ` ${forward}` : ''} --expected-hash ${loaded.contentHash} --reason ${reason ? shellArg(reason) : '"<why>"'}`);
503
571
  return;
504
572
  }
505
573
  runEditor(path.join(workDir, 'SKILL.md'));
@@ -512,7 +580,7 @@ async function runSkillEdit(args) {
512
580
  return;
513
581
  }
514
582
  await assertCurrentMirrorHash({ skill, expectedHash: loaded.contentHash, localFiles: edited, args });
515
- const data = await applySkillPackage({ skill, files: edited, expectedHash: loaded.contentHash, args });
583
+ const data = await applySkillPackage({ skill, files: edited, expectedHash: loaded.contentHash, reason, via: 'skill-edit', args });
516
584
  console.log(`Applied ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(edited))})`);
517
585
  if (!keep)
518
586
  fs.rmSync(workDir, { recursive: true, force: true });
@@ -520,20 +588,38 @@ async function runSkillEdit(args) {
520
588
  async function runSkillMirrorApply(args) {
521
589
  const dir = args[0];
522
590
  const skill = getArg(args, '--skill') || (dir ? path.basename(path.resolve(dir)) : '');
591
+ const usage = 'greprag skill mirror apply <dir> --skill <name> --expected-hash <hash> --reason "<why>"';
523
592
  if (!dir || !skill)
524
- throw new Error('Usage: greprag skill mirror apply <dir> --skill <name> --expected-hash <hash>');
593
+ throw new Error(`Usage: ${usage}`);
594
+ refuseBundledSkillWrite(skill);
595
+ const reason = requireReason(args, usage);
525
596
  const expectedRevisionRaw = getArg(args, '--expected-revision') || getArg(args, '--expectedRevision');
526
- const expectedHash = getArg(args, '--expected-hash') || getArg(args, '--expectedHash');
527
- if (!expectedHash) {
528
- throw new Error('Refusing unguarded apply; export first, then pass the exported --expected-hash <hash>.');
597
+ const passedHash = getArg(args, '--expected-hash') || getArg(args, '--expectedHash');
598
+ const base = readMirrorBase(dir);
599
+ if (!base) {
600
+ throw new Error(`Refusing apply: ${path.resolve(dir)} has no export record (${MIRROR_BASE_FILE}). Run greprag skill mirror export ${skill} --to <dir>, edit that folder, then apply it.`);
529
601
  }
602
+ if (base.skill !== skill) {
603
+ throw new Error(`Refusing apply: ${path.resolve(dir)} was exported from ${base.skill}, not ${skill}.`);
604
+ }
605
+ if (passedHash && passedHash !== base.contentHash) {
606
+ throw new Error(`Refusing apply: this folder was exported at ${base.contentHash} (revision ${base.revision}), but --expected-hash is ${passedHash}. A hash from a different export does not describe these files; export again and redo the edits there.`);
607
+ }
608
+ const expectedHash = base.contentHash;
530
609
  const files = collectSkillPackageOrThrow(dir);
610
+ const present = new Set(files.map(file => file.path));
611
+ const missing = Object.keys(base.files).filter(relative => !present.has(relative));
612
+ if (missing.length && !args.includes('--allow-remove')) {
613
+ throw new Error(`Refusing apply: files from the export are missing in ${path.resolve(dir)}:\n${missing.map(relative => ` missing ${relative}`).join('\n')}\nRestore them, or pass --allow-remove if deleting them is intended.`);
614
+ }
531
615
  await assertCurrentMirrorHash({ skill, expectedHash, localFiles: files, args });
532
616
  const data = await applySkillPackage({
533
617
  skill,
534
618
  files,
535
619
  expectedHash,
536
620
  expectedRevision: expectedRevisionRaw ? Number(expectedRevisionRaw) : undefined,
621
+ reason,
622
+ via: 'mirror-apply',
537
623
  args,
538
624
  });
539
625
  console.log(`Applied ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(files))})`);
@@ -590,6 +676,8 @@ async function runSkillMirrorSnapshot(args) {
590
676
  console.log(`Snapshot ${String(snap.snapshotName || name)} stored for ${skill}: revision ${Number(snap.revision || 0)}`);
591
677
  return;
592
678
  }
679
+ const promoteUsage = 'greprag skill mirror snapshot promote <skill> --name <snapshot> --expected-hash <current-live-hash> --reason "<why>"';
680
+ const promoteReason = verb === 'promote' && !rest.includes('--check') ? requireReason(rest, promoteUsage) : '';
593
681
  const data = await mirrorApiJson('GET', `/v1/skillmirror/${encodeURIComponent(skill)}/snapshots/${encodeURIComponent(name)}${mirrorLookupQuery(rest)}`);
594
682
  const snapshot = data.snapshot;
595
683
  if (verb === 'show') {
@@ -610,13 +698,13 @@ async function runSkillMirrorSnapshot(args) {
610
698
  const files = snapshotPresentFiles(snapshot);
611
699
  if (!writeSkillPackage(to, files, { force: rest.includes('--force'), check: rest.includes('--check'), exact: true }))
612
700
  return;
613
- console.log(`Restored snapshot ${name} for ${skill} to ${path.resolve(to)}. Run apply with the expected revision if this should become canon.`);
701
+ console.log(`Restored snapshot ${name} for ${skill} to ${path.resolve(to)}. To make it canon, run greprag skill mirror snapshot promote ${skill} --name ${name} --expected-hash <current-live-hash> --reason "<why>".`);
614
702
  return;
615
703
  }
616
704
  if (verb === 'promote') {
617
705
  const expectedHash = getArg(rest, '--expected-hash') || getArg(rest, '--expectedHash');
618
706
  if (!expectedHash) {
619
- throw new Error('Usage: greprag skill mirror snapshot promote <skill> --name <snapshot> --expected-hash <current-live-hash>');
707
+ throw new Error(`Usage: ${promoteUsage}`);
620
708
  }
621
709
  const files = snapshotPresentFiles(snapshot);
622
710
  if (!files.some(file => file.path === 'SKILL.md')) {
@@ -629,12 +717,129 @@ async function runSkillMirrorSnapshot(args) {
629
717
  return;
630
718
  }
631
719
  await assertCurrentMirrorHash({ skill, expectedHash, localFiles: files, args: rest });
632
- const data = await applySkillPackage({ skill, files, expectedHash, args: rest });
720
+ const data = await applySkillPackage({
721
+ skill, files, expectedHash, reason: `promote snapshot "${name}": ${promoteReason}`, via: 'snapshot-promote', args: rest,
722
+ });
633
723
  console.log(`Promoted snapshot ${name} for ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(files))})`);
634
724
  return;
635
725
  }
636
726
  throw new Error('Usage: greprag skill mirror snapshot create|list|show|restore|promote <skill> [--name <snapshot>]');
637
727
  }
728
+ // ---------- Revision history: history / diff / revert ------------------------
729
+ // adr: adr/skill-revision-history.md
730
+ function revisionArg(raw, label) {
731
+ const value = Number(String(raw || '').replace(/^r/i, ''));
732
+ if (!raw || !Number.isInteger(value) || value < 0)
733
+ throw new Error(`${label} must be a revision number such as 12 or r12`);
734
+ return value;
735
+ }
736
+ function positionals(args) {
737
+ const out = [];
738
+ for (let i = 0; i < args.length; i++) {
739
+ if (args[i].startsWith('--')) {
740
+ if (args[i + 1] && !args[i + 1].startsWith('--'))
741
+ i++;
742
+ continue;
743
+ }
744
+ out.push(args[i]);
745
+ }
746
+ return out;
747
+ }
748
+ function authorLabel(author) {
749
+ if (!author)
750
+ return '';
751
+ const parts = [
752
+ typeof author.harness === 'string' ? author.harness : '',
753
+ typeof author.sessionId === 'string' ? author.sessionId.slice(0, 8) : '',
754
+ typeof author.chip === 'string' ? author.chip : '',
755
+ ].filter(Boolean);
756
+ const via = typeof author.via === 'string' ? author.via : '';
757
+ return [via, parts.join(' ')].filter(Boolean).join(' · ');
758
+ }
759
+ async function runSkillHistory(args) {
760
+ const skill = args[0];
761
+ if (!skill)
762
+ throw new Error('Usage: greprag skill history <name> [--skill-id <id>] [--limit <n>]');
763
+ const limit = getArg(args, '--limit');
764
+ const qs = mirrorLookupQuery(args);
765
+ const sep = qs ? '&' : '?';
766
+ const data = await mirrorApiJson('GET', `/v1/skillmirror/${encodeURIComponent(skill)}/history${qs}${limit ? `${sep}limit=${encodeURIComponent(limit)}` : ''}`);
767
+ const revisions = data.revisions || [];
768
+ if (!revisions.length) {
769
+ console.log(`No recorded revisions for ${skill}.`);
770
+ return;
771
+ }
772
+ console.log(`${skill} (${String(data.skillId || '')}) — newest first`);
773
+ console.log(`current: r${String(revisions[0].revision)} ${String(revisions[0].contentHash || '')}`);
774
+ for (const r of revisions) {
775
+ const date = String(r.createdAt || '').replace('T', ' ').slice(0, 16);
776
+ console.log('');
777
+ console.log(`r${String(r.revision).padEnd(4)} ${date} ${String(r.kind).padEnd(8)} ${authorLabel(r.author)}`);
778
+ console.log(` ${String(r.reason || '')}`);
779
+ const labels = r.labels || [];
780
+ if (labels.length)
781
+ console.log(` snapshots: ${labels.join(', ')}`);
782
+ if (typeof r.revertedFrom === 'number')
783
+ console.log(` bytes of r${r.revertedFrom}`);
784
+ const changes = r.changes || [];
785
+ if (r.comparedTo === null || r.comparedTo === undefined) {
786
+ console.log(` first recorded revision (${Number(r.fileCount || 0)} files)`);
787
+ }
788
+ else if (!changes.length) {
789
+ console.log(` no file changes vs r${String(r.comparedTo)}`);
790
+ }
791
+ else {
792
+ const files = changes.map(c => `${String(c.status)} ${String(c.path)}`).join(', ');
793
+ console.log(` +${Number(r.added || 0)} -${Number(r.removed || 0)} vs r${String(r.comparedTo)}: ${files}`);
794
+ }
795
+ }
796
+ }
797
+ async function runSkillDiff(args) {
798
+ const [skill, revA, revB] = positionals(args);
799
+ if (!skill || !revA)
800
+ throw new Error('Usage: greprag skill diff <name> <revA> [<revB>] [--skill-id <id>]');
801
+ const from = revisionArg(revA, 'revA');
802
+ const to = revB === undefined ? undefined : revisionArg(revB, 'revB');
803
+ const qs = mirrorLookupQuery(args);
804
+ const params = `${qs ? `${qs}&` : '?'}from=${from}${to === undefined ? '' : `&to=${to}`}`;
805
+ const data = await mirrorApiJson('GET', `/v1/skillmirror/${encodeURIComponent(skill)}/diff${params}`);
806
+ const files = data.files || [];
807
+ console.log(`${skill}: r${String(data.from)} → r${String(data.to)}`);
808
+ if (!files.length) {
809
+ console.log('No differences.');
810
+ return;
811
+ }
812
+ for (const file of files) {
813
+ console.log('');
814
+ console.log(String(file.patch || `${String(file.status)} ${String(file.path)}`));
815
+ }
816
+ }
817
+ async function runSkillRevert(args) {
818
+ const usage = 'greprag skill revert <name> --to <rev> --reason "<why>" --expected-hash <current-hash> [--skill-id <id>]';
819
+ const skill = args[0];
820
+ if (!skill || skill.startsWith('--'))
821
+ throw new Error(`Usage: ${usage}`);
822
+ refuseBundledSkillWrite(skill);
823
+ const to = revisionArg(getArg(args, '--to'), '--to');
824
+ const reason = requireReason(args, usage);
825
+ const expectedHash = getArg(args, '--expected-hash') || getArg(args, '--expectedHash');
826
+ if (!expectedHash) {
827
+ throw new Error(`Refusing: --expected-hash <current-hash> is required (the hash \`greprag skill history ${skill}\` or the adapter shows for the current revision).\nUsage: ${usage}`);
828
+ }
829
+ const skillId = getArg(args, '--skill-id') || getArg(args, '--skillId');
830
+ const scope = getArg(args, '--scope');
831
+ const projectId = getArg(args, '--project-id') || getArg(args, '--projectId');
832
+ const data = await mirrorApiJson('POST', `/v1/skillmirror/${encodeURIComponent(skill)}/revert`, {
833
+ to,
834
+ reason,
835
+ expectedHash,
836
+ author: (0, skill_mirror_client_1.skillRevisionAuthor)('revert'),
837
+ ...(skillId ? { skillId } : {}),
838
+ ...(scope ? { scope } : {}),
839
+ ...(projectId ? { projectId } : {}),
840
+ });
841
+ console.log(`Reverted ${skill} to the bytes of r${to}: new revision ${Number(data.revision || 0)} (${String(data.contentHash || '')})`);
842
+ }
638
843
  async function runSkill(args) {
639
844
  const sub = args[0];
640
845
  if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
@@ -646,6 +851,15 @@ async function runSkill(args) {
646
851
  case 'edit':
647
852
  await runSkillEdit(args.slice(1));
648
853
  return;
854
+ case 'history':
855
+ await runSkillHistory(args.slice(1));
856
+ return;
857
+ case 'diff':
858
+ await runSkillDiff(args.slice(1));
859
+ return;
860
+ case 'revert':
861
+ await runSkillRevert(args.slice(1));
862
+ return;
649
863
  case 'mirror': {
650
864
  const mirrorVerb = args[1] || 'sync';
651
865
  if (mirrorVerb === 'export') {
@@ -780,7 +994,10 @@ async function runSkill(args) {
780
994
  const apiKey = process.env.GREPRAG_API_KEY || '';
781
995
  if (apiKey) {
782
996
  const apiUrl = process.env.GREPRAG_API_URL || 'https://api.greprag.com';
783
- const outcome = await (0, skill_mirror_client_1.maybeMirrorSkill)({ skill: name, cwd: process.cwd(), apiUrl, apiKey });
997
+ const outcome = await (0, skill_mirror_client_1.maybeMirrorSkill)({
998
+ skill: name, cwd: process.cwd(), apiUrl, apiKey,
999
+ reason: `source stamp ${project} @ ${stamped.binding.baselineCommit.slice(0, 8)}`,
1000
+ });
784
1001
  console.log(`Mirror: ${outcome}`);
785
1002
  }
786
1003
  else {
package/dist/hook.js CHANGED
@@ -1165,6 +1165,7 @@ async function store(input, source = 'claude-code') {
1165
1165
  if (mirrorSkill) {
1166
1166
  await (0, skill_mirror_client_1.maybeMirrorSkill)({
1167
1167
  skill: mirrorSkill, cwd, apiUrl: cfg.apiUrl, apiKey: cfg.apiKey,
1168
+ sessionId: input.session_id,
1168
1169
  });
1169
1170
  }
1170
1171
  }
@@ -1525,7 +1526,7 @@ async function recap(input, mode = 'plain', opts = {}) {
1525
1526
  return;
1526
1527
  // Ensure the machine's desk-line is up (idempotent singleton). One per machine,
1527
1528
  // not per session — it long-polls the cloud and answers "what's truly live?"
1528
- // from local pidfile truth, so the Front Desk never reads a stale registry.
1529
+ // from local pidfile truth, so the live-session list never comes from a stale registry.
1529
1530
  // The lock makes this a no-op on every SessionStart after the first.
1530
1531
  // adr: adr/desk-line-relay.md
1531
1532
  try {