convene-cli 1.0.4 → 1.1.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.
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deploy = deploy;
4
+ /**
5
+ * `convene deploy` (WP6) — the ONE verb a human learns. It claims the deploy lane
6
+ * and runs the compatibility verdict in one shot, then prints what to do.
7
+ *
8
+ * DIE-LOUD (exit 1) on a confirmed conflict — this is the explicit, human-run
9
+ * counterpart to the fail-open `gate-push`/`guard` HOOKS (those are Wave 4 and
10
+ * NOT implemented here). One verb cannot be both postures, so they are split.
11
+ *
12
+ * convene deploy [--lane <name>] [--eta <min>] [--break-glass [--reason <s>]]
13
+ *
14
+ * Lane is LANE-AUTHORITATIVE (server-enforced); the compat (behind-HEAD) half is
15
+ * advisory and client-attested. holder_instance is server-stamped from the
16
+ * X-Convene-Session-Instance header this client attaches.
17
+ *
18
+ * --break-glass / CONVENE_DEPLOY_BREAKGLASS=1: self-authorized escape hatch.
19
+ * It force-takes the lane and posts a loud audited status naming who/why — the
20
+ * observable alternative to silently disabling a hook.
21
+ */
22
+ const ctx_1 = require("../ctx");
23
+ const cache_1 = require("../cache");
24
+ const git_1 = require("../git");
25
+ const LANE_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the 10s default
26
+ /** Canonical lane name for the current branch (deploy:<ref>), or the override. */
27
+ function resolveLaneName(opts, cwd) {
28
+ if (opts.lane)
29
+ return opts.lane.includes(':') ? opts.lane : `deploy:refs/heads/${opts.lane}`;
30
+ const branch = (0, git_1.currentBranch)(cwd) || 'main';
31
+ return `deploy:refs/heads/${branch}`;
32
+ }
33
+ async function deploy(opts = {}) {
34
+ const ctx = (0, ctx_1.getContext)({ project: opts.project });
35
+ const slug = (0, ctx_1.requireSlug)(ctx);
36
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
37
+ ctx.api.withInstance(instance);
38
+ const top = (0, git_1.gitToplevel)();
39
+ const cwd = top || process.cwd();
40
+ const lane = resolveLaneName(opts, cwd);
41
+ const breakGlass = opts.breakGlass || process.env.CONVENE_DEPLOY_BREAKGLASS === '1';
42
+ if (breakGlass) {
43
+ // Force-take the lane (owner-gated server-side) + post a loud audited status.
44
+ const fr = await ctx.api.laneForceRelease(slug, lane, LANE_TIMEOUT_MS);
45
+ if (fr.status === 403)
46
+ (0, ctx_1.die)('break-glass force-release is owner-only — you are not a project owner.');
47
+ const who = ctx.member;
48
+ const reason = opts.reason ? ` — ${opts.reason}` : '';
49
+ await ctx.api.post(slug, { type: 'status', body: `BREAK-GLASS deploy on ${lane} by @${who}${reason}` }, (0, ctx_1.uuid)(), LANE_TIMEOUT_MS);
50
+ }
51
+ // Claim the lane (LANE-AUTHORITATIVE). A foreign hold → die-loud 409.
52
+ const claim = await ctx.api.laneClaim(slug, lane, { eta_minutes: opts.eta ?? null, intent: 'deploy' }, LANE_TIMEOUT_MS);
53
+ if (claim.status === 409) {
54
+ const h = claim.json?.details ?? claim.json ?? {};
55
+ const holder = h.holder_handle ? `@${h.holder_handle}` : 'another session';
56
+ (0, ctx_1.die)(`deploy lane ${lane} is HELD by ${holder}. ` +
57
+ 'Wait for release, retry with --break-glass (audited), or (owners) `convene lane release --force`.');
58
+ }
59
+ if (!claim.ok || !claim.json?.granted) {
60
+ (0, ctx_1.die)(`could not claim deploy lane (${claim.status}): ${claim.error ?? 'unknown error'}`);
61
+ }
62
+ // Compat verdict (advisory): attest HEAD; the server returns allow/rebase/wait.
63
+ const headSha = (0, git_1.revParse)('HEAD', cwd) ?? '';
64
+ const verdict = await ctx.api.gatePush(slug, { head_sha: headSha, refs: [lane.replace(/^deploy:/, '')] }, LANE_TIMEOUT_MS);
65
+ const v = verdict.ok ? verdict.json?.verdict : 'allow';
66
+ if (v === 'rebase') {
67
+ process.stdout.write(`convene: lane ${lane} claimed — but HEAD is behind origin. Run \`git pull --rebase\` then push.\n`);
68
+ return;
69
+ }
70
+ process.stdout.write(`convene: lane ${lane} claimed — clear to deploy. Release with \`convene lane release ${lane}\` when done.\n`);
71
+ }
@@ -1,4 +1,7 @@
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.runFetch = runFetch;
4
7
  /**
@@ -14,23 +17,69 @@ exports.runFetch = runFetch;
14
17
  * a 4s fetch timeout bounds the slow path; a failed fetch falls back to the
15
18
  * stale cache and renders DEGRADED (loud-but-non-blocking).
16
19
  */
