convene-cli 1.0.5 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
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 exit_1 = require("../exit");
50
+ const WATCHDOG_MS = 4000;
51
+ const NET_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the api.ts 10s default
52
+ const RETRIES = 2;
53
+ /** Only `holder_handle` (validated) reaches the model-facing reason. */
54
+ function safeHandle(s) {
55
+ if (typeof s !== 'string')
56
+ return 'another session';
57
+ const cleaned = s.replace(/[^a-zA-Z0-9_.\-/]+/g, '').slice(0, 64);
58
+ return cleaned ? `@${cleaned}` : 'another session';
59
+ }
60
+ function fmtAge(sec) {
61
+ const n = typeof sec === 'number' && Number.isFinite(sec) ? Math.max(0, Math.round(sec)) : null;
62
+ if (n == null)
63
+ return 'unknown';
64
+ if (n < 60)
65
+ return `${n}s`;
66
+ const m = Math.round(n / 60);
67
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`;
68
+ }
69
+ /** A transport failure (connection-refused/DNS) is status 0; a timeout/5xx is "up but contended". */
70
+ function isTransportFailure(status) {
71
+ return status === 0;
72
+ }
73
+ function isRetryable(status) {
74
+ // 408 request-timeout, 429 too-many, 5xx — bus up but contended → retry inside the watchdog.
75
+ return status === 408 || status === 429 || status >= 500;
76
+ }
77
+ /** Parse git's pre-push stdin into the deploy-ish ref(s) being pushed. */
78
+ function parseRefsFromStdin(stdin) {
79
+ const refs = [];
80
+ for (const line of stdin.split('\n')) {
81
+ const parts = line.trim().split(/\s+/);
82
+ if (parts.length < 4)
83
+ continue;
84
+ const [localRef, localSha] = parts;
85
+ if (!localSha || /^0+$/.test(localSha))
86
+ continue; // deletion — nothing landed
87
+ if (localRef.startsWith('refs/heads/') || localRef.startsWith('refs/tags/'))
88
+ refs.push(localRef);
89
+ }
90
+ return refs;
91
+ }
92
+ /** Async, timeout-bounded stdin read (git closes the pipe immediately). */
93
+ function readStdin(timeoutMs) {
94
+ if (process.stdin.isTTY)
95
+ return Promise.resolve(null);
96
+ return new Promise((resolve) => {
97
+ let data = '';
98
+ let settled = false;
99
+ const finish = (v) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ process.stdin.removeAllListeners();
105
+ resolve(v);
106
+ };
107
+ const timer = setTimeout(() => finish(null), timeoutMs);
108
+ process.stdin.setEncoding('utf8');
109
+ process.stdin.on('data', (c) => {
110
+ data += c;
111
+ });
112
+ process.stdin.on('end', () => finish(data));
113
+ process.stdin.on('error', () => finish(null));
114
+ process.stdin.resume();
115
+ });
116
+ }
117
+ function emitJson(obj) {
118
+ process.stdout.write(JSON.stringify(obj) + '\n');
119
+ }
120
+ /** PreToolUse "allow but tell the human" verdict — NOT a hard deny. */
121
+ function ask(reason) {
122
+ emitJson({
123
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason },
124
+ });
125
+ }
126
+ /** A loud, non-blocking advisory (fail-open) surfaced to the agent. */
127
+ function loudOpen(systemMessage) {
128
+ emitJson({ systemMessage });
129
+ }
130
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
131
+ async function run(opts) {
132
+ const top = (0, git_1.gitToplevel)();
133
+ if (!top)
134
+ return 0; // not a git repo → no-op
135
+ const cwd = top;
136
+ const proj = (0, config_1.loadProjectConfig)(top);
137
+ const slug = opts.project || proj?.slug || null;
138
+ if (!slug)
139
+ return 0; // repo not on the bus → no-op (zero network)
140
+ const cfg = (0, config_1.resolveConfig)();
141
+ const member = cfg.member;
142
+ const session = member ? (0, git_1.sessionId)(member, top) : null;
143
+ // Determine the ref(s) being pushed.
144
+ let refs;
145
+ if (opts.stdin) {
146
+ const stdin = await readStdin(1500);
147
+ const trimmed = (stdin ?? '').trim();
148
+ if (trimmed.startsWith('{')) {
149
+ // Claude Code PreToolUse/PostToolUse payload (JSON). Gate ONLY a real
150
+ // `git push` — classify the command with the SAME anchored classifier as
151
+ // `guard`, so an ordinary Bash command (even one whose ARGS contain
152
+ // "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));
155
+ if (cls.kind === 'push') {
156
+ const cb = (0, git_1.currentBranch)(cwd);
157
+ refs = cls.refs.length ? cls.refs : cb ? [`refs/heads/${cb}`] : [];
158
+ }
159
+ else {
160
+ refs = [];
161
+ }
162
+ }
163
+ else {
164
+ // git pre-push hook context: real refspecs arrive on stdin.
165
+ refs = stdin ? parseRefsFromStdin(stdin) : [];
166
+ if (!refs.length) {
167
+ const b = (0, git_1.currentBranch)(cwd);
168
+ refs = b ? [`refs/heads/${b}`] : [];
169
+ }
170
+ }
171
+ }
172
+ else {
173
+ // Explicit invocation (e.g. `convene deploy`): gate the current branch.
174
+ const b = (0, git_1.currentBranch)(cwd);
175
+ refs = b ? [`refs/heads/${b}`] : [];
176
+ }
177
+ // A deploy ref is one the anchored classifier recognizes (heads/main, tags, …).
178
+ const deployRefs = refs.filter((r) => (0, guard_1.classifyPushRefs)(r));
179
+ const ref = deployRefs[0] ?? refs[0] ?? 'refs/heads/main';
180
+ const lane = `deploy:${ref}`;
181
+ // PostToolUse: release the lane (idempotent no-op if we no longer hold it).
182
+ if (opts.post) {
183
+ if (!cfg.apiKey || !session)
184
+ return 0;
185
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
186
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
187
+ await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
188
+ return 0;
189
+ }
190
+ // Non-deploy ref → allow instantly with ZERO network.
191
+ if (!deployRefs.length)
192
+ return 0;
193
+ // Break-glass: self-authorized takeover. Exit 0 + a loud audited status.
194
+ const breakGlass = opts.breakGlass || process.env.CONVENE_DEPLOY_BREAKGLASS === '1';
195
+ // Not authenticated → fail-OPEN-loud (we cannot verify the lane).
196
+ if (!cfg.apiKey || !session) {
197
+ loudOpen('convene: deploy lane UNVERIFIED — not logged in, proceeding UNGATED.');
198
+ return 0;
199
+ }
200
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
201
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
202
+ if (breakGlass) {
203
+ // Self-authorized: the verdict is ALLOW (exit 0) regardless of the bus. Emit
204
+ // the loud audited advisory FIRST, then do the force-take + status + claim as
205
+ // BEST-EFFORT in parallel under a single short cap — so a slow/unreachable bus
206
+ // can never push break-glass past the watchdog (it must still exit 0 promptly).
207
+ loudOpen(`convene: BREAK-GLASS — forced deploy lane ${lane}. This takeover is audited.`);
208
+ await Promise.race([
209
+ Promise.allSettled([
210
+ api.laneForceRelease(slug, lane, NET_TIMEOUT_MS),
211
+ api.post(slug, { type: 'status', body: `BREAK-GLASS deploy on ${lane} by @${member}` }, `bg:${lane}:${Date.now()}`, NET_TIMEOUT_MS),
212
+ api.laneClaim(slug, lane, { intent: 'deploy' }, NET_TIMEOUT_MS),
213
+ ]),
214
+ sleep(NET_TIMEOUT_MS), // never block the exit-0 verdict longer than one timeout
215
+ ]).catch(() => undefined);
216
+ return 0;
217
+ }
218
+ if (opts.dryRun) {
219
+ loudOpen(`convene: gate-push would gate lane ${lane} (dry-run, no network).`);
220
+ return 0;
221
+ }
222
+ // ── Local git (compat) runs in PARALLEL with the network claim ──────────────
223
+ const headSha = (0, git_1.revParse)('HEAD', cwd) ?? '';
224
+ // The compat check fetches the real remote tip, then tests fast-forward.
225
+ const compatP = (async () => {
226
+ try {
227
+ const fetched = (0, git_1.gitFetch)(ref, 'origin', cwd);
228
+ if (!fetched)
229
+ return { behind: false }; // can't verify → don't block on compat
230
+ const remote = (0, git_1.revParse)(`origin/${ref.replace(/^refs\/heads\//, '')}`, cwd) ?? (0, git_1.revParse)(`origin/${ref}`, cwd);
231
+ if (!remote || !headSha)
232
+ return { behind: false };
233
+ // behind ⇔ remote is NOT an ancestor of HEAD (a non-fast-forward push).
234
+ return { behind: !(0, git_1.isAncestor)(remote, headSha, cwd) };
235
+ }
236
+ catch {
237
+ return { behind: false };
238
+ }
239
+ })();
240
+ // ── Network claim with transport/timeout discrimination + retry ─────────────
241
+ let claimRes = null;
242
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
243
+ claimRes = await api.laneClaim(slug, lane, { intent: 'deploy' }, NET_TIMEOUT_MS);
244
+ if (claimRes.status === 200 || claimRes.status === 409)
245
+ break; // resolved
246
+ if (isTransportFailure(claimRes.status)) {
247
+ // Genuinely unreachable → fail-OPEN-loud immediately, no retry.
248
+ loudOpen(`convene: deploy lane UNVERIFIED — bus unreachable, proceeding UNGATED on ${lane}.`);
249
+ return 0;
250
+ }
251
+ if (!isRetryable(claimRes.status))
252
+ break;
253
+ if (attempt < RETRIES)
254
+ await sleep(150 * (attempt + 1)); // backoff inside the watchdog
255
+ }
256
+ // 409 LANE_HELD by a different instance → check for a directed halt (hard) vs
257
+ // soft foreign lane (ask). exit 2 only on a CONFIRMED positive.
258
+ if (claimRes && claimRes.status === 409) {
259
+ const d = claimRes.json?.details ?? claimRes.json ?? {};
260
+ const holder = safeHandle(d.holder_handle);
261
+ // Is there an open DIRECTED HALT for this session? That is a HARD block.
262
+ const halt = await directedHaltFor(api, slug, ref);
263
+ if (halt) {
264
+ blockReason(`convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not push.`);
265
+ return 2;
266
+ }
267
+ // Soft foreign-lane conflict: ask, don't hard-deny.
268
+ const age = fmtAge(typeof d.heartbeat_age_sec === 'number' ? d.heartbeat_age_sec : undefined);
269
+ ask(`Deploy lane ${lane} is held by ${holder} (heartbeat ${age} ago). ` +
270
+ `To proceed anyway: \`convene deploy --break-glass\` (audited), or wait for release. Allow this push?`);
271
+ return 0;
272
+ }
273
+ // Timeout/5xx after retries (bus up but contended) → fail-OPEN-LOUD + audit post.
274
+ if (!claimRes || (claimRes.status !== 200 && claimRes.status !== 409)) {
275
+ await api
276
+ .post(slug, { type: 'status', body: `gate skipped on ${lane} — bus contended/unverified at push time` }, `gateskip:${lane}:${Date.now()}`, NET_TIMEOUT_MS)
277
+ .catch(() => undefined);
278
+ loudOpen(`convene: deploy lane UNVERIFIED — bus contended, proceeding UNGATED on ${lane} (skip posted).`);
279
+ return 0;
280
+ }
281
+ // 200 → we hold the lane (claimed fresh or self-reclaimed). Now the COMPAT gate.
282
+ const compat = await compatP;
283
+ if (compat.behind) {
284
+ // Behind HEAD after a fresh fetch → CONFIRMED positive → release + hard block.
285
+ await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
286
+ blockReason(`convene: BLOCKED — HEAD is behind origin/${ref.replace(/^refs\/heads\//, '')} after fetch. ` +
287
+ `Run \`git pull --rebase\` then push again.`);
288
+ return 2;
289
+ }
290
+ // Also honor a directed halt even when the lane was free (defense in depth).
291
+ const halt = await directedHaltFor(api, slug, ref);
292
+ if (halt) {
293
+ await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
294
+ blockReason(`convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not push.`);
295
+ return 2;
296
+ }
297
+ // Clear: lane claimed, fast-forward. Allow (PostToolUse releases later).
298
+ return 0;
299
+ }
300
+ /**
301
+ * Fixed-template block reason on stderr (PreToolUse exit 2 surfaces stderr to the
302
+ * agent). NO free-text interpolation of intent/holder_session/body.
303
+ */
304
+ function blockReason(reason) {
305
+ process.stderr.write(reason + '\n');
306
+ }
307
+ /**
308
+ * Is there an open halt/interrupt directed at THIS session? The server computes
309
+ * relevance from principal + session (the client never passes a session string
310
+ * that authorizes which halts apply). Any uncertainty (transport/parse) → no
311
+ * halt (fail-open). Returns true only on a confirmed, server-relevance-filtered
312
+ * directed halt.
313
+ */
314
+ async function directedHaltFor(api, slug, ref) {
315
+ const res = await api.laneState(slug, `deploy:${ref}`, NET_TIMEOUT_MS).catch(() => null);
316
+ if (!res || !res.ok || !res.json)
317
+ return false; // can't verify → fail-open
318
+ const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
319
+ return halts.length > 0;
320
+ }
321
+ async function gatePush(opts = {}) {
322
+ let code = 0;
323
+ const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), WATCHDOG_MS);
324
+ watchdog.unref();
325
+ try {
326
+ code = await run(opts);
327
+ }
328
+ catch {
329
+ code = 0; // fail-open: never wedge a push on our own error
330
+ }
331
+ clearTimeout(watchdog);
332
+ (0, exit_1.exitClean)(code);
333
+ }
@@ -0,0 +1,315 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyPushRefs = classifyPushRefs;
4
+ exports.classifyCommand = classifyCommand;
5
+ exports.commandFromPayload = commandFromPayload;
6
+ exports.guard = guard;
7
+ /**
8
+ * `convene guard` (WP9) — the PreToolUse halt+lane gate for Bash commands. Wired
9
+ * as a PreToolUse `Bash` hook (the WIRING is the WP13 capstone — this is the verb).
10
+ *
11
+ * POSTURE = FAIL-OPEN-LOUD (P0-FAILSAFE), exactly like fetch.ts/gate-push.ts:
12
+ * - A top-level watchdog force-exits 0 at WATCHDOG_MS=4000.
13
+ * - Network calls use an EXPLICIT short timeout (2500ms), never the 10s default.
14
+ * - Transport failure / timeout / parse error / DEGRADED / no-bus → exit 0
15
+ * (loud systemMessage on a contended bus, silent for a clean non-match).
16
+ * - NEVER calls die(); NEVER routes through ctx.getContext() (which die()s on a
17
+ * missing config) — it owns its own watchdog.
18
+ *
19
+ * ANCHORED command classifier (NOT bare substring): match only command-LEADING
20
+ * verbs — `git push`, `sls|cdk|serverless deploy`, `kubectl apply`,
21
+ * `helm upgrade`, `fly deploy`, `vercel --prod`, `gh release`, `npm publish`, and
22
+ * real `--force`/`-f` FLAGS. It MUST NOT match `grep -r deploy`, `rm -f x`, or a
23
+ * filename like `release.ts` (those are not command-leading deploy verbs).
24
+ * - Non-match → exit 0 with ZERO network.
25
+ * - Match → check open directed halts; for DEPLOY commands also check lane-state
26
+ * (cached ~3s keyed (slug,intent)).
27
+ *
28
+ * exit 2 (BLOCK) ONLY on:
29
+ * (a) an open DIRECTED HALT for this session (hard, for ANY matched command), OR
30
+ * (b) a CONFIRMED held lane for a DEPLOY command (different instance).
31
+ * A SOFT held-lane conflict (foreign live lane, no directed halt) → 'ask'.
32
+ */
33
+ const git_1 = require("../git");
34
+ const config_1 = require("../config");
35
+ const cache_1 = require("../cache");
36
+ const api_1 = require("../api");
37
+ const exit_1 = require("../exit");
38
+ const WATCHDOG_MS = 4000;
39
+ const NET_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the 10s default
40
+ /**
41
+ * True iff a pushed REF is a deploy ref (the lane is per-target). Used by both
42
+ * `guard` (push classification) and `gate-push` to keep ONE classifier.
43
+ * Default deploy refs: refs/heads/main, refs/heads/master, any refs/tags/*.
44
+ */
45
+ function classifyPushRefs(ref) {
46
+ const r = ref.trim();
47
+ if (r.startsWith('refs/tags/'))
48
+ return true;
49
+ const head = r.replace(/^refs\/heads\//, '');
50
+ return head === 'main' || head === 'master';
51
+ }
52
+ /** Split a token off any leading env-assignment / `command` / `exec` prefixes. */
53
+ function leadingTokens(cmd) {
54
+ // Split on shell connectors so `foo && git push` classifies the `git push`.
55
+ const segments = cmd.split(/(?:&&|\|\||;|\||\n)/g);
56
+ const tokens = [];
57
+ for (const seg of segments) {
58
+ const words = seg.trim().split(/\s+/).filter(Boolean);
59
+ // Drop leading VAR=val assignments and a leading `sudo`/`command`/`exec`/`time`.
60
+ let i = 0;
61
+ while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^(sudo|command|exec|time|env)$/.test(words[i])))
62
+ i++;
63
+ if (i < words.length)
64
+ tokens.push(...words.slice(i));
65
+ tokens.push('\u0000'); // segment boundary marker
66
+ }
67
+ return tokens;
68
+ }
69
+ /**
70
+ * ANCHORED classifier. Matches command-LEADING verbs only. Never a bare
71
+ * substring of `deploy`/`release`/`-f` (so `grep -r deploy`, `rm -f x`,
72
+ * `cat release.ts` do NOT match).
73
+ */
74
+ function classifyCommand(command) {
75
+ if (!command || !command.trim())
76
+ return { kind: 'none' };
77
+ // Walk each connector-split segment; classify on its FIRST word (the program).
78
+ const segments = command.split(/(?:&&|\|\||;|\||\n)/g);
79
+ for (const seg of segments) {
80
+ const words = seg.trim().split(/\s+/).filter(Boolean);
81
+ let i = 0;
82
+ while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^(sudo|command|exec|time|env)$/.test(words[i])))
83
+ i++;
84
+ const w = words.slice(i);
85
+ if (!w.length)
86
+ continue;
87
+ const prog = w[0];
88
+ const sub = w[1];
89
+ const rest = w.slice(1);
90
+ // git push <remote?> <refspec...> — extract candidate refs.
91
+ if (prog === 'git' && sub === 'push') {
92
+ const refs = [];
93
+ // refspecs are the non-flag args after `push` (skip the remote name heuristically).
94
+ const args = w.slice(2).filter((a) => !a.startsWith('-'));
95
+ // args[0] is usually the remote; the rest are refspecs. Normalize each.
96
+ for (let k = 0; k < args.length; k++) {
97
+ const a = args[k];
98
+ if (k === 0 && !a.includes(':') && !a.includes('/'))
99
+ continue; // remote name
100
+ const local = a.includes(':') ? a.split(':')[0] : a;
101
+ if (!local)
102
+ continue;
103
+ refs.push(local.startsWith('refs/') ? local : `refs/heads/${local}`);
104
+ }
105
+ return { kind: 'push', refs };
106
+ }
107
+ // Deploy/publish verbs (program + leading subcommand).
108
+ if ((prog === 'sls' || prog === 'serverless' || prog === 'cdk') && sub === 'deploy')
109
+ return { kind: 'deploy' };
110
+ if (prog === 'fly' && sub === 'deploy')
111
+ return { kind: 'deploy' };
112
+ if (prog === 'kubectl' && sub === 'apply')
113
+ return { kind: 'deploy' };
114
+ if (prog === 'helm' && sub === 'upgrade')
115
+ return { kind: 'deploy' };
116
+ if (prog === 'gh' && sub === 'release')
117
+ return { kind: 'deploy' };
118
+ if (prog === 'npm' && sub === 'publish')
119
+ return { kind: 'deploy' };
120
+ if (prog === 'vercel' && rest.includes('--prod'))
121
+ return { kind: 'deploy' };
122
+ // A real --force / -f FLAG on a mutating program (anchored as a flag token,
123
+ // never a bare substring). `rm -f` is excluded — it's a filesystem op, not a
124
+ // deploy/push. We only treat --force on push-like programs as deploy-adjacent.
125
+ const hasForceFlag = rest.some((a) => a === '--force' || /^-[a-eg-zA-Z]*f[a-eg-zA-Z]*$/.test(a) || a === '-f');
126
+ if (hasForceFlag && (prog === 'git' || prog === 'sls' || prog === 'serverless' || prog === 'cdk' || prog === 'helm')) {
127
+ return { kind: 'force' };
128
+ }
129
+ }
130
+ return { kind: 'none' };
131
+ }
132
+ /** Async, timeout-bounded stdin read (the PreToolUse payload is JSON). */
133
+ function readStdin(timeoutMs) {
134
+ if (process.stdin.isTTY)
135
+ return Promise.resolve(null);
136
+ return new Promise((resolve) => {
137
+ let data = '';
138
+ let settled = false;
139
+ const finish = (v) => {
140
+ if (settled)
141
+ return;
142
+ settled = true;
143
+ clearTimeout(timer);
144
+ process.stdin.removeAllListeners();
145
+ resolve(v);
146
+ };
147
+ const timer = setTimeout(() => finish(null), timeoutMs);
148
+ process.stdin.setEncoding('utf8');
149
+ process.stdin.on('data', (c) => {
150
+ data += c;
151
+ });
152
+ process.stdin.on('end', () => finish(data));
153
+ process.stdin.on('error', () => finish(null));
154
+ process.stdin.resume();
155
+ });
156
+ }
157
+ /** Pull the Bash command string out of a PreToolUse hook payload. */
158
+ function commandFromPayload(raw) {
159
+ if (!raw)
160
+ return '';
161
+ try {
162
+ const j = JSON.parse(raw);
163
+ const c = j?.tool_input?.command;
164
+ return typeof c === 'string' ? c : '';
165
+ }
166
+ catch {
167
+ return '';
168
+ }
169
+ }
170
+ function emitJson(obj) {
171
+ process.stdout.write(JSON.stringify(obj) + '\n');
172
+ }
173
+ function ask(reason) {
174
+ emitJson({
175
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason },
176
+ });
177
+ }
178
+ function loudOpen(systemMessage) {
179
+ emitJson({ systemMessage });
180
+ }
181
+ function blockReason(reason) {
182
+ process.stderr.write(reason + '\n');
183
+ }
184
+ function safeHandle(s) {
185
+ if (typeof s !== 'string')
186
+ return 'another session';
187
+ const cleaned = s.replace(/[^a-zA-Z0-9_.\-/]+/g, '').slice(0, 64);
188
+ return cleaned ? `@${cleaned}` : 'another session';
189
+ }
190
+ async function run(opts) {
191
+ const raw = opts.stdin ? await readStdin(1500) : null;
192
+ const command = commandFromPayload(raw);
193
+ const cls = classifyCommand(command);
194
+ // The DEFAULT path is the WP9 deploy gate: a NON-match exits 0 with ZERO
195
+ // network (the overwhelming common case). The HALT-ONLY path (the cheap `.*`
196
+ // matcher, wired in WP13) checks EVERY command for a directed halt, so a long
197
+ // non-deploy turn aborts at its next tool call. A non-match in halt-only mode
198
+ // is NOT a free pass — it still does the one cheap cached halt read.
199
+ if (!opts.haltOnly && cls.kind === 'none')
200
+ return 0;
201
+ const top = (0, git_1.gitToplevel)();
202
+ if (!top)
203
+ return 0; // not a git repo → no-op
204
+ const proj = (0, config_1.loadProjectConfig)(top);
205
+ const slug = opts.project || proj?.slug || null;
206
+ if (!slug)
207
+ return 0; // not on the bus → no-op (covers BOTH match + non-match)
208
+ const cfg = (0, config_1.resolveConfig)();
209
+ const member = cfg.member;
210
+ const session = member ? (0, git_1.sessionId)(member, top) : null;
211
+ if (!cfg.apiKey || !session) {
212
+ // We cannot verify. The halt-only `.*` matcher stays SILENT (no point shouting
213
+ // on every Bash); the deploy gate on a MATCHED command fails OPEN-loud.
214
+ if (!opts.haltOnly && cls.kind !== 'none') {
215
+ loudOpen('convene: halt/lane state UNVERIFIED — not logged in, proceeding UNGATED.');
216
+ }
217
+ return 0;
218
+ }
219
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
220
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
221
+ // ── HALT-ONLY mode: ONE cheap cached halt read for ANY command ──────────────
222
+ // exit 2 ONLY on a confirmed OPEN DIRECTED halt for this session (the only
223
+ // confirmed positive). Any uncertainty (state===null) → exit 0 (fail-open). The
224
+ // "no halt for me" answer is cached for a short TTL so a command burst pays one
225
+ // GET. NO deploy classification, NO lane read — strictly the halt backstop.
226
+ if (opts.haltOnly) {
227
+ const halt = await haltStateCached(api, slug);
228
+ if (halt && halt.halts.length > 0) {
229
+ blockReason('convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not proceed.');
230
+ return 2;
231
+ }
232
+ return 0; // null (couldn't verify) OR no halt → fail-open
233
+ }
234
+ // ── DEFAULT (deploy) path — unchanged from WP9 ──────────────────────────────
235
+ // Derive the lane intent for a deploy/push command (null = non-deploy).
236
+ let ref = null;
237
+ if (cls.kind === 'push') {
238
+ const deployRef = cls.refs.find((r) => classifyPushRefs(r));
239
+ // No deploy ref in the push (e.g. a feature branch) → not a deploy; only the
240
+ // halt check applies. ref stays null so we don't gate a feature-branch lane.
241
+ ref = deployRef ?? null;
242
+ }
243
+ else if (cls.kind === 'deploy' || cls.kind === 'force') {
244
+ ref = 'refs/heads/main'; // generic deploy lane intent
245
+ }
246
+ const intent = ref ? `deploy:${ref}` : 'deploy';
247
+ // ONE lane-state GET serves both the halt check and the lane check (cached ~3s
248
+ // keyed (slug,intent) by laneStateCached). Any failure → fail-open (no block).
249
+ const state = await laneStateCached(api, slug, intent);
250
+ if (!state) {
251
+ loudOpen('convene: halt/lane state UNVERIFIED — bus unreachable, proceeding UNGATED.');
252
+ return 0;
253
+ }
254
+ // (a) An open DIRECTED HALT for this session is a HARD block for ANY matched command.
255
+ const halts = Array.isArray(state.halts) ? state.halts : [];
256
+ if (halts.length > 0) {
257
+ blockReason('convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not proceed.');
258
+ return 2;
259
+ }
260
+ // (b) A held lane only matters for a DEPLOY command (push to a deploy ref, or a
261
+ // deploy/force verb). A foreign held lane → soft conflict → 'ask' (NOT a hard
262
+ // deny — the gate-push CAS is the real serializer at push time).
263
+ const isDeploy = ref != null || cls.kind === 'deploy' || cls.kind === 'force';
264
+ if (isDeploy) {
265
+ const lanes = Array.isArray(state.lanes) ? state.lanes : [];
266
+ const foreign = lanes.find((l) => l && l.holder_instance_self === false);
267
+ if (foreign) {
268
+ ask(`A deploy lane is held by ${safeHandle(foreign.holder_handle)}. ` +
269
+ `The push gate will serialize this — proceed and let it claim, or coordinate first. Allow?`);
270
+ return 0;
271
+ }
272
+ }
273
+ return 0;
274
+ }
275
+ const STATE_TTL_MS = 3000;
276
+ const stateCache = new Map();
277
+ async function laneStateCached(api, slug, intent) {
278
+ const key = `${slug}\u0000${intent}`;
279
+ const hit = stateCache.get(key);
280
+ if (hit && Date.now() - hit.at < STATE_TTL_MS)
281
+ return { lanes: hit.lanes, halts: hit.halts };
282
+ const res = await api.laneState(slug, intent, NET_TIMEOUT_MS).catch(() => null);
283
+ if (!res || !res.ok || !res.json)
284
+ return null; // fail-open
285
+ const lanes = Array.isArray(res.json.lanes) ? res.json.lanes : [];
286
+ const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
287
+ stateCache.set(key, { at: Date.now(), lanes, halts });
288
+ return { lanes, halts };
289
+ }
290
+ const haltCache = new Map();
291
+ async function haltStateCached(api, slug) {
292
+ const hit = haltCache.get(slug);
293
+ if (hit && Date.now() - hit.at < STATE_TTL_MS)
294
+ return { halts: hit.halts };
295
+ // No intent: a halt is routed by member/session, not by lane — the cheapest read.
296
+ const res = await api.laneState(slug, null, NET_TIMEOUT_MS).catch(() => null);
297
+ if (!res || !res.ok || !res.json)
298
+ return null; // fail-open (and don't cache the miss)
299
+ const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
300
+ haltCache.set(slug, { at: Date.now(), halts });
301
+ return { halts };
302
+ }
303
+ async function guard(opts = {}) {
304
+ let code = 0;
305
+ const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), WATCHDOG_MS);
306
+ watchdog.unref();
307
+ try {
308
+ code = await run(opts);
309
+ }
310
+ catch {
311
+ code = 0; // fail-open: never block on our own error
312
+ }
313
+ clearTimeout(watchdog);
314
+ (0, exit_1.exitClean)(code);
315
+ }