greprag 5.82.1 → 5.83.1

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 (43) hide show
  1. package/dist/claude-lifecycle-cutover.js +5 -16
  2. package/dist/commands/codex.js +4 -0
  3. package/dist/commands/collision-check.js +9 -6
  4. package/dist/commands/corpus/client.js +16 -0
  5. package/dist/commands/corpus/index.js +5 -2
  6. package/dist/commands/corpus/section.js +3 -3
  7. package/dist/commands/corpus/walk.js +3 -3
  8. package/dist/commands/delivery-reminder.js +13 -9
  9. package/dist/commands/delivery.js +64 -0
  10. package/dist/commands/docptr.js +10 -24
  11. package/dist/commands/init.js +4 -5
  12. package/dist/commands/load-primer-reminder.js +2 -2
  13. package/dist/commands/load.js +45 -12
  14. package/dist/commands/merge-guard.js +94 -9
  15. package/dist/commands/parity.js +0 -2
  16. package/dist/commands/pipe-wrap.js +4 -2
  17. package/dist/commands/skill-mirror-reminder.js +24 -6
  18. package/dist/commands/skill.js +24 -8
  19. package/dist/commands/skillgain.js +5 -1
  20. package/dist/commands/state-trigger.js +67 -31
  21. package/dist/delivery-config.js +69 -0
  22. package/dist/deploy-locks.js +18 -3
  23. package/dist/doc-mirror-client.js +39 -11
  24. package/dist/hook.js +35 -39
  25. package/dist/index.js +62 -27
  26. package/dist/native-adapter-refresh.js +114 -0
  27. package/dist/native-skill-adoption.js +20 -53
  28. package/dist/native-skill-mirror.js +130 -52
  29. package/dist/native-skill-pending.js +5 -2
  30. package/dist/opencode-plugin.bundle.js +1083 -160
  31. package/dist/opencode-plugin.js +6 -0
  32. package/dist/skill-activation-manifest.js +98 -22
  33. package/dist/skill-landing.js +6 -0
  34. package/dist/skill-mirror-files.js +59 -0
  35. package/package.json +1 -1
  36. package/scripts/postinstall.js +4 -19
  37. package/skill/greprag/SKILL.md +1 -1
  38. package/skill/greprag/docs/corpus.md +2 -2
  39. package/skill/greprag/docs/setup.md +1 -1
  40. package/skill/mechanic/SKILL.md +15 -40
  41. package/skill/mechanic/docs/skill-fix-conventions.md +2 -2
  42. package/skill/templates/chip-spawn.md +36 -20
  43. package/skill/templates/delivery.md +19 -5
@@ -22,7 +22,7 @@
22
22
  * plumbing that happens to share a prefix, and `merge-base` is used BY the
23
23
  * provenance check — blocking it would break the gate with the guard.
24
24
  * · only a merge nothing already serialises. A command that takes the lock
25
- * itself, ahead of the merge and `&&`-joined to it, is already serialised.
25
+ * itself and stops on acquisition failure is already serialised.
26
26
  *
27
27
  * Holding the lock yourself is a pass. A peer holding it is the refusal that
28
28
  * matters: it is the exact collision, caught before the merge instead of after.
@@ -51,6 +51,12 @@
51
51
  * trips. Only `&&` earns this: after `;` or `||` the merge still runs when the
52
52
  * acquire failed, so those are refused, and the refusal now says so.
53
53
  *
54
+ * A THIRD, 2026-10-06 in C:/sift: an acquire run from a linked worktree wrote the
55
+ * lock into that worktree's private git dir, so this guard, reading the canonical
56
+ * checkout, refused the holder's own merge whatever its shape. Locks now live in the
57
+ * repository's shared `.git`, and a chained acquire counts when it writes the same
58
+ * lock file this guard reads.
59
+ *
54
60
  * # adr: adr/deploy-gate-cli.md
55
61
  */
56
62
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
@@ -126,11 +132,11 @@ function shellSegments(command) {
126
132
  let start = 0;
127
133
  let afterAnd = true;
128
134
  for (let match = separator.exec(masked); match; match = separator.exec(masked)) {
129
- segments.push({ text: source.slice(start, match.index), afterAnd });
135
+ segments.push({ text: source.slice(start, match.index), start, afterAnd });
130
136
  afterAnd = match[0] === '&&';
131
137
  start = match.index + match[0].length;
132
138
  }
133
- segments.push({ text: source.slice(start), afterAnd });
139
+ segments.push({ text: source.slice(start), start, afterAnd });
134
140
  return segments;
135
141
  }
