convene-cli 1.2.0 → 1.4.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,249 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.update = update;
4
+ exports.runUpdate = runUpdate;
5
+ /**
6
+ * `convene update` — Phase 4: check for + apply best-practices catalog updates.
7
+ *
8
+ * The repo's manifest (.convene/project.json schema 2) pins the (practice, version,
9
+ * level) triples it materialized against some catalog release. This command diffs
10
+ * that against the LIVE catalog (GET /catalog, falling back to the bundled mirror)
11
+ * and, on --apply, RE-MATERIALIZES the adopted practices at their CURRENT levels to
12
+ * the live versions — then rewrites the manifest.
13
+ *
14
+ * HARD SAFETY RAILS (the whole point of this verb):
15
+ * - MAJOR-bump practices are SKIPPED unless --force (a major bump may change
16
+ * behavior/enforcement; the human re-reads and re-adopts deliberately).
17
+ * - DRIFTED practices (a human hand-edited the region) are SKIPPED unless --force
18
+ * — update never silently overwrites a local edit.
19
+ * - NEVER git add / commit / push. Changes land in the working tree; the user
20
+ * reviews with `git diff` and commits.
21
+ * - --auto-patch limits an UNATTENDED apply to patch-only bumps (minor/major are
22
+ * left for a human even without drift).
23
+ *
24
+ * Fail-soft throughout: offline → bundled catalog; a malformed manifest or any
25
+ * unexpected error prints a clear note and exits non-fatally for the dry run.
26
+ */
27
+ const config_1 = require("../config");
28
+ const git_1 = require("../git");
29
+ const api_1 = require("../api");
30
+ const catalog_1 = require("../catalog");
31
+ const report_1 = require("../catalog/report");
32
+ const manifest_1 = require("../catalog/manifest");
33
+ const materialize_1 = require("../catalog/materialize");
34
+ const ctx_1 = require("../ctx");
35
+ const log = (m) => process.stdout.write(m + '\n');
36
+ /** A practice has an actual bump available to take (patch/minor/major). */
37
+ function hasBump(row) {
38
+ return row.status === 'patch' || row.status === 'minor' || row.status === 'major';
39
+ }
40
+ /** Skipped only because it is locally edited (drift) and not forced. */
41
+ function skipForDrift(row, opts) {
42
+ return hasBump(row) && row.drifted && !opts.force;
43
+ }
44
+ /** Skipped only because it is a major bump (and not drift-skipped) and not forced. */
45
+ function skipForMajor(row, opts) {
46
+ return row.status === 'major' && !skipForDrift(row, opts) && !opts.force;
47
+ }
48
+ /** Skipped only because --auto-patch excludes a (non-drift, non-major) minor bump. */
49
+ function skipForPatchGate(row, opts) {
50
+ return Boolean(opts.autoPatch) && row.status === 'minor' && !row.drifted && !opts.force;
51
+ }
52
+ /** Will this practice actually be re-materialized on apply, given the safety rails? */
53
+ function isApplicable(row, opts) {
54
+ if (!hasBump(row))
55
+ return false; // up-to-date / removed-from-catalog → leave alone
56
+ if (skipForDrift(row, opts))
57
+ return false;
58
+ if (skipForMajor(row, opts))
59
+ return false;
60
+ if (skipForPatchGate(row, opts))
61
+ return false;
62
+ return true;
63
+ }
64
+ /** Right-pad to a fixed width for the dry-run table. */
65
+ function pad(s, n) {
66
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
67
+ }
68
+ async function update(opts = {}) {
69
+ const top = (0, git_1.gitToplevel)();
70
+ if (!top)
71
+ (0, ctx_1.die)('not a git repository — run `convene update` inside a repo');
72
+ const manifest = (0, config_1.loadManifest)(top);
73
+ if (!manifest) {
74
+ log('· no best practices adopted — nothing to update. Run `convene init` to choose some.');
75
+ return;
76
+ }
77
+ // Live catalog (fail-soft → bundled). Build an authed client only if we have a
78
+ // key; loadCatalog itself falls back on any failure, so this never blocks.
79
+ const cfg = (0, config_1.resolveConfig)();
80
+ const api = cfg.apiKey && cfg.member ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, (0, git_1.sessionId)(cfg.member, top), cfg.tool) : null;
81
+ const { catalog, source } = await (0, catalog_1.loadCatalog)(api);
82
+ await runUpdate(top, manifest, catalog, source, opts);
83
+ }
84
+ /**
85
+ * Core of `convene update`, with the resolved (top, manifest, catalog) already in
86
+ * hand — the seam tests drive with a synthetic catalog (no fs/network resolution).
87
+ * Pure orchestration: dry-run reporting OR re-materialize + manifest rewrite. Never
88
+ * git-adds/commits. `source` only flavors the dry-run header.
89
+ */
90
+ async function runUpdate(top, manifest, catalog, source, opts) {
91
+ const rows = classify(manifest, catalog, top);
92
+ const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog);
93
+ if (!opts.apply) {
94
+ printDryRun(rows, cmp.repoVersion, catalog.version, source, opts);
95
+ return;
96
+ }
97
+ await applyUpdate(top, manifest, catalog, rows, opts);
98
+ }
99
+ /** Build the per-practice rows: status vs. the live catalog + drift flags. */
100
+ function classify(manifest, catalog, top) {
101
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
102
+ const drifted = new Set((0, materialize_1.detectDrift)(top, manifest));
103
+ return manifest.practices.map((entry) => {
104
+ const cat = byId.get(entry.id);
105
+ if (!cat) {
106
+ return {
107
+ id: entry.id,
108
+ level: entry.level,
109
+ manifestVersion: entry.version,
110
+ catalogVersion: null,
111
+ status: 'removed-from-catalog',
112
+ drifted: drifted.has(entry.id),
113
+ };
114
+ }
115
+ const bump = (0, manifest_1.bumpClass)(entry.version, cat.version);
116
+ const status = bump === 'none' ? 'up-to-date' : bump;
117
+ return {
118
+ id: entry.id,
119
+ level: entry.level,
120
+ manifestVersion: entry.version,
121
+ catalogVersion: cat.version,
122
+ status,
123
+ drifted: drifted.has(entry.id),
124
+ };
125
+ });
126
+ }
127
+ function printDryRun(rows, repoVersion, catalogVersion, source, opts) {
128
+ const behind = repoVersion !== catalogVersion;
129
+ log(behind
130
+ ? `Catalog update available: repo on v${repoVersion} → catalog v${catalogVersion}${source === 'bundled' ? ' (bundled — offline)' : ''}`
131
+ : `Best practices up to date at catalog v${repoVersion}${source === 'bundled' ? ' (bundled — offline)' : ''}`);
132
+ log('');
133
+ const idW = Math.max(8, ...rows.map((r) => r.id.length));
134
+ log(` ${pad('practice', idW)} ${pad('level', 9)} ${pad('version', 18)} change`);
135
+ for (const r of rows) {
136
+ const to = r.catalogVersion ?? '—';
137
+ const verCol = `${r.manifestVersion} → ${to}`;
138
+ let change;
139
+ switch (r.status) {
140
+ case 'up-to-date':
141
+ change = 'up to date';
142
+ break;
143
+ case 'removed-from-catalog':
144
+ change = 'removed from catalog (kept; update can\'t take it)';
145
+ break;
146
+ default:
147
+ change = `${r.status} bump`;
148
+ }
149
+ const drift = r.drifted ? ' [EDITED LOCALLY]' : '';
150
+ log(` ${pad(r.id, idW)} ${pad(r.level, 9)} ${pad(verCol, 18)} ${change}${drift}`);
151
+ }
152
+ log('');
153
+ const applicable = rows.filter((r) => isApplicable(r, opts));
154
+ const skippedMajor = rows.filter((r) => skipForMajor(r, opts));
155
+ const skippedDrift = rows.filter((r) => skipForDrift(r, opts));
156
+ const skippedPatchGate = rows.filter((r) => skipForPatchGate(r, opts));
157
+ if (applicable.length === 0 && skippedMajor.length === 0 && skippedDrift.length === 0 && skippedPatchGate.length === 0) {
158
+ log('Nothing to apply.');
159
+ return;
160
+ }
161
+ if (applicable.length) {
162
+ log(`${applicable.length} practice(s) would update on \`convene update --apply\`.`);
163
+ }
164
+ reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
165
+ log('');
166
+ log('Next: `convene update --apply` (review with `git diff` and commit yourself — Convene never commits).');
167
+ }
168
+ async function applyUpdate(top, manifest, catalog, rows, opts) {
169
+ const byId = new Map(catalog.practices.map((p) => [p.id, p]));
170
+ const toUpdate = rows.filter((r) => isApplicable(r, opts));
171
+ const skippedMajor = rows.filter((r) => skipForMajor(r, opts));
172
+ const skippedDrift = rows.filter((r) => skipForDrift(r, opts));
173
+ const skippedPatchGate = rows.filter((r) => skipForPatchGate(r, opts));
174
+ const takeIds = new Set(toUpdate.map((r) => r.id));
175
+ if (takeIds.size === 0) {
176
+ log('Nothing applied — no practice was eligible.');
177
+ reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
178
+ reportRemoved(rows);
179
+ return;
180
+ }
181
+ // PRESERVE skipped (drifted / major / patch-gated) sections byte-for-byte: snapshot
182
+ // their on-disk blocks BEFORE re-materializing, then splice them back afterward so
183
+ // a human edit or a deliberately-deferred major is never touched. (A REMOVED-from-
184
+ // catalog practice has no bump and isn't skipped here — it simply isn't re-rendered
185
+ // and its on-disk block + manifest entry both carry forward unchanged.)
186
+ const skippedIds = new Set([...skippedMajor, ...skippedDrift, ...skippedPatchGate].map((r) => r.id));
187
+ const allBlocks = (0, materialize_1.extractPracticeBlocks)(top);
188
+ const preserve = new Map();
189
+ for (const id of skippedIds) {
190
+ const b = allBlocks.get(id);
191
+ if (b)
192
+ preserve.set(id, b);
193
+ }
194
+ // Re-materialize every adopted practice STILL IN THE CATALOG and NOT skipped, each
195
+ // at its CURRENT level. This advances taken practices to the live catalog body and
196
+ // refreshes their enforcement artifacts (idempotent merges), while leaving the
197
+ // skipped + removed practices out of this render pass. `slug` is unused by the doc
198
+ // renderer — pass ''.
199
+ const renderSelections = manifest.practices
200
+ .filter((e) => byId.has(e.id) && !skippedIds.has(e.id))
201
+ .map((e) => ({ id: e.id, level: e.level }));
202
+ const reMaterialized = (0, materialize_1.materializePractices)(top, '', renderSelections, catalog);
203
+ // Splice the preserved skipped blocks back into the freshly-written doc so the
204
+ // managed doc stays a single coherent artifact with every adopted section present.
205
+ (0, materialize_1.splicePreservedBlocks)(top, preserve);
206
+ // Build the next manifest: fresh entry for each re-materialized practice; the
207
+ // verbatim OLD entry for everyone else (skipped, up-to-date, removed) — preserving
208
+ // their version + hash so a skipped/edited region is never re-flagged as drift.
209
+ const freshById = new Map(reMaterialized.map((e) => [e.id, e]));
210
+ const nextPractices = manifest.practices.map((old) => freshById.get(old.id) ?? old);
211
+ const nextManifest = {
212
+ catalogVersion: catalog.version,
213
+ channel: manifest.channel,
214
+ practices: nextPractices,
215
+ };
216
+ (0, config_1.writeManifest)(top, nextManifest);
217
+ // Phase 5: report the freshly-rewritten manifest so the dashboard's adoption
218
+ // view tracks the update. Best-effort, fail-OPEN, fire-and-forget — `--apply`
219
+ // never fails/slows because the report failed; skipped silently with no api key.
220
+ const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug;
221
+ if (slug)
222
+ await (0, report_1.reportManifest)(top, slug, nextManifest);
223
+ log(`✓ updated ${takeIds.size} practice(s); manifest catalog version → v${catalog.version}.`);
224
+ for (const r of toUpdate) {
225
+ log(` ${r.id}: v${r.manifestVersion} → v${r.catalogVersion} [${r.level}]`);
226
+ }
227
+ reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
228
+ reportRemoved(rows);
229
+ log('');
230
+ log('Changes are in your working tree only. Review with `git diff` and commit yourself — Convene never commits.');
231
+ }
232
+ /** Note any adopted practices the catalog no longer ships — kept, never auto-dropped. */
233
+ function reportRemoved(rows) {
234
+ const removed = rows.filter((r) => r.status === 'removed-from-catalog');
235
+ if (removed.length) {
236
+ log(` ${removed.length} practice(s) removed from the catalog — kept as-is (update can't take them): ${removed.map((r) => r.id).join(', ')}`);
237
+ }
238
+ }
239
+ function reportSkips(skippedMajor, skippedDrift, skippedPatchGate) {
240
+ if (skippedMajor.length) {
241
+ log(` skipped ${skippedMajor.length} MAJOR bump(s) (re-run with --force to take): ${skippedMajor.map((r) => r.id).join(', ')}`);
242
+ }
243
+ if (skippedDrift.length) {
244
+ log(` skipped ${skippedDrift.length} locally-EDITED practice(s) (your edits preserved; --force to overwrite): ${skippedDrift.map((r) => r.id).join(', ')}`);
245
+ }
246
+ if (skippedPatchGate.length) {
247
+ log(` skipped ${skippedPatchGate.length} minor/major bump(s) under --auto-patch (run \`convene update --apply\` without --auto-patch): ${skippedPatchGate.map((r) => r.id).join(', ')}`);
248
+ }
249
+ }
package/dist/config.js CHANGED
@@ -12,6 +12,8 @@ exports.resolveConfig = resolveConfig;
12
12
  exports.ensureConfigDir = ensureConfigDir;
