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.
package/dist/api.js CHANGED
@@ -207,9 +207,11 @@ class ConveneApi {
207
207
  }
208
208
  /**
209
209
  * GET /catalog — the canonical best-practices catalog (PUBLIC, no tenant data).
210
- * The CLI prefers this live read but is fully fail-soft: on any non-ok / network
211
- * error the caller falls back to the bundled offline mirror. Bounded by a short
212
- * timeout never the 10s default.
210
+ * The server returns a release ENVELOPE `{ version, contentHash, catalog }`; the
211
+ * actual Catalog lives under `.catalog` (loadCatalog unwraps it). The CLI prefers
212
+ * this live read but is fully fail-soft: on any non-ok / network error the caller
213
+ * falls back to the bundled offline mirror. Bounded by a short timeout — never
214
+ * the 10s default.
213
215
  */
214
216
  getCatalog(timeoutMs) {
215
217
  return this.request('GET', '/catalog', { timeoutMs });
package/dist/cache.js CHANGED
@@ -23,6 +23,8 @@ 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
30
  exports.readWatchCursor = readWatchCursor;
@@ -358,6 +360,50 @@ function markAnnounced(slug, instance, branch) {
358
360
  /* best-effort */
359
361
  }
360
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
+ }
361
407
  // ── last-broadcast-sha (announce ↔ wrap ↔ push dedup) ─────────────────────────
362
408
  // The most recent HEAD sha this session has already told the bus about — written
363
409
  // by `convene announce` (the session-start tip), `convene wrap` (a turn-end wrap),
@@ -6,16 +6,26 @@ const catalog_generated_1 = require("./catalog.generated");
6
6
  Object.defineProperty(exports, "CATALOG", { enumerable: true, get: function () { return catalog_generated_1.CATALOG; } });
7
7
  Object.defineProperty(exports, "CATALOG_VERSION", { enumerable: true, get: function () { return catalog_generated_1.CATALOG_VERSION; } });
8
8
  /**
9
- * Load the catalog, preferring the live server copy when `api` is given. Any
10
- * failure (no client, non-ok status, network error, empty body) silently falls
11
- * back to the bundled mirror this never throws.
9
+ * Load the catalog, preferring the live server copy when `api` is given. Unwraps
10
+ * the server's release envelope ({ version, contentHash, catalog }) and tolerates
11
+ * a bare Catalog for back-compat. Any failure (no client, non-ok status, network
12
+ * error, empty/malformed body) silently falls back to the bundled mirror — this
13
+ * never throws.
12
14
  */
