convene-cli 1.9.0 → 1.10.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.
@@ -20,10 +20,12 @@ exports.runFetch = runFetch;
20
20
  * stale cache and renders DEGRADED (loud-but-non-blocking).
21
21
  */
22
22
  const node_fs_1 = __importDefault(require("node:fs"));
23
+ const node_child_process_1 = require("node:child_process");
23
24
  const git_1 = require("../git");
24
25
  const config_1 = require("../config");
25
26
  const cache_1 = require("../cache");
26
27
  const manifest_1 = require("../catalog/manifest");
28
+ const binding_local_1 = require("../binding-local");
27
29
  const api_1 = require("../api");
28
30
  const render_1 = require("../render");
29
31
  const catchup_1 = require("./catchup");
@@ -145,6 +147,33 @@ function codexCwdFromStdin() {
145
147
  function toRenderMessages(arr) {
146
148
  return Array.isArray(arr) ? arr : [];
147
149
  }
150
+ /**
151
+ * Front-of-session auto-announce: the FIRST authenticated `fetch` of a session
152
+ * spawns a DETACHED, fire-and-forget `convene announce` so the bus learns who is
153
+ * working on what before any push — the cross-tool keystone (this path runs for
154
+ * Claude Code AND Codex's `fetch --codex-hook`). Detached + unref'd (mirrors
155
+ * session-start's launchWatch) so the prompt hot path is NEVER slowed by a network
156
+ * post. Gated by the cheap local (instance, branch) sentinel so we don't spawn a
157
+ * no-op process every prompt; the announce command re-checks it and carries the
158
+ * authoritative server idempotency-key, so a same-instant double-spawn is harmless.
159
+ * Best-effort: any failure only narrows cross-session visibility, never blocks.
160
+ */
161
+ function maybeAnnounce(slug, top) {
162
+ try {
163
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
164
+ const branch = (0, git_1.currentBranch)(top) ?? 'detached';
165
+ if ((0, cache_1.announceAlreadyPosted)(slug, instance, branch))
166
+ return; // already announced this (instance, branch)
167
+ const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'announce'], {
168
+ detached: true,
169
+ stdio: 'ignore',
170
+ });
171
+ child.unref();
172
+ }
173
+ catch {
174
+ /* fail-open */
175
+ }
176
+ }
148
177
  async function runFetch(opts = {}) {
149
178
  // Absolute watchdog: under any circumstance, do not hold the prompt past 6s.
150
179
  // unref'd so the timer itself is never a live libuv handle at teardown; it still
@@ -173,7 +202,7 @@ async function runFetch(opts = {}) {
173
202
  if (!proj?.slug)
174
203
  return done(0); // repo not on the bus → silent no-op
175
204
  const slug = proj.slug;
176
- const cfg = (0, config_1.resolveConfig)();
205
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
177
206
  const lookback = opts.lookback ?? 60;
178
207
  const max = opts.max ?? 20;
179
208
  const member = cfg.member;
@@ -191,6 +220,9 @@ async function runFetch(opts = {}) {
191
220
  }), opts.codexHook);
192
221
  return done(0);
193
222
  }
223
+ // Authenticated + on the bus: spawn the front-of-session announce (detached,
224
+ // once per session/branch). Fires regardless of which render path runs below.
225
+ maybeAnnounce(slug, top);
194
226
  // `--since-last`: render the catch-up digest since the read cursor instead
195
227
  // of the time-windowed feed. Read-only (no advance), fail-open. Suppressed if
196
228
  // SessionStart already surfaced a catch-up this boot (per-instance sentinel).
@@ -218,6 +250,9 @@ async function runFetch(opts = {}) {
218
250
  const nudge = catalogBehindNudge(top);
219
251
  if (nudge)
220
252
  process.stdout.write(nudge + '\n');
253
+ const drift = (0, binding_local_1.localBindingDrift)(top, proj);
254
+ if (drift)
255
+ process.stdout.write(drift + '\n');
221
256
  };
222
257
  // Cache short-circuit for rapid successive prompts.
223
258
  const cache = (0, cache_1.readCache)(slug);
