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,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.pickPracticesInteractively = pickPracticesInteractively;
7
+ /**
8
+ * Interactive best-practices picker for `convene init` / `convene setup`.
9
+ *
10
+ * STRICTLY interactive: the caller MUST gate on `process.stdin.isTTY &&
11
+ * process.stdout.isTTY && !opts.yes` before invoking this — it is never reached in
12
+ * tests (which run non-TTY) or under `--yes` (agents/CI). The whole flow is wrapped
13
+ * so any error / EOF / Ctrl-D falls back to `[]` (adopt nothing) — a picker hiccup
14
+ * must NEVER crash onboarding. The readline interface is always closed in a finally.
15
+ */
16
+ const promises_1 = __importDefault(require("node:readline/promises"));
17
+ const select_1 = require("./select");
18
+ const out = (m) => process.stdout.write(m + '\n');
19
+ /** Count of opt-in (default-ON) practices in the named tiers — for the menu labels. */
20
+ function presetCount(catalog, preset) {
21
+ return (0, select_1.presetSelections)(catalog, preset).length;
22
+ }
23
+ /**
24
+ * Run the onboarding picker and resolve a Selection[]. Returns [] on EOF / any
25
+ * error / explicit "None". Never throws.
26
+ */
27
+ async function pickPracticesInteractively(catalog) {
28
+ const rl = promises_1.default.createInterface({ input: process.stdin, output: process.stdout });
29
+ try {
30
+ out('');
31
+ out('Adopt Convene best practices for this repo? (lean CLAUDE.md guidance now; enforcement hooks later)');
32
+ out(` [1] Essentials (${presetCount(catalog, 'essentials')})`);
33
+ out(` [2] Essentials + Recommended (${presetCount(catalog, 'recommended')}) [default]`);
34
+ out(` [3] Everything (${presetCount(catalog, 'all')})`);
35
+ out(' [4] Customize');
36
+ out(' [n] None');
37
+ const choice = (await rl.question('Choose [2]: ')).trim().toLowerCase();
38
+ if (choice === 'n' || choice === 'none')
39
+ return [];
40
+ if (choice === '1')
41
+ return (0, select_1.presetSelections)(catalog, 'essentials');
42
+ if (choice === '3')
43
+ return (0, select_1.presetSelections)(catalog, 'all');
44
+ if (choice === '4')
45
+ return await customize(catalog, rl);
46
+ // Empty input or "2" → the recommended default.
47
+ return (0, select_1.presetSelections)(catalog, 'recommended');
48
+ }
49
+ catch {
50
+ // EOF (Ctrl-D), a closed pipe, anything — never crash onboarding.
51
+ return [];
52
+ }
53
+ finally {
54
+ rl.close();
55
+ }
56
+ }
57
+ /**
58
+ * Customize from the recommended preset: for each chosen practice show
59
+ * `title [defaultLevel]` and accept enter=keep, 's'=skip, or a level name from
60
+ * availableLevels. Skimmable; unknown levels re-prompt-free (keep at default).
61
+ */
62
+ async function customize(catalog, rl) {
63
+ const base = (0, select_1.presetSelections)(catalog, 'recommended');
64
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
65
+ const result = [];
66
+ out('');
67
+ out('Customize — for each: [enter] keep · [s] skip · or type a level name.');
68
+ for (const sel of base) {
69
+ const p = byId.get(sel.id);
70
+ if (!p)
71
+ continue;
72
+ const levels = p.availableLevels.join('/');
73
+ const ans = (await rl.question(` ${p.title} [${sel.level}] (${levels}): `)).trim().toLowerCase();
74
+ if (ans === 's' || ans === 'skip')
75
+ continue;
76
+ if (ans === '') {
77
+ result.push(sel);
78
+ continue;
79
+ }
80
+ if (p.availableLevels.includes(ans)) {
81
+ result.push({ id: sel.id, level: ans });
82
+ }
83
+ else {
84
+ out(` (unrecognized level "${ans}" — keeping ${sel.level})`);
85
+ result.push(sel);
86
+ }
87
+ }
88
+ return result;
89
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reportManifest = reportManifest;
4
+ /**
5
+ * Phase 5 — report the adopted best-practices manifest to the server so the
6
+ * dashboard can show per-project adoption.
7
+ *
8
+ * This is PURELY observability and is ALWAYS best-effort / fail-OPEN: it must
9
+ * never fail or slow down `convene init` (onboarding) or `convene update --apply`
10
+ * because the report failed, was offline, or the server is down. The whole thing
11
+ * is fire-and-forget — every failure path returns `{ ok: false }` and is swallowed
12
+ * by the caller.
13
+ */
14
+ const api_1 = require("../api");
15
+ const config_1 = require("../config");
16
+ const git_1 = require("../git");
17
+ /** A short, fixed budget — the report is non-blocking, so never the 10s default. */
18
+ const REPORT_TIMEOUT_MS = 5_000;
19
+ /**
20
+ * Fire-and-forget the manifest report. Resolves to `{ ok }`, NEVER throws and
21
+ * NEVER prints. Skips silently (ok:false) when offline or with no API key — the
22
+ * report is optional and onboarding/update proceed regardless.
23
+ *
24
+ * - `offline` — caller ran with --offline; skip the network entirely.
25
+ * - no api key / no member — not logged in; nothing to authenticate the POST.
26
+ */
27
+ async function reportManifest(top, slug, manifest, opts = {}) {
28
+ try {
29
+ if (opts.offline)
30
+ return { ok: false };
31
+ const cfg = (0, config_1.resolveConfig)();
32
+ if (!cfg.apiKey)
33
+ return { ok: false };
34
+ const session = cfg.member ? (0, git_1.sessionId)(cfg.member, top) : null;
35
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool);
36
+ const res = await api.reportBestPractices(slug, manifest, REPORT_TIMEOUT_MS);
37
+ return { ok: res.ok };
38
+ }
39
+ catch {
40
+ // Belt-and-suspenders: ConveneApi.request is already fail-soft, but the
41
+ // report is observability-only — any unexpected throw is swallowed so a call
42
+ // site can fire-and-forget without a try/catch of its own.
43
+ return { ok: false };
44
+ }
45
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.selectionsForTiers = selectionsForTiers;
4
+ exports.parseSelectionFlags = parseSelectionFlags;
5
+ exports.presetSelections = presetSelections;
6
+ /** All `optInDefault:'on'` practices in the given tiers, at each one's defaultLevel. */
7
+ function selectionsForTiers(catalog, tiers) {
8
+ const want = new Set(tiers);
9
+ return catalog.practices
10
+ .filter((p) => want.has(p.tier) && p.optInDefault === 'on')
11
+ .map((p) => ({ id: p.id, level: p.defaultLevel }));
12
+ }
13
+ /**
14
+ * Resolve CLI selection flags into a Selection[] — or `null` when NO selection
15
+ * flag was passed (the caller then decides: prompt interactively, or skip the
16
+ * catalog entirely). Precedence, applied in CATALOG order at the end so the
17
+ * result is deterministic regardless of flag order:
18
+ * --no-practices → [] (explicitly adopt nothing)
19
+ * --all-practices → every practice at its defaultLevel
20
+ * --tier a,b → selectionsForTiers
21
+ * --practice id|id=lvl → add/override that id at the level (validated)
22
+ * `--practice` always wins over a tier pick for the same id.
23
+ */
24
+ function parseSelectionFlags(opts, catalog) {
25
+ const hasTier = typeof opts.tier === 'string' && opts.tier.length > 0;
26
+ const hasPractice = Array.isArray(opts.practice) && opts.practice.length > 0;
27
+ if (!opts.noPractices && !opts.allPractices && !hasTier && !hasPractice)
28
+ return null;
29
+ // --no-practices is the explicit "adopt nothing" — it short-circuits everything.
30
+ if (opts.noPractices)
31
+ return [];
32
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
33
+ // Keep levels in a map keyed by id; emit in CATALOG order at the end.
34
+ const chosen = new Map();
35
+ if (opts.allPractices) {
36
+ for (const p of catalog.practices)
37
+ chosen.set(p.id, p.defaultLevel);
38
+ }
39
+ else if (hasTier) {
40
+ const tiers = opts.tier.split(',').map((t) => t.trim()).filter(Boolean);
41
+ for (const s of selectionsForTiers(catalog, tiers))
42
+ chosen.set(s.id, s.level);
43
+ }
44
+ // --practice add/override (after tier so it wins): "id" → defaultLevel, "id=level" → that level.
45
+ if (hasPractice) {
46
+ for (const raw of opts.practice) {
47
+ const eq = raw.indexOf('=');
48
+ const id = (eq >= 0 ? raw.slice(0, eq) : raw).trim();
49
+ const lvl = eq >= 0 ? raw.slice(eq + 1).trim() : null;
50
+ const p = byId.get(id);
51
+ if (!p)
52
+ throw new Error(`unknown practice "${id}" — not in the catalog (run \`convene doctor\` to see ids)`);
53
+ let level;
54
+ if (lvl === null) {
55
+ level = p.defaultLevel;
56
+ }
57
+ else {
58
+ if (!p.availableLevels.includes(lvl)) {
59
+ throw new Error(`invalid level "${lvl}" for practice "${id}" — choose one of: ${p.availableLevels.join(', ')}`);
60
+ }
61
+ level = lvl;
62
+ }
63
+ chosen.set(id, level);
64
+ }
65
+ }
66
+ return catalog.practices.filter((p) => chosen.has(p.id)).map((p) => ({ id: p.id, level: chosen.get(p.id) }));
67
+ }
68
+ /**
69
+ * Named onboarding presets:
70
+ * essentials — essentials-tier opt-ins
71
+ * recommended — essentials + recommended opt-ins
72
+ * all — every practice at its defaultLevel
73
+ * none — []
74
+ */
75
+ function presetSelections(catalog, preset) {
76
+ switch (preset) {
77
+ case 'none':
78
+ return [];
79
+ case 'essentials':
80
+ return selectionsForTiers(catalog, ['essentials']);
81
+ case 'recommended':
82
+ return selectionsForTiers(catalog, ['essentials', 'recommended']);
83
+ case 'all':
84
+ return catalog.practices.map((p) => ({ id: p.id, level: p.defaultLevel }));
85
+ }
86
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical type contract for the Convene best-practices catalog — the data model
4
+ * shared by the server (source of truth, seeds the DB + serves GET /api/v1/catalog)
5
+ * and the CLI (a generated mirror it materializes into repos). Kept deliberately
6
+ * small and stable; the CLI carries a byte-mirror of this file (see brand.ts for the
7
+ * same dual-copy convention) so the two packages never import across the workspace.
8
+ *
9
+ * Versioning: every Practice carries its own SemVer (so a single practice can be
10
+ * tightened without rev'ing the whole catalog), and the Catalog itself carries a
11
+ * release SemVer. A repo's manifest pins the (practice, version, level) triple it
12
+ * adopted — see PracticeManifestEntry — so updates are diffable and never silent.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -13,6 +13,8 @@ const node_child_process_1 = require("node:child_process");
13
13
  const brand_1 = require("../brand");
14
14
  const api_1 = require("../api");
15
15
  const config_1 = require("../config");
16
+ const catalog_1 = require("../catalog");
17
+ const manifest_1 = require("../catalog/manifest");
16
18
  const git_1 = require("../git");
17
19
  const hook_1 = require("../hook");
18
20
  const cache_1 = require("../cache");
@@ -310,6 +312,48 @@ async function doctor(opts) {
310
312
  for (const c of checks) {
311
313
  process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
312
314
  }
315
+ // Best practices (local-only, advisory): adopted-practice inventory + whether
316
+ // the repo trails the catalog. Fail-soft — never throws and never alters the
317
+ // doctor exit code.
318
+ reportBestPractices(top, proj?.slug ?? null);
313
319
  if (!checks.every((c) => c.ok))
314
320
  process.exitCode = 1;
315
321
  }
322
+ /**
323
+ * Print doctor's "Best practices" section. Local-only this phase: reads the repo
324
+ * manifest and diffs it against the bundled catalog. Purely informational — it
325
+ * never throws (a malformed manifest is swallowed) and never sets the exit code.
326
+ * - manifest present → catalog freshness line + each adopted practice + unknowns.
327
+ * - on the bus but no manifest → a single nudge to adopt some.
328
+ * - not on the bus → nothing.
329
+ */
330
+ function reportBestPractices(top, slug) {
331
+ try {
332
+ const manifest = (0, config_1.loadManifest)(top);
333
+ if (!manifest) {
334
+ if (slug) {
335
+ process.stdout.write('· no best practices adopted — run `convene init` to choose some\n');
336
+ }
337
+ return;
338
+ }
339
+ const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog_1.CATALOG);
340
+ process.stdout.write(cmp.behind
341
+ ? `· catalog v${cmp.catalogVersion} available (repo on v${cmp.repoVersion}) — run \`convene update\`\n`
342
+ : `· best practices up to date at v${cmp.repoVersion}\n`);
343
+ for (const p of cmp.adopted) {
344
+ const flag = p.outdated ? ` (outdated → v${p.catalogVersion})` : '';
345
+ process.stdout.write(` ${p.id} @ ${p.manifestVersion} [${p.level}]${flag}\n`);
346
+ }
347
+ if (cmp.unknownIds.length) {
348
+ process.stdout.write(` unknown (not in catalog): ${cmp.unknownIds.join(', ')}\n`);
349
+ }
350
+ // Adoption is reported to the server on init/update — point the human at the
351
+ // dashboard view. Local-only hint; no network call (slug present ⇒ on the bus).
352
+ if (slug) {
353
+ process.stdout.write(` adoption is visible on the dashboard: ${(0, config_1.resolveConfig)().baseUrl}/p/${slug}\n`);
354
+ }
355
+ }
356
+ catch {
357
+ /* fail-soft: best-practices reporting must never break doctor */
358
+ }
359
+ }
@@ -21,6 +21,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
21
21
  const git_1 = require("../git");
22
22
  const config_1 = require("../config");
23
23
  const cache_1 = require("../cache");
24
+ const manifest_1 = require("../catalog/manifest");
24
25
  const api_1 = require("../api");
25
26
  const render_1 = require("../render");
26
27
  const catchup_1 = require("./catchup");
@@ -28,6 +29,30 @@ const exit_1 = require("../exit");
28
29
  const CACHE_TTL_SEC = 3;
29
30
  const FETCH_TIMEOUT_MS = 4000;
30
31
  const WATCHDOG_MS = 6000;
32
+ /** Catalog-version cache TTL for the behind-nudge — long enough to stay off the hot path. */
33
+ const CATALOG_VERSION_TTL_SEC = 3600;
34
+ /**
35
+ * A single fail-soft nudge line iff the repo's adopted-practices manifest trails a
36
+ * CACHED live catalog version. Reads only local state (the manifest + the catalog-
37
+ * version cache) — NEVER hits the network, so it can't slow the hot path. On a cache
38
+ * miss, no manifest, or any error → null (no line; never spam when up to date).
39
+ */
40
+ function catalogBehindNudge(top) {
41
+ try {
42
+ const manifest = (0, config_1.loadManifest)(top);
43
+ if (!manifest)
44
+ return null;
45
+ const live = (0, cache_1.readCatalogVersion)(CATALOG_VERSION_TTL_SEC);
46
+ if (!live)
47
+ return null;
48
+ if (!(0, manifest_1.semverLt)(manifest.catalogVersion, live))
49
+ return null;
50
+ return `convene: best-practices catalog v${live} available (repo on v${manifest.catalogVersion}) — run \`convene update\``;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
31
56
  /**
32
57
  * Write a rendered block. In `codexHook` mode, wrap it in Codex's UserPromptSubmit
33
58
  * hook envelope so its `additionalContext` lands in the model's per-turn context;
@@ -128,10 +153,21 @@ async function runFetch(opts = {}) {
128
153
  // DEGRADED/failure under --since-last emits nothing (structural suppression).
129
154
  return done(0);
130
155
  }
156
+ // A fail-soft, network-free nudge line emitted alongside a healthy block when
157
+ // the repo trails the CACHED live catalog version (suppressed under --json /
158
+ // codex-hook to keep those envelopes clean).
159
+ const emitNudge = () => {
160
+ if (opts.json || opts.codexHook)
161
+ return;
162
+ const nudge = catalogBehindNudge(top);
163
+ if (nudge)
164
+ process.stdout.write(nudge + '\n');
165
+ };
131
166
  // Cache short-circuit for rapid successive prompts.
132
167
  const cache = (0, cache_1.readCache)(slug);
133
168
  if (cache && (0, cache_1.ageSeconds)(cache) < CACHE_TTL_SEC) {
134
169
  renderData(cache.data, { slug, member, session, lookback, syncedAgoSec: (0, cache_1.ageSeconds)(cache), json: opts.json });
170
+ emitNudge();
135
171
  return done(0);
136
172
  }
137
173
  // Slow path: bounded network fetch.
@@ -140,6 +176,20 @@ async function runFetch(opts = {}) {
140
176
  if (res.ok && res.json) {
141
177
  (0, cache_1.writeCache)(slug, res.json);
142
178
  renderData(res.json, { slug, member, session, lookback, syncedAgoSec: 0, json: opts.json });
179
+ // Opportunistically refresh the catalog-version cache off the slow path (never
180
+ // its own round-trip on the hot path): only when the cached version is stale,
181
+ // and only if a manifest exists (a non-best-practices repo never needs it).
182
+ if ((0, config_1.loadManifest)(top) && !(0, cache_1.readCatalogVersion)(CATALOG_VERSION_TTL_SEC)) {
183
+ try {
184
+ const cat = await api.getCatalog(FETCH_TIMEOUT_MS);
185
+ if (cat.ok && cat.json?.version)
186
+ (0, cache_1.writeCatalogVersion)(cat.json.version);
187
+ }
188
+ catch {
189
+ /* fail-soft: the nudge just stays suppressed until next refresh */
190
+ }
191
+ }
192
+ emitNudge();
143
193
  return done(0);
144
194
  }
145
195
  // Fetch failed/timed out → DEGRADED from stale cache (or absent).
@@ -3,7 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.inbox = inbox;
4
4
  /** `convene inbox` — open questions/proposals addressed to me. */
5
5
  const ctx_1 = require("../ctx");
6
+ const git_1 = require("../git");
7
+ const config_1 = require("../config");
6
8
  async function inbox(opts) {
9
+ // `--all-projects` is a DELIBERATELY cross-project view (every project you belong to).
10
+ // Per-project scoping is otherwise airtight; this one flag opts out of it on purpose.
11
+ // Running it from a repo that isn't on the bus pulls other projects' items into an
12
+ // unrelated session — almost never intended. Make it a deliberate act: require the
13
+ // cwd repo to be on Convene, or an explicit `--force`.
14
+ if (opts.allProjects && !opts.force) {
15
+ const proj = (0, config_1.loadProjectConfig)((0, git_1.gitToplevel)());
16
+ if (!proj?.slug) {
17
+ (0, ctx_1.die)('refusing `--all-projects` from a repo that is NOT on Convene — this flag is a deliberate ' +
18
+ 'cross-project view; running it from an unrelated checkout pulls other projects’ items into ' +
19
+ 'this session. cd into a Convene repo, or pass `--force` if you really mean to.');
20
+ }
21
+ }
7
22
  const ctx = (0, ctx_1.getContext)({ project: opts.project });
8
23
  const slug = opts.allProjects ? null : ctx.slug; // null => /inbox across all my projects
9
24
  const res = await ctx.api.inbox(slug, 10_000);
@@ -3,7 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AIDER_CONF = exports.CONVENE_PATHS = void 0;
6
7
  exports.upsertMarkerBlock = upsertMarkerBlock;
8
+ exports.removeMarkerBlock = removeMarkerBlock;
9
+ exports.removeGitignoreGuard = removeGitignoreGuard;
10
+ exports.removeTomlBlock = removeTomlBlock;
7
11
  exports.init = init;
8
12
  /**
9
13
  * `convene init` — one-command repo onboarding. IDEMPOTENT + merge-safe
@@ -20,7 +24,33 @@ const api_1 = require("../api");
20
24
  const protocol_1 = require("../protocol");
21
25
  const hook_1 = require("../hook");
22
26
  const githook_1 = require("../githook");
27
+ const catalog_1 = require("../catalog");
28
+ const materialize_1 = require("../catalog/materialize");
29
+ const select_1 = require("../catalog/select");
30
+ const prompt_1 = require("../catalog/prompt");
31
+ const report_1 = require("../catalog/report");
23
32
  const ctx_1 = require("../ctx");
33
+ /**
34
+ * The repo-relative paths convene init writes (the onboarding footprint). Shared by
35
+ * init's `--commit` and `convene off-board` so the two stay in lockstep — staging
36
+ * exactly these, never a blanket `git add -A`.
37
+ */
38
+ exports.CONVENE_PATHS = [
39
+ '.convene',
40
+ 'CLAUDE.md',
41
+ 'AGENTS.md',
42
+ 'CONVENE_PROTOCOL.md',
43
+ '.gitignore',
44
+ '.claude/settings.json',
45
+ '.githooks/pre-push',
46
+ '.cursor/rules/convene.mdc',
47
+ '.cursor/mcp.json',
48
+ '.clinerules/convene.md',
49
+ '.gemini/settings.json',
50
+ '.aider.conf.yml',
51
+ '.vscode/mcp.json',
52
+ '.codex/config.toml',
53
+ ];
24
54
  const log = (m) => process.stdout.write(m + '\n');
25
55
  /** Replace content between the convene markers, or append the block if absent. */
26
56
  function upsertMarkerBlock(content, block) {
@@ -34,6 +64,29 @@ function upsertMarkerBlock(content, block) {
34
64
  const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
35
65
  return content + sep + block + '\n';
36
66
  }
67
+ /**
68
+ * Inverse of upsertMarkerBlock (off-board). Removes the convene block (markers
69
+ * inclusive), collapsing the blank separator upsert added before it and the
70
+ * trailing newline after — so a file that ONLY ever held the block returns to ''
71
+ * (caller deletes it), and a file with pre-existing content returns BYTE-IDENTICAL
72
+ * to its pre-onboard form. `removed` is false when no block is present.
73
+ */
74
+ function removeMarkerBlock(content) {
75
+ return stripBetween(content, brand_1.BRAND.blockBegin, brand_1.BRAND.blockEnd);
76
+ }
77
+ /** Shared block-removal for both the HTML (CLAUDE/AGENTS) and TOML (Codex) markers. */
78
+ function stripBetween(content, begin, end) {
79
+ const start = content.indexOf(begin);
80
+ const endIdx = content.indexOf(end);
81
+ if (start < 0 || endIdx <= start)
82
+ return { content, removed: false };
83
+ const head = content.slice(0, start).replace(/\n+$/, '');
84
+ const tail = content.slice(endIdx + end.length).replace(/^\n+/, '');
85
+ let joined = head && tail ? head + '\n\n' + tail : head + tail;
86
+ if (joined.length > 0 && !joined.endsWith('\n'))
87
+ joined += '\n';
88
+ return { content: joined, removed: true };
89
+ }
37
90
  // Every file this writes lives inside the git repo, so git history IS the backup —
38
91
  // dropping a sibling `.bak` just litters the working tree (and shows up as untracked
39
92
  // noise / risks being committed). The only `.bak` we keep is for the user's GLOBAL
@@ -97,6 +150,39 @@ function ensureGitignoreGuard(top) {
97
150
  log(' then `git add -f .convene/project.json`.');
98
151
  }
99
152
  }
153
+ /**
154
+ * Inverse of ensureGitignoreGuard (off-board). Drops the three granular guard lines
155
+ * AND restores any blanket `.convene/` rule we had commented out — so the file
156
+ * round-trips to its pre-onboard form. If the file ends up empty (it was created
157
+ * solely for the guard), it is deleted. Returns true iff anything changed.
158
+ */
159
+ function removeGitignoreGuard(top) {
160
+ const file = node_path_1.default.join(top, '.gitignore');
161
+ if (!node_fs_1.default.existsSync(file))
162
+ return false;
163
+ const old = node_fs_1.default.readFileSync(file, 'utf8');
164
+ const guardComment = '# convene (keep local cache out of git; .convene/project.json IS committed)';
165
+ const next = old
166
+ .split('\n')
167
+ .filter((line) => {
168
+ const t = line.trim();
169
+ return t !== guardComment && t !== '.convene/cache/' && t !== '.convene/*.local.json';
170
+ })
171
+ .map((line) => {
172
+ // Re-enable a blanket rule we disabled (e.g. "# .convene/ (disabled by convene init: …)").
173
+ const m = line.match(/^#\s(.+?)\s+\(disabled by convene init/);
174
+ return m ? m[1] : line;
175
+ })
176
+ .join('\n');
177
+ if (next === old)
178
+ return false;
179
+ if (next.trim().length === 0) {
180
+ node_fs_1.default.rmSync(file, { force: true });
181
+ return true;
182
+ }
183
+ node_fs_1.default.writeFileSync(file, next);
184
+ return true;
185
+ }
100
186
  function hookSnippet() {
101
187
  return JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: hook_1.HOOK_COMMAND }] }] } }, null, 2);
