convene-cli 1.2.0 → 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/init.js +49 -0
- package/dist/commands/offboard.js +95 -10
- package/dist/commands/override.js +65 -0
- package/dist/commands/practice-guard.js +291 -0
- package/dist/commands/setup.js +11 -1
- package/dist/commands/update.js +249 -0
- package/dist/config.js +19 -1
- package/dist/index.js +43 -2
- package/package.json +1 -1
|
@@ -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 });
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/fetch.js
CHANGED
|
@@ -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).
|
package/dist/commands/init.js
CHANGED
|
@@ -24,6 +24,11 @@ const api_1 = require("../api");
|
|
|
24
24
|
const protocol_1 = require("../protocol");
|
|
25
25
|
const hook_1 = require("../hook");
|
|
26
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");
|
|
27
32
|
const ctx_1 = require("../ctx");
|
|
28
33
|
/**
|
|
29
34
|
* The repo-relative paths convene init writes (the onboarding footprint). Shared by
|
|
@@ -477,6 +482,44 @@ function writeMcpConfigs(top, baseUrl) {
|
|
|
477
482
|
writeCodexConfig(top, baseUrl);
|
|
478
483
|
log('· Cline & Windsurf register MCP only in a user-global file — add `convene` (`npx -y convene-mcp`) there manually; see CONVENE_PROTOCOL.md.');
|
|
479
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
|
+
}
|
|
480
523
|
async function init(opts) {
|
|
481
524
|
const top = (0, git_1.gitToplevel)();
|
|
482
525
|
if (!top)
|
|
@@ -698,6 +741,12 @@ async function init(opts) {
|
|
|
698
741
|
}
|
|
699
742
|
// 8. memory seed (best-effort, outside the repo)
|
|
700
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);
|
|
701
750
|
// 8a. optional isolated commit — stage ONLY the convene files (never `git add -A`),
|
|
702
751
|
// so onboarding can never be bundled into unrelated work (the VAcontractorCo
|
|
703
752
|
// entangled-commit failure). Off by default; `--commit` opts in.
|
|
@@ -27,8 +27,42 @@ const api_1 = require("../api");
|
|
|
27
27
|
const hook_1 = require("../hook");
|
|
28
28
|
const githook_1 = require("../githook");
|
|
29
29
|
const init_1 = require("./init");
|
|
30
|
+
const materialize_1 = require("../catalog/materialize");
|
|
31
|
+
const catalog_1 = require("../catalog");
|
|
30
32
|
const protocol_1 = require("../protocol");
|
|
31
33
|
const ctx_1 = require("../ctx");
|
|
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.
|
|
39
|
+
*/
|
|
40
|
+
function catalogManagedDenyEntries() {
|
|
41
|
+
const out = new Set();
|
|
42
|
+
for (const p of catalog_1.CATALOG.practices) {
|
|
43
|
+
for (const a of p.artifacts) {
|
|
44
|
+
if (a.kind === 'settingsJson') {
|
|
45
|
+
for (const d of (0, materialize_1.denyEntriesOf)(a))
|
|
46
|
+
out.add(d);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
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.
|
|
55
|
+
*/
|
|
56
|
+
function catalogManagedGitignoreLines() {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const p of catalog_1.CATALOG.practices) {
|
|
59
|
+
for (const a of p.artifacts) {
|
|
60
|
+
if (a.kind === 'gitignore')
|
|
61
|
+
out.push(...a.lines);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
32
66
|
const log = (m) => process.stdout.write(m + '\n');
|
|
33
67
|
const abs = (top, rel) => node_path_1.default.join(top, rel);
|
|
34
68
|
const jsonOut = (obj) => JSON.stringify(obj, null, 2) + '\n';
|
|
@@ -84,15 +118,23 @@ function delIfOurs(top, rel, expected, touched, dryRun) {
|
|
|
84
118
|
if (!dryRun)
|
|
85
119
|
node_fs_1.default.rmSync(p, { force: true });
|
|
86
120
|
}
|
|
87
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Strip BOTH convene-managed regions from a Markdown doc — the main coordination
|
|
123
|
+
* block AND the Phase-2 best-practices ref region (`<!-- convene:practices:begin -->`
|
|
124
|
+
* …) — in ONE read/write so the file is reported (and staged) at most once. Deletes
|
|
125
|
+
* the file if nothing else remains. The dedicated `.convene/best-practices.md` +
|
|
126
|
+
* `.convene/practices/` docs are removed wholesale by `delPath('.convene')`.
|
|
127
|
+
*/
|
|
88
128
|
function stripMarker(top, rel, touched, dryRun) {
|
|
89
129
|
const p = abs(top, rel);
|
|
90
130
|
if (!node_fs_1.default.existsSync(p))
|
|
91
131
|
return;
|
|
92
|
-
const
|
|
93
|
-
|
|
132
|
+
const orig = node_fs_1.default.readFileSync(p, 'utf8');
|
|
133
|
+
const block = (0, init_1.removeMarkerBlock)(orig);
|
|
134
|
+
const ref = (0, materialize_1.removeRefRegion)(block.content);
|
|
135
|
+
if (!block.removed && !ref.removed)
|
|
94
136
|
return;
|
|
95
|
-
applyStripOrDelete(top, rel, content, touched, dryRun);
|
|
137
|
+
applyStripOrDelete(top, rel, ref.content, touched, dryRun);
|
|
96
138
|
}
|
|
97
139
|
/** Strip the convene block from a Codex config.toml; delete if nothing else remains. */
|
|
98
140
|
function stripToml(top, rel, touched, dryRun) {
|
|
@@ -189,7 +231,14 @@ function finalizeJson(top, rel, obj, touched, dryRun) {
|
|
|
189
231
|
node_fs_1.default.writeFileSync(p, jsonOut(obj));
|
|
190
232
|
}
|
|
191
233
|
}
|
|
192
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Strip convene-managed content from the committed .claude/settings.json — both the
|
|
236
|
+
* convene-authored hooks (withoutConveneHooks, which also matches the materialized
|
|
237
|
+
* `convene practice-guard …` PreToolUse/Stop hooks) AND the Phase-3 permissions.deny
|
|
238
|
+
* entries materialize.ts merged in (subtract EXACTLY the catalog's managed set;
|
|
239
|
+
* drop permissions.deny if it empties, then permissions if it empties). A teammate's
|
|
240
|
+
* own hooks / deny rules / settings are preserved. Delete the file if nothing remains.
|
|
241
|
+
*/
|
|
193
242
|
function stripClaudeSettings(top, rel, touched, dryRun) {
|
|
194
243
|
const p = abs(top, rel);
|
|
195
244
|
if (!node_fs_1.default.existsSync(p))
|
|
@@ -202,7 +251,26 @@ function stripClaudeSettings(top, rel, touched, dryRun) {
|
|
|
202
251
|
log(`· ${rel} (left as-is — unparseable JSON)`);
|
|
203
252
|
return;
|
|
204
253
|
}
|
|
205
|
-
const
|
|
254
|
+
const stripped = (0, hook_1.withoutConveneHooks)(obj);
|
|
255
|
+
let removed = stripped.removed;
|
|
256
|
+
const settings = stripped.settings;
|
|
257
|
+
// Subtract the convene-managed permissions.deny entries (only ours).
|
|
258
|
+
const managedDeny = catalogManagedDenyEntries();
|
|
259
|
+
const deny = settings?.permissions?.deny;
|
|
260
|
+
if (Array.isArray(deny) && managedDeny.size) {
|
|
261
|
+
const kept = deny.filter((d) => !(typeof d === 'string' && managedDeny.has(d)));
|
|
262
|
+
if (kept.length !== deny.length) {
|
|
263
|
+
removed = true;
|
|
264
|
+
if (kept.length > 0) {
|
|
265
|
+
settings.permissions.deny = kept;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
delete settings.permissions.deny;
|
|
269
|
+
if (settings.permissions && Object.keys(settings.permissions).length === 0)
|
|
270
|
+
delete settings.permissions;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
206
274
|
if (!removed)
|
|
207
275
|
return;
|
|
208
276
|
if ((0, hook_1.settingsIsEmpty)(settings)) {
|
|
@@ -236,24 +304,41 @@ function handleGitHook(top, touched, dryRun) {
|
|
|
236
304
|
else if (r.status === 'skipped-foreign')
|
|
237
305
|
log('· .githooks/pre-push (left as-is — foreign hook)');
|
|
238
306
|
}
|
|
239
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Reverse BOTH .gitignore footprints: init's cache guard (removeGitignoreGuard, which
|
|
309
|
+
* also re-enables any blanket rule init disabled) AND the Phase-3 practices lines +
|
|
310
|
+
* managed marker materialize.ts appended (removeGitignoreLines). Done in one
|
|
311
|
+
* read/write so the file is reported/staged at most once; deletes the file if empty.
|
|
312
|
+
*/
|
|
240
313
|
function handleGitignore(top, touched, dryRun) {
|
|
241
314
|
const rel = '.gitignore';
|
|
242
315
|
const p = abs(top, rel);
|
|
243
316
|
if (!node_fs_1.default.existsSync(p))
|
|
244
317
|
return;
|
|
245
318
|
const old = node_fs_1.default.readFileSync(p, 'utf8');
|
|
246
|
-
const
|
|
319
|
+
const practiceLines = catalogManagedGitignoreLines();
|
|
320
|
+
const practicesProbe = (0, materialize_1.removeGitignoreLines)(old, practiceLines);
|
|
321
|
+
const willChangeGuard = old.includes('.convene/cache/') ||
|
|
247
322
|
old.includes('.convene/*.local.json') ||
|
|
248
323
|
old.includes('# convene (keep local cache') ||
|
|
249
324
|
/\(disabled by convene init/.test(old);
|
|
250
|
-
if (!
|
|
325
|
+
if (!willChangeGuard && !practicesProbe.removed)
|
|
251
326
|
return;
|
|
252
327
|
if (dryRun) {
|
|
253
328
|
touched.push({ rel, action: 'strip' });
|
|
254
329
|
return;
|
|
255
330
|
}
|
|
256
|
-
|
|
331
|
+
// 1. Strip the Phase-3 practices lines first (write-back so removeGitignoreGuard
|
|
332
|
+
// operates on the practices-free body).
|
|
333
|
+
if (practicesProbe.removed) {
|
|
334
|
+
if (practicesProbe.content.trim().length === 0)
|
|
335
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
336
|
+
else
|
|
337
|
+
node_fs_1.default.writeFileSync(p, practicesProbe.content);
|
|
338
|
+
}
|
|
339
|
+
// 2. Then reverse init's cache guard (no-op if the file was already deleted).
|
|
340
|
+
const guardChanged = node_fs_1.default.existsSync(p) ? (0, init_1.removeGitignoreGuard)(top) : false;
|
|
341
|
+
if (practicesProbe.removed || guardChanged) {
|
|
257
342
|
touched.push({ rel, action: node_fs_1.default.existsSync(p) ? 'strip' : 'delete' });
|
|
258
343
|
}
|
|
259
344
|
}
|