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.
- package/dist/api.js +92 -1
- package/dist/cache.js +211 -0
- package/dist/commands/auth.js +149 -0
- package/dist/commands/catchup.js +123 -0
- package/dist/commands/deploy.js +71 -0
- package/dist/commands/fetch.js +71 -4
- package/dist/commands/gate-push.js +331 -0
- package/dist/commands/guard.js +313 -0
- package/dist/commands/init.js +193 -8
- package/dist/commands/lane.js +116 -0
- package/dist/commands/post.js +31 -1
- package/dist/commands/session-start.js +103 -0
- package/dist/commands/watch.js +147 -0
- package/dist/git.js +16 -0
- package/dist/githook.js +48 -10
- package/dist/hook.js +108 -2
- package/dist/index.js +88 -0
- package/dist/protocol.js +104 -22
- package/dist/render.js +176 -1
- package/dist/test-env.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,313 @@
|
|
|
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 WATCHDOG_MS = 4000;
|
|
38
|
+
const NET_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the 10s default
|
|
39
|
+
/**
|
|
40
|
+
* True iff a pushed REF is a deploy ref (the lane is per-target). Used by both
|
|
41
|
+
* `guard` (push classification) and `gate-push` to keep ONE classifier.
|
|
42
|
+
* Default deploy refs: refs/heads/main, refs/heads/master, any refs/tags/*.
|
|
43
|
+
*/
|
|
44
|
+
function classifyPushRefs(ref) {
|
|
45
|
+
const r = ref.trim();
|
|
46
|
+
if (r.startsWith('refs/tags/'))
|
|
47
|
+
return true;
|
|
48
|
+
const head = r.replace(/^refs\/heads\//, '');
|
|
49
|
+
return head === 'main' || head === 'master';
|
|
50
|
+
}
|
|
51
|
+
/** Split a token off any leading env-assignment / `command` / `exec` prefixes. */
|
|
52
|
+
function leadingTokens(cmd) {
|
|
53
|
+
// Split on shell connectors so `foo && git push` classifies the `git push`.
|
|
54
|
+
const segments = cmd.split(/(?:&&|\|\||;|\||\n)/g);
|
|
55
|
+
const tokens = [];
|
|
56
|
+
for (const seg of segments) {
|
|
57
|
+
const words = seg.trim().split(/\s+/).filter(Boolean);
|
|
58
|
+
// Drop leading VAR=val assignments and a leading `sudo`/`command`/`exec`/`time`.
|
|
59
|
+
let i = 0;
|
|
60
|
+
while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^(sudo|command|exec|time|env)$/.test(words[i])))
|
|
61
|
+
i++;
|
|
62
|
+
if (i < words.length)
|
|
63
|
+
tokens.push(...words.slice(i));
|
|
64
|
+
tokens.push('\u0000'); // segment boundary marker
|
|
65
|
+
}
|
|
66
|
+
return tokens;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* ANCHORED classifier. Matches command-LEADING verbs only. Never a bare
|
|
70
|
+
* substring of `deploy`/`release`/`-f` (so `grep -r deploy`, `rm -f x`,
|
|
71
|
+
* `cat release.ts` do NOT match).
|
|
72
|
+
*/
|
|
73
|
+
function classifyCommand(command) {
|
|
74
|
+
if (!command || !command.trim())
|
|
75
|
+
return { kind: 'none' };
|
|
76
|
+
// Walk each connector-split segment; classify on its FIRST word (the program).
|
|
77
|
+
const segments = command.split(/(?:&&|\|\||;|\||\n)/g);
|
|
78
|
+
for (const seg of segments) {
|
|
79
|
+
const words = seg.trim().split(/\s+/).filter(Boolean);
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^(sudo|command|exec|time|env)$/.test(words[i])))
|
|
82
|
+
i++;
|
|
83
|
+
const w = words.slice(i);
|
|
84
|
+
if (!w.length)
|
|
85
|
+
continue;
|
|
86
|
+
const prog = w[0];
|
|
87
|
+
const sub = w[1];
|
|
88
|
+
const rest = w.slice(1);
|
|
89
|
+
// git push <remote?> <refspec...> — extract candidate refs.
|
|
90
|
+
if (prog === 'git' && sub === 'push') {
|
|
91
|
+
const refs = [];
|
|
92
|
+
// refspecs are the non-flag args after `push` (skip the remote name heuristically).
|
|
93
|
+
const args = w.slice(2).filter((a) => !a.startsWith('-'));
|
|
94
|
+
// args[0] is usually the remote; the rest are refspecs. Normalize each.
|
|
95
|
+
for (let k = 0; k < args.length; k++) {
|
|
96
|
+
const a = args[k];
|
|
97
|
+
if (k === 0 && !a.includes(':') && !a.includes('/'))
|
|
98
|
+
continue; // remote name
|
|
99
|
+
const local = a.includes(':') ? a.split(':')[0] : a;
|
|
100
|
+
if (!local)
|
|
101
|
+
continue;
|
|
102
|
+
refs.push(local.startsWith('refs/') ? local : `refs/heads/${local}`);
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'push', refs };
|
|
105
|
+
}
|
|
106
|
+
// Deploy/publish verbs (program + leading subcommand).
|
|
107
|
+
if ((prog === 'sls' || prog === 'serverless' || prog === 'cdk') && sub === 'deploy')
|
|
108
|
+
return { kind: 'deploy' };
|
|
109
|
+
if (prog === 'fly' && sub === 'deploy')
|
|
110
|
+
return { kind: 'deploy' };
|
|
111
|
+
if (prog === 'kubectl' && sub === 'apply')
|
|
112
|
+
return { kind: 'deploy' };
|
|
113
|
+
if (prog === 'helm' && sub === 'upgrade')
|
|
114
|
+
return { kind: 'deploy' };
|
|
115
|
+
if (prog === 'gh' && sub === 'release')
|
|
116
|
+
return { kind: 'deploy' };
|
|
117
|
+
if (prog === 'npm' && sub === 'publish')
|
|
118
|
+
return { kind: 'deploy' };
|
|
119
|
+
if (prog === 'vercel' && rest.includes('--prod'))
|
|
120
|
+
return { kind: 'deploy' };
|
|
121
|
+
// A real --force / -f FLAG on a mutating program (anchored as a flag token,
|
|
122
|
+
// never a bare substring). `rm -f` is excluded — it's a filesystem op, not a
|
|
123
|
+
// deploy/push. We only treat --force on push-like programs as deploy-adjacent.
|
|
124
|
+
const hasForceFlag = rest.some((a) => a === '--force' || /^-[a-eg-zA-Z]*f[a-eg-zA-Z]*$/.test(a) || a === '-f');
|
|
125
|
+
if (hasForceFlag && (prog === 'git' || prog === 'sls' || prog === 'serverless' || prog === 'cdk' || prog === 'helm')) {
|
|
126
|
+
return { kind: 'force' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { kind: 'none' };
|
|
130
|
+
}
|
|
131
|
+
/** Async, timeout-bounded stdin read (the PreToolUse payload is JSON). */
|
|
132
|
+
function readStdin(timeoutMs) {
|
|
133
|
+
if (process.stdin.isTTY)
|
|
134
|
+
return Promise.resolve(null);
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
let data = '';
|
|
137
|
+
let settled = false;
|
|
138
|
+
const finish = (v) => {
|
|
139
|
+
if (settled)
|
|
140
|
+
return;
|
|
141
|
+
settled = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
process.stdin.removeAllListeners();
|
|
144
|
+
resolve(v);
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
147
|
+
process.stdin.setEncoding('utf8');
|
|
148
|
+
process.stdin.on('data', (c) => {
|
|
149
|
+
data += c;
|
|
150
|
+
});
|
|
151
|
+
process.stdin.on('end', () => finish(data));
|
|
152
|
+
process.stdin.on('error', () => finish(null));
|
|
153
|
+
process.stdin.resume();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** Pull the Bash command string out of a PreToolUse hook payload. */
|
|
157
|
+
function commandFromPayload(raw) {
|
|
158
|
+
if (!raw)
|
|
159
|
+
return '';
|
|
160
|
+
try {
|
|
161
|
+
const j = JSON.parse(raw);
|
|
162
|
+
const c = j?.tool_input?.command;
|
|
163
|
+
return typeof c === 'string' ? c : '';
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function emitJson(obj) {
|
|
170
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
171
|
+
}
|
|
172
|
+
function ask(reason) {
|
|
173
|
+
emitJson({
|
|
174
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function loudOpen(systemMessage) {
|
|
178
|
+
emitJson({ systemMessage });
|
|
179
|
+
}
|
|
180
|
+
function blockReason(reason) {
|
|
181
|
+
process.stderr.write(reason + '\n');
|
|
182
|
+
}
|
|
183
|
+
function safeHandle(s) {
|
|
184
|
+
if (typeof s !== 'string')
|
|
185
|
+
return 'another session';
|
|
186
|
+
const cleaned = s.replace(/[^a-zA-Z0-9_.\-/]+/g, '').slice(0, 64);
|
|
187
|
+
return cleaned ? `@${cleaned}` : 'another session';
|
|
188
|
+
}
|
|
189
|
+
async function run(opts) {
|
|
190
|
+
const raw = opts.stdin ? await readStdin(1500) : null;
|
|
191
|
+
const command = commandFromPayload(raw);
|
|
192
|
+
const cls = classifyCommand(command);
|
|
193
|
+
// The DEFAULT path is the WP9 deploy gate: a NON-match exits 0 with ZERO
|
|
194
|
+
// network (the overwhelming common case). The HALT-ONLY path (the cheap `.*`
|
|
195
|
+
// matcher, wired in WP13) checks EVERY command for a directed halt, so a long
|
|
196
|
+
// non-deploy turn aborts at its next tool call. A non-match in halt-only mode
|
|
197
|
+
// is NOT a free pass — it still does the one cheap cached halt read.
|
|
198
|
+
if (!opts.haltOnly && cls.kind === 'none')
|
|
199
|
+
return 0;
|
|
200
|
+
const top = (0, git_1.gitToplevel)();
|
|
201
|
+
if (!top)
|
|
202
|
+
return 0; // not a git repo → no-op
|
|
203
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
204
|
+
const slug = opts.project || proj?.slug || null;
|
|
205
|
+
if (!slug)
|
|
206
|
+
return 0; // not on the bus → no-op (covers BOTH match + non-match)
|
|
207
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
208
|
+
const member = cfg.member;
|
|
209
|
+
const session = member ? (0, git_1.sessionId)(member, top) : null;
|
|
210
|
+
if (!cfg.apiKey || !session) {
|
|
211
|
+
// We cannot verify. The halt-only `.*` matcher stays SILENT (no point shouting
|
|
212
|
+
// on every Bash); the deploy gate on a MATCHED command fails OPEN-loud.
|
|
213
|
+
if (!opts.haltOnly && cls.kind !== 'none') {
|
|
214
|
+
loudOpen('convene: halt/lane state UNVERIFIED — not logged in, proceeding UNGATED.');
|
|
215
|
+
}
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
219
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
220
|
+
// ── HALT-ONLY mode: ONE cheap cached halt read for ANY command ──────────────
|
|
221
|
+
// exit 2 ONLY on a confirmed OPEN DIRECTED halt for this session (the only
|
|
222
|
+
// confirmed positive). Any uncertainty (state===null) → exit 0 (fail-open). The
|
|
223
|
+
// "no halt for me" answer is cached for a short TTL so a command burst pays one
|
|
224
|
+
// GET. NO deploy classification, NO lane read — strictly the halt backstop.
|
|
225
|
+
if (opts.haltOnly) {
|
|
226
|
+
const halt = await haltStateCached(api, slug);
|
|
227
|
+
if (halt && halt.halts.length > 0) {
|
|
228
|
+
blockReason('convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not proceed.');
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
return 0; // null (couldn't verify) OR no halt → fail-open
|
|
232
|
+
}
|
|
233
|
+
// ── DEFAULT (deploy) path — unchanged from WP9 ──────────────────────────────
|
|
234
|
+
// Derive the lane intent for a deploy/push command (null = non-deploy).
|
|
235
|
+
let ref = null;
|
|
236
|
+
if (cls.kind === 'push') {
|
|
237
|
+
const deployRef = cls.refs.find((r) => classifyPushRefs(r));
|
|
238
|
+
// No deploy ref in the push (e.g. a feature branch) → not a deploy; only the
|
|
239
|
+
// halt check applies. ref stays null so we don't gate a feature-branch lane.
|
|
240
|
+
ref = deployRef ?? null;
|
|
241
|
+
}
|
|
242
|
+
else if (cls.kind === 'deploy' || cls.kind === 'force') {
|
|
243
|
+
ref = 'refs/heads/main'; // generic deploy lane intent
|
|
244
|
+
}
|
|
245
|
+
const intent = ref ? `deploy:${ref}` : 'deploy';
|
|
246
|
+
// ONE lane-state GET serves both the halt check and the lane check (cached ~3s
|
|
247
|
+
// keyed (slug,intent) by laneStateCached). Any failure → fail-open (no block).
|
|
248
|
+
const state = await laneStateCached(api, slug, intent);
|
|
249
|
+
if (!state) {
|
|
250
|
+
loudOpen('convene: halt/lane state UNVERIFIED — bus unreachable, proceeding UNGATED.');
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
// (a) An open DIRECTED HALT for this session is a HARD block for ANY matched command.
|
|
254
|
+
const halts = Array.isArray(state.halts) ? state.halts : [];
|
|
255
|
+
if (halts.length > 0) {
|
|
256
|
+
blockReason('convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not proceed.');
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
// (b) A held lane only matters for a DEPLOY command (push to a deploy ref, or a
|
|
260
|
+
// deploy/force verb). A foreign held lane → soft conflict → 'ask' (NOT a hard
|
|
261
|
+
// deny — the gate-push CAS is the real serializer at push time).
|
|
262
|
+
const isDeploy = ref != null || cls.kind === 'deploy' || cls.kind === 'force';
|
|
263
|
+
if (isDeploy) {
|
|
264
|
+
const lanes = Array.isArray(state.lanes) ? state.lanes : [];
|
|
265
|
+
const foreign = lanes.find((l) => l && l.holder_instance_self === false);
|
|
266
|
+
if (foreign) {
|
|
267
|
+
ask(`A deploy lane is held by ${safeHandle(foreign.holder_handle)}. ` +
|
|
268
|
+
`The push gate will serialize this — proceed and let it claim, or coordinate first. Allow?`);
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
const STATE_TTL_MS = 3000;
|
|
275
|
+
const stateCache = new Map();
|
|
276
|
+
async function laneStateCached(api, slug, intent) {
|
|
277
|
+
const key = `${slug}\u0000${intent}`;
|
|
278
|
+
const hit = stateCache.get(key);
|
|
279
|
+
if (hit && Date.now() - hit.at < STATE_TTL_MS)
|
|
280
|
+
return { lanes: hit.lanes, halts: hit.halts };
|
|
281
|
+
const res = await api.laneState(slug, intent, NET_TIMEOUT_MS).catch(() => null);
|
|
282
|
+
if (!res || !res.ok || !res.json)
|
|
283
|
+
return null; // fail-open
|
|
284
|
+
const lanes = Array.isArray(res.json.lanes) ? res.json.lanes : [];
|
|
285
|
+
const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
|
|
286
|
+
stateCache.set(key, { at: Date.now(), lanes, halts });
|
|
287
|
+
return { lanes, halts };
|
|
288
|
+
}
|
|
289
|
+
const haltCache = new Map();
|
|
290
|
+
async function haltStateCached(api, slug) {
|
|
291
|
+
const hit = haltCache.get(slug);
|
|
292
|
+
if (hit && Date.now() - hit.at < STATE_TTL_MS)
|
|
293
|
+
return { halts: hit.halts };
|
|
294
|
+
// No intent: a halt is routed by member/session, not by lane — the cheapest read.
|
|
295
|
+
const res = await api.laneState(slug, null, NET_TIMEOUT_MS).catch(() => null);
|
|
296
|
+
if (!res || !res.ok || !res.json)
|
|
297
|
+
return null; // fail-open (and don't cache the miss)
|
|
298
|
+
const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
|
|
299
|
+
haltCache.set(slug, { at: Date.now(), halts });
|
|
300
|
+
return { halts };
|
|
301
|
+
}
|
|
302
|
+
async function guard(opts = {}) {
|
|
303
|
+
let code = 0;
|
|
304
|
+
const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
|
|
305
|
+
try {
|
|
306
|
+
code = await run(opts);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
code = 0; // fail-open: never block on our own error
|
|
310
|
+
}
|
|
311
|
+
clearTimeout(watchdog);
|
|
312
|
+
process.exit(code);
|
|
313
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -34,13 +34,15 @@ function upsertMarkerBlock(content, block) {
|
|
|
34
34
|
const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
35
35
|
return content + sep + block + '\n';
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
// Every file this writes lives inside the git repo, so git history IS the backup —
|
|
38
|
+
// dropping a sibling `.bak` just litters the working tree (and shows up as untracked
|
|
39
|
+
// noise / risks being committed). The only `.bak` we keep is for the user's GLOBAL
|
|
40
|
+
// ~/.claude/settings.json, which is outside any repo (see registerHook).
|
|
41
|
+
function writeIfChanged(file, content) {
|
|
38
42
|
const exists = node_fs_1.default.existsSync(file);
|
|
39
43
|
const old = exists ? node_fs_1.default.readFileSync(file, 'utf8') : null;
|
|
40
44
|
if (old === content)
|
|
41
45
|
return 'unchanged';
|
|
42
|
-
if (exists && backup)
|
|
43
|
-
node_fs_1.default.writeFileSync(file + '.bak', old);
|
|
44
46
|
node_fs_1.default.writeFileSync(file, content);
|
|
45
47
|
return exists ? 'updated' : 'created';
|
|
46
48
|
}
|
|
@@ -81,8 +83,7 @@ function ensureGitignoreGuard(top) {
|
|
|
81
83
|
next = next + sep + guard;
|
|
82
84
|
}
|
|
83
85
|
if (next !== old) {
|
|
84
|
-
|
|
85
|
-
node_fs_1.default.writeFileSync(file + '.bak', old);
|
|
86
|
+
// .gitignore is git-tracked — no .bak (git history is the backup).
|
|
86
87
|
node_fs_1.default.writeFileSync(file, next);
|
|
87
88
|
}
|
|
88
89
|
// Belt-and-suspenders: confirm the join-token-bearing file is actually trackable.
|
|
@@ -128,6 +129,82 @@ function registerHook(noHook) {
|
|
|
128
129
|
log(hookSnippet());
|
|
129
130
|
}
|
|
130
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* The WP13 coordination hooks, in INSTALL ORDER. Order matters for the two Bash
|
|
134
|
+
* PreToolUse entries: `gate-push --stdin` is wired before `guard`, so `guard` lands
|
|
135
|
+
* LAST among Bash PreToolUse hooks (awareness/ux #10) — the deploy gate runs, then
|
|
136
|
+
* the cheap halt/lane backstop. Each entry names the VERB its binary must support;
|
|
137
|
+
* a stale `convene` missing the verb is skipped (so it can't error on every boot).
|
|
138
|
+
*
|
|
139
|
+
* `convene watch` is NOT a Bash hook — it's a long-running detached daemon that
|
|
140
|
+
* `convene session-start` spawns from the SessionStart path (§4.4). Wiring it as a
|
|
141
|
+
* blocking Bash/PreToolUse entry would stall; launching from session-start keeps it
|
|
142
|
+
* off the discretionary tool path.
|
|
143
|
+
*/
|
|
144
|
+
const COORD_HOOKS = [
|
|
145
|
+
{
|
|
146
|
+
event: 'SessionStart',
|
|
147
|
+
matcher: 'startup|resume|clear',
|
|
148
|
+
command: 'convene session-start',
|
|
149
|
+
verb: 'session-start',
|
|
150
|
+
note: 'auto catch-up digest + mints the session-instance + launches `convene watch`',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
event: 'PreToolUse',
|
|
154
|
+
matcher: 'Bash',
|
|
155
|
+
command: 'convene gate-push --stdin',
|
|
156
|
+
verb: 'gate-push',
|
|
157
|
+
note: 'deploy gate before a push (fail-open-loud)',
|
|
158
|
+
},
|
|
159
|
+
// guard is appended AFTER gate-push so it is LAST among Bash PreToolUse hooks.
|
|
160
|
+
{
|
|
161
|
+
event: 'PreToolUse',
|
|
162
|
+
matcher: 'Bash',
|
|
163
|
+
command: 'convene guard',
|
|
164
|
+
verb: 'guard',
|
|
165
|
+
note: 'halt + lane backstop for Bash (fail-open-loud)',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
event: 'PreToolUse',
|
|
169
|
+
matcher: '.*',
|
|
170
|
+
command: 'convene guard --halt-only',
|
|
171
|
+
verb: 'guard',
|
|
172
|
+
note: 'cheap directed-halt backstop on every tool call',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
event: 'PostToolUse',
|
|
176
|
+
matcher: 'Bash',
|
|
177
|
+
command: 'convene gate-push --post',
|
|
178
|
+
verb: 'gate-push',
|
|
179
|
+
note: 'release the deploy lane after a push (idempotent)',
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
/**
|
|
183
|
+
* Wire the WP13 coordination hooks into a settings file (global or committed
|
|
184
|
+
* project), idempotent + merge-safe via ensureHook (deep-clone, never clobber,
|
|
185
|
+
* parseSettings-null → 'manual' so we never clobber unparseable JSON). Skips any
|
|
186
|
+
* hook whose verb the installed binary doesn't support.
|
|
187
|
+
*/
|
|
188
|
+
function registerCoordinationHooks(settingsPath, label) {
|
|
189
|
+
let unparseable = false;
|
|
190
|
+
for (const h of COORD_HOOKS) {
|
|
191
|
+
if (!(0, hook_1.binarySupportsVerb)(h.verb)) {
|
|
192
|
+
log(`· ${label}: skipped \`${h.command}\` — installed \`convene\` lacks \`${h.verb}\` (upgrade the CLI to enable).`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const r = (0, hook_1.ensureHook)(h.event, h.command, h.matcher, settingsPath);
|
|
196
|
+
if (r === 'manual') {
|
|
197
|
+
unparseable = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (unparseable) {
|
|
202
|
+
log(`· ${label}: left as-is (existing settings unparseable) — add the coordination hooks manually.`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
log(`✓ ${label}: coordination hooks wired (SessionStart catch-up, PreToolUse gate/guard, PostToolUse release).`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
131
208
|
function installGitHookStep(top, skip) {
|
|
132
209
|
if (skip) {
|
|
133
210
|
log('Skipped git pre-push hook (--no-githook).');
|
|
@@ -234,6 +311,84 @@ function writeAgentRules(top, slug, member, baseUrl) {
|
|
|
234
311
|
writeGeminiSettings(top);
|
|
235
312
|
writeAiderConf(top);
|
|
236
313
|
}
|
|
314
|
+
// ── MCP client configs ───────────────────────────────────────────────────────
|
|
315
|
+
// Tools that speak MCP get the `convene` server auto-registered so the agent can
|
|
316
|
+
// call convene_fetch / post / lanes / etc. The published `convene-mcp` runs via
|
|
317
|
+
// `npx -y` (no global install); it resolves the API key from ~/.convene/config.json
|
|
318
|
+
// (written by `convene login` / `setup`), so the COMMITTED config carries NO secret
|
|
319
|
+
// — only the non-secret base URL. Idempotent JSON/TOML merge; never clobbers other
|
|
320
|
+
// servers. Codex ALSO gets a UserPromptSubmit hook (true per-turn injection, the
|
|
321
|
+
// cross-tool analog of the Claude Code fetch hook). Claude Code is intentionally
|
|
322
|
+
// EXCLUDED — its richer hook + CLI integration already covers it. Cline & Windsurf
|
|
323
|
+
// register MCP only via a USER-GLOBAL file (outside the repo), so init can't commit
|
|
324
|
+
// them — they're documented for manual setup instead.
|
|
325
|
+
const MCP_PKG = 'convene-mcp';
|
|
326
|
+
const TOML_BEGIN = '# >>> convene (managed) — do not edit between these markers';
|
|
327
|
+
const TOML_END = '# <<< convene (managed)';
|
|
328
|
+
/** Replace the convene block between TOML markers, or append it (TOML-comment markers). */
|
|
329
|
+
function upsertTomlBlock(content, block) {
|
|
330
|
+
const s = content.indexOf(TOML_BEGIN);
|
|
331
|
+
const e = content.indexOf(TOML_END);
|
|
332
|
+
if (s >= 0 && e > s) {
|
|
333
|
+
return content.slice(0, s) + block + content.slice(e + TOML_END.length);
|
|
334
|
+
}
|
|
335
|
+
const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
336
|
+
return content + sep + block + '\n';
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Ensure the `convene` server in a JSON MCP config under `topKey` (`mcpServers` for
|
|
340
|
+
* Cursor/Gemini, `servers` for VS Code). `stdioType` adds `"type":"stdio"` (VS Code
|
|
341
|
+
* wants it; the others infer stdio from `command`). Preserves other servers + keys;
|
|
342
|
+
* leaves an unparseable file untouched.
|
|
343
|
+
*/
|
|
344
|
+
function ensureJsonMcpServer(file, topKey, baseUrl, stdioType, label) {
|
|
345
|
+
let obj = {};
|
|
346
|
+
if (node_fs_1.default.existsSync(file)) {
|
|
347
|
+
try {
|
|
348
|
+
obj = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8')) || {};
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
log(`· ${label} (left as-is — unparseable JSON)`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!obj[topKey] || typeof obj[topKey] !== 'object')
|
|
356
|
+
obj[topKey] = {};
|
|
357
|
+
obj[topKey].convene = stdioType
|
|
358
|
+
? { type: 'stdio', command: 'npx', args: ['-y', MCP_PKG], env: { CONVENE_BASE_URL: baseUrl } }
|
|
359
|
+
: { command: 'npx', args: ['-y', MCP_PKG], env: { CONVENE_BASE_URL: baseUrl } };
|
|
360
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
361
|
+
const r = writeIfChanged(file, JSON.stringify(obj, null, 2) + '\n');
|
|
362
|
+
log(`${r === 'unchanged' ? '·' : '✓'} ${label} (${r})`);
|
|
363
|
+
}
|
|
364
|
+
/** Codex: project `.codex/config.toml` with the MCP server AND the UserPromptSubmit hook. */
|
|
365
|
+
function writeCodexConfig(top, baseUrl) {
|
|
366
|
+
const file = node_path_1.default.join(top, '.codex', 'config.toml');
|
|
367
|
+
const block = `${TOML_BEGIN}\n` +
|
|
368
|
+
`[mcp_servers.convene]\n` +
|
|
369
|
+
`command = "npx"\n` +
|
|
370
|
+
`args = ["-y", "${MCP_PKG}"]\n` +
|
|
371
|
+
`[mcp_servers.convene.env]\n` +
|
|
372
|
+
`CONVENE_BASE_URL = "${baseUrl}"\n` +
|
|
373
|
+
`\n` +
|
|
374
|
+
`[[hooks.UserPromptSubmit]]\n` +
|
|
375
|
+
`type = "command"\n` +
|
|
376
|
+
`command = "convene fetch --codex-hook"\n` +
|
|
377
|
+
`timeout = 10\n` +
|
|
378
|
+
`${TOML_END}`;
|
|
379
|
+
const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
|
|
380
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
381
|
+
const r = writeIfChanged(file, upsertTomlBlock(old, block));
|
|
382
|
+
log(`${r === 'unchanged' ? '·' : '✓'} .codex/config.toml (${r}) — Codex per-turn fetch hook + MCP server (trust the project once via \`/hooks\`; needs the \`convene\` CLI on PATH)`);
|
|
383
|
+
}
|
|
384
|
+
/** Write MCP client configs for the tools that take a committable project file. */
|
|
385
|
+
function writeMcpConfigs(top, baseUrl) {
|
|
386
|
+
ensureJsonMcpServer(node_path_1.default.join(top, '.cursor', 'mcp.json'), 'mcpServers', baseUrl, false, '.cursor/mcp.json — Cursor MCP server');
|
|
387
|
+
ensureJsonMcpServer(node_path_1.default.join(top, '.vscode', 'mcp.json'), 'servers', baseUrl, true, '.vscode/mcp.json — VS Code / Copilot MCP server');
|
|
388
|
+
ensureJsonMcpServer(node_path_1.default.join(top, '.gemini', 'settings.json'), 'mcpServers', baseUrl, false, '.gemini/settings.json — Gemini CLI MCP server');
|
|
389
|
+
writeCodexConfig(top, baseUrl);
|
|
390
|
+
log('· Cline & Windsurf register MCP only in a user-global file — add `convene` (`npx -y convene-mcp`) there manually; see CONVENE_PROTOCOL.md.');
|
|
391
|
+
}
|
|
237
392
|
async function init(opts) {
|
|
238
393
|
const top = (0, git_1.gitToplevel)();
|
|
239
394
|
if (!top)
|
|
@@ -246,6 +401,7 @@ async function init(opts) {
|
|
|
246
401
|
const skipGithook = opts.noGithook === true || opts.githook === false;
|
|
247
402
|
const skipJoinToken = opts.noJoinToken === true || opts.joinToken === false;
|
|
248
403
|
const skipAgentRules = opts.noAgentRules === true || opts.agentRules === false;
|
|
404
|
+
const skipMcp = opts.noMcp === true || opts.mcp === false;
|
|
249
405
|
let slug = opts.slug || existing?.slug;
|
|
250
406
|
let displayName = opts.name || existing?.displayName;
|
|
251
407
|
let joinToken = existing?.joinToken; // reuse if present (keeps init idempotent)
|
|
@@ -338,9 +494,16 @@ async function init(opts) {
|
|
|
338
494
|
// 4. .gitignore guard
|
|
339
495
|
ensureGitignoreGuard(top);
|
|
340
496
|
log('✓ .gitignore guard');
|
|
341
|
-
// 5. CLAUDE.md + AGENTS.md managed block
|
|
342
|
-
|
|
343
|
-
|
|
497
|
+
// 5. CLAUDE.md + AGENTS.md managed block. The two blocks INTENTIONALLY DIVERGE:
|
|
498
|
+
// CLAUDE.md uses conveneBlock (no manual-deploy line — the PreToolUse hook
|
|
499
|
+
// gates deploys), AGENTS.md uses conveneAgentsBlock (adds the explicit
|
|
500
|
+
// `convene deploy` line for tools with no in-time gate). Each is PURE, so a
|
|
501
|
+
// re-run is byte-identical per file (P0-IDEMPOTENT).
|
|
502
|
+
const fileBlocks = [
|
|
503
|
+
['CLAUDE.md', (0, protocol_1.conveneBlock)(slug, member, baseUrl)],
|
|
504
|
+
['AGENTS.md', (0, protocol_1.conveneAgentsBlock)(slug, member, baseUrl)],
|
|
505
|
+
];
|
|
506
|
+
for (const [fname, block] of fileBlocks) {
|
|
344
507
|
const file = node_path_1.default.join(top, fname);
|
|
345
508
|
const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
|
|
346
509
|
const result = writeIfChanged(file, upsertMarkerBlock(old, block));
|
|
@@ -378,6 +541,19 @@ async function init(opts) {
|
|
|
378
541
|
log(' otherwise they connect via `convene setup`.');
|
|
379
542
|
}
|
|
380
543
|
}
|
|
544
|
+
// 7a-bis. The WP13 coordination hooks (SessionStart catch-up, PreToolUse
|
|
545
|
+
// gate-push + guard + halt-only, PostToolUse release) — wired idempotently via
|
|
546
|
+
// the generalized ensureHook into the committed PROJECT settings ONLY, never
|
|
547
|
+
// global. These are BLOCKING PreToolUse/SessionStart hooks: writing them into
|
|
548
|
+
// ~/.claude/settings.json would fire them (and shell out to `convene`) in EVERY
|
|
549
|
+
// repo on the machine, gating unrelated work behind a tool that no-ops there.
|
|
550
|
+
// The committed .claude/settings.json already covers this checkout (Claude Code
|
|
551
|
+
// applies project hooks after a one-time per-repo trust prompt) AND lets
|
|
552
|
+
// teammates inherit them — so global is both redundant and a footgun. Only the
|
|
553
|
+
// lightweight `convene fetch` UserPromptSubmit hook stays global (registerHook
|
|
554
|
+
// above). guard lands LAST among Bash PreToolUse; verbs the binary lacks are
|
|
555
|
+
// skipped so a stale CLI never errors on boot.
|
|
556
|
+
registerCoordinationHooks((0, hook_1.projectSettingsPath)(top), '.claude/settings.json (committed)');
|
|
381
557
|
}
|
|
382
558
|
// 7b. committed git pre-push hook — the tool-agnostic backstop that auto-posts a
|
|
383
559
|
// [STATUS] when work is pushed (fires for Codex/Cowork/humans, not just Claude).
|
|
@@ -390,6 +566,15 @@ async function init(opts) {
|
|
|
390
566
|
else {
|
|
391
567
|
writeAgentRules(top, slug, member, baseUrl);
|
|
392
568
|
}
|
|
569
|
+
// 7d. MCP client configs — register the `convene` MCP server (and, for Codex, a
|
|
570
|
+
// per-turn fetch hook) so MCP-speaking tools get the bus without the CLI
|
|
571
|
+
// hooks. Committed + secret-free (key resolved from ~/.convene/config.json).
|
|
572
|
+
if (skipMcp) {
|
|
573
|
+
log('Skipped MCP client configs (--no-mcp).');
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
writeMcpConfigs(top, baseUrl);
|
|
577
|
+
}
|
|
393
578
|
// 8. memory seed (best-effort, outside the repo)
|
|
394
579
|
seedMemory(top, slug, baseUrl);
|
|
395
580
|
// 9. teammate one-liner
|