20
+ const node_fs_1 = __importDefault(require("node:fs"));
17
21
  const git_1 = require("../git");
18
22
  const config_1 = require("../config");
19
23
  const cache_1 = require("../cache");
20
24
  const api_1 = require("../api");
21
25
  const render_1 = require("../render");
26
+ const catchup_1 = require("./catchup");
22
27
  const CACHE_TTL_SEC = 3;
23
28
  const FETCH_TIMEOUT_MS = 4000;
24
29
  const WATCHDOG_MS = 6000;
25
- function emit(block) {
30
+ /**
31
+ * Write a rendered block. In `codexHook` mode, wrap it in Codex's UserPromptSubmit
32
+ * hook envelope so its `additionalContext` lands in the model's per-turn context;
33
+ * otherwise write the raw block (the Claude Code hook reads stdout directly).
34
+ */
35
+ function emit(block, codexHook) {
36
+ if (codexHook) {
37
+ process.stdout.write(JSON.stringify({
38
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block },
39
+ }) + '\n');
40
+ return;
41
+ }
26
42
  process.stdout.write(block + '\n');
27
43
  }
44
+ /**
45
+ * Codex passes the hook a JSON object on stdin including `cwd`. Read it best-effort
46
+ * to resolve the repo when the hook runs from elsewhere. Never blocks on a TTY and
47
+ * never throws — a missing/garbled payload just means "use the current cwd".
48
+ */
49
+ function codexCwdFromStdin() {
50
+ try {
51
+ if (process.stdin.isTTY)
52
+ return null;
53
+ const raw = node_fs_1.default.readFileSync(0, 'utf8');
54
+ if (!raw)
55
+ return null;
56
+ const j = JSON.parse(raw);
57
+ return typeof j?.cwd === 'string' && j.cwd ? j.cwd : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
28
63
  function toRenderMessages(arr) {
29
64
  return Array.isArray(arr) ? arr : [];
30
65
  }
31
66
  async function runFetch(opts = {}) {
32
67
  // Absolute watchdog: under any circumstance, do not hold the prompt past 6s.
33
68
  const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
69
+ // Codex hook: honor the stdin `cwd` so the repo resolves correctly, and force
70
+ // `--json` off (codex-hook is its own output envelope).
71
+ if (opts.codexHook) {
72
+ opts = { ...opts, json: false };
73
+ const cwd = codexCwdFromStdin();
74
+ if (cwd) {
75
+ try {
76
+ process.chdir(cwd);
77
+ }
78
+ catch {
79
+ /* ignore — fall back to the current cwd */
80
+ }
81
+ }
82
+ }
34
83
  try {
35
84
  const top = (0, git_1.gitToplevel)();
36
85
  if (!top)
@@ -54,7 +103,25 @@ async function runFetch(opts = {}) {
54
103
  openItems: [],
55
104
  recent: [],
56
105
  health: { state: 'note', line: 'convene: not configured — run `convene login` (coordination context unavailable)' },
57
- }));
106
+ }), opts.codexHook);
107
+ return done(0);
108
+ }
109
+ // `--since-last`: render the catch-up digest since the read cursor instead
110
+ // of the time-windowed feed. Read-only (no advance), fail-open. Suppressed if
111
+ // SessionStart already surfaced a catch-up this boot (per-instance sentinel).
112
+ if (opts.sinceLast) {
113
+ const instance = (0, cache_1.readSessionInstance)(slug);
114
+ if (instance && (0, cache_1.catchupAlreadySurfaced)(slug, instance))
115
+ return done(0); // already shown this boot
116
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
117
+ const res = await api.sessionOpen(slug, { advance: false }, FETCH_TIMEOUT_MS);
118
+ if (res.ok && res.json && !res.json.degraded) {
119
+ if (opts.json)
120
+ emit(JSON.stringify(res.json));
121
+ else
122
+ emit((0, render_1.renderSessionOpenBlock)({ slug, member, session, digest: (0, catchup_1.toDigest)(res.json) }), opts.codexHook);
123
+ }
124
+ // DEGRADED/failure under --since-last emits nothing (structural suppression).
58
125
  return done(0);
59
126
  }
