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.
@@ -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
- * 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.
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
- * Every .gitignore line the catalog's gitignore artifacts contribute the set
54
- * materialize.ts appends at any hook level. Off-board removes exactly these.
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
- const managedDeny = catalogManagedDenyEntries();
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
- /** The one-line reminder for a practice: its catalog `title` (stable, lean). */
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} (level: reminder).`;
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 yet on this machine? Run \`convene setup\` here (or fetch ${baseUrl}/start and follow it) it self-provisions you and plugs into this project.`,
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.0",
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",