13
13
  exports.saveFileConfig = saveFileConfig;
14
14
  exports.writeProjectConfig = writeProjectConfig;
15
+ exports.loadManifest = loadManifest;
16
+ exports.writeManifest = writeManifest;
15
17
  /**
16
18
  * Config resolution with precedence:
17
19
  * env (CONVENE_API_KEY, CONVENE_BASE_URL, CONVENE_MEMBER, ...)
@@ -96,6 +98,22 @@ function writeProjectConfig(toplevel, cfg) {
96
98
  const dir = node_path_1.default.join(toplevel, '.convene');
97
99
  node_fs_1.default.mkdirSync(dir, { recursive: true });
98
100
  const file = node_path_1.default.join(dir, 'project.json');
99
- node_fs_1.default.writeFileSync(file, JSON.stringify({ schema: 1, ...cfg }, null, 2) + '\n');
101
+ // schema 2 ONLY once a best-practices manifest is present; plain onboarding
102
+ // stays at schema 1 (byte-identical to pre-catalog output).
103
+ const schema = cfg.bestPractices ? 2 : 1;
104
+ node_fs_1.default.writeFileSync(file, JSON.stringify({ schema, ...cfg }, null, 2) + '\n');
100
105
  return file;
101
106
  }
107
+ /** The adopted best-practices manifest for a repo, or null if none/absent. */
108
+ function loadManifest(toplevel) {
109
+ return loadProjectConfig(toplevel)?.bestPractices ?? null;
110
+ }
111
+ /**
112
+ * Merge a best-practices manifest into the repo's project.json, preserving
113
+ * slug/displayName/joinToken and bumping it to schema 2. Round-trip safe.
114
+ */
115
+ function writeManifest(toplevel, manifest) {
116
+ const existing = loadProjectConfig(toplevel) ?? {};
117
+ const { schema: _schema, ...rest } = existing;
118
+ return writeProjectConfig(toplevel, { ...rest, bestPractices: manifest });
119
+ }
package/dist/index.js CHANGED
@@ -57,14 +57,28 @@ const lane_1 = require("./commands/lane");
57
57
  const deploy_1 = require("./commands/deploy");
