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.
@@ -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).
@@ -125,7 +125,7 @@ async function loop(opts) {
125
125
  const slug = opts.project || proj?.slug || null;
126
126
  if (!slug)
127
127
  return 0; // not on the bus → no-op
128
- const cfg = (0, config_1.resolveConfig)();
128
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
129
129
  const member = cfg.member;
130
130
  const session = member ? (0, git_1.sessionId)(member, top) : null;
131
131
  if (!cfg.apiKey || !session)
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.wrap = wrap;
4
+ /**
5
+ * `convene wrap` — the end-of-session wrap. Wired as a Claude Code `Stop` hook, so
6
+ * it fires at every turn-end; it stays QUIET unless the session has landed NEW
7
+ * committed work the bus hasn't already heard about, so a session that finishes a
8
+ * piece of work broadcasts a one-line summary without the agent having to remember.
9
+ *
10
+ * FAIL-OPEN like `convene notify-push` (P0-FAILSAFE): any error / missing config /
11
+ * non-bus repo exits 0; a 5s watchdog backstops a hang. It NEVER blocks turn-end
12
+ * (always exits 0) — the auto-post is a backstop, never a gate (the agent is never
13
+ * wedged; that was the user's chosen posture).
14
+ *
15
+ * NOISE CONTROL (the whole point of a per-turn hook): the last-broadcast-sha cursor
16
+ * (shared with `announce` + `notify-push`) means wrap posts ONLY when HEAD has
17
+ * advanced past the last tip the bus heard about — so:
18
+ * - an idle turn with no new commits → silent (HEAD == cursor);
19
+ * - a session-start turn before any work → silent (cursor seeded by `announce`);
20
+ * - a commit the pre-push hook already announced → silent (push moved the cursor);
21
+ * - a genuine new commit since the bus last heard → ONE wrap, then advance.
22
+ * Uncommitted-only changes are intentionally NOT wrapped: a turn-end with only a
23
+ * dirty tree usually means mid-task, and auto-claiming "wrapped" there would be both
24
+ * noisy and frequently wrong.
25
+ */
26
+ const git_1 = require("../git");
27
+ const config_1 = require("../config");
28
+ const cache_1 = require("../cache");
29
+ const api_1 = require("../api");
30
+ const exit_1 = require("../exit");
31
+ function clip(s, max) {
32
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
33
+ }
34
+ async function run(opts) {
35
+ const top = (0, git_1.gitToplevel)();
36
+ if (!top)
37
+ return; // not a git repo → silent no-op
38
+ const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
39
+ if (!slug)
40
+ return; // repo not on the bus → silent no-op
41
+ const head = (0, git_1.revParse)('HEAD', top);
42
+ if (!head)
43
+ return; // no commits at all → nothing to wrap
44
+ const last = (0, cache_1.readLastBroadcastSha)(slug);
45
+ if (head === last)
46
+ return; // the bus already knows this tip — stay quiet
47
+ if (!last) {
48
+ // No baseline yet (announce hasn't landed, or this is a brand-new cursor).
49
+ // Establish it SILENTLY at the current tip — never post a spurious "wrapped"
50
+ // for the commit the session merely started on.
51
+ (0, cache_1.writeLastBroadcastSha)(slug, head);
52
+ return;
53
+ }
54
+ // last is set AND HEAD has moved past it → genuine new committed work.
55
+ const branch = (0, git_1.currentBranch)(top) ?? 'HEAD';
56
+ const subject = (0, git_1.commitSubject)(head, top) || '(no commit message)';
57
+ const short = (0, git_1.shortSha)(head, top) || head.slice(0, 7);
58
+ const n = (0, git_1.revListCount)(`${last}..${head}`, top);
59
+ const body = clip(n && n > 0
60
+ ? `wrapped ${branch}: ${n} commit${n === 1 ? '' : 's'} since session start · ${subject} (${short})`
61
+ : `wrapped ${branch}: now at ${subject} (${short})`, 200);
62
+ if (opts.dryRun) {
63
+ process.stdout.write(body + '\n');
64
+ return;
65
+ }
66
+ const cfg = (0, config_1.resolveConfig)();
67
+ if (!cfg.apiKey || !cfg.member)
68
+ return;
69
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
70
+ const session = (0, git_1.sessionId)(cfg.member, top);
71
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
72
+ const idem = `wrap:${slug}:${instance}:${head}`;
73
+ const res = await api.post(slug, { type: 'status', body }, idem, 4000);
74
+ if (res.ok) {
75
+ (0, cache_1.writeLastBroadcastSha)(slug, head); // advance the cursor so we never re-wrap this tip
76
+ if (res.json?.message?.short_id) {
77
+ process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} — ${body}\n`);
78
+ }
79
+ }
80
+ }
81
+ async function wrap(opts = {}) {
82
+ const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
83
+ watchdog.unref();
84
+ const done = () => {
85
+ clearTimeout(watchdog);
86
+ (0, exit_1.exitClean)(0);
87
+ };
88
+ try {
89
+ await run(opts);
90
+ }
91
+ catch {
92
+ /* fail-open: a Stop hook must never wedge turn-end */
93
+ }
94
+ done();
95
+ }
package/dist/config.js CHANGED
@@ -16,6 +16,8 @@ exports.saveFileConfig = saveFileConfig;
16
16
  exports.writeProjectConfig = writeProjectConfig;
17
17
  exports.loadManifest = loadManifest;
18
18
  exports.writeManifest = writeManifest;
19
+ exports.writeBinding = writeBinding;
20
+ exports.resolveConfigForRepo = resolveConfigForRepo;
19
21
  /**
20
22
  * Config resolution with precedence:
21
23
  * env (CONVENE_API_KEY, CONVENE_BASE_URL, CONVENE_MEMBER, ...)
@@ -26,6 +28,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
26
28
  const node_os_1 = __importDefault(require("node:os"));
27
29
  const node_path_1 = __importDefault(require("node:path"));
28
30
  const brand_1 = require("./brand");
31
+ const binding_1 = require("./binding");
29
32
  /** Home base, overridable for hermetic tests via CONVENE_HOME_OVERRIDE. */
30
33
  function homeBase() {
31
34
  return process.env.CONVENE_HOME_OVERRIDE || node_os_1.default.homedir();
@@ -128,9 +131,12 @@ function writeProjectConfig(toplevel, cfg) {
128
131
  const dir = node_path_1.default.join(toplevel, '.convene');
129
132
  node_fs_1.default.mkdirSync(dir, { recursive: true });
130
133
  const file = node_path_1.default.join(dir, 'project.json');
131
- // schema 2 ONLY once a best-practices manifest is present; plain onboarding
132
- // stays at schema 1 (byte-identical to pre-catalog output).
133
- const schema = cfg.bestPractices ? 2 : 1;
134
+ // schema 3 once a host-pin binding is present; else schema 2 once a best-
135
+ // practices manifest is present; else schema 1 (byte-identical to pre-catalog
136
+ // output). The ladder is additive: a binding-less / manifest-less file is
137
+ // unchanged, and older CLIs read a higher schema fine (plain JSON.parse, no
138
+ // schema validation — see loadProjectConfig).
139
+ const schema = cfg.binding ? 3 : cfg.bestPractices ? 2 : 1;
134
140
  node_fs_1.default.writeFileSync(file, JSON.stringify({ schema, ...cfg }, null, 2) + '\n');
135
141
  return file;
136
142
  }
@@ -147,3 +153,47 @@ function writeManifest(toplevel, manifest) {
147
153
  const { schema: _schema, ...rest } = existing;
148
154
  return writeProjectConfig(toplevel, { ...rest, bestPractices: manifest });
149
155
  }
156
+ /**
157
+ * Merge a host-pin binding stamp into the repo's project.json, preserving
158
+ * slug/displayName/joinToken/bestPractices and bumping it to schema 3. Strips the
159
+ * stale on-disk `schema` first so writeProjectConfig re-derives it (the same
160
+ * round-trip-safe pattern as writeManifest). Written only by the deliberate
161
+ * `convene update --refresh` / `--host` paths.
162
+ */
163
+ function writeBinding(toplevel, binding) {
164
+ const existing = loadProjectConfig(toplevel) ?? {};
165
+ const { schema: _schema, ...rest } = existing;
166
+ return writeProjectConfig(toplevel, { ...rest, binding });
167
+ }
168
+ /**
169
+ * Test-escape: when CONVENE_IGNORE_BINDING is truthy, resolveConfigForRepo behaves
170
+ * exactly like resolveConfig (binding-blind). Set by the unit-test bootstrap so the
171
+ * suite — and a later-stamped `convene` repo whose project.json sits at the test
172
+ * cwd — never has a committed pin silently override the env/file the tests rely on.
173
+ * Power users (and a future e2e host-pin scenario) opt in explicitly.
174
+ */
175
+ function bindingIgnored() {
176
+ const v = process.env.CONVENE_IGNORE_BINDING;
177
+ return v != null && v !== '' && v !== '0' && !/^(false|no|off)$/i.test(v);
178
+ }
179
+ /**
180
+ * Repo-aware config resolution: when the repo carries a committed `binding.host`
181
+ * pin, that host is AUTHORITATIVE (pin > env > global > default) — the whole point
182
+ * of host-pinning is that ambient config (a stale CONVENE_BASE_URL or global
183
+ * baseUrl) can never silently re-point a bound repo at the wrong bus. An unpinned
184
+ * repo (or the CONVENE_IGNORE_BINDING escape) is byte-identical to resolveConfig().
185
+ *
186
+ * `proj` may be passed by callers that already loaded project.json (the fetch /
187
+ * session-start hot paths) to avoid a second disk read. resolveConfig() itself is
188
+ * left untouched — only the callers that should honor the pin migrate to this.
189
+ */
190
+ function resolveConfigForRepo(toplevel, proj) {
191
+ const base = resolveConfig();
192
+ if (bindingIgnored())
193
+ return base;
194
+ const p = proj !== undefined ? proj : loadProjectConfig(toplevel);
195
+ const pin = p?.binding?.host;
196
+ if (!pin)
197
+ return base;
198
+ return { ...base, baseUrl: (0, binding_1.normalizeHost)(pin) };
199
+ }
package/dist/ctx.js CHANGED
@@ -13,7 +13,13 @@ function die(msg) {
13
13
  process.exit(1);
14
14
  }
15
15
  function getContext(opts = {}) {
16
- const cfg = (0, config_1.resolveConfig)();
16
+ // Resolve repo-aware so a committed host pin governs the BUS-MUTATING commands
17
+ // too (post/answer/ack/resolve/inbox/lane/deploy all flow through here) — not
18
+ // just the diagnostic paths. The pin is authoritative over ambient config, so a
19
+ // stale CONVENE_BASE_URL can never silently re-point a bound repo's writes/lanes.
20
+ const top = (0, git_1.gitToplevel)();
21
+ const proj = (0, config_1.loadProjectConfig)(top);
22
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
17
23
  if (cfg.insecurePerms) {
18
24
  process.stderr.write(`convene: WARNING ${cfg.configFile} is world/group-readable; run: chmod 600 ${cfg.configFile}\n`);
19
25
  }
@@ -21,8 +27,7 @@ function getContext(opts = {}) {
21
27
  die('not logged in — run `convene login`');
22
28
  if (!cfg.member)
23
29
  die('no member configured — run `convene login --member <handle>`');
24
- const top = (0, git_1.gitToplevel)();
25
- const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
30
+ const slug = opts.project || proj?.slug || null;
26
31
  const session = top ? (0, git_1.sessionId)(cfg.member, top) : `${cfg.member}/cli`;
27
32
  return {
28
33
  api: new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool),
package/dist/hook.js CHANGED
@@ -15,6 +15,7 @@ exports.withGenericHook = withGenericHook;
15
15
  exports.ensureHook = ensureHook;
16
16
  exports.ensureHookRegistered = ensureHookRegistered;
17
17
  exports.isConveneHookCommand = isConveneHookCommand;
18
+ exports.conveneHookFingerprint = conveneHookFingerprint;
18
19
  exports.withoutConveneHooks = withoutConveneHooks;
19
20
  exports.settingsIsEmpty = settingsIsEmpty;
20
21
  exports.projectSettingsPath = projectSettingsPath;
@@ -29,6 +30,7 @@ exports.ensureProjectHookRegistered = ensureProjectHookRegistered;
29
30
  */
30
31
  const node_fs_1 = __importDefault(require("node:fs"));
31
32
  const node_path_1 = __importDefault(require("node:path"));
33
+ const node_crypto_1 = require("node:crypto");
32
34
  const node_child_process_1 = require("node:child_process");
33
35
  const config_1 = require("./config");
34
36
  exports.SETTINGS_PATH = node_path_1.default.join((0, config_1.homeBase)(), '.claude', 'settings.json');
@@ -201,6 +203,41 @@ function ensureHookRegistered() {
201
203
  function isConveneHookCommand(command) {
202
204
  return typeof command === 'string' && /^convene(\s|$)/.test(command.trim());
203
205
  }
206
+ /**
207
+ * A stable, content-addressed fingerprint of the convene-authored hook wiring in a
208
+ * Claude Code settings object — every convene hook command across every event,
209
+ * normalized (`event:command`), sorted, and hashed. Empty → 'none'.
210
+ *
211
+ * Used by the host-pin / freshness binding to detect when hook wiring has DRIFTED
212
+ * from what a repo was last reconciled against. Computed SEPARATELY for the
213
+ * COMMITTED repo `.claude/settings.json` (the value stamped into the binding, in
214
+ * CONVENE_PATHS) and the per-machine GLOBAL `~/.claude/settings.json` (a doctor
215
+ * advisory, never committed) — see must-fix B. Pure; reuses isConveneHookCommand so
216
+ * a foreign hook never perturbs the fingerprint.
217
+ */
218
+ function conveneHookFingerprint(settings) {
219
+ const cmds = [];
220
+ const hooks = settings?.hooks;
221
+ if (hooks && typeof hooks === 'object') {
222
+ for (const event of Object.keys(hooks)) {
223
+ const groups = hooks[event];
224
+ if (!Array.isArray(groups))
225
+ continue;
226
+ for (const g of groups) {
227
+ if (!Array.isArray(g?.hooks))
228
+ continue;
229
+ for (const h of g.hooks) {
230
+ if (isConveneHookCommand(h?.command))
231
+ cmds.push(`${event}:${String(h.command).trim()}`);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ if (cmds.length === 0)
237
+ return 'none';
238
+ cmds.sort();
239
+ return (0, node_crypto_1.createHash)('sha256').update(cmds.join('\n')).digest('hex').slice(0, 12);
240
+ }
204
241
  /**
205
242
  * Return a new settings object (deep-clone) with every convene-authored hook
206
243
  * removed, pruning emptied groups and emptied event arrays (and the `hooks` key
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.program = void 0;
37
38
  /** Convene CLI entrypoint. */
38
39
  const fs_1 = require("fs");
39
40
  const path_1 = require("path");
@@ -41,12 +42,16 @@ const commander_1 = require("commander");
41
42
  const brand_1 = require("./brand");
42
43
  const fetch_1 = require("./commands/fetch");
43
44
  const notify_1 = require("./commands/notify");
45
+ const announce_1 = require("./commands/announce");
46
+ const wrap_1 = require("./commands/wrap");
44
47
  const post = __importStar(require("./commands/post"));
45
48
  const inbox_1 = require("./commands/inbox");
49
+ const feedback_1 = require("./commands/feedback");
46
50
  const auth_1 = require("./commands/auth");
47
51
  const init_1 = require("./commands/init");
48
52
  const offboard_1 = require("./commands/offboard");
49
53
  const join_1 = require("./commands/join");
54
+ const reclaim_1 = require("./commands/reclaim");
50
55
  const setup_1 = require("./commands/setup");
51
56
  const migrate_1 = require("./commands/migrate");
52
57
  const rotate_1 = require("./commands/rotate");
@@ -66,6 +71,7 @@ const explain_1 = require("./commands/explain");
66
71
  const practices_1 = require("./commands/practices");
67
72
  const update_1 = require("./commands/update");
68
73
  const program = new commander_1.Command();
74
+ exports.program = program;
69
75
  // Read the version from package.json so `convene --version` always tracks the
70
76
  // published version (npm includes package.json in the tarball). dist/index.js
71
77
  // sits one level below package.json; in dev (tsx) src/index.ts does too.
@@ -192,6 +198,18 @@ program
192
198
  .option('--project <slug>')
193
199
  .option('--dry-run', 'print the status it would post; do not post')
194
200
  .action((opts) => (0, notify_1.notifyPush)(opts));
201
+ program
202
+ .command('announce')
203
+ .description('front-of-session auto-announce: post a one-line "started session on <branch>" [STATUS] (once per session/branch; fail-silent)')
204
+ .option('--project <slug>')
205
+ .option('--dry-run', 'print the status it would post; do not post')
206
+ .action((opts) => (0, announce_1.announce)(opts));
207
+ program
208
+ .command('wrap')
209
+ .description('Stop hook: post a one-line wrap [STATUS] when the session has landed new committed work (idempotent, fail-silent)')
210
+ .option('--project <slug>')
211
+ .option('--dry-run', 'print the status it would post; do not post')
212
+ .action((opts) => (0, wrap_1.wrap)(opts));
195
213
  const postCmd = program.command('post').description('post outbound coordination messages');
196
214
  postCmd
197
215
  .command('status <body>')
@@ -244,6 +262,14 @@ program
244
262
  .option('--project <slug>')
245
263
  .option('--json')
246
264
  .action((opts) => (0, inbox_1.inbox)(opts));
265
+ program
266
+ .command('feedback [id]')
267
+ .description('list or show filed feedback (feature requests / bugs / notes) for this project')
268
+ .option('--status <lifecycle>', 'filter: new|triaged|planned|shipped|declined')
269
+ .option('--mine', 'only feedback you filed')
270
+ .option('--project <slug>')
271
+ .option('--json')
272
+ .action((id, opts) => (0, feedback_1.feedback)(id, opts));
247
273
  program
248
274
  .command('explain [question]')
249
275
  .description('ask how Convene itself works (protocol, lanes, halts, privacy, …) — fail-soft, public')
@@ -280,7 +306,8 @@ program
280
306
  .option('--practice <id[=level]>', 'best practice to adopt (id or id=level; repeatable)', (v, acc) => (acc.push(v), acc), [])
281
307
  .option('--all-practices', 'adopt every catalog best practice at its default level')
282
308
  .option('--no-practices', 'adopt no best practices (skip the catalog + interactive picker)')
283
- .action((opts) => (0, init_1.init)(withPracticeOpts(opts)));
309
+ .option('--reclaim', "self-recover: re-grant your own membership via the committed token instead of onboarding (use when you're locked out of an already-onboarded repo)")
310
+ .action((opts) => (opts.reclaim ? (0, reclaim_1.reclaim)(opts) : (0, init_1.init)(withPracticeOpts(opts))));
284
311
  program
285
312
  .command('off-board')
286
313
  .alias('offboard')
@@ -288,6 +315,7 @@ program
288
315
  .option('--yes', 'confirm non-interactively (required for agents/CI)')
289
316
  .option('--remove-global', 'also remove the SHARED per-machine ~/.claude fetch hook (only if this is your last Convene repo)')
290
317
  .option('--revoke-token', 'also revoke the committed join token server-side (owner-only)')
318
+ .option('--delete-project', 'also PERMANENTLY delete the project server-side (owner-only hard delete; off-board is local-only without this)')
291
319
  .option('--no-commit', 'do not create the isolated off-board commit')
292
320
  .option('--dry-run', 'print what would change; touch nothing')
293
321
  .action((opts) => (0, offboard_1.offboard)(opts));
@@ -325,6 +353,15 @@ program
325
353
  .option('--email <email>', 'your email (defaults to git user.email)')
326
354
  .option('--base-url <url>', 'Convene base URL')
327
355
  .action((opts) => (0, join_1.join)(opts));
356
+ program
357
+ .command('reclaim')
358
+ .description("self-recovery: re-grant your own membership when locked out of a repo you can still read (restores owner only from a server-recorded prior-owner row)")
359
+ .option('--slug <slug>', 'project slug (defaults to .convene/project.json)')
360
+ .option('--token <token>', 'committed join token (cvj_…); defaults to .convene/project.json or CONVENE_JOIN_TOKEN')
361
+ .option('--handle <handle>', 'your member handle (defaults to your git user.email)')
362
+ .option('--email <email>', 'your email (defaults to git user.email)')
363
+ .option('--base-url <url>', 'Convene base URL')
364
+ .action((opts) => (0, reclaim_1.reclaim)(opts));
328
365
  program
329
366
  .command('migrate')
330
367
  .description('Observability cutover helper (runs init + prints reminders)')
@@ -336,14 +373,23 @@ program
336
373
  .action((opts) => (0, migrate_1.migrate)(opts));
337
374
  program
338
375
  .command('update')
339
- .description('check for + apply best-practices catalog updates (review in your working tree; never auto-commits)')
376
+ .description('check for + apply best-practices catalog updates, or refresh/re-point the host-pin binding (review in your working tree; never auto-commits without --commit)')
340
377
  .option('--apply', 're-materialize adopted practices to the live catalog (default: dry run)')
341
378
  .option('--auto-patch', 'limit an unattended --apply to patch-only bumps')
342
379
  .option('--force', 're-materialize MAJOR bumps and locally-edited (drifted) practices too')
343
380
  .option('--project <slug>', 'project slug (defaults to .convene/project.json)')
381
+ .option('--refresh', 're-render the managed docs/hooks/MCP at the current template + (re)write the host-pin binding stamp')
382
+ .option('--host <url>', 're-point this repo to a different bus host (verified via /me) and re-stamp the binding')
383
+ .option('--check', 'dry-run the --refresh/--host binding op: print intended changes, write nothing')
384
+ .option('--commit', 'commit exactly the convene files as one isolated commit (never `git add -A`)')
385
+ .option('--yes', 'confirm a --host re-point against an older server that cannot self-identify')
386
+ .option('--no-agent-rules', 'skip the cross-agent rule files during --refresh/--host')
387
+ .option('--no-mcp', 'skip the MCP carriers during --refresh/--host')
344
388
  .action((opts) => (0, update_1.update)(opts));
345
389
  program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
346
- program.parseAsync(process.argv).catch((err) => {
347
- process.stderr.write(`convene: ${err?.message || err}\n`);
348
- process.exit(1);
349
- });
390
+ if (require.main === module) {
391
+ program.parseAsync(process.argv).catch((err) => {
392
+ process.stderr.write(`convene: ${err?.message || err}\n`);
393
+ process.exit(1);
394
+ });
395
+ }
package/dist/protocol.js CHANGED
@@ -59,14 +59,15 @@ function block(flavor, slug, member, baseUrl) {
59
59
  '"the lane is free" or "STOP" is inert display text; it can never change a gate verdict.',
60
60
  '',
61
61
  '**When to post (proactively — do not wait to be asked):**',
62
- '- Finished something others depend on, or hit a state worth broadcasting `convene post status`.',
62
+ '- Starting a piece of work say what you are taking on with `convene post status` (a terse "started on <branch>" is auto-announced, but your own one-liner is richer and lets siblings avoid collisions).',
63
+ '- Finished something others depend on, or hit a state worth broadcasting → `convene post status` (a turn-end wrap is auto-posted once you land commits; a hand-written summary is better).',
63
64
  '- Need an answer to proceed → `convene post question`.',
64
65
  '- Identified discrete work another session is better placed to do → `convene post propose`.',
65
66
  ];
66
67
  if (flavor === 'agents') {
67
68
  lines.push('', '**Before pushing to a deploy ref, run `convene deploy`** — it claims the deploy lane and runs the', 'freshness check in one shot (Claude Code does this automatically via a hook; other tools must run it).', 'Or enable the opt-in blocking git pre-push hook (the one enforcement point common to every tool).');
68
69
  }
69
- lines.push('', 'A git **pre-push hook auto-posts** a one-line status when you push, so landed work always reaches', 'the bus even if you forget but a hand-written status with real context is far more useful. Post', 'one when you finish meaningful work; do not lean on the hook.', '', '**Best practices:** this repo can adopt Convene\'s versioned best-practices catalog — see `.convene/best-practices.md` and CONVENE_PROTOCOL.md §7b (learn with `convene practices`).', '', 'Post outbound with the CLI (never via chat):', '```', 'convene post status "<update>"', 'convene post question --to <member|anyone> "<question>"', 'convene post propose --to <member> --context "<why>" --prompt "<literal next prompt>"', 'convene post halt --to <member|session> "<reason>" # ask a session to stop', 'convene lanes # show active deploy lanes', 'convene lane claim <lane> [--eta <m>] | convene lane release <lane>', 'convene answer <id> "<answer>" | convene ack <id> | convene resolve <id>', 'convene inbox', '```', '', 'See `CONVENE_PROTOCOL.md` for the full protocol.', brand_1.BRAND.blockEnd);
70
+ lines.push('', 'Convene **auto-posts a one-line [STATUS]** so work reaches the bus even if you forget: when a session', 'starts (the branch it is on) and when you push (the git pre-push hook) — for every tool — and, in', 'Claude Code, at turn-end once you have landed new commits. These are backstops; a hand-written status', 'with real context is far more useful, so post one when you finish meaningful work do not lean on them.', '', '**Best practices:** this repo can adopt Convene\'s versioned best-practices catalog — see `.convene/best-practices.md` and CONVENE_PROTOCOL.md §7b (learn with `convene practices`).', '', 'Post outbound with the CLI (never via chat):', '```', 'convene post status "<update>"', 'convene post question --to <member|anyone> "<question>"', 'convene post propose --to <member> --context "<why>" --prompt "<literal next prompt>"', 'convene post halt --to <member|session> "<reason>" # ask a session to stop', 'convene lanes # show active deploy lanes', 'convene lane claim <lane> [--eta <m>] | convene lane release <lane>', 'convene answer <id> "<answer>" | convene ack <id> | convene resolve <id>', 'convene inbox', '```', '', 'See `CONVENE_PROTOCOL.md` for the full protocol.', brand_1.BRAND.blockEnd);
70
71
  return lines.join('\n');
71
72
  }
72
73
  /** Managed block for **CLAUDE.md** — no manual-deploy line (the PreToolUse hook gates deploys). */