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.
- package/dist/api.js +103 -1
- package/dist/cache.js +260 -1
- package/dist/commands/auth.js +164 -0
- package/dist/commands/catchup.js +125 -0
- package/dist/commands/deploy.js +71 -0
- package/dist/commands/explain.js +59 -0
- package/dist/commands/fetch.js +77 -6
- package/dist/commands/gate-push.js +333 -0
- package/dist/commands/guard.js +315 -0
- package/dist/commands/init.js +193 -4
- package/dist/commands/lane.js +116 -0
- package/dist/commands/notify.js +4 -2
- package/dist/commands/post.js +55 -1
- package/dist/commands/session-start.js +105 -0
- package/dist/commands/setup.js +3 -0
- package/dist/commands/watch.js +147 -0
- package/dist/commands/worktree.js +63 -0
- package/dist/exit.js +49 -0
- package/dist/git.js +63 -2
- package/dist/githook.js +48 -10
- package/dist/hook.js +108 -2
- package/dist/index.js +108 -0
- package/dist/protocol.js +119 -25
- package/dist/render.js +181 -2
- package/dist/test-env.js +5 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|