58
58
  const guard_1 = require("./commands/guard");
59
59
  const gate_push_1 = require("./commands/gate-push");
60
+ const practice_guard_1 = require("./commands/practice-guard");
61
+ const override_1 = require("./commands/override");
60
62
  const watch_1 = require("./commands/watch");
61
63
  const explain_1 = require("./commands/explain");
64
+ const practices_1 = require("./commands/practices");
65
+ const update_1 = require("./commands/update");
62
66
  const program = new commander_1.Command();
63
67
  // Read the version from package.json so `convene --version` always tracks the
64
68
  // published version (npm includes package.json in the tarball). dist/index.js
65
69
  // sits one level below package.json; in dev (tsx) src/index.ts does too.
66
70
  const { version } = JSON.parse((0, fs_1.readFileSync)((0, path_1.resolve)(__dirname, '../package.json'), 'utf8'));
67
71
  program.name(brand_1.BRAND.bin).description('Convene — AI development coordination bus').version(version);
72
+ /**
73
+ * Commander maps `--no-practices` to `practices: false`; the catalog selection logic
74
+ * reads `noPractices`. Translate it (leaving every other option untouched) so init /
75
+ * setup see the field they expect.
76
+ */
77
+ function withPracticeOpts(opts) {
78
+ if (opts.practices === false)
79
+ opts.noPractices = true;
80
+ return opts;
81
+ }
68
82
  program