102
188
  }
@@ -294,6 +380,9 @@ function writeGeminiSettings(top) {
294
380
  const r = writeIfChanged(file, JSON.stringify(obj, null, 2) + '\n');
295
381
  log(`${r === 'unchanged' ? '·' : '✓'} .gemini/settings.json (${r}) — Gemini CLI reads AGENTS.md each prompt`);
296
382
  }
383
+ /** The exact `.aider.conf.yml` init writes when none exists — so off-board can tell
384
+ * "ours" (delete) from a user's own config (leave). */
385
+ exports.AIDER_CONF = 'read:\n - AGENTS.md\n - CONVENE_PROTOCOL.md\n';
297
386
  function writeAiderConf(top) {
298
387
  // No YAML parser bundled, so write-if-absent (don't risk clobbering an existing config).
299
388
  const file = node_path_1.default.join(top, '.aider.conf.yml');
@@ -301,7 +390,7 @@ function writeAiderConf(top) {
301
390
  log('· .aider.conf.yml (exists) — add `read: [AGENTS.md, CONVENE_PROTOCOL.md]` so Aider loads Convene');
302
391
  return;
303
392
  }
304
- node_fs_1.default.writeFileSync(file, 'read:\n - AGENTS.md\n - CONVENE_PROTOCOL.md\n');
393
+ node_fs_1.default.writeFileSync(file, exports.AIDER_CONF);
305
394
  log('✓ .aider.conf.yml (created) — Aider loads the Convene instructions at startup');
306
395
  }
