convene-cli 1.11.0 → 1.13.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 +10 -0
- package/dist/brand.js +2 -2
- package/dist/cache.js +75 -85
- package/dist/commands/adopt.js +296 -0
- package/dist/commands/auth.js +75 -18
- package/dist/commands/enroll.js +72 -0
- package/dist/commands/explain.js +15 -0
- package/dist/commands/friction.js +104 -0
- package/dist/commands/init.js +4 -67
- package/dist/commands/join.js +20 -2
- package/dist/commands/update.js +171 -13
- package/dist/commands/watch.js +30 -48
- package/dist/git.js +18 -0
- package/dist/hook.js +65 -1
- package/dist/index.js +28 -0
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -42,6 +42,8 @@ class ConveneApi {
|
|
|
42
42
|
headers['x-convene-session-instance'] = this.instance;
|
|
43
43
|
if (opts.idempotencyKey)
|
|
44
44
|
headers['idempotency-key'] = opts.idempotencyKey;
|
|
45
|
+
if (opts.headers)
|
|
46
|
+
Object.assign(headers, opts.headers);
|
|
45
47
|
const res = await fetch(`${this.baseUrl}${brand_1.BRAND.apiBase}${apiPath}`, {
|
|
46
48
|
method,
|
|
47
49
|
headers,
|
|
@@ -154,6 +156,14 @@ class ConveneApi {
|
|
|
154
156
|
join(slug, body, timeoutMs) {
|
|
155
157
|
return this.request('POST', `/projects/${encodeURIComponent(slug)}/join`, { body, timeoutMs });
|
|
156
158
|
}
|
|
159
|
+
/** Device enrollment — start (no bearer). Emails the member a confirm link if one exists. */
|
|
160
|
+
enrollStart(body, timeoutMs) {
|
|
161
|
+
return this.request('POST', '/enroll/device/start', { body, timeoutMs });
|
|
162
|
+
}
|
|
163
|
+
/** Device enrollment — poll for the minted key, presenting the raw device secret (no bearer). */
|
|
164
|
+
enrollPoll(enrollmentId, rawSecret, timeoutMs) {
|
|
165
|
+
return this.request('GET', `/enroll/device/poll?id=${encodeURIComponent(String(enrollmentId))}`, { headers: { 'x-device-secret': rawSecret }, timeoutMs });
|
|
166
|
+
}
|
|
157
167
|
/** Self-recovery: re-grant your own membership via the committed join token (no bearer
|
|
158
168
|
* auth required). Restores OWNER only from a server-recorded prior-owner row. */
|
|
159
169
|
reclaim(slug, body, timeoutMs) {
|
package/dist/brand.js
CHANGED
|
@@ -13,8 +13,8 @@ exports.BRAND = {
|
|
|
13
13
|
product: 'Convene',
|
|
14
14
|
slug: 'convene',
|
|
15
15
|
bin: 'convene',
|
|
16
|
-
domain: '
|
|
17
|
-
baseUrl: 'https://
|
|
16
|
+
domain: 'convene.live',
|
|
17
|
+
baseUrl: 'https://convene.live',
|
|
18
18
|
/** Public product / front-door base URL for human-facing links (dashboard, /start, docs). */
|
|
19
19
|
siteDomain: 'convene.live',
|
|
20
20
|
siteUrl: 'https://convene.live',
|
package/dist/cache.js
CHANGED
|
@@ -3,7 +3,7 @@ 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.OVERRIDE_TTL_MS = exports.
|
|
6
|
+
exports.OVERRIDE_TTL_MS = exports.LIVE_SESSION_RECENT_SEC = exports.LIVE_SESSION_WINDOW_SEC = void 0;
|
|
7
7
|
exports.readCache = readCache;
|
|
8
8
|
exports.writeCache = writeCache;
|
|
9
9
|
exports.ageSeconds = ageSeconds;
|
|
@@ -23,13 +23,12 @@ exports.markAutoIsolated = markAutoIsolated;
|
|
|
23
23
|
exports.autoIsolatedAlready = autoIsolatedAlready;
|
|
24
24
|
exports.announceAlreadyPosted = announceAlreadyPosted;
|
|
25
25
|
exports.markAnnounced = markAnnounced;
|
|
26
|
+
exports.frictionAlreadyPosted = frictionAlreadyPosted;
|
|
27
|
+
exports.markFrictionPosted = markFrictionPosted;
|
|
26
28
|
exports.readLastBroadcastSha = readLastBroadcastSha;
|
|
27
29
|
exports.writeLastBroadcastSha = writeLastBroadcastSha;
|
|
28
|
-
exports.
|
|
29
|
-
exports.
|
|
30
|
-
exports.appendWatchEntry = appendWatchEntry;
|
|
31
|
-
exports.appendWatch = appendWatch;
|
|
32
|
-
exports.readWatchSince = readWatchSince;
|
|
30
|
+
exports.readWatchCursor = readWatchCursor;
|
|
31
|
+
exports.persistWatchCursor = persistWatchCursor;
|
|
33
32
|
exports.touchWatchHeartbeat = touchWatchHeartbeat;
|
|
34
33
|
exports.watchHeartbeatAgeSec = watchHeartbeatAgeSec;
|
|
35
34
|
exports.writeWatchPid = writeWatchPid;
|
|
@@ -361,6 +360,50 @@ function markAnnounced(slug, instance, branch) {
|
|
|
361
360
|
/* best-effort */
|
|
362
361
|
}
|
|
363
362
|
}
|
|
363
|
+
// ── in-session friction-capture sentinel ──────────────────────────────────────
|
|
364
|
+
// `convene friction` files ONE feature_feedback row per DISTINCT in-session
|
|
365
|
+
// confusion signal (e.g. an unmatched `convene explain` question). The server
|
|
366
|
+
// idempotency-key is the authoritative dedupe; this local sentinel just spares the
|
|
367
|
+
// redundant post/spawn when the SAME signal recurs within a session. Keyed on the
|
|
368
|
+
// per-boot instance (a fresh boot re-captures) + the signal hash; bounded so a
|
|
369
|
+
// chatty session can't grow the file without limit.
|
|
370
|
+
const FRICTION_MAX_KEYS = 50;
|
|
371
|
+
function frictionFile(slug) {
|
|
372
|
+
return slugFile(scoped(slug), 'friction');
|
|
373
|
+
}
|
|
374
|
+
/** True iff this (instance, sigHash) friction signal was already posted this boot. */
|
|
375
|
+
function frictionAlreadyPosted(slug, instance, sigHash) {
|
|
376
|
+
try {
|
|
377
|
+
const e = JSON.parse(node_fs_1.default.readFileSync(frictionFile(slug), 'utf8'));
|
|
378
|
+
return !!e && e.instance === instance && Array.isArray(e.hashes) && e.hashes.includes(sigHash);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Record (instance, sigHash) as posted. Resets on a new instance; bounded. Best-effort. */
|
|
385
|
+
function markFrictionPosted(slug, instance, sigHash) {
|
|
386
|
+
try {
|
|
387
|
+
let e = { instance, hashes: [] };
|
|
388
|
+
try {
|
|
389
|
+
const prev = JSON.parse(node_fs_1.default.readFileSync(frictionFile(slug), 'utf8'));
|
|
390
|
+
if (prev && prev.instance === instance && Array.isArray(prev.hashes))
|
|
391
|
+
e = prev;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* no prior entry / new instance → start fresh */
|
|
395
|
+
}
|
|
396
|
+
if (!e.hashes.includes(sigHash))
|
|
397
|
+
e.hashes.push(sigHash);
|
|
398
|
+
if (e.hashes.length > FRICTION_MAX_KEYS)
|
|
399
|
+
e.hashes = e.hashes.slice(-FRICTION_MAX_KEYS);
|
|
400
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
401
|
+
node_fs_1.default.writeFileSync(frictionFile(slug), JSON.stringify(e), { mode: 0o600 });
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* best-effort */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
364
407
|
// ── last-broadcast-sha (announce ↔ wrap ↔ push dedup) ─────────────────────────
|
|
365
408
|
// The most recent HEAD sha this session has already told the bus about — written
|
|
366
409
|
// by `convene announce` (the session-start tip), `convene wrap` (a turn-end wrap),
|
|
@@ -390,19 +433,32 @@ function writeLastBroadcastSha(slug, sha) {
|
|
|
390
433
|
/* best-effort */
|
|
391
434
|
}
|
|
392
435
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
436
|
+
// ── watch poll resume cursor (WP12) ──────────────────────────────────────────
|
|
437
|
+
// The `convene watch` daemon (cli/src/commands/watch.ts) long-polls the bus,
|
|
438
|
+
// stamps a liveness heartbeat each iteration (the health line reads its age), and
|
|
439
|
+
// optionally desktop-notifies on a directed halt. It persists a MONOTONIC poll
|
|
440
|
+
// cursor here so a relaunch (every SessionStart) resumes incrementally instead of
|
|
441
|
+
// re-scanning the backlog from zero.
|
|
442
|
+
//
|
|
443
|
+
// The daemon does NOT render anything into agent-facing context: directed halts
|
|
444
|
+
// reach the agent via the server-truth PULL surfaces (fetch / session-open /
|
|
445
|
+
// lane-state), which query the server — the single authority for halt state. (The
|
|
446
|
+
// daemon formerly APPENDED matched halts to a per-slug `.watch.jsonl` for a reader
|
|
447
|
+
// to drain, but that reader was never wired into any surface, and a local cache
|
|
448
|
+
// could drift from server truth and mislead. The jsonl was removed in favor of the
|
|
449
|
+
// pull path; only this resume cursor + the heartbeat remain.)
|
|
450
|
+
function watchCursorFile(slug) {
|
|
451
|
+
// Legacy `.watch.hw` (high-water) extension retained so existing caches aren't
|
|
452
|
+
// orphaned on upgrade — the file is now the daemon's own poll resume cursor.
|
|
397
453
|
return slugFile(slug, 'watch.hw');
|
|
398
454
|
}
|
|
399
455
|
function watchHeartbeatFile(slug) {
|
|
400
456
|
return slugFile(slug, 'watch.hb');
|
|
401
457
|
}
|
|
402
|
-
/** Read the persisted
|
|
403
|
-
function
|
|
458
|
+
/** Read the daemon's persisted poll resume cursor (0 if none). */
|
|
459
|
+
function readWatchCursor(slug) {
|
|
404
460
|
try {
|
|
405
|
-
const n = parseInt(node_fs_1.default.readFileSync(
|
|
461
|
+
const n = parseInt(node_fs_1.default.readFileSync(watchCursorFile(slug), 'utf8').trim(), 10);
|
|
406
462
|
return Number.isFinite(n) ? n : 0;
|
|
407
463
|
}
|
|
408
464
|
catch {
|
|
@@ -410,87 +466,21 @@ function readWatchHighWater(slug) {
|
|
|
410
466
|
}
|
|
411
467
|
}
|
|
412
468
|
/**
|
|
413
|
-
* Persist the
|
|
414
|
-
*
|
|
415
|
-
* already
|
|
469
|
+
* Persist the daemon's poll resume cursor — MONOTONIC. A lower seq is ignored
|
|
470
|
+
* (GREATEST semantics) so a relaunch never rewinds the cursor and re-scans
|
|
471
|
+
* material it already polled past. Best-effort; never throws.
|
|
416
472
|
*/
|
|
417
|
-
function
|
|
473
|
+
function persistWatchCursor(slug, seq) {
|
|
418
474
|
try {
|
|
419
475
|
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
420
|
-
const cur =
|
|
476
|
+
const cur = readWatchCursor(slug);
|
|
421
477
|
if (seq > cur)
|
|
422
|
-
node_fs_1.default.writeFileSync(
|
|
478
|
+
node_fs_1.default.writeFileSync(watchCursorFile(slug), String(seq) + '\n', { mode: 0o600 });
|
|
423
479
|
}
|
|
424
480
|
catch {
|
|
425
481
|
/* best-effort */
|
|
426
482
|
}
|
|
427
483
|
}
|
|
428
|
-
/** Back-compat alias for the WP2 stub name; identical monotonic behavior. */
|
|
429
|
-
exports.writeWatchHighWater = persistHighWater;
|
|
430
|
-
/**
|
|
431
|
-
* APPEND a watch entry to the jsonl (append-only — never rewrites the file).
|
|
432
|
-
* The append is atomic at the OS level for a single small write, so a concurrent
|
|
433
|
-
* reader sees whole lines. Best-effort; never throws (the watch daemon is
|
|
434
|
-
* fail-open). Entries WITHOUT a finite numeric seq are dropped — an un-keyed
|
|
435
|
-
* entry can't participate in the high-water drain and would be re-rendered
|
|
436
|
-
* forever.
|
|
437
|
-
*/
|
|
438
|
-
function appendWatchEntry(slug, entry) {
|
|
439
|
-
if (!entry || typeof entry.seq !== 'number' || !Number.isFinite(entry.seq))
|
|
440
|
-
return;
|
|
441
|
-
try {
|
|
442
|
-
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
443
|
-
node_fs_1.default.appendFileSync(watchFile(slug), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
444
|
-
}
|
|
445
|
-
catch {
|
|
446
|
-
/* best-effort */
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
/** Back-compat alias for the WP2 stub name. */
|
|
450
|
-
function appendWatch(slug, entry) {
|
|
451
|
-
appendWatchEntry(slug, entry);
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Read all watch entries with seq > highWater, ascending by seq, de-duplicated
|
|
455
|
-
* on seq (the daemon may re-append the same entry after a resume — the reader
|
|
456
|
-
* tolerates duplicates by keeping the first per seq). Does NOT advance the
|
|
457
|
-
* high-water and does NOT truncate — the caller decides what was actually
|
|
458
|
-
* rendered and calls persistHighWater(slug, lastRenderedSeq). A malformed line
|
|
459
|
-
* is skipped, never fatal.
|
|
460
|
-
*/
|
|
461
|
-
function readWatchSince(slug, highWater) {
|
|
462
|
-
let raw;
|
|
463
|
-
try {
|
|
464
|
-
raw = node_fs_1.default.readFileSync(watchFile(slug), 'utf8');
|
|
465
|
-
}
|
|
466
|
-
catch {
|
|
467
|
-
return [];
|
|
468
|
-
}
|
|
469
|
-
const seen = new Set();
|
|
470
|
-
const out = [];
|
|
471
|
-
for (const line of raw.split('\n')) {
|
|
472
|
-
const s = line.trim();
|
|
473
|
-
if (!s)
|
|
474
|
-
continue;
|
|
475
|
-
let e;
|
|
476
|
-
try {
|
|
477
|
-
e = JSON.parse(s);
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
continue; // a torn/partial line — skip, the next drain re-reads it whole
|
|
481
|
-
}
|
|
482
|
-
if (typeof e.seq !== 'number' || !Number.isFinite(e.seq))
|
|
483
|
-
continue;
|
|
484
|
-
if (e.seq <= highWater)
|
|
485
|
-
continue;
|
|
486
|
-
if (seen.has(e.seq))
|
|
487
|
-
continue;
|
|
488
|
-
seen.add(e.seq);
|
|
489
|
-
out.push(e);
|
|
490
|
-
}
|
|
491
|
-
out.sort((a, b) => a.seq - b.seq);
|
|
492
|
-
return out;
|
|
493
|
-
}
|
|
494
484
|
// ── watch heartbeat (WP12) ───────────────────────────────────────────────────
|
|
495
485
|
// The daemon stamps a heartbeat each loop iteration; the health line / doctor
|
|
496
486
|
// read its age. A stale/absent heartbeat ⇒ watch is down ⇒ surface DEGRADED.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseAdoptTargets = parseAdoptTargets;
|
|
4
|
+
exports.runAdopt = runAdopt;
|
|
5
|
+
exports.adopt = adopt;
|
|
6
|
+
/**
|
|
7
|
+
* `convene adopt <id|id=level …>` — incrementally adopt catalog best practices into a
|
|
8
|
+
* repo that already has a manifest, WITHOUT replacing the adopted set.
|
|
9
|
+
*
|
|
10
|
+
* This is the missing inverse of the gap `convene update` cannot close. `update` only
|
|
11
|
+
* re-materializes practices ALREADY in the manifest (bumping them to the live catalog),
|
|
12
|
+
* and `convene init --practice X` REPLACES the whole adopted set with just X. So when
|
|
13
|
+
* the catalog GROWS a new practice, neither verb can take it — an agent is forced to
|
|
14
|
+
* hand-roll buildPracticesDoc/regionHash itself. `adopt` merges the named practice(s)
|
|
15
|
+
* into the existing set, drift-safely:
|
|
16
|
+
*
|
|
17
|
+
* - Existing adopted practices are PRESERVED byte-for-byte (their on-disk section,
|
|
18
|
+
* pinned version, hash, and level). adopt never silently bumps or re-renders them,
|
|
19
|
+
* and never clobbers a local edit; it re-renders ONLY the ids it is adding or
|
|
20
|
+
* deliberately re-leveling. Mechanism: render the full union (so every section
|
|
21
|
+
* lands in CATALOG order), then splice each untouched block back verbatim — the
|
|
22
|
+
* same preserve/splice rail `convene update --apply` uses. (Rendering only the new
|
|
23
|
+
* ids would REORDER the doc, because splicePreservedBlocks APPENDS any id missing
|
|
24
|
+
* from the freshly-rendered doc — see catalog/materialize.ts.)
|
|
25
|
+
* - manifest.catalogVersion is NOT advanced. adopt does not take the live catalog for
|
|
26
|
+
* EXISTING practices (that is `convene update`'s job, gated by its drift/major
|
|
27
|
+
* rails), so the "behind" watermark must stay honest — bumping it would make the
|
|
28
|
+
* next `convene update` read "up to date" and strand the existing practices at
|
|
29
|
+
* stale bodies. The one exception: bootstrapping an absent manifest stamps it to
|
|
30
|
+
* the catalog the first practice came from.
|
|
31
|
+
* - Live-catalog-first (so it can take a practice newer than the bundled mirror),
|
|
32
|
+
* fail-soft to the bundled mirror offline.
|
|
33
|
+
* - NEVER git-adds/commits — changes land in the working tree for the human to review
|
|
34
|
+
* + commit, exactly like init/update.
|
|
35
|
+
*
|
|
36
|
+
* Apply-by-default: naming a practice IS the deliberate act, so there is no --apply gate
|
|
37
|
+
* (an apply gate would recreate the very "update available → nothing to apply" dead-end
|
|
38
|
+
* this command exists to kill).
|
|
39
|
+
*/
|
|
40
|
+
const config_1 = require("../config");
|
|
41
|
+
const git_1 = require("../git");
|
|
42
|
+
const api_1 = require("../api");
|
|
43
|
+
const catalog_1 = require("../catalog");
|
|
44
|
+
const report_1 = require("../catalog/report");
|
|
45
|
+
const manifest_1 = require("../catalog/manifest");
|
|
46
|
+
const materialize_1 = require("../catalog/materialize");
|
|
47
|
+
const ctx_1 = require("../ctx");
|
|
48
|
+
const log = (m) => process.stdout.write(m + '\n');
|
|
49
|
+
/**
|
|
50
|
+
* Parse + VALIDATE the raw `id` / `id=level` tokens against the catalog. PURE and
|
|
51
|
+
* THROWS on the first invalid token (no fs side-effects) so the command is atomic —
|
|
52
|
+
* one bad token aborts before anything is written. Mirrors select.ts parseSelectionFlags.
|
|
53
|
+
*
|
|
54
|
+
* A BARE id resolves to the practice's CURRENT adopted level if it is already in the
|
|
55
|
+
* manifest, else its defaultLevel — so `convene adopt <already-adopted-id>` is always a
|
|
56
|
+
* deliberate no-op, never an accidental re-level back to the default. Later duplicates
|
|
57
|
+
* win. `source` only flavors the unknown-id message: offline we cannot tell "the id
|
|
58
|
+
* does not exist" from "the id exists only in a newer catalog we could not fetch".
|
|
59
|
+
*/
|
|
60
|
+
function parseAdoptTargets(targets, catalog, source, existingById) {
|
|
61
|
+
const byId = new Map(catalog.practices.map((p) => [p.id, p]));
|
|
62
|
+
const slot = new Map(); // id → index in `out` (later-wins on dup)
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const raw of targets) {
|
|
65
|
+
const eq = raw.indexOf('=');
|
|
66
|
+
const id = (eq >= 0 ? raw.slice(0, eq) : raw).trim();
|
|
67
|
+
const lvlTok = eq >= 0 ? raw.slice(eq + 1).trim() : null;
|
|
68
|
+
const p = byId.get(id);
|
|
69
|
+
if (!p) {
|
|
70
|
+
const near = catalog.practices
|
|
71
|
+
.filter((x) => x.id.includes(id) || id.includes(x.id))
|
|
72
|
+
.slice(0, 5)
|
|
73
|
+
.map((x) => x.id);
|
|
74
|
+
const hint = near.length ? ` Did you mean: ${near.join(', ')}` : '';
|
|
75
|
+
if (source === 'bundled') {
|
|
76
|
+
throw new Error(`unknown practice "${id}" in the bundled catalog v${catalog.version} (offline). ` +
|
|
77
|
+
'It may exist in a newer live catalog — reconnect and retry, or refresh the bundled ' +
|
|
78
|
+
'mirror (`npm i -g convene-cli@latest`).' +
|
|
79
|
+
hint);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`unknown practice "${id}" — not in the catalog (run \`convene practices\` to see ids).${hint}`);
|
|
82
|
+
}
|
|
83
|
+
let level;
|
|
84
|
+
if (lvlTok === null) {
|
|
85
|
+
level = existingById.get(id)?.level ?? p.defaultLevel;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (!p.availableLevels.includes(lvlTok)) {
|
|
89
|
+
throw new Error(`invalid level "${lvlTok}" for practice "${id}" — choose one of: ${p.availableLevels.join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
level = lvlTok;
|
|
92
|
+
}
|
|
93
|
+
const entry = { id, level, bare: lvlTok === null };
|
|
94
|
+
if (slot.has(id))
|
|
95
|
+
out[slot.get(id)] = entry;
|
|
96
|
+
else {
|
|
97
|
+
slot.set(id, out.length);
|
|
98
|
+
out.push(entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Core of `convene adopt` with (top, manifest, catalog, parsed) already resolved — the
|
|
105
|
+
* seam tests drive this directly with a synthetic catalog (no fs/network resolution).
|
|
106
|
+
* Classifies each target, preserves every untouched section, re-materializes the union,
|
|
107
|
+
* splices the untouched blocks back, and rewrites the manifest. Never throws on user
|
|
108
|
+
* error (parseAdoptTargets already validated); never git-commits.
|
|
109
|
+
*/
|
|
110
|
+
async function runAdopt(top, manifest, catalog, source, bootstrapped, parsed, opts) {
|
|
111
|
+
const byId = new Map(catalog.practices.map((p) => [p.id, p]));
|
|
112
|
+
const existingById = new Map(manifest.practices.map((e) => [e.id, e]));
|
|
113
|
+
const drifted = bootstrapped ? new Set() : new Set((0, materialize_1.detectDrift)(top, manifest));
|
|
114
|
+
// Classify: NEW (not yet adopted) / RELEVEL (adopted, different level) / NOOP
|
|
115
|
+
// (adopted, same level) / drift-refusal (re-level of a locally-edited section without
|
|
116
|
+
// --force — a level change forces a re-render whose hash must change, so the splice
|
|
117
|
+
// cannot preserve the local edit; mirror update's skipForDrift).
|
|
118
|
+
const newIds = [];
|
|
119
|
+
const relevel = [];
|
|
120
|
+
const noops = [];
|
|
121
|
+
const driftRefusals = [];
|
|
122
|
+
for (const t of parsed) {
|
|
123
|
+
const prev = existingById.get(t.id);
|
|
124
|
+
if (!prev)
|
|
125
|
+
newIds.push(t);
|
|
126
|
+
else if (prev.level === t.level)
|
|
127
|
+
noops.push(t);
|
|
128
|
+
else if (drifted.has(t.id) && !opts.force)
|
|
129
|
+
driftRefusals.push(t);
|
|
130
|
+
else
|
|
131
|
+
relevel.push(t);
|
|
132
|
+
}
|
|
133
|
+
const touched = [...newIds, ...relevel];
|
|
134
|
+
const touchedIds = new Set(touched.map((t) => t.id));
|
|
135
|
+
// Nothing changes: report the no-ops / refusals and return WITHOUT writing the
|
|
136
|
+
// manifest/doc or reporting — a same-level adopt must be byte-identical to a no-op.
|
|
137
|
+
if (touched.length === 0) {
|
|
138
|
+
for (const t of noops)
|
|
139
|
+
log(`· ${t.id} already adopted at ${t.level} — unchanged`);
|
|
140
|
+
for (const t of driftRefusals) {
|
|
141
|
+
log(`refusing to re-level ${t.id}: its section was edited locally (drift) — re-run with --force to overwrite your edits, or revert the edit first.`);
|
|
142
|
+
}
|
|
143
|
+
if (driftRefusals.length === 0) {
|
|
144
|
+
log('Nothing to adopt — all named practices already adopted at the requested level.');
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// PRESERVE every untouched adopted section byte-for-byte (incl. drifted + removed-
|
|
149
|
+
// from-catalog ids): snapshot the on-disk blocks BEFORE re-materializing.
|
|
150
|
+
const allBlocks = (0, materialize_1.extractPracticeBlocks)(top);
|
|
151
|
+
const preserve = new Map();
|
|
152
|
+
for (const e of manifest.practices) {
|
|
153
|
+
if (touchedIds.has(e.id))
|
|
154
|
+
continue;
|
|
155
|
+
const b = allBlocks.get(e.id);
|
|
156
|
+
if (b)
|
|
157
|
+
preserve.set(e.id, b);
|
|
158
|
+
}
|
|
159
|
+
// Render the FULL UNION in catalog order: every adopted id STILL IN THE CATALOG at its
|
|
160
|
+
// CURRENT manifest level (a placeholder slot so the splice below is an in-place
|
|
161
|
+
// replace, never an append that would reorder the doc), plus each touched id at its
|
|
162
|
+
// TARGET level. An id removed from the catalog can't be rendered — it rides through
|
|
163
|
+
// `preserve` only.
|
|
164
|
+
const levelMap = new Map();
|
|
165
|
+
for (const e of manifest.practices)
|
|
166
|
+
if (byId.has(e.id) && !touchedIds.has(e.id))
|
|
167
|
+
levelMap.set(e.id, e.level);
|
|
168
|
+
for (const t of touched)
|
|
169
|
+
if (byId.has(t.id))
|
|
170
|
+
levelMap.set(t.id, t.level);
|
|
171
|
+
const renderSelections = [...levelMap].map(([id, level]) => ({ id, level }));
|
|
172
|
+
// Respect --no-hook exactly as init does: settingsJson(deny)/gitignore still apply
|
|
173
|
+
// (they are not Claude-Code hooks); only the settingsHook guards are skipped.
|
|
174
|
+
const wireHooks = !(opts.noHook === true || opts.hook === false);
|
|
175
|
+
const reMaterialized = (0, materialize_1.materializePractices)(top, '', renderSelections, catalog, { wireHooks });
|
|
176
|
+
// Splice the untouched blocks back over their just-rendered placeholders — restoring
|
|
177
|
+
// each one's original version marker, body (incl. local edits), and therefore its
|
|
178
|
+
// original regionHash. After this only the touched sections reflect the live catalog.
|
|
179
|
+
//
|
|
180
|
+
// Two narrow, accepted edges (shared with `update --apply`'s identical rail):
|
|
181
|
+
// (a) An untouched id whose section is MISSING from the doc on disk (a truncated /
|
|
182
|
+
// hand-mangled doc) is absent from `preserve`, so its just-rendered live body
|
|
183
|
+
// leaks through while its manifest entry stays pinned (a one-time false-drift
|
|
184
|
+
// on an already-out-of-sync doc). `update --apply` is the repair path.
|
|
185
|
+
// (b) An untouched id REMOVED from the live catalog can't be re-rendered (it isn't
|
|
186
|
+
// in renderSelections), so the splice APPENDS its preserved block at the doc
|
|
187
|
+
// tail rather than its catalog-order slot — manifest order is still correct;
|
|
188
|
+
// only the doc section's position drifts. (We still keep it — better than
|
|
189
|
+
// `update --apply`, which drops a removed practice's section entirely.)
|
|
190
|
+
(0, materialize_1.splicePreservedBlocks)(top, preserve);
|
|
191
|
+
// Assemble the next manifest: verbatim OLD entry for every untouched id (preserving
|
|
192
|
+
// version+hash+level so it is never re-flagged as drift), the FRESH entry for each
|
|
193
|
+
// re-leveled id, and the fresh entry APPENDED for each genuinely-new id (existing
|
|
194
|
+
// slots keep their order — minimal project.json diff, matching update's no-reorder
|
|
195
|
+
// convention; the ordering is locked by a test).
|
|
196
|
+
const freshById = new Map(reMaterialized.map((e) => [e.id, e]));
|
|
197
|
+
const kept = manifest.practices.map((old) => touchedIds.has(old.id) ? freshById.get(old.id) : old);
|
|
198
|
+
const existingIds = new Set(manifest.practices.map((e) => e.id));
|
|
199
|
+
const added = catalog.practices
|
|
200
|
+
.filter((p) => touchedIds.has(p.id) && !existingIds.has(p.id))
|
|
201
|
+
.map((p) => freshById.get(p.id));
|
|
202
|
+
const nextManifest = {
|
|
203
|
+
catalogVersion: bootstrapped ? catalog.version : manifest.catalogVersion,
|
|
204
|
+
channel: manifest.channel ?? 'minor',
|
|
205
|
+
practices: [...kept, ...added],
|
|
206
|
+
};
|
|
207
|
+
(0, config_1.writeManifest)(top, nextManifest);
|
|
208
|
+
// Report adoption to the dashboard — best-effort, fail-OPEN, fire-and-forget (never
|
|
209
|
+
// fails/slows adopt; skipped silently when --offline or no api key). Same as init/update.
|
|
210
|
+
const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug;
|
|
211
|
+
if (slug) {
|
|
212
|
+
const { ok } = await (0, report_1.reportManifest)(top, slug, nextManifest, { offline: opts.offline });
|
|
213
|
+
if (ok)
|
|
214
|
+
log('· reported adoption to the dashboard');
|
|
215
|
+
}
|
|
216
|
+
// Success summary — the agent-legible centerpiece: a line per target, then the
|
|
217
|
+
// verbatim never-commit rail so a reading agent knows the exact next step.
|
|
218
|
+
const offlineNote = source === 'bundled' ? ' — bundled/offline' : '';
|
|
219
|
+
const reLabel = relevel.length ? ` + re-leveled ${relevel.length}` : '';
|
|
220
|
+
log(`✓ adopted ${newIds.length}${reLabel} practice(s) into .convene/best-practices.md (catalog v${catalog.version}${offlineNote}):`);
|
|
221
|
+
for (const t of newIds) {
|
|
222
|
+
const p = byId.get(t.id);
|
|
223
|
+
log(` + ${t.id} v${p.version} [${p.tier} · ${t.level}]`);
|
|
224
|
+
}
|
|
225
|
+
for (const t of relevel) {
|
|
226
|
+
const prev = existingById.get(t.id);
|
|
227
|
+
const fresh = freshById.get(t.id);
|
|
228
|
+
// Re-leveling re-renders from the LIVE catalog, so it also re-pins the practice to
|
|
229
|
+
// the live version (and takes the live body) — which can cross a MAJOR boundary.
|
|
230
|
+
// Disclose that jump rather than presenting a re-level as level-only: an operator
|
|
231
|
+
// re-leveling a behind practice silently gets the new content otherwise.
|
|
232
|
+
const verNote = prev.version !== fresh.version
|
|
233
|
+
? ` (v${prev.version} → v${fresh.version}${(0, manifest_1.bumpClass)(prev.version, fresh.version) === 'major' ? ', MAJOR catalog bump' : ''})`
|
|
234
|
+
: '';
|
|
235
|
+
log(` ↑ ${t.id} ${prev.level} → ${t.level}${verNote}`);
|
|
236
|
+
// A demotion can ORPHAN enforcement artifacts — adopt only ever ADDS (mergeDenyArray
|
|
237
|
+
// / ensureHook never subtract). Note it whenever the OLD level wired something the
|
|
238
|
+
// NEW level won't: a hook level → non-hook orphans the settingsHook/gitignore; a
|
|
239
|
+
// hook-hard → anything-else orphans permissions.deny (wired only at hook-hard, and
|
|
240
|
+
// only when the practice ships that artifact — else there is nothing to orphan).
|
|
241
|
+
const hookOrphaned = (0, materialize_1.isHookLevel)(prev.level) && !(0, materialize_1.isHookLevel)(t.level);
|
|
242
|
+
const denyOrphaned = prev.level === 'hook-hard' &&
|
|
243
|
+
t.level !== 'hook-hard' &&
|
|
244
|
+
(byId.get(t.id)?.artifacts.some((a) => a.kind === 'settingsJson') ?? false);
|
|
245
|
+
if (hookOrphaned || denyOrphaned) {
|
|
246
|
+
log(` note: demoting ${t.id} leaves its previously-wired hook/deny entries in .claude/settings.json; remove them by hand if you want enforcement fully relaxed.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const t of noops)
|
|
250
|
+
log(` · ${t.id} already adopted at ${t.level} — unchanged`);
|
|
251
|
+
for (const t of driftRefusals) {
|
|
252
|
+
log(` refusing to re-level ${t.id}: edited locally (drift) — re-run with --force to overwrite, or revert the edit.`);
|
|
253
|
+
}
|
|
254
|
+
log('');
|
|
255
|
+
log('Changes are in your working tree only. Review with `git diff` and commit yourself — Convene never commits.');
|
|
256
|
+
log('Learn the why behind any of these: `convene practices <id>`.');
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* `convene adopt` entry point: resolve env + catalog (live-first, bundled fallback) +
|
|
260
|
+
* the repo manifest (bootstrapping one if the repo adopted nothing yet), validate the
|
|
261
|
+
* targets up front (atomic), then hand off to runAdopt. Fail-soft throughout.
|
|
262
|
+
*/
|
|
263
|
+
async function adopt(targets, opts = {}) {
|
|
264
|
+
const top = (0, git_1.gitToplevel)();
|
|
265
|
+
if (!top)
|
|
266
|
+
(0, ctx_1.die)('not a git repository — run `convene adopt` inside a repo');
|
|
267
|
+
if (!targets || targets.length === 0) {
|
|
268
|
+
(0, ctx_1.die)('usage: convene adopt <id|id=level> [more...] (see `convene practices` for ids)');
|
|
269
|
+
}
|
|
270
|
+
// Live catalog (fail-soft → bundled). Build an authed client only with a key;
|
|
271
|
+
// loadCatalog never blocks. Honor a committed host pin (pin-authoritative) so the
|
|
272
|
+
// catalog fetch + adoption report hit the bound bus — same as `convene update`.
|
|
273
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top);
|
|
274
|
+
const api = cfg.apiKey && cfg.member ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, (0, git_1.sessionId)(cfg.member, top), cfg.tool) : null;
|
|
275
|
+
const { catalog, source } = await (0, catalog_1.loadCatalog)(api);
|
|
276
|
+
// Load or BOOTSTRAP the manifest: adopt must work on a repo onboarded with
|
|
277
|
+
// --no-practices (or pre-catalog) — there is simply nothing to merge into yet.
|
|
278
|
+
const existing = (0, config_1.loadManifest)(top);
|
|
279
|
+
const bootstrapped = existing === null;
|
|
280
|
+
const manifest = existing ?? {
|
|
281
|
+
catalogVersion: catalog.version,
|
|
282
|
+
channel: 'minor',
|
|
283
|
+
practices: [],
|
|
284
|
+
};
|
|
285
|
+
if (bootstrapped)
|
|
286
|
+
log('· no practices adopted yet — creating the best-practices manifest.');
|
|
287
|
+
const existingById = new Map(manifest.practices.map((e) => [e.id, e]));
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = parseAdoptTargets(targets, catalog, source, existingById);
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
(0, ctx_1.die)(e.message); // die() exits the process (never returns)
|
|
294
|
+
}
|
|
295
|
+
await runAdopt(top, manifest, catalog, source, bootstrapped, parsed, opts);
|
|
296
|
+
}
|