convene-cli 1.11.0 → 1.13.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
@@ -42,6 +42,8 @@ class ConveneApi {
42
42
  headers['x-convene-session-instance'] = this.instance;
43
43
  if (opts.idempotencyKey)
44
44
  headers['idempotency-key'] = opts.idempotencyKey;
45
+ if (opts.headers)
46
+ Object.assign(headers, opts.headers);
45
47
  const res = await fetch(`${this.baseUrl}${brand_1.BRAND.apiBase}${apiPath}`, {
46
48
  method,
47
49
  headers,
@@ -154,6 +156,14 @@ class ConveneApi {
154
156
  join(slug, body, timeoutMs) {
155
157
  return this.request('POST', `/projects/${encodeURIComponent(slug)}/join`, { body, timeoutMs });
156
158
  }
159
+ /** Device enrollment — start (no bearer). Emails the member a confirm link if one exists. */
160
+ enrollStart(body, timeoutMs) {
161
+ return this.request('POST', '/enroll/device/start', { body, timeoutMs });
162
+ }
163
+ /** Device enrollment — poll for the minted key, presenting the raw device secret (no bearer). */
164
+ enrollPoll(enrollmentId, rawSecret, timeoutMs) {
165
+ return this.request('GET', `/enroll/device/poll?id=${encodeURIComponent(String(enrollmentId))}`, { headers: { 'x-device-secret': rawSecret }, timeoutMs });
166
+ }
157
167
  /** Self-recovery: re-grant your own membership via the committed join token (no bearer
158
168
  * auth required). Restores OWNER only from a server-recorded prior-owner row. */
159
169
  reclaim(slug, body, timeoutMs) {
package/dist/brand.js CHANGED
@@ -13,8 +13,8 @@ exports.BRAND = {
13
13
  product: 'Convene',
14
14
  slug: 'convene',
15
15
  bin: 'convene',
16
- domain: 'dev.convene.live',
17
- baseUrl: 'https://dev.convene.live',
16
+ domain: 'convene.live',
17
+ baseUrl: 'https://convene.live',
18
18
  /** Public product / front-door base URL for human-facing links (dashboard, /start, docs). */
19
19
  siteDomain: 'convene.live',
20
20
  siteUrl: 'https://convene.live',
package/dist/cache.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.OVERRIDE_TTL_MS = exports.writeWatchHighWater = exports.LIVE_SESSION_RECENT_SEC = exports.LIVE_SESSION_WINDOW_SEC = void 0;
6
+ exports.OVERRIDE_TTL_MS = exports.LIVE_SESSION_RECENT_SEC = exports.LIVE_SESSION_WINDOW_SEC = void 0;
7
7
  exports.readCache = readCache;
8
8
  exports.writeCache = writeCache;
9
9
  exports.ageSeconds = ageSeconds;
@@ -23,13 +23,12 @@ exports.markAutoIsolated = markAutoIsolated;
23
23
  exports.autoIsolatedAlready = autoIsolatedAlready;
24
24
  exports.announceAlreadyPosted = announceAlreadyPosted;
25
25
  exports.markAnnounced = markAnnounced;
26
+ exports.frictionAlreadyPosted = frictionAlreadyPosted;
27
+ exports.markFrictionPosted = markFrictionPosted;
26
28
  exports.readLastBroadcastSha = readLastBroadcastSha;
27
29
  exports.writeLastBroadcastSha = writeLastBroadcastSha;
28
- exports.readWatchHighWater = readWatchHighWater;
29
- exports.persistHighWater = persistHighWater;
30
- exports.appendWatchEntry = appendWatchEntry;
31
- exports.appendWatch = appendWatch;
32
- exports.readWatchSince = readWatchSince;
30
+ exports.readWatchCursor = readWatchCursor;
31
+ exports.persistWatchCursor = persistWatchCursor;
33
32
  exports.touchWatchHeartbeat = touchWatchHeartbeat;
34
33
  exports.watchHeartbeatAgeSec = watchHeartbeatAgeSec;
35
34
  exports.writeWatchPid = writeWatchPid;
@@ -361,6 +360,50 @@ function markAnnounced(slug, instance, branch) {
361
360
  /* best-effort */
362
361
  }
363
362
  }
363
+ // ── in-session friction-capture sentinel ──────────────────────────────────────
364
+ // `convene friction` files ONE feature_feedback row per DISTINCT in-session
365
+ // confusion signal (e.g. an unmatched `convene explain` question). The server
366
+ // idempotency-key is the authoritative dedupe; this local sentinel just spares the
367
+ // redundant post/spawn when the SAME signal recurs within a session. Keyed on the
368
+ // per-boot instance (a fresh boot re-captures) + the signal hash; bounded so a
369
+ // chatty session can't grow the file without limit.
370
+ const FRICTION_MAX_KEYS = 50;
371
+ function frictionFile(slug) {
372
+ return slugFile(scoped(slug), 'friction');
373
+ }
374
+ /** True iff this (instance, sigHash) friction signal was already posted this boot. */
375
+ function frictionAlreadyPosted(slug, instance, sigHash) {
376
+ try {
377
+ const e = JSON.parse(node_fs_1.default.readFileSync(frictionFile(slug), 'utf8'));
378
+ return !!e && e.instance === instance && Array.isArray(e.hashes) && e.hashes.includes(sigHash);
379
+ }
380
+ catch {
381
+ return false;
382
+ }
383
+ }
384
+ /** Record (instance, sigHash) as posted. Resets on a new instance; bounded. Best-effort. */
385
+ function markFrictionPosted(slug, instance, sigHash) {
386
+ try {
387
+ let e = { instance, hashes: [] };
388
+ try {
389
+ const prev = JSON.parse(node_fs_1.default.readFileSync(frictionFile(slug), 'utf8'));
390
+ if (prev && prev.instance === instance && Array.isArray(prev.hashes))
391
+ e = prev;
392
+ }
393
+ catch {
394
+ /* no prior entry / new instance → start fresh */
395
+ }
396
+ if (!e.hashes.includes(sigHash))
397
+ e.hashes.push(sigHash);
398
+ if (e.hashes.length > FRICTION_MAX_KEYS)
399
+ e.hashes = e.hashes.slice(-FRICTION_MAX_KEYS);
400
+ node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
401
+ node_fs_1.default.writeFileSync(frictionFile(slug), JSON.stringify(e), { mode: 0o600 });
402
+ }
403
+ catch {
404
+ /* best-effort */
405
+ }
406
+ }
364
407
  // ── last-broadcast-sha (announce ↔ wrap ↔ push dedup) ─────────────────────────
365
408
  // The most recent HEAD sha this session has already told the bus about — written
366
409
  // by `convene announce` (the session-start tip), `convene wrap` (a turn-end wrap),
@@ -390,19 +433,32 @@ function writeLastBroadcastSha(slug, sha) {
390
433
  /* best-effort */
391
434
  }
392
435
  }
393
- function watchFile(slug) {
394
- return slugFile(slug, 'watch.jsonl');
395
- }
396
- function watchHighWaterFile(slug) {
436
+ // ── watch poll resume cursor (WP12) ──────────────────────────────────────────
437
+ // The `convene watch` daemon (cli/src/commands/watch.ts) long-polls the bus,
438
+ // stamps a liveness heartbeat each iteration (the health line reads its age), and
439
+ // optionally desktop-notifies on a directed halt. It persists a MONOTONIC poll
440
+ // cursor here so a relaunch (every SessionStart) resumes incrementally instead of
441
+ // re-scanning the backlog from zero.
442
+ //
443
+ // The daemon does NOT render anything into agent-facing context: directed halts
444
+ // reach the agent via the server-truth PULL surfaces (fetch / session-open /
445
+ // lane-state), which query the server — the single authority for halt state. (The
446
+ // daemon formerly APPENDED matched halts to a per-slug `.watch.jsonl` for a reader
447
+ // to drain, but that reader was never wired into any surface, and a local cache
448
+ // could drift from server truth and mislead. The jsonl was removed in favor of the
449
+ // pull path; only this resume cursor + the heartbeat remain.)
450
+ function watchCursorFile(slug) {
451
+ // Legacy `.watch.hw` (high-water) extension retained so existing caches aren't
452
+ // orphaned on upgrade — the file is now the daemon's own poll resume cursor.
397
453
  return slugFile(slug, 'watch.hw');
398
454
  }
399
455
  function watchHeartbeatFile(slug) {
400
456
  return slugFile(slug, 'watch.hb');
401
457
  }
402
- /** Read the persisted high-water seq for the watch jsonl (0 if none). */
403
- function readWatchHighWater(slug) {
458
+ /** Read the daemon's persisted poll resume cursor (0 if none). */
459
+ function readWatchCursor(slug) {
404
460
  try {
405
- const n = parseInt(node_fs_1.default.readFileSync(watchHighWaterFile(slug), 'utf8').trim(), 10);
461
+ const n = parseInt(node_fs_1.default.readFileSync(watchCursorFile(slug), 'utf8').trim(), 10);
406
462
  return Number.isFinite(n) ? n : 0;
407
463
  }
408
464
  catch {
@@ -410,87 +466,21 @@ function readWatchHighWater(slug) {
410
466
  }
411
467
  }
412
468
  /**
413
- * Persist the high-water seq for the watch jsonl — MONOTONIC. A lower seq is
414
- * ignored (GREATEST semantics) so a reader can never rewind past material it
415
- * already drained. NEVER truncates the jsonl.
469
+ * Persist the daemon's poll resume cursor — MONOTONIC. A lower seq is ignored
470
+ * (GREATEST semantics) so a relaunch never rewinds the cursor and re-scans
471
+ * material it already polled past. Best-effort; never throws.
416
472
  */
417
- function persistHighWater(slug, seq) {
473
+ function persistWatchCursor(slug, seq) {
418
474
  try {
419
475
  node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
420
- const cur = readWatchHighWater(slug);
476
+ const cur = readWatchCursor(slug);
421
477
  if (seq > cur)
422
- node_fs_1.default.writeFileSync(watchHighWaterFile(slug), String(seq) + '\n', { mode: 0o600 });
478
+ node_fs_1.default.writeFileSync(watchCursorFile(slug), String(seq) + '\n', { mode: 0o600 });
423
479
  }
424
480
  catch {
425
481
  /* best-effort */
426
482
  }
427
483
  }
428
- /** Back-compat alias for the WP2 stub name; identical monotonic behavior. */
429
- exports.writeWatchHighWater = persistHighWater;
430
- /**
431
- * APPEND a watch entry to the jsonl (append-only — never rewrites the file).
432
- * The append is atomic at the OS level for a single small write, so a concurrent
433
- * reader sees whole lines. Best-effort; never throws (the watch daemon is
434
- * fail-open). Entries WITHOUT a finite numeric seq are dropped — an un-keyed
435
- * entry can't participate in the high-water drain and would be re-rendered
436
- * forever.
437
- */
438
- function appendWatchEntry(slug, entry) {
439
- if (!entry || typeof entry.seq !== 'number' || !Number.isFinite(entry.seq))
440
- return;
441
- try {
442
- node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
443
- node_fs_1.default.appendFileSync(watchFile(slug), JSON.stringify(entry) + '\n', { mode: 0o600 });
444
- }
445
- catch {
446
- /* best-effort */
447
- }
448
- }
449
- /** Back-compat alias for the WP2 stub name. */
450
- function appendWatch(slug, entry) {
451
- appendWatchEntry(slug, entry);
452
- }
453
- /**
454
- * Read all watch entries with seq > highWater, ascending by seq, de-duplicated
455
- * on seq (the daemon may re-append the same entry after a resume — the reader
456
- * tolerates duplicates by keeping the first per seq). Does NOT advance the
457
- * high-water and does NOT truncate — the caller decides what was actually
458
- * rendered and calls persistHighWater(slug, lastRenderedSeq). A malformed line
459
- * is skipped, never fatal.
460
- */
461
- function readWatchSince(slug, highWater) {
462
- let raw;
463
- try {
464
- raw = node_fs_1.default.readFileSync(watchFile(slug), 'utf8');
465
- }
466
- catch {
467
- return [];
468
- }
469
- const seen = new Set();
470
- const out = [];
471
- for (const line of raw.split('\n')) {
472
- const s = line.trim();
473
- if (!s)
474
- continue;
475
- let e;
476
- try {
477
- e = JSON.parse(s);
478
- }
479
- catch {
480
- continue; // a torn/partial line — skip, the next drain re-reads it whole
481
- }
482
- if (typeof e.seq !== 'number' || !Number.isFinite(e.seq))
483
- continue;
484
- if (e.seq <= highWater)
485
- continue;
486
- if (seen.has(e.seq))
487
- continue;
488
- seen.add(e.seq);
489
- out.push(e);
490
- }
491
- out.sort((a, b) => a.seq - b.seq);
492
- return out;
493
- }
494
484
  // ── watch heartbeat (WP12) ───────────────────────────────────────────────────
495
485
  // The daemon stamps a heartbeat each loop iteration; the health line / doctor
496
486
  // read its age. A stale/absent heartbeat ⇒ watch is down ⇒ surface DEGRADED.
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseAdoptTargets = parseAdoptTargets;
4
+ exports.runAdopt = runAdopt;
5
+ exports.adopt = adopt;
6
+ /**
7
+ * `convene adopt <id|id=level …>` — incrementally adopt catalog best practices into a
8
+ * repo that already has a manifest, WITHOUT replacing the adopted set.
9
+ *
10
+ * This is the missing inverse of the gap `convene update` cannot close. `update` only
11
+ * re-materializes practices ALREADY in the manifest (bumping them to the live catalog),
12
+ * and `convene init --practice X` REPLACES the whole adopted set with just X. So when
13
+ * the catalog GROWS a new practice, neither verb can take it — an agent is forced to
14
+ * hand-roll buildPracticesDoc/regionHash itself. `adopt` merges the named practice(s)
15
+ * into the existing set, drift-safely:
16
+ *
17
+ * - Existing adopted practices are PRESERVED byte-for-byte (their on-disk section,
18
+ * pinned version, hash, and level). adopt never silently bumps or re-renders them,
19
+ * and never clobbers a local edit; it re-renders ONLY the ids it is adding or
20
+ * deliberately re-leveling. Mechanism: render the full union (so every section
21
+ * lands in CATALOG order), then splice each untouched block back verbatim — the
22
+ * same preserve/splice rail `convene update --apply` uses. (Rendering only the new
23
+ * ids would REORDER the doc, because splicePreservedBlocks APPENDS any id missing
24
+ * from the freshly-rendered doc — see catalog/materialize.ts.)
25
+ * - manifest.catalogVersion is NOT advanced. adopt does not take the live catalog for
26
+ * EXISTING practices (that is `convene update`'s job, gated by its drift/major
27
+ * rails), so the "behind" watermark must stay honest — bumping it would make the
28
+ * next `convene update` read "up to date" and strand the existing practices at
29
+ * stale bodies. The one exception: bootstrapping an absent manifest stamps it to
30
+ * the catalog the first practice came from.
31
+ * - Live-catalog-first (so it can take a practice newer than the bundled mirror),
32
+ * fail-soft to the bundled mirror offline.
33
+ * - NEVER git-adds/commits — changes land in the working tree for the human to review
34
+ * + commit, exactly like init/update.
35
+ *
36
+ * Apply-by-default: naming a practice IS the deliberate act, so there is no --apply gate
37
+ * (an apply gate would recreate the very "update available → nothing to apply" dead-end
38
+ * this command exists to kill).
39
+ */
40
+ const config_1 = require("../config");
41
+ const git_1 = require("../git");
42
+ const api_1 = require("../api");
43
+ const catalog_1 = require("../catalog");
44
+ const report_1 = require("../catalog/report");
45
+ const manifest_1 = require("../catalog/manifest");
46
+ const materialize_1 = require("../catalog/materialize");
47
+ const ctx_1 = require("../ctx");
48
+ const log = (m) => process.stdout.write(m + '\n');
49
+ /**
50
+ * Parse + VALIDATE the raw `id` / `id=level` tokens against the catalog. PURE and
51
+ * THROWS on the first invalid token (no fs side-effects) so the command is atomic —
52
+ * one bad token aborts before anything is written. Mirrors select.ts parseSelectionFlags.
53
+ *
54
+ * A BARE id resolves to the practice's CURRENT adopted level if it is already in the
55
+ * manifest, else its defaultLevel — so `convene adopt <already-adopted-id>` is always a
56
+ * deliberate no-op, never an accidental re-level back to the default. Later duplicates
57
+ * win. `source` only flavors the unknown-id message: offline we cannot tell "the id
58
+ * does not exist" from "the id exists only in a newer catalog we could not fetch".
59
+ */
60
+ function parseAdoptTargets(targets, catalog, source, existingById) {
61
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
62
+ const slot = new Map(); // id → index in `out` (later-wins on dup)
63
+ const out = [];
64
+ for (const raw of targets) {
65
+ const eq = raw.indexOf('=');
66
+ const id = (eq >= 0 ? raw.slice(0, eq) : raw).trim();
67
+ const lvlTok = eq >= 0 ? raw.slice(eq + 1).trim() : null;
68
+ const p = byId.get(id);
69
+ if (!p) {
70
+ const near = catalog.practices
71
+ .filter((x) => x.id.includes(id) || id.includes(x.id))
72
+ .slice(0, 5)
73
+ .map((x) => x.id);
74
+ const hint = near.length ? ` Did you mean: ${near.join(', ')}` : '';
75
+ if (source === 'bundled') {
76
+ throw new Error(`unknown practice "${id}" in the bundled catalog v${catalog.version} (offline). ` +
77
+ 'It may exist in a newer live catalog — reconnect and retry, or refresh the bundled ' +
78
+ 'mirror (`npm i -g convene-cli@latest`).' +
79
+ hint);
80
+ }
81
+ throw new Error(`unknown practice "${id}" — not in the catalog (run \`convene practices\` to see ids).${hint}`);
82
+ }
83
+ let level;
84
+ if (lvlTok === null) {
85
+ level = existingById.get(id)?.level ?? p.defaultLevel;
86
+ }
87
+ else {
88
+ if (!p.availableLevels.includes(lvlTok)) {
89
+ throw new Error(`invalid level "${lvlTok}" for practice "${id}" — choose one of: ${p.availableLevels.join(', ')}`);
90
+ }
91
+ level = lvlTok;
92
+ }
93
+ const entry = { id, level, bare: lvlTok === null };
94
+ if (slot.has(id))
95
+ out[slot.get(id)] = entry;
96
+ else {
97
+ slot.set(id, out.length);
98
+ out.push(entry);
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Core of `convene adopt` with (top, manifest, catalog, parsed) already resolved — the
105
+ * seam tests drive this directly with a synthetic catalog (no fs/network resolution).
106
+ * Classifies each target, preserves every untouched section, re-materializes the union,
107
+ * splices the untouched blocks back, and rewrites the manifest. Never throws on user
108
+ * error (parseAdoptTargets already validated); never git-commits.
109
+ */
110
+ async function runAdopt(top, manifest, catalog, source, bootstrapped, parsed, opts) {
111
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
112
+ const existingById = new Map(manifest.practices.map((e) => [e.id, e]));
113
+ const drifted = bootstrapped ? new Set() : new Set((0, materialize_1.detectDrift)(top, manifest));
114
+ // Classify: NEW (not yet adopted) / RELEVEL (adopted, different level) / NOOP
115
+ // (adopted, same level) / drift-refusal (re-level of a locally-edited section without
116
+ // --force — a level change forces a re-render whose hash must change, so the splice
117
+ // cannot preserve the local edit; mirror update's skipForDrift).
118
+ const newIds = [];
119
+ const relevel = [];
120
+ const noops = [];
121
+ const driftRefusals = [];
122
+ for (const t of parsed) {
123
+ const prev = existingById.get(t.id);
124
+ if (!prev)
125
+ newIds.push(t);
126
+ else if (prev.level === t.level)
127
+ noops.push(t);
128
+ else if (drifted.has(t.id) && !opts.force)
129
+ driftRefusals.push(t);
130
+ else
131
+ relevel.push(t);
132
+ }
133
+ const touched = [...newIds, ...relevel];
134
+ const touchedIds = new Set(touched.map((t) => t.id));
135
+ // Nothing changes: report the no-ops / refusals and return WITHOUT writing the
136
+ // manifest/doc or reporting — a same-level adopt must be byte-identical to a no-op.
137
+ if (touched.length === 0) {
138
+ for (const t of noops)
139
+ log(`· ${t.id} already adopted at ${t.level} — unchanged`);
140
+ for (const t of driftRefusals) {
141
+ log(`refusing to re-level ${t.id}: its section was edited locally (drift) — re-run with --force to overwrite your edits, or revert the edit first.`);
142
+ }
143
+ if (driftRefusals.length === 0) {
144
+ log('Nothing to adopt — all named practices already adopted at the requested level.');
145
+ }
146
+ return;
147
+ }
148
+ // PRESERVE every untouched adopted section byte-for-byte (incl. drifted + removed-
149
+ // from-catalog ids): snapshot the on-disk blocks BEFORE re-materializing.
150
+ const allBlocks = (0, materialize_1.extractPracticeBlocks)(top);
151
+ const preserve = new Map();
152
+ for (const e of manifest.practices) {
153
+ if (touchedIds.has(e.id))
154
+ continue;
155
+ const b = allBlocks.get(e.id);
156
+ if (b)
157
+ preserve.set(e.id, b);
158
+ }
159
+ // Render the FULL UNION in catalog order: every adopted id STILL IN THE CATALOG at its
160
+ // CURRENT manifest level (a placeholder slot so the splice below is an in-place
161
+ // replace, never an append that would reorder the doc), plus each touched id at its
162
+ // TARGET level. An id removed from the catalog can't be rendered — it rides through
163
+ // `preserve` only.
164
+ const levelMap = new Map();
165
+ for (const e of manifest.practices)
166
+ if (byId.has(e.id) && !touchedIds.has(e.id))
167
+ levelMap.set(e.id, e.level);
168
+ for (const t of touched)
169
+ if (byId.has(t.id))
170
+ levelMap.set(t.id, t.level);
171
+ const renderSelections = [...levelMap].map(([id, level]) => ({ id, level }));
172
+ // Respect --no-hook exactly as init does: settingsJson(deny)/gitignore still apply
173
+ // (they are not Claude-Code hooks); only the settingsHook guards are skipped.
174
+ const wireHooks = !(opts.noHook === true || opts.hook === false);
175
+ const reMaterialized = (0, materialize_1.materializePractices)(top, '', renderSelections, catalog, { wireHooks });
176
+ // Splice the untouched blocks back over their just-rendered placeholders — restoring
177
+ // each one's original version marker, body (incl. local edits), and therefore its
178
+ // original regionHash. After this only the touched sections reflect the live catalog.
179
+ //
180
+ // Two narrow, accepted edges (shared with `update --apply`'s identical rail):
181
+ // (a) An untouched id whose section is MISSING from the doc on disk (a truncated /
182
+ // hand-mangled doc) is absent from `preserve`, so its just-rendered live body
183
+ // leaks through while its manifest entry stays pinned (a one-time false-drift
184
+ // on an already-out-of-sync doc). `update --apply` is the repair path.
185
+ // (b) An untouched id REMOVED from the live catalog can't be re-rendered (it isn't
186
+ // in renderSelections), so the splice APPENDS its preserved block at the doc
187
+ // tail rather than its catalog-order slot — manifest order is still correct;
188
+ // only the doc section's position drifts. (We still keep it — better than
189
+ // `update --apply`, which drops a removed practice's section entirely.)
190
+ (0, materialize_1.splicePreservedBlocks)(top, preserve);
191
+ // Assemble the next manifest: verbatim OLD entry for every untouched id (preserving
192
+ // version+hash+level so it is never re-flagged as drift), the FRESH entry for each
193
+ // re-leveled id, and the fresh entry APPENDED for each genuinely-new id (existing
194
+ // slots keep their order — minimal project.json diff, matching update's no-reorder
195
+ // convention; the ordering is locked by a test).
196
+ const freshById = new Map(reMaterialized.map((e) => [e.id, e]));
197
+ const kept = manifest.practices.map((old) => touchedIds.has(old.id) ? freshById.get(old.id) : old);
198
+ const existingIds = new Set(manifest.practices.map((e) => e.id));
199
+ const added = catalog.practices
200
+ .filter((p) => touchedIds.has(p.id) && !existingIds.has(p.id))
201
+ .map((p) => freshById.get(p.id));
202
+ const nextManifest = {
203
+ catalogVersion: bootstrapped ? catalog.version : manifest.catalogVersion,
204
+ channel: manifest.channel ?? 'minor',
205
+ practices: [...kept, ...added],
206
+ };
207
+ (0, config_1.writeManifest)(top, nextManifest);
208
+ // Report adoption to the dashboard — best-effort, fail-OPEN, fire-and-forget (never
209
+ // fails/slows adopt; skipped silently when --offline or no api key). Same as init/update.
210
+ const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug;
211
+ if (slug) {
212
+ const { ok } = await (0, report_1.reportManifest)(top, slug, nextManifest, { offline: opts.offline });
213
+ if (ok)
214
+ log('· reported adoption to the dashboard');
215
+ }
216
+ // Success summary — the agent-legible centerpiece: a line per target, then the
217
+ // verbatim never-commit rail so a reading agent knows the exact next step.
218
+ const offlineNote = source === 'bundled' ? ' — bundled/offline' : '';
219
+ const reLabel = relevel.length ? ` + re-leveled ${relevel.length}` : '';
220
+ log(`✓ adopted ${newIds.length}${reLabel} practice(s) into .convene/best-practices.md (catalog v${catalog.version}${offlineNote}):`);
221
+ for (const t of newIds) {
222
+ const p = byId.get(t.id);
223
+ log(` + ${t.id} v${p.version} [${p.tier} · ${t.level}]`);
224
+ }
225
+ for (const t of relevel) {
226
+ const prev = existingById.get(t.id);
227
+ const fresh = freshById.get(t.id);
228
+ // Re-leveling re-renders from the LIVE catalog, so it also re-pins the practice to
229
+ // the live version (and takes the live body) — which can cross a MAJOR boundary.
230
+ // Disclose that jump rather than presenting a re-level as level-only: an operator
231
+ // re-leveling a behind practice silently gets the new content otherwise.
232
+ const verNote = prev.version !== fresh.version
233
+ ? ` (v${prev.version} → v${fresh.version}${(0, manifest_1.bumpClass)(prev.version, fresh.version) === 'major' ? ', MAJOR catalog bump' : ''})`
234
+ : '';
235
+ log(` ↑ ${t.id} ${prev.level} → ${t.level}${verNote}`);
236
+ // A demotion can ORPHAN enforcement artifacts — adopt only ever ADDS (mergeDenyArray
237
+ // / ensureHook never subtract). Note it whenever the OLD level wired something the
238
+ // NEW level won't: a hook level → non-hook orphans the settingsHook/gitignore; a
239
+ // hook-hard → anything-else orphans permissions.deny (wired only at hook-hard, and
240
+ // only when the practice ships that artifact — else there is nothing to orphan).
241
+ const hookOrphaned = (0, materialize_1.isHookLevel)(prev.level) && !(0, materialize_1.isHookLevel)(t.level);
242
+ const denyOrphaned = prev.level === 'hook-hard' &&
243
+ t.level !== 'hook-hard' &&
244
+ (byId.get(t.id)?.artifacts.some((a) => a.kind === 'settingsJson') ?? false);
245
+ if (hookOrphaned || denyOrphaned) {
246
+ log(` note: demoting ${t.id} leaves its previously-wired hook/deny entries in .claude/settings.json; remove them by hand if you want enforcement fully relaxed.`);
247
+ }
248
+ }
249
+ for (const t of noops)
250
+ log(` · ${t.id} already adopted at ${t.level} — unchanged`);
251
+ for (const t of driftRefusals) {
252
+ log(` refusing to re-level ${t.id}: edited locally (drift) — re-run with --force to overwrite, or revert the edit.`);
253
+ }
254
+ log('');
255
+ log('Changes are in your working tree only. Review with `git diff` and commit yourself — Convene never commits.');
256
+ log('Learn the why behind any of these: `convene practices <id>`.');
257
+ }
258
+ /**
259
+ * `convene adopt` entry point: resolve env + catalog (live-first, bundled fallback) +
260
+ * the repo manifest (bootstrapping one if the repo adopted nothing yet), validate the
261
+ * targets up front (atomic), then hand off to runAdopt. Fail-soft throughout.
262
+ */
263
+ async function adopt(targets, opts = {}) {
264
+ const top = (0, git_1.gitToplevel)();
265
+ if (!top)
266
+ (0, ctx_1.die)('not a git repository — run `convene adopt` inside a repo');
267
+ if (!targets || targets.length === 0) {
268
+ (0, ctx_1.die)('usage: convene adopt <id|id=level> [more...] (see `convene practices` for ids)');
269
+ }
270
+ // Live catalog (fail-soft → bundled). Build an authed client only with a key;
271
+ // loadCatalog never blocks. Honor a committed host pin (pin-authoritative) so the
272
+ // catalog fetch + adoption report hit the bound bus — same as `convene update`.
273
+ const cfg = (0, config_1.resolveConfigForRepo)(top);
274
+ const api = cfg.apiKey && cfg.member ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, (0, git_1.sessionId)(cfg.member, top), cfg.tool) : null;
275
+ const { catalog, source } = await (0, catalog_1.loadCatalog)(api);
276
+ // Load or BOOTSTRAP the manifest: adopt must work on a repo onboarded with
277
+ // --no-practices (or pre-catalog) — there is simply nothing to merge into yet.
278
+ const existing = (0, config_1.loadManifest)(top);
279
+ const bootstrapped = existing === null;
280
+ const manifest = existing ?? {
281
+ catalogVersion: catalog.version,
282
+ channel: 'minor',
283
+ practices: [],
284
+ };
285
+ if (bootstrapped)
286
+ log('· no practices adopted yet — creating the best-practices manifest.');
287
+ const existingById = new Map(manifest.practices.map((e) => [e.id, e]));
288
+ let parsed;
289
+ try {
290
+ parsed = parseAdoptTargets(targets, catalog, source, existingById);
291
+ }
292
+ catch (e) {
293
+ (0, ctx_1.die)(e.message); // die() exits the process (never returns)
294
+ }
295
+ await runAdopt(top, manifest, catalog, source, bootstrapped, parsed, opts);
296
+ }