convene-cli 1.12.0 → 1.13.1

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.
@@ -11,6 +11,7 @@ exports.explain = explain;
11
11
  * offline agent still gets the essentials. The endpoint is unauthenticated, so
12
12
  * this works even before `convene login`.
13
13
  */
14
+ const node_child_process_1 = require("node:child_process");
14
15
  const config_1 = require("../config");
15
16
  const api_1 = require("../api");
16
17
  const brand_1 = require("../brand");
@@ -45,6 +46,20 @@ async function explain(question) {
45
46
  return;
46
47
  }
47
48
  if (res.ok && res.json && res.json.matched === false) {
49
+ // The agent asked how something works and Convene had no curated answer —
50
+ // the cleanest in-session friction signal. Capture it fire-and-forget
51
+ // (detached + unref'd, exactly as `convene fetch` spawns `convene announce`)
52
+ // so the product learns from confusion automatically instead of relying on a
53
+ // voluntary `convene suggest`. Gated on creds: no key/member → no spawn.
54
+ if (q && cfg.apiKey && cfg.member) {
55
+ try {
56
+ const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'friction', '--kind', 'unmatched-explain', '--q', q], { detached: true, stdio: 'ignore' });
57
+ child.unref();
58
+ }
59
+ catch {
60
+ /* fail-open: friction capture must never break `explain` */
61
+ }
62
+ }
48
63
  // Unmatched query — point at the index + bundled essentials (still exit 0).
49
64
  process.stdout.write(`No specific match for "${q}". ${brand_1.BRAND.product} basics:\n\n${bundledSummary(cfg.baseUrl)}\n`);
50
65
  return;
@@ -105,7 +105,10 @@ function catalogBehindNudge(top) {
105
105
  return null;
106
106
  if (!(0, manifest_1.semverLt)(manifest.catalogVersion, live))
107
107
  return null;
108
- return `convene: best-practices catalog v${live} available (repo on v${manifest.catalogVersion}) — run \`convene update\``;
108
+ // Shared phrasing with `convene doctor` `live` is the SERVER version (the
109
+ // cache is populated from api.getCatalog), so the "server catalog vX" wording
110
+ // is accurate here and means the SAME thing in both surfaces.
111
+ return `convene: ${(0, manifest_1.catalogBehindLine)(live, manifest.catalogVersion)}`;
109
112
  }