307
396
  function writeAgentRules(top, slug, member, baseUrl) {
@@ -335,6 +424,10 @@ function upsertTomlBlock(content, block) {
335
424
  const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
336
425
  return content + sep + block + '\n';
337
426
  }
427
+ /** Inverse of upsertTomlBlock (off-board) — removes the convene block from a Codex config.toml. */
428
+ function removeTomlBlock(content) {
429
+ return stripBetween(content, TOML_BEGIN, TOML_END);
430
+ }
338
431
  /**
339
432
  * Ensure the `convene` server in a JSON MCP config under `topKey` (`mcpServers` for
340
433
  * Cursor/Gemini, `servers` for VS Code). `stdioType` adds `"type":"stdio"` (VS Code
@@ -389,10 +482,59 @@ function writeMcpConfigs(top, baseUrl) {
389
482
  writeCodexConfig(top, baseUrl);
390
483
  log('· Cline & Windsurf register MCP only in a user-global file — add `convene` (`npx -y convene-mcp`) there manually; see CONVENE_PROTOCOL.md.');
391
484
  }
485
+ /**
486
+ * Resolve + materialize the adopted best practices for this repo (Phase 2). Flags
487
+ * win when present; otherwise a real interactive TTY (and NOT --yes) gets the
488
+ * picker; otherwise we skip silently. An EMPTY selection writes NOTHING (no
489
+ * manifest, no doc, no ref region) — that invariant keeps a plain init byte-identical.
490
+ */
491
+ async function adoptBestPractices(top, slug, opts) {
492
+ let selections;
493
+ const flags = (0, select_1.parseSelectionFlags)(opts, catalog_1.CATALOG);
494
+ if (flags !== null) {
495
+ selections = flags;
496
+ }
497
+ else if (process.stdin.isTTY && process.stdout.isTTY && !opts.yes) {
498
+ selections = await (0, prompt_1.pickPracticesInteractively)(catalog_1.CATALOG);
499
+ }
500
+ else {
501
+ // Non-interactive with no --tier/--practice flags: adopt nothing, silently — a plain
502
+ // headless onboarding stays byte-identical. `convene doctor` is where a human is
503
+ // nudged that best practices are available to adopt.
504
+ return;
505
+ }
506
+ // Respect --no-hook: when init declines to write the committed project settings,
507
+ // the materialized settingsHook guards are skipped too (settingsJson/gitignore
508
+ // still apply — they are not Claude-Code hooks).
509
+ const wireHooks = !(opts.noHook === true || opts.hook === false);
510
+ const entries = (0, materialize_1.materializePractices)(top, slug, selections, catalog_1.CATALOG, { wireHooks });
511
+ if (entries.length) {
512
+ const manifest = { catalogVersion: catalog_1.CATALOG.version, channel: 'minor', practices: entries };
513
+ (0, config_1.writeManifest)(top, manifest);
514
+ log(`✓ adopted ${entries.length} best practices (.convene/best-practices.md) — see them with \`convene doctor\``);
515
+ // Phase 5: report adoption to the server so the dashboard can show it.
516
+ // Best-effort, fail-OPEN, fire-and-forget — onboarding NEVER fails/slows
517
+ // because the report failed; skipped silently when --offline or no api key.
518
+ const { ok } = await (0, report_1.reportManifest)(top, slug, manifest, { offline: opts.offline });
519
+ if (ok)
520
+ log('· reported adoption to the dashboard');
521
+ }
522
+ }
392
523
  async function init(opts) {
393
524
  const top = (0, git_1.gitToplevel)();
394
525
  if (!top)
395
526
  (0, ctx_1.die)('not a git repository — run `convene init` inside a repo');
527
+ // Consent gate: onboarding writes a footprint and registers per-prompt hooks — it
528
+ // must be a DELIBERATE choice, never an accidental side-effect. A human at a
529
+ // terminal (TTY) confirms simply by running it; an agent / CI (no TTY) must pass
530
+ // `--yes` so a repo can never be onboarded as a stray side-effect (the VAcontractorCo
531
+ // failure). This is about intent, not confidentiality — private repos are first-class
532
+ // (init commits the join token into them by design), and each repo is its own
533
+ // project, scoped to the members you add.
534
+ if (!opts.yes && !process.stdout.isTTY) {
535
+ (0, ctx_1.die)('refusing to onboard non-interactively without confirmation — connecting THIS repo to Convene ' +
536
+ 'is a deliberate choice, not a side-effect. If you intend it, re-run with `--yes`.');
537
+ }
396
538
  const cfg = (0, config_1.resolveConfig)();
397
539
  const baseUrl = cfg.baseUrl;
398
540
  let member = cfg.member;
@@ -481,6 +623,23 @@ async function init(opts) {
481
623
  joinToken = jt.json.join_token;
482
624
  }
483
625
  }
626
+ // Membership verification — the fix for "half-onboarded silently". Every path
627
+ // above should leave us a MEMBER of `slug`, but `createProject` returns 409 for a
628
+ // project that already exists WITHOUT making us a member (the VAcontractorCo case),
629
+ // and that was being swallowed — so init wrote the full footprint and every later
630
+ // `convene fetch` 403'd into a DEGRADED block. Probe membership NOW, before any
631
+ // local file is written: getProject returns 403 specifically for exists-but-not-a-
632
+ // member. Fail loudly with nothing left behind. (status 0 = offline/timeout → fail
633
+ // open and proceed, matching init's fail-open ethos elsewhere.)
634
+ const verify = await api.getProject(slug, 8000);
635
+ if (verify.status === 403) {
636
+ (0, ctx_1.die)(`onboarding aborted: project "${slug}" exists but the server did not confirm your membership ` +
637
+ `(GET returned 403). No local files were written. Ask an owner to add you — or \`convene join\` ` +
638
+ `with a token — then re-run \`convene init\`.`);
639
+ }
640
+ if (verify.status !== 200 && verify.status !== 0) {
641
+ log(`⚠ could not confirm project membership (status ${verify.status}); proceeding with local setup.`);
642
+ }
484
643
  }
