convene-cli 1.8.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.
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.filterFeedback = filterFeedback;
4
+ exports.feedback = feedback;
5
+ /**
6
+ * `convene feedback [id]` — the READ counterpart to `convene suggest`. Lists the
7
+ * feature_feedback (feature requests / bugs / notes) filed for this project, or
8
+ * shows one item by short_id/id.
9
+ *
10
+ * FAIL-OPEN, like `convene lanes`: reading your own backlog must NEVER break a
11
+ * workflow. Any read failure prints a short stderr note and returns (exit 0) —
12
+ * it never dies/throws. (Contrast `convene inbox`, which is die-loud.)
13
+ *
14
+ * The server endpoint already exists (GET /projects/:slug/feedback, member-gated,
15
+ * returns `{ items, role }`); this command is CLI-only. Filters (--status, --mine,
16
+ * by-id) are applied CLIENT-SIDE over the returned items. The item `body` is
17
+ * UNTRUSTED member free-text and only ever reaches the terminal through the
18
+ * inertToken-sanitizing renderers in render.ts — never inlined here.
19
+ */
20
+ const ctx_1 = require("../ctx");
21
+ const render_1 = require("../render");
22
+ const FEEDBACK_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the 10s default
23
+ /** Apply the client-side filters in a pure, testable way. */
24
+ function filterFeedback(items, opts) {
25
+ let out = items;
26
+ if (opts.id) {
27
+ out = out.filter((f) => f.short_id === opts.id || f.id === opts.id);
28
+ }
29
+ if (opts.status) {
30
+ const want = opts.status.toLowerCase();
31
+ out = out.filter((f) => (f.lifecycle || '').toLowerCase() === want);
32
+ }
33
+ if (opts.mine && opts.member) {
34
+ out = out.filter((f) => f.source_member === opts.member);
35
+ }
36
+ return out;
37
+ }
38
+ async function feedback(id, opts) {
39
+ try {
40
+ const ctx = (0, ctx_1.getContext)({ project: opts.project });
41
+ const slug = (0, ctx_1.requireSlug)(ctx);
42
+ const res = await ctx.api.listFeedback(slug, {}, FEEDBACK_TIMEOUT_MS);
43
+ if (!res.ok || !res.json) {
44
+ // Fail-open: a backlog read failure is informational, never blocking.
45
+ process.stderr.write(`convene: feedback UNVERIFIED — could not reach the bus (${res.error ?? res.status})\n`);
46
+ return;
47
+ }
48
+ const all = Array.isArray(res.json.items) ? res.json.items : [];
49
+ const items = filterFeedback(all, {
50
+ id,
51
+ status: opts.status,
52
+ mine: opts.mine,
53
+ member: ctx.member,
54
+ });
55
+ if (opts.json) {
56
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
57
+ return;
58
+ }
59
+ // Single-item show.
60
+ if (id) {
61
+ if (items.length === 0) {
62
+ process.stdout.write(`no feedback matching "${id}" in ${slug}.\n`);
63
+ return;
64
+ }
65
+ for (const f of items)
66
+ process.stdout.write((0, render_1.feedbackDetail)(f) + '\n');
67
+ return;
68
+ }
69
+ // List view.
70
+ if (items.length === 0) {
71
+ if (opts.status || opts.mine) {
72
+ process.stdout.write(`no feedback matches that filter in ${slug}.\n`);
73
+ }
74
+ else {
75
+ process.stdout.write(`no feedback filed yet — file one with \`convene suggest "<text>"\`.\n`);
76
+ }
77
+ return;
78
+ }
79
+ process.stdout.write(`${items.length} feedback item(s) for ${slug}:\n`);
80
+ for (const f of items)
81
+ process.stdout.write((0, render_1.feedbackLine)(f) + '\n');
82
+ }
83
+ catch (err) {
84
+ // Defensive: the command must never throw out of fail-open.
85
+ process.stderr.write(`convene: feedback UNVERIFIED — ${err?.message ?? 'unknown error'}\n`);
86
+ return;
87
+ }
88
+ }
@@ -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;