convene-cli 1.2.0 → 1.3.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,42 @@ 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
+ * Every permissions.deny entry the catalog's settingsJson artifacts contribute —
36
+ * the EXACT set materialize.ts can write into .claude/settings.json at hook-hard.
37
+ * Off-board subtracts exactly these (and only these), so a teammate's own deny rule
38
+ * is never removed. Computed from CATALOG so it tracks the catalog automatically.
39
+ */
40
+ function catalogManagedDenyEntries() {
41
+ const out = new Set();
42
+ for (const p of catalog_1.CATALOG.practices) {
43
+ for (const a of p.artifacts) {
44
+ if (a.kind === 'settingsJson') {
45
+ for (const d of (0, materialize_1.denyEntriesOf)(a))
46
+ out.add(d);
47
+ }
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ /**
53
+ * Every .gitignore line the catalog's gitignore artifacts contribute — the set
54
+ * materialize.ts appends at any hook level. Off-board removes exactly these.
55
+ */
56
+ function catalogManagedGitignoreLines() {
57
+ const out = [];
58
+ for (const p of catalog_1.CATALOG.practices) {
59
+ for (const a of p.artifacts) {
60
+ if (a.kind === 'gitignore')
61
+ out.push(...a.lines);
62
+ }
63
+ }
64
+ return out;
65
+ }
32
66
  const log = (m) => process.stdout.write(m + '\n');
33
67
  const abs = (top, rel) => node_path_1.default.join(top, rel);
34
68
  const jsonOut = (obj) => JSON.stringify(obj, null, 2) + '\n';
@@ -84,15 +118,23 @@ function delIfOurs(top, rel, expected, touched, dryRun) {
84
118
  if (!dryRun)
85
119
  node_fs_1.default.rmSync(p, { force: true });
86
120
  }
87
- /** Strip the convene managed block from a Markdown doc; delete the file if nothing else remains. */
121
+ /**
122
+ * Strip BOTH convene-managed regions from a Markdown doc — the main coordination
123
+ * block AND the Phase-2 best-practices ref region (`<!-- convene:practices:begin -->`
124
+ * …) — in ONE read/write so the file is reported (and staged) at most once. Deletes
125
+ * the file if nothing else remains. The dedicated `.convene/best-practices.md` +
126
+ * `.convene/practices/` docs are removed wholesale by `delPath('.convene')`.
127
+ */
88
128
  function stripMarker(top, rel, touched, dryRun) {
89
129
  const p = abs(top, rel);
90
130
  if (!node_fs_1.default.existsSync(p))
91
131
  return;
92
- const { content, removed } = (0, init_1.removeMarkerBlock)(node_fs_1.default.readFileSync(p, 'utf8'));
93
- if (!removed)
132
+ const orig = node_fs_1.default.readFileSync(p, 'utf8');
133
+ const block = (0, init_1.removeMarkerBlock)(orig);
134
+ const ref = (0, materialize_1.removeRefRegion)(block.content);
135
+ if (!block.removed && !ref.removed)
94
136
  return;
95
- applyStripOrDelete(top, rel, content, touched, dryRun);
137
+ applyStripOrDelete(top, rel, ref.content, touched, dryRun);
96
138
  }
97
139
  /** Strip the convene block from a Codex config.toml; delete if nothing else remains. */
98
140
  function stripToml(top, rel, touched, dryRun) {
@@ -189,7 +231,14 @@ function finalizeJson(top, rel, obj, touched, dryRun) {
189
231
  node_fs_1.default.writeFileSync(p, jsonOut(obj));
190
232
  }
191
233
  }
192
- /** Strip convene hooks from the committed .claude/settings.json; delete if nothing else remains. */
234
+ /**
235
+ * Strip convene-managed content from the committed .claude/settings.json — both the
236
+ * convene-authored hooks (withoutConveneHooks, which also matches the materialized
237
+ * `convene practice-guard …` PreToolUse/Stop hooks) AND the Phase-3 permissions.deny
238
+ * entries materialize.ts merged in (subtract EXACTLY the catalog's managed set;
239
+ * drop permissions.deny if it empties, then permissions if it empties). A teammate's
240
+ * own hooks / deny rules / settings are preserved. Delete the file if nothing remains.
241
+ */
193
242
  function stripClaudeSettings(top, rel, touched, dryRun) {
194
243
  const p = abs(top, rel);
195
244
  if (!node_fs_1.default.existsSync(p))
@@ -202,7 +251,26 @@ function stripClaudeSettings(top, rel, touched, dryRun) {
202
251
  log(`· ${rel} (left as-is — unparseable JSON)`);
203
252
  return;
204
253
  }
205
- const { settings, removed } = (0, hook_1.withoutConveneHooks)(obj);
254
+ const stripped = (0, hook_1.withoutConveneHooks)(obj);
255
+ let removed = stripped.removed;
256
+ const settings = stripped.settings;
257
+ // Subtract the convene-managed permissions.deny entries (only ours).
258
+ const managedDeny = catalogManagedDenyEntries();
259
+ const deny = settings?.permissions?.deny;
260
+ if (Array.isArray(deny) && managedDeny.size) {
261
+ const kept = deny.filter((d) => !(typeof d === 'string' && managedDeny.has(d)));
262
+ if (kept.length !== deny.length) {
263
+ removed = true;
264
+ if (kept.length > 0) {
265
+ settings.permissions.deny = kept;
266
+ }
267
+ else {
268
+ delete settings.permissions.deny;
269
+ if (settings.permissions && Object.keys(settings.permissions).length === 0)
270
+ delete settings.permissions;
271
+ }
272
+ }
273
+ }
206
274
  if (!removed)
207
275
  return;
208
276
  if ((0, hook_1.settingsIsEmpty)(settings)) {
@@ -236,24 +304,41 @@ function handleGitHook(top, touched, dryRun) {
236
304
  else if (r.status === 'skipped-foreign')
237
305
  log('· .githooks/pre-push (left as-is — foreign hook)');
238
306
  }
239
- /** Reverse the .gitignore guard (and re-enable any blanket rule init disabled). */
307
+ /**
308
+ * Reverse BOTH .gitignore footprints: init's cache guard (removeGitignoreGuard, which
309
+ * also re-enables any blanket rule init disabled) AND the Phase-3 practices lines +
310
+ * managed marker materialize.ts appended (removeGitignoreLines). Done in one
311
+ * read/write so the file is reported/staged at most once; deletes the file if empty.
312
+ */
240
313
  function handleGitignore(top, touched, dryRun) {
241
314
  const rel = '.gitignore';
242
315
  const p = abs(top, rel);
243
316
  if (!node_fs_1.default.existsSync(p))
244
317
  return;
245
318
  const old = node_fs_1.default.readFileSync(p, 'utf8');
246
- const willChange = old.includes('.convene/cache/') ||
319
+ const practiceLines = catalogManagedGitignoreLines();
320
+ const practicesProbe = (0, materialize_1.removeGitignoreLines)(old, practiceLines);
321
+ const willChangeGuard = old.includes('.convene/cache/') ||
247
322
  old.includes('.convene/*.local.json') ||
248
323
  old.includes('# convene (keep local cache') ||
249
324
  /\(disabled by convene init/.test(old);
250
- if (!willChange)
325
+ if (!willChangeGuard && !practicesProbe.removed)
251
326
  return;
252
327
  if (dryRun) {
253
328
  touched.push({ rel, action: 'strip' });
254
329
  return;
255
330
  }
256
- if ((0, init_1.removeGitignoreGuard)(top)) {
331
+ // 1. Strip the Phase-3 practices lines first (write-back so removeGitignoreGuard
332
+ // operates on the practices-free body).
333
+ if (practicesProbe.removed) {
334
+ if (practicesProbe.content.trim().length === 0)
335
+ node_fs_1.default.rmSync(p, { force: true });
336
+ else
337
+ node_fs_1.default.writeFileSync(p, practicesProbe.content);
338
+ }
339
+ // 2. Then reverse init's cache guard (no-op if the file was already deleted).
340
+ const guardChanged = node_fs_1.default.existsSync(p) ? (0, init_1.removeGitignoreGuard)(top) : false;
341
+ if (practicesProbe.removed || guardChanged) {
257
342
  touched.push({ rel, action: node_fs_1.default.existsSync(p) ? 'strip' : 'delete' });
258
343
  }
259
344
  }