convene-cli 1.13.1 → 1.13.2

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.
@@ -129,7 +129,23 @@ function loudOpen(systemMessage) {
129
129
  }
130
130
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
131
131
  async function run(opts) {
132
- const top = (0, git_1.gitToplevel)();
132
+ // Read the PreToolUse / pre-push payload ONCE up front. stdin can only be read
133
+ // once, so everything downstream reuses `payloadRaw`. For the Claude Code
134
+ // PreToolUse path we need it to resolve the directory the push ACTUALLY runs in
135
+ // BEFORE resolving the repo — a release is routinely pushed from a SEPARATE
136
+ // worktree, and gating the SESSION-ROOT checkout instead false-blocks it (bug
137
+ // deff9ab9).
138
+ const payloadRaw = opts.stdin ? await readStdin(1500) : null;
139
+ const trimmed = (payloadRaw ?? '').trim();
140
+ const isHookPayload = trimmed.startsWith('{');
141
+ // Effective push directory: the PreToolUse `cwd` (reflects a persisted `cd`)
142
+ // overridden by a leading in-command `cd`; process.cwd() otherwise — which is
143
+ // also correct for the real git pre-push hook, where cwd already IS the pushed
144
+ // worktree.
145
+ const pushCwd = isHookPayload
146
+ ? (0, guard_1.resolvePushCwd)(payloadRaw, (0, guard_1.commandFromPayload)(payloadRaw))
147
+ : { dir: process.cwd(), indeterminate: false };
148
+ const top = (0, git_1.gitToplevel)(pushCwd.dir);
133
149
  if (!top)
134
150
  return 0; // not a git repo → no-op
135
151
  const cwd = top;
@@ -143,15 +159,14 @@ async function run(opts) {
143
159
  // Determine the ref(s) being pushed.
144
160
  let refs;
145
161
  if (opts.stdin) {
146
- const stdin = await readStdin(1500);
147
- const trimmed = (stdin ?? '').trim();
148
- if (trimmed.startsWith('{')) {
162
+ if (isHookPayload) {
149
163
  // Claude Code PreToolUse/PostToolUse payload (JSON). Gate ONLY a real
150
164
  // `git push` — classify the command with the SAME anchored classifier as
151
165
  // `guard`, so an ordinary Bash command (even one whose ARGS contain
152
166
  // "deploy"/"release"/a ref name) is a zero-network no-op and NEVER claims a
153
- // lane. A bare `git push` (no refspec) falls back to the current branch.
154
- const cls = (0, guard_1.classifyCommand)((0, guard_1.commandFromPayload)(stdin));
167
+ // lane. A bare `git push` (no refspec) falls back to the current branch (of
168
+ // the RESOLVED push worktree).
169
+ const cls = (0, guard_1.classifyCommand)((0, guard_1.commandFromPayload)(payloadRaw));
155
170
  if (cls.kind === 'push') {
156
171
  const cb = (0, git_1.currentBranch)(cwd);
157
172
  refs = cls.refs.length ? cls.refs : cb ? [`refs/heads/${cb}`] : [];
@@ -162,7 +177,7 @@ async function run(opts) {
162
177
  }
163
178
  else {
164
179
  // git pre-push hook context: real refspecs arrive on stdin.
165
- refs = stdin ? parseRefsFromStdin(stdin) : [];
180
+ refs = payloadRaw ? parseRefsFromStdin(payloadRaw) : [];
166
181
  if (!refs.length) {
167
182
  const b = (0, git_1.currentBranch)(cwd);
168
183
  refs = b ? [`refs/heads/${b}`] : [];
@@ -280,13 +295,26 @@ async function run(opts) {
280
295
  }
281
296
  // 200 → we hold the lane (claimed fresh or self-reclaimed). Now the COMPAT gate.
282
297
  const compat = await compatP;
283
- if (compat.behind) {
284
- // Behind HEAD after a fresh fetch CONFIRMED positive release + hard block.
298
+ if (compat.behind && !pushCwd.indeterminate) {
299
+ // Behind HEAD after a fresh fetch, against the RESOLVED push worktree
300
+ // CONFIRMED positive → release + hard block.
285
301
  await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
286
302
  blockReason(`convene: BLOCKED — HEAD is behind origin/${ref.replace(/^refs\/heads\//, '')} after fetch. ` +
287
303
  `Run \`git pull --rebase\` then push again.`);
288
304
  return 2;
289
305
  }
306
+ if (compat.behind && pushCwd.indeterminate) {
307
+ // A dynamic in-command `cd` meant we could NOT resolve the pushed worktree, so
308
+ // this "behind" verdict may have come from an unrelated checkout. The lane IS
309
+ // held (the claim succeeded); keep it and ALLOW — degrade only the LOCAL
310
+ // freshness check to fail-open-loud rather than risk a false block (bug
311
+ // deff9ab9). NOTE: the committed git pre-push hook only RE-gates freshness when
312
+ // `convene.blockingPush` is enabled (it otherwise just posts a status), so this
313
+ // is a genuine skip of the freshness check, not a deferral to another gate.
314
+ loudOpen(`convene: deploy lane ${lane} claimed; could not resolve the push worktree from the ` +
315
+ `command, so the behind-HEAD freshness check was SKIPPED — proceeding UNGATED on freshness. ` +
316
+ `Confirm the branch is current before relying on this deploy.`);
317
+ }
290
318
  // Also honor a directed halt even when the lane was free (defense in depth).
291
319
  const halt = await directedHaltFor(api, slug, ref);
292
320
  if (halt) {
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.classifyPushRefs = classifyPushRefs;
4
7
  exports.classifyCommand = classifyCommand;
5
8
  exports.commandFromPayload = commandFromPayload;
9
+ exports.cwdFromPayload = cwdFromPayload;
10
+ exports.resolvePushCwd = resolvePushCwd;
6
11
  exports.guard = guard;
7
12
  /**
8
13
  * `convene guard` (WP9) — the PreToolUse halt+lane gate for Bash commands. Wired
@@ -30,6 +35,7 @@ exports.guard = guard;
30
35
  * (b) a CONFIRMED held lane for a DEPLOY command (different instance).
31
36
  * A SOFT held-lane conflict (foreign live lane, no directed halt) → 'ask'.
32
37
  */
38
+ const node_path_1 = __importDefault(require("node:path"));
33
39
  const git_1 = require("../git");
34
40
  const config_1 = require("../config");
35
41
  const cache_1 = require("../cache");
@@ -167,6 +173,123 @@ function commandFromPayload(raw) {
167
173
  return '';
168
174
  }
169
175
  }
176
+ /**
177
+ * Pull the top-level `cwd` (the directory the tool will run in) out of a Claude
178
+ * Code PreToolUse payload, or null if absent/unparseable. Claude Code stamps this
179
+ * with the session's CURRENT working directory, which already reflects any `cd`
180
+ * that PERSISTED from a prior Bash call (within the project / additional dirs).
181
+ */
182
+ function cwdFromPayload(raw) {
183
+ if (!raw)
184
+ return null;
185
+ try {
186
+ const j = JSON.parse(raw);
187
+ const c = j?.cwd;
188
+ return typeof c === 'string' && c.trim() ? c.trim() : null;
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ function isPush(w) {
195
+ return w[0] === 'git' && w[1] === 'push';
196
+ }
197
+ /**
198
+ * Resolve a `cd`/`pushd` target to an absolute dir against `from`, or report it
199
+ * unresolvable. Strips cd option flags (`--`, `-L`, `-P`, …); rejects `-` (OLDPWD)
200
+ * and any dynamic/quoted/glob/`~`/escaped path — only a single literal path is safe.
201
+ */
202
+ function resolveCdTarget(args, from) {
203
+ let rest = args;
204
+ while (rest.length && (rest[0] === '--' || /^-[A-Za-z]+$/.test(rest[0])))
205
+ rest = rest.slice(1);
206
+ const target = rest[0];
207
+ if (!target || target === '-' || rest.length > 1 || /[$*?`"'~\\]/.test(target))
208
+ return null;
209
+ return node_path_1.default.isAbsolute(target) ? target : node_path_1.default.resolve(from, target);
210
+ }
211
+ /**
212
+ * Resolve the directory a gated `git push` will run in, for the PreToolUse gate.
213
+ *
214
+ * The base is the PreToolUse payload's `cwd` (the session's current dir, already
215
+ * reflecting a PERSISTED `cd`), falling back to `process.cwd()` — which is also
216
+ * the right answer for the real git pre-push hook path, where the process cwd IS
217
+ * the pushed worktree. But the hook fires BEFORE the command runs, so a `cd`
218
+ * performed INSIDE the gated command — `(cd ../repo-release; git push origin main)`
219
+ * or `cd ../wt && git push` — is NOT yet reflected in `cwd`. We therefore also walk
220
+ * the command and apply a leading `cd`/`pushd` that precedes the push.
221
+ *
222
+ * Shell-faithful enough for the real cases: a `(` opens a SUBSHELL scope (a `cd`
223
+ * inside it that `)` closes BEFORE the push does not persist — `(cd X); git push`
224
+ * runs in the base, `(cd X; git push)` runs in X); a `cd` whose `||`/pipe RHS IS
225
+ * the push does not apply (the push then runs in the base); anything we cannot
226
+ * resolve statically (a `$var`/glob/quoted target, a bare `popd`, an over-long
227
+ * command) flags `indeterminate` so the caller degrades to fail-open-loud rather
228
+ * than trusting a guessed dir.
229
+ *
230
+ * Why this matters (bug deff9ab9): a release is routinely pushed from a SEPARATE
231
+ * git worktree — the one-worktree-per-session pattern Convene itself recommends.
232
+ * Resolving the repo from the SESSION-ROOT checkout instead of the pushed worktree
233
+ * made the behind-HEAD gate compare against a stale, unrelated HEAD and false-block
234
+ * the push.
235
+ */
236
+ function resolvePushCwd(raw, command, fallback = process.cwd()) {
237
+ const base = cwdFromPayload(raw) ?? fallback;
238
+ const cmd = (command ?? '').trim();
239
+ if (!cmd)
240
+ return { dir: base, indeterminate: false };
241
+ // An absurdly long command is pathological (and could waste work pre-watchdog) —
242
+ // do not try to parse it; fail-open on the local freshness check.
243
+ if (cmd.length > 8192)
244
+ return { dir: base, indeterminate: true };
245
+ // Split on shell connectors, KEEPING them. `||` must precede `|` in the
246
+ // alternation so a single pipe never shadows it.
247
+ const parts = cmd.split(/(\s*&&\s*|\s*\|\|\s*|\s*;\s*|\s*\|\s*|\n)/);
248
+ const segs = [];
249
+ for (let i = 0; i < parts.length; i += 2) {
250
+ const rawSeg = parts[i] ?? '';
251
+ const open = (rawSeg.match(/^\s*\(+/)?.[0].match(/\(/g) || []).length;
252
+ const close = (rawSeg.match(/\)+\s*$/)?.[0].match(/\)/g) || []).length;
253
+ const seg = rawSeg.replace(/^[\s(]+/, '').replace(/[\s)]+$/, '');
254
+ const words = seg.split(/\s+/).filter(Boolean);
255
+ let k = 0;
256
+ while (k < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k]) || /^(sudo|command|exec|time|env)$/.test(words[k])))
257
+ k++;
258
+ segs.push({ open, close, words: words.slice(k), conn: (parts[i + 1] ?? '').trim() });
259
+ }
260
+ let dir = base;
261
+ let indeterminate = false;
262
+ const scope = []; // saved dirs at each OPEN `(` — restored when it `)` closes
263
+ for (let idx = 0; idx < segs.length; idx++) {
264
+ const s = segs[idx];
265
+ for (let o = 0; o < s.open; o++)
266
+ scope.push(dir); // enter subshell(s)
267
+ const w = s.words;
268
+ if (isPush(w))
269
+ break; // reached the push — `dir` is its cwd (still inside any open subshell)
270
+ if (w[0] === 'cd' || w[0] === 'pushd') {
271
+ // A pipe RHS, or a `||` RHS that IS the push, means the push does NOT run in
272
+ // this cd's dir (cd in a pipe is a subshell; `cd X || git push` only pushes
273
+ // when the cd FAILED) — leave the dir untouched. A `cd X || handler; … push`
274
+ // still applies (its success persists to the later push).
275
+ const pushIsRhs = (s.conn === '||' || s.conn === '|') && isPush(segs[idx + 1]?.words ?? []);
276
+ if (s.conn !== '|' && !pushIsRhs) {
277
+ const resolved = resolveCdTarget(w.slice(1), dir);
278
+ if (resolved === null)
279
+ indeterminate = true;
280
+ else
281
+ dir = resolved;
282
+ }
283
+ }
284
+ else if (w[0] === 'popd') {
285
+ indeterminate = true; // we do not model the pushd/popd stack
286
+ }
287
+ for (let c = 0; c < s.close; c++)
288
+ if (scope.length)
289
+ dir = scope.pop(); // exit subshell(s)
290
+ }
291
+ return { dir, indeterminate };
292
+ }
170
293
  function emitJson(obj) {
171
294
  process.stdout.write(JSON.stringify(obj) + '\n');
172
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "convene-cli",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://convene.live",