@@ -137,7 +137,7 @@ async function run(opts) {
137
137
  const slug = opts.project || proj?.slug || null;
138
138
  if (!slug)
139
139
  return 0; // repo not on the bus → no-op (zero network)
140
- const cfg = (0, config_1.resolveConfig)();
140
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
141
141
  const member = cfg.member;
142
142
  const session = member ? (0, git_1.sessionId)(member, top) : null;
143
143
  // Determine the ref(s) being pushed.
@@ -205,7 +205,7 @@ async function run(opts) {
205
205
  const slug = opts.project || proj?.slug || null;
206
206
  if (!slug)
207
207
  return 0; // not on the bus → no-op (covers BOTH match + non-match)
208
- const cfg = (0, config_1.resolveConfig)();
208
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
209
209
  const member = cfg.member;
210
210
  const session = member ? (0, git_1.sessionId)(member, top) : null;
211
211
  if (!cfg.apiKey || !session) {
@@ -7,7 +7,11 @@ exports.AIDER_CONF = exports.CONVENE_PATHS = void 0;
7
7
  exports.upsertMarkerBlock = upsertMarkerBlock;
8
8
  exports.removeMarkerBlock = removeMarkerBlock;
9
9
  exports.removeGitignoreGuard = removeGitignoreGuard;
10
+ exports.writeAgentRules = writeAgentRules;
10
11
  exports.removeTomlBlock = removeTomlBlock;
12
+ exports.writeMcpConfigs = writeMcpConfigs;
13
+ exports.writeCoordinationBlocks = writeCoordinationBlocks;
14
+ exports.commitConveneFiles = commitConveneFiles;
11
15
  exports.refreshDocs = refreshDocs;
12
16
  exports.init = init;
13
17
  /**
@@ -275,6 +279,15 @@ const COORD_HOOKS = [
275
279
  verb: 'beat',
276
280
  note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
277
281
  },
282
+ // Stop fires at every turn-end (no tool matcher); `convene wrap` is idempotent +
283
+ // debounced via the last-broadcast-sha cursor, so it posts at most one wrap per
284
+ // stretch of new committed work and stays silent on idle turns. Never blocks.
285
+ {
286
+ event: 'Stop',
287
+ command: 'convene wrap',
288
+ verb: 'wrap',
289
+ note: 'session-end wrap status when new committed work landed (idempotent, fail-open)',
290
+ },
278
291
  ];
279
292
  /**
280
293
  * Wire the WP13 coordination hooks into a settings file (global or committed
@@ -609,7 +622,10 @@ async function refreshDocs(opts) {
609
622
  if (!existing?.slug) {
610
623
  (0, ctx_1.die)('this repo is not on Convene yet — run `convene setup` first; `--refresh-docs` only re-renders an already-onboarded repo.');
611
624
  }
612
- const cfg = (0, config_1.resolveConfig)();
625
+ // Honor a committed host pin so a pinned repo re-renders its carriers at the
626
+ // bound host, not the ambient one (resolveConfigForRepo); unpinned repos are
627
+ // unchanged (it falls back to resolveConfig).
628
+ const cfg = (0, config_1.resolveConfigForRepo)(top, existing);
613
629
  const slug = existing.slug;
614
630
  const member = cfg.member;
615
631
  const baseUrl = cfg.baseUrl;
@@ -647,10 +663,12 @@ async function init(opts) {
647
663
  (0, ctx_1.die)('refusing to onboard non-interactively without confirmation — connecting THIS repo to Convene ' +
648
664
  'is a deliberate choice, not a side-effect. If you intend it, re-run with `--yes`.');
649
665
  }
650
- const cfg = (0, config_1.resolveConfig)();
666
+ const existing = (0, config_1.loadProjectConfig)(top);
667
+ // Honor a committed host pin on a RE-RUN (a freshly-onboarding repo has none, so
668
+ // this equals resolveConfig); keeps a re-run targeting the bound bus.
669
+ const cfg = (0, config_1.resolveConfigForRepo)(top, existing);
651
670
  const baseUrl = cfg.baseUrl;
652
671
  let member = cfg.member;
653
- const existing = (0, config_1.loadProjectConfig)(top);
654
672
  const skipHook = opts.noHook === true || opts.hook === false;
655
673
  const skipGithook = opts.noGithook === true || opts.githook === false;
656
674
  const skipJoinToken = opts.noJoinToken === true || opts.joinToken === false;
@@ -760,7 +778,12 @@ async function init(opts) {
760
778
  displayName = displayName || slug;
761
779
  // 3. .convene/project.json (committed). The joinToken is a PROJECT-SCOPED
762
780
  // enrollment secret — safe to commit in a PRIVATE repo (repo-read = team).
763
- const projFile = (0, config_1.writeProjectConfig)(top, { slug, displayName, ...(joinToken ? { joinToken } : {}) });
781
+ // Preserve any sibling committed fields (bestPractices manifest, host-pin
782
+ // binding) on a re-run — strip the stale schema so writeProjectConfig
783
+ // re-derives it. For a FRESH repo `existing` is null, so this stays
784
+ // byte-identical to a plain `{ slug, displayName, joinToken }` schema-1 write.
785
+ const { schema: _schema, ...preserved } = existing ?? {};
786
+ const projFile = (0, config_1.writeProjectConfig)(top, { ...preserved, slug, displayName, ...(joinToken ? { joinToken } : {}) });
764
787
  log(`✓ ${node_path_1.default.relative(top, projFile)}${joinToken ? ' (incl. join token — private repos only)' : ''}`);
765
788
  // 4. .gitignore guard
766
789
  ensureGitignoreGuard(top);
@@ -26,7 +26,7 @@ async function join(opts) {
26
26
  const token = opts.token || process.env.CONVENE_JOIN_TOKEN || proj?.joinToken;
27
27
  if (!token)
28
28
  (0, ctx_1.die)('no join token — pass --token <cvj_…>, set CONVENE_JOIN_TOKEN, or ask an owner for one');
29
- const cfg = (0, config_1.resolveConfig)();
29
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj); // redeem against the pinned bus unless --base-url overrides
30
30
  const baseUrl = (opts.baseUrl || cfg.baseUrl).replace(/\/$/, '');
31
31
  const handle = opts.handle || (0, git_1.deriveHandle)(top ?? undefined);
32
32
  const email = opts.email || (0, git_1.gitUserEmail)(top ?? undefined) || undefined;
@@ -21,6 +21,7 @@ exports.notifyPush = notifyPush;
21
21
  const config_1 = require("../config");
22
22
  const git_1 = require("../git");
23
23
  const api_1 = require("../api");
24
+ const cache_1 = require("../cache");
24
25
  const exit_1 = require("../exit");
25
26
  const isZero = (sha) => /^0+$/.test(sha);
26
27
  /** Parse git's pre-push stdin into the non-deletion refs being pushed. */
@@ -109,10 +110,11 @@ function readStdin(timeoutMs) {
109
110
  });
110
111
  }
111
112
  async function run(opts) {
112
- const cfg = (0, config_1.resolveConfig)();
113
113
  const top = (0, git_1.gitToplevel)();
114
+ const proj = (0, config_1.loadProjectConfig)(top);
115
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj); // honor a committed host pin for the push notify
114
116
  const cwd = top || process.cwd();
115
- const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
117
+ const slug = opts.project || proj?.slug || null;
116
118
  if (!slug)
117
119
  return; // not a bus repo — silent no-op, exactly like `convene fetch`
118
120
  const stdin = await readStdin(1500);
@@ -142,8 +144,14 @@ async function run(opts) {
142
144
  const session = top ? (0, git_1.sessionId)(cfg.member, top) : `${cfg.member}/cli`;
143
145
  const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool);
144
146
  const res = await api.post(slug, { type: 'status', body }, idem, 4000);
145
- if (res.ok && res.json?.message?.short_id) {
146
- process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} ${body}\n`);
147
+ if (res.ok) {
148
+ // Advance the shared broadcast cursor to the pushed tip so a turn-end `convene
149
+ // wrap` (Stop hook) doesn't double-post a "wrapped" status for the same commit
150
+ // the bus just heard about via this push.
151
+ (0, cache_1.writeLastBroadcastSha)(slug, refs[0].localSha);
152
+ if (res.json?.message?.short_id) {
153
+ process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} — ${body}\n`);
154
+ }
147
155
  }
148
156
  }
149
157
  async function notifyPush(opts) {
@@ -466,7 +466,7 @@ async function offboard(opts) {
466
466
  if (!opts.yes && !dryRun && !process.stdout.isTTY) {
467
467
  (0, ctx_1.die)('refusing to off-board non-interactively without confirmation — re-run with `--yes` to confirm removing Convene from THIS repo.');
468
468
  }
469
- const cfg = (0, config_1.resolveConfig)();
469
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj); // revoke against the bound bus, not a stale ambient host
470
470
  const baseUrl = cfg.baseUrl;
471
471
  const slug = proj.slug;
472
472
  // 1. Optional server-side token revoke FIRST (before deleting local files, so a
@@ -486,6 +486,31 @@ async function offboard(opts) {
486
486
  : `⚠ could not revoke the join token (${r.error}) — it is owner-only; revoke from the dashboard if needed.`);
487
487
  }
488
488
  }
489
+ // 1.5 Optional owner hard-delete of the project server-side (--delete-project). This
490
+ // PERMANENTLY removes the project + ALL its messages for EVERY member (owner-only,
491
+ // typed confirm = the slug itself). Warn (don't fail) on 403/error, then continue
492
+ // with the local off-board — the reversible default is to leave the project intact.
493
+ if (opts.deleteProject) {
494
+ if (dryRun) {
495
+ log(`· would PERMANENTLY delete the project "${slug}" server-side (--delete-project).`);
496
+ }
497
+ else if (!cfg.apiKey) {
498
+ log('⚠ --delete-project: not logged in; skipping the server-side delete.');
499
+ }
500
+ else {
501
+ const api = new api_1.ConveneApi(baseUrl, cfg.apiKey, cfg.member ? (0, git_1.sessionId)(cfg.member, top) : null, cfg.tool);
502
+ const r = await api.deleteProjectOwner(slug, slug, 8000);
503
+ if (r.ok) {
504
+ log(`✓ PERMANENTLY deleted the project "${slug}" server-side (all members + messages; slug tombstoned).`);
505
+ }
506
+ else if (r.status === 403) {
507
+ log(`⚠ --delete-project: only an owner can delete "${slug}" — left it intact; off-boarding locally only.`);
508
+ }
509
+ else {
510
+ log(`⚠ --delete-project: could not delete "${slug}" (${r.error}) — left it intact; off-boarding locally only.`);
511
+ }
512
+ }
513
+ }
489
514
  // 2. Local footprint removal.
490
515
  const touched = [];
491
516
  // Read the adopted-practices manifest BEFORE any deletion — delPath('.convene')
@@ -557,4 +582,13 @@ async function offboard(opts) {
557
582
  log('The shared `convene fetch` hook was kept so your OTHER Convene repos keep working — re-run');
558
583
  log('with `--remove-global` if this was your last Convene repo and you want the machine fully clean.');
559
584
  }
585
+ // Reclaim guidance: unless the project was just deleted, off-board is LOCAL-ONLY — the
586
+ // project + your membership still exist server-side. This is the clarity that the
587
+ // orphaned-off-board incident lacked (people assumed off-board tore down the server side).
588
+ if (!opts.deleteProject) {
589
+ log('');
590
+ log(`Server-side, the project "${slug}" and your membership are untouched — off-board removed Convene`);
591
+ log('from THIS checkout only. To reconnect later: restore .convene/project.json from git history, then');
592
+ log('run `convene reclaim` (re-grants your membership via the committed token).');
593
+ }
560
594
  }
@@ -43,7 +43,7 @@ async function override(id, opts = {}) {
43
43
  const tok = (0, cache_1.writeOverrideToken)(slug, id, reason);
44
44
  // 2) Best-effort attributed [STATUS] to the bus. FAIL-OPEN: a bus failure warns
45
45
  // but never blocks — the local token already stands.
46
- const cfg = (0, config_1.resolveConfig)();
46
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
47
47
  const member = cfg.member;
48
48
  const session = member && top ? (0, git_1.sessionId)(member, top) : null;
49
49
  let posted = false;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reclaim = reclaim;
4
+ /**
5
+ * `convene reclaim` — self-recovery for an owner/member locked out of a project they
6
+ * can still READ (they hold the committed cvj_ token in .convene/project.json). It
7
+ * re-grants your own membership server-side. Like `join`, the committed token proves
8
+ * "can read the repo" and grants MEMBER only; OWNER is restored ONLY when the server
9
+ * has a recorded prior-owner row for you (your membership was soft-removed, not erased).
10
+ *
11
+ * This is the recurrence-fix for the orphaned-off-board incident: a returning owner no
12
+ * longer dead-ends on a 403, they run `convene reclaim`. If only `member` comes back,
13
+ * an owner/superadmin must promote you (the dashboard repair path).
14
+ */
15
+ const brand_1 = require("../brand");
16
+ const api_1 = require("../api");
17
+ const config_1 = require("../config");
18
+ const git_1 = require("../git");
19
+ const hook_1 = require("../hook");
20
+ const githook_1 = require("../githook");
21
+ const ctx_1 = require("../ctx");
22
+ const log = (m) => process.stdout.write(m + '\n');
23
+ async function reclaim(opts) {
24
+ const top = (0, git_1.gitToplevel)();
25
+ const proj = (0, config_1.loadProjectConfig)(top);
26
+ const slug = opts.slug || proj?.slug;
27
+ if (!slug)
28
+ (0, ctx_1.die)('no project — run inside a `convene init`-ed repo, or pass --slug <slug>');
29
+ const token = opts.token || process.env.CONVENE_JOIN_TOKEN || proj?.joinToken;
30
+ if (!token) {
31
+ (0, ctx_1.die)('no join token — reclaim needs the committed token from .convene/project.json (restore it from git history if off-board removed it), or pass --token <cvj_…>');
32
+ }
33
+ const cfg = (0, config_1.resolveConfig)();
34
+ const baseUrl = (opts.baseUrl || cfg.baseUrl).replace(/\/$/, '');
35
+ const handle = opts.handle || (0, git_1.deriveHandle)(top ?? undefined);
36
+ const email = opts.email || (0, git_1.gitUserEmail)(top ?? undefined) || undefined;
37
+ // Authenticated mode if we already have a key (re-grants the existing identity, and
38
+ // can restore owner from a prior-owner row); unauthenticated mints a fresh identity
39
+ // (member only — a new identity has no prior-owner row to match).
40
+ const api = new api_1.ConveneApi(baseUrl, cfg.apiKey ?? null);
41
+ const res = await api.reclaim(slug, { token, handle, email, display_name: handle, tool: 'cli' }, 10_000);
42
+ if (res.status === 409 && res.json?.code === 'SLUG_REBOUND') {
43
+ (0, ctx_1.die)('this token predates the current project at this slug — a different project now owns it. Ask a superadmin to repair, or re-onboard with `convene init`.');
44
+ }
45
+ if (res.status === 409)
46
+ (0, ctx_1.die)(`the handle "${handle}" is already taken — re-run with --handle <unique-handle>, or \`convene login\` with your existing key first`);
47
+ if (res.status === 401)
48
+ (0, ctx_1.die)('the committed join token is invalid, expired, or revoked — ask an owner/superadmin for a fresh one (or use the dashboard repair path).');
49
+ if (!res.ok && res.status !== 200)
50
+ (0, ctx_1.die)(`reclaim failed (${res.status}): ${res.error ?? 'unknown error'}`);
51
+ const memberHandle = res.json?.member?.handle ?? handle;
52
+ const grantedRole = res.json?.project?.role ?? 'member';
53
+ const ownerRestored = res.json?.owner_restored === true;
54
+ if (res.json?.api_key) {
55
+ (0, config_1.saveFileConfig)({ apiKey: res.json.api_key, baseUrl, member: memberHandle });
56
+ log(`Reclaimed "${slug}" as ${memberHandle} (new identity, role: ${grantedRole}). Logged in (config 0600).`);
57
+ }
58
+ else if (res.json?.already) {
59
+ log(`Already an active member of "${slug}" as ${memberHandle} (role: ${grantedRole}). Nothing to reclaim.`);
60
+ }
61
+ else {
62
+ log(`Reclaimed "${slug}" as ${memberHandle} (role: ${grantedRole}).`);
63
+ }
64
+ if (ownerRestored) {
65
+ log('✓ Owner restored — the server had a recorded prior-owner row for you.');
66
+ }
67
+ else if (grantedRole !== 'owner') {
68
+ log('You were re-granted MEMBER. If you should be an owner, ask an existing owner or a');
69
+ log('superadmin to promote you (superadmin dashboard → project → "Make owner").');
70
+ }
71
+ // Same hook re-registration tail as `join`, so the recovered checkout is fully wired.
72
+ const hook = (0, hook_1.ensureHookRegistered)();
73
+ log(hook === 'registered'
74
+ ? 'Registered the convene fetch hook in ~/.claude/settings.json.'
75
+ : hook === 'already'
76
+ ? 'Hook already registered.'
77
+ : 'Could not auto-register the hook — run `convene doctor --fix` or add it manually.');
78
+ if (top) {
79
+ const pr = (0, hook_1.ensureProjectHookRegistered)(top);
80
+ if (pr === 'registered')
81
+ log('Wrote committed project hook (.claude/settings.json) — commit it so teammates auto-connect.');
82
+ const gh = (0, githook_1.installGitHooks)(top);
83
+ if (gh.status === 'installed' || gh.status === 'updated') {
84
+ log('Installed the git pre-push hook (.githooks/pre-push) — pushes auto-post a [STATUS].');
85
+ }
86
+ }
87
+ log(`You are operational on ${brand_1.BRAND.product}. Try: convene whoami`);
88
+ }
@@ -21,10 +21,10 @@ async function rotateJoinToken(opts) {
21
21
  const top = (0, git_1.gitToplevel)();
22
22
  if (!top)
23
23
  (0, ctx_1.die)('not a git repository — run inside a repo');
24
- const cfg = (0, config_1.resolveConfig)();
24
+ const existing = (0, config_1.loadProjectConfig)(top);
25
+ const cfg = (0, config_1.resolveConfigForRepo)(top, existing); // mint/revoke against the pinned bus
25
26
  if (!cfg.apiKey)
26
27
  (0, ctx_1.die)('not logged in — run `convene login`');
27
- const existing = (0, config_1.loadProjectConfig)(top);
28
28
  const slug = opts.slug || existing?.slug;
29
29
  if (!slug)
30
30
  (0, ctx_1.die)('no project — run from a repo with .convene/project.json, or pass --slug');
@@ -39,8 +39,15 @@ async function rotateJoinToken(opts) {
39
39
  if (!minted.ok || !minted.json?.join_token)
40
40
  (0, ctx_1.die)(`could not mint a new join token: ${minted.error}`);
41
41
  const newToken = minted.json.join_token;
42
- // 2. write project.json (so we never lose a working token even if revoke fails)
42
+ // 2. write project.json (so we never lose a working token even if revoke fails).
43
+ // Preserve every OTHER committed field (bestPractices manifest, host-pin
44
+ // binding, …) — only the token changes here. Strip the stale `schema` so
45
+ // writeProjectConfig re-derives it (round-trip-safe; same discipline as
46
+ // writeManifest/writeBinding). Without this, rotating the token would silently
47
+ // un-pin the repo and drop its adopted practices.
48
+ const { schema: _schema, ...rest } = existing ?? {};
43
49
  const projFile = (0, config_1.writeProjectConfig)(top, {
50
+ ...rest,
44
51
  slug: slug,
45
52
  displayName: existing?.displayName || slug,
46
53
  joinToken: newToken,
@@ -22,6 +22,7 @@ exports.sessionStart = sessionStart;
22
22
  const node_child_process_1 = require("node:child_process");
23
23
  const git_1 = require("../git");
24
24
  const config_1 = require("../config");
25
+ const binding_local_1 = require("../binding-local");
25
26
  const cache_1 = require("../cache");
26
27
  const worktree_1 = require("./worktree");
27
28
  const api_1 = require("../api");
@@ -123,7 +124,14 @@ async function run(opts) {
123
124
  if (!proj?.slug)
124
125
  return; // not on the bus → silent no-op
125
126
  const slug = proj.slug;
126
- const cfg = (0, config_1.resolveConfig)();
127
+ // Host-pin drift banner — LOCAL ONLY, emitted BEFORE the network call so it
128
+ // survives an offline/DEGRADED boot (where the catch-up block is suppressed) and
129
+ // never adds boot latency. Fail-open: localBindingDrift returns null on no binding
130
+ // or any error, and the whole run() is inside sessionStart's try/catch + watchdog.
131
+ const drift = (0, binding_local_1.localBindingDrift)(top, proj);
132
+ if (drift)
133
+ emit(drift);
134
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
127
135
  if (!cfg.apiKey || !cfg.member)
128
136
  return; // not authenticated → silent (fail-open)
129
137
  const member = cfg.member;
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.update = update;
4
7
  exports.runUpdate = runUpdate;
@@ -24,6 +27,8 @@ exports.runUpdate = runUpdate;
24
27
  * Fail-soft throughout: offline → bundled catalog; a malformed manifest or any
25
28
  * unexpected error prints a clear note and exits non-fatally for the dry run.
26
29
  */
30
+ const node_fs_1 = __importDefault(require("node:fs"));
31
+ const node_path_1 = __importDefault(require("node:path"));
27
32
  const config_1 = require("../config");
28
33
  const git_1 = require("../git");
29
34
  const api_1 = require("../api");
@@ -31,6 +36,10 @@ const catalog_1 = require("../catalog");
31
36
  const report_1 = require("../catalog/report");
32
37
  const manifest_1 = require("../catalog/manifest");
33
38
  const materialize_1 = require("../catalog/materialize");
39
+ const init_1 = require("./init");
40
+ const hook_1 = require("../hook");
41
+ const binding_1 = require("../binding");
42
+ const version_1 = require("../version");
34
43
  const ctx_1 = require("../ctx");
35
44
  const log = (m) => process.stdout.write(m + '\n');
36
45
  /** A practice has an actual bump available to take (patch/minor/major). */
@@ -69,18 +78,198 @@ async function update(opts = {}) {
69
78
  const top = (0, git_1.gitToplevel)();
70
79
  if (!top)
71
80
  (0, ctx_1.die)('not a git repository — run `convene update` inside a repo');
81
+ // Host-pin / freshness binding paths (`--refresh` / `--host`) are INDEPENDENT of
82
+ // the best-practices catalog — they re-render the managed surfaces + (re)write the
83
+ // schema-3 binding stamp, and must work even on a repo that adopted no practices.
84
+ // Route them before the manifest gate; the default + `--apply` keep the existing
85
+ // catalog-update flow unchanged.
86
+ if (opts.refresh || opts.host) {
87
+ return runBindingRefresh(top, opts);
88
+ }
72
89
  const manifest = (0, config_1.loadManifest)(top);
73
90
  if (!manifest) {
74
91
  log('· no best practices adopted — nothing to update. Run `convene init` to choose some.');
75
92
  return;
76
93
  }
77
94
  // Live catalog (fail-soft → bundled). Build an authed client only if we have a
78
- // key; loadCatalog itself falls back on any failure, so this never blocks.
79
- const cfg = (0, config_1.resolveConfig)();
95
+ // key; loadCatalog itself falls back on any failure, so this never blocks. Honor a
96
+ // committed host pin so the catalog fetch + adoption report hit the bound bus.
97
+ const cfg = (0, config_1.resolveConfigForRepo)(top);
80
98
  const api = cfg.apiKey && cfg.member ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, (0, git_1.sessionId)(cfg.member, top), cfg.tool) : null;
81
99
  const { catalog, source } = await (0, catalog_1.loadCatalog)(api);
82
100
  await runUpdate(top, manifest, catalog, source, opts);
83
101
  }
102
+ /**
103
+ * `convene update --refresh` / `--host <url>` — the deliberate host-pin / freshness
104
+ * actions. Re-render the managed surfaces (CLAUDE.md/AGENTS.md coordination blocks,
105
+ * cross-agent rules, all four MCP carriers) at the AUTHORITATIVE host, re-wire the
106
+ * `convene fetch` hook (committed + global), recompute the committed-hook
107
+ * fingerprint, and WRITE the schema-3 binding stamp. This is the ONLY path that
108
+ * stamps a binding — `init` never does (it would break the byte-identical schema-1
109
+ * onboarding), and `doctor` never does (it is read-only w.r.t. the binding).
110
+ *
111
+ * Rails (same discipline as init/refresh-docs): `--check` is a dry run that writes
112
+ * NOTHING; without `--commit` the changes land in the working tree only (never
113
+ * `git add -A`); with `--commit` exactly the CONVENE_PATHS land as one isolated
114
+ * commit. `--host` additionally VERIFIES the target via GET /me before stamping
115
+ * (must-fix A: a typed host that the server does not self-identify as is refused),
116
+ * and moves the global config baseUrl so the CLI and the carriers cannot drift.
117
+ */
118
+ /** The four committable MCP carriers a re-point must keep in sync (see init.ts). */
119
+ const MCP_CARRIERS = ['.cursor/mcp.json', '.vscode/mcp.json', '.gemini/settings.json', '.codex/config.toml'];
120
+ /**
121
+ * After a refresh/re-point, warn about any MCP carrier whose embedded
122
+ * CONVENE_BASE_URL no longer matches the bound host — an unparseable carrier that
123
+ * was silently skipped, or a carrier deliberately not touched under --no-mcp.
124
+ * Advisory only (never throws / never fails the command); a carrier with no convene
125
+ * entry, or absent entirely, is not flagged. Matches the JSON (`"CONVENE_BASE_URL":
126
+ * "…"`) and TOML (`CONVENE_BASE_URL = "…"`) forms with one regex.
127
+ */
128
+ function warnStaleCarriers(top, host) {
129
+ const want = (0, binding_1.normalizeHost)(host);
130
+ const stale = [];
131
+ for (const rel of MCP_CARRIERS) {
132
+ let body;
133
+ try {
134
+ body = node_fs_1.default.readFileSync(node_path_1.default.join(top, rel), 'utf8');
135
+ }
136
+ catch {
137
+ continue; // carrier absent — nothing to keep in sync
138
+ }
139
+ const m = body.match(/CONVENE_BASE_URL"?\s*[:=]\s*"([^"]+)"/);
140
+ if (!m)
141
+ continue; // present but carries no convene server entry
142
+ if ((0, binding_1.normalizeHost)(m[1]) !== want)
143
+ stale.push(`${rel} → ${m[1]}`);
144
+ }
145
+ if (stale.length) {
146
+ log(`⚠ ${stale.length} MCP carrier(s) still point elsewhere — re-run \`convene update --refresh\` ` +
147
+ `(without --no-mcp) to fix: ${stale.join(', ')}`);
148
+ }
149
+ }
150
+ async function runBindingRefresh(top, opts) {
151
+ const existing = (0, config_1.loadProjectConfig)(top);
152
+ if (!existing?.slug) {
153
+ (0, ctx_1.die)('this repo is not on Convene yet — run `convene setup` first; `convene update --refresh` only re-stamps an already-onboarded repo.');
154
+ }
155
+ const slug = existing.slug;
156
+ const repoCfg = (0, config_1.resolveConfigForRepo)(top, existing);
157
+ const member = repoCfg.member;
158
+ if (!member)
159
+ (0, ctx_1.die)('not configured — run `convene login` first (a binding records who stamped it).');
160
+ // ── Resolve the target host + (best-effort / required) server confirmation ──
161
+ let host;
162
+ let serverVerified = false;
163
+ let serverHost = null;
164
+ let deploymentId;
165
+ if (opts.host) {
166
+ // --host re-point: REFUSE to stamp unless the target self-identifies as itself.
167
+ host = (0, binding_1.normalizeHost)(opts.host);
168
+ const api = new api_1.ConveneApi(host, repoCfg.apiKey, (0, git_1.sessionId)(member, top), repoCfg.tool);
169
+ const me = await api.me(8000);
170
+ if (!me.ok || !me.json) {
171
+ (0, ctx_1.die)(`cannot reach ${host} to verify it (${me.status}: ${me.error ?? 'unreachable'}) — refusing to re-point.`);
172
+ }
173
+ serverHost = me.json.canonical_host ? (0, binding_1.normalizeHost)(me.json.canonical_host) : null;
174
+ deploymentId = me.json.deployment_id;
175
+ if (serverHost) {
176
+ if (serverHost !== host) {
177
+ (0, ctx_1.die)(`refusing to pin: ${host} self-identifies as canonical host ${serverHost}, not ${host} ` +
178
+ `(it may be a front-door/redirect). Pin to ${serverHost} instead, or check the URL.`);
179
+ }
180
+ serverVerified = true;
181
+ }
182
+ else if (!opts.yes) {
183
+ (0, ctx_1.die)(`the server at ${host} does not report a canonical host (older server) — cannot verify it is the bus you intend.\n` +
184
+ ` server says: member=${me.json.member} org_id=${me.json.org_id} kind=${me.json.kind}\n` +
185
+ ` re-run with --yes to pin anyway.`);
186
+ }
187
+ else {
188
+ log(`⚠ ${host} did not self-identify (older server); pinning anyway per --yes ` +
189
+ `(member=${me.json.member} org_id=${me.json.org_id} kind=${me.json.kind}).`);
190
+ }
191
+ }
192
+ else {
193
+ // --refresh: re-stamp the host the repo already resolves to (pin if pinned, else
194
+ // ambient). Server confirmation is best-effort + NON-fatal here.
195
+ host = (0, binding_1.normalizeHost)(repoCfg.baseUrl);
196
+ if (repoCfg.apiKey) {
197
+ const api = new api_1.ConveneApi(host, repoCfg.apiKey, (0, git_1.sessionId)(member, top), repoCfg.tool);
198
+ const me = await api.me(8000);
199
+ if (me.ok && me.json) {
200
+ serverHost = me.json.canonical_host ? (0, binding_1.normalizeHost)(me.json.canonical_host) : null;
201
+ deploymentId = me.json.deployment_id;
202
+ if (serverHost && serverHost === host)
203
+ serverVerified = true;
204
+ else if (serverHost && serverHost !== host) {
205
+ log(`⚠ the server at ${host} self-identifies as ${serverHost} — stamping ${host} (what this repo talks to); \`convene doctor\` will flag the divergence.`);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ const skipAgentRules = opts.noAgentRules === true || opts.agentRules === false;
211
+ const skipMcp = opts.noMcp === true || opts.mcp === false;
212
+ if (opts.check) {
213
+ log(`Dry run — \`convene update ${opts.host ? `--host ${host}` : '--refresh'}\` would:`);
214
+ log(` • re-render CLAUDE.md + AGENTS.md coordination blocks at ${host}`);
215
+ if (!skipAgentRules)
216
+ log(' • re-render the cross-agent rule files');
217
+ if (!skipMcp)
218
+ log(` • re-render all MCP carriers with CONVENE_BASE_URL=${host}`);
219
+ log(' • ensure the committed + global `convene fetch` hook');
220
+ if (opts.host)
221
+ log(` • update ~/.convene/config.json baseUrl → ${host}`);
222
+ log(` • write the .convene/project.json binding stamp (schema 3${serverVerified ? ', server-confirmed' : ''})`);
223
+ log(opts.commit ? ' • commit exactly the convene files as one isolated commit' : ' • leave changes in the working tree (no commit without --commit)');
224
+ log('');
225
+ log('Nothing written (--check). Re-run without --check to apply.');
226
+ return;
227
+ }
228
+ log(`${opts.host ? 'Re-pointing' : 'Refreshing'} Convene binding for "${slug}" at ${host}…`);
229
+ (0, init_1.writeCoordinationBlocks)(top, slug, member, host);
230
+ if (!skipAgentRules)
231
+ (0, init_1.writeAgentRules)(top, slug, member, host);
232
+ if (!skipMcp)
233
+ (0, init_1.writeMcpConfigs)(top, host);
234
+ // Re-wire the `convene fetch` hook in BOTH the committed repo settings (in
235
+ // CONVENE_PATHS — its fingerprint is stamped) and the per-machine global settings
236
+ // (advisory; never committed). Both idempotent.
237
+ (0, hook_1.ensureProjectHookRegistered)(top);
238
+ (0, hook_1.ensureHook)('UserPromptSubmit', hook_1.HOOK_COMMAND);
239
+ // Recompute the committed-hook fingerprint AFTER wiring so the stamp matches disk.
240
+ const hookFingerprint = (0, hook_1.conveneHookFingerprint)((0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top))));
241
+ const binding = {
242
+ host,
243
+ cliVersion: (0, version_1.cliVersion)(),
244
+ hookFingerprint,
245
+ stampedAt: new Date().toISOString(),
246
+ stampedBy: member,
247
+ serverVerified,
248
+ ...(existing.bestPractices?.catalogVersion ? { catalogVersion: existing.bestPractices.catalogVersion } : {}),
249
+ ...(deploymentId && deploymentId !== 'unknown' ? { deploymentId } : {}),
250
+ };
251
+ (0, config_1.writeBinding)(top, binding);
252
+ // A --host re-point moves the global config baseUrl too, or the CLI (which reads
253
+ // file.baseUrl) and the carriers diverge. Do it LAST — after the stamp + carriers
254
+ // have all been written — so a mid-sequence failure never leaves the global config
255
+ // pointed at <new> with the repo still UNPINNED (stamp-then-move keeps the pin the
256
+ // single authority).
257
+ if (opts.host)
258
+ (0, config_1.saveFileConfig)({ baseUrl: host });
259
+ log(`✓ .convene/project.json binding stamped — host ${host}, CLI v${binding.cliVersion}${serverVerified ? ', server-confirmed' : ''}.`);
260
+ // Carrier consistency: warn (do NOT fail) about any MCP carrier still pointing
261
+ // elsewhere — covers a carrier skipped because its JSON was unparseable, and the
262
+ // --no-mcp case where carriers were deliberately not touched. No freshness check
263
+ // reads a carrier's host, so without this a stale carrier would be silent.
264
+ warnStaleCarriers(top, host);
265
+ if (opts.commit) {
266
+ (0, init_1.commitConveneFiles)(top, opts.host ? `Re-point Convene to ${host}` : 'Refresh Convene binding to current template', opts.host ? 'the re-point' : 'the binding refresh');
267
+ }
268
+ else {
269
+ log('');
270
+ log('Changes are in your working tree only. Review with `git diff` and commit yourself (or re-run with `--commit`).');
271
+ }
272
+ }
84
273
  /**
85
274
  * Core of `convene update`, with the resolved (top, manifest, catalog) already in
86
275
  * hand — the seam tests drive with a synthetic catalog (no fs/network resolution).