convene-cli 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
- function writeIfChanged(file, content, backup = true) {
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
- if (node_fs_1.default.existsSync(file))
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
- const block = (0, protocol_1.conveneBlock)(slug, member, baseUrl);
343
- for (const fname of ['CLAUDE.md', 'AGENTS.md']) {
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