convene-cli 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +23 -0
- package/dist/cache.js +83 -1
- package/dist/catalog/catalog.generated.js +860 -0
- package/dist/catalog/index.js +26 -0
- package/dist/catalog/manifest.js +71 -0
- package/dist/catalog/materialize.js +516 -0
- package/dist/catalog/prompt.js +89 -0
- package/dist/catalog/report.js +45 -0
- package/dist/catalog/select.js +86 -0
- package/dist/catalog/types.js +14 -0
- package/dist/commands/auth.js +44 -0
- package/dist/commands/fetch.js +50 -0
- package/dist/commands/inbox.js +15 -0
- package/dist/commands/init.js +182 -1
- package/dist/commands/offboard.js +526 -0
- package/dist/commands/override.js +65 -0
- package/dist/commands/practice-guard.js +291 -0
- package/dist/commands/setup.js +20 -3
- package/dist/commands/update.js +249 -0
- package/dist/config.js +19 -1
- package/dist/git.js +73 -0
- package/dist/githook.js +37 -0
- package/dist/hook.js +56 -0
- package/dist/index.js +60 -4
- package/dist/protocol.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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/inbox.js
CHANGED
|
@@ -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);
|
package/dist/commands/init.js
CHANGED
|
@@ -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,
|
|
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}`);
|