60
127
  // Cache short-circuit for rapid successive prompts.
@@ -86,7 +153,7 @@ async function runFetch(opts = {}) {
86
153
  openItems: toRenderMessages(cache?.data?.inbox ?? []),
87
154
  recent: toRenderMessages(cache?.data?.messages ?? []),
88
155
  health,
89
- }));
156
+ }), opts.codexHook);
90
157
  return done(0);
91
158
  }
92
159
  catch {
@@ -110,6 +177,6 @@ async function runFetch(opts = {}) {
110
177
  openItems: toRenderMessages(data?.inbox ?? []),
111
178
  recent: toRenderMessages(data?.messages ?? []),
112
179
  health: { state: 'ok', syncedAgoSec: ctx.syncedAgoSec, openCount: Number(data?.open_for_you ?? (data?.inbox?.length ?? 0)) },
113
- }));
180
+ }), opts.codexHook);
114
181
  }
115
182
  }
@@ -0,0 +1,331 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.gatePush = gatePush;
4
+ /**
5
+ * `convene gate-push` (WP9) — the PUSH GATE. Wired as a PreToolUse hook on
6
+ * `Bash(git push …)` (PreToolUse mode) and a PostToolUse hook (`--post`) that
7
+ * releases the lane. The hook WIRING itself is the WP13 capstone — this file is
8
+ * only the verb.
9
+ *
10
+ * POSTURE = FAIL-OPEN-LOUD (P0-FAILSAFE):
11
+ * - A top-level watchdog force-exits 0 at WATCHDOG_MS=4000 no matter what, so a
12
+ * hung network/git can NEVER wedge a push.
13
+ * - Network calls (claim / lane-state) use an EXPLICIT short timeout (2500ms),
14
+ * never the api.ts 10s default; local git (isAncestor/revParse) runs in
15
+ * PARALLEL with the network call.
16
+ * - TRANSPORT failure (ApiResult.status===0 = connection-refused/DNS) →
17
+ * fail-OPEN-LOUD immediately (exit 0 + a `systemMessage` "deploy lane
18
+ * UNVERIFIED — proceeding UNGATED").
19
+ * - TIMEOUT / 5xx (bus up but contended) → retry 2× with backoff INSIDE the
20
+ * watchdog, then still fail-open-loud + best-effort post a [STATUS]
21
+ * "gate skipped" so the skip is auditable in real time.
22
+ *
23
+ * exit 2 (BLOCK) ONLY on a CONFIRMED positive:
24
+ * (a) LANE_HELD by a DIFFERENT instance (409 from the claim CAS), OR
25
+ * (b) an open DIRECTED HALT for this session (from lane-state.halts), OR
26
+ * (c) HEAD CONFIRMED-BEHIND after `git fetch origin <ref>` then
27
+ * isAncestor(remote, HEAD) === false.
28
+ * A SOFT foreign-lane conflict (a foreign live lane but no directed halt) is NOT
29
+ * a hard deny — it emits permissionDecision:'ask'.
30
+ *
31
+ * Auto-CLAIM on the way through: when the lane is free we CLAIM it (serialize via
32
+ * the server CAS, not a read-then-decide race). --post releases it (on success
33
+ * AND failure), an idempotent no-op if this instance no longer holds.
34
+ *
35
+ * --break-glass / CONVENE_DEPLOY_BREAKGLASS=1 → exit 0 + a loud audited takeover.
36
+ *
37
+ * NEVER calls die() and NEVER routes through ctx.getContext() (which die()s on a
38
+ * missing config) — it owns its own watchdog exactly like fetch.ts.
39
+ *
40
+ * The exit-2 stderr reason is a FIXED TEMPLATE with NO free-text interpolation of
41
+ * intent / holder_session / body — only a validated holder_handle, a numeric
42
+ * heartbeat age, and the one-step recovery hint.
43
+ */
44
+ const git_1 = require("../git");
45
+ const config_1 = require("../config");
46
+ const cache_1 = require("../cache");
47
+ const api_1 = require("../api");
48
+ const guard_1 = require("./guard");
49
+ const WATCHDOG_MS = 4000;
50
+ const NET_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the api.ts 10s default
51
+ const RETRIES = 2;
52
+ /** Only `holder_handle` (validated) reaches the model-facing reason. */
53
+ function safeHandle(s) {
54
+ if (typeof s !== 'string')
55
+ return 'another session';
56
+ const cleaned = s.replace(/[^a-zA-Z0-9_.\-/]+/g, '').slice(0, 64);
57
+ return cleaned ? `@${cleaned}` : 'another session';
58
+ }
59
+ function fmtAge(sec) {
60
+ const n = typeof sec === 'number' && Number.isFinite(sec) ? Math.max(0, Math.round(sec)) : null;
61
+ if (n == null)
62
+ return 'unknown';
63
+ if (n < 60)
64
+ return `${n}s`;
65
+ const m = Math.round(n / 60);
66
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`;
67
+ }
68
+ /** A transport failure (connection-refused/DNS) is status 0; a timeout/5xx is "up but contended". */
69
+ function isTransportFailure(status) {
70
+ return status === 0;
71
+ }
72
+ function isRetryable(status) {
73
+ // 408 request-timeout, 429 too-many, 5xx — bus up but contended → retry inside the watchdog.
74
+ return status === 408 || status === 429 || status >= 500;
75
+ }
76
+ /** Parse git's pre-push stdin into the deploy-ish ref(s) being pushed. */
77
+ function parseRefsFromStdin(stdin) {
78
+ const refs = [];
79
+ for (const line of stdin.split('\n')) {
80
+ const parts = line.trim().split(/\s+/);
81
+ if (parts.length < 4)
82
+ continue;
83
+ const [localRef, localSha] = parts;
84
+ if (!localSha || /^0+$/.test(localSha))
85
+ continue; // deletion — nothing landed
86
+ if (localRef.startsWith('refs/heads/') || localRef.startsWith('refs/tags/'))
87
+ refs.push(localRef);
88
+ }
89
+ return refs;
90
+ }
91
+ /** Async, timeout-bounded stdin read (git closes the pipe immediately). */
92
+ function readStdin(timeoutMs) {
93
+ if (process.stdin.isTTY)
94
+ return Promise.resolve(null);
95
+ return new Promise((resolve) => {
96
+ let data = '';
97
+ let settled = false;
98
+ const finish = (v) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ process.stdin.removeAllListeners();
104
+ resolve(v);
105
+ };
106
+ const timer = setTimeout(() => finish(null), timeoutMs);
107
+ process.stdin.setEncoding('utf8');
108
+ process.stdin.on('data', (c) => {
109
+ data += c;
110
+ });
111
+ process.stdin.on('end', () => finish(data));
112
+ process.stdin.on('error', () => finish(null));
113
+ process.stdin.resume();
114
+ });
115
+ }
116
+ function emitJson(obj) {
117
+ process.stdout.write(JSON.stringify(obj) + '\n');
118
+ }
119
+ /** PreToolUse "allow but tell the human" verdict — NOT a hard deny. */
120
+ function ask(reason) {
121
+ emitJson({
122
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason },
123
+ });
124
+ }
125
+ /** A loud, non-blocking advisory (fail-open) surfaced to the agent. */
126
+ function loudOpen(systemMessage) {
127
+ emitJson({ systemMessage });
128
+ }
129
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
130
+ async function run(opts) {
131
+ const top = (0, git_1.gitToplevel)();
132
+ if (!top)
133
+ return 0; // not a git repo → no-op
134
+ const cwd = top;
135
+ const proj = (0, config_1.loadProjectConfig)(top);
136
+ const slug = opts.project || proj?.slug || null;
137
+ if (!slug)
138
+ return 0; // repo not on the bus → no-op (zero network)
139
+ const cfg = (0, config_1.resolveConfig)();
140
+ const member = cfg.member;
141
+ const session = member ? (0, git_1.sessionId)(member, top) : null;
142
+ // Determine the ref(s) being pushed.
143
+ let refs;
144
+ if (opts.stdin) {
145
+ const stdin = await readStdin(1500);
146
+ const trimmed = (stdin ?? '').trim();
147
+ if (trimmed.startsWith('{')) {
148
+ // Claude Code PreToolUse/PostToolUse payload (JSON). Gate ONLY a real
149
+ // `git push` — classify the command with the SAME anchored classifier as
150
+ // `guard`, so an ordinary Bash command (even one whose ARGS contain
151
+ // "deploy"/"release"/a ref name) is a zero-network no-op and NEVER claims a
152
+ // lane. A bare `git push` (no refspec) falls back to the current branch.
153
+ const cls = (0, guard_1.classifyCommand)((0, guard_1.commandFromPayload)(stdin));
154
+ if (cls.kind === 'push') {
155
+ const cb = (0, git_1.currentBranch)(cwd);
156
+ refs = cls.refs.length ? cls.refs : cb ? [`refs/heads/${cb}`] : [];
157
+ }
158
+ else {
159
+ refs = [];
160
+ }
161
+ }
162
+ else {
163
+ // git pre-push hook context: real refspecs arrive on stdin.
164
+ refs = stdin ? parseRefsFromStdin(stdin) : [];
165
+ if (!refs.length) {
166
+ const b = (0, git_1.currentBranch)(cwd);
167
+ refs = b ? [`refs/heads/${b}`] : [];
168
+ }
169
+ }
170
+ }
171
+ else {
172
+ // Explicit invocation (e.g. `convene deploy`): gate the current branch.
173
+ const b = (0, git_1.currentBranch)(cwd);
174
+ refs = b ? [`refs/heads/${b}`] : [];
175
+ }
176
+ // A deploy ref is one the anchored classifier recognizes (heads/main, tags, …).
177
+ const deployRefs = refs.filter((r) => (0, guard_1.classifyPushRefs)(r));
178
+ const ref = deployRefs[0] ?? refs[0] ?? 'refs/heads/main';
179
+ const lane = `deploy:${ref}`;
180
+ // PostToolUse: release the lane (idempotent no-op if we no longer hold it).
181
+ if (opts.post) {
182
+ if (!cfg.apiKey || !session)
183
+ return 0;
184
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
185
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
186
+ await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
187
+ return 0;
188
+ }
189
+ // Non-deploy ref → allow instantly with ZERO network.
190
+ if (!deployRefs.length)
191
+ return 0;
192
+ // Break-glass: self-authorized takeover. Exit 0 + a loud audited status.
193
+ const breakGlass = opts.breakGlass || process.env.CONVENE_DEPLOY_BREAKGLASS === '1';
194
+ // Not authenticated → fail-OPEN-loud (we cannot verify the lane).
195
+ if (!cfg.apiKey || !session) {
196
+ loudOpen('convene: deploy lane UNVERIFIED — not logged in, proceeding UNGATED.');
197
+ return 0;
198
+ }
199
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
200
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
201
+ if (breakGlass) {
202
+ // Self-authorized: the verdict is ALLOW (exit 0) regardless of the bus. Emit
203
+ // the loud audited advisory FIRST, then do the force-take + status + claim as
204
+ // BEST-EFFORT in parallel under a single short cap — so a slow/unreachable bus
205
+ // can never push break-glass past the watchdog (it must still exit 0 promptly).
206
+ loudOpen(`convene: BREAK-GLASS — forced deploy lane ${lane}. This takeover is audited.`);
207
+ await Promise.race([
208
+ Promise.allSettled([
209
+ api.laneForceRelease(slug, lane, NET_TIMEOUT_MS),
210
+ api.post(slug, { type: 'status', body: `BREAK-GLASS deploy on ${lane} by @${member}` }, `bg:${lane}:${Date.now()}`, NET_TIMEOUT_MS),
211
+ api.laneClaim(slug, lane, { intent: 'deploy' }, NET_TIMEOUT_MS),
212
+ ]),
213
+ sleep(NET_TIMEOUT_MS), // never block the exit-0 verdict longer than one timeout
214
+ ]).catch(() => undefined);
215
+ return 0;
216
+ }
217
+ if (opts.dryRun) {
218
+ loudOpen(`convene: gate-push would gate lane ${lane} (dry-run, no network).`);
219
+ return 0;
220
+ }
221
+ // ── Local git (compat) runs in PARALLEL with the network claim ──────────────
222
+ const headSha = (0, git_1.revParse)('HEAD', cwd) ?? '';
223
+ // The compat check fetches the real remote tip, then tests fast-forward.
224
+ const compatP = (async () => {
225
+ try {
226
+ const fetched = (0, git_1.gitFetch)(ref, 'origin', cwd);
227
+ if (!fetched)
228
+ return { behind: false }; // can't verify → don't block on compat
229
+ const remote = (0, git_1.revParse)(`origin/${ref.replace(/^refs\/heads\//, '')}`, cwd) ?? (0, git_1.revParse)(`origin/${ref}`, cwd);
230
+ if (!remote || !headSha)
231
+ return { behind: false };
232
+ // behind ⇔ remote is NOT an ancestor of HEAD (a non-fast-forward push).
233
+ return { behind: !(0, git_1.isAncestor)(remote, headSha, cwd) };
234
+ }
235
+ catch {
236
+ return { behind: false };
237
+ }
238
+ })();
239
+ // ── Network claim with transport/timeout discrimination + retry ─────────────
240
+ let claimRes = null;
241
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
242
+ claimRes = await api.laneClaim(slug, lane, { intent: 'deploy' }, NET_TIMEOUT_MS);
243
+ if (claimRes.status === 200 || claimRes.status === 409)
244
+ break; // resolved
245
+ if (isTransportFailure(claimRes.status)) {
246
+ // Genuinely unreachable → fail-OPEN-loud immediately, no retry.
247
+ loudOpen(`convene: deploy lane UNVERIFIED — bus unreachable, proceeding UNGATED on ${lane}.`);
248
+ return 0;
249
+ }
250
+ if (!isRetryable(claimRes.status))
251
+ break;
252
+ if (attempt < RETRIES)
253
+ await sleep(150 * (attempt + 1)); // backoff inside the watchdog
254
+ }
255
+ // 409 LANE_HELD by a different instance → check for a directed halt (hard) vs
256
+ // soft foreign lane (ask). exit 2 only on a CONFIRMED positive.
257
+ if (claimRes && claimRes.status === 409) {
258
+ const d = claimRes.json?.details ?? claimRes.json ?? {};
259
+ const holder = safeHandle(d.holder_handle);
260
+ // Is there an open DIRECTED HALT for this session? That is a HARD block.
261
+ const halt = await directedHaltFor(api, slug, ref);
262
+ if (halt) {
263
+ blockReason(`convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not push.`);
264
+ return 2;
265
+ }
266
+ // Soft foreign-lane conflict: ask, don't hard-deny.
267
+ const age = fmtAge(typeof d.heartbeat_age_sec === 'number' ? d.heartbeat_age_sec : undefined);
268
+ ask(`Deploy lane ${lane} is held by ${holder} (heartbeat ${age} ago). ` +
269
+ `To proceed anyway: \`convene deploy --break-glass\` (audited), or wait for release. Allow this push?`);
270
+ return 0;
271
+ }
272
+ // Timeout/5xx after retries (bus up but contended) → fail-OPEN-LOUD + audit post.
273
+ if (!claimRes || (claimRes.status !== 200 && claimRes.status !== 409)) {
274
+ await api
275
+ .post(slug, { type: 'status', body: `gate skipped on ${lane} — bus contended/unverified at push time` }, `gateskip:${lane}:${Date.now()}`, NET_TIMEOUT_MS)
276
+ .catch(() => undefined);
277
+ loudOpen(`convene: deploy lane UNVERIFIED — bus contended, proceeding UNGATED on ${lane} (skip posted).`);
278
+ return 0;
279
+ }
280
+ // 200 → we hold the lane (claimed fresh or self-reclaimed). Now the COMPAT gate.
281
+ const compat = await compatP;
282
+ if (compat.behind) {
283
+ // Behind HEAD after a fresh fetch → CONFIRMED positive → release + hard block.
284
+ await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
285
+ blockReason(`convene: BLOCKED — HEAD is behind origin/${ref.replace(/^refs\/heads\//, '')} after fetch. ` +
286
+ `Run \`git pull --rebase\` then push again.`);
287
+ return 2;
288
+ }
289
+ // Also honor a directed halt even when the lane was free (defense in depth).
290
+ const halt = await directedHaltFor(api, slug, ref);
291
+ if (halt) {
292
+ await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
293
+ blockReason(`convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not push.`);
294
+ return 2;
295
+ }
296
+ // Clear: lane claimed, fast-forward. Allow (PostToolUse releases later).
297
+ return 0;
298
+ }
299
+ /**
300
+ * Fixed-template block reason on stderr (PreToolUse exit 2 surfaces stderr to the
301
+ * agent). NO free-text interpolation of intent/holder_session/body.
302
+ */
303
+ function blockReason(reason) {
304
+ process.stderr.write(reason + '\n');
305
+ }
306
+ /**
307
+ * Is there an open halt/interrupt directed at THIS session? The server computes
308
+ * relevance from principal + session (the client never passes a session string
309
+ * that authorizes which halts apply). Any uncertainty (transport/parse) → no
310
+ * halt (fail-open). Returns true only on a confirmed, server-relevance-filtered
311
+ * directed halt.
312
+ */
313
+ async function directedHaltFor(api, slug, ref) {
314
+ const res = await api.laneState(slug, `deploy:${ref}`, NET_TIMEOUT_MS).catch(() => null);
315
+ if (!res || !res.ok || !res.json)
316
+ return false; // can't verify → fail-open
317
+ const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
318
+ return halts.length > 0;
319
+ }
320
+ async function gatePush(opts = {}) {
321
+ let code = 0;
322
+ const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
323
+ try {
324
+ code = await run(opts);
325
+ }
326
+ catch {
327
+ code = 0; // fail-open: never wedge a push on our own error
328
+ }
329
+ clearTimeout(watchdog);
330
+ process.exit(code);
331
+ }