convene-cli 1.1.1 → 1.3.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,526 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.offboard = offboard;
7
+ /**
8
+ * `convene off-board` — the exact inverse of `convene init`. Cleanly removes this
9
+ * repo from Convene in ONE isolated commit, instead of the ~14-file manual surgery
10
+ * the VAcontractorCo cleanup needed.
11
+ *
12
+ * Principles (mirrors init's discipline):
13
+ * - Surgical: strip only the convene-managed block from CLAUDE.md/AGENTS.md and the
14
+ * convene server/hook entries from shared config files — never clobber a user's
15
+ * own content. Delete a file only when it was convene's alone (empty after strip,
16
+ * or byte-identical to what init wrote).
17
+ * - Isolated: stage ONLY the touched convene paths into one commit, never `git add -A`.
18
+ * - Identity-preserving: the machine identity in ~/.convene/config.json is shared
19
+ * across repos and is NEVER removed here.
20
+ * - Idempotent: safe to re-run; a repo with no project.json is a clean no-op.
21
+ */
22
+ const node_fs_1 = __importDefault(require("node:fs"));
23
+ const node_path_1 = __importDefault(require("node:path"));
24
+ const config_1 = require("../config");
25
+ const git_1 = require("../git");
26
+ const api_1 = require("../api");
27
+ const hook_1 = require("../hook");
28
+ const githook_1 = require("../githook");
29
+ const init_1 = require("./init");
30
+ const materialize_1 = require("../catalog/materialize");
31
+ const catalog_1 = require("../catalog");
32
+ const protocol_1 = require("../protocol");
33
+ const ctx_1 = require("../ctx");
34
+ /**
35
+ * Every permissions.deny entry the catalog's settingsJson artifacts contribute —
36
+ * the EXACT set materialize.ts can write into .claude/settings.json at hook-hard.
37
+ * Off-board subtracts exactly these (and only these), so a teammate's own deny rule
38
+ * is never removed. Computed from CATALOG so it tracks the catalog automatically.
39
+ */
40
+ function catalogManagedDenyEntries() {
41
+ const out = new Set();
42
+ for (const p of catalog_1.CATALOG.practices) {
43
+ for (const a of p.artifacts) {
44
+ if (a.kind === 'settingsJson') {
45
+ for (const d of (0, materialize_1.denyEntriesOf)(a))
46
+ out.add(d);
47
+ }
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ /**
53
+ * Every .gitignore line the catalog's gitignore artifacts contribute — the set
54
+ * materialize.ts appends at any hook level. Off-board removes exactly these.
55
+ */
56
+ function catalogManagedGitignoreLines() {
57
+ const out = [];
58
+ for (const p of catalog_1.CATALOG.practices) {
59
+ for (const a of p.artifacts) {
60
+ if (a.kind === 'gitignore')
61
+ out.push(...a.lines);
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ const log = (m) => process.stdout.write(m + '\n');
67
+ const abs = (top, rel) => node_path_1.default.join(top, rel);
68
+ const jsonOut = (obj) => JSON.stringify(obj, null, 2) + '\n';
69
+ /** Delete a file/dir that is unambiguously convene's (e.g. `.convene/`, dedicated rule files). */
70
+ function delPath(top, rel, touched, dryRun) {
71
+ const p = abs(top, rel);
72
+ if (!node_fs_1.default.existsSync(p))
73
+ return;
74
+ touched.push({ rel, action: 'delete' });
75
+ if (!dryRun)
76
+ node_fs_1.default.rmSync(p, { recursive: true, force: true });
77
+ }
78
+ /**
79
+ * A CONVENE_PROTOCOL.md that init generated, recognized by its stable opening rather
80
+ * than byte-equality — byte-equality breaks across CLI versions AND across the
81
+ * convene.stateful.world → dev.convene.live baseURL migration (the URL is baked into
82
+ * the doc). A doc whose intro was hand-replaced (e.g. the Convene repo's own) is preserved.
83
+ */
84
+ function isGeneratedProtocolDoc(content) {
85
+ return (content.startsWith('# Convene Protocol\n') &&
86
+ content.includes('This repository participates in **Convene**, a hosted, multi-tenant'));
87
+ }
88
+ function handleProtocolDoc(top, touched, dryRun) {
89
+ const rel = 'CONVENE_PROTOCOL.md';
90
+ const p = abs(top, rel);
91
+ if (!node_fs_1.default.existsSync(p))
92
+ return;
93
+ if (!isGeneratedProtocolDoc(node_fs_1.default.readFileSync(p, 'utf8'))) {
94
+ log(`· ${rel} (left as-is — hand-authored; remove manually if you meant to)`);
95
+ return;
96
+ }
97
+ touched.push({ rel, action: 'delete' });
98
+ if (!dryRun)
99
+ node_fs_1.default.rmSync(p, { force: true });
100
+ }
101
+ /** Delete a file ONLY if it is byte-identical to what init wrote (else it was hand-edited — leave it). */
102
+ function delIfOurs(top, rel, expected, touched, dryRun) {
103
+ const p = abs(top, rel);
104
+ if (!node_fs_1.default.existsSync(p))
105
+ return;
106
+ let content;
107
+ try {
108
+ content = node_fs_1.default.readFileSync(p, 'utf8');
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ if (content !== expected) {
114
+ log(`· ${rel} (left as-is — edited since onboarding; remove manually if you meant to)`);
115
+ return;
116
+ }
117
+ touched.push({ rel, action: 'delete' });
118
+ if (!dryRun)
119
+ node_fs_1.default.rmSync(p, { force: true });
120
+ }
121
+ /**
122
+ * Strip BOTH convene-managed regions from a Markdown doc — the main coordination
123
+ * block AND the Phase-2 best-practices ref region (`<!-- convene:practices:begin -->`
124
+ * …) — in ONE read/write so the file is reported (and staged) at most once. Deletes
125
+ * the file if nothing else remains. The dedicated `.convene/best-practices.md` +
126
+ * `.convene/practices/` docs are removed wholesale by `delPath('.convene')`.
127
+ */
128
+ function stripMarker(top, rel, touched, dryRun) {
129
+ const p = abs(top, rel);
130
+ if (!node_fs_1.default.existsSync(p))
131
+ return;
132
+ const orig = node_fs_1.default.readFileSync(p, 'utf8');
133
+ const block = (0, init_1.removeMarkerBlock)(orig);
134
+ const ref = (0, materialize_1.removeRefRegion)(block.content);
135
+ if (!block.removed && !ref.removed)
136
+ return;
137
+ applyStripOrDelete(top, rel, ref.content, touched, dryRun);
138
+ }
139
+ /** Strip the convene block from a Codex config.toml; delete if nothing else remains. */
140
+ function stripToml(top, rel, touched, dryRun) {
141
+ const p = abs(top, rel);
142
+ if (!node_fs_1.default.existsSync(p))
143
+ return;
144
+ const { content, removed } = (0, init_1.removeTomlBlock)(node_fs_1.default.readFileSync(p, 'utf8'));
145
+ if (!removed)
146
+ return;
147
+ applyStripOrDelete(top, rel, content, touched, dryRun);
148
+ }
149
+ function applyStripOrDelete(top, rel, content, touched, dryRun) {
150
+ const p = abs(top, rel);
151
+ if (content.trim().length === 0) {
152
+ touched.push({ rel, action: 'delete' });
153
+ if (!dryRun)
154
+ node_fs_1.default.rmSync(p, { force: true });
155
+ }
156
+ else {
157
+ touched.push({ rel, action: 'strip' });
158
+ if (!dryRun)
159
+ node_fs_1.default.writeFileSync(p, content);
160
+ }
161
+ }
162
+ /** Remove the `convene` MCP server from a JSON config (Cursor `mcpServers`, VS Code `servers`). */
163
+ function stripJsonServer(top, rel, topKey, touched, dryRun) {
164
+ const p = abs(top, rel);
165
+ if (!node_fs_1.default.existsSync(p))
166
+ return;
167
+ let obj;
168
+ try {
169
+ obj = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8')) || {};
170
+ }
171
+ catch {
172
+ log(`· ${rel} (left as-is — unparseable JSON)`);
173
+ return;
174
+ }
175
+ if (!obj[topKey] || !obj[topKey].convene)
176
+ return;
177
+ delete obj[topKey].convene;
178
+ if (Object.keys(obj[topKey]).length === 0)
179
+ delete obj[topKey];
180
+ finalizeJson(top, rel, obj, touched, dryRun);
181
+ }
182
+ /** Gemini settings carry BOTH the convene MCP server and the AGENTS.md context entry. */
183
+ function stripGemini(top, rel, touched, dryRun) {
184
+ const p = abs(top, rel);
185
+ if (!node_fs_1.default.existsSync(p))
186
+ return;
187
+ let obj;
188
+ try {
189
+ obj = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8')) || {};
190
+ }
191
+ catch {
192
+ log(`· ${rel} (left as-is — unparseable JSON)`);
193
+ return;
194
+ }
195
+ let changed = false;
196
+ if (obj.mcpServers && obj.mcpServers.convene) {
197
+ delete obj.mcpServers.convene;
198
+ if (Object.keys(obj.mcpServers).length === 0)
199
+ delete obj.mcpServers;
200
+ changed = true;
201
+ }
202
+ if (obj.context && obj.context.fileName) {
203
+ const names = Array.isArray(obj.context.fileName) ? obj.context.fileName : [obj.context.fileName];
204
+ const filtered = names.filter((n) => n !== 'AGENTS.md');
205
+ if (filtered.length !== names.length) {
206
+ changed = true;
207
+ if (filtered.length === 0) {
208
+ delete obj.context.fileName;
209
+ if (Object.keys(obj.context).length === 0)
210
+ delete obj.context;
211
+ }
212
+ else {
213
+ obj.context.fileName = filtered;
214
+ }
215
+ }
216
+ }
217
+ if (!changed)
218
+ return;
219
+ finalizeJson(top, rel, obj, touched, dryRun);
220
+ }
221
+ function finalizeJson(top, rel, obj, touched, dryRun) {
222
+ const p = abs(top, rel);
223
+ if (Object.keys(obj).length === 0) {
224
+ touched.push({ rel, action: 'delete' });
225
+ if (!dryRun)
226
+ node_fs_1.default.rmSync(p, { force: true });
227
+ }
228
+ else {
229
+ touched.push({ rel, action: 'strip' });
230
+ if (!dryRun)
231
+ node_fs_1.default.writeFileSync(p, jsonOut(obj));
232
+ }
233
+ }
234
+ /**
235
+ * Strip convene-managed content from the committed .claude/settings.json — both the
236
+ * convene-authored hooks (withoutConveneHooks, which also matches the materialized
237
+ * `convene practice-guard …` PreToolUse/Stop hooks) AND the Phase-3 permissions.deny
238
+ * entries materialize.ts merged in (subtract EXACTLY the catalog's managed set;
239
+ * drop permissions.deny if it empties, then permissions if it empties). A teammate's
240
+ * own hooks / deny rules / settings are preserved. Delete the file if nothing remains.
241
+ */
242
+ function stripClaudeSettings(top, rel, touched, dryRun) {
243
+ const p = abs(top, rel);
244
+ if (!node_fs_1.default.existsSync(p))
245
+ return;
246
+ let obj;
247
+ try {
248
+ obj = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8'));
249
+ }
250
+ catch {
251
+ log(`· ${rel} (left as-is — unparseable JSON)`);
252
+ return;
253
+ }
254
+ const stripped = (0, hook_1.withoutConveneHooks)(obj);
255
+ let removed = stripped.removed;
256
+ const settings = stripped.settings;
257
+ // Subtract the convene-managed permissions.deny entries (only ours).
258
+ const managedDeny = catalogManagedDenyEntries();
259
+ const deny = settings?.permissions?.deny;
260
+ if (Array.isArray(deny) && managedDeny.size) {
261
+ const kept = deny.filter((d) => !(typeof d === 'string' && managedDeny.has(d)));
262
+ if (kept.length !== deny.length) {
263
+ removed = true;
264
+ if (kept.length > 0) {
265
+ settings.permissions.deny = kept;
266
+ }
267
+ else {
268
+ delete settings.permissions.deny;
269
+ if (settings.permissions && Object.keys(settings.permissions).length === 0)
270
+ delete settings.permissions;
271
+ }
272
+ }
273
+ }
274
+ if (!removed)
275
+ return;
276
+ if ((0, hook_1.settingsIsEmpty)(settings)) {
277
+ touched.push({ rel, action: 'delete' });
278
+ if (!dryRun)
279
+ node_fs_1.default.rmSync(p, { force: true });
280
+ }
281
+ else {
282
+ touched.push({ rel, action: 'strip' });
283
+ if (!dryRun)
284
+ node_fs_1.default.writeFileSync(p, jsonOut(settings));
285
+ }
286
+ }
287
+ /** Remove the committed git pre-push hook + relinquish core.hooksPath (only if ours). */
288
+ function handleGitHook(top, touched, dryRun) {
289
+ const rel = '.githooks/pre-push';
290
+ const p = abs(top, rel);
291
+ if (!node_fs_1.default.existsSync(p))
292
+ return;
293
+ if (!/convene:githook/.test(node_fs_1.default.readFileSync(p, 'utf8'))) {
294
+ log('· .githooks/pre-push (left as-is — not a convene hook)');
295
+ return;
296
+ }
297
+ if (dryRun) {
298
+ touched.push({ rel, action: 'delete' });
299
+ return;
300
+ }
301
+ const r = (0, githook_1.uninstallGitHooks)(top);
302
+ if (r.status === 'removed')
303
+ touched.push({ rel, action: 'delete' });
304
+ else if (r.status === 'skipped-foreign')
305
+ log('· .githooks/pre-push (left as-is — foreign hook)');
306
+ }
307
+ /**
308
+ * Reverse BOTH .gitignore footprints: init's cache guard (removeGitignoreGuard, which
309
+ * also re-enables any blanket rule init disabled) AND the Phase-3 practices lines +
310
+ * managed marker materialize.ts appended (removeGitignoreLines). Done in one
311
+ * read/write so the file is reported/staged at most once; deletes the file if empty.
312
+ */
313
+ function handleGitignore(top, touched, dryRun) {
314
+ const rel = '.gitignore';
315
+ const p = abs(top, rel);
316
+ if (!node_fs_1.default.existsSync(p))
317
+ return;
318
+ const old = node_fs_1.default.readFileSync(p, 'utf8');
319
+ const practiceLines = catalogManagedGitignoreLines();
320
+ const practicesProbe = (0, materialize_1.removeGitignoreLines)(old, practiceLines);
321
+ const willChangeGuard = old.includes('.convene/cache/') ||
322
+ old.includes('.convene/*.local.json') ||
323
+ old.includes('# convene (keep local cache') ||
324
+ /\(disabled by convene init/.test(old);
325
+ if (!willChangeGuard && !practicesProbe.removed)
326
+ return;
327
+ if (dryRun) {
328
+ touched.push({ rel, action: 'strip' });
329
+ return;
330
+ }
331
+ // 1. Strip the Phase-3 practices lines first (write-back so removeGitignoreGuard
332
+ // operates on the practices-free body).
333
+ if (practicesProbe.removed) {
334
+ if (practicesProbe.content.trim().length === 0)
335
+ node_fs_1.default.rmSync(p, { force: true });
336
+ else
337
+ node_fs_1.default.writeFileSync(p, practicesProbe.content);
338
+ }
339
+ // 2. Then reverse init's cache guard (no-op if the file was already deleted).
340
+ const guardChanged = node_fs_1.default.existsSync(p) ? (0, init_1.removeGitignoreGuard)(top) : false;
341
+ if (practicesProbe.removed || guardChanged) {
342
+ touched.push({ rel, action: node_fs_1.default.existsSync(p) ? 'strip' : 'delete' });
343
+ }
344
+ }
345
+ /** Drop now-empty convene dirs. Order matters: nested dirs (.cursor/rules) before parents. */
346
+ function cleanupEmptyDirs(top) {
347
+ for (const d of ['.cursor/rules', '.cursor', '.clinerules', '.gemini', '.codex', '.vscode', '.claude', '.githooks']) {
348
+ const p = abs(top, d);
349
+ try {
350
+ if (node_fs_1.default.existsSync(p) && node_fs_1.default.readdirSync(p).length === 0)
351
+ node_fs_1.default.rmdirSync(p);
352
+ }
353
+ catch {
354
+ /* not empty / already gone */
355
+ }
356
+ }
357
+ }
358
+ /** Remove the per-machine `convene fetch` hook from ~/.claude/settings.json (keeps a .bak). */
359
+ function removeGlobalHook(dryRun) {
360
+ if (!node_fs_1.default.existsSync(hook_1.SETTINGS_PATH))
361
+ return;
362
+ let raw;
363
+ try {
364
+ raw = node_fs_1.default.readFileSync(hook_1.SETTINGS_PATH, 'utf8');
365
+ }
366
+ catch {
367
+ return;
368
+ }
369
+ let obj;
370
+ try {
371
+ obj = JSON.parse(raw);
372
+ }
373
+ catch {
374
+ log('· ~/.claude/settings.json (left as-is — unparseable)');
375
+ return;
376
+ }
377
+ const { settings, removed } = (0, hook_1.withoutConveneHooks)(obj);
378
+ if (!removed)
379
+ return;
380
+ if (dryRun) {
381
+ log('· would remove the global `convene fetch` hook from ~/.claude/settings.json');
382
+ return;
383
+ }
384
+ try {
385
+ node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH + '.bak', raw);
386
+ if ((0, hook_1.settingsIsEmpty)(settings))
387
+ node_fs_1.default.rmSync(hook_1.SETTINGS_PATH, { force: true });
388
+ else
389
+ node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH, jsonOut(settings));
390
+ log('✓ removed the global `convene fetch` hook (backup: ~/.claude/settings.json.bak)');
391
+ }
392
+ catch {
393
+ log('· could not update ~/.claude/settings.json — remove the `convene fetch` hook manually.');
394
+ }
395
+ }
396
+ /** Remove the seeded memory file + its MEMORY.md index line (best-effort). */
397
+ function removeSeededMemory(top, slug, baseUrl, dryRun) {
398
+ try {
399
+ const mangled = top.replace(/\//g, '-');
400
+ const memDir = node_path_1.default.join((0, config_1.homeBase)(), '.claude', 'projects', mangled, 'memory');
401
+ const { name, indexLine } = (0, protocol_1.memoryEntry)(slug, baseUrl);
402
+ const memFile = node_path_1.default.join(memDir, `${name}.md`);
403
+ const indexFile = node_path_1.default.join(memDir, 'MEMORY.md');
404
+ if (node_fs_1.default.existsSync(memFile)) {
405
+ if (dryRun)
406
+ log(`· would remove seeded memory ${name}.md`);
407
+ else
408
+ node_fs_1.default.rmSync(memFile, { force: true });
409
+ }
410
+ if (!dryRun && node_fs_1.default.existsSync(indexFile)) {
411
+ const idx = node_fs_1.default.readFileSync(indexFile, 'utf8');
412
+ if (idx.includes(indexLine)) {
413
+ const next = idx
414
+ .split('\n')
415
+ .filter((l) => l.trim() !== indexLine.trim())
416
+ .join('\n');
417
+ node_fs_1.default.writeFileSync(indexFile, next);
418
+ }
419
+ }
420
+ }
421
+ catch {
422
+ /* memory cleanup is best-effort */
423
+ }
424
+ }
425
+ async function offboard(opts) {
426
+ const top = (0, git_1.gitToplevel)();
427
+ if (!top)
428
+ (0, ctx_1.die)('not a git repository — run `convene off-board` inside a repo');
429
+ const proj = (0, config_1.loadProjectConfig)(top);
430
+ if (!proj?.slug) {
431
+ log('This repo is not on Convene — nothing to off-board.');
432
+ return;
433
+ }
434
+ const dryRun = !!opts.dryRun;
435
+ // Consent gate: off-board is destructive. A human at a TTY confirms by running it;
436
+ // an agent / CI (no TTY) must pass `--yes`. A dry-run changes nothing, so it's exempt.
437
+ if (!opts.yes && !dryRun && !process.stdout.isTTY) {
438
+ (0, ctx_1.die)('refusing to off-board non-interactively without confirmation — re-run with `--yes` to confirm removing Convene from THIS repo.');
439
+ }
440
+ const cfg = (0, config_1.resolveConfig)();
441
+ const baseUrl = cfg.baseUrl;
442
+ const slug = proj.slug;
443
+ // 1. Optional server-side token revoke FIRST (before deleting local files, so a
444
+ // failure leaves the repo recoverable). Owner-only — warn (don't fail) on 403.
445
+ if (opts.revokeToken && proj.joinToken) {
446
+ if (dryRun) {
447
+ log('· would revoke the committed join token server-side (--revoke-token).');
448
+ }
449
+ else if (!cfg.apiKey) {
450
+ log('⚠ --revoke-token: not logged in; skipping the server-side revoke.');
451
+ }
452
+ else {
453
+ const api = new api_1.ConveneApi(baseUrl, cfg.apiKey, cfg.member ? (0, git_1.sessionId)(cfg.member, top) : null, cfg.tool);
454
+ const r = await api.revokeJoinToken(slug, proj.joinToken.slice(0, 14), 8000);
455
+ log(r.ok
456
+ ? '✓ revoked the committed join token server-side.'
457
+ : `⚠ could not revoke the join token (${r.error}) — it is owner-only; revoke from the dashboard if needed.`);
458
+ }
459
+ }
460
+ // 2. Local footprint removal.
461
+ const touched = [];
462
+ stripMarker(top, 'CLAUDE.md', touched, dryRun);
463
+ stripMarker(top, 'AGENTS.md', touched, dryRun);
464
+ handleProtocolDoc(top, touched, dryRun);
465
+ delIfOurs(top, '.aider.conf.yml', init_1.AIDER_CONF, touched, dryRun);
466
+ delPath(top, '.convene', touched, dryRun);
467
+ delPath(top, '.cursor/rules/convene.mdc', touched, dryRun);
468
+ delPath(top, '.clinerules/convene.md', touched, dryRun);
469
+ stripClaudeSettings(top, '.claude/settings.json', touched, dryRun);
470
+ stripToml(top, '.codex/config.toml', touched, dryRun);
471
+ stripJsonServer(top, '.cursor/mcp.json', 'mcpServers', touched, dryRun);
472
+ stripJsonServer(top, '.vscode/mcp.json', 'servers', touched, dryRun);
473
+ stripGemini(top, '.gemini/settings.json', touched, dryRun);
474
+ handleGitHook(top, touched, dryRun);
475
+ handleGitignore(top, touched, dryRun);
476
+ if (!dryRun)
477
+ cleanupEmptyDirs(top);
478
+ // 3. Repo-specific seeded memory is always cleaned (it's keyed to THIS repo's path).
479
+ // The machine-wide ~/.claude `convene fetch` hook is SHARED by every Convene repo
480
+ // on this machine, so off-boarding ONE repo must NOT remove it by default — that
481
+ // would silently stop auto-injection in your other Convene repos. Remove it only
482
+ // with --remove-global. NEVER touch ~/.convene/config.json (the machine identity).
483
+ removeSeededMemory(top, slug, baseUrl, dryRun);
484
+ if (opts.removeGlobal)
485
+ removeGlobalHook(dryRun);
486
+ // 4. Report + isolated commit.
487
+ if (touched.length === 0) {
488
+ log(`Convene files already absent — repo "${slug}" is clean.`);
489
+ return;
490
+ }
491
+ log('');
492
+ log(`${dryRun ? 'Would remove' : 'Removed'} Convene from "${slug}":`);
493
+ for (const t of touched)
494
+ log(` ${t.action === 'delete' ? '✗ delete' : '~ strip '} ${t.rel}`);
495
+ if (dryRun) {
496
+ log('');
497
+ log('(dry-run — nothing was changed.)');
498
+ return;
499
+ }
500
+ const doCommit = opts.commit !== false; // default on; --no-commit disables
501
+ if (doCommit) {
502
+ // Stage ONLY the tracked convene paths (deletions + strips), never `git add -A`,
503
+ // so the off-board lands as one isolated commit and can't sweep in other work.
504
+ const repoPaths = touched.map((t) => t.rel).filter((rel) => (0, git_1.pathIsTracked)(rel, top));
505
+ if (repoPaths.length && (0, git_1.gitAddPaths)(repoPaths, top) && (0, git_1.hasStagedChanges)(top)) {
506
+ const res = (0, git_1.gitCommit)('Off-board from Convene coordination bus', repoPaths, top);
507
+ if (res.ok)
508
+ log(`✓ committed the removal as one isolated commit${res.sha ? ` (${res.sha})` : ''}.`);
509
+ else
510
+ log('· could not create the off-board commit — commit the changes manually.');
511
+ }
512
+ else {
513
+ log('· nothing to commit (the convene files were untracked) — they are removed from the working tree.');
514
+ }
515
+ }
516
+ else {
517
+ log('· skipped commit (--no-commit) — review with `git status` and commit when ready.');
518
+ }
519
+ log('');
520
+ log('Off-boarded. The per-machine identity in ~/.convene/config.json was left intact (it is shared');
521
+ log('across repos).');
522
+ if (!opts.removeGlobal) {
523
+ log('The shared `convene fetch` hook was kept so your OTHER Convene repos keep working — re-run');
524
+ log('with `--remove-global` if this was your last Convene repo and you want the machine fully clean.');
525
+ }
526
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.override = override;
4
+ /**
5
+ * `convene override <id> --reason "<why>"` (Phase 3) — the attributed, short-lived
6
+ * bypass for a best-practice gate. Writes a LOCAL, expiry-based override token that
7
+ * `convene practice-guard <id>` honors → ALLOW, and best-effort posts an attributed
8
+ * [STATUS] to the bus so the bypass is auditable in real time.
9
+ *
10
+ * POSTURE:
11
+ * - DIE-LOUD on a missing/empty --reason (an unattributed override is the whole
12
+ * thing we are guarding against) and on a missing project.
13
+ * - FAIL-OPEN on the BUS: the local token is the source of truth for the gate;
14
+ * if the bus is unreachable we STILL write the token and WARN — an override must
15
+ * never be blocked by a flaky network. The status post is attribution, not
16
+ * authorization.
17
+ */
18
+ const config_1 = require("../config");
19
+ const git_1 = require("../git");
20
+ const api_1 = require("../api");
21
+ const cache_1 = require("../cache");
22
+ const ctx_1 = require("../ctx");
23
+ const NET_TIMEOUT_MS = 2500; // explicit short timeout — never the api.ts 10s default
24
+ function fmtTtl(ms) {
25
+ const sec = Math.round(ms / 1000);
26
+ if (sec < 60)
27
+ return `${sec}s`;
28
+ const m = Math.round(sec / 60);
29
+ return m === 1 ? '1 minute' : `${m} minutes`;
30
+ }
31
+ async function override(id, opts = {}) {
32
+ if (!id || !id.trim())
33
+ (0, ctx_1.die)('override requires a practice <id> (e.g. `convene override protect-shared-files --reason "…"`)');
34
+ const reason = (opts.reason ?? '').trim();
35
+ if (!reason)
36
+ (0, ctx_1.die)('override requires --reason "<why>" — an unattributed override defeats the gate');
37
+ const top = (0, git_1.gitToplevel)();
38
+ const proj = (0, config_1.loadProjectConfig)(top);
39
+ const slug = opts.project || proj?.slug || null;
40
+ if (!slug)
41
+ (0, ctx_1.die)('no project — run inside a `convene init`-ed repo, or pass --project <slug>');
42
+ // 1) Write the LOCAL token first — this is what the gate honors. Source of truth.
43
+ const tok = (0, cache_1.writeOverrideToken)(slug, id, reason);
44
+ // 2) Best-effort attributed [STATUS] to the bus. FAIL-OPEN: a bus failure warns
45
+ // but never blocks — the local token already stands.
46
+ const cfg = (0, config_1.resolveConfig)();
47
+ const member = cfg.member;
48
+ const session = member && top ? (0, git_1.sessionId)(member, top) : null;
49
+ let posted = false;
50
+ if (cfg.apiKey && session) {
51
+ try {
52
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
53
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
54
+ const res = await api.post(slug, { type: 'status', body: `practice ${id} gate overridden — ${reason}` }, (0, ctx_1.uuid)(), NET_TIMEOUT_MS);
55
+ posted = !!res.ok;
56
+ }
57
+ catch {
58
+ posted = false;
59
+ }
60
+ }
61
+ process.stdout.write(`override active for ${id} (${fmtTtl(cache_1.OVERRIDE_TTL_MS)}) — the next edits gated by this practice will be allowed.\n`);
62
+ if (!posted) {
63
+ process.stderr.write('convene: WARNING bus unreachable — override applied locally but NOT announced to the channel.\n');
64
+ }
65
+ }