greprag 5.83.1 → 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.
@@ -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 {
@@ -59,6 +59,9 @@ exports.releaseLock = releaseLock;
59
59
  exports.landedClaimsPath = landedClaimsPath;
60
60
  exports.isAncestorOfHead = isAncestorOfHead;
61
61
  exports.commitExists = commitExists;
62
+ exports.integrationRef = integrationRef;
63
+ exports.refTip = refTip;
64
+ exports.landedVerdict = landedVerdict;
62
65
  exports.readLandedClaims = readLandedClaims;
63
66
  exports.openClaims = openClaims;
64
67
  exports.recordLandedClaim = recordLandedClaim;
@@ -66,6 +69,7 @@ exports.dropLandedClaim = dropLandedClaim;
66
69
  exports.readMergeState = readMergeState;
67
70
  const fs = __importStar(require("fs"));
68
71
  const path = __importStar(require("path"));
72
+ const delivery_config_1 = require("./delivery-config");
69
73
  const proc_1 = require("./proc");
70
74
  const session_id_1 = require("./session-id");
71
75
  /** The basename already on disk in the repos that have a deploy lock today. The
@@ -210,6 +214,7 @@ function readMergeLock(cwd) {
210
214
  function acquireLock(file, label, staleMs = exports.DEPLOY_STALE_MS, options = {}) {
211
215
  const session = lockSession();
212
216
  const record = {
217
+ ...(options.base ? { base: options.base } : {}),
213
218
  // A process-held lock must record the process that is actually DEPLOYING, which for
214
219
  // a script that shells out to this CLI is the caller, not this short-lived command.
215
220
  // Recording our own pid there would make the lock read as stale the moment it exists.
@@ -267,6 +272,49 @@ function commitExists(cwd, sha) {
267
272
  });
268
273
  return result.status === 0;
269
274
  }
275
+ /** The branch merges land on: the profile's default branch, else this checkout's HEAD.
276
+ * A ref, not HEAD, because the lock is shared across worktrees and a linked worktree's
277
+ * HEAD is the very branch being merged. */
278
+ function integrationRef(cwd) {
279
+ const branch = (0, delivery_config_1.resolveDeliveryProfile)(cwd).profile?.git?.defaultBranch;
280
+ if (branch) {
281
+ const ref = `refs/heads/${branch}`;
282
+ const probe = (0, proc_1.safeSpawnSync)('git', ['rev-parse', '--verify', '--quiet', ref], {
283
+ encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
284
+ });
285
+ if (probe.status === 0)
286
+ return ref;
287
+ }
288
+ return 'HEAD';
289
+ }
290
+ function refTip(cwd, ref) {
291
+ const result = (0, proc_1.safeSpawnSync)('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
292
+ encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
293
+ });
294
+ return result.status === 0 ? String(result.stdout).trim() || null : null;
295
+ }
296
+ /**
297
+ * Is `sha` a merge that landed under this lock?
298
+ *
299
+ * Release used to take any commit on trust, and the commit agents passed was
300
+ * `$(git rev-parse HEAD)`, which is on HEAD by definition. On 2026-10-06 in C:/sift a
301
+ * fast-forward was refused, a pipe to `tail` hid the refusal, and release recorded the
302
+ * peer commit already at the tip as this session's landed work; the deploy then shipped
303
+ * master without the branch. The claim must be checked against what the merge changed:
304
+ * it has to be on the integration branch now, and it cannot be the tip that was there
305
+ * before the lock was taken.
306
+ */
307
+ function landedVerdict(cwd, sha, base) {
308
+ const ref = integrationRef(cwd);
309
+ const ancestor = (0, proc_1.safeSpawnSync)('git', ['merge-base', '--is-ancestor', sha, ref], {
310
+ encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
311
+ });
312
+ if (ancestor.status !== 0)
313
+ return 'not-landed';
314
+ if (base && sha === base)
315
+ return 'nothing-new';
316
+ return 'landed';
317
+ }
270
318
  function readLandedClaims(cwd) {
271
319
  try {
272
320
  const raw = JSON.parse(fs.readFileSync(landedClaimsPath(cwd), 'utf8'));
package/dist/hook.js CHANGED
@@ -115,9 +115,9 @@ const merge_guard_1 = require("./commands/merge-guard");
115
115
  // injects the pointer so the post-compact model can Read the verbatim record.
116
116
  // All real logic lives in ./commands/archive. adr: adr/governed-compact.md
117
117
  const archive_1 = require("./commands/archive");
118
- // Collision schematic (SessionStart) — detect another live session in the same
119
- // repo (cloud watcher registry) and inject a coordinate-now directive. All real
120
- // logic lives in ./commands/collision-check. docs/schematic-triggers.md
118
+ // Collision schematic (SessionStart) — detect another greprag watcher session
119
+ // (OpenCode, Grok) in the same repo and inject a coordinate-now directive. All
120
+ // real logic lives in ./commands/collision-check. docs/collision-schematic.md
121
121
  const collision_check_1 = require("./commands/collision-check");
122
122
  // (The asyncRewake idle-wake poll was REVERTED 2026-06-23 — falsified: a detached
123
123
  // process's exit-2 has no harness event to attach to in true deep idle, so it never
@@ -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 {
@@ -2114,13 +2115,13 @@ async function armCheck(input) {
2114
2115
  }) + '\n');
2115
2116
  }
