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.
- package/dist/api.js +23 -0
- package/dist/cache.js +83 -1
- package/dist/catalog/catalog.generated.js +860 -0
- package/dist/catalog/index.js +26 -0
- package/dist/catalog/manifest.js +71 -0
- package/dist/catalog/materialize.js +516 -0
- package/dist/catalog/prompt.js +89 -0
- package/dist/catalog/report.js +45 -0
- package/dist/catalog/select.js +86 -0
- package/dist/catalog/types.js +14 -0
- package/dist/commands/auth.js +44 -0
- package/dist/commands/fetch.js +50 -0
- package/dist/commands/inbox.js +15 -0
- package/dist/commands/init.js +182 -1
- package/dist/commands/offboard.js +526 -0
- package/dist/commands/override.js +65 -0
- package/dist/commands/practice-guard.js +291 -0
- package/dist/commands/setup.js +20 -3
- package/dist/commands/update.js +249 -0
- package/dist/config.js +19 -1
- package/dist/git.js +73 -0
- package/dist/githook.js +37 -0
- package/dist/hook.js +56 -0
- package/dist/index.js +60 -4
- package/dist/protocol.js +14 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|