convene-cli 1.2.0 → 1.4.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.
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.pickPracticesInteractively = pickPracticesInteractively;
7
+ /**
8
+ * Interactive best-practices picker for `convene init` / `convene setup`.
9
+ *
10
+ * STRICTLY interactive: the caller MUST gate on `process.stdin.isTTY &&
11
+ * process.stdout.isTTY && !opts.yes` before invoking this — it is never reached in
12
+ * tests (which run non-TTY) or under `--yes` (agents/CI). The whole flow is wrapped
13
+ * so any error / EOF / Ctrl-D falls back to `[]` (adopt nothing) — a picker hiccup
14
+ * must NEVER crash onboarding. The readline interface is always closed in a finally.
15
+ */
16
+ const promises_1 = __importDefault(require("node:readline/promises"));
17
+ const select_1 = require("./select");
18
+ const out = (m) => process.stdout.write(m + '\n');
19
+ /** Count of opt-in (default-ON) practices in the named tiers — for the menu labels. */
20
+ function presetCount(catalog, preset) {
21
+ return (0, select_1.presetSelections)(catalog, preset).length;
22
+ }
23
+ /**
24
+ * Run the onboarding picker and resolve a Selection[]. Returns [] on EOF / any
25
+ * error / explicit "None". Never throws.
26
+ */
27
+ async function pickPracticesInteractively(catalog) {
28
+ const rl = promises_1.default.createInterface({ input: process.stdin, output: process.stdout });
29
+ try {
30
+ out('');
31
+ out('Adopt Convene best practices for this repo? (lean CLAUDE.md guidance now; enforcement hooks later)');
32
+ out(` [1] Essentials (${presetCount(catalog, 'essentials')})`);
33
+ out(` [2] Essentials + Recommended (${presetCount(catalog, 'recommended')}) [default]`);
34
+ out(` [3] Everything (${presetCount(catalog, 'all')})`);
35
+ out(' [4] Customize');
36
+ out(' [n] None');
37
+ const choice = (await rl.question('Choose [2]: ')).trim().toLowerCase();
38
+ if (choice === 'n' || choice === 'none')
39
+ return [];
40
+ if (choice === '1')
41
+ return (0, select_1.presetSelections)(catalog, 'essentials');
42
+ if (choice === '3')
43
+ return (0, select_1.presetSelections)(catalog, 'all');
44
+ if (choice === '4')
45
+ return await customize(catalog, rl);
46
+ // Empty input or "2" → the recommended default.
47
+ return (0, select_1.presetSelections)(catalog, 'recommended');
48
+ }
49
+ catch {
50
+ // EOF (Ctrl-D), a closed pipe, anything — never crash onboarding.
51
+ return [];
52
+ }
53
+ finally {
54
+ rl.close();
55
+ }
56
+ }
57
+ /**
58
+ * Customize from the recommended preset: for each chosen practice show
59
+ * `title [defaultLevel]` and accept enter=keep, 's'=skip, or a level name from
60
+ * availableLevels. Skimmable; unknown levels re-prompt-free (keep at default).
61
+ */
62
+ async function customize(catalog, rl) {
63
+ const base = (0, select_1.presetSelections)(catalog, 'recommended');
64
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
65
+ const result = [];
66
+ out('');
67
+ out('Customize — for each: [enter] keep · [s] skip · or type a level name.');
68
+ for (const sel of base) {
69
+ const p = byId.get(sel.id);
70
+ if (!p)
71
+ continue;
72
+ const levels = p.availableLevels.join('/');
73
+ const ans = (await rl.question(` ${p.title} [${sel.level}] (${levels}): `)).trim().toLowerCase();
74
+ if (ans === 's' || ans === 'skip')
75
+ continue;
76
+ if (ans === '') {
77
+ result.push(sel);
78
+ continue;
79
+ }
80
+ if (p.availableLevels.includes(ans)) {
81
+ result.push({ id: sel.id, level: ans });
82
+ }
83
+ else {
84
+ out(` (unrecognized level "${ans}" — keeping ${sel.level})`);
85
+ result.push(sel);
86
+ }
87
+ }
88
+ return result;
89
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reportManifest = reportManifest;
4
+ /**
5
+ * Phase 5 — report the adopted best-practices manifest to the server so the
6
+ * dashboard can show per-project adoption.
7
+ *
8
+ * This is PURELY observability and is ALWAYS best-effort / fail-OPEN: it must
9
+ * never fail or slow down `convene init` (onboarding) or `convene update --apply`
10
+ * because the report failed, was offline, or the server is down. The whole thing
11
+ * is fire-and-forget — every failure path returns `{ ok: false }` and is swallowed
12
+ * by the caller.
13
+ */
14
+ const api_1 = require("../api");
15
+ const config_1 = require("../config");
16
+ const git_1 = require("../git");
17
+ /** A short, fixed budget — the report is non-blocking, so never the 10s default. */
18
+ const REPORT_TIMEOUT_MS = 5_000;
19
+ /**
20
+ * Fire-and-forget the manifest report. Resolves to `{ ok }`, NEVER throws and
21
+ * NEVER prints. Skips silently (ok:false) when offline or with no API key — the
22
+ * report is optional and onboarding/update proceed regardless.
23
+ *
24
+ * - `offline` — caller ran with --offline; skip the network entirely.
25
+ * - no api key / no member — not logged in; nothing to authenticate the POST.
26
+ */
27
+ async function reportManifest(top, slug, manifest, opts = {}) {
28
+ try {
29
+ if (opts.offline)
30
+ return { ok: false };
31
+ const cfg = (0, config_1.resolveConfig)();
32
+ if (!cfg.apiKey)
33
+ return { ok: false };
34
+ const session = cfg.member ? (0, git_1.sessionId)(cfg.member, top) : null;
35
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool);
36
+ const res = await api.reportBestPractices(slug, manifest, REPORT_TIMEOUT_MS);
37
+ return { ok: res.ok };
38
+ }
39
+ catch {
40
+ // Belt-and-suspenders: ConveneApi.request is already fail-soft, but the
41
+ // report is observability-only — any unexpected throw is swallowed so a call
42
+ // site can fire-and-forget without a try/catch of its own.
43
+ return { ok: false };
44
+ }
45
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.selectionsForTiers = selectionsForTiers;
4
+ exports.parseSelectionFlags = parseSelectionFlags;
5
+ exports.presetSelections = presetSelections;
6
+ /** All `optInDefault:'on'` practices in the given tiers, at each one's defaultLevel. */
7
+ function selectionsForTiers(catalog, tiers) {
8
+ const want = new Set(tiers);
9
+ return catalog.practices
10
+ .filter((p) => want.has(p.tier) && p.optInDefault === 'on')
11
+ .map((p) => ({ id: p.id, level: p.defaultLevel }));
12
+ }
13
+ /**
14
+ * Resolve CLI selection flags into a Selection[] — or `null` when NO selection
15
+ * flag was passed (the caller then decides: prompt interactively, or skip the
16
+ * catalog entirely). Precedence, applied in CATALOG order at the end so the
17
+ * result is deterministic regardless of flag order:
18
+ * --no-practices → [] (explicitly adopt nothing)
19
+ * --all-practices → every practice at its defaultLevel
20
+ * --tier a,b → selectionsForTiers
21
+ * --practice id|id=lvl → add/override that id at the level (validated)
22
+ * `--practice` always wins over a tier pick for the same id.
23
+ */
24
+ function parseSelectionFlags(opts, catalog) {
25
+ const hasTier = typeof opts.tier === 'string' && opts.tier.length > 0;
26
+ const hasPractice = Array.isArray(opts.practice) && opts.practice.length > 0;
27
+ if (!opts.noPractices && !opts.allPractices && !hasTier && !hasPractice)
28
+ return null;
29
+ // --no-practices is the explicit "adopt nothing" — it short-circuits everything.
30
+ if (opts.noPractices)
31
+ return [];
32
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
33
+ // Keep levels in a map keyed by id; emit in CATALOG order at the end.
34
+ const chosen = new Map();
35
+ if (opts.allPractices) {
36
+ for (const p of catalog.practices)
37
+ chosen.set(p.id, p.defaultLevel);
38
+ }
39
+ else if (hasTier) {
40
+ const tiers = opts.tier.split(',').map((t) => t.trim()).filter(Boolean);
41
+ for (const s of selectionsForTiers(catalog, tiers))
42
+ chosen.set(s.id, s.level);
43
+ }
44
+ // --practice add/override (after tier so it wins): "id" → defaultLevel, "id=level" → that level.
45
+ if (hasPractice) {
46
+ for (const raw of opts.practice) {
47
+ const eq = raw.indexOf('=');
48
+ const id = (eq >= 0 ? raw.slice(0, eq) : raw).trim();
49
+ const lvl = eq >= 0 ? raw.slice(eq + 1).trim() : null;
50
+ const p = byId.get(id);
51
+ if (!p)
52
+ throw new Error(`unknown practice "${id}" — not in the catalog (run \`convene doctor\` to see ids)`);
53
+ let level;
54
+ if (lvl === null) {
55
+ level = p.defaultLevel;
56
+ }
57
+ else {
58
+ if (!p.availableLevels.includes(lvl)) {
59
+ throw new Error(`invalid level "${lvl}" for practice "${id}" — choose one of: ${p.availableLevels.join(', ')}`);
60
+ }
61
+ level = lvl;
62
+ }
63
+ chosen.set(id, level);
64
+ }
65
+ }
66
+ return catalog.practices.filter((p) => chosen.has(p.id)).map((p) => ({ id: p.id, level: chosen.get(p.id) }));
67
+ }
68
+ /**
69
+ * Named onboarding presets:
70
+ * essentials — essentials-tier opt-ins
71
+ * recommended — essentials + recommended opt-ins
72
+ * all — every practice at its defaultLevel
73
+ * none — []
74
+ */
75
+ function presetSelections(catalog, preset) {
76
+ switch (preset) {
77
+ case 'none':
78
+ return [];
79
+ case 'essentials':
80
+ return selectionsForTiers(catalog, ['essentials']);
81
+ case 'recommended':
82
+ return selectionsForTiers(catalog, ['essentials', 'recommended']);
83
+ case 'all':
84
+ return catalog.practices.map((p) => ({ id: p.id, level: p.defaultLevel }));
85
+ }
86
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical type contract for the Convene best-practices catalog — the data model
4
+ * shared by the server (source of truth, seeds the DB + serves GET /api/v1/catalog)
5
+ * and the CLI (a generated mirror it materializes into repos). Kept deliberately
6
+ * small and stable; the CLI carries a byte-mirror of this file (see brand.ts for the
7
+ * same dual-copy convention) so the two packages never import across the workspace.
8
+ *
9
+ * Versioning: every Practice carries its own SemVer (so a single practice can be
10
+ * tightened without rev'ing the whole catalog), and the Catalog itself carries a
11
+ * release SemVer. A repo's manifest pins the (practice, version, level) triple it
12
+ * adopted — see PracticeManifestEntry — so updates are diffable and never silent.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -13,6 +13,8 @@ const node_child_process_1 = require("node:child_process");
13
13
  const brand_1 = require("../brand");
14
14
  const api_1 = require("../api");
15
15
  const config_1 = require("../config");
16
+ const catalog_1 = require("../catalog");
17
+ const manifest_1 = require("../catalog/manifest");
16
18
  const git_1 = require("../git");
17
19
  const hook_1 = require("../hook");
18
20
  const cache_1 = require("../cache");
@@ -310,6 +312,48 @@ async function doctor(opts) {
310
312
  for (const c of checks) {
311
313
  process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
312
314
  }
315
+ // Best practices (local-only, advisory): adopted-practice inventory + whether
316
+ // the repo trails the catalog. Fail-soft — never throws and never alters the
317
+ // doctor exit code.
318
+ reportBestPractices(top, proj?.slug ?? null);
313
319
  if (!checks.every((c) => c.ok))
314
320
  process.exitCode = 1;
315
321
  }
322
+ /**
323
+ * Print doctor's "Best practices" section. Local-only this phase: reads the repo
324
+ * manifest and diffs it against the bundled catalog. Purely informational — it
325
+ * never throws (a malformed manifest is swallowed) and never sets the exit code.
326
+ * - manifest present → catalog freshness line + each adopted practice + unknowns.
327
+ * - on the bus but no manifest → a single nudge to adopt some.
328
+ * - not on the bus → nothing.
329
+ */
330
+ function reportBestPractices(top, slug) {
331
+ try {
332
+ const manifest = (0, config_1.loadManifest)(top);
333
+ if (!manifest) {
334
+ if (slug) {
335
+ process.stdout.write('· no best practices adopted — run `convene init` to choose some\n');
336
+ }
337
+ return;
338
+ }
339
+ const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog_1.CATALOG);
340
+ process.stdout.write(cmp.behind
341
+ ? `· catalog v${cmp.catalogVersion} available (repo on v${cmp.repoVersion}) — run \`convene update\`\n`
342
+ : `· best practices up to date at v${cmp.repoVersion}\n`);
343
+ for (const p of cmp.adopted) {
344
+ const flag = p.outdated ? ` (outdated → v${p.catalogVersion})` : '';
345
+ process.stdout.write(` ${p.id} @ ${p.manifestVersion} [${p.level}]${flag}\n`);
346
+ }
347
+ if (cmp.unknownIds.length) {
348
+ process.stdout.write(` unknown (not in catalog): ${cmp.unknownIds.join(', ')}\n`);
349
+ }
350
+ // Adoption is reported to the server on init/update — point the human at the
351
+ // dashboard view. Local-only hint; no network call (slug present ⇒ on the bus).
352
+ if (slug) {
353
+ process.stdout.write(` adoption is visible on the dashboard: ${(0, config_1.resolveConfig)().baseUrl}/p/${slug}\n`);
354
+ }
355
+ }
356
+ catch {
357
+ /* fail-soft: best-practices reporting must never break doctor */
358
+ }
359
+ }
@@ -21,6 +21,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
21
21
  const git_1 = require("../git");
22
22
  const config_1 = require("../config");
23
23
  const cache_1 = require("../cache");
24
+ const manifest_1 = require("../catalog/manifest");
24
25
  const api_1 = require("../api");
25
26
  const render_1 = require("../render");
26
27
  const catchup_1 = require("./catchup");
@@ -28,6 +29,30 @@ const exit_1 = require("../exit");
28
29
  const CACHE_TTL_SEC = 3;
29
30
  const FETCH_TIMEOUT_MS = 4000;
30
31
  const WATCHDOG_MS = 6000;
32
+ /** Catalog-version cache TTL for the behind-nudge — long enough to stay off the hot path. */
33
+ const CATALOG_VERSION_TTL_SEC = 3600;
34
+ /**
35
+ * A single fail-soft nudge line iff the repo's adopted-practices manifest trails a
36
+ * CACHED live catalog version. Reads only local state (the manifest + the catalog-
37
+ * version cache) — NEVER hits the network, so it can't slow the hot path. On a cache
38
+ * miss, no manifest, or any error → null (no line; never spam when up to date).
39
+ */
40
+ function catalogBehindNudge(top) {
41
+ try {
42
+ const manifest = (0, config_1.loadManifest)(top);
43
+ if (!manifest)
44
+ return null;
45
+ const live = (0, cache_1.readCatalogVersion)(CATALOG_VERSION_TTL_SEC);
46
+ if (!live)
47
+ return null;
48
+ if (!(0, manifest_1.semverLt)(manifest.catalogVersion, live))
49
+ return null;
50
+ return `convene: best-practices catalog v${live} available (repo on v${manifest.catalogVersion}) — run \`convene update\``;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
31
56
  /**
32
57
  * Write a rendered block. In `codexHook` mode, wrap it in Codex's UserPromptSubmit
33
58
  * hook envelope so its `additionalContext` lands in the model's per-turn context;
@@ -128,10 +153,21 @@ async function runFetch(opts = {}) {
128
153
  // DEGRADED/failure under --since-last emits nothing (structural suppression).
129
154
  return done(0);
130
155
  }
156
+ // A fail-soft, network-free nudge line emitted alongside a healthy block when
157
+ // the repo trails the CACHED live catalog version (suppressed under --json /
158
+ // codex-hook to keep those envelopes clean).
159
+ const emitNudge = () => {
160
+ if (opts.json || opts.codexHook)
161
+ return;
162
+ const nudge = catalogBehindNudge(top);
163
+ if (nudge)
164
+ process.stdout.write(nudge + '\n');
165
+ };
131
166
  // Cache short-circuit for rapid successive prompts.
132
167
  const cache = (0, cache_1.readCache)(slug);
133
168
  if (cache && (0, cache_1.ageSeconds)(cache) < CACHE_TTL_SEC) {
134
169
  renderData(cache.data, { slug, member, session, lookback, syncedAgoSec: (0, cache_1.ageSeconds)(cache), json: opts.json });
170
+ emitNudge();
135
171
  return done(0);
136
172
  }
137
173
  // Slow path: bounded network fetch.
@@ -140,6 +176,20 @@ async function runFetch(opts = {}) {
140
176
  if (res.ok && res.json) {
141
177
  (0, cache_1.writeCache)(slug, res.json);
142
178
  renderData(res.json, { slug, member, session, lookback, syncedAgoSec: 0, json: opts.json });
179
+ // Opportunistically refresh the catalog-version cache off the slow path (never
180
+ // its own round-trip on the hot path): only when the cached version is stale,
181
+ // and only if a manifest exists (a non-best-practices repo never needs it).
182
+ if ((0, config_1.loadManifest)(top) && !(0, cache_1.readCatalogVersion)(CATALOG_VERSION_TTL_SEC)) {
183
+ try {
184
+ const cat = await api.getCatalog(FETCH_TIMEOUT_MS);
185
+ if (cat.ok && cat.json?.version)
186
+ (0, cache_1.writeCatalogVersion)(cat.json.version);
187
+ }
188
+ catch {
189
+ /* fail-soft: the nudge just stays suppressed until next refresh */
190
+ }
191
+ }
192
+ emitNudge();
143
193
  return done(0);
144
194
  }
145
195
  // Fetch failed/timed out → DEGRADED from stale cache (or absent).
@@ -24,6 +24,11 @@ const api_1 = require("../api");
24
24
  const protocol_1 = require("../protocol");
25
25
  const hook_1 = require("../hook");
26
26
  const githook_1 = require("../githook");
27
+ const catalog_1 = require("../catalog");
28
+ const materialize_1 = require("../catalog/materialize");
29
+ const select_1 = require("../catalog/select");
30
+ const prompt_1 = require("../catalog/prompt");
31
+ const report_1 = require("../catalog/report");
27
32
  const ctx_1 = require("../ctx");
28
33
  /**
29
34
  * The repo-relative paths convene init writes (the onboarding footprint). Shared by
@@ -477,6 +482,44 @@ function writeMcpConfigs(top, baseUrl) {
477
482
  writeCodexConfig(top, baseUrl);
478
483
  log('· Cline & Windsurf register MCP only in a user-global file — add `convene` (`npx -y convene-mcp`) there manually; see CONVENE_PROTOCOL.md.');
479
484
  }
485
+ /**
486
+ * Resolve + materialize the adopted best practices for this repo (Phase 2). Flags
487
+ * win when present; otherwise a real interactive TTY (and NOT --yes) gets the
488
+ * picker; otherwise we skip silently. An EMPTY selection writes NOTHING (no
489
+ * manifest, no doc, no ref region) — that invariant keeps a plain init byte-identical.
490
+ */
491
+ async function adoptBestPractices(top, slug, opts) {
492
+ let selections;
493
+ const flags = (0, select_1.parseSelectionFlags)(opts, catalog_1.CATALOG);
494
+ if (flags !== null) {
495
+ selections = flags;
496
+ }
497
+ else if (process.stdin.isTTY && process.stdout.isTTY && !opts.yes) {
498
+ selections = await (0, prompt_1.pickPracticesInteractively)(catalog_1.CATALOG);
499
+ }
500
+ else {
501
+ // Non-interactive with no --tier/--practice flags: adopt nothing, silently — a plain
502
+ // headless onboarding stays byte-identical. `convene doctor` is where a human is
503
+ // nudged that best practices are available to adopt.
504
+ return;
505
+ }
506
+ // Respect --no-hook: when init declines to write the committed project settings,
507
+ // the materialized settingsHook guards are skipped too (settingsJson/gitignore
508
+ // still apply — they are not Claude-Code hooks).
509
+ const wireHooks = !(opts.noHook === true || opts.hook === false);
510
+ const entries = (0, materialize_1.materializePractices)(top, slug, selections, catalog_1.CATALOG, { wireHooks });
511
+ if (entries.length) {
512
+ const manifest = { catalogVersion: catalog_1.CATALOG.version, channel: 'minor', practices: entries };
513
+ (0, config_1.writeManifest)(top, manifest);
514
+ log(`✓ adopted ${entries.length} best practices (.convene/best-practices.md) — see them with \`convene doctor\``);
515
+ // Phase 5: report adoption to the server so the dashboard can show it.
516
+ // Best-effort, fail-OPEN, fire-and-forget — onboarding NEVER fails/slows
517
+ // because the report failed; skipped silently when --offline or no api key.
518
+ const { ok } = await (0, report_1.reportManifest)(top, slug, manifest, { offline: opts.offline });
519
+ if (ok)
520
+ log('· reported adoption to the dashboard');
521
+ }
522
+ }
480
523
  async function init(opts) {
481
524
  const top = (0, git_1.gitToplevel)();
482
525
  if (!top)
@@ -698,6 +741,12 @@ async function init(opts) {
698
741
  }
699
742
  // 8. memory seed (best-effort, outside the repo)
700
743
  seedMemory(top, slug, baseUrl);
744
+ // 8-bis. best-practices (Phase 2). Resolve selections from flags; else prompt
745
+ // interactively at a real TTY; else (non-TTY / no flags) skip. CRITICAL
746
+ // INVARIANT: when `selections` is empty NOTHING is written — no manifest, no
747
+ // .convene/best-practices.md, no ref region — so a plain non-interactive init
748
+ // stays byte-identical to its pre-catalog output (init.test.ts passes unchanged).
749
+ await adoptBestPractices(top, slug, opts);
701
750
  // 8a. optional isolated commit — stage ONLY the convene files (never `git add -A`),
702
751
  // so onboarding can never be bundled into unrelated work (the VAcontractorCo
703
752
  // entangled-commit failure). Off by default; `--commit` opts in.
@@ -27,8 +27,65 @@ const api_1 = require("../api");
27
27
  const hook_1 = require("../hook");
28
28
  const githook_1 = require("../githook");
29
29
  const init_1 = require("./init");
30
+ const materialize_1 = require("../catalog/materialize");
31
+ const catalog_1 = require("../catalog");
30
32
  const protocol_1 = require("../protocol");
31
33
  const ctx_1 = require("../ctx");
34
+ /**
35
+ * The permissions.deny entries to subtract on off-board. materialize.ts writes a
36
+ * practice's settingsJson(permissions.deny) artifact ONLY at hook-hard, so the exact
37
+ * set this repo could have materialized is the deny artifacts of the practices its
38
+ * MANIFEST records at hook-hard — not every practice in the live catalog. Filtering by
39
+ * the manifest (what was actually adopted) stops off-board from removing a teammate's
40
+ * own deny rule that happens to match an UNADOPTED catalog practice's entry, and keeps
41
+ * reversal correct as the catalog grows/shrinks after onboarding.
42
+ *
43
+ * Fallback: a repo with no readable manifest (pre-schema-2, or unreadable) → the full
44
+ * catalog managed set (the prior behavior), so older repos still get cleaned.
45
+ *
46
+ * KNOWN RESIDUAL: artifacts are read from the CURRENT catalog by practice id, so if an
47
+ * adopted practice's deny STRING itself changed between the version materialized and the
48
+ * current catalog, the old string can still be orphaned. Fully closing that needs the
49
+ * manifest to record the materialized contributions (a manifest-schema change, deferred).
50
+ */
51
+ function catalogManagedDenyEntries(manifest) {
52
+ const out = new Set();
53
+ const adopted = manifest ? new Map(manifest.practices.map((e) => [e.id, e.level])) : null;
54
+ for (const p of catalog_1.CATALOG.practices) {
55
+ if (adopted && adopted.get(p.id) !== 'hook-hard')
56
+ continue; // deny materializes only at hook-hard
57
+ for (const a of p.artifacts) {
58
+ if (a.kind === 'settingsJson') {
59
+ for (const d of (0, materialize_1.denyEntriesOf)(a))
60
+ out.add(d);
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ /**
67
+ * The .gitignore lines to subtract on off-board. materialize.ts appends a practice's
68
+ * gitignore artifact at ANY hook level (hook-soft / hook-hard), so the set is the
69
+ * gitignore artifacts of the practices the MANIFEST records at a hook level. Same
70
+ * manifest-filtering rationale (and the same fallback + known residual) as
71
+ * catalogManagedDenyEntries.
72
+ */
73
+ function catalogManagedGitignoreLines(manifest) {
74
+ const out = [];
75
+ const adopted = manifest ? new Map(manifest.practices.map((e) => [e.id, e.level])) : null;
76
+ for (const p of catalog_1.CATALOG.practices) {
77
+ if (adopted) {
78
+ const lvl = adopted.get(p.id);
79
+ if (!lvl || !(0, materialize_1.isHookLevel)(lvl))
80
+ continue; // gitignore materializes only at hook levels
81
+ }
82
+ for (const a of p.artifacts) {
83
+ if (a.kind === 'gitignore')
84
+ out.push(...a.lines);
85
+ }
86
+ }
87
+ return out;
88
+ }
32
89
  const log = (m) => process.stdout.write(m + '\n');
33
90
  const abs = (top, rel) => node_path_1.default.join(top, rel);
34
91
  const jsonOut = (obj) => JSON.stringify(obj, null, 2) + '\n';
@@ -84,15 +141,23 @@ function delIfOurs(top, rel, expected, touched, dryRun) {
84
141
  if (!dryRun)
85
142
  node_fs_1.default.rmSync(p, { force: true });
86
143
  }
87
- /** Strip the convene managed block from a Markdown doc; delete the file if nothing else remains. */
144
+ /**
145
+ * Strip BOTH convene-managed regions from a Markdown doc — the main coordination
146
+ * block AND the Phase-2 best-practices ref region (`<!-- convene:practices:begin -->`
147
+ * …) — in ONE read/write so the file is reported (and staged) at most once. Deletes
148
+ * the file if nothing else remains. The dedicated `.convene/best-practices.md` +
149
+ * `.convene/practices/` docs are removed wholesale by `delPath('.convene')`.
150
+ */
88
151
  function stripMarker(top, rel, touched, dryRun) {
89
152
  const p = abs(top, rel);
90
153
  if (!node_fs_1.default.existsSync(p))
91
154
  return;
92
- const { content, removed } = (0, init_1.removeMarkerBlock)(node_fs_1.default.readFileSync(p, 'utf8'));
93
- if (!removed)
155
+ const orig = node_fs_1.default.readFileSync(p, 'utf8');
156
+ const block = (0, init_1.removeMarkerBlock)(orig);
157
+ const ref = (0, materialize_1.removeRefRegion)(block.content);
158
+ if (!block.removed && !ref.removed)
94
159
  return;
95
- applyStripOrDelete(top, rel, content, touched, dryRun);
160
+ applyStripOrDelete(top, rel, ref.content, touched, dryRun);
96
161
  }
97
162
  /** Strip the convene block from a Codex config.toml; delete if nothing else remains. */
98
163
  function stripToml(top, rel, touched, dryRun) {
@@ -189,8 +254,15 @@ function finalizeJson(top, rel, obj, touched, dryRun) {
189
254
  node_fs_1.default.writeFileSync(p, jsonOut(obj));
190
255
  }
191
256
  }
192
- /** Strip convene hooks from the committed .claude/settings.json; delete if nothing else remains. */
193
- function stripClaudeSettings(top, rel, touched, dryRun) {
257
+ /**
258
+ * Strip convene-managed content from the committed .claude/settings.json — both the
259
+ * convene-authored hooks (withoutConveneHooks, which also matches the materialized
260
+ * `convene practice-guard …` PreToolUse/Stop hooks) AND the Phase-3 permissions.deny
261
+ * entries materialize.ts merged in (subtract EXACTLY the catalog's managed set;
262
+ * drop permissions.deny if it empties, then permissions if it empties). A teammate's
263
+ * own hooks / deny rules / settings are preserved. Delete the file if nothing remains.
264
+ */
265
+ function stripClaudeSettings(top, rel, touched, dryRun, manifest) {
194
266
  const p = abs(top, rel);
195
267
  if (!node_fs_1.default.existsSync(p))
196
268
  return;
@@ -202,7 +274,27 @@ function stripClaudeSettings(top, rel, touched, dryRun) {
202
274
  log(`· ${rel} (left as-is — unparseable JSON)`);
203
275
  return;
204
276
  }
205
- const { settings, removed } = (0, hook_1.withoutConveneHooks)(obj);
277
+ const stripped = (0, hook_1.withoutConveneHooks)(obj);
278
+ let removed = stripped.removed;
279
+ const settings = stripped.settings;
280
+ // Subtract the convene-managed permissions.deny entries (only ours — those this repo
281
+ // actually adopted at hook-hard, per the manifest).
282
+ const managedDeny = catalogManagedDenyEntries(manifest);
283
+ const deny = settings?.permissions?.deny;
284
+ if (Array.isArray(deny) && managedDeny.size) {
285
+ const kept = deny.filter((d) => !(typeof d === 'string' && managedDeny.has(d)));
286
+ if (kept.length !== deny.length) {
287
+ removed = true;
288
+ if (kept.length > 0) {
289
+ settings.permissions.deny = kept;
290
+ }
291
+ else {
292
+ delete settings.permissions.deny;
293
+ if (settings.permissions && Object.keys(settings.permissions).length === 0)
294
+ delete settings.permissions;
295
+ }
296
+ }
297
+ }
206
298
  if (!removed)
207
299
  return;
208
300
  if ((0, hook_1.settingsIsEmpty)(settings)) {
@@ -236,24 +328,41 @@ function handleGitHook(top, touched, dryRun) {
236
328
  else if (r.status === 'skipped-foreign')
237
329
  log('· .githooks/pre-push (left as-is — foreign hook)');
238
330
  }
239
- /** Reverse the .gitignore guard (and re-enable any blanket rule init disabled). */
240
- function handleGitignore(top, touched, dryRun) {
331
+ /**
332
+ * Reverse BOTH .gitignore footprints: init's cache guard (removeGitignoreGuard, which
333
+ * also re-enables any blanket rule init disabled) AND the Phase-3 practices lines +
334
+ * managed marker materialize.ts appended (removeGitignoreLines). Done in one
335
+ * read/write so the file is reported/staged at most once; deletes the file if empty.
336
+ */
337
+ function handleGitignore(top, touched, dryRun, manifest) {
241
338
  const rel = '.gitignore';
242
339
  const p = abs(top, rel);
243
340
  if (!node_fs_1.default.existsSync(p))
244
341
  return;
245
342
  const old = node_fs_1.default.readFileSync(p, 'utf8');
246
- const willChange = old.includes('.convene/cache/') ||
343
+ const practiceLines = catalogManagedGitignoreLines(manifest);
344
+ const practicesProbe = (0, materialize_1.removeGitignoreLines)(old, practiceLines);
345
+ const willChangeGuard = old.includes('.convene/cache/') ||
247
346
  old.includes('.convene/*.local.json') ||
248
347
  old.includes('# convene (keep local cache') ||
249
348
  /\(disabled by convene init/.test(old);
250
- if (!willChange)
349
+ if (!willChangeGuard && !practicesProbe.removed)
251
350
  return;
252
351
  if (dryRun) {
253
352
  touched.push({ rel, action: 'strip' });
254
353
  return;
255
354
  }
256
- if ((0, init_1.removeGitignoreGuard)(top)) {
355
+ // 1. Strip the Phase-3 practices lines first (write-back so removeGitignoreGuard
356
+ // operates on the practices-free body).
357
+ if (practicesProbe.removed) {
358
+ if (practicesProbe.content.trim().length === 0)
359
+ node_fs_1.default.rmSync(p, { force: true });
360
+ else
361
+ node_fs_1.default.writeFileSync(p, practicesProbe.content);
362
+ }
363
+ // 2. Then reverse init's cache guard (no-op if the file was already deleted).
364
+ const guardChanged = node_fs_1.default.existsSync(p) ? (0, init_1.removeGitignoreGuard)(top) : false;
365
+ if (practicesProbe.removed || guardChanged) {
257
366
  touched.push({ rel, action: node_fs_1.default.existsSync(p) ? 'strip' : 'delete' });
258
367
  }
259
368
  }
@@ -374,6 +483,11 @@ async function offboard(opts) {
374
483
  }
375
484
  // 2. Local footprint removal.
376
485
  const touched = [];
486
+ // Read the adopted-practices manifest BEFORE any deletion — delPath('.convene')
487
+ // removes .convene/project.json, the manifest's home. It tells off-board which
488
+ // practices (and at what level) this repo actually materialized, so reversal
489
+ // subtracts exactly those deny/gitignore entries (never a teammate's own rule).
490
+ const manifest = (0, config_1.loadManifest)(top);
377
491
  stripMarker(top, 'CLAUDE.md', touched, dryRun);
378
492
  stripMarker(top, 'AGENTS.md', touched, dryRun);
379
493
  handleProtocolDoc(top, touched, dryRun);
@@ -381,13 +495,13 @@ async function offboard(opts) {
381
495
  delPath(top, '.convene', touched, dryRun);
382
496
  delPath(top, '.cursor/rules/convene.mdc', touched, dryRun);
383
497
  delPath(top, '.clinerules/convene.md', touched, dryRun);
384
- stripClaudeSettings(top, '.claude/settings.json', touched, dryRun);
498
+ stripClaudeSettings(top, '.claude/settings.json', touched, dryRun, manifest);
385
499
  stripToml(top, '.codex/config.toml', touched, dryRun);
386
500
  stripJsonServer(top, '.cursor/mcp.json', 'mcpServers', touched, dryRun);
387
501
  stripJsonServer(top, '.vscode/mcp.json', 'servers', touched, dryRun);
388
502
  stripGemini(top, '.gemini/settings.json', touched, dryRun);
389
503
  handleGitHook(top, touched, dryRun);
390
- handleGitignore(top, touched, dryRun);
504
+ handleGitignore(top, touched, dryRun, manifest);
391
505
  if (!dryRun)
392
506
  cleanupEmptyDirs(top);
393
507
  // 3. Repo-specific seeded memory is always cleaned (it's keyed to THIS repo's path).