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.
- package/dist/capture-manifest.js +2 -1
- package/dist/codex-fast-hook.js +6 -0
- package/dist/codex-steering.js +1 -1
- package/dist/commands/app-model.js +0 -1
- package/dist/commands/arm-reminder.js +9 -7
- package/dist/commands/collision-check.js +7 -6
- package/dist/commands/corpus/client.js +13 -3
- package/dist/commands/delivery-reminder.js +35 -14
- package/dist/commands/deploy-gate.js +55 -0
- package/dist/commands/deploy-lock.js +100 -0
- package/dist/commands/deploy-record.js +145 -0
- package/dist/commands/deploy-verify.js +111 -0
- package/dist/commands/inbox-primer-reminder.js +5 -5
- package/dist/commands/inbox-watch.js +2 -4
- package/dist/commands/init.js +82 -0
- package/dist/commands/load.js +40 -0
- package/dist/commands/loadout-reminder.js +1 -1
- package/dist/commands/merge-guard.js +419 -0
- package/dist/commands/merge-lock.js +176 -0
- package/dist/commands/parity-reminder.js +53 -0
- package/dist/commands/persona-reminder.js +11 -0
- package/dist/commands/persona.js +50 -0
- package/dist/commands/procedure.js +77 -6
- package/dist/commands/reminder-registry.js +21 -5
- package/dist/commands/repodoc.js +433 -0
- package/dist/commands/search.js +149 -0
- package/dist/commands/skillgain.js +33 -25
- package/dist/delivery-lifecycle.js +16 -1
- package/dist/deploy-gate.js +355 -0
- package/dist/deploy-locks.js +339 -0
- package/dist/deploy-verify.js +209 -0
- package/dist/env-redaction.js +157 -0
- package/dist/harness-limits.js +17 -0
- package/dist/hook-runtime.js +11 -1
- package/dist/hook.js +170 -88
- package/dist/index.js +593 -567
- package/dist/inline-atom-episode.js +15 -7
- package/dist/inline-atom.js +8 -2
- package/dist/native-skill-adoption.js +11 -0
- package/dist/native-skill-mirror.js +8 -1
- package/dist/node-identity.bundle.js +1166 -0
- package/dist/opencode-plugin.bundle.js +307 -119
- package/dist/procedure-enabled.js +55 -0
- package/dist/procedure-runtime.js +6 -0
- package/dist/procedure-scope.js +190 -0
- package/dist/procedure-watch.js +29 -16
- package/dist/procedure.js +111 -5
- package/dist/project-anchor.js +1 -14
- package/dist/reminder-injector.js +11 -10
- package/dist/repodoc-client.js +296 -0
- package/dist/session-id.js +7 -8
- package/dist/skill-landing.js +57 -2
- package/dist/skill-mirror-client.js +14 -0
- package/dist/skill-mirror-files.js +18 -0
- package/package.json +2 -2
- package/scripts/bundle-node-identity.mjs +47 -0
- package/skill/templates/chip-spawn.md +7 -1
- package/skill/templates/delivery.md +105 -0
- package/skill/templates/prompt-audit.md +196 -0
- package/skill/templates/skill-change.md +25 -2
- package/dist/assistant-doctrine.js +0 -85
- package/dist/commands/assistant-reminder.js +0 -19
- 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,
|