485
644
  else if (!slug) {
486
645
  (0, ctx_1.die)('--offline requires --slug (or an existing .convene/project.json)');
@@ -582,6 +741,28 @@ async function init(opts) {
582
741
  }
583
742
  // 8. memory seed (best-effort, outside the repo)
584
743
  seedMemory(top, slug, baseUrl);
744
+ // 8-bis. best-practices (Phase 2). Resolve selections from flags; else prompt
745
+ // interactively at a real TTY; else (non-TTY / no flags) skip. CRITICAL
746
+ // INVARIANT: when `selections` is empty NOTHING is written — no manifest, no
747
+ // .convene/best-practices.md, no ref region — so a plain non-interactive init
748
+ // stays byte-identical to its pre-catalog output (init.test.ts passes unchanged).
749
+ await adoptBestPractices(top, slug, opts);
750
+ // 8a. optional isolated commit — stage ONLY the convene files (never `git add -A`),
751
+ // so onboarding can never be bundled into unrelated work (the VAcontractorCo
752
+ // entangled-commit failure). Off by default; `--commit` opts in.
753
+ if (opts.commit) {
754
+ const paths = exports.CONVENE_PATHS.filter((p) => node_fs_1.default.existsSync(node_path_1.default.join(top, p)));
755
+ if (paths.length && (0, git_1.gitAddPaths)(paths, top)) {
756
+ const res = (0, git_1.gitCommit)('Onboard onto Convene coordination bus', paths, top);
757
+ if (res.ok)
758
+ log(`✓ committed onboarding as one isolated commit${res.sha ? ` (${res.sha})` : ''} — only the convene files were staged.`);
759
+ else
760
+ log('· nothing committed (no staged changes).');
761
+ }
762
+ else if (paths.length) {
763
+ log('⚠ could not stage the convene files — commit them manually.');
764
+ }
765
+ }
585
766
  // 9. teammate one-liner
586
767
  log('');
587
768
  log(`Done. Project "${slug}" — dashboard: ${baseUrl}/p/${slug}`);