110
113
  catch {
111
114
  return null;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.friction = friction;
4
+ /**
5
+ * `convene friction` — automatic in-session CONFUSION capture. Files ONE
6
+ * low-severity feature_feedback row (category 'friction') when an agent hits a
7
+ * product rough edge the platform should learn from — today: an unmatched
8
+ * `convene explain` query (the agent literally asked how something works and
9
+ * Convene had no curated answer — the cleanest possible friction signal). It is
10
+ * spawned fire-and-forget by the surface that detects the friction (exactly as
11
+ * `convene fetch` spawns `convene announce`), so it NEVER blocks that surface.
12
+ *
13
+ * Mirrors `convene announce`'s posture:
14
+ * - FAIL-OPEN: any error / missing config / non-bus repo exits 0 silently; a 5s
15
+ * watchdog backstops a hang. It can never break the command that spawned it.
16
+ * - IDEMPOTENT: a deterministic server idempotency-key
17
+ * (`friction:<slug>:<instance>:<sigHash>`) is the authoritative dedupe; a local
18
+ * (instance, sigHash) sentinel spares the redundant post when the SAME signal
19
+ * recurs in a session.
20
+ * - PRIVACY: the captured text is the agent's own words about Convene-the-product
21
+ * — the same class of text `convene suggest` already mirrors to maintainers. It
22
+ * is CLIPPED and posted only behind a valid key + project membership (the
23
+ * authenticated-session gate). It never carries prompt text or work content
24
+ * beyond the question the agent typed.
25
+ */
26
+ const node_crypto_1 = require("node:crypto");
27
+ const git_1 = require("../git");
28
+ const config_1 = require("../config");
29
+ const cache_1 = require("../cache");
30
+ const api_1 = require("../api");
31
+ const exit_1 = require("../exit");
32
+ const SIGNAL_MAX = 240;
33
+ function clip(s, max) {
34
+ const t = s.trim();
35
+ return t.length <= max ? t : t.slice(0, max - 1) + '…';
36
+ }
37
+ /** Stable short hash of the normalized signal — keys both the idempotency key and the local sentinel. */
38
+ function signalHash(kind, signal) {
39
+ const norm = `${kind}\n${signal.toLowerCase().replace(/\s+/g, ' ').trim()}`;
40
+ return (0, node_crypto_1.createHash)('sha256').update(norm).digest('hex').slice(0, 16);
41
+ }
42
+ /** Human-readable feedback body for a friction kind. */
43
+ function bodyFor(kind, signal) {
44
+ const q = clip(signal, SIGNAL_MAX);
45
+ switch (kind) {
46
+ case 'unmatched-explain':
47
+ return `Unmatched \`convene explain\` query (no curated answer): "${q}"`;
48
+ default:
49
+ return `[${kind}] ${q}`;
50
+ }
51
+ }
52
+ async function run(opts) {
53
+ const kind = (opts.kind ?? '').trim();
54
+ const signal = (opts.q ?? '').trim();
55
+ if (!kind || !signal)
56
+ return; // no signal → nothing to capture
57
+ const top = (0, git_1.gitToplevel)();
58
+ if (!top)
59
+ return; // not a git repo → silent no-op
60
+ const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
61
+ if (!slug)
62
+ return; // repo not on the bus → silent no-op
63
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
64
+ const sig = signalHash(kind, signal);
65
+ const body = bodyFor(kind, signal);
66
+ if (opts.dryRun) {
67
+ process.stdout.write(body + '\n');
68
+ return;
69
+ }
70
+ // Already captured this exact signal this session — spare the redundant post.
71
+ if ((0, cache_1.frictionAlreadyPosted)(slug, instance, sig))
72
+ return;
73
+ // The authenticated-session gate: only post with a real key + member.
74
+ const cfg = (0, config_1.resolveConfig)();
75
+ if (!cfg.apiKey || !cfg.member)
76
+ return;
77
+ const session = (0, git_1.sessionId)(cfg.member, top);
78
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
79
+ const idem = `friction:${slug}:${instance}:${sig}`;
80
+ const res = await api.post(slug, { type: 'feature_feedback', body, category: 'friction', severity: 'low', tags: [kind] }, idem, 4000);
81
+ if (res.ok) {
82
+ // Only mark on success so a transient failure retries on the next occurrence.
83
+ (0, cache_1.markFrictionPosted)(slug, instance, sig);
84
+ if (res.json?.message?.short_id) {
85
+ process.stdout.write(`convene: captured [FRICTION] ${res.json.message.short_id} (${kind})\n`);
86
+ }
87
+ }
88
+ }
89
+ async function friction(opts = {}) {
90
+ // Backstop: force-exit on every path so a keep-alive socket can't linger.
91
+ const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
92
+ watchdog.unref();
93
+ const done = () => {
94
+ clearTimeout(watchdog);
95
+ (0, exit_1.exitClean)(0);
96
+ };
97
+ try {
98
+ await run(opts);
99
+ }
100
+ catch {
101
+ /* fail-open: a friction capture must never break the surface that spawned it */
102
+ }
103
+ done();
104
+ }
@@ -223,72 +223,9 @@ function registerHook(noHook) {
223
223
  log(hookSnippet());
224
224
  }
225
225
  }
226
- /**
227
- * The WP13 coordination hooks, in INSTALL ORDER. Order matters for the two Bash
228
- * PreToolUse entries: `gate-push --stdin` is wired before `guard`, so `guard` lands
229
- * LAST among Bash PreToolUse hooks (awareness/ux #10) — the deploy gate runs, then
230
- * the cheap halt/lane backstop. Each entry names the VERB its binary must support;
231
- * a stale `convene` missing the verb is skipped (so it can't error on every boot).
232
- *
233
- * `convene watch` is NOT a Bash hook — it's a long-running detached daemon that
234
- * `convene session-start` spawns from the SessionStart path (§4.4). Wiring it as a
235
- * blocking Bash/PreToolUse entry would stall; launching from session-start keeps it
236
- * off the discretionary tool path.
237
- */
238
- const COORD_HOOKS = [
239
- {
240
- event: 'SessionStart',
241
- matcher: 'startup|resume|clear',
242
- command: 'convene session-start',
243
- verb: 'session-start',
244
- note: 'auto catch-up digest + mints the session-instance + launches `convene watch`',
245
- },
246
- {
247
- event: 'PreToolUse',
248
- matcher: 'Bash',
249
- command: 'convene gate-push --stdin',
250
- verb: 'gate-push',
251
- note: 'deploy gate before a push (fail-open-loud)',
252
- },
253
- // guard is appended AFTER gate-push so it is LAST among Bash PreToolUse hooks.
254
- {
255
- event: 'PreToolUse',
256
- matcher: 'Bash',
257
- command: 'convene guard',
258
- verb: 'guard',
259
- note: 'halt + lane backstop for Bash (fail-open-loud)',
260
- },
261
- {
262
- event: 'PreToolUse',
263
- matcher: '.*',
264
- command: 'convene guard --halt-only',
265
- verb: 'guard',
266
- note: 'cheap directed-halt backstop on every tool call',
267
- },
268
- {
269
- event: 'PostToolUse',
270
- matcher: 'Bash',
271
- command: 'convene gate-push --post',
272
- verb: 'gate-push',
273
- note: 'release the deploy lane after a push (idempotent)',
274
- },
275
- {
276
- event: 'PostToolUse',
277
- matcher: 'Edit|Write|MultiEdit',
278
- command: 'convene beat --stdin',
279
- verb: 'beat',
280
- note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
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
- },
291
- ];
226
+ // COORD_HOOKS (the canonical coordination-hook set, in install order) + the
227
+ // missingCoordHooks drift-check now live in ../hook as the SINGLE source of truth,
228
+ // so the writer here and the `convene doctor` drift-check can never disagree.
292
229
  /**
293
230
  * Wire the WP13 coordination hooks into a settings file (global or committed
294
231
  * project), idempotent + merge-safe via ensureHook (deep-clone, never clobber,
@@ -297,7 +234,7 @@ const COORD_HOOKS = [
297
234
  */
