greprag 5.80.0 → 5.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/capture-manifest.js +2 -1
  2. package/dist/codex-fast-hook.js +6 -0
  3. package/dist/codex-steering.js +1 -1
  4. package/dist/commands/app-model.js +0 -1
  5. package/dist/commands/arm-reminder.js +9 -7
  6. package/dist/commands/collision-check.js +7 -6
  7. package/dist/commands/corpus/client.js +13 -3
  8. package/dist/commands/delivery-reminder.js +35 -14
  9. package/dist/commands/deploy-gate.js +55 -0
  10. package/dist/commands/deploy-lock.js +100 -0
  11. package/dist/commands/deploy-record.js +145 -0
  12. package/dist/commands/deploy-verify.js +111 -0
  13. package/dist/commands/inbox-primer-reminder.js +5 -5
  14. package/dist/commands/inbox-watch.js +2 -4
  15. package/dist/commands/init.js +82 -0
  16. package/dist/commands/load.js +40 -0
  17. package/dist/commands/loadout-reminder.js +1 -1
  18. package/dist/commands/merge-guard.js +419 -0
  19. package/dist/commands/merge-lock.js +176 -0
  20. package/dist/commands/parity-reminder.js +53 -0
  21. package/dist/commands/persona-reminder.js +11 -0
  22. package/dist/commands/persona.js +50 -0
  23. package/dist/commands/procedure.js +77 -6
  24. package/dist/commands/reminder-registry.js +21 -5
  25. package/dist/commands/repodoc.js +433 -0
  26. package/dist/commands/search.js +149 -0
  27. package/dist/commands/skillgain.js +33 -25
  28. package/dist/delivery-lifecycle.js +16 -1
  29. package/dist/deploy-gate.js +355 -0
  30. package/dist/deploy-locks.js +339 -0
  31. package/dist/deploy-verify.js +209 -0
  32. package/dist/env-redaction.js +157 -0
  33. package/dist/harness-limits.js +17 -0
  34. package/dist/hook-runtime.js +11 -1
  35. package/dist/hook.js +170 -88
  36. package/dist/index.js +593 -567
  37. package/dist/inline-atom-episode.js +15 -7
  38. package/dist/inline-atom.js +8 -2
  39. package/dist/native-skill-adoption.js +11 -0
  40. package/dist/native-skill-mirror.js +8 -1
  41. package/dist/node-identity.bundle.js +1166 -0
  42. package/dist/opencode-plugin.bundle.js +307 -119
  43. package/dist/procedure-enabled.js +55 -0
  44. package/dist/procedure-runtime.js +6 -0
  45. package/dist/procedure-scope.js +190 -0
  46. package/dist/procedure-watch.js +29 -16
  47. package/dist/procedure.js +111 -5
  48. package/dist/project-anchor.js +1 -14
  49. package/dist/reminder-injector.js +11 -10
  50. package/dist/repodoc-client.js +296 -0
  51. package/dist/session-id.js +7 -8
  52. package/dist/skill-landing.js +57 -2
  53. package/dist/skill-mirror-client.js +14 -0
  54. package/dist/skill-mirror-files.js +18 -0
  55. package/package.json +2 -2
  56. package/scripts/bundle-node-identity.mjs +47 -0
  57. package/skill/templates/chip-spawn.md +7 -1
  58. package/skill/templates/delivery.md +105 -0
  59. package/skill/templates/prompt-audit.md +196 -0
  60. package/skill/templates/skill-change.md +25 -2
  61. package/dist/assistant-doctrine.js +0 -85
  62. package/dist/commands/assistant-reminder.js +0 -19
  63. package/dist/commands/assistant.js +0 -95
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ /** merge-guard.ts — PreToolUse[Bash]: no `git merge` into a shared canonical
3
+ * checkout without holding the merge lock.
4
+ *
5
+ * WHY A GUARD AND NOT A REMINDER. The merge lock and the provenance claim both
6
+ * work, and both are advisory: a session that simply runs `git merge` skips them
7
+ * and nothing notices. That is not hypothetical. On 2026-09-04 two sessions
8
+ * merged into the same Sift checkout at once, one resolved the other's conflicts
9
+ * and completed the merge under a new sha, and the first session's merge commit
10
+ * ended up on no branch — it found out only because its deploy shipped a
11
+ * different commit than it had merged. `merge-state` catches the aftermath;
12
+ * nothing was stopping the collision.
13
+ *
14
+ * SCOPE — deliberately narrow, because an over-broad guard gets bypassed for
15
+ * being wrong:
16
+ * · only the canonical primary checkout named by `.greprag/delivery.json`.
17
+ * A merge inside an isolated worktree affects nobody else and is untouched.
18
+ * · only a merge that STARTS one. `--abort`, `--continue` and `--quit` finish
19
+ * or abandon a merge already in progress and must never be blocked — that
20
+ * would strand exactly the conflicted checkout this whole effort is about.
21
+ * · never `git merge-base`, `merge-file` or `merge-tree`. Those are read-only
22
+ * plumbing that happens to share a prefix, and `merge-base` is used BY the
23
+ * provenance check — blocking it would break the gate with the guard.
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.
26
+ *
27
+ * Holding the lock yourself is a pass. A peer holding it is the refusal that
28
+ * matters: it is the exact collision, caught before the merge instead of after.
29
+ *
30
+ * TWO FALSE REJECTIONS, both on 2026-09-06 in C:/sift, both fixed here.
31
+ *
32
+ * 1. THE SHELL'S CWD IS NOT THE MERGE TARGET. The guard compared the hook's
33
+ * `cwd` against `canonicalRoot`, so `git -C <worktree> merge master` run from
34
+ * C:/sift was refused — a merge inside a worktree, which this file's own
35
+ * refusal promises is never blocked. The same string compare was wrong in the
36
+ * other direction too: a plain `git merge` from any SUBDIRECTORY of the
37
+ * canonical checkout did not match the root and sailed through unguarded.
38
+ * One check, two opposite errors, because it asked the wrong question. The
39
+ * question is not "where is the shell" but "which worktree does this merge
40
+ * land in", and only git can answer that — so the guard now asks it, running
41
+ * `rev-parse --show-toplevel` with the same `-C`/`--git-dir` options and from
42
+ * the same directory the merge itself will use. (The worktree WRITE guard hit
43
+ * this same defect on 2026-09-05 and was repaired the same way: stop reading
44
+ * the payload cwd as if it named the thing being acted on.)
45
+ *
46
+ * 2. AN ACQUIRE IN THE SAME COMMAND IS AN ACQUIRE. The guard saw `git merge` and
47
+ * read the lock file, which the `greprag merge-lock acquire` two tokens to its
48
+ * left had not yet written, and refused. `acquire` exits 1 when anyone else
49
+ * holds the lock, so `acquire && merge && release` cannot merge past a peer —
50
+ * `&&` stops it. Refusing that chain protected nothing and cost three round
51
+ * trips. Only `&&` earns this: after `;` or `||` the merge still runs when the
52
+ * acquire failed, so those are refused, and the refusal now says so.
53
+ *
54
+ * # adr: adr/deploy-gate-cli.md
55
+ */
56
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
57
+ if (k2 === undefined) k2 = k;
58
+ var desc = Object.getOwnPropertyDescriptor(m, k);
59
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
60
+ desc = { enumerable: true, get: function() { return m[k]; } };
61
+ }
62
+ Object.defineProperty(o, k2, desc);
63
+ }) : (function(o, m, k, k2) {
64
+ if (k2 === undefined) k2 = k;
65
+ o[k2] = m[k];
66
+ }));
67
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
68
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
69
+ }) : function(o, v) {
70
+ o["default"] = v;
71
+ });
72
+ var __importStar = (this && this.__importStar) || (function () {
73
+ var ownKeys = function(o) {
74
+ ownKeys = Object.getOwnPropertyNames || function (o) {
75
+ var ar = [];
76
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
77
+ return ar;
78
+ };
79
+ return ownKeys(o);
80
+ };
81
+ return function (mod) {
82
+ if (mod && mod.__esModule) return mod;
83
+ var result = {};
84
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
85
+ __setModuleDefault(result, mod);
86
+ return result;
87
+ };
88
+ })();
89
+ Object.defineProperty(exports, "__esModule", { value: true });
90
+ exports.startsAMerge = startsAMerge;
91
+ exports.mergeTakesItsOwnLock = mergeTakesItsOwnLock;
92
+ exports.buildMergeGuardReason = buildMergeGuardReason;
93
+ exports.runMergeGuard = runMergeGuard;
94
+ const deploy_locks_1 = require("../deploy-locks");
95
+ const delivery_config_1 = require("../delivery-config");
96
+ const deploy_gate_1 = require("../deploy-gate");
97
+ const proc_1 = require("../proc");
98
+ const path = __importStar(require("path"));
99
+ /** Git's global options that take their value as a SEPARATE token. `git -c k=v merge`
100
+ * is the one that matters in practice — a regex that skips only `-*` tokens reads
101
+ * `k=v` as the subcommand and waves the merge through. */
102
+ const GLOBAL_OPTS_WITH_VALUE = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
103
+ /** Blank out the CONTENTS of quoted spans, keeping the string's length and its
104
+ * quote characters. Two things depend on this:
105
+ *
106
+ * · splitting. Without it a script that merely QUOTES a command —
107
+ * `node -e "... 'cd X && git merge Y' ..."` — splits on the `&&` inside the
108
+ * quotes and yields a segment that really does begin `git merge`. That false
109
+ * positive blocked this guard's own test suite the first time it ran.
110
+ * · offsets. The mask is only used to FIND separators; every segment is then cut
111
+ * from the original string, so `git -C "C:/some path" merge` still carries its
112
+ * real path. The earlier mask collapsed a quoted span to two characters, which
113
+ * is why a quoted `-C` value could not be read at all. */
114
+ function maskQuoted(command) {
115
+ return command
116
+ .replace(/'[^']*'/g, m => `'${' '.repeat(m.length - 2)}'`)
117
+ .replace(/"[^"]*"/g, m => `"${' '.repeat(m.length - 2)}"`);
118
+ }
119
+ /** Split a command on the shell separators a caller might chain with, keeping
120
+ * each segment's original text and whether `&&` joined it to what came before. */
121
+ function shellSegments(command) {
122
+ const source = String(command || '');
123
+ const masked = maskQuoted(source);
124
+ const separator = /&&|\|\||[;\n|]/g;
125
+ const segments = [];
126
+ let start = 0;
127
+ let afterAnd = true;
128
+ for (let match = separator.exec(masked); match; match = separator.exec(masked)) {
129
+ segments.push({ text: source.slice(start, match.index), afterAnd });
130
+ afterAnd = match[0] === '&&';
131
+ start = match.index + match[0].length;
132
+ }
133
+ segments.push({ text: source.slice(start), afterAnd });
134
+ return segments;
135
+ }
136
+ /** Quote-aware tokens. A quoted run is one token with its quotes removed, so
137
+ * `-C "C:/a path"` reads as the directory it names. */
138
+ function tokenize(segment) {
139
+ const tokens = [];
140
+ let current = '';
141
+ let quote = null;
142
+ let open = false;
143
+ for (const ch of segment) {
144
+ if (quote) {
145
+ if (ch === quote)
146
+ quote = null;
147
+ else
148
+ current += ch;
149
+ continue;
150
+ }
151
+ if (ch === '"' || ch === "'") {
152
+ quote = ch;
153
+ open = true;
154
+ continue;
155
+ }
156
+ if (/\s/.test(ch)) {
157
+ if (current || open)
158
+ tokens.push(current);
159
+ current = '';
160
+ open = false;
161
+ continue;
162
+ }
163
+ current += ch;
164
+ }
165
+ if (current || open)
166
+ tokens.push(current);
167
+ return tokens;
168
+ }
169
+ /** The git global options a merge command carries (everything between `git` and
170
+ * `merge`), or null when this segment does not start a merge in this repository. */
171
+ function mergeGlobalOpts(segment) {
172
+ const tokens = tokenize(segment);
173
+ if (!/^(?:git|git\.exe)$/i.test(tokens[0] || ''))
174
+ return null;
175
+ // Walk past git's own global options to find the subcommand.
176
+ let i = 1;
177
+ while (i < tokens.length && tokens[i].startsWith('-')) {
178
+ if (GLOBAL_OPTS_WITH_VALUE.has(tokens[i]))
179
+ i += 2;
180
+ else
181
+ i += 1;
182
+ }
183
+ // `merge` exactly: never merge-base / merge-file / merge-tree.
184
+ if (tokens[i] !== 'merge')
185
+ return null;
186
+ // Finishing or abandoning an in-progress merge is always allowed.
187
+ if (tokens.slice(i + 1).some(t => t === '--abort' || t === '--continue' || t === '--quit'))
188
+ return null;
189
+ return tokens.slice(1, i);
190
+ }
191
+ /** Pure. True only for a command that STARTS a merge in this repository. */
192
+ function startsAMerge(command) {
193
+ return shellSegments(command).some(s => mergeGlobalOpts(s.text) !== null);
194
+ }
195
+ /** True for a `greprag merge-lock acquire` — the command whose exit code is what
196
+ * makes an `&&` chain serialised. `status` and `release` are not it. */
197
+ function isMergeLockAcquire(segment) {
198
+ const tokens = tokenize(segment);
199
+ const start = tokens[0] === '&' ? 1 : 0;
200
+ return /(^|[\\/])greprag(\.[a-z]+)?$/i.test(tokens[start] || '')
201
+ && tokens[start + 1] === 'merge-lock' && tokens[start + 2] === 'acquire';
202
+ }
203
+ /** True when the command takes the merge lock itself, ahead of the merge, with
204
+ * nothing but `&&` between them.
205
+ *
206
+ * `acquire` exits 1 when someone else holds the lock, so `&&` refuses to run the
207
+ * merge — the chain is already serialised and the guard has nothing to add. After
208
+ * a `;` or `||` the merge runs even though the acquire failed, which is precisely
209
+ * the collision this guard exists to prevent. */
210
+ function mergeTakesItsOwnLock(command) {
211
+ const segments = shellSegments(command);
212
+ const mergeAt = segments.findIndex(s => mergeGlobalOpts(s.text) !== null);
213
+ if (mergeAt <= 0)
214
+ return false;
215
+ for (let i = mergeAt; i > 0; i--) {
216
+ if (!segments[i].afterAnd)
217
+ return false; // a `;` or `||` broke the chain
218
+ if (isMergeLockAcquire(segments[i - 1].text))
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ /** The directory the merge command will actually run in: the shell's cwd, moved by
224
+ * any `cd` that precedes the merge in the same chain. */
225
+ function mergeCwd(segments, mergeAt, cwd) {
226
+ let dir = cwd;
227
+ for (let i = 0; i < mergeAt; i++) {
228
+ const tokens = tokenize(segments[i].text);
229
+ if (!/^(cd|Set-Location)$/i.test(tokens[0] || ''))
230
+ continue;
231
+ const dest = /^-(LiteralPath|Path)$/i.test(tokens[1] || '') ? tokens[2] : tokens[1];
232
+ if (!dest || dest === '-')
233
+ continue;
234
+ dir = path.resolve(dir, dest);
235
+ }
236
+ return dir;
237
+ }
238
+ /** The worktree this merge lands in, asked of git rather than guessed from a path.
239
+ *
240
+ * Returns null when git cannot answer — no repository, git missing, options it
241
+ * rejects. The caller falls back to the directory itself, which is the pre-2026-09-06
242
+ * behaviour and still refuses a bare merge run at the canonical root. */
243
+ function mergeWorktreeRoot(globalOpts, dir) {
244
+ try {
245
+ const result = (0, proc_1.safeSpawnSync)('git', [...globalOpts, 'rev-parse', '--show-toplevel'], {
246
+ encoding: 'utf8', cwd: dir, stdio: ['ignore', 'pipe', 'pipe'],
247
+ });
248
+ if (result.status !== 0)
249
+ return null;
250
+ const top = String(result.stdout).trim();
251
+ return top || null;
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
257
+ /** Merge location is Git topology, not deploy policy. Ref-push repos such as
258
+ * PayBot omit deploy.canonicalRoot but still share a primary checkout. */
259
+ function canonicalRoot(cwd) {
260
+ const resolved = (0, delivery_config_1.resolveDeliveryProfile)(cwd);
261
+ if (resolved.mode !== 'profile')
262
+ return null;
263
+ if (resolved.profile?.deploy?.canonicalRoot)
264
+ return resolved.profile.deploy.canonicalRoot;
265
+ const common = (0, proc_1.safeSpawnSync)('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
266
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
267
+ });
268
+ if (common.status !== 0)
269
+ return null;
270
+ const gitDir = String(common.stdout).trim();
271
+ return path.basename(gitDir) === '.git' ? path.dirname(gitDir) : null;
272
+ }
273
+ function buildMergeGuardReason(held, ownership) {
274
+ if (ownership === 'peer' && held) {
275
+ return [
276
+ 'MERGE BLOCKED: another session is already merging into this canonical checkout',
277
+ `(session ${(0, deploy_locks_1.describeLockOwner)(held.record)}, since ${held.record.startedAt}).`,
278
+ 'Two merges at once is how one gets orphaned onto no branch — it happened here on 2026-09-04.',
279
+ 'Wait for it to finish, then merge. If you know that session is gone: greprag merge-lock release',
280
+ ].join(' ');
281
+ }
282
+ // The lock is real but nobody can be shown to hold it — either it was written without
283
+ // a session id, or this harness exports none. Saying "another session" here is a lie
284
+ // that shuts the holder out of its own merge with no stated way back in; this says what
285
+ // is actually known and names the one command that resolves it.
286
+ if (ownership === 'unattributable' && held) {
287
+ return [
288
+ 'MERGE BLOCKED: this checkout holds a merge lock that names no owner',
289
+ `(pid ${held.record.pid}, since ${held.record.startedAt}), so it cannot be told from yours.`,
290
+ 'It is most likely YOUR OWN lock, taken before this session had an id.',
291
+ 'If you took it, or nobody else is merging here: greprag merge-lock release — then acquire and merge again.',
292
+ ].join(' ');
293
+ }
294
+ return [
295
+ 'MERGE BLOCKED: take the merge lock first — this is the shared canonical checkout, and',
296
+ 'merges into it are serialised the way deploys already are.',
297
+ ' greprag merge-lock acquire --label "<what you are merging>"',
298
+ ' git merge --ff-only <rebased branch>',
299
+ ' 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.',
303
+ '`--landed` is what lets the deploy prove YOUR work is in what ships, rather than only',
304
+ 'that production serves HEAD. Merging in a worktree instead? That is never blocked.',
305
+ ].join(' ');
306
+ }
307
+ function runMergeGuard(input) {
308
+ const tool = String(input.tool_name || '').toLowerCase().split('.').pop();
309
+ if (!['bash', 'shell', 'exec_command', 'shell_command'].includes(tool || ''))
310
+ return null;
311
+ const command = input.tool_input?.command ?? input.tool_input?.cmd;
312
+ if (typeof command !== 'string')
313
+ return null;
314
+ const segments = shellSegments(command);
315
+ for (let mergeAt = 0; mergeAt < segments.length; mergeAt++) {
316
+ if (mergeGlobalOpts(segments[mergeAt].text) === null)
317
+ continue;
318
+ const out = guardMergeAt(input, segments, mergeAt);
319
+ if (out)
320
+ return out;
321
+ }
322
+ return null;
323
+ }
324
+ function guardMergeAt(input, segments, mergeAt) {
325
+ const cwd = input.tool_input?.workdir || input.cwd || process.cwd();
326
+ const globalOpts = mergeGlobalOpts(segments[mergeAt].text) || [];
327
+ const dir = mergeCwd(segments, mergeAt, cwd);
328
+ const target = mergeWorktreeRoot(globalOpts, dir) ?? dir;
329
+ let root;
330
+ try {
331
+ root = canonicalRoot(target);
332
+ }
333
+ catch {
334
+ return null;
335
+ }
336
+ if (!root)
337
+ return null;
338
+ // Which worktree does this merge land in? Not "where is the shell" — the two differ
339
+ // whenever the command carries a `cd` or a `-C`, and that difference is the whole bug.
340
+ // Not the shared checkout (a worktree, a clone, an unprofiled repo) — not our business.
341
+ if ((0, deploy_gate_1.normalizePath)(target) !== (0, deploy_gate_1.normalizePath)(root))
342
+ return null;
343
+ let held = null;
344
+ try {
345
+ held = (0, deploy_locks_1.readMergeLock)(target);
346
+ // Explicit --continue/--abort/--quit already bypass the start matcher. A new
347
+ // merge must never adopt another session's unfinished integration.
348
+ if ((0, deploy_locks_1.readMergeState)(target).inProgress)
349
+ return denyMerge(input, 'MERGE BLOCKED: an integration is already in progress here. Resolve its ownership and finish or abort it before starting another merge.');
350
+ }
351
+ catch {
352
+ // Cannot read the lock — say nothing rather than block a merge on our own fault.
353
+ return null;
354
+ }
355
+ // Both sides go through the shared session helpers rather than a hand-rolled
356
+ // `slice(0, 8)`. A UUIDv7 harness (Codex, Grok) is addressed by 16 hex digits, and
357
+ // truncating it locally to 8 was a second copy of identity logic waiting to disagree
358
+ // with the one in session-id.ts.
359
+ const ownership = (0, deploy_locks_1.lockOwnership)(held, input.session_id);
360
+ const chainedLock = takesLockForTarget(segments, mergeAt, cwd, target);
361
+ if (ownership !== 'mine' && !chainedLock)
362
+ return denyMerge(input, buildMergeGuardReason(held, ownership));
363
+ // Git owns the final freshness check AT mutation time. Even if master changes
364
+ // after this hook (or after a chained acquire), --ff-only cannot integrate a
365
+ // divergent branch. Rebase and tests stay in the isolated source worktree.
366
+ const words = tokenize(segments[mergeAt].text);
367
+ const args = words.slice(1 + globalOpts.length + 1);
368
+ if (!requestsFastForwardOnly(args)) {
369
+ return denyMerge(input, 'MERGE BLOCKED: shared-checkout integration requires git merge --ff-only <branch>. '
370
+ + 'Fetch the configured remote, update the default branch, rebase your worktree onto its current tip, '
371
+ + 'resolve conflicts and rerun the affected checks there. Take the merge lock, recheck the tip, '
372
+ + 'then fast-forward. If the default branch moved again, free your lock and repeat the rebase/checks. '
373
+ + 'Record the landed commit when freeing the lock; never rebase the shared default branch.');
374
+ }
375
+ return null;
376
+ }
377
+ /** Only an option counts, not a message, strategy option, or revision after --. */
378
+ function requestsFastForwardOnly(args) {
379
+ let ffOnly = false;
380
+ for (let i = 0; i < args.length; i++) {
381
+ const arg = args[i];
382
+ if (arg === '--')
383
+ break;
384
+ if (['-m', '--message', '-s', '--strategy', '-X', '--strategy-option', '-F', '--file'].includes(arg)) {
385
+ i++;
386
+ continue;
387
+ }
388
+ if (arg === '--ff-only')
389
+ ffOnly = true;
390
+ if (['--ff', '--no-ff', '--squash', '--no-commit'].includes(arg))
391
+ return false;
392
+ }
393
+ return ffOnly;
394
+ }
395
+ /** An acquire only serialises THIS merge when it runs in this checkout, and
396
+ * nothing frees the lock before the merge. Inspect every merge in a batch. */
397
+ function takesLockForTarget(segments, mergeAt, cwd, target) {
398
+ for (let i = mergeAt; i > 0; i--) {
399
+ if (!segments[i].afterAnd)
400
+ return false;
401
+ const words = tokenize(segments[i - 1].text);
402
+ if (words.includes('merge-lock') && words.includes('release'))
403
+ return false;
404
+ if (!isMergeLockAcquire(segments[i - 1].text))
405
+ 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);
408
+ }
409
+ return false;
410
+ }
411
+ function denyMerge(input, reason) {
412
+ return {
413
+ hookSpecificOutput: {
414
+ hookEventName: input.hook_event_name || 'PreToolUse',
415
+ permissionDecision: 'deny',
416
+ permissionDecisionReason: reason,
417
+ },
418
+ };
419
+ }
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runMergeLock = runMergeLock;
4
+ const deploy_locks_1 = require("../deploy-locks");
5
+ const proc_1 = require("../proc");
6
+ const HELP = `greprag merge-lock — serialise merges into a shared canonical checkout
7
+
8
+ Usage:
9
+ greprag merge-lock acquire [--label <text>] [--json]
10
+ greprag merge-lock release [--landed <sha>] [--label <text>] [--json]
11
+ greprag merge-lock status [--json]
12
+ greprag merge-lock claim [--drop <sha>] [--json]
13
+
14
+ Deploys have been serialised for a while; merges have not. On 2026-09-04 two
15
+ sessions merged into the same checkout at once, one resolved the other's
16
+ conflicts, and the first session's merge commit was orphaned onto no branch — it
17
+ found out only because its deploy shipped a different commit than it merged.
18
+
19
+ Take this before you merge into a shared checkout and release it after. The
20
+ deploy gate refuses while it is held, and reports an open merge that nobody holds
21
+ as abandoned — which is the state a dead session leaves behind.
22
+
23
+ Exit 0 when the lock is yours, 1 when someone else holds it.
24
+
25
+ \`--landed <sha>\` records what you just merged. The deploy gate then refuses until
26
+ that commit is actually in what ships. Without it a deploy can only prove production
27
+ serves HEAD — never that HEAD contains your work. On 2026-09-04 a session's merge was
28
+ redone by a peer under a new sha, its deploy exited 0 honestly, and it reported
29
+ shipping work that was on no branch.
30
+
31
+ Options:
32
+ --label <text> What this merge is, shown to whoever is blocked by it
33
+ --landed <sha> Record this commit as merged, to be verified at deploy time
34
+ --drop <sha> (claim) Forget a claim you have confirmed by hand
35
+ --json Machine-readable result`;
36
+ /** Resolve to a full sha, so a claim never depends on an abbreviation staying unique. */
37
+ function resolveCommit(rev, cwd) {
38
+ const result = (0, proc_1.safeSpawnSync)('git', ['rev-parse', '--verify', `${rev}^{commit}`], {
39
+ encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
40
+ });
41
+ return result.status === 0 ? String(result.stdout).trim() : null;
42
+ }
43
+ function flagValue(args, name) {
44
+ const index = args.indexOf(name);
45
+ if (index === -1)
46
+ return undefined;
47
+ const value = args[index + 1];
48
+ return value && !value.startsWith('--') ? value : undefined;
49
+ }
50
+ function runMergeLock(args) {
51
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
52
+ console.log(HELP);
53
+ return;
54
+ }
55
+ const sub = args[0] || 'status';
56
+ const json = args.includes('--json');
57
+ const cwd = process.cwd();
58
+ let file;
59
+ try {
60
+ file = (0, deploy_locks_1.mergeLockPath)(cwd);
61
+ }
62
+ catch (error) {
63
+ console.error(`merge-lock: ${error instanceof Error ? error.message : String(error)}`);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ if (sub === 'acquire') {
68
+ // Session-held, so never pid-checked: the process running this command exits
69
+ // immediately and a pid check would mark the lock stale the moment it is taken.
70
+ const result = (0, deploy_locks_1.acquireLock)(file, flagValue(args, '--label') || 'merge', deploy_locks_1.MERGE_STALE_MS, { checkPid: false });
71
+ if (json)
72
+ console.log(JSON.stringify(result));
73
+ else if (result.ok) {
74
+ const session = (0, deploy_locks_1.lockSession)();
75
+ console.log(`merge-lock: held by session ${session || 'UNIDENTIFIED'} (pid ${process.pid})`);
76
+ if (result.held)
77
+ console.log(` reclaimed a stale lock from pid ${result.held.record.pid}`);
78
+ // Say it HERE, not six round trips later when the merge guard refuses a lock it
79
+ // cannot attribute. Silence at this point is what made the failure undiagnosable.
80
+ if (!session) {
81
+ console.log(' WARNING: no harness session id is set, so this lock records no owner');
82
+ console.log(' and the merge guard cannot recognise it as yours. Export GREPRAG_SESSION_ID');
83
+ console.log(' before acquiring, or release and re-acquire once the id is set.');
84
+ }
85
+ }
86
+ else {
87
+ console.error(`merge-lock: another merge is running in this checkout (pid ${result.held?.record.pid},`
88
+ + ` session ${result.held ? (0, deploy_locks_1.describeLockOwner)(result.held.record) : 'unidentified'}, started ${result.held?.record.startedAt}).`
89
+ + '\n Wait for it. Merge conflicts are the coordination point; two merges at once is how one gets orphaned.');
90
+ }
91
+ if (!result.ok)
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ if (sub === 'release') {
96
+ // `--landed <sha>` is how a merge becomes checkable at deploy time. Without it the
97
+ // deploy can only prove production serves HEAD, never that HEAD contains your work.
98
+ const landed = flagValue(args, '--landed');
99
+ if (landed) {
100
+ const resolved = resolveCommit(landed, cwd);
101
+ if (!resolved) {
102
+ console.error(`merge-lock: ${landed} is not a commit in this repository — nothing claimed.`);
103
+ (0, deploy_locks_1.releaseLock)(file);
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ try {
108
+ const claim = (0, deploy_locks_1.recordLandedClaim)(cwd, resolved, flagValue(args, '--label') || 'merged work');
109
+ console.log(`merge-lock: released; claimed ${claim.sha.slice(0, 10)} as landed`);
110
+ console.log(' The deploy gate will refuse until that commit is in what ships.');
111
+ }
112
+ catch (error) {
113
+ console.error(`merge-lock: released, but the claim was NOT recorded: ${error instanceof Error ? error.message : String(error)}`);
114
+ process.exitCode = 1;
115
+ }
116
+ (0, deploy_locks_1.releaseLock)(file);
117
+ return;
118
+ }
119
+ (0, deploy_locks_1.releaseLock)(file);
120
+ if (json)
121
+ console.log(JSON.stringify({ released: true, path: file }));
122
+ else
123
+ console.log('merge-lock: released');
124
+ return;
125
+ }
126
+ if (sub === 'claim') {
127
+ const drop = flagValue(args, '--drop');
128
+ if (drop) {
129
+ console.log((0, deploy_locks_1.dropLandedClaim)(cwd, drop) ? `merge-lock: dropped the claim for ${drop}` : `merge-lock: no claim matching ${drop}`);
130
+ return;
131
+ }
132
+ const all = (0, deploy_locks_1.readLandedClaims)(cwd);
133
+ const open = (0, deploy_locks_1.openClaims)(cwd, all);
134
+ if (json) {
135
+ console.log(JSON.stringify({ all, open }));
136
+ return;
137
+ }
138
+ if (all.length === 0) {
139
+ console.log('merge-lock: no landed claims recorded');
140
+ return;
141
+ }
142
+ console.log(`merge-lock: ${all.length} claim(s), ${open.length} still unshipped:`);
143
+ for (const c of all) {
144
+ const state = open.some(o => o.sha === c.sha)
145
+ ? ((0, deploy_locks_1.commitExists)(cwd, c.sha) ? 'UNSHIPPED (exists, on no branch — rewritten or redone)' : 'UNSHIPPED (not in this repository)')
146
+ : 'shipped';
147
+ console.log(` ${c.sha.slice(0, 10)} ${state} ${c.label} (${c.at})`);
148
+ }
149
+ return;
150
+ }
151
+ if (sub === 'status') {
152
+ const held = (0, deploy_locks_1.readMergeLock)(cwd);
153
+ const merge = (0, deploy_locks_1.readMergeState)(cwd);
154
+ if (json) {
155
+ console.log(JSON.stringify({ held, merge }));
156
+ }
157
+ else {
158
+ if (!held)
159
+ console.log('merge-lock: not held');
160
+ else if (held.stale)
161
+ console.log(`merge-lock: stale (pid ${held.record.pid}, started ${held.record.startedAt}) — would be reclaimed`);
162
+ else
163
+ console.log(`merge-lock: held by pid ${held.record.pid}, session ${(0, deploy_locks_1.describeLockOwner)(held.record)}, since ${held.record.startedAt}`);
164
+ if (merge.inProgress) {
165
+ console.log(` a ${merge.kind} is in progress, ${merge.unresolved.length} unresolved path(s)`);
166
+ for (const p of merge.unresolved.slice(0, 10))
167
+ console.log(` ${p}`);
168
+ if (!held || held.stale)
169
+ console.log(' NOBODY HOLDS THE LOCK — this merge looks abandoned.');
170
+ }
171
+ }
172
+ return;
173
+ }
174
+ console.error(`Unknown merge-lock command: ${sub}\n\n${HELP}`);
175
+ process.exitCode = 1;
176
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /** Parity pending — tell a harness what IT still has to port.
3
+ *
4
+ * The Parity System already knows which copies are stale when a unit advances.
5
+ * Until now that knowledge only surfaced two ways: an operator running
6
+ * `greprag parity`, or a STALE directive fired at whoever happened to open the
7
+ * file. Neither reaches the harness that actually owes the work, so drift sat
8
+ * until someone went looking.
9
+ *
10
+ * This module inverts the audience. The deficiency is "I am a stale follower on
11
+ * N units", evaluated for the CURRENT harness only:
12
+ * - the LEAD sees nothing — it is the copy that advanced, it owes nothing;
13
+ * - a stale harness sees what it owes and the command that ports it;
14
+ * - zero pending is silent, so the signal auto-stops the moment it is cleared.
15
+ *
16
+ * Diverged units are deliberately excluded. Two-sided advancement needs
17
+ * arbitration by the operator, never an unattended port that picks a winner by
18
+ * recency.
19
+ *
20
+ * adr: adr/parity-system.md */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.parityPendingModule = void 0;
23
+ exports.parityPendingDetect = parityPendingDetect;
24
+ exports.buildParityPending = buildParityPending;
25
+ function parityPendingDetect(env) {
26
+ const pending = env.parityPending || [];
27
+ if (!pending.length)
28
+ return { tier: 'silent' };
29
+ return { tier: 'nudge', detail: { count: pending.length } };
30
+ }
31
+ /** Announce-only. This is work to schedule, not an interrupt to obey mid-turn —
32
+ * a porting job belongs at session start, next to the other orientation. */
33
+ function buildParityPending(env) {
34
+ const pending = env.parityPending || [];
35
+ if (!pending.length)
36
+ return null;
37
+ const harness = env.platform || 'this harness';
38
+ const lines = pending.slice(0, 5).map((p) => `• ${p.unit} — ${p.summary} (led by ${p.leadHarness}; your copy: ${p.path})`);
39
+ const more = pending.length > lines.length
40
+ ? `\n• …and ${pending.length - lines.length} more — \`greprag parity\` for the full table.`
41
+ : '';
42
+ return `[greprag parity — ${pending.length} advancement(s) landed elsewhere that ${harness} `
43
+ + `has not taken yet:]\n${lines.join('\n')}${more}\n`
44
+ + `PORT THE RESULT, NEVER THE MECHANISM: read what the lead actually achieved `
45
+ + `(\`greprag memory search\` its unit name), then build the equivalent for this `
46
+ + `harness's own shape. A diff is the wrong artifact — the files differ.`;
47
+ }
48
+ exports.parityPendingModule = {
49
+ id: 'parity-pending',
50
+ detect: parityPendingDetect,
51
+ announce: buildParityPending,
52
+ reminder: () => null,
53
+ };
@@ -3,6 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.personaAnnounceModule = void 0;
4
4
  exports.personaAnnounceModule = {
5
5
  id: 'persona-announce',
6
+ // Stays registered for EVERY harness — and a `harnesses:` tag here would still
7
+ // be wrong, because `recompact` cannot tell which harness invoked it.
8
+ //
9
+ // The HOOK path (recap/recompact, Claude Code + Codex + Grok) drops this module
10
+ // at its own call site: Persona rides a dedicated `greprag-hook persona` hook
11
+ // there, with its own full ~2048-byte allowance, because a real persona is
12
+ // larger than the whole shared announce budget and was being deferred WHOLE.
13
+ //
14
+ // OpenCode is why the module stays registered: its plugin renders this straight
15
+ // into the system prompt on every model call, with no inline cap to fit.
16
+ // adr: adr/persona.md, adr/announce-inline-budget.md
6
17
  detect: () => ({ tier: 'silent' }),
7
18
  announce: (env) => env.personaAnnounce || null,
8
19
  reminder: () => null,