2116
2117
  /** Collision schematic (SessionStart) — `greprag-hook collision-check`. Detects
2117
- * another live session working in THIS repo via the cloud watcher registry and
2118
- * injects a directive telling the agent to message the peer + work in a
2119
- * worktree. The hook NEVER sends — AI never auto-acts. Dedup is per-session so a
2120
- * resume/compact re-fire pings each peer at most once. All detection/dedup/
2121
- * directive logic lives in ./commands/collision-check (pure + tested). Reads
2122
- * only the watcher registry (no inbox content) — no eavesdrop surface.
2123
- * Fail-quiet: any miss → silent. docs/schematic-triggers.md */
2118
+ * another greprag watcher session (OpenCode, Grok) working in THIS repo via the
2119
+ * cloud watcher registry and injects a directive telling the agent to message
2120
+ * the peer + work in a worktree. The hook NEVER sends — AI never auto-acts.
2121
+ * Dedup is per-session so a resume/compact re-fire pings each peer at most
2122
+ * once. All detection/dedup/directive logic lives in ./commands/collision-check
2123
+ * (pure + tested). Reads only the watcher registry (no inbox content) — no
2124
+ * eavesdrop surface. Fail-quiet: any miss → silent. docs/collision-schematic.md */
2124
2125
  async function collisionCheck(input) {
2125
2126
  const cwd = input.cwd || process.cwd();
2126
2127
  const cfg = (0, hook_runtime_1.getHookConfig)(cwd);
@@ -2353,11 +2354,8 @@ async function main() {
2353
2354
  }
2354
2355
  const harness = (0, harness_1.inferCurrentHarness)();
2355
2356
  if (subcommand === 'recap') {
2356
- // ALWAYS additionalContext. Raw stdout is treated by the Claude Code harness
2357
- // like command output: past ~2KB it spills to a file and injects a preview,
2358
- // which silently ate ~89% of an 18.7KB announce (Persona sits at byte 10,841).
2359
- // additionalContext is the documented injection channel. Grok keeps its
2360
- // sidecar via the platform arg. adr: adr/announce-delivery-channel.md
2357
+ // ALWAYS additionalContext, the documented injection channel. Grok keeps its
2358
+ // sidecar via the platform arg. adr: adr/announce-inline-budget.md
2361
2359
  await recap(input, 'additionalContext', {
2362
2360
  platform: harness === 'grok' ? 'grok' : undefined,
2363
2361
  });