69
83
  .command('login')
70
84
  .description('authenticate and save config (0600)')
@@ -142,6 +156,18 @@ program
142
156
  .option('--dry-run', 'classify + report; do not gate or hit the network for the verdict')
143
157
  .option('--project <slug>')
144
158
  .action((opts) => (0, gate_push_1.gatePush)(opts));
159
+ program
160
+ .command('practice-guard <id>')
161
+ .description('PreToolUse/Stop hook: level-aware best-practice gate (fail-open-loud)')
162
+ .option('--stdin', 'read the PreToolUse JSON payload from stdin')
163
+ .option('--project <slug>')
164
+ .action((id, opts) => (0, practice_guard_1.practiceGuard)(id, opts));
165
+ program
166
+ .command('override <id>')
167
+ .description('grant a short-lived, attributed bypass for a best-practice gate')
168
+ .option('--reason <text>', 'why the gate is being overridden (required; attributed to the bus)')
169
+ .option('--project <slug>')
170
+ .action((id, opts) => (0, override_1.override)(id, opts));
145
171
  program
146
172
  .command('watch')
147
173
  .description('SessionStart-launched detached long-poll for directed halts (fail-open, self-healing)')
@@ -210,6 +236,10 @@ program
210
236
  .command('explain [question]')
211
237
  .description('ask how Convene itself works (protocol, lanes, halts, privacy, …) — fail-soft, public')