136
142
  /** Quote-aware tokens. A quoted run is one token with its quotes removed, so
@@ -297,9 +303,10 @@ function buildMergeGuardReason(held, ownership) {
297
303
  ' greprag merge-lock acquire --label "<what you are merging>"',
298
304
  ' git merge --ff-only <rebased branch>',
299
305
  ' greprag merge-lock release --landed <the merge sha> --label "<what it was>"',
300
- 'One call works too, as long as the acquire comes first and every step is joined by',
301
- '`&&` — that is what stops the merge when someone else holds the lock. A `;` or `||`',
302
- 'between them runs the merge anyway, so those are refused.',
306
+ 'One call works too when the acquire comes first and failure stops the merge:',
307
+ 'use an `&&` chain, or in PowerShell a try/finally block with an immediate',
308
+ '`if ($LASTEXITCODE -ne 0) { throw ... }` after acquire. Bare `;` and `||`',
309
+ 'let the merge run after a failed acquire, so those are refused.',
303
310
  '`--landed` is what lets the deploy prove YOUR work is in what ships, rather than only',
304
311
  'that production serves HEAD. Merging in a worktree instead? That is never blocked.',
305
312
  ].join(' ');
@@ -357,7 +364,8 @@ function guardMergeAt(input, segments, mergeAt) {
357
364
  // truncating it locally to 8 was a second copy of identity logic waiting to disagree
358
365
  // with the one in session-id.ts.
359
366
  const ownership = (0, deploy_locks_1.lockOwnership)(held, input.session_id);
360
- const chainedLock = takesLockForTarget(segments, mergeAt, cwd, target);
367
+ const chainedLock = takesLockForTarget(segments, mergeAt, cwd, target)
368
+ || powerShellTryTakesLock(input.tool_input?.command ?? input.tool_input?.cmd ?? '', segments[mergeAt].start, cwd, target);
361
369
  if (ownership !== 'mine' && !chainedLock)
362
370
  return denyMerge(input, buildMergeGuardReason(held, ownership));
363
371
  // Git owns the final freshness check AT mutation time. Even if master changes
@@ -403,11 +411,88 @@ function takesLockForTarget(segments, mergeAt, cwd, target) {
403
411
  return false;
404
412
  if (!isMergeLockAcquire(segments[i - 1].text))
405
413
  continue;
406
- const lockDir = mergeCwd(segments, i - 1, cwd);
407
- return (0, deploy_gate_1.normalizePath)(mergeWorktreeRoot([], lockDir) ?? lockDir) === (0, deploy_gate_1.normalizePath)(target);
414
+ return acquiresTargetLock(mergeCwd(segments, i - 1, cwd), target);
415
+ }
416
+ return false;
417
+ }
418
+ /** An acquire run in `dir` serialises a merge into `target` exactly when it writes the
419
+ * lock the guard reads for `target`. The lock is repository-wide, so an acquire from a
420
+ * linked worktree counts; comparing worktree roots refused that holder on 2026-10-06. */
421
+ function acquiresTargetLock(dir, target) {
422
+ try {
423
+ return (0, deploy_gate_1.normalizePath)((0, deploy_locks_1.mergeLockPath)(dir)) === (0, deploy_gate_1.normalizePath)((0, deploy_locks_1.mergeLockPath)(target));
424
+ }
425
+ catch {
426
+ return false;
427
+ }
428
+ }
429
+ /** PowerShell's `try { acquire; if ($LASTEXITCODE -ne 0) { throw ... }; merge }
430
+ * finally { release }` is a fail-closed lock sequence too. Inspect only direct
431
+ * statements in the try body; a bare semicolon or an acquire nested in a branch
432
+ * must not excuse a merge. */
433
+ function powerShellTryTakesLock(command, mergeStart, cwd, target) {
434
+ const masked = maskQuoted(command);
435
+ const tryStart = /(?:^|[;\n])\s*try\s*\{/gi;
436
+ for (let match = tryStart.exec(masked); match; match = tryStart.exec(masked)) {
437
+ const open = masked.indexOf('{', match.index);
438
+ const close = matchingBrace(masked, open);
439
+ if (close < 0 || mergeStart <= open || mergeStart >= close)
440
+ continue;
441
+ const suffix = masked.slice(close + 1);
442
+ const finallyMatch = /^\s*finally\s*\{/.exec(suffix);
443
+ if (!finallyMatch || matchingBrace(masked, close + 1 + finallyMatch[0].lastIndexOf('{')) < 0)
444
+ continue;
445
+ const statements = powerShellStatements(command, masked, open + 1, close);
446
+ const mergeIndex = statements.findIndex(s => mergeStart >= s.start && mergeStart < s.end);
447
+ if (mergeIndex < 2 || mergeGlobalOpts(statements[mergeIndex].text)?.length !== 0)
448
+ continue;
449
+ // A location change would make the lock's target ambiguous. The regular
450
+ // chained-command path already resolves simple cd/Set-Location sequences.
451
+ if (/(?:^|[;\n])\s*(?:cd|chdir|sl|Set-Location|Push-Location|Pop-Location|pushd|popd)\b/i.test(masked.slice(0, mergeStart)))
452
+ continue;
453
+ if (!acquiresTargetLock(cwd, target))
454
+ continue;
455
+ for (let i = mergeIndex - 2; i >= 0; i--) {
456
+ if (!isMergeLockAcquire(statements[i].text))
457
+ continue;
458
+ if (/[|&]/.test(maskQuoted(statements[i].text)))
459
+ continue;
460
+ if (!/^if\s*\(\s*\$LASTEXITCODE\s*-ne\s*0\s*\)\s*\{\s*throw\b[^{}]*\}\s*$/i.test(statements[i + 1].text.trim()))
461
+ continue;
462
+ if (statements.slice(i + 2, mergeIndex).some(s => /\bmerge-lock\s+release\b/i.test(s.text)))
463
+ continue;
464
+ return true;
465
+ }
408
466
  }
409
467
  return false;
410
468
  }
469
+ function matchingBrace(masked, open) {
470
+ let depth = 0;
471
+ for (let i = open; i < masked.length; i++) {
472
+ if (masked[i] === '{')
473
+ depth++;
474
+ else if (masked[i] === '}' && --depth === 0)
475
+ return i;
476
+ }
477
+ return -1;
478
+ }
479
+ function powerShellStatements(command, masked, start, end) {
480
+ const statements = [];
481
+ let depth = 0;
482
+ let from = start;
483
+ for (let i = start; i < end; i++) {
484
+ if (masked[i] === '{')
485
+ depth++;
486
+ else if (masked[i] === '}')
487
+ depth--;
488
+ else if (depth === 0 && (masked[i] === ';' || masked[i] === '\n')) {
489
+ statements.push({ text: command.slice(from, i).trim(), start: from, end: i });
490
+ from = i + 1;
491
+ }
492
+ }
493
+ statements.push({ text: command.slice(from, end).trim(), start: from, end });
494
+ return statements.filter(s => s.text);
495
+ }
411
496
  function denyMerge(input, reason) {
412
497
  return {
413
498
  hookSpecificOutput: {
@@ -80,10 +80,8 @@ const SEED_UNITS = [
80
80
  {
81
81
  name: 'chip-spawn',
82
82
  followers: [
83
- { harness: 'claude-code', candidate: '~/.claude/docs/chip-spawn.md' },
84
83
  { harness: 'claude-code', candidate: 'packages/cli/skill/templates/chip-spawn.md' },
85
84
  { harness: 'codex', candidate: 'packages/cli/skill/templates/codex-chip-spawn.md' },
86
- { harness: 'codex', candidate: 'docs/codex-chip-spawn.md' },
87
85
  { harness: 'opencode', candidate: null, note: 'no dedicated opencode chip-spawn doc found — chips ride greprag opencode chip + templates/chip-leader-opencode.md covers the leader shape only' },
88
86
  ],
89
87
  },
@@ -189,13 +189,15 @@ function searchFlagsDisqualify(tokens) {
189
189
  }
190
190
  /** First non-flag token = the search pattern, sanitized into a crush
191
191
  * `--query` hint: strip quotes, keep word chars/space/dot/dash only
192
- * (safe inside double quotes), collapse whitespace, cap at 80 chars. */
192
+ * (safe inside double quotes), drop leading dashes, collapse whitespace,
193
+ * cap at 80 chars. A hint of `--help` or `-h` would reach the CLI as the help
194
+ * flag, which prints help in place of the crushed output (adr/cli-help-dispatch.md). */
193
195
  function extractSearchQuery(tokens) {
194
196
  for (const tok of tokens) {
195
197
  if (tok.startsWith('-'))
196
198
  continue;
197
199
  const unquoted = tok.replace(/^["']|["']$/g, '');
198
- const clean = unquoted.replace(/[^\w\s.-]/g, ' ').replace(/\s+/g, ' ').trim();
200
+ const clean = unquoted.replace(/[^\w\s.-]/g, ' ').replace(/^[\s-]+/, '').replace(/\s+/g, ' ').trim();
199
201
  return clean.slice(0, 80);
200
202
  }
201
203
  return '';
@@ -18,19 +18,36 @@ function renderEntry(skill) {
18
18
  function buildSkillMirrorAnnounce(m, platform) {
19
19
  if (!m || m.count <= 0)
20
20
  return null;
21
+ // A hand-kept copy is in the native skill list, so it is not in the trigger
22
+ // list below, but the harness runs that copy and never sees canon move on.
23
+ // Name each one; replacing a live copy is the user's call, skill by skill.
24
+ const cutoverCommand = (0, skill_activation_manifest_1.localCopyCutoverCommand)(platform || '', m.localCopies);
25
+ const copies = m.localCopies.length === 1 ? 'one mirrored skill' : `${m.localCopies.length} mirrored skills`;
26
+ const copySection = cutoverCommand ? [
27
+ '### Hand-Kept Copies',
28
+ `This harness lists ${copies} from a hand-kept copy instead of an adapter, `
29
+ + `so it keeps running that copy when the mirrored skill changes: ${m.localCopies.join(', ')}. `
30
+ + 'Replacing a copy with its adapter is the user\'s call, one skill at a time (the old SKILL.md is backed up first):',
31
+ '',
32
+ ` ${cutoverCommand}`,
33
+ '',
34
+ 'Offer that command when the user asks why a skill behaves out of date, or when you needed one of them this session.',
35
+ '',
36
+ ] : [];
21
37
  if (m.skills.length === 0) {
22
- return '[greprag skills: slash skills are available. '
38
+ const line = '[greprag skills: slash skills are available. '
23
39
  + 'When discussing skills with the user, say use/create/update/refresh `/skill`; '
24
40
  + 'keep storage mechanics internal unless diagnosing a failure or the user asks how it works. '
25
41
  + 'Use `greprag load <skill>` to read skill instructions.]';
42
+ return [line, '', ...copySection].join('\n').trimEnd();
26
43
  }
27
- // The entries this harness does not advertise are exactly the mirrored
28
- // skills with no native adapter installed. Name the gap and the one command
29
- // that closes it, or it stays invisible until the operator spots it by hand.
30
- const installCommand = (0, skill_activation_manifest_1.nativeAdapterInstallCommand)(platform || '', m.skills.map(s => s.skillName));
44
+ // Mirrored skills with no native package never reach the native skill list.
45
+ // Name the gap and the one command that closes it, or it stays invisible
46
+ // until the operator spots it by hand.
47
+ const installCommand = (0, skill_activation_manifest_1.nativeAdapterInstallCommand)(platform || '', m.missing);
31
48
  const gapSection = installCommand ? [
32
49
  '### Missing Native Adapters',
33
- `${m.skills.length} of these skills are in your GrepRAG mirror but have NO adapter installed in this harness, `
50
+ `${m.missing.length} of these skills are in your GrepRAG mirror but have NO adapter installed in this harness, `
34
51
  + 'so they never appear in the native skill list. Install the adapters once with:',
35
52
  '',
36
53
  ` ${installCommand}`,
@@ -47,6 +64,7 @@ function buildSkillMirrorAnnounce(m, platform) {
47
64
  'When discussing skills with the user, say use/create/update/refresh `/skill`; keep storage mechanics internal unless diagnosing a failure or the user asks how it works.',
48
65
  '',
49
66
  ...gapSection,
67
+ ...copySection,
50
68
  '### Trigger Rules',
51
69
  'If the user names a skill (with or without `/`) OR the request clearly matches a skill\'s description below, '
52
70
  + 'you MUST activate that skill for the current turn. Match meaning, not exact keywords.',
@@ -59,6 +59,7 @@ const project_anchor_1 = require("../project-anchor");
59
59
  const skill_mirror_client_1 = require("../skill-mirror-client");
60
60
  const skill_landing_1 = require("../skill-landing");
61
61
  const skill_staleness_1 = require("../skill-staleness");
62
+ const load_1 = require("./load");
62
63
  const CORE_SKILL = 'greprag';
63
64
  const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
65
  /** `--only a,b` / `--only=a,b` / repeated `--only a --only b` → skill names. */
@@ -342,6 +343,7 @@ function forwardedMirrorArgs(args) {
342
343
  }
343
344
  return parts.join(' ');
344
345
  }
346
+ /** Returns whether it wrote: under `check` it only lists the plan, so a caller reports success only on true. */
345
347
  function writeSkillPackage(targetDir, files, opts) {
346
348
  const target = path.resolve(targetDir);
347
349
  if (fs.existsSync(target) && !dirEmpty(target) && !opts.force) {
@@ -357,7 +359,8 @@ function writeSkillPackage(targetDir, files, opts) {
357
359
  console.log(` write ${file.path}`);
358
360
  for (const relative of removals)
359
361
  console.log(` remove ${relative}`);
360
- return;
362
+ console.log('Check only: nothing written.');
363
+ return false;
361
364
  }
362
365
  fs.mkdirSync(target, { recursive: true });
363
366
  for (const relative of removals)
@@ -367,6 +370,7 @@ function writeSkillPackage(targetDir, files, opts) {
367
370
  fs.mkdirSync(path.dirname(destination), { recursive: true });
368
371
  fs.writeFileSync(destination, file.content.endsWith('\n') ? file.content : file.content + '\n', 'utf8');
369
372
  }
373
+ return true;
370
374
  }
371
375
  function printSkillMirrorConflict(params) {
372
376
  const local = new Map(params.localFiles.map(file => [file.path, file.content]));
@@ -395,8 +399,9 @@ async function runSkillMirrorExport(args) {
395
399
  .filter((file) => typeof file.path === 'string' && typeof file.content === 'string');
396
400
  if (!files.length)
397
401
  throw new Error(`No mirrored files returned for ${name}.`);
398
- writeSkillPackage(to, files, { force: args.includes('--force'), check: args.includes('--check'), exact: true });
399
- console.log(`Exported ${name} revision ${Number(data.revision || 0)} (${String(data.contentHash || '').slice(0, 12)})`);
402
+ if (!writeSkillPackage(to, files, { force: args.includes('--force'), check: args.includes('--check'), exact: true }))
403
+ return;
404
+ console.log(`Exported ${name} revision ${Number(data.revision || 0)} (${String(data.contentHash || '')})`);
400
405
  }
401
406
  async function loadMirrorPackage(skill, args) {
402
407
  const data = await mirrorApiJson('GET', `/v1/skillmirror/${encodeURIComponent(skill)}${mirrorLookupQuery(args)}`);
@@ -428,7 +433,16 @@ function runEditor(target) {
428
433
  throw new Error(`editor exited with status ${result.status}`);
429
434
  }
430
435
  }
436
+ /** A skill the CLI ships as its own bundle has one canon, the bundle, and
437
+ * `greprag load` serves it for that skill's global id. A mirror write under
438
+ * its name would land in a row nothing reads. */
439
+ function refuseBundledSkillWrite(skill) {
440
+ if (!(0, load_1.bundleOwnsSkill)(skill))
441
+ return;
442
+ 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
+ }
431
444
  async function applySkillPackage(params) {
445
+ refuseBundledSkillWrite(params.skill);
432
446
  const hash = (0, skill_mirror_client_1.computeMirrorHash)(params.files);
433
447
  const body = {
434
448
  files: params.files,
@@ -465,6 +479,7 @@ async function runSkillEdit(args) {
465
479
  const skill = args[0];
466
480
  if (!skill)
467
481
  throw new Error('Usage: greprag skill edit <name> [--dir <dir>] [--no-open] [--force] [--keep]');
482
+ refuseBundledSkillWrite(skill);
468
483
  const explicitDir = getArg(args, '--dir') || getArg(args, '--to');
469
484
  const noOpen = args.includes('--no-open');
470
485
  const keep = args.includes('--keep') || Boolean(explicitDir);
@@ -476,7 +491,7 @@ async function runSkillEdit(args) {
476
491
  force: args.includes('--force') || !explicitDir,
477
492
  exact: true,
478
493
  });
479
- console.log(`Editing ${skill} revision ${loaded.revision} (${loaded.contentHash.slice(0, 12)})`);
494
+ console.log(`Editing ${skill} revision ${loaded.revision} (${loaded.contentHash})`);
480
495
  console.log(`Work dir: ${workDir}`);
481
496
  const skipEditor = noOpen || codexHarnessDetected();
482
497
  if (skipEditor) {
@@ -498,7 +513,7 @@ async function runSkillEdit(args) {
498
513
  }
499
514
  await assertCurrentMirrorHash({ skill, expectedHash: loaded.contentHash, localFiles: edited, args });
500
515
  const data = await applySkillPackage({ skill, files: edited, expectedHash: loaded.contentHash, args });
501
- console.log(`Applied ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(edited)).slice(0, 12)})`);
516
+ console.log(`Applied ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(edited))})`);
502
517
  if (!keep)
503
518
  fs.rmSync(workDir, { recursive: true, force: true });
504
519
  }
@@ -521,7 +536,7 @@ async function runSkillMirrorApply(args) {
521
536
  expectedRevision: expectedRevisionRaw ? Number(expectedRevisionRaw) : undefined,
522
537
  args,
523
538
  });
524
- console.log(`Applied ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(files)).slice(0, 12)})`);
539
+ console.log(`Applied ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(files))})`);
525
540
  }
526
541
  function snapshotNameArg(args) {
527
542
  const explicit = getArg(args, '--name');
@@ -593,7 +608,8 @@ async function runSkillMirrorSnapshot(args) {
593
608
  if (!to)
594
609
  throw new Error('Usage: greprag skill mirror snapshot restore <skill> --name <snapshot> --to <dir> [--force] [--check]');
595
610
  const files = snapshotPresentFiles(snapshot);
596
- writeSkillPackage(to, files, { force: rest.includes('--force'), check: rest.includes('--check'), exact: true });
611
+ if (!writeSkillPackage(to, files, { force: rest.includes('--force'), check: rest.includes('--check'), exact: true }))
612
+ return;
597
613
  console.log(`Restored snapshot ${name} for ${skill} to ${path.resolve(to)}. Run apply with the expected revision if this should become canon.`);
598
614
  return;
599
615
  }
@@ -614,7 +630,7 @@ async function runSkillMirrorSnapshot(args) {
614
630
  }
615
631
  await assertCurrentMirrorHash({ skill, expectedHash, localFiles: files, args: rest });
616
632
  const data = await applySkillPackage({ skill, files, expectedHash, args: rest });
617
- console.log(`Promoted snapshot ${name} for ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(files)).slice(0, 12)})`);
633
+ console.log(`Promoted snapshot ${name} for ${skill}: revision ${Number(data.revision || 0)} (${String(data.contentHash || (0, skill_mirror_client_1.computeMirrorHash)(files))})`);
618
634
  return;
619
635
  }
620
636
  throw new Error('Usage: greprag skill mirror snapshot create|list|show|restore|promote <skill> [--name <snapshot>]');
@@ -40,6 +40,11 @@ Gains land automatically at session start as a briefcase (fuse line + focused
40
40
  doc) and SELF-APPLY on the skill's next invocation.`;
41
41
  async function runSkillGain(args) {
42
42
  const sub = args[0];
43
+ // Help needs no key: anything that is not a verb, a help flag included.
44
+ if (sub !== 'list' && sub !== 'done' && sub !== 'reject') {
45
+ console.log(HELP);
46
+ return;
47
+ }
43
48
  const cfg = getConfig();
44
49
  if (!cfg.apiKey) {
45
50
  console.error('GREPRAG_API_KEY not set — run `greprag init` first.');
@@ -112,5 +117,4 @@ async function runSkillGain(args) {
112
117
  }
113
118
  return;
114
119
  }
115
- console.log(HELP);
116
120
  }
@@ -60,6 +60,7 @@ exports.isFixSpawnDispatch = isFixSpawnDispatch;
60
60
  exports.computeBootstrapFriction = computeBootstrapFriction;
61
61
  exports.scanKeywords = scanKeywords;
62
62
  exports.computeStateValues = computeStateValues;
63
+ const crypto = __importStar(require("crypto"));
63
64
  const fs = __importStar(require("fs"));
64
65
  /** Stress level enum, encoded numerically so the `state` DSL can threshold it
65
66
  * with gte/lte (0 calm · 1 elevated · 2 high). */
@@ -101,9 +102,25 @@ function isRealUser(rec) {
101
102
  }
102
103
  return true;
103
104
  }
105
+ // adr: adr/stress-target-keying.md
106
+ /** Tools whose input is a CHANGE to a file, not a lookup of it. */
107
+ const WRITE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
104
108
  function toolTargetKey(b) {
105
109
  const name = b.name || '';
106
110
  const inp = (b.input || {});
111
+ // A write repeats only when the same change is issued again. Keyed on the
112
+ // path, every successive edit of one document read as rep + retry, and a
113
+ // report built across operator prompts paged FRICTION HIGH for turns after
114
+ // its last real error (paybot-website 02d0ecbf, 2026-10-05). Writes still
115
+ // reach churn through `writes`, which stays path-keyed.
116
+ if (WRITE_TOOLS.has(name)) {
117
+ let body = '';
118
+ try {
119
+ body = JSON.stringify(inp);
120
+ }
121
+ catch { /* unserializable → empty */ }
122
+ return `${name}:${crypto.createHash('sha1').update(body).digest('hex').slice(0, 16)}`;
123
+ }
107
124
  let key = (inp.command ?? inp.file_path ?? inp.pattern ?? inp.path);
108
125
  if (typeof key !== 'string') {
109
126
  try {
@@ -149,6 +166,7 @@ function parseTurns(transcriptPath, maxTurns = 40) {
149
166
  const turns = [];
150
167
  const pending = new Map();
151
168
  let total = 0;
169
+ let seq = 0;
152
170
  let cur = null;
153
171
  const flush = () => {
154
172
  if (cur) {
@@ -172,7 +190,7 @@ function parseTurns(transcriptPath, maxTurns = 40) {
172
190
  if (isRealUser(rec)) {
173
191
  flush();
174
192
  total++;
175
- cur = { proseWords: 0, toolTargets: [], errors: 0, nTools: 0, writes: [], failures: [] };
193
+ cur = { proseWords: 0, toolTargets: [], errors: 0, nTools: 0, writes: [], failures: [], successes: [] };
176
194
  continue;
177
195
  }
178
196
  if (!cur)
@@ -202,20 +220,25 @@ function parseTurns(transcriptPath, maxTurns = 40) {
202
220
  }
203
221
  else if (rec.type === 'user') { // tool_result records
204
222
  for (const b of blocks(rec)) {
205
- if (b && b.type === 'tool_result' && resultIsError(b)) {
223
+ if (!b || b.type !== 'tool_result')
224
+ continue;
225
+ // The soft regex feeds `errors` (the validated stress feature) only.
226
+ if (resultIsError(b))
206
227
  cur.errors++;
207
- // A ToolFailure (the bootstrap classifier's input) requires HARD
208
- // evidence the command itself failed — the harness's is_error flag,
209
- // never error-looking output text: a successful `… | grep -i error`
210
- // matches ERROR_RE by construction (false-page a0d1a7e2 2026-07-12).
211
- // The soft regex still feeds `errors` (the validated stress feature).
212
- if (b.is_error !== true)
213
- continue;
214
- const useId = typeof b.tool_use_id === 'string' ? b.tool_use_id : '';
215
- const prior = useId ? pending.get(useId) : null;
216
- if (prior)
217
- cur.failures.push({ ...prior, error: toolResultText(b).slice(0, 500) });
218
- }
228
+ const useId = typeof b.tool_use_id === 'string' ? b.tool_use_id : '';
229
+ const prior = useId ? pending.get(useId) : null;
230
+ if (!prior)
231
+ continue;
232
+ seq++;
233
+ // A ToolFailure (the bootstrap classifier's input) requires HARD
234
+ // evidence the command itself failed — the harness's is_error flag,
235
+ // never error-looking output text: a successful `… | grep -i error`
236
+ // matches ERROR_RE by construction (false-page a0d1a7e2 2026-07-12).
237
+ // Everything else settled OK — the evidence that clears a signature.
238
+ if (b.is_error === true)
239
+ cur.failures.push({ ...prior, seq, error: toolResultText(b).slice(0, 500) });
240
+ else
241
+ cur.successes.push({ ...prior, seq });
219
242
  }
220
243
  }
221
244
  }
@@ -446,9 +469,20 @@ function commandFamily(command) {
446
469
  // Quote-stripped: a tool token inside a quoted payload must not mint the
447
470
  // dedupe key — the original incident's fix-spawn argument embedded `node`,
448
471
  // colliding with the real failure's family and inflating `repeats` to 2.
472
+ // Bare `node` keys on the script's BASENAME: `node a.js` and `node b.js` are
473
+ // different shapes, while `node <typo-dir>/h.js` and `node <dir>/h.js` are one
474
+ // (the resolved-typo re-fire, sift 65bed6f0 2026-09-18).
449
475
  const c = stripQuotedSpans(command).toLowerCase();
450
- const m = c.match(/\b(?:npm\s+run\s+\S+|npm\s+\S+|pnpm\s+\S+|yarn\s+\S+|npx\s+\S+|fnm\s+\S+|corepack\s+\S+|turbo\s+\S+|vitest\b|jest\b|tsc\b|node\b)/);
451
- return (m?.[0] || c).replace(/\s+/g, ' ').slice(0, 80);
476
+ const m = c.match(/\b(?:npm\s+run\s+\S+|npm\s+\S+|pnpm\s+\S+|yarn\s+\S+|npx\s+\S+|fnm\s+\S+|corepack\s+\S+|turbo\s+\S+|vitest\b|jest\b|tsc\b|node(?:\s+\S+)?)/);
477
+ const family = (m?.[0] || c).replace(/\s+/g, ' ');
478
+ const node = family.match(/^node (\S+)$/);
479
+ return (node ? `node ${node[1].replace(/\\/g, '/').split('/').pop()}` : family).slice(0, 80);
480
+ }
481
+ /** `run` settled after `failure` in transcript order (turn, then seq). */
482
+ function settledAfter(run, runTurn, failure, failureTurn) {
483
+ if (runTurn !== failureTurn)
484
+ return runTurn > failureTurn;
485
+ return (run.seq ?? -1) > (failure.seq ?? Infinity);
452
486
  }
453
487
  /** Detect setup/toolchain failures that are likely repairable once for all future
454
488
  * sessions. This is deliberately narrower than generic stress: source-code
@@ -468,21 +502,23 @@ function computeBootstrapFriction(turns) {
468
502
  if (!failures.length)
469
503
  return none;
470
504
  const latest = failures[failures.length - 1];
471
- // Dispatch suppression: a `greprag fix spawn` that RAN (didn't itself fail)
472
- // at/after the latest qualifying failure IS the remedy in motion — the
473
- // friction is dispatched, not fresh. Without this the same signature
474
- // re-paged every turn it stayed in the window, even after compliance
475
- // (incident 250ac565, 2026-07-12). A FAILED spawn never counts — the chip
476
- // did not launch. Same-turn spawns suppress too (conservative-quiet: a
477
- // recurrence on a later run re-fires, since it postdates the spawn).
505
+ const latestFamily = commandFamily(latest.failure.command);
506
+ // A signature lives only until a later SUCCESSFUL run resolves it. Without
507
+ // this the directive re-paged every turn the failure stayed in the window:
508
+ // after compliance (incident 250ac565, 2026-07-12) and after the very next
509
+ // run of the same shape succeeded (sift 65bed6f0, 2026-09-18). Two resolvers:
510
+ // - dispatch: a `greprag fix spawn` that RAN at/after the latest failure's
511
+ // turn — the remedy is in motion (same-turn spawns count: conservative-quiet);
512
+ // - recovery: the same command shape succeeded after the latest failure —
513
+ // the toolchain works now, the failure was transient.
514
+ // A FAILED run never resolves. A recurrence after either re-fires, since the
515
+ // new failure becomes `latest` and postdates the resolver.
478
516
  for (let turn = latest.turn; turn < turns.length; turn++) {
479
- const t = turns[turn];
480
- const failedCmds = new Set((t.failures || []).map(f => f.command));
481
- for (const tg of t.toolTargets || []) {
482
- if (!tg.startsWith('Bash:'))
483
- continue;
484
- const cmd = tg.slice('Bash:'.length);
485
- if (isFixSpawnDispatch(cmd) && !failedCmds.has(cmd))
517
+ for (const s of turns[turn].successes || []) {
518
+ if (isFixSpawnDispatch(s.command))
519
+ return none;
520
+ if (commandFamily(s.command) === latestFamily
521
+ && settledAfter(s, turn, latest.failure, latest.turn))
486
522
  return none;
487
523
  }
488
524
  }
@@ -491,7 +527,7 @@ function computeBootstrapFriction(turns) {
491
527
  const key = `${f.signal}:${commandFamily(f.failure.command)}`;
492
528
  counts.set(key, (counts.get(key) || 0) + 1);
493
529
  }
494
- const latestKey = `${latest.signal}:${commandFamily(latest.failure.command)}`;
530
+ const latestKey = `${latest.signal}:${latestFamily}`;
495
531
  const repeats = counts.get(latestKey) || 1;
496
532
  const strongSingle = latest.signal === 'command-not-found'
497
533
  || latest.signal === 'fnm-node-bootstrap'
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.deliveryProfilePath = deliveryProfilePath;
38
+ exports.draftDeliveryProfile = draftDeliveryProfile;
38
39
  exports.resolveDeliveryProfile = resolveDeliveryProfile;
39
40
  const fs = __importStar(require("fs"));
40
41
  const path = __importStar(require("path"));
@@ -160,6 +161,74 @@ function parseProfile(root, raw) {
160
161
  deploySource, releaseSource, errors,
161
162
  };
162
163
  }
164
+ function git(cwd, command) {
165
+ try {
166
+ return (0, proc_1.safeExecSync)(`git ${command}`, {
167
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
168
+ }).trim();
169
+ }
170
+ catch {
171
+ return '';
172
+ }
173
+ }
174
+ const PROVIDER_SIGNALS = [
175
+ ['wrangler.toml', 'Cloudflare Workers/Pages (wrangler)'],
176
+ ['wrangler.jsonc', 'Cloudflare Workers/Pages (wrangler)'],
177
+ ['wrangler.json', 'Cloudflare Workers/Pages (wrangler)'],
178
+ ['Procfile', 'Heroku (Procfile)'],
179
+ ['app.json', 'Heroku (app.json)'],
180
+ ['fly.toml', 'Fly.io'],
181
+ ['vercel.json', 'Vercel'],
182
+ ['netlify.toml', 'Netlify'],
183
+ ['Dockerfile', 'Docker image'],
184
+ ];
185
+ /** Draft a profile from the repo's own Git facts. Deploy targets stay a stub until a deploy doc exists. */
186
+ // adr: adr/delivery-profile.md
187
+ function draftDeliveryProfile(cwd) {
188
+ const root = gitRoot(cwd);
189
+ if (!root)
190
+ return null;
191
+ const remotes = git(root, 'remote').split(/\r?\n/).filter(Boolean);
192
+ const remote = remotes.includes('origin') ? 'origin' : remotes[0] || 'none';
193
+ const originHead = remote !== 'none' ? git(root, `symbolic-ref --short refs/remotes/${remote}/HEAD`) : '';
194
+ const branches = git(root, 'branch --format=%(refname:short)').split(/\r?\n/);
195
+ const defaultBranch = originHead.replace(`${remote}/`, '')
196
+ || ['main', 'master'].find((b) => branches.includes(b))
197
+ || git(root, 'branch --show-current') || 'main';
198
+ const commonDir = git(root, 'rev-parse --path-format=absolute --git-common-dir');
199
+ const canonicalRoot = (commonDir ? path.dirname(commonDir) : root).replace(/\\/g, '/');
200
+ const providerSignals = PROVIDER_SIGNALS
201
+ .filter(([file]) => fs.existsSync(path.join(root, file))).map(([, label]) => label);
202
+ try {
203
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
204
+ if (pkg.name && pkg.private !== true)
205
+ providerSignals.push(`npm package ${pkg.name}`);
206
+ }
207
+ catch { /* no package.json */ }
208
+ const unique = [...new Set(providerSignals)];
209
+ const hasDeployDoc = fs.existsSync(path.join(root, 'docs', 'deploy.md'));
210
+ const todo = [];
211
+ if (remote === 'none')
212
+ todo.push('no Git remote: delivery is commit-and-merge only until one is added');
213
+ if (unique.length && !hasDeployDoc) {
214
+ todo.push('write docs/deploy.md (exact deploy command + production check), then add a deploy block pointing at it');
215
+ }
216
+ if (hasDeployDoc) {
217
+ todo.push('rename the "production" target to match docs/deploy.md; set pushDeploys=true if a push alone ships it');
218
+ }
219
+ return {
220
+ root,
221
+ providerSignals: unique,
222
+ todo,
223
+ profile: {
224
+ version: 1,
225
+ git: { defaultBranch, remote, integrationStrategy: 'merge', pushDeploys: false },
226
+ deploy: hasDeployDoc
227
+ ? { source: 'docs/deploy.md', canonicalRoot, targets: [{ id: 'production', procedureId: 'deploy:production' }] }
228
+ : undefined,
229
+ },
230
+ };
231
+ }
163
232
  // adr: adr/delivery-profile.md
164
233
  function resolveDeliveryProfile(cwd) {
165
234
  const root = gitRoot(cwd);