convene-cli 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -154,6 +154,18 @@ class ConveneApi {
154
154
  join(slug, body, timeoutMs) {
155
155
  return this.request('POST', `/projects/${encodeURIComponent(slug)}/join`, { body, timeoutMs });
156
156
  }
157
+ /** Self-recovery: re-grant your own membership via the committed join token (no bearer
158
+ * auth required). Restores OWNER only from a server-recorded prior-owner row. */
159
+ reclaim(slug, body, timeoutMs) {
160
+ return this.request('POST', `/projects/${encodeURIComponent(slug)}/reclaim`, { body, timeoutMs });
161
+ }
162
+ /** Owner: PERMANENTLY delete your own project (typed confirm_slug; owner-role-gated). */
163
+ deleteProjectOwner(slug, confirmSlug, timeoutMs) {
164
+ return this.request('DELETE', `/projects/${encodeURIComponent(slug)}`, {
165
+ body: { confirm_slug: confirmSlug },
166
+ timeoutMs,
167
+ });
168
+ }
157
169
  /** Self-serve: provision a brand-new global identity + key (no bearer auth). */
158
170
  provision(body, timeoutMs) {
159
171
  return this.request('POST', '/provision', { body, timeoutMs });
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localBindingDrift = localBindingDrift;
4
+ /**
5
+ * The local-only (network-free) host-pin drift line shared by the two hot paths —
6
+ * the `convene fetch` UserPromptSubmit nudge and the `convene session-start` boot
7
+ * banner. Kept separate from the pure `binding.ts` comparator because it touches
8
+ * the filesystem (the committed hook settings) and the running CLI version; both
9
+ * callers must NEVER hit the network on the boot/hot path, so this reads only local
10
+ * state and fails open to null.
11
+ *
12
+ * The host axis is intentionally absent: an authoritative pin makes the resolved
13
+ * host == the pin, so host divergence is a `convene doctor` concern (where /me adds
14
+ * server ground truth), not a hot-path one. What CAN drift locally is the CLI
15
+ * (older than the CLI that bound the repo — the stale-CLI problem) and the
16
+ * committed hook wiring (no longer matching the stamped fingerprint).
17
+ */
18
+ const manifest_1 = require("./catalog/manifest");
19
+ const hook_1 = require("./hook");
20
+ const version_1 = require("./version");
21
+ function localBindingDrift(top, proj) {
22
+ try {
23
+ const binding = proj?.binding;
24
+ if (!binding)
25
+ return null;
26
+ if ((0, manifest_1.semverLt)((0, version_1.cliVersion)(), binding.cliVersion)) {
27
+ return `convene: CLI v${(0, version_1.cliVersion)()} is older than this repo's binding (v${binding.cliVersion}) — \`npm i -g convene-cli@latest\``;
28
+ }
29
+ const committed = (0, hook_1.conveneHookFingerprint)((0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top))));
30
+ if (committed !== binding.hookFingerprint) {
31
+ return 'convene: repo hook wiring changed since it was bound — run `convene update --refresh`';
32
+ }
33
+ return null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeHost = normalizeHost;
4
+ exports.assessBinding = assessBinding;
5
+ /**
6
+ * Pure binding ⇄ live-state comparison — the local-only diff that powers the
7
+ * host-pin / freshness surfacing in `convene doctor`, `convene fetch`, and
8
+ * `convene session-start`.
9
+ *
10
+ * A repo's `.convene/project.json` `binding` stamp (schema 3) records what the
11
+ * repo was last RECONCILED against: the canonical bus host, the CLI version that
12
+ * stamped it, the catalog version, and a fingerprint of the COMMITTED hook wiring.
13
+ * This module compares that stamp against the live runtime truth (the resolved
14
+ * host, the running CLI, the server-echoed canonical host, the on-disk committed
15
+ * hook fingerprint) and classifies each axis.
16
+ *
17
+ * Modeled on `compareToCatalog` (catalog/manifest.ts): dependency-free and
18
+ * side-effect-free (no fs, no network, never throws) so it unit-tests offline and
19
+ * is safe to call on the fetch hot path. The ONLY non-stdlib dependency is the
20
+ * existing `semverLt` — the binding's CLI-version axis is the one semver-shaped
21
+ * dimension; the host axis is plain canonicalized string equality.
22
+ */
23
+ const manifest_1 = require("./catalog/manifest");
24
+ /**
25
+ * Canonicalize a host/base-URL for equality so the pin and the resolved/ server-
26
+ * echoed host compare equal across cosmetic differences: trim, lowercase, strip a
27
+ * trailing-slash-only path, and drop an explicit DEFAULT port (:443 for https, :80
28
+ * for http) — `https://x` and `https://x:443/` address the same endpoint. Scheme is
29
+ * preserved (http vs https is a REAL difference — the in-process test server). Pure
30
+ * + never throws: a value the URL parser rejects falls back to trim/lowercase/strip.
31
+ */
32
+ function normalizeHost(s) {
33
+ const trimmed = s.trim();
34
+ try {
35
+ const u = new URL(trimmed);
36
+ const scheme = u.protocol.toLowerCase(); // e.g. 'https:'
37
+ const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : '';
38
+ const port = u.port && u.port !== defaultPort ? `:${u.port}` : '';
39
+ const tail = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '');
40
+ return `${scheme}//${u.hostname}${port}${tail}`.toLowerCase();
41
+ }
42
+ catch {
43
+ return trimmed.toLowerCase().replace(/\/+$/, '');
44
+ }
45
+ }
46
+ /**
47
+ * Compare a committed binding stamp against live runtime state. Pure; never throws.
48
+ *
49
+ * Host axis (HARD): a mismatch iff the EFFECTIVE host differs from the pin, OR the
50
+ * server actually reached (via /me) self-identifies as a different canonical host
51
+ * than the pin. An absent server host is fail-open (cannot contradict ⇒ not a
52
+ * mismatch) — never brick a new CLI against an old server that omits the field.
53
+ * CLI axis (SOFT): directional via semverLt — `behind` when the running CLI is
54
+ * older than the stamping CLI (update your CLI), `ahead` when newer (consider
55
+ * re-stamping). bumpClass is deliberately NOT used: it collapses "ahead" to
56
+ * "none" and so cannot express the stale-CLI direction this feature targets.
57
+ * Hook axis (SOFT): committed fingerprint vs the stamped one; `unknown` when the
58
+ * committed fingerprint is unreadable.
59
+ */
60
+ function assessBinding(stamp, live) {
61
+ const stampHost = normalizeHost(stamp.host);
62
+ const resolvedHost = normalizeHost(live.host);
63
+ const serverHost = live.serverHost ? normalizeHost(live.serverHost) : null;
64
+ let hostStatus;
65
+ if (resolvedHost !== stampHost)
66
+ hostStatus = 'mismatch';
67
+ else if (serverHost !== null && serverHost !== stampHost)
68
+ hostStatus = 'mismatch';
69
+ else
70
+ hostStatus = 'ok';
71
+ let cliStatus = 'ok';
72
+ if ((0, manifest_1.semverLt)(live.cliVersion, stamp.cliVersion))
73
+ cliStatus = 'behind';
74
+ else if ((0, manifest_1.semverLt)(stamp.cliVersion, live.cliVersion))
75
+ cliStatus = 'ahead';
76
+ const committed = live.committedHookFingerprint ?? null;
77
+ let hookStatus;
78
+ if (committed === null)
79
+ hookStatus = 'unknown';
80
+ else if (committed === stamp.hookFingerprint)
81
+ hookStatus = 'ok';
82
+ else
83
+ hookStatus = 'drift';
84
+ return {
85
+ host: { stamp: stampHost, resolved: resolvedHost, serverHost, status: hostStatus },
86
+ cli: { stamp: stamp.cliVersion, running: live.cliVersion, status: cliStatus },
87
+ hook: { stamp: stamp.hookFingerprint, committed, status: hookStatus },
88
+ hostMismatch: hostStatus === 'mismatch',
89
+ };
90
+ }
package/dist/cache.js CHANGED
@@ -9,6 +9,8 @@ exports.writeCache = writeCache;
9
9
  exports.ageSeconds = ageSeconds;
10
10
  exports.readCatalogVersion = readCatalogVersion;
11
11
  exports.writeCatalogVersion = writeCatalogVersion;
12
+ exports.readCliVersionCheck = readCliVersionCheck;
13
+ exports.writeCliVersionCheck = writeCliVersionCheck;
12
14
  exports.readSessionInstance = readSessionInstance;
13
15
  exports.mintSessionInstance = mintSessionInstance;
14
16
  exports.ensureSessionInstance = ensureSessionInstance;
@@ -19,6 +21,10 @@ exports.markCatchupSurfaced = markCatchupSurfaced;
19
21
  exports.catchupAlreadySurfaced = catchupAlreadySurfaced;
20
22
  exports.markAutoIsolated = markAutoIsolated;
21
23
  exports.autoIsolatedAlready = autoIsolatedAlready;
24
+ exports.announceAlreadyPosted = announceAlreadyPosted;
25
+ exports.markAnnounced = markAnnounced;
26
+ exports.readLastBroadcastSha = readLastBroadcastSha;
27
+ exports.writeLastBroadcastSha = writeLastBroadcastSha;
22
28
  exports.readWatchHighWater = readWatchHighWater;
23
29
  exports.persistHighWater = persistHighWater;
24
30
  exports.appendWatchEntry = appendWatchEntry;
@@ -103,6 +109,33 @@ function writeCatalogVersion(version) {
103
109
  /* best-effort */
104
110
  }
105
111
  }