13
15
  async function loadCatalog(api, timeoutMs = 5_000) {
14
16
  if (api) {
15
17
  try {
16
18
  const res = await api.getCatalog(timeoutMs);
17
- if (res.ok && res.json && Array.isArray(res.json.practices) && res.json.version) {
18
- return { catalog: res.json, source: 'live' };
19
+ // The server wraps the catalog in a release envelope
20
+ // ({ version, contentHash, catalog }); the Catalog (with `practices`) lives
21
+ // one level down under `.catalog`. Unwrap that, but also tolerate a bare
22
+ // Catalog for back-compat with a differently-shaped/older server. VALIDATE
23
+ // the UNWRAPPED object — the envelope's top-level `version` matches a bare
24
+ // Catalog's, so guarding on the inner `practices` is what tells them apart.
25
+ const body = res.json;
26
+ const cat = body && 'catalog' in body ? body.catalog : body;
27
+ if (res.ok && cat && Array.isArray(cat.practices) && typeof cat.version === 'string') {
28
+ return { catalog: cat, source: 'live' };
19
29
  }
20
30
  }
21
31
  catch {
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.semverLt = semverLt;
4
4
  exports.bumpClass = bumpClass;
5
5
  exports.compareToCatalog = compareToCatalog;
6
+ exports.catalogBehindLine = catalogBehindLine;
7
+ exports.bestPracticesFreshnessLine = bestPracticesFreshnessLine;
6
8
  /**
7
9
  * Strict SemVer less-than over the dotted numeric core (pre-release/build
8
10
  * metadata ignored — the catalog uses plain X.Y.Z). Missing components read as
@@ -69,3 +71,31 @@ function compareToCatalog(manifest, catalog) {
69
71
  unknownIds,
70
72
  };
71
73
  }
74
+ /**
75
+ * The canonical "your repo trails the catalog" nudge, shared by `convene doctor`
76
+ * and the `convene fetch` channel nudge so the SAME sentence ALWAYS means the
77
+ * same thing: `serverVersion` is the SERVER-published catalog version
78
+ * (authoritative), `repoVersion` is what this repo adopted. Never call this with
79
+ * a bundled-mirror version — a stale binary's bundled version is not "available"
80
+ * on the server (see `bestPracticesFreshnessLine`'s offline branch for that).
81
+ */
82
+ function catalogBehindLine(serverVersion, repoVersion) {
83
+ return `server catalog v${serverVersion} available (repo adopted v${repoVersion}) — run \`convene update\``;
84
+ }
85
+ /**
86
+ * The `convene doctor` best-practices freshness line, given a catalog comparison
87
+ * and WHERE the catalog came from. Server-truthful by construction: it only makes
88
+ * an "available"/"up to date" claim when the comparison was against the LIVE
89
+ * server catalog. When the server was unreachable the comparison is against the
90
+ * bundled mirror baked into this binary — which may trail OR lead the server — so
91
+ * it refuses to claim either and just states both versions + that the server is
92
+ * unknown. Pure (no stdout/network) so it unit-tests directly.
93
+ */
94
+ function bestPracticesFreshnessLine(cmp, source) {
95
+ if (source === 'live') {
96
+ return cmp.behind
97
+ ? catalogBehindLine(cmp.catalogVersion, cmp.repoVersion)
98
+ : `best practices up to date with server catalog v${cmp.repoVersion}`;
99
+ }
100
+ return `bundled catalog v${cmp.catalogVersion} (offline — server version unknown) vs repo adopted v${cmp.repoVersion}`;
101
+ }
@@ -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
+ }
@@ -3,6 +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.assessWatchHealth = assessWatchHealth;
6
7
  exports.login = login;
7
8
  exports.whoami = whoami;
8
9
  exports.assessLaneIdentity = assessLaneIdentity;
@@ -54,6 +55,41 @@ function relaunchWatch(slug) {
54
55
  return false;
55
56
  }
56
57
  }
58
+ /**
59
+ * PURE health verdict for the `doctor` "watch" line (unit-tested, no I/O). The
60
+ * watcher is a BEST-EFFORT liveness/notify daemon: it self-terminates once its
61
+ * owning session goes idle (~30m — see watch.ts) and is only relaunched at the
62
+ * next SessionStart, so a stale/absent heartbeat is the EXPECTED state for any
63
+ * quiet or long-lived session, NOT an error. Directed halts still surface via the
64
+ * pull path (fetch / session-open) and are still BLOCKED by the guard (live
65
+ * lane-state) whether or not this daemon is up. The check is therefore
66
+ * informational (never fails doctor); the verdict only drives the human-readable
67
+ * detail and whether `--fix` offers a relaunch.
68
+ * - alive — heartbeat fresh (age ≤ staleSec).
69
+ * - wedged — stale heartbeat but the pidfile owner is STILL ALIVE: a live process
70
+ * that has stopped stamping (the one genuinely odd state).
71
+ * - idle — stale/absent heartbeat and no live watcher: simply not running.
72
+ */
73
+ function assessWatchHealth(args) {
74
+ const { ageSec, staleSec, hasLiveWatcher } = args;
75
+ if (ageSec != null && ageSec <= staleSec)
76
+ return 'alive';
77
+ if (hasLiveWatcher)
78
+ return 'wedged';
79
+ return 'idle';
80
+ }
81
+ /** The `doctor` "watch" detail line for a health state (age in seconds, or null). */
82
+ function watchDetail(state, age) {
83
+ switch (state) {
84
+ case 'alive':
85
+ return `halt watcher alive (heartbeat ${age}s ago)`;
86
+ case 'wedged':
87
+ return `halt watcher process up but not heartbeating (${age}s) — restart the session if mid-turn halt pings stop`;
88
+ case 'idle':
89
+ default:
90
+ return 'halt watcher not running — relaunches at next session start (directed halts still surface via the pull path + the guard; `convene doctor --fix` starts one now)';
91
+ }
92
+ }
57
93
  function readStdin() {
58
94
  try {
59
95
  return node_fs_1.default.readFileSync(0, 'utf8').trim();
@@ -450,33 +486,54 @@ async function doctor(opts) {
450
486
  // 6b. settings non-destructiveness — Convene's edits stay additive + marker-scoped;
451
487
  // user-owned hooks/files are never clobbered. Fails only on genuine corruption.
452
488
  checks.push(assessSettingsIntegrity(top));
453
- // 7. watch heartbeata stale/absent heartbeat means the mid-task halt watcher
454
- // is DOWN (so directed halts won't surface between turns). Only meaningful for a
455
- // repo on the bus; --fix may (re)launch `convene watch` detached.
489
+ // 6c. coordination-hook driftthe committed project .claude/settings.json should
490
+ // carry the canonical COORD_HOOKS set. The writer (`convene init`) and this
491
+ // check share ONE source of truth (../hook), so a hook added to COORD_HOOKS
492
+ // (e.g. PR #55's Stop `convene wrap`) can't silently go unwired in a repo
493
+ // onboarded earlier. Verbs THIS binary lacks are skipped. ADVISORY (ok:true):
494
+ // drift is a nudge, not a hard failure — it must not fail doctor fleet-wide.
495
+ if (proj?.slug && top) {
496
+ const projSettings = (0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top)));
497
+ if (projSettings != null) {
498
+ const missing = (0, hook_1.missingCoordHooks)(projSettings);
499
+ checks.push({
500
+ name: 'coord-hooks',
501
+ ok: true,
502
+ detail: missing.length === 0
503
+ ? 'committed coordination hooks match the canonical set'
504
+ : `drifted — committed settings missing ${missing
505
+ .map((h) => `${h.event} \`${h.command}\``)
506
+ .join(', ')}; run \`convene init --refresh-docs\` to wire`,
507
+ });
508
+ }
509
+ }
510
+ // 7. watch heartbeat — the mid-task halt watcher is a BEST-EFFORT liveness/notify
511
+ // daemon that self-exits when its session goes idle and only relaunches at the
512
+ // next SessionStart, so a stale/absent heartbeat is EXPECTED for a quiet or
513
+ // long-lived session — NOT a failure. Directed halts surface via the pull path
514
+ // and are blocked by the guard regardless of this daemon. Informational only
515
+ // (never fails doctor); --fix offers a best-effort relaunch. See assessWatchHealth.
456
516
  if (proj?.slug) {
457
- let age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
458
- let watchOk = age != null && age <= WATCH_STALE_SEC;
459
- if (!watchOk && opts.fix) {
460
- if (relaunchWatch(proj.slug)) {
517
+ const slug = proj.slug;
518
+ const watcherAlive = () => {
519
+ const p = (0, cache_1.readWatchPid)(slug);
520
+ return !!(p && (0, cache_1.isPidAlive)(p.pid));
521
+ };
522
+ let age = (0, cache_1.watchHeartbeatAgeSec)(slug);
523
+ let state = assessWatchHealth({ ageSec: age, staleSec: WATCH_STALE_SEC, hasLiveWatcher: watcherAlive() });
524
+ if (state !== 'alive' && opts.fix) {
525
+ if (relaunchWatch(slug)) {
461
526
  // Give the freshly-launched daemon a beat to stamp its first heartbeat.
462
527
  const until = Date.now() + 2500;
463
528
  while (Date.now() < until) {
464
- age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
529
+ age = (0, cache_1.watchHeartbeatAgeSec)(slug);
465
530
  if (age != null && age <= WATCH_STALE_SEC)
466
531
  break;
467
532
  }
468
- watchOk = age != null && age <= WATCH_STALE_SEC;
533
+ state = assessWatchHealth({ ageSec: age, staleSec: WATCH_STALE_SEC, hasLiveWatcher: watcherAlive() });
469
534
  }
470
535
  }
471
- checks.push({
472
- name: 'watch',
473
- ok: watchOk,
474
- detail: watchOk
475
- ? `halt watcher alive (heartbeat ${age}s ago)`
476
- : age == null
477
- ? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
478
- : `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
479
- });
536
+ checks.push({ name: 'watch', ok: true, detail: watchDetail(state, age) });
480
537
  // 7a. reap orphaned watchers — the cleanup half of the daemon-leak fix. Only
481
538
  // under --fix (running `ps` on every doctor would slow the fast path). Runs
482
539
  // AFTER the relaunch + heartbeat-wait above so the freshly-relaunched watcher
@@ -579,22 +636,35 @@ async function doctor(opts) {
579
636
  for (const c of checks) {
580
637
  process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
581
638
  }
582
- // Best practices (local-only, advisory): adopted-practice inventory + whether
583
- // the repo trails the catalog. Fail-soft — never throws and never alters the
584
- // doctor exit code.
585
- reportBestPractices(top, proj?.slug ?? null);
639
+ // Best practices (advisory): adopted-practice inventory + whether the repo
640
+ // trails the catalog. Fail-soft — never throws and never alters the doctor exit
641
+ // code. Resolve the catalog SERVER-TRUTHFULLY first (prefer the live published
642
+ // catalog, fall back to the bundled mirror) — the same `loadCatalog` resolver
643
+ // `convene update` uses — so the "available" line reflects what the SERVER
644
+ // publishes, not the version baked into this binary. doctor is already async +
645
+ // already does network I/O (api.me above), so a live fetch here costs nothing
646
+ // extra; loadCatalog is fail-soft and tags its source, so an unreachable server
647
+ // simply yields the bundled mirror, labelled honestly as offline.
648
+ const { catalog: bpCatalog, source: bpSource } = await (0, catalog_1.loadCatalog)(cfg.apiKey ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey) : null);
649
+ reportBestPractices(top, proj?.slug ?? null, bpCatalog, bpSource);
586
650
  if (!checks.every((c) => c.ok))
587
651
  process.exitCode = 1;
588
652
  }
589
653
  /**
590
- * Print doctor's "Best practices" section. Local-only this phase: reads the repo
591
- * manifest and diffs it against the bundled catalog. Purely informational it
592
- * never throws (a malformed manifest is swallowed) and never sets the exit code.
593
- * - manifest present catalog freshness line + each adopted practice + unknowns.
654
+ * Print doctor's "Best practices" section. Diffs the repo manifest against the
655
+ * RESOLVED catalog (`catalog`/`source` come from loadCataloglive-preferred,
656
+ * bundled fallback). Purely informational it never throws (a malformed manifest
657
+ * is swallowed) and never sets the exit code.
658
+ * - manifest present → server-truthful freshness line + each adopted practice + unknowns.
594
659
  * - on the bus but no manifest → a single nudge to adopt some.
595
660
  * - not on the bus → nothing.
661
+ * The freshness wording is server-truthful: an "available"/"up to date" claim is
662
+ * made only when `source === 'live'`; an unreachable server yields the honest
663
+ * "bundled catalog … (offline — server version unknown)" line (see
664
+ * `bestPracticesFreshnessLine`). The same `catalogBehindLine` phrasing is shared
665
+ * with the `convene fetch` nudge so the sentence never means two different things.
596
666
  */
597
- function reportBestPractices(top, slug) {
667
+ function reportBestPractices(top, slug, catalog, source) {
598
668
  try {
599
669
  const manifest = (0, config_1.loadManifest)(top);
600
670
  if (!manifest) {
@@ -603,10 +673,8 @@ function reportBestPractices(top, slug) {
603
673
  }
604
674
  return;
605
675
  }
606
- const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog_1.CATALOG);
607
- process.stdout.write(cmp.behind
608
- ? `· catalog v${cmp.catalogVersion} available (repo on v${cmp.repoVersion}) — run \`convene update\`\n`
609
- : `· best practices up to date at v${cmp.repoVersion}\n`);
676
+ const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog);
677
+ process.stdout.write(`· ${(0, manifest_1.bestPracticesFreshnessLine)(cmp, source)}\n`);
610
678
  for (const p of cmp.adopted) {
611
679
  const flag = p.outdated ? ` (outdated → v${p.catalogVersion})` : '';
612
680
  process.stdout.write(` ${p.id} @ ${p.manifestVersion} [${p.level}]${flag}\n`);