298
235
  function registerCoordinationHooks(settingsPath, label) {
299
236
  let unparseable = false;
300
- for (const h of COORD_HOOKS) {
237
+ for (const h of hook_1.COORD_HOOKS) {
301
238
  if (!(0, hook_1.binarySupportsVerb)(h.verb)) {
302
239
  log(`· ${label}: skipped \`${h.command}\` — installed \`convene\` lacks \`${h.verb}\` (upgrade the CLI to enable).`);
303
240
  continue;
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.update = update;
7
+ exports.assessBindingCommitSafety = assessBindingCommitSafety;
7
8
  exports.runUpdate = runUpdate;
8
9
  /**
9
10
  * `convene update` — Phase 4: check for + apply best-practices catalog updates.
@@ -147,6 +148,88 @@ function warnStaleCarriers(top, host) {
147
148
  `(without --no-mcp) to fix: ${stale.join(', ')}`);
148
149
  }
149
150
  }
151
+ /**
152
+ * The managed files a binding re-stamp REWRITES (a curated subset of CONVENE_PATHS) —
153
+ * the only ones a divergent origin edit can actually CONFLICT on. Deliberately excludes
154
+ * the append/merge-only members: `.gitignore` (idempotent append-only guard),
155
+ * `.claude/settings.json` (additive JSON hook-merge), `.githooks/pre-push` (regenerated,
156
+ * no user content), and `CONVENE_PROTOCOL.md` (write-if-absent — never rewritten). An
157
+ * unrelated origin edit to one of those auto-merges, so refusing on it is a false alarm.
158
+ */
159
+ const BINDING_REWRITE_PATHS = [
160
+ 'CLAUDE.md',
161
+ 'AGENTS.md',
162
+ '.convene/project.json',
163
+ '.cursor/rules/convene.mdc',
164
+ '.clinerules/convene.md',
165
+ '.aider.conf.yml',
166
+ '.cursor/mcp.json',
167
+ '.vscode/mcp.json',
168
+ '.gemini/settings.json',
169
+ '.codex/config.toml',
170
+ ];
171
+ /**
172
+ * Decide whether committing the convene-managed files on the LOCAL HEAD would collide
173
+ * with origin. The incident: `convene update --host … --commit` on a checkout whose
174
+ * origin had ALREADY re-pointed (and committed the same CLAUDE.md/AGENTS.md/.convene
175
+ * files) produced a commit guaranteed to conflict on coordination-critical files —
176
+ * manual git-archaeology in front of contributors.
177
+ *
178
+ * Fails OPEN on any unverifiable git state (no origin, detached HEAD, fetch failed,
179
+ * branch absent on origin) — exactly like the deploy compat gate (gate-push.ts), we
180
+ * never block a commit we cannot PROVE is unsafe. Only a CONFIRMED collision refuses.
181
+ */
182
+ function assessBindingCommitSafety(top, host) {
183
+ if (!(0, git_1.originRemote)(top))
184
+ return { kind: 'ok', reason: 'no origin remote' };
185
+ const branch = (0, git_1.currentBranch)(top);
186
+ if (!branch)
187
+ return { kind: 'ok', reason: 'detached HEAD' };
188
+ if (!(0, git_1.gitFetch)(branch, 'origin', top))
189
+ return { kind: 'ok', reason: 'fetch failed (unverifiable)' };
190
+ const remote = (0, git_1.revParse)(`origin/${branch}`, top);
191
+ const head = (0, git_1.revParse)('HEAD', top);
192
+ if (!remote || !head)
193
+ return { kind: 'ok', reason: 'branch not on origin' };
194
+ // Origin tip is an ancestor of HEAD → local is up to date or ahead → a commit then
195
+ // push fast-forwards, no conflict possible.
196
+ if ((0, git_1.isAncestor)(remote, head, top))
197
+ return { kind: 'ok', reason: 'up to date / ahead of origin' };
198
+ // Behind or diverged. Does origin ALREADY carry this exact host binding? (A redundant
199
+ // re-commit here is the most common foot-gun — surface the friendlier pull message.)
200
+ const originProj = (0, git_1.gitShow)(`origin/${branch}`, '.convene/project.json', top);
201
+ if (originProj) {
202
+ try {
203
+ const b = JSON.parse(originProj)?.binding;
204
+ if (b?.host && (0, binding_1.normalizeHost)(b.host) === host)
205
+ return { kind: 'already-on-origin', branch };
206
+ }
207
+ catch {
208
+ /* unparseable origin project.json → fall through to the managed-file diff */
209
+ }
210
+ }
211
+ // Otherwise refuse ONLY if origin already changed a managed file the re-stamp
212
+ // REWRITES (BINDING_REWRITE_PATHS) — then a commit on top conflicts on it. A plain
213
+ // divergence that touched only append/merge-only managed files (or no managed file)
214
+ // is a routine rebase, not a convene-specific refusal.
215
+ const files = (0, git_1.changedPaths)('HEAD', `origin/${branch}`, BINDING_REWRITE_PATHS, top);
216
+ if (files.length)
217
+ return { kind: 'would-conflict', branch, files };
218
+ return { kind: 'ok', reason: 'diverged but origin did not touch managed files' };
219
+ }
220
+ /** The refusal/guidance message for a non-ok CommitSafety verdict. */
221
+ function bindingCommitMessage(s, host) {
222
+ if (s.kind === 'already-on-origin') {
223
+ return (`origin/${s.branch} already re-points to ${host} — pull (\`git pull --rebase\`), do not re-commit. ` +
224
+ `Re-stamping with --commit here would create a redundant commit that conflicts on .convene/project.json. (Override: --force.)`);
225
+ }
226
+ if (s.kind === 'would-conflict') {
227
+ return (`refusing to --commit: your branch is behind/diverged from origin/${s.branch}, which already changed ` +
228
+ `convene-managed files (${s.files.join(', ')}). Committing the re-stamp now will conflict on coordination-critical ` +
229
+ `files. Run \`git pull --rebase\` first, then re-run. (Override: --force.)`);
230
+ }
231
+ return '';
232
+ }
150
233
  async function runBindingRefresh(top, opts) {
151
234
  const existing = (0, config_1.loadProjectConfig)(top);
152
235
  if (!existing?.slug) {
@@ -209,6 +292,13 @@ async function runBindingRefresh(top, opts) {
209
292
  }
210
293
  const skipAgentRules = opts.noAgentRules === true || opts.agentRules === false;
211
294
  const skipMcp = opts.noMcp === true || opts.mcp === false;
295
+ // Divergence guard: a `--commit` that lands the re-stamped managed files on a local
296
+ // HEAD behind/diverged from an origin which ALSO changed those files conflicts on
297
+ // coordination-critical files (the VAcontractorCo incident). Assessed BEFORE any
298
+ // mutation so a refusal leaves the working tree clean. Only relevant under --commit.
299
+ const commitSafety = opts.commit
300
+ ? assessBindingCommitSafety(top, host)
301
+ : { kind: 'ok', reason: 'no --commit' };
212
302
  if (opts.check) {
213
303
  log(`Dry run — \`convene update ${opts.host ? `--host ${host}` : '--refresh'}\` would:`);
214
304
  log(` • re-render CLAUDE.md + AGENTS.md coordination blocks at ${host}`);
@@ -221,10 +311,24 @@ async function runBindingRefresh(top, opts) {
221
311
  log(` • update ~/.convene/config.json baseUrl → ${host}`);
222
312
  log(` • write the .convene/project.json binding stamp (schema 3${serverVerified ? ', server-confirmed' : ''})`);
223
313
  log(opts.commit ? ' • commit exactly the convene files as one isolated commit' : ' • leave changes in the working tree (no commit without --commit)');
314
+ if (opts.commit && commitSafety.kind !== 'ok') {
315
+ log('');
316
+ log(opts.force
317
+ ? ` ⚠ origin divergence detected (${commitSafety.kind}); --force is set, so --commit would proceed ANYWAY.`
318
+ : ` ✗ --commit would be REFUSED — ${bindingCommitMessage(commitSafety, host)}`);
319
+ }
224
320
  log('');
225
- log('Nothing written (--check). Re-run without --check to apply.');
321
+ log(opts.commit
322
+ ? 'Nothing written — origin was fetched only to assess the --commit divergence guard (no commit, no file changes). Re-run without --check to apply.'
323
+ : 'Nothing written (--check). Re-run without --check to apply.');
226
324
  return;
227
325
  }
326
+ // ENFORCE the divergence guard before mutating anything (clean tree on refusal).
327
+ if (opts.commit && commitSafety.kind !== 'ok') {
328
+ if (!opts.force)
329
+ (0, ctx_1.die)(bindingCommitMessage(commitSafety, host));
330
+ log(`⚠ --force: committing despite origin divergence (${commitSafety.kind}) — you will need to reconcile with origin afterward.`);
331
+ }
228
332
  log(`${opts.host ? 'Re-pointing' : 'Refreshing'} Convene binding for "${slug}" at ${host}…`);
229
333
  (0, init_1.writeCoordinationBlocks)(top, slug, member, host);
230
334
  if (!skipAgentRules)
@@ -279,11 +383,18 @@ async function runBindingRefresh(top, opts) {
279
383
  async function runUpdate(top, manifest, catalog, source, opts) {
280
384
  const rows = classify(manifest, catalog, top);
281
385
  const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog);
386
+ // Newly-available catalog practices this repo has NOT adopted — the delta `update`
387
+ // is otherwise blind to (it only bumps the already-adopted set). Surfaced in the dry
388
+ // run + as an apply pointer so the "update available → nothing to apply" dead-end
389
+ // becomes "the delta is N new practices; run `convene adopt <id>`". update NEVER
390
+ // auto-adopts — adoption stays a deliberate act (see `convene adopt`).
391
+ const adoptedIds = new Set(manifest.practices.map((e) => e.id));
392
+ const available = catalog.practices.filter((p) => !adoptedIds.has(p.id));
282
393
  if (!opts.apply) {
283
- printDryRun(rows, cmp.repoVersion, catalog.version, source, opts);
394
+ printDryRun(rows, cmp.repoVersion, catalog.version, source, opts, available);
284
395
  return;
285
396
  }
286
- await applyUpdate(top, manifest, catalog, rows, opts);
397
+ await applyUpdate(top, manifest, catalog, rows, opts, available);
287
398
  }
288
399
  /** Build the per-practice rows: status vs. the live catalog + drift flags. */
289
400
  function classify(manifest, catalog, top) {
@@ -313,11 +424,20 @@ function classify(manifest, catalog, top) {
313
424
  };
314
425
  });
315
426
  }
316
- function printDryRun(rows, repoVersion, catalogVersion, source, opts) {
427
+ function printDryRun(rows, repoVersion, catalogVersion, source, opts, available) {
317
428
  const behind = repoVersion !== catalogVersion;
318
- log(behind
319
- ? `Catalog update available: repo on v${repoVersion} → catalog v${catalogVersion}${source === 'bundled' ? ' (bundled — offline)' : ''}`
320
- : `Best practices up to date at catalog v${repoVersion}${source === 'bundled' ? ' (bundled — offline)' : ''}`);
429
+ const hasAdoptedBump = rows.some((r) => hasBump(r));
430
+ const bundledNote = source === 'bundled' ? ' (bundled — offline)' : '';
431
+ // The catalog watermark moving does NOT mean an adopted practice has an update: it
432
+ // also moves when the catalog merely ADDS practices. If nothing adopted has a real
433
+ // bump, don't imply adopted updates exist — that recreates the dead-end this
434
+ // surfacing was built to kill. The available-not-adopted block below carries the
435
+ // actual delta + the `convene adopt` pointer.
436
+ log(behind && !hasAdoptedBump
437
+ ? `Adopted practices up to date at catalog v${repoVersion}; catalog is at v${catalogVersion}${bundledNote}`
438
+ : behind
439
+ ? `Catalog update available: repo on v${repoVersion} → catalog v${catalogVersion}${bundledNote}`
440
+ : `Best practices up to date at catalog v${repoVersion}${bundledNote}`);
321
441
  log('');
322
442
  const idW = Math.max(8, ...rows.map((r) => r.id.length));
323
443
  log(` ${pad('practice', idW)} ${pad('level', 9)} ${pad('version', 18)} change`);
@@ -343,18 +463,54 @@ function printDryRun(rows, repoVersion, catalogVersion, source, opts) {
343
463
  const skippedMajor = rows.filter((r) => skipForMajor(r, opts));
344
464
  const skippedDrift = rows.filter((r) => skipForDrift(r, opts));
345
465
  const skippedPatchGate = rows.filter((r) => skipForPatchGate(r, opts));
346
- if (applicable.length === 0 && skippedMajor.length === 0 && skippedDrift.length === 0 && skippedPatchGate.length === 0) {
466
+ const nothingToApply = applicable.length === 0 && skippedMajor.length === 0 && skippedDrift.length === 0 && skippedPatchGate.length === 0;
467
+ // The load-bearing fix: when there is nothing to apply to the ADOPTED set, do NOT
468
+ // dead-end on a bare "Nothing to apply." while newly-available practices go unseen —
469
+ // fall through to the available block below.
470
+ if (nothingToApply && available.length === 0) {
347
471
  log('Nothing to apply.');
348
472
  return;
349
473
  }
350
- if (applicable.length) {
351
- log(`${applicable.length} practice(s) would update on \`convene update --apply\`.`);
474
+ if (nothingToApply) {
475
+ log('No updates to apply to your adopted practices.');
352
476
  }
353
- reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
477
+ else {
478
+ if (applicable.length) {
479
+ log(`${applicable.length} practice(s) would update on \`convene update --apply\`.`);
480
+ }
481
+ reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
482
+ }
483
+ printAvailable(available, catalogVersion);
484
+ if (!nothingToApply) {
485
+ log('');
486
+ log('Next: `convene update --apply` (review with `git diff` and commit yourself — Convene never commits).');
487
+ }
488
+ }
489
+ /**
490
+ * List catalog practices the repo has NOT adopted and point at `convene adopt`. The
491
+ * delta `convene update` cannot take (it only bumps the already-adopted set) — without
492
+ * this an agent that saw "Catalog update available: repo on vX → vY" runs `--apply`,
493
+ * gets "Nothing to apply", and never learns the new practices exist. No-op when empty.
494
+ */
495
+ function printAvailable(available, catalogVersion) {
496
+ if (available.length === 0)
497
+ return;
354
498
  log('');
355
- log('Next: `convene update --apply` (review with `git diff` and commit yourself Convene never commits).');
499
+ log(`${available.length} new practice(s) available in catalog v${catalogVersion} but NOT adopted here:`);
500
+ const aw = Math.max(8, ...available.map((p) => p.id.length));
501
+ for (const p of available)
502
+ log(` ${pad(p.id, aw)} ${pad(p.tier, 12)} ${p.title}`);
503
+ log('');
504
+ log('`convene update` only refreshes practices you already adopted — it never adopts new ones for you.');
505
+ log(`To add one: \`convene adopt <id>\` (e.g. \`convene adopt ${available[0].id}\`). See the why first: \`convene practices <id>\`.`);
506
+ }
507
+ /** One-line apply-path pointer to `convene adopt` for newly-available practices. */
508
+ function availablePointer(available) {
509
+ if (available.length === 0)
510
+ return;
511
+ log(` ${available.length} new practice(s) available but NOT adopted — \`convene update --apply\` never adopts new ones. Use \`convene adopt <id>\`.`);
356
512
  }
357
- async function applyUpdate(top, manifest, catalog, rows, opts) {
513
+ async function applyUpdate(top, manifest, catalog, rows, opts, available) {
358
514
  const byId = new Map(catalog.practices.map((p) => [p.id, p]));
359
515
  const toUpdate = rows.filter((r) => isApplicable(r, opts));
360
516
  const skippedMajor = rows.filter((r) => skipForMajor(r, opts));
@@ -365,6 +521,7 @@ async function applyUpdate(top, manifest, catalog, rows, opts) {
365
521
  log('Nothing applied — no practice was eligible.');
366
522
  reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
367
523
  reportRemoved(rows);
524
+ availablePointer(available); // never auto-adopts; just points at `convene adopt`
368
525
  return;
369
526
  }
370
527
  // PRESERVE skipped (drifted / major / patch-gated) sections byte-for-byte: snapshot
@@ -415,6 +572,7 @@ async function applyUpdate(top, manifest, catalog, rows, opts) {
415
572
  }
416
573
  reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
417
574
  reportRemoved(rows);
575
+ availablePointer(available); // never auto-adopts; just points at `convene adopt`
418
576
  log('');
419
577
  log('Changes are in your working tree only. Review with `git diff` and commit yourself — Convene never commits.');
420
578
  }
package/dist/git.js CHANGED
@@ -20,6 +20,8 @@ exports.revListCount = revListCount;
20
20
  exports.revParse = revParse;
21
21
  exports.isAncestor = isAncestor;
22
22
  exports.gitFetch = gitFetch;
23
+ exports.gitShow = gitShow;
24
+ exports.changedPaths = changedPaths;
23
25
  exports.gitHooksDir = gitHooksDir;
24
26
  exports.gitConfigSetLocal = gitConfigSetLocal;
25
27
  exports.gitConfigUnsetLocal = gitConfigUnsetLocal;
@@ -240,6 +242,22 @@ function gitFetch(ref, remote = 'origin', cwd = process.cwd()) {
240
242
  return false;
241
243
  }
242
244
  }
245
+ /**
246
+ * Contents of `ref:relPath` (e.g. `origin/main:.convene/project.json`), or null when
247
+ * the ref or path does not exist there. Read-only, bounded, never throws — used to
248
+ * peek at what a remote tip carries without checking it out.
249
+ */
250
+ function gitShow(ref, relPath, cwd = process.cwd()) {
251
+ return git(['show', `${ref}:${relPath}`], cwd);
252
+ }
253
+ /**
254
+ * File paths that DIFFER between two refs, restricted to `paths` (files or dirs):
255
+ * `git diff --name-only refA refB -- <paths>`. Empty array when identical or on error.
256
+ */
257
+ function changedPaths(refA, refB, paths, cwd = process.cwd()) {
258
+ const out = git(['diff', '--name-only', refA, refB, '--', ...paths], cwd);
259
+ return out ? out.split('\n').map((l) => l.trim()).filter(Boolean) : [];
260
+ }
243
261
  /** Absolute path to this repo's hooks directory (resolves worktrees/submodules). */
244
262
  function gitHooksDir(cwd = process.cwd()) {
245
263
  const p = git(['rev-parse', '--git-path', 'hooks'], cwd);
package/dist/hook.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.HOOK_COMMAND = exports.SETTINGS_PATH = void 0;
6
+ exports.COORD_HOOKS = exports.HOOK_COMMAND = exports.SETTINGS_PATH = void 0;
7
7
  exports.binarySupportsVerb = binarySupportsVerb;
8
8
  exports.readSettingsRaw = readSettingsRaw;
9
9
  exports.parseSettings = parseSettings;
@@ -13,6 +13,7 @@ exports.serializeSettings = serializeSettings;
13
13
  exports.genericHookIsRegistered = genericHookIsRegistered;
14
14
  exports.withGenericHook = withGenericHook;
15
15
  exports.ensureHook = ensureHook;
16
+ exports.missingCoordHooks = missingCoordHooks;
16
17
  exports.ensureHookRegistered = ensureHookRegistered;
17
18
  exports.isConveneHookCommand = isConveneHookCommand;
18
19
  exports.conveneHookFingerprint = conveneHookFingerprint;
@@ -174,6 +175,69 @@ function ensureHook(eventName, command, matcher, settingsPath = exports.SETTINGS
174
175
  return 'manual';
175
176
  }
176
177
  }
178
+ exports.COORD_HOOKS = [
179
+ {
180
+ event: 'SessionStart',
181
+ matcher: 'startup|resume|clear',
182
+ command: 'convene session-start',
183
+ verb: 'session-start',
184
+ note: 'auto catch-up digest + mints the session-instance + launches `convene watch`',
185
+ },
186
+ {
187
+ event: 'PreToolUse',
188
+ matcher: 'Bash',
189
+ command: 'convene gate-push --stdin',
190
+ verb: 'gate-push',
191
+ note: 'deploy gate before a push (fail-open-loud)',
192
+ },
193
+ // guard is appended AFTER gate-push so it is LAST among Bash PreToolUse hooks.
194
+ {
195
+ event: 'PreToolUse',
196
+ matcher: 'Bash',
197
+ command: 'convene guard',
198
+ verb: 'guard',
199
+ note: 'halt + lane backstop for Bash (fail-open-loud)',
200
+ },
201
+ {
202
+ event: 'PreToolUse',
203
+ matcher: '.*',
204
+ command: 'convene guard --halt-only',
205
+ verb: 'guard',
206
+ note: 'cheap directed-halt backstop on every tool call',
207
+ },
208
+ {
209
+ event: 'PostToolUse',
210
+ matcher: 'Bash',
211
+ command: 'convene gate-push --post',
212
+ verb: 'gate-push',
213
+ note: 'release the deploy lane after a push (idempotent)',
214
+ },
215
+ {
216
+ event: 'PostToolUse',
217
+ matcher: 'Edit|Write|MultiEdit',
218
+ command: 'convene beat --stdin',
219
+ verb: 'beat',
220
+ note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
221
+ },
222
+ // Stop fires at every turn-end (no tool matcher); `convene wrap` is idempotent +
223
+ // debounced via the last-broadcast-sha cursor, so it posts at most one wrap per
224
+ // stretch of new committed work and stays silent on idle turns. Never blocks.
225
+ {
226
+ event: 'Stop',
227
+ command: 'convene wrap',
228
+ verb: 'wrap',
229
+ note: 'session-end wrap status when new committed work landed (idempotent, fail-open)',
230
+ },
231
+ ];
232
+ /**
233
+ * COORD_HOOKS entries whose verb THIS binary supports but which are NOT present in
234
+ * `settings` — i.e. coordination wiring that has drifted from the canonical set.
235
+ * Verbs the binary lacks are skipped (mirroring registerCoordinationHooks), so an
236
+ * older CLI is never told to wire something it can't run.
237
+ */
238
+ function missingCoordHooks(settings) {
239
+ return exports.COORD_HOOKS.filter((h) => binarySupportsVerb(h.verb) && !genericHookIsRegistered(settings, h.event, h.command, h.matcher));
240
+ }
177
241
  /** Ensure the UserPromptSubmit hook is registered (idempotent, backs up). */
178
242
  function ensureHookRegistered() {
179
243
  const raw = readSettingsRaw();
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ const fetch_1 = require("./commands/fetch");
44
44
  const notify_1 = require("./commands/notify");
45
45
  const announce_1 = require("./commands/announce");
46
46
  const wrap_1 = require("./commands/wrap");
47
+ const friction_1 = require("./commands/friction");
47
48
  const post = __importStar(require("./commands/post"));
48
49
  const inbox_1 = require("./commands/inbox");
49
50
  const feedback_1 = require("./commands/feedback");
@@ -71,6 +72,7 @@ const watch_reap_1 = require("./commands/watch-reap");
71
72
  const explain_1 = require("./commands/explain");
72
73
  const practices_1 = require("./commands/practices");
73
74
  const update_1 = require("./commands/update");
75
+ const adopt_1 = require("./commands/adopt");
74
76
  const program = new commander_1.Command();
75
77
  exports.program = program;
76
78
  // Read the version from package.json so `convene --version` always tracks the
@@ -220,6 +222,14 @@ program
220
222
  .option('--project <slug>')
221
223
  .option('--dry-run', 'print the status it would post; do not post')
222
224
  .action((opts) => (0, wrap_1.wrap)(opts));
225
+ program
226
+ .command('friction')
227
+ .description('auto-capture in-session confusion as a low-severity [FRICTION] feature_feedback (idempotent, fail-silent)')
228
+ .option('--kind <kind>', 'friction kind (e.g. unmatched-explain)')
229
+ .option('--q <text>', "the signal text — the agent's own words about Convene")
230
+ .option('--project <slug>')
231
+ .option('--dry-run', 'print what it would capture; do not post')
232
+ .action((opts) => (0, friction_1.friction)(opts));
223
233
  const postCmd = program.command('post').description('post outbound coordination messages');
224
234
  postCmd
225
235
  .command('status <body>')
@@ -396,6 +406,14 @@ program
396
406
  .option('--no-agent-rules', 'skip the cross-agent rule files during --refresh/--host')
397
407
  .option('--no-mcp', 'skip the MCP carriers during --refresh/--host')
398
408
  .action((opts) => (0, update_1.update)(opts));
409
+ program
410
+ .command('adopt <targets...>')
411
+ .description('adopt one or more catalog best practices into this repo (merges into the existing set, drift-safe; review in your working tree, never auto-commits)')
412
+ .option('--force', 're-level a locally-edited (drifted) practice anyway (overwrites your edits)')
413
+ .option('--no-hook', 'do not wire settingsHook guards (settingsJson deny + gitignore still apply)')
414
+ .option('--offline', 'skip the dashboard adoption report')
415
+ .option('--project <slug>', 'project slug (defaults to .convene/project.json)')
416
+ .action((targets, opts) => (0, adopt_1.adopt)(targets, opts));
399
417
  program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
400
418
  if (require.main === module) {
401
419
  program.parseAsync(process.argv).catch((err) => {