convene-cli 1.1.1 → 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,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CATALOG_VERSION = exports.CATALOG = void 0;
4
+ exports.loadCatalog = loadCatalog;
5
+ const catalog_generated_1 = require("./catalog.generated");
6
+ Object.defineProperty(exports, "CATALOG", { enumerable: true, get: function () { return catalog_generated_1.CATALOG; } });
7
+ Object.defineProperty(exports, "CATALOG_VERSION", { enumerable: true, get: function () { return catalog_generated_1.CATALOG_VERSION; } });
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.
12
+ */
13
+ async function loadCatalog(api, timeoutMs = 5_000) {
14
+ if (api) {
15
+ try {
16
+ 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
+ }
20
+ }
21
+ catch {
22
+ /* fail-soft → bundled */
23
+ }
24
+ }
25
+ return { catalog: catalog_generated_1.CATALOG, source: 'bundled' };
26
+ }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.semverLt = semverLt;
4
+ exports.bumpClass = bumpClass;
5
+ exports.compareToCatalog = compareToCatalog;
6
+ /**
7
+ * Strict SemVer less-than over the dotted numeric core (pre-release/build
8
+ * metadata ignored — the catalog uses plain X.Y.Z). Missing components read as
9
+ * 0 so "1.2" < "1.2.1". Non-numeric components compare as 0.
10
+ */
11
+ function semverLt(a, b) {
12
+ const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0);
13
+ const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0);
14
+ const len = Math.max(pa.length, pb.length);
15
+ for (let i = 0; i < len; i++) {
16
+ const x = pa[i] ?? 0;
17
+ const y = pb[i] ?? 0;
18
+ if (x < y)
19
+ return true;
20
+ if (x > y)
21
+ return false;
22
+ }
23
+ return false;
24
+ }
25
+ /**
26
+ * Classify the bump from `from` → `to` over the dotted numeric core: 'major' if
27
+ * the X differs, else 'minor' if Y differs, else 'patch' if Z differs, else
28
+ * 'none'. A DOWNGRADE (to < from on any component) reads as 'none' — the repo is
29
+ * not behind, so update has nothing to take. Missing components read as 0.
30
+ */
31
+ function bumpClass(from, to) {
32
+ if (!semverLt(from, to))
33
+ return 'none'; // equal or ahead → nothing to take
34
+ const pa = from.split('.').map((n) => Number.parseInt(n, 10) || 0);
35
+ const pb = to.split('.').map((n) => Number.parseInt(n, 10) || 0);
36
+ if ((pa[0] ?? 0) !== (pb[0] ?? 0))
37
+ return 'major';
38
+ if ((pa[1] ?? 0) !== (pb[1] ?? 0))
39
+ return 'minor';
40
+ return 'patch';
41
+ }
42
+ /** Compare a repo's adopted manifest against the catalog. Pure; never throws. */
43
+ function compareToCatalog(manifest, catalog) {
44
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
45
+ const repoVersion = manifest.catalogVersion;
46
+ const catalogVersion = catalog.version;
47
+ const adopted = [];
48
+ const unknownIds = [];
49
+ for (const entry of manifest.practices) {
50
+ const cat = byId.get(entry.id);
51
+ if (!cat) {
52
+ unknownIds.push(entry.id);
53
+ continue;
54
+ }
55
+ adopted.push({
56
+ id: entry.id,
57
+ title: cat.title,
58
+ manifestVersion: entry.version,
59
+ catalogVersion: cat.version,
60
+ level: entry.level,
61
+ outdated: semverLt(entry.version, cat.version),
62
+ });
63
+ }
64
+ return {
65
+ repoVersion,
66
+ catalogVersion,
67
+ behind: repoVersion !== catalogVersion,
68
+ adopted,
69
+ unknownIds,
70
+ };
71
+ }
@@ -0,0 +1,516 @@
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.GITIGNORE_PRACTICES_MARKER = exports.REF_END = exports.REF_BEGIN = exports.PRACTICES_DOC_HEADER = exports.PRACTICE_END = exports.PRACTICE_BEGIN = void 0;
7
+ exports.renderPracticeSection = renderPracticeSection;
8
+ exports.regionHash = regionHash;
9
+ exports.currentRegionHashes = currentRegionHashes;
10
+ exports.extractPracticeBlocks = extractPracticeBlocks;
11
+ exports.splicePreservedBlocks = splicePreservedBlocks;
12
+ exports.detectDrift = detectDrift;
13
+ exports.buildPracticesDoc = buildPracticesDoc;
14
+ exports.upsertRefRegion = upsertRefRegion;
15
+ exports.removeRefRegion = removeRefRegion;
16
+ exports.isHookLevel = isHookLevel;
17
+ exports.mergeDenyArray = mergeDenyArray;
18
+ exports.mergeSettingsJson = mergeSettingsJson;
19
+ exports.denyEntriesOf = denyEntriesOf;
20
+ exports.ensureGitignoreLines = ensureGitignoreLines;
21
+ exports.removeGitignoreLines = removeGitignoreLines;
22
+ exports.materializePractices = materializePractices;
23
+ exports.dematerializePractices = dematerializePractices;
24
+ /**
25
+ * Materialize adopted best practices into a repo — the Phase-2 doc/ref core PLUS
26
+ * the Phase-3 ENFORCEMENT artifacts (settingsJson / gitignore / settingsHook).
27
+ * PURE-ish: the rendering helpers (renderPracticeSection / buildPracticesDoc /
28
+ * regionHash / upsertRefRegion / removeRefRegion / mergeDenyArray / ensureGitignoreLines)
29
+ * are side-effect-free and deterministic (no dates, byte-identical for identical
30
+ * inputs); only materialize/dematerialize touch the filesystem, and they do so
31
+ * idempotently via writeIfChanged.
32
+ *
33
+ * What lands in a repo when practices are adopted, BY LEVEL:
34
+ * - .convene/best-practices.md — the full managed catalog of adopted practices.
35
+ * - a small ref region in CLAUDE.md (@.convene/best-practices.md) and AGENTS.md.
36
+ * - any `doc` artifact under .convene/practices/ (write-if-absent).
37
+ * - settingsJson (permissions.deny) — ONLY at hook-hard (advisory → doc stanza only).
38
+ * - gitignore lines — at ANY hook level (hook-soft / hook-hard).
39
+ * - settingsHook (PreToolUse/Stop guards) — at hook-soft OR hook-hard, wired via
40
+ * ensureHook into the COMMITTED .claude/settings.json (only when init is
41
+ * installing the project settings — respects --no-hook).
42
+ *
43
+ * Modeled precisely on init.ts (upsertMarkerBlock / stripBetween / writeIfChanged):
44
+ * same marker discipline, same blank-line/newline collapsing, same "git history is
45
+ * the backup" no-.bak ethos. node:crypto is the only new dependency.
46
+ */
47
+ const node_crypto_1 = __importDefault(require("node:crypto"));
48
+ const node_fs_1 = __importDefault(require("node:fs"));
49
+ const node_path_1 = __importDefault(require("node:path"));
50
+ const hook_1 = require("../hook");
51
+ // ── Markers ────────────────────────────────────────────────────────────────
52
+ // Per-practice markers carry the version so a drift/update pass can find a stale
53
+ // region. The ref region markers (added to CLAUDE.md/AGENTS.md) are a separate,
54
+ // versionless pair — that block's content is a single stable @-import line.
55
+ /** Begin marker for one practice's section inside .convene/best-practices.md. */
56
+ const PRACTICE_BEGIN = (id, version) => `<!-- convene:practice ${id} v${version} -->`;
57
+ exports.PRACTICE_BEGIN = PRACTICE_BEGIN;
58
+ /** End marker for one practice's section. */
59
+ const PRACTICE_END = (id) => `<!-- /convene:practice ${id} -->`;
60
+ exports.PRACTICE_END = PRACTICE_END;
61
+ /** Stable managed header for .convene/best-practices.md — no timestamps (PURE). */
62
+ exports.PRACTICES_DOC_HEADER = '<!-- convene:best-practices (managed) — do not edit between the practice markers; local edits are overwritten on update -->\n' +
63
+ '# Adopted best practices\n\n' +
64
+ 'This file is generated and maintained by Convene from the shared best-practices catalog.\n' +
65
+ 'Each section below is an adopted practice, pinned at the level your repo chose.\n';
66
+ /** Begin/end markers for the small ref region added to CLAUDE.md / AGENTS.md. */
67
+ exports.REF_BEGIN = '<!-- convene:practices:begin -->';
68
+ exports.REF_END = '<!-- convene:practices:end -->';
69
+ // ── Pure rendering ───────────────────────────────────────────────────────────
70
+ /**
71
+ * A LEAN markdown section for one adopted practice: a heading tagged with tier and
72
+ * level, the practice's claudeMd body (if any), then a one-line note per `ci` /
73
+ * `doc` artifact. Pure (no dates); kept tight because this loads as guidance.
74
+ */
75
+ function renderPracticeSection(p, level) {
76
+ const lines = [`## ${p.title} [${p.tier} · ${level}]`];
77
+ for (const a of p.artifacts) {
78
+ if (a.kind === 'claudeMd')
79
+ lines.push(a.body);
80
+ }
81
+ for (const a of p.artifacts) {
82
+ if (a.kind === 'ci')
83
+ lines.push(`CI: ${a.body}`);
84
+ else if (a.kind === 'doc')
85
+ lines.push(`Doc: see ${a.path}`);
86
+ }
87
+ return lines.join('\n');
88
+ }
89
+ /** sha256 hex of a section's text — the manifest's drift-detection hash. */
90
+ function regionHash(section) {
91
+ return node_crypto_1.default.createHash('sha256').update(section, 'utf8').digest('hex');
92
+ }
93
+ /** Path to a repo's managed best-practices doc. */
94
+ function practicesDocPath(top) {
95
+ return node_path_1.default.join(top, '.convene', 'best-practices.md');
96
+ }
97
+ /**
98
+ * Parse .convene/best-practices.md at `top` and return a map of practice id →
99
+ * regionHash of that practice's CURRENT inner body (the text between its
100
+ * PRACTICE_BEGIN/END markers, with the single wrapping newlines buildPracticesDoc
101
+ * adds stripped). The hash is taken over the same string renderPracticeSection
102
+ * produces, so a clean (unedited) region hashes byte-identical to the manifest
103
+ * entry. A missing/unreadable doc → empty map (no drift can be asserted). Pure
104
+ * read; never throws.
105
+ */
106
+ function currentRegionHashes(top) {
107
+ const out = new Map();
108
+ let doc;
109
+ try {
110
+ doc = node_fs_1.default.readFileSync(practicesDocPath(top), 'utf8');
111
+ }
112
+ catch {
113
+ return out; // no doc → nothing to hash
114
+ }
115
+ // PRACTICE_BEGIN(id, v) and PRACTICE_END(id) are HTML comments; capture the id
116
+ // from BEGIN and the inner body up to the matching END. buildPracticesDoc wraps
117
+ // the section as: BEGIN + '\n' + section + '\n' + END, so trim exactly one
118
+ // leading and one trailing newline to recover the rendered section verbatim.
119
+ const re = /<!-- convene:practice (\S+) v\S+ -->([\s\S]*?)<!-- \/convene:practice \1 -->/g;
120
+ let m;
121
+ while ((m = re.exec(doc)) !== null) {
122
+ const id = m[1];
123
+ let body = m[2];
124
+ if (body.startsWith('\n'))
125
+ body = body.slice(1);
126
+ if (body.endsWith('\n'))
127
+ body = body.slice(0, -1);
128
+ out.set(id, regionHash(body));
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Extract each practice's FULL on-disk block (BEGIN marker → END marker inclusive,
134
+ * verbatim) from .convene/best-practices.md, keyed by id. `convene update` uses
135
+ * this to PRESERVE skipped (drifted / major-bump) sections byte-for-byte while
136
+ * re-materializing the rest. Missing/unreadable doc → empty map. Pure read.
137
+ */
138
+ function extractPracticeBlocks(top) {
139
+ const out = new Map();
140
+ let doc;
141
+ try {
142
+ doc = node_fs_1.default.readFileSync(practicesDocPath(top), 'utf8');
143
+ }
144
+ catch {
145
+ return out;
146
+ }
147
+ const re = /<!-- convene:practice (\S+) v\S+ -->[\s\S]*?<!-- \/convene:practice \1 -->/g;
148
+ let m;
149
+ while ((m = re.exec(doc)) !== null)
150
+ out.set(m[1], m[0]);
151
+ return out;
152
+ }
153
+ /**
154
+ * Replace each practice block in .convene/best-practices.md whose id appears in
155
+ * `blocks` with the supplied verbatim block text (BEGIN→END inclusive). Used by
156
+ * `convene update --apply` to splice PRESERVED skipped sections back into a
157
+ * freshly re-materialized doc, so a skipped (drifted/major) region is left exactly
158
+ * as it sat on disk. Practices not present in the doc are appended in the order
159
+ * given (defensive; normally every id is already present). Writes only if changed.
160
+ */
161
+ function splicePreservedBlocks(top, blocks) {
162
+ if (blocks.size === 0)
163
+ return;
164
+ const file = practicesDocPath(top);
165
+ let doc;
166
+ try {
167
+ doc = node_fs_1.default.readFileSync(file, 'utf8');
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ let next = doc;
173
+ const appendIds = [];
174
+ for (const [id, block] of blocks) {
175
+ const re = new RegExp(`<!-- convene:practice ${escapeRe(id)} v\\S+ -->[\\s\\S]*?<!-- /convene:practice ${escapeRe(id)} -->`);
176
+ if (re.test(next))
177
+ next = next.replace(re, () => block);
178
+ else
179
+ appendIds.push(id);
180
+ }
181
+ for (const id of appendIds) {
182
+ next = (next.endsWith('\n') ? next : next + '\n') + '\n' + blocks.get(id) + '\n';
183
+ }
184
+ writeIfChanged(file, next);
185
+ }
186
+ function escapeRe(s) {
187
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
+ }
189
+ /**
190
+ * Return the manifest practice ids whose CURRENT region hash (as the doc sits on
191
+ * disk) differs from the hash recorded in the manifest — i.e. the practice's
192
+ * section was hand-edited locally. A practice absent from the doc (no current
193
+ * hash) is NOT reported as drift (it is a missing region, a different condition);
194
+ * only a present-but-changed region counts. Side-effect-free; never throws.
195
+ */
196
+ function detectDrift(top, manifest) {
197
+ const current = currentRegionHashes(top);
198
+ const drifted = [];
199
+ for (const entry of manifest.practices) {
200
+ const cur = current.get(entry.id);
201
+ if (cur !== undefined && cur !== entry.hash)
202
+ drifted.push(entry.id);
203
+ }
204
+ return drifted;
205
+ }
206
+ /**
207
+ * The full .convene/best-practices.md for a set of selections: the managed header
208
+ * followed by each adopted practice wrapped in its PRACTICE_BEGIN/END markers, in
209
+ * CATALOG order (selections not in the catalog are dropped). PURE → byte-identical
210
+ * for the same (slug, selections, catalog).
211
+ */
212
+ function buildPracticesDoc(slug, selections) {
213
+ const parts = [exports.PRACTICES_DOC_HEADER];
214
+ for (const { practice, level } of selections) {
215
+ parts.push((0, exports.PRACTICE_BEGIN)(practice.id, practice.version) +
216
+ '\n' +
217
+ renderPracticeSection(practice, level) +
218
+ '\n' +
219
+ (0, exports.PRACTICE_END)(practice.id));
220
+ }
221
+ // Header + one blank line between each block; trailing newline.
222
+ return parts.join('\n\n') + '\n';
223
+ }
224
+ /**
225
+ * Insert/replace the REF_BEGIN..REF_END region in a CLAUDE.md / AGENTS.md.
226
+ * Modeled exactly on init.ts upsertMarkerBlock: replace between markers if present,
227
+ * else append with the same blank-line separator discipline. `refBody` is the
228
+ * region's inner content (the markers are added here).
229
+ */
230
+ function upsertRefRegion(content, refBody) {
231
+ const block = exports.REF_BEGIN + '\n' + refBody + '\n' + exports.REF_END;
232
+ const start = content.indexOf(exports.REF_BEGIN);
233
+ const end = content.indexOf(exports.REF_END);
234
+ if (start >= 0 && end > start) {
235
+ return content.slice(0, start) + block + content.slice(end + exports.REF_END.length);
236
+ }
237
+ const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
238
+ return content + sep + block + '\n';
239
+ }
240
+ /**
241
+ * Inverse of upsertRefRegion (off-board) — removes the ref region, collapsing the
242
+ * blank separator before it and the trailing newline after, so a file that ONLY
243
+ * ever held the region returns to '' (caller deletes it) and a file with
244
+ * pre-existing content round-trips BYTE-IDENTICAL. Mirrors init.ts stripBetween.
245
+ */
246
+ function removeRefRegion(content) {
247
+ const start = content.indexOf(exports.REF_BEGIN);
248
+ const endIdx = content.indexOf(exports.REF_END);
249
+ if (start < 0 || endIdx <= start)
250
+ return { content, removed: false };
251
+ const head = content.slice(0, start).replace(/\n+$/, '');
252
+ const tail = content.slice(endIdx + exports.REF_END.length).replace(/^\n+/, '');
253
+ let joined = head && tail ? head + '\n\n' + tail : head + tail;
254
+ if (joined.length > 0 && !joined.endsWith('\n'))
255
+ joined += '\n';
256
+ return { content: joined, removed: true };
257
+ }
258
+ // ── Pure enforcement helpers (Phase 3) ───────────────────────────────────────
259
+ // Side-effect-free + deterministic so they unit-test without a repo and so a re-run
260
+ // is byte-identical. A level "wires a hook" iff it is hook-soft or hook-hard.
261
+ /** A mechanical hook level — the gate is wired in settings (settingsHook artifacts). */
262
+ function isHookLevel(level) {
263
+ return level === 'hook-soft' || level === 'hook-hard';
264
+ }
265
+ /**
266
+ * UNION two permissions.deny string arrays, deduped, in a STABLE order: the existing
267
+ * entries first (their original order preserved), then any artifact entries not
268
+ * already present (in artifact order). Idempotent — re-merging the same set is a
269
+ * no-op, so a re-run stays byte-identical.
270
+ */
271
+ function mergeDenyArray(existing, add) {
272
+ const out = [];
273
+ const seen = new Set();
274
+ const push = (v) => {
275
+ if (typeof v === 'string' && !seen.has(v)) {
276
+ seen.add(v);
277
+ out.push(v);
278
+ }
279
+ };
280
+ if (Array.isArray(existing))
281
+ for (const e of existing)
282
+ push(e);
283
+ for (const a of add)
284
+ push(a);
285
+ return out;
286
+ }
287
+ /**
288
+ * Deep-merge a permissions-deny settingsJson artifact into a parsed settings object
289
+ * (mutates + returns it). UNIONs permissions.deny; every OTHER key is preserved
290
+ * untouched. Only the `permissions.deny` shape is handled (the only settingsJson
291
+ * artifact the catalog ships); other merge keys are ignored defensively.
292
+ */
293
+ function mergeSettingsJson(settings, art) {
294
+ const next = settings && typeof settings === 'object' ? settings : {};
295
+ const deny = art.merge?.permissions?.deny;
296
+ if (Array.isArray(deny) && deny.length) {
297
+ next.permissions = next.permissions && typeof next.permissions === 'object' ? next.permissions : {};
298
+ next.permissions.deny = mergeDenyArray(next.permissions.deny, deny);
299
+ }
300
+ return next;
301
+ }
302
+ /**
303
+ * The full set of permissions.deny entries a settingsJson artifact contributes —
304
+ * used by off-board to subtract EXACTLY those (and only those) on reversal. Pure.
305
+ */
306
+ function denyEntriesOf(art) {
307
+ const deny = art.merge?.permissions?.deny;
308
+ return Array.isArray(deny) ? deny.filter((d) => typeof d === 'string') : [];
309
+ }
310
+ /**
311
+ * Append `lines` to a .gitignore body idempotently (never duplicate an existing
312
+ * line, ignoring surrounding whitespace), under a single managed marker so off-board
313
+ * can recognize + strip them. Mirrors init's ensureGitignoreGuard blank-line/newline
314
+ * discipline. PURE — returns the next content (caller writes if changed).
315
+ */
316
+ exports.GITIGNORE_PRACTICES_MARKER = '# convene best-practices (managed)';
317
+ function ensureGitignoreLines(content, lines) {
318
+ const present = new Set(content.split('\n').map((l) => l.trim()));
319
+ const missing = lines.filter((l) => !present.has(l.trim()));
320
+ if (missing.length === 0)
321
+ return content;
322
+ const hasMarker = content.split('\n').some((l) => l.trim() === exports.GITIGNORE_PRACTICES_MARKER);
323
+ const block = (hasMarker ? '' : exports.GITIGNORE_PRACTICES_MARKER + '\n') + missing.join('\n') + '\n';
324
+ const sep = content.length === 0 ? '' : content.endsWith('\n') ? '' : '\n';
325
+ return content + sep + block;
326
+ }
327
+ /**
328
+ * Remove `lines` (and the managed marker once no managed lines remain) from a
329
+ * .gitignore body — the inverse of ensureGitignoreLines. Returns the next content
330
+ * and whether anything changed. Collapses a trailing blank run so a file that ONLY
331
+ * held the managed block round-trips byte-identical.
332
+ */
333
+ function removeGitignoreLines(content, lines) {
334
+ // Drop the managed practice lines AND the managed marker — the only content the
335
+ // single managed block ever holds, so removing all of it returns the file to its
336
+ // pre-materialize body.
337
+ const drop = new Set([...lines.map((l) => l.trim()), exports.GITIGNORE_PRACTICES_MARKER]);
338
+ const srcLines = content.split('\n');
339
+ let removed = false;
340
+ const kept = srcLines.filter((l) => {
341
+ if (drop.has(l.trim())) {
342
+ removed = true;
343
+ return false;
344
+ }
345
+ return true;
346
+ });
347
+ if (!removed)
348
+ return { content, removed: false };
349
+ let joined = kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '');
350
+ if (joined.length > 0)
351
+ joined += '\n';
352
+ return { content: joined, removed: true };
353
+ }
354
+ // ── fs orchestration ─────────────────────────────────────────────────────────
355
+ // Mirrors init.ts: every file lives in the git repo, so git history IS the backup;
356
+ // write only when content actually changes (idempotent / merge-safe).
357
+ function writeIfChanged(file, content) {
358
+ const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : null;
359
+ if (old === content)
360
+ return;
361
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
362
+ node_fs_1.default.writeFileSync(file, content);
363
+ }
364
+ /** The inner body of the CLAUDE.md ref region — a single @-import line. */
365
+ const CLAUDE_REF_BODY = '@.convene/best-practices.md';
366
+ /** The inner body of the AGENTS.md ref region — a prose pointer (no @-import). */
367
+ const AGENTS_REF_BODY = '**Adopted best practices:** see `.convene/best-practices.md` (managed by Convene).';
368
+ /** Phase-3 settingsHook practices that init's OWN hooks already cover — do NOT wire
369
+ * them again. `recheck-main-before-deploy-lane` is gated by init's `convene gate-push`
370
+ * PreToolUse/PostToolUse hooks; double-gating it would fire two deploy gates. */
371
+ const HOOK_COVERED_BY_INIT = new Set(['recheck-main-before-deploy-lane']);
372
+ /**
373
+ * Materialize the adopted selections into the repo at `top` and return the manifest
374
+ * entries (in CATALOG order). Idempotent: byte-identical inputs → byte-identical
375
+ * tree. CRITICAL no-op contract: an EMPTY `selections` writes NOTHING and returns
376
+ * [], so a plain onboarding stays byte-identical to its pre-catalog form.
377
+ *
378
+ * Writes, applied PER ADOPTED PRACTICE ACCORDING TO LEVEL:
379
+ * - .convene/best-practices.md; the ref region in CLAUDE.md + AGENTS.md; and each
380
+ * adopted practice's `doc` artifacts (write-if-absent) — at ANY level.
381
+ * - settingsJson (permissions.deny) — ONLY at hook-hard (advisory → doc stanza only).
382
+ * - gitignore lines — at ANY hook level (hook-soft / hook-hard).
383
+ * - settingsHook — at hook-soft OR hook-hard, wired via ensureHook into the
384
+ * committed .claude/settings.json (gated on binarySupportsVerb so a stale CLI
385
+ * degrades gracefully; respects opts.wireHooks for --no-hook). The
386
+ * init-covered recheck-main-before-deploy-lane hook is intentionally NOT wired
387
+ * (init's gate-push already covers it).
388
+ */
389
+ function materializePractices(top, slug, selections, catalog, opts = {}) {
390
+ if (selections.length === 0)
391
+ return [];
392
+ const wireHooks = opts.wireHooks !== false;
393
+ const levelById = new Map(selections.map((s) => [s.id, s.level]));
394
+ // Resolve to (practice, level) in CATALOG order; silently drop unknown ids.
395
+ const resolved = catalog.practices
396
+ .filter((p) => levelById.has(p.id))
397
+ .map((p) => ({ practice: p, level: levelById.get(p.id) }));
398
+ if (resolved.length === 0)
399
+ return [];
400
+ // 1. .convene/best-practices.md (the full managed doc).
401
+ writeIfChanged(node_path_1.default.join(top, '.convene', 'best-practices.md'), buildPracticesDoc(slug, resolved));
402
+ // 2. ref region in CLAUDE.md + AGENTS.md (insert/replace; preserves other content).
403
+ for (const [fname, refBody] of [
404
+ ['CLAUDE.md', CLAUDE_REF_BODY],
405
+ ['AGENTS.md', AGENTS_REF_BODY],
406
+ ]) {
407
+ const file = node_path_1.default.join(top, fname);
408
+ const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
409
+ writeIfChanged(file, upsertRefRegion(old, refBody));
410
+ }
411
+ // 3. `doc` artifacts — write-if-absent (never clobber a hand-enriched doc).
412
+ for (const { practice } of resolved) {
413
+ for (const a of practice.artifacts) {
414
+ if (a.kind !== 'doc')
415
+ continue;
416
+ const file = node_path_1.default.join(top, a.path);
417
+ if (node_fs_1.default.existsSync(file))
418
+ continue;
419
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
420
+ node_fs_1.default.writeFileSync(file, a.body);
421
+ }
422
+ }
423
+ // 4. settingsJson (permissions.deny) — ENFORCE only at hook-hard. Deep-merge into
424
+ // the committed .claude/settings.json: UNION permissions.deny, preserve all else.
425
+ // (advisory adoption writes only the CLAUDE.md stanza — no settings change.)
426
+ const denyArts = [];
427
+ for (const { practice, level } of resolved) {
428
+ if (level !== 'hook-hard')
429
+ continue;
430
+ for (const a of practice.artifacts) {
431
+ if (a.kind === 'settingsJson')
432
+ denyArts.push(a);
433
+ }
434
+ }
435
+ if (denyArts.length) {
436
+ const file = (0, hook_1.projectSettingsPath)(top);
437
+ const settings = (0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)(file));
438
+ if (settings !== null) {
439
+ // unparseable → don't clobber
440
+ let merged = settings;
441
+ for (const a of denyArts)
442
+ merged = mergeSettingsJson(merged, a);
443
+ writeIfChanged(file, (0, hook_1.serializeSettings)(merged));
444
+ }
445
+ }
446
+ // 5. gitignore lines — at ANY hook level. Append idempotently under a managed marker.
447
+ const ignoreLines = [];
448
+ for (const { practice, level } of resolved) {
449
+ if (!isHookLevel(level))
450
+ continue;
451
+ for (const a of practice.artifacts) {
452
+ if (a.kind === 'gitignore')
453
+ ignoreLines.push(...a.lines);
454
+ }
455
+ }
456
+ if (ignoreLines.length) {
457
+ const file = node_path_1.default.join(top, '.gitignore');
458
+ const old = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
459
+ writeIfChanged(file, ensureGitignoreLines(old, ignoreLines));
460
+ }
461
+ // 6. settingsHook — wire at hook-soft / hook-hard into the committed project
462
+ // settings, via ensureHook (idempotent + merge-safe). Skip when init isn't
463
+ // installing project settings (--no-hook), skip init-covered hooks, and gate
464
+ // each on binarySupportsVerb so a stale CLI degrades gracefully (matches
465
+ // registerCoordinationHooks). The materialized hooks are fail-open (the
466
+ // practice-guard command is fail-open-loud).
467
+ if (wireHooks) {
468
+ const settingsPath = (0, hook_1.projectSettingsPath)(top);
469
+ for (const { practice, level } of resolved) {
470
+ if (!isHookLevel(level))
471
+ continue;
472
+ if (HOOK_COVERED_BY_INIT.has(practice.id))
473
+ continue;
474
+ for (const a of practice.artifacts) {
475
+ if (a.kind !== 'settingsHook')
476
+ continue;
477
+ const verb = a.verb ?? 'practice-guard';
478
+ if (!(0, hook_1.binarySupportsVerb)(verb))
479
+ continue;
480
+ (0, hook_1.ensureHook)(a.event, a.command, a.matcher, settingsPath);
481
+ }
482
+ }
483
+ }
484
+ // 7. manifest entries, in CATALOG order.
485
+ return resolved.map(({ practice, level }) => ({
486
+ id: practice.id,
487
+ version: practice.version,
488
+ level,
489
+ hash: regionHash(renderPracticeSection(practice, level)),
490
+ }));
491
+ }
492
+ /**
493
+ * The off-board reverse of materializePractices: strip the ref region from CLAUDE.md
494
+ * and AGENTS.md (deleting a file that becomes empty, mirroring init's stripMarker
495
+ * delete), and return the repo-relative paths touched (for reporting). Does NOT
496
+ * delete .convene/best-practices.md or .convene/practices/ — those live under
497
+ * .convene, which `convene off-board` already removes wholesale.
498
+ */
499
+ function dematerializePractices(top) {
500
+ const touched = [];
501
+ for (const fname of ['CLAUDE.md', 'AGENTS.md']) {
502
+ const file = node_path_1.default.join(top, fname);
503
+ if (!node_fs_1.default.existsSync(file))
504
+ continue;
505
+ const old = node_fs_1.default.readFileSync(file, 'utf8');
506
+ const { content, removed } = removeRefRegion(old);
507
+ if (!removed)
508
+ continue;
509
+ if (content.length === 0)
510
+ node_fs_1.default.rmSync(file, { force: true });
511
+ else if (content !== old)
512
+ node_fs_1.default.writeFileSync(file, content);
513
+ touched.push(fname);
514
+ }
515
+ return touched;
516
+ }