112
+ function cliVersionFile() {
113
+ return node_path_1.default.join(config_1.CACHE_DIR, 'cli-version.json');
114
+ }
115
+ /** The cached latest-published CLI version if present AND within `ttlSec`, else null. */
116
+ function readCliVersionCheck(ttlSec) {
117
+ try {
118
+ const e = JSON.parse(node_fs_1.default.readFileSync(cliVersionFile(), 'utf8'));
119
+ if (!e || typeof e.version !== 'string' || typeof e.fetchedAt !== 'number')
120
+ return null;
121
+ if ((Date.now() - e.fetchedAt) / 1000 >= ttlSec)
122
+ return null;
123
+ return e.version || null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ /** Persist the latest-published CLI version (best-effort; never throws). */
130
+ function writeCliVersionCheck(version) {
131
+ try {
132
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
133
+ node_fs_1.default.writeFileSync(cliVersionFile(), JSON.stringify({ fetchedAt: Date.now(), version }), { mode: 0o600 });
134
+ }
135
+ catch {
136
+ /* best-effort */
137
+ }
138
+ }
106
139
  // ── session-instance id (WP2) ────────────────────────────────────────────────
107
140
  // An opaque per-session UUID minted at SessionStart and persisted in CACHE_DIR.
108
141
  // Sent as X-Convene-Session-Instance so the server can stamp holder_instance and
@@ -305,6 +338,58 @@ function autoIsolatedAlready(slug, instance) {
305
338
  * incumbent forces a relocation.
306
339
  */
307
340
  exports.LIVE_SESSION_RECENT_SEC = 3 * 60;
341
+ function announcedFile(slug) {
342
+ return slugFile(scoped(slug), 'announced');
343
+ }
344
+ /** True iff this exact (instance, branch) has already been announced. */
345
+ function announceAlreadyPosted(slug, instance, branch) {
346
+ try {
347
+ const e = JSON.parse(node_fs_1.default.readFileSync(announcedFile(slug), 'utf8'));
348
+ return !!e && e.instance === instance && e.branch === branch;
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
354
+ /** Record that (instance, branch) has been announced. Best-effort; never throws. */
355
+ function markAnnounced(slug, instance, branch) {
356
+ try {
357
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
358
+ node_fs_1.default.writeFileSync(announcedFile(slug), JSON.stringify({ instance, branch }), { mode: 0o600 });
359
+ }
360
+ catch {
361
+ /* best-effort */
362
+ }
363
+ }
364
+ // ── last-broadcast-sha (announce ↔ wrap ↔ push dedup) ─────────────────────────
365
+ // The most recent HEAD sha this session has already told the bus about — written
366
+ // by `convene announce` (the session-start tip), `convene wrap` (a turn-end wrap),
367
+ // and `convene notify-push` (a push). `convene wrap` (a Stop hook, so it fires at
368
+ // every turn-end) reads it to stay quiet: it only posts when HEAD has advanced
369
+ // PAST this sha, so it never re-wraps a tip the bus already heard about (from the
370
+ // announce, a prior wrap, or the pre-push hook) and never spams an idle session.
371
+ function broadcastShaFile(slug) {
372
+ return slugFile(scoped(slug), 'broadcast-sha');
373
+ }
374
+ /** The last HEAD sha broadcast to the bus for this session, or null. */
375
+ function readLastBroadcastSha(slug) {
376
+ try {
377
+ return node_fs_1.default.readFileSync(broadcastShaFile(slug), 'utf8').trim() || null;
378
+ }
379
+ catch {
380
+ return null;
381
+ }
382
+ }
383
+ /** Record the HEAD sha just broadcast to the bus. Best-effort; never throws. */
384
+ function writeLastBroadcastSha(slug, sha) {
385
+ try {
386
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
387
+ node_fs_1.default.writeFileSync(broadcastShaFile(slug), sha + '\n', { mode: 0o600 });
388
+ }
389
+ catch {
390
+ /* best-effort */
391
+ }
392
+ }
308
393
  function watchFile(slug) {
309
394
  return slugFile(slug, 'watch.jsonl');
310
395
  }
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CATALOG_VERSION = exports.CATALOG = void 0;
4
4
  exports.CATALOG = {
5
- "version": "1.0.0",
5
+ "version": "1.1.0",
6
6
  "tiers": [
7
7
  {
8
8
  "id": "essentials",
@@ -213,6 +213,31 @@ exports.CATALOG = {
213
213
  "https://editorconfig.org/"
214
214
  ]
215
215
  },
216
+ {
217
+ "id": "announce-session-intent",
218
+ "version": "1.0.0",
219
+ "title": "Announce what you are working on at session start and end",
220
+ "category": "Parallel & Multi-Session Coordination",
221
+ "tier": "essentials",
222
+ "what": "At the start of a work session, broadcast what you are taking on; when you wrap (or land/push commits), broadcast what you did — `convene post status \"<one line>\"`. Do not rely on memory or on others reading your diff. Convene also auto-posts a terse backstop at these points (a \"started on <branch>\" line on the first prompt for every tool, a push status via the pre-push hook, and — in Claude Code — a turn-end wrap once commits land), but a hand-written line with real context is the goal; the auto-posts only guarantee a session is never dark.",
223
+ "why": "The most common multi-session failure is a silent session: an agent (often a non-Claude tool with no SessionStart/Stop hook) works for an hour and no peer knows where it is, so two sessions collide or duplicate work. Inbound context is automatic and cross-tool; outbound was voluntary prose and got skipped — a Codex session edited the marketing site and never touched the bus. Per `hooks-for-must-haves`, the fix is to make the announce automatic (a hook), with the agent line as enrichment. Production-learned at Convene.",
224
+ "defaultLevel": "advisory",
225
+ "availableLevels": [
226
+ "advisory"
227
+ ],
228
+ "optInDefault": "on",
229
+ "productionLearned": true,
230
+ "artifacts": [
231
+ {
232
+ "kind": "claudeMd",
233
+ "body": "Post what you are taking on at the start of a work session and what you did when you wrap — `convene post status \"<one line>\"` — so concurrent sessions across tools see who owns what.\nA terse start/wrap line is auto-posted as a backstop; your own hand-written status is richer and is what peers rely on."
234
+ }
235
+ ],
236
+ "sourceUrls": [
237
+ "https://code.claude.com/docs/en/hooks",
238
+ "https://code.claude.com/docs/en/best-practices"
239
+ ]
240
+ },
216
241
  {
217
242
  "id": "worktree-per-session",
218
243
  "version": "1.0.0",
@@ -857,4 +882,4 @@ exports.CATALOG = {
857
882
  }
858
883
  ]
859
884
  };
860
- exports.CATALOG_VERSION = "1.0.0";
885
+ exports.CATALOG_VERSION = "1.1.0";
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.announce = announce;
4
+ /**
5
+ * `convene announce` — the front-of-session auto-announce. Posts ONE [STATUS]
6
+ * ("started session on <branch>") the first time a session is seen, so concurrent
7
+ * sessions across every tool know who is working on what BEFORE any push. This is
8
+ * the cross-tool keystone that closes the dark-session gap: it is spawned
9
+ * fire-and-forget by `convene fetch` on the first prompt of a session, which runs
10
+ * for BOTH Claude Code (UserPromptSubmit hook) and Codex (`fetch --codex-hook`).
11
+ *
12
+ * FAIL-OPEN, exactly like `convene notify-push` (P0-FAILSAFE): any error / missing
13
+ * config / non-bus repo exits 0 silently; a 5s watchdog backstops a hang. It NEVER
14
+ * blocks anything — it is detached from the prompt hot path.
15
+ *
16
+ * IDEMPOTENT on two levels: a local (instance, branch) sentinel spares the
17
+ * redundant post/spawn, and a deterministic server idempotency-key
18
+ * (`announce:<slug>:<instance>:<branch>`) is the authoritative dedupe so a retry
19
+ * or a same-instant double-spawn collapses to one message.
20
+ *
21
+ * PRIVACY: the body carries ONLY the branch name — never the user's prompt text.
22
+ * The bus is cross-member, so prompt text must never land on it automatically.
23
+ */
24
+ const git_1 = require("../git");
25
+ const config_1 = require("../config");
26
+ const cache_1 = require("../cache");
27
+ const api_1 = require("../api");
28
+ const exit_1 = require("../exit");
29
+ function clip(s, max) {
30
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
31
+ }
32
+ async function run(opts) {
33
+ const top = (0, git_1.gitToplevel)();
34
+ if (!top)
35
+ return; // not a git repo → silent no-op
36
+ const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
37
+ if (!slug)
38
+ return; // repo not on the bus → silent no-op
39
+ const cfg = (0, config_1.resolveConfig)();
40
+ // The instance is minted at SessionStart for Claude Code; for Codex (no
41
+ // SessionStart) ensure one here so the (instance, branch) sentinel is stable.
42
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
43
+ const branch = (0, git_1.currentBranch)(top); // null on a detached HEAD
44
+ const branchKey = branch ?? 'detached';
45
+ // Already announced this (instance, branch) — nothing to do (skip in dry-run so
46
+ // it always prints what it WOULD post).
47
+ if (!opts.dryRun && (0, cache_1.announceAlreadyPosted)(slug, instance, branchKey))
48
+ return;
49
+ const body = clip(branch ? `started session on ${branch}` : 'started session (detached HEAD)', 200);
50
+ if (opts.dryRun) {
51
+ process.stdout.write(body + '\n');
52
+ return;
53
+ }
54
+ // Credentials are only needed for the real post (dry-run works offline).
55
+ if (!cfg.apiKey || !cfg.member)
56
+ return;
57
+ const session = (0, git_1.sessionId)(cfg.member, top);
58
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
59
+ const idem = `announce:${slug}:${instance}:${branchKey}`;
60
+ const res = await api.post(slug, { type: 'status', body }, idem, 4000);
61
+ if (res.ok) {
62
+ // Only mark on success so a transient failure retries on the next prompt.
63
+ (0, cache_1.markAnnounced)(slug, instance, branchKey);
64
+ // Seed the broadcast cursor with the session-start tip so a turn-end `wrap`
65
+ // only fires once HEAD has advanced past it (no spurious wrap of the start tip).
66
+ const head = (0, git_1.revParse)('HEAD', top);
67
+ if (head)
68
+ (0, cache_1.writeLastBroadcastSha)(slug, head);
69
+ if (res.json?.message?.short_id) {
70
+ process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} — ${body}\n`);
71
+ }
72
+ }
73
+ }
74
+ async function announce(opts = {}) {
75
+ // Backstop: force-exit on every path so a keep-alive socket can't linger.
76
+ const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
77
+ watchdog.unref();
78
+ const done = () => {
79
+ clearTimeout(watchdog);
80
+ (0, exit_1.exitClean)(0);
81
+ };
82
+ try {
83
+ await run(opts);
84
+ }
85
+ catch {
86
+ /* fail-open: a coordination post must never break a prompt or a boot */
87
+ }
88
+ done();
89
+ }
@@ -7,6 +7,7 @@ exports.login = login;
7
7
  exports.whoami = whoami;
8
8
  exports.assessLaneIdentity = assessLaneIdentity;
9
9
  exports.assessSettingsIntegrity = assessSettingsIntegrity;
10
+ exports.assessFreshness = assessFreshness;
10
11
  exports.doctor = doctor;
11
12
  /** login / whoami / doctor. */
12
13
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -17,6 +18,8 @@ const api_1 = require("../api");
17
18
  const config_1 = require("../config");
18
19
  const catalog_1 = require("../catalog");
19
20
  const manifest_1 = require("../catalog/manifest");
21
+ const binding_1 = require("../binding");
22
+ const version_1 = require("../version");
20
23
  const git_1 = require("../git");
21
24
  const hook_1 = require("../hook");
22
25
  const cache_1 = require("../cache");
@@ -83,13 +86,14 @@ async function login(opts) {
83
86
  process.stdout.write(`Config saved to ${config_1.CONFIG_FILE} (0600).\n`);
84
87
  }
85
88
  async function whoami() {
86
- const cfg = (0, config_1.resolveConfig)();
89
+ const top = (0, git_1.gitToplevel)();
90
+ const proj = (0, config_1.loadProjectConfig)(top);
91
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj); // report the host commands actually use (the pin, if any)
87
92
  if (!cfg.apiKey) {
88
93
  process.stdout.write('Not logged in. Run `convene login`.\n');
89
94
  return;
90
95
  }
91
- const top = (0, git_1.gitToplevel)();
92
- const onBus = !!(0, config_1.loadProjectConfig)(top)?.slug;
96
+ const onBus = !!proj?.slug;
93
97
  const session = cfg.member && top ? (0, git_1.sessionId)(cfg.member, top) : cfg.member ? `${cfg.member}/cli` : '(unknown)';
94
98
  let serverMember = cfg.member;
95
99
  const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool);
@@ -100,7 +104,7 @@ async function whoami() {
100
104
  process.stdout.write(`base: ${cfg.baseUrl}\n`);
101
105
  process.stdout.write(`tool: ${cfg.tool}\n`);
102
106
  process.stdout.write(`session: ${session}\n`);
103
- process.stdout.write(`this repo on the bus: ${onBus ? `yes (${(0, config_1.loadProjectConfig)(top).slug})` : 'no'}\n`);
107
+ process.stdout.write(`this repo on the bus: ${onBus ? `yes (${proj.slug})` : 'no'}\n`);
104
108
  process.stdout.write(`server: ${me.ok ? 'reachable' : 'UNREACHABLE'}\n`);
105
109
  // The API key is never printed.
106
110
  }
@@ -255,9 +259,122 @@ function assessSettingsIntegrity(top, globalSettingsPath = hook_1.SETTINGS_PATH)
255
259
  const tail = notes.length ? ` (${notes.join('; ')})` : '';
256
260
  return { name, ok: true, detail: `settings JSON valid + marker blocks intact; Convene edits are additive${tail}` };
257
261
  }
262
+ /** Cache TTL for the latest-published CLI version probe — long (24h), advisory. */
263
+ const CLI_VERSION_TTL_SEC = 24 * 60 * 60;
264
+ /**
265
+ * The latest published `convene-cli` version, 24h-cached and FAIL-SOFT: a cache hit
266
+ * returns instantly; a miss runs a bounded `npm view` and caches the result; any
267
+ * failure (offline / no npm / timeout) returns null so the cli-version arm is simply
268
+ * omitted — doctor never blocks on, or fails because of, this network probe.
269
+ */
270
+ function latestCliVersion() {
271
+ const cached = (0, cache_1.readCliVersionCheck)(CLI_VERSION_TTL_SEC);
272
+ if (cached)
273
+ return cached;
274
+ try {
275
+ const r = (0, node_child_process_1.spawnSync)('npm', ['view', 'convene-cli', 'version'], { timeout: 4000, encoding: 'utf8' });
276
+ if (r.error || typeof r.status !== 'number' || r.status !== 0)
277
+ return null;
278
+ const v = (r.stdout || '').trim();
279
+ if (!/^\d+\.\d+\.\d+/.test(v))
280
+ return null;
281
+ (0, cache_1.writeCliVersionCheck)(v);
282
+ return v;
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ }
288
+ /**
289
+ * Pure freshness/host-pin assessment for `convene doctor` — mirrors
290
+ * assessLaneIdentity/assessSettingsIntegrity (takes inputs, returns Check[], no
291
+ * network/fs inside, unit-testable). Exactly ONE check is HARD:
292
+ * - host-pin (HARD, ok:false ⇒ doctor exits 1): the repo's pinned host diverges
293
+ * from the host the CLI reaches OR from the server's self-identified canonical
294
+ * host. An UNSTAMPED on-bus repo yields a single SOFT nudge instead.
295
+ * Every other arm is SOFT (ok:true, advisory — never flips the exit code):
296
+ * - hook-fp: committed hook wiring drifted from the stamp (names the committed file);
297
+ * - cli-stamp: running CLI older than the CLI that bound the repo;
298
+ * - cli-version: running CLI older than the latest published release.
299
+ * (Catalog freshness stays in reportBestPractices.) Off-bus → no checks.
300
+ */
301
+ function assessFreshness(inp) {
302
+ const out = [];
303
+ if (!inp.onBus)
304
+ return out;
305
+ if (!inp.binding) {
306
+ out.push({
307
+ name: 'host-pin',
308
+ ok: true,
309
+ detail: 'repo not host-pinned — run `convene update --refresh` to bind it to this bus (records host + CLI + hook state)',
310
+ });
311
+ }
312
+ else {
313
+ const a = (0, binding_1.assessBinding)(inp.binding, {
314
+ host: inp.resolvedHost,
315
+ cliVersion: inp.runningCliVersion,
316
+ serverHost: inp.serverHost,
317
+ committedHookFingerprint: inp.committedHookFingerprint,
318
+ });
319
+ out.push({
320
+ name: 'host-pin',
321
+ ok: !a.hostMismatch,
322
+ detail: a.hostMismatch
323
+ ? `HOST MISMATCH — repo pinned to ${a.host.stamp} but ` +
324
+ (a.host.resolved !== a.host.stamp
325
+ ? `resolving to ${a.host.resolved}`
326
+ : `the server self-identifies as ${a.host.serverHost}`) +
327
+ ' (re-point deliberately with `convene update --host <url>`, or fix your config)'
328
+ : `pinned to ${a.host.stamp}${a.host.serverHost ? ' — server-confirmed' : ' (server host unverified)'}`,
329
+ });
330
+ // Ambient-vs-pin divergence: a stale CONVENE_BASE_URL / global baseUrl that
331
+ // disagrees with the pin. Pinned commands honor the pin (so this is SOFT, not a
332
+ // mismatch), but the user should know their environment points elsewhere — it is
333
+ // exactly the dev/prod confusion this feature exists to surface, and the
334
+ // authoritative resolvedHost would otherwise hide it.
335
+ if ((0, binding_1.normalizeHost)(inp.ambientHost) !== (0, binding_1.normalizeHost)(inp.binding.host)) {
336
+ out.push({
337
+ name: 'host-env',
338
+ ok: true,
339
+ detail: `your environment resolves to ${(0, binding_1.normalizeHost)(inp.ambientHost)} but this repo is pinned to ${(0, binding_1.normalizeHost)(inp.binding.host)} — the pin wins for convene commands; unset CONVENE_BASE_URL / fix ~/.convene/config.json to avoid confusion`,
340
+ });
341
+ }
342
+ if (a.hook.status === 'drift') {
343
+ out.push({
344
+ name: 'hook-fp',
345
+ ok: true,
346
+ detail: 'committed .claude/settings.json hook wiring changed since the repo was bound — `convene update --refresh` to re-stamp',
347
+ });
348
+ }
349
+ if (a.cli.status === 'behind') {
350
+ out.push({
351
+ name: 'cli-stamp',
352
+ ok: true,
353
+ detail: `CLI v${a.cli.running} is older than the CLI that bound this repo (v${a.cli.stamp}) — \`npm i -g convene-cli@latest\``,
354
+ });
355
+ }
356
+ }
357
+ if (inp.latestCliVersion && (0, manifest_1.semverLt)(inp.runningCliVersion, inp.latestCliVersion)) {
358
+ out.push({
359
+ name: 'cli-version',
360
+ ok: true,
361
+ detail: `convene-cli v${inp.latestCliVersion} available (running v${inp.runningCliVersion}) — \`npm i -g convene-cli@latest\``,
362
+ });
363
+ }
364
+ return out;
365
+ }
258
366
  async function doctor(opts) {
259
367
  const checks = [];
260
- const cfg = (0, config_1.resolveConfig)();
368
+ // Resolve the git toplevel + committed project config UP FRONT so config
369
+ // resolution can honor a committed host pin (resolveConfigForRepo) — the same
370
+ // host the rest of doctor talks to and reports. `top`/`proj` are reused by the
371
+ // git/project/freshness checks below instead of being re-read.
372
+ const top = (0, git_1.gitToplevel)();
373
+ const proj = (0, config_1.loadProjectConfig)(top);
374
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
375
+ // /me response, captured by the auth check and REUSED by the freshness host arm
376
+ // (the server-echoed canonical_host) — never a second /me round-trip.
377
+ let meJson = null;
261
378
  // 1. binary
262
379
  checks.push({ name: 'binary', ok: true, detail: `convene running from ${process.argv[1] || process.execPath}` });
263
380
  // 2. config + perms
@@ -290,6 +407,7 @@ async function doctor(opts) {
290
407
  if (cfg.apiKey) {
291
408
  const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey);
292
409
  const me = await api.me(6000);
410
+ meJson = me.json;
293
411
  checks.push({
294
412
  name: 'auth',
295
413
  ok: me.ok,
@@ -300,10 +418,8 @@ async function doctor(opts) {
300
418
  checks.push({ name: 'auth', ok: false, detail: 'skipped (no key)' });
301
419
  }
302
420
  // 4. git toplevel
303
- const top = (0, git_1.gitToplevel)();
304
421
  checks.push({ name: 'git', ok: !!top, detail: top ? `worktree: ${(0, git_1.worktreeBasename)(top)}` : 'not in a git repo' });
305
422
  // 5. project on bus
306
- const proj = (0, config_1.loadProjectConfig)(top);
307
423
  checks.push({
308
424
  name: 'project',
309
425
  ok: !!proj?.slug,
@@ -438,6 +554,28 @@ async function doctor(opts) {
438
554
  checks.push(assessLaneIdentity(rows, cfg.member ?? null, instance != null));
439
555
  }
440
556
  }
557
+ // 9. host-pin / freshness. The host arm is HARD (a pin/server divergence flips the
558
+ // exit code via the every() gate below — exactly the dev/prod-confusion guard);
559
+ // cli-version / hook-fp / cli-stamp are SOFT advisories. Inputs are gathered here
560
+ // (reusing the /me already fetched + a 24h-cached `npm view`); the verdict is the
561
+ // pure assessFreshness. doctor NEVER re-stamps/re-points — that is the deliberate
562
+ // `convene update --refresh` / `--host`; --fix touches only config perms + the
563
+ // GLOBAL hook + watch (never the binding).
564
+ if (top) {
565
+ const committedHookFingerprint = (0, hook_1.conveneHookFingerprint)((0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top))));
566
+ for (const c of assessFreshness({
567
+ binding: proj?.binding ?? null,
568
+ onBus: !!proj?.slug,
569
+ resolvedHost: cfg.baseUrl,
570
+ ambientHost: (0, config_1.resolveConfig)().baseUrl, // binding-BLIND, to catch a stale env/global host
571
+ runningCliVersion: (0, version_1.cliVersion)(),
572
+ serverHost: meJson?.canonical_host ?? null,
573
+ committedHookFingerprint,
574
+ latestCliVersion: cfg.apiKey ? latestCliVersion() : null,
575
+ })) {
576
+ checks.push(c);
577
+ }
578
+ }
441
579
  for (const c of checks) {
442
580
  process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
443
581
  }
@@ -65,7 +65,7 @@ async function runCatchup(opts) {
65
65
  if (!proj?.slug)
66
66
  return; // not on the bus → no-op
67
67
  const slug = proj.slug;
68
- const cfg = (0, config_1.resolveConfig)();
68
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
69
69
  if (!cfg.apiKey || !cfg.member) {
70
70
  if (failOpen)
71
71
  return;