convene-cli 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/offboard.js +44 -15
- package/dist/commands/practice-guard.js +25 -6
- package/dist/commands/practices.js +110 -0
- package/dist/index.js +5 -0
- package/dist/protocol.js +1 -1
- package/package.json +1 -1
|
@@ -32,14 +32,28 @@ const catalog_1 = require("../catalog");
|
|
|
32
32
|
const protocol_1 = require("../protocol");
|
|
33
33
|
const ctx_1 = require("../ctx");
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* The permissions.deny entries to subtract on off-board. materialize.ts writes a
|
|
36
|
+
* practice's settingsJson(permissions.deny) artifact ONLY at hook-hard, so the exact
|
|
37
|
+
* set this repo could have materialized is the deny artifacts of the practices its
|
|
38
|
+
* MANIFEST records at hook-hard — not every practice in the live catalog. Filtering by
|
|
39
|
+
* the manifest (what was actually adopted) stops off-board from removing a teammate's
|
|
40
|
+
* own deny rule that happens to match an UNADOPTED catalog practice's entry, and keeps
|
|
41
|
+
* reversal correct as the catalog grows/shrinks after onboarding.
|
|
42
|
+
*
|
|
43
|
+
* Fallback: a repo with no readable manifest (pre-schema-2, or unreadable) → the full
|
|
44
|
+
* catalog managed set (the prior behavior), so older repos still get cleaned.
|
|
45
|
+
*
|
|
46
|
+
* KNOWN RESIDUAL: artifacts are read from the CURRENT catalog by practice id, so if an
|
|
47
|
+
* adopted practice's deny STRING itself changed between the version materialized and the
|
|
48
|
+
* current catalog, the old string can still be orphaned. Fully closing that needs the
|
|
49
|
+
* manifest to record the materialized contributions (a manifest-schema change, deferred).
|
|
39
50
|
*/
|
|
40
|
-
function catalogManagedDenyEntries() {
|
|
51
|
+
function catalogManagedDenyEntries(manifest) {
|
|
41
52
|
const out = new Set();
|
|
53
|
+
const adopted = manifest ? new Map(manifest.practices.map((e) => [e.id, e.level])) : null;
|
|
42
54
|
for (const p of catalog_1.CATALOG.practices) {
|
|
55
|
+
if (adopted && adopted.get(p.id) !== 'hook-hard')
|
|
56
|
+
continue; // deny materializes only at hook-hard
|
|
43
57
|
for (const a of p.artifacts) {
|
|
44
58
|
if (a.kind === 'settingsJson') {
|
|
45
59
|
for (const d of (0, materialize_1.denyEntriesOf)(a))
|
|
@@ -50,12 +64,21 @@ function catalogManagedDenyEntries() {
|
|
|
50
64
|
return out;
|
|
51
65
|
}
|
|
52
66
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
67
|
+
* The .gitignore lines to subtract on off-board. materialize.ts appends a practice's
|
|
68
|
+
* gitignore artifact at ANY hook level (hook-soft / hook-hard), so the set is the
|
|
69
|
+
* gitignore artifacts of the practices the MANIFEST records at a hook level. Same
|
|
70
|
+
* manifest-filtering rationale (and the same fallback + known residual) as
|
|
71
|
+
* catalogManagedDenyEntries.
|
|
55
72
|
*/
|
|
56
|
-
function catalogManagedGitignoreLines() {
|
|
73
|
+
function catalogManagedGitignoreLines(manifest) {
|
|
57
74
|
const out = [];
|
|
75
|
+
const adopted = manifest ? new Map(manifest.practices.map((e) => [e.id, e.level])) : null;
|
|
58
76
|
for (const p of catalog_1.CATALOG.practices) {
|
|
77
|
+
if (adopted) {
|
|
78
|
+
const lvl = adopted.get(p.id);
|
|
79
|
+
if (!lvl || !(0, materialize_1.isHookLevel)(lvl))
|
|
80
|
+
continue; // gitignore materializes only at hook levels
|
|
81
|
+
}
|
|
59
82
|
for (const a of p.artifacts) {
|
|
60
83
|
if (a.kind === 'gitignore')
|
|
61
84
|
out.push(...a.lines);
|
|
@@ -239,7 +262,7 @@ function finalizeJson(top, rel, obj, touched, dryRun) {
|
|
|
239
262
|
* drop permissions.deny if it empties, then permissions if it empties). A teammate's
|
|
240
263
|
* own hooks / deny rules / settings are preserved. Delete the file if nothing remains.
|
|
241
264
|
*/
|
|
242
|
-
function stripClaudeSettings(top, rel, touched, dryRun) {
|
|
265
|
+
function stripClaudeSettings(top, rel, touched, dryRun, manifest) {
|
|
243
266
|
const p = abs(top, rel);
|
|
244
267
|
if (!node_fs_1.default.existsSync(p))
|
|
245
268
|
return;
|
|
@@ -254,8 +277,9 @@ function stripClaudeSettings(top, rel, touched, dryRun) {
|
|
|
254
277
|
const stripped = (0, hook_1.withoutConveneHooks)(obj);
|
|
255
278
|
let removed = stripped.removed;
|
|
256
279
|
const settings = stripped.settings;
|
|
257
|
-
// Subtract the convene-managed permissions.deny entries (only ours
|
|
258
|
-
|
|
280
|
+
// Subtract the convene-managed permissions.deny entries (only ours — those this repo
|
|
281
|
+
// actually adopted at hook-hard, per the manifest).
|
|
282
|
+
const managedDeny = catalogManagedDenyEntries(manifest);
|
|
259
283
|
const deny = settings?.permissions?.deny;
|
|
260
284
|
if (Array.isArray(deny) && managedDeny.size) {
|
|
261
285
|
const kept = deny.filter((d) => !(typeof d === 'string' && managedDeny.has(d)));
|
|
@@ -310,13 +334,13 @@ function handleGitHook(top, touched, dryRun) {
|
|
|
310
334
|
* managed marker materialize.ts appended (removeGitignoreLines). Done in one
|
|
311
335
|
* read/write so the file is reported/staged at most once; deletes the file if empty.
|
|
312
336
|
*/
|
|
313
|
-
function handleGitignore(top, touched, dryRun) {
|
|
337
|
+
function handleGitignore(top, touched, dryRun, manifest) {
|
|
314
338
|
const rel = '.gitignore';
|
|
315
339
|
const p = abs(top, rel);
|
|
316
340
|
if (!node_fs_1.default.existsSync(p))
|
|
317
341
|
return;
|
|
318
342
|
const old = node_fs_1.default.readFileSync(p, 'utf8');
|
|
319
|
-
const practiceLines = catalogManagedGitignoreLines();
|
|
343
|
+
const practiceLines = catalogManagedGitignoreLines(manifest);
|
|
320
344
|
const practicesProbe = (0, materialize_1.removeGitignoreLines)(old, practiceLines);
|
|
321
345
|
const willChangeGuard = old.includes('.convene/cache/') ||
|
|
322
346
|
old.includes('.convene/*.local.json') ||
|
|
@@ -459,6 +483,11 @@ async function offboard(opts) {
|
|
|
459
483
|
}
|
|
460
484
|
// 2. Local footprint removal.
|
|
461
485
|
const touched = [];
|
|
486
|
+
// Read the adopted-practices manifest BEFORE any deletion — delPath('.convene')
|
|
487
|
+
// removes .convene/project.json, the manifest's home. It tells off-board which
|
|
488
|
+
// practices (and at what level) this repo actually materialized, so reversal
|
|
489
|
+
// subtracts exactly those deny/gitignore entries (never a teammate's own rule).
|
|
490
|
+
const manifest = (0, config_1.loadManifest)(top);
|
|
462
491
|
stripMarker(top, 'CLAUDE.md', touched, dryRun);
|
|
463
492
|
stripMarker(top, 'AGENTS.md', touched, dryRun);
|
|
464
493
|
handleProtocolDoc(top, touched, dryRun);
|
|
@@ -466,13 +495,13 @@ async function offboard(opts) {
|
|
|
466
495
|
delPath(top, '.convene', touched, dryRun);
|
|
467
496
|
delPath(top, '.cursor/rules/convene.mdc', touched, dryRun);
|
|
468
497
|
delPath(top, '.clinerules/convene.md', touched, dryRun);
|
|
469
|
-
stripClaudeSettings(top, '.claude/settings.json', touched, dryRun);
|
|
498
|
+
stripClaudeSettings(top, '.claude/settings.json', touched, dryRun, manifest);
|
|
470
499
|
stripToml(top, '.codex/config.toml', touched, dryRun);
|
|
471
500
|
stripJsonServer(top, '.cursor/mcp.json', 'mcpServers', touched, dryRun);
|
|
472
501
|
stripJsonServer(top, '.vscode/mcp.json', 'servers', touched, dryRun);
|
|
473
502
|
stripGemini(top, '.gemini/settings.json', touched, dryRun);
|
|
474
503
|
handleGitHook(top, touched, dryRun);
|
|
475
|
-
handleGitignore(top, touched, dryRun);
|
|
504
|
+
handleGitignore(top, touched, dryRun, manifest);
|
|
476
505
|
if (!dryRun)
|
|
477
506
|
cleanupEmptyDirs(top);
|
|
478
507
|
// 3. Repo-specific seeded memory is always cleaned (it's keyed to THIS repo's path).
|
|
@@ -89,12 +89,27 @@ function note(reason) {
|
|
|
89
89
|
function blockReason(reason) {
|
|
90
90
|
process.stderr.write(reason + '\n');
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* A compact "why + source" suffix for a practice, drawn from the catalog. Hitting a
|
|
94
|
+
* gate is the highest-frequency moment an agent could LEARN the rationale, so every
|
|
95
|
+
* reminder/block cites the practice's `why` (the failure mode it prevents) and a
|
|
96
|
+
* `sourceUrls[0]` reference right there — turning enforcement into in-context
|
|
97
|
+
* teaching with zero new infra (the catalog is already loaded; no hot-path network).
|
|
98
|
+
* Empty when the practice or its `why` is unknown. Pure + local.
|
|
99
|
+
*/
|
|
100
|
+
function whySuffix(id) {
|
|
101
|
+
const p = catalog_1.CATALOG.practices.find((x) => x.id === id);
|
|
102
|
+
if (!p || !p.why)
|
|
103
|
+
return '';
|
|
104
|
+
const src = p.sourceUrls && p.sourceUrls.length > 0 ? `\n source: ${p.sourceUrls[0]}` : '';
|
|
105
|
+
return `\n why: ${p.why}${src}`;
|
|
106
|
+
}
|
|
107
|
+
/** The one-line reminder for a practice: its catalog `title` + why/source (lean). */
|
|
93
108
|
function reminderFor(id) {
|
|
94
109
|
const p = catalog_1.CATALOG.practices.find((x) => x.id === id);
|
|
95
110
|
if (!p)
|
|
96
111
|
return `convene: best practice ${id} applies here.`;
|
|
97
|
-
return `convene: ${p.title} — ${p.id}
|
|
112
|
+
return `convene: ${p.title} — ${p.id}.${whySuffix(id)}`;
|
|
98
113
|
}
|
|
99
114
|
/**
|
|
100
115
|
* DEFAULT protected paths for protect-shared-files (used when the repo has not
|
|
@@ -235,12 +250,14 @@ async function run(opts, id) {
|
|
|
235
250
|
if (level === 'hook-hard') {
|
|
236
251
|
blockReason(`convene: BLOCKED — ${shortRel(target, top)} is a shared/global file (lockfile, migration, or root config) ` +
|
|
237
252
|
`protected by practice protect-shared-files. Editing it across sessions reliably breaks integration. ` +
|
|
238
|
-
`If this session OWNS this change: \`convene override protect-shared-files --reason "<why>"\`, then retry.`
|
|
253
|
+
`If this session OWNS this change: \`convene override protect-shared-files --reason "<why>"\`, then retry.` +
|
|
254
|
+
whySuffix(id));
|
|
239
255
|
return 2;
|
|
240
256
|
}
|
|
241
257
|
// hook-soft → remind, allow.
|
|
242
258
|
note(`convene: ${shortRel(target, top)} is a shared/global file — protect-shared-files. ` +
|
|
243
|
-
`Only edit it if this session is the assigned owner; otherwise sequence the change.`
|
|
259
|
+
`Only edit it if this session is the assigned owner; otherwise sequence the change.` +
|
|
260
|
+
whySuffix(id));
|
|
244
261
|
return 0;
|
|
245
262
|
}
|
|
246
263
|
case 'worktree-per-session': {
|
|
@@ -249,7 +266,8 @@ async function run(opts, id) {
|
|
|
249
266
|
const live = (0, cache_1.liveSessionCount)(slug, 15 * 60);
|
|
250
267
|
if (live > 1) {
|
|
251
268
|
note(`convene: ${live} live sessions share this checkout — worktree-per-session. ` +
|
|
252
|
-
`Run each concurrent agent in its own worktree (\`convene worktree <branch>\`) to avoid silent clobbering.`
|
|
269
|
+
`Run each concurrent agent in its own worktree (\`convene worktree <branch>\`) to avoid silent clobbering.` +
|
|
270
|
+
whySuffix(id));
|
|
253
271
|
}
|
|
254
272
|
return 0;
|
|
255
273
|
}
|
|
@@ -258,7 +276,8 @@ async function run(opts, id) {
|
|
|
258
276
|
if (behindOriginMain(top)) {
|
|
259
277
|
const b = (0, git_1.currentBranch)(top) ?? 'this branch';
|
|
260
278
|
note(`convene: ${b} is behind origin/main — pull-main-before-execution. ` +
|
|
261
|
-
`Rebase onto origin/main and re-validate the plan before editing.`
|
|
279
|
+
`Rebase onto origin/main and re-validate the plan before editing.` +
|
|
280
|
+
whySuffix(id));
|
|
262
281
|
}
|
|
263
282
|
return 0;
|
|
264
283
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.practices = practices;
|
|
4
|
+
/**
|
|
5
|
+
* `convene practices [id]` — the LEARN surface for the best-practices catalog.
|
|
6
|
+
*
|
|
7
|
+
* Without an id: lists every catalog practice grouped by tier, marking the ones THIS
|
|
8
|
+
* repo adopted (and at what level), so a human or agent can see what is on offer and
|
|
9
|
+
* what is active. With an id: prints that practice in depth — what it is, WHY (the
|
|
10
|
+
* failure mode it prevents), its enforcement levels, provenance, and the source URLs
|
|
11
|
+
* the practice was distilled from.
|
|
12
|
+
*
|
|
13
|
+
* The `why` and `sourceUrls` are authored on every one of the catalog's practices but
|
|
14
|
+
* were rendered nowhere — the picker shows a bare title, the materialized doc and the
|
|
15
|
+
* dashboard show the `what`/id. This command is where a repo LEARNS the rationale,
|
|
16
|
+
* before or after adopting. It deliberately lives OFF the always-loaded path (a doc
|
|
17
|
+
* imported into CLAUDE.md every turn would tax the agent — Gloaguen et al. 2026), so
|
|
18
|
+
* the heavy rationale is on-demand here, not in the hot path.
|
|
19
|
+
*
|
|
20
|
+
* Reads the bundled offline mirror (CATALOG) — network-free, instant, fail-soft. The
|
|
21
|
+
* repo manifest (loadManifest) only annotates adoption; its absence is fine (every
|
|
22
|
+
* practice then shows as available/not-adopted).
|
|
23
|
+
*/
|
|
24
|
+
const git_1 = require("../git");
|
|
25
|
+
const config_1 = require("../config");
|
|
26
|
+
const catalog_1 = require("../catalog");
|
|
27
|
+
const out = (s) => process.stdout.write(s + '\n');
|
|
28
|
+
/** id → adopted level, from the repo manifest. Empty (and never throws) when none. */
|
|
29
|
+
function adoptedLevels() {
|
|
30
|
+
const m = new Map();
|
|
31
|
+
try {
|
|
32
|
+
const top = (0, git_1.gitToplevel)();
|
|
33
|
+
if (!top)
|
|
34
|
+
return m;
|
|
35
|
+
const manifest = (0, config_1.loadManifest)(top);
|
|
36
|
+
if (manifest)
|
|
37
|
+
for (const e of manifest.practices)
|
|
38
|
+
m.set(e.id, e.level);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* fail-soft: no manifest annotations, list still works */
|
|
42
|
+
}
|
|
43
|
+
return m;
|
|
44
|
+
}
|
|
45
|
+
/** Entry point: detail view when an id is given, else the grouped list. Never throws. */
|
|
46
|
+
function practices(id, _opts = {}) {
|
|
47
|
+
const adopted = adoptedLevels();
|
|
48
|
+
const wanted = (id ?? '').trim();
|
|
49
|
+
if (wanted)
|
|
50
|
+
printDetail(wanted, adopted);
|
|
51
|
+
else
|
|
52
|
+
printList(adopted);
|
|
53
|
+
}
|
|
54
|
+
/** The grouped list: every practice by tier, adopted ones marked with their level. */
|
|
55
|
+
function printList(adopted) {
|
|
56
|
+
out(`Convene best-practices catalog v${catalog_1.CATALOG.version} — ${catalog_1.CATALOG.practices.length} practices, ` +
|
|
57
|
+
`${adopted.size} adopted in this repo.`);
|
|
58
|
+
out('Run `convene practices <id>` for the why + sources behind any one.');
|
|
59
|
+
// Pad ids to a common width so titles line up; ✓ marks an adopted practice.
|
|
60
|
+
const idWidth = Math.min(34, catalog_1.CATALOG.practices.reduce((w, p) => Math.max(w, p.id.length), 0));
|
|
61
|
+
for (const tier of catalog_1.CATALOG.tiers) {
|
|
62
|
+
const inTier = catalog_1.CATALOG.practices.filter((p) => p.tier === tier.id);
|
|
63
|
+
if (inTier.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
out('');
|
|
66
|
+
out(`${tier.name}`);
|
|
67
|
+
for (const p of inTier) {
|
|
68
|
+
const lvl = adopted.get(p.id);
|
|
69
|
+
const mark = lvl ? `✓ [${lvl}]` : '·';
|
|
70
|
+
out(` ${mark.padEnd(13)} ${p.id.padEnd(idWidth)} ${p.title}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The depth view for one practice: what / why / levels / provenance / sources. */
|
|
75
|
+
function printDetail(id, adopted) {
|
|
76
|
+
const p = catalog_1.CATALOG.practices.find((x) => x.id === id);
|
|
77
|
+
if (!p) {
|
|
78
|
+
out(`No practice "${id}" in catalog v${catalog_1.CATALOG.version}.`);
|
|
79
|
+
const near = catalog_1.CATALOG.practices.filter((x) => x.id.includes(id) || id.includes(x.id)).slice(0, 5);
|
|
80
|
+
if (near.length) {
|
|
81
|
+
out('Did you mean:');
|
|
82
|
+
for (const n of near)
|
|
83
|
+
out(` ${n.id}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
out('Run `convene practices` to list them all.');
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const lvl = adopted.get(p.id);
|
|
91
|
+
out(p.title);
|
|
92
|
+
out(`${p.id} · ${p.tier} / ${p.category} · v${p.version}`);
|
|
93
|
+
out(lvl ? `adopted here at: ${lvl}` : `not adopted here (suggested level: ${p.defaultLevel})`);
|
|
94
|
+
out(`enforcement levels: ${p.availableLevels.join(', ')}`);
|
|
95
|
+
if (p.productionLearned) {
|
|
96
|
+
out('provenance: production-learned at the BrightAI Opportunity Explorer');
|
|
97
|
+
}
|
|
98
|
+
out('');
|
|
99
|
+
out('WHAT');
|
|
100
|
+
out(` ${p.what}`);
|
|
101
|
+
out('');
|
|
102
|
+
out('WHY');
|
|
103
|
+
out(` ${p.why}`);
|
|
104
|
+
if (p.sourceUrls && p.sourceUrls.length > 0) {
|
|
105
|
+
out('');
|
|
106
|
+
out('SOURCES');
|
|
107
|
+
for (const u of p.sourceUrls)
|
|
108
|
+
out(` - ${u}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,7 @@ const practice_guard_1 = require("./commands/practice-guard");
|
|
|
61
61
|
const override_1 = require("./commands/override");
|
|
62
62
|
const watch_1 = require("./commands/watch");
|
|
63
63
|
const explain_1 = require("./commands/explain");
|
|
64
|
+
const practices_1 = require("./commands/practices");
|
|
64
65
|
const update_1 = require("./commands/update");
|
|
65
66
|
const program = new commander_1.Command();
|
|
66
67
|
// Read the version from package.json so `convene --version` always tracks the
|
|
@@ -235,6 +236,10 @@ program
|
|
|
235
236
|
.command('explain [question]')
|
|
236
237
|
.description('ask how Convene itself works (protocol, lanes, halts, privacy, …) — fail-soft, public')
|
|
237
238
|
.action((question) => (0, explain_1.explain)(question));
|
|
239
|
+
program
|
|
240
|
+
.command('practices [id]')
|
|
241
|
+
.description('learn the best practices: list adopted + available, or pass an id for the why + sources')
|
|
242
|
+
.action((id) => (0, practices_1.practices)(id));
|
|
238
243
|
program
|
|
239
244
|
.command('suggest <text>')
|
|
240
245
|
.description('send a feature request / bug report / feedback into Convene')
|
package/dist/protocol.js
CHANGED
|
@@ -31,7 +31,7 @@ function block(flavor, slug, member, baseUrl) {
|
|
|
31
31
|
`This repo is on **Convene** — a tool-agnostic AI development coordination bus — as project \`${slug}\`.`,
|
|
32
32
|
`Dashboard: ${baseUrl}/p/${slug}`,
|
|
33
33
|
'',
|
|
34
|
-
`> Not connected
|
|
34
|
+
`> **Not connected on this machine?** Paste this one line into your Claude Code **or** Codex session: *"Connect me to Convene for this repo: run \`npm i -g convene-cli@latest\`, then \`convene setup\` and follow the prompts, then \`convene doctor\`."* — or run those yourself, or fetch ${baseUrl}/start and follow it. One time per machine; both tools are then wired automatically.`,
|
|
35
35
|
'',
|
|
36
36
|
'Each turn you get a `<convene-channel>` block — a health line, open items addressed to you, and',
|
|
37
37
|
'recent activity. (Claude Code injects it via the `convene fetch` UserPromptSubmit hook; with other',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "convene-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://dev.convene.live",
|