212
238
  .action((question) => (0, explain_1.explain)(question));
239
+ program
240
+ .command('practices [id]')
241
+ .description('learn the best practices: list adopted + available, or pass an id for the why + sources')
242
+ .action((id) => (0, practices_1.practices)(id));
213
243
  program
214
244
  .command('suggest <text>')
215
245
  .description('send a feature request / bug report / feedback into Convene')
@@ -233,7 +263,11 @@ program
233
263
  .option('--yes', 'confirm onboarding non-interactively (required for agents/CI)')
234
264
  .option('--commit', 'commit ONLY the convene files as one isolated commit (never `git add -A`)')
235
265
  .option('--offline', 'write local files only (no API calls)')
236
- .action((opts) => (0, init_1.init)(opts));
266
+ .option('--tier <names>', 'best practices: comma-separated tiers to adopt (essentials,recommended,advanced)')
267
+ .option('--practice <id[=level]>', 'best practice to adopt (id or id=level; repeatable)', (v, acc) => (acc.push(v), acc), [])
268
+ .option('--all-practices', 'adopt every catalog best practice at its default level')
269
+ .option('--no-practices', 'adopt no best practices (skip the catalog + interactive picker)')
270
+ .action((opts) => (0, init_1.init)(withPracticeOpts(opts)));
237
271
  program
238
272
  .command('off-board')
239
273
  .alias('offboard')
@@ -264,7 +298,11 @@ program
264
298
  .option('--force', 'commit a join token even if the repo looks public')
265
299
  .option('--yes', 'confirm onboarding non-interactively (required for agents/CI)')
266
300
  .option('--commit', 'commit ONLY the convene files as one isolated commit')
267
- .action((opts) => (0, setup_1.setup)(opts));
301
+ .option('--tier <names>', 'best practices: comma-separated tiers to adopt (essentials,recommended,advanced)')
302
+ .option('--practice <id[=level]>', 'best practice to adopt (id or id=level; repeatable)', (v, acc) => (acc.push(v), acc), [])
303
+ .option('--all-practices', 'adopt every catalog best practice at its default level')
304
+ .option('--no-practices', 'adopt no best practices (skip the catalog + interactive picker)')
305
+ .action((opts) => (0, setup_1.setup)(withPracticeOpts(opts)));
268
306
  program
269
307
  .command('join')
270
308
  .description('self-provision: redeem a project join token for your own key + hook')
@@ -283,6 +321,14 @@ program
283
321
  .option('--yes')
284
322
  .option('--offline')
285
323
  .action((opts) => (0, migrate_1.migrate)(opts));
324
+ program
325
+ .command('update')
326
+ .description('check for + apply best-practices catalog updates (review in your working tree; never auto-commits)')
327
+ .option('--apply', 're-materialize adopted practices to the live catalog (default: dry run)')
328
+ .option('--auto-patch', 'limit an unattended --apply to patch-only bumps')
329
+ .option('--force', 're-materialize MAJOR bumps and locally-edited (drifted) practices too')
330
+ .option('--project <slug>', 'project slug (defaults to .convene/project.json)')
331
+ .action((opts) => (0, update_1.update)(opts));
286
332
  program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
287
333
  program.parseAsync(process.argv).catch((err) => {
288
334
  process.stderr.write(`convene: ${err?.message || err}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "convene-cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://dev.convene.live",