convene-cli 1.12.0 → 1.13.1
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 +5 -3
- package/dist/cache.js +46 -0
- package/dist/catalog/index.js +15 -5
- package/dist/catalog/manifest.js +30 -0
- package/dist/commands/adopt.js +296 -0
- package/dist/commands/auth.js +99 -31
- package/dist/commands/explain.js +15 -0
- package/dist/commands/fetch.js +4 -1
- package/dist/commands/friction.js +104 -0
- package/dist/commands/init.js +4 -67
- package/dist/commands/update.js +171 -13
- package/dist/git.js +18 -0
- package/dist/hook.js +65 -1
- package/dist/index.js +18 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -207,9 +207,11 @@ class ConveneApi {
|
|
|
207
207
|
}
|
|
208
208
|
/**
|
|
209
209
|
* GET /catalog — the canonical best-practices catalog (PUBLIC, no tenant data).
|
|
210
|
-
* The
|
|
211
|
-
*
|
|
212
|
-
*
|
|
210
|
+
* The server returns a release ENVELOPE `{ version, contentHash, catalog }`; the
|
|
211
|
+
* actual Catalog lives under `.catalog` (loadCatalog unwraps it). The CLI prefers
|
|
212
|
+
* this live read but is fully fail-soft: on any non-ok / network error the caller
|
|
213
|
+
* falls back to the bundled offline mirror. Bounded by a short timeout — never
|
|
214
|
+
* the 10s default.
|
|
213
215
|
*/
|
|
214
216
|
getCatalog(timeoutMs) {
|
|
215
217
|
return this.request('GET', '/catalog', { timeoutMs });
|
package/dist/cache.js
CHANGED
|
@@ -23,6 +23,8 @@ 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
30
|
exports.readWatchCursor = readWatchCursor;
|
|
@@ -358,6 +360,50 @@ function markAnnounced(slug, instance, branch) {
|
|
|
358
360
|
/* best-effort */
|
|
359
361
|
}
|
|
360
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
|
+
}
|
|
361
407
|
// ── last-broadcast-sha (announce ↔ wrap ↔ push dedup) ─────────────────────────
|
|
362
408
|
// The most recent HEAD sha this session has already told the bus about — written
|
|
363
409
|
// by `convene announce` (the session-start tip), `convene wrap` (a turn-end wrap),
|
package/dist/catalog/index.js
CHANGED
|
@@ -6,16 +6,26 @@ const catalog_generated_1 = require("./catalog.generated");
|
|
|
6
6
|
Object.defineProperty(exports, "CATALOG", { enumerable: true, get: function () { return catalog_generated_1.CATALOG; } });
|
|
7
7
|
Object.defineProperty(exports, "CATALOG_VERSION", { enumerable: true, get: function () { return catalog_generated_1.CATALOG_VERSION; } });
|
|
8
8
|
/**
|
|
9
|
-
* Load the catalog, preferring the live server copy when `api` is given.
|
|
10
|
-
*
|
|
11
|
-
* back
|
|
9
|
+
* Load the catalog, preferring the live server copy when `api` is given. Unwraps
|
|
10
|
+
* the server's release envelope ({ version, contentHash, catalog }) and tolerates
|
|
11
|
+
* a bare Catalog for back-compat. Any failure (no client, non-ok status, network
|
|
12
|
+
* error, empty/malformed body) silently falls back to the bundled mirror — this
|
|
13
|
+
* never throws.
|
|
12
14
|
*/
|
|
13
15
|
async function loadCatalog(api, timeoutMs = 5_000) {
|
|
14
16
|
if (api) {
|
|
15
17
|
try {
|
|
16
18
|
const res = await api.getCatalog(timeoutMs);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// The server wraps the catalog in a release envelope
|
|
20
|
+
// ({ version, contentHash, catalog }); the Catalog (with `practices`) lives
|
|
21
|
+
// one level down under `.catalog`. Unwrap that, but also tolerate a bare
|
|
22
|
+
// Catalog for back-compat with a differently-shaped/older server. VALIDATE
|
|
23
|
+
// the UNWRAPPED object — the envelope's top-level `version` matches a bare
|
|
24
|
+
// Catalog's, so guarding on the inner `practices` is what tells them apart.
|
|
25
|
+
const body = res.json;
|
|
26
|
+
const cat = body && 'catalog' in body ? body.catalog : body;
|
|
27
|
+
if (res.ok && cat && Array.isArray(cat.practices) && typeof cat.version === 'string') {
|
|
28
|
+
return { catalog: cat, source: 'live' };
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
catch {
|
package/dist/catalog/manifest.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.semverLt = semverLt;
|
|
4
4
|
exports.bumpClass = bumpClass;
|
|
5
5
|
exports.compareToCatalog = compareToCatalog;
|
|
6
|
+
exports.catalogBehindLine = catalogBehindLine;
|
|
7
|
+
exports.bestPracticesFreshnessLine = bestPracticesFreshnessLine;
|
|
6
8
|
/**
|
|
7
9
|
* Strict SemVer less-than over the dotted numeric core (pre-release/build
|
|
8
10
|
* metadata ignored — the catalog uses plain X.Y.Z). Missing components read as
|
|
@@ -69,3 +71,31 @@ function compareToCatalog(manifest, catalog) {
|
|
|
69
71
|
unknownIds,
|
|
70
72
|
};
|
|
71
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The canonical "your repo trails the catalog" nudge, shared by `convene doctor`
|
|
76
|
+
* and the `convene fetch` channel nudge so the SAME sentence ALWAYS means the
|
|
77
|
+
* same thing: `serverVersion` is the SERVER-published catalog version
|
|
78
|
+
* (authoritative), `repoVersion` is what this repo adopted. Never call this with
|
|
79
|
+
* a bundled-mirror version — a stale binary's bundled version is not "available"
|
|
80
|
+
* on the server (see `bestPracticesFreshnessLine`'s offline branch for that).
|
|
81
|
+
*/
|
|
82
|
+
function catalogBehindLine(serverVersion, repoVersion) {
|
|
83
|
+
return `server catalog v${serverVersion} available (repo adopted v${repoVersion}) — run \`convene update\``;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The `convene doctor` best-practices freshness line, given a catalog comparison
|
|
87
|
+
* and WHERE the catalog came from. Server-truthful by construction: it only makes
|
|
88
|
+
* an "available"/"up to date" claim when the comparison was against the LIVE
|
|
89
|
+
* server catalog. When the server was unreachable the comparison is against the
|
|
90
|
+
* bundled mirror baked into this binary — which may trail OR lead the server — so
|
|
91
|
+
* it refuses to claim either and just states both versions + that the server is
|
|
92
|
+
* unknown. Pure (no stdout/network) so it unit-tests directly.
|
|
93
|
+
*/
|
|
94
|
+
function bestPracticesFreshnessLine(cmp, source) {
|
|
95
|
+
if (source === 'live') {
|
|
96
|
+
return cmp.behind
|
|
97
|
+
? catalogBehindLine(cmp.catalogVersion, cmp.repoVersion)
|
|
98
|
+
: `best practices up to date with server catalog v${cmp.repoVersion}`;
|
|
99
|
+
}
|
|
100
|
+
return `bundled catalog v${cmp.catalogVersion} (offline — server version unknown) vs repo adopted v${cmp.repoVersion}`;
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -3,6 +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.assessWatchHealth = assessWatchHealth;
|
|
6
7
|
exports.login = login;
|
|
7
8
|
exports.whoami = whoami;
|
|
8
9
|
exports.assessLaneIdentity = assessLaneIdentity;
|
|
@@ -54,6 +55,41 @@ function relaunchWatch(slug) {
|
|
|
54
55
|
return false;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* PURE health verdict for the `doctor` "watch" line (unit-tested, no I/O). The
|
|
60
|
+
* watcher is a BEST-EFFORT liveness/notify daemon: it self-terminates once its
|
|
61
|
+
* owning session goes idle (~30m — see watch.ts) and is only relaunched at the
|
|
62
|
+
* next SessionStart, so a stale/absent heartbeat is the EXPECTED state for any
|
|
63
|
+
* quiet or long-lived session, NOT an error. Directed halts still surface via the
|
|
64
|
+
* pull path (fetch / session-open) and are still BLOCKED by the guard (live
|
|
65
|
+
* lane-state) whether or not this daemon is up. The check is therefore
|
|
66
|
+
* informational (never fails doctor); the verdict only drives the human-readable
|
|
67
|
+
* detail and whether `--fix` offers a relaunch.
|
|
68
|
+
* - alive — heartbeat fresh (age ≤ staleSec).
|
|
69
|
+
* - wedged — stale heartbeat but the pidfile owner is STILL ALIVE: a live process
|
|
70
|
+
* that has stopped stamping (the one genuinely odd state).
|
|
71
|
+
* - idle — stale/absent heartbeat and no live watcher: simply not running.
|
|
72
|
+
*/
|
|
73
|
+
function assessWatchHealth(args) {
|
|
74
|
+
const { ageSec, staleSec, hasLiveWatcher } = args;
|
|
75
|
+
if (ageSec != null && ageSec <= staleSec)
|
|
76
|
+
return 'alive';
|
|
77
|
+
if (hasLiveWatcher)
|
|
78
|
+
return 'wedged';
|
|
79
|
+
return 'idle';
|
|
80
|
+
}
|
|
81
|
+
/** The `doctor` "watch" detail line for a health state (age in seconds, or null). */
|
|
82
|
+
function watchDetail(state, age) {
|
|
83
|
+
switch (state) {
|
|
84
|
+
case 'alive':
|
|
85
|
+
return `halt watcher alive (heartbeat ${age}s ago)`;
|
|
86
|
+
case 'wedged':
|
|
87
|
+
return `halt watcher process up but not heartbeating (${age}s) — restart the session if mid-turn halt pings stop`;
|
|
88
|
+
case 'idle':
|
|
89
|
+
default:
|
|
90
|
+
return 'halt watcher not running — relaunches at next session start (directed halts still surface via the pull path + the guard; `convene doctor --fix` starts one now)';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
57
93
|
function readStdin() {
|
|
58
94
|
try {
|
|
59
95
|
return node_fs_1.default.readFileSync(0, 'utf8').trim();
|
|
@@ -450,33 +486,54 @@ async function doctor(opts) {
|
|
|
450
486
|
// 6b. settings non-destructiveness — Convene's edits stay additive + marker-scoped;
|
|
451
487
|
// user-owned hooks/files are never clobbered. Fails only on genuine corruption.
|
|
452
488
|
checks.push(assessSettingsIntegrity(top));
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
489
|
+
// 6c. coordination-hook drift — the committed project .claude/settings.json should
|
|
490
|
+
// carry the canonical COORD_HOOKS set. The writer (`convene init`) and this
|
|
491
|
+
// check share ONE source of truth (../hook), so a hook added to COORD_HOOKS
|
|
492
|
+
// (e.g. PR #55's Stop `convene wrap`) can't silently go unwired in a repo
|
|
493
|
+
// onboarded earlier. Verbs THIS binary lacks are skipped. ADVISORY (ok:true):
|
|
494
|
+
// drift is a nudge, not a hard failure — it must not fail doctor fleet-wide.
|
|
495
|
+
if (proj?.slug && top) {
|
|
496
|
+
const projSettings = (0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top)));
|
|
497
|
+
if (projSettings != null) {
|
|
498
|
+
const missing = (0, hook_1.missingCoordHooks)(projSettings);
|
|
499
|
+
checks.push({
|
|
500
|
+
name: 'coord-hooks',
|
|
501
|
+
ok: true,
|
|
502
|
+
detail: missing.length === 0
|
|
503
|
+
? 'committed coordination hooks match the canonical set'
|
|
504
|
+
: `drifted — committed settings missing ${missing
|
|
505
|
+
.map((h) => `${h.event} \`${h.command}\``)
|
|
506
|
+
.join(', ')}; run \`convene init --refresh-docs\` to wire`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// 7. watch heartbeat — the mid-task halt watcher is a BEST-EFFORT liveness/notify
|
|
511
|
+
// daemon that self-exits when its session goes idle and only relaunches at the
|
|
512
|
+
// next SessionStart, so a stale/absent heartbeat is EXPECTED for a quiet or
|
|
513
|
+
// long-lived session — NOT a failure. Directed halts surface via the pull path
|
|
514
|
+
// and are blocked by the guard regardless of this daemon. Informational only
|
|
515
|
+
// (never fails doctor); --fix offers a best-effort relaunch. See assessWatchHealth.
|
|
456
516
|
if (proj?.slug) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
517
|
+
const slug = proj.slug;
|
|
518
|
+
const watcherAlive = () => {
|
|
519
|
+
const p = (0, cache_1.readWatchPid)(slug);
|
|
520
|
+
return !!(p && (0, cache_1.isPidAlive)(p.pid));
|
|
521
|
+
};
|
|
522
|
+
let age = (0, cache_1.watchHeartbeatAgeSec)(slug);
|
|
523
|
+
let state = assessWatchHealth({ ageSec: age, staleSec: WATCH_STALE_SEC, hasLiveWatcher: watcherAlive() });
|
|
524
|
+
if (state !== 'alive' && opts.fix) {
|
|
525
|
+
if (relaunchWatch(slug)) {
|
|
461
526
|
// Give the freshly-launched daemon a beat to stamp its first heartbeat.
|
|
462
527
|
const until = Date.now() + 2500;
|
|
463
528
|
while (Date.now() < until) {
|
|
464
|
-
age = (0, cache_1.watchHeartbeatAgeSec)(
|
|
529
|
+
age = (0, cache_1.watchHeartbeatAgeSec)(slug);
|
|
465
530
|
if (age != null && age <= WATCH_STALE_SEC)
|
|
466
531
|
break;
|
|
467
532
|
}
|
|
468
|
-
|
|
533
|
+
state = assessWatchHealth({ ageSec: age, staleSec: WATCH_STALE_SEC, hasLiveWatcher: watcherAlive() });
|
|
469
534
|
}
|
|
470
535
|
}
|
|
471
|
-
checks.push({
|
|
472
|
-
name: 'watch',
|
|
473
|
-
ok: watchOk,
|
|
474
|
-
detail: watchOk
|
|
475
|
-
? `halt watcher alive (heartbeat ${age}s ago)`
|
|
476
|
-
: age == null
|
|
477
|
-
? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
|
|
478
|
-
: `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
|
|
479
|
-
});
|
|
536
|
+
checks.push({ name: 'watch', ok: true, detail: watchDetail(state, age) });
|
|
480
537
|
// 7a. reap orphaned watchers — the cleanup half of the daemon-leak fix. Only
|
|
481
538
|
// under --fix (running `ps` on every doctor would slow the fast path). Runs
|
|
482
539
|
// AFTER the relaunch + heartbeat-wait above so the freshly-relaunched watcher
|
|
@@ -579,22 +636,35 @@ async function doctor(opts) {
|
|
|
579
636
|
for (const c of checks) {
|
|
580
637
|
process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
|
|
581
638
|
}
|
|
582
|
-
// Best practices (
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
639
|
+
// Best practices (advisory): adopted-practice inventory + whether the repo
|
|
640
|
+
// trails the catalog. Fail-soft — never throws and never alters the doctor exit
|
|
641
|
+
// code. Resolve the catalog SERVER-TRUTHFULLY first (prefer the live published
|
|
642
|
+
// catalog, fall back to the bundled mirror) — the same `loadCatalog` resolver
|
|
643
|
+
// `convene update` uses — so the "available" line reflects what the SERVER
|
|
644
|
+
// publishes, not the version baked into this binary. doctor is already async +
|
|
645
|
+
// already does network I/O (api.me above), so a live fetch here costs nothing
|
|
646
|
+
// extra; loadCatalog is fail-soft and tags its source, so an unreachable server
|
|
647
|
+
// simply yields the bundled mirror, labelled honestly as offline.
|
|
648
|
+
const { catalog: bpCatalog, source: bpSource } = await (0, catalog_1.loadCatalog)(cfg.apiKey ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey) : null);
|
|
649
|
+
reportBestPractices(top, proj?.slug ?? null, bpCatalog, bpSource);
|
|
586
650
|
if (!checks.every((c) => c.ok))
|
|
587
651
|
process.exitCode = 1;
|
|
588
652
|
}
|
|
589
653
|
/**
|
|
590
|
-
* Print doctor's "Best practices" section.
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
654
|
+
* Print doctor's "Best practices" section. Diffs the repo manifest against the
|
|
655
|
+
* RESOLVED catalog (`catalog`/`source` come from loadCatalog — live-preferred,
|
|
656
|
+
* bundled fallback). Purely informational — it never throws (a malformed manifest
|
|
657
|
+
* is swallowed) and never sets the exit code.
|
|
658
|
+
* - manifest present → server-truthful freshness line + each adopted practice + unknowns.
|
|
594
659
|
* - on the bus but no manifest → a single nudge to adopt some.
|
|
595
660
|
* - not on the bus → nothing.
|
|
661
|
+
* The freshness wording is server-truthful: an "available"/"up to date" claim is
|
|
662
|
+
* made only when `source === 'live'`; an unreachable server yields the honest
|
|
663
|
+
* "bundled catalog … (offline — server version unknown)" line (see
|
|
664
|
+
* `bestPracticesFreshnessLine`). The same `catalogBehindLine` phrasing is shared
|
|
665
|
+
* with the `convene fetch` nudge so the sentence never means two different things.
|
|
596
666
|
*/
|
|
597
|
-
function reportBestPractices(top, slug) {
|
|
667
|
+
function reportBestPractices(top, slug, catalog, source) {
|
|
598
668
|
try {
|
|
599
669
|
const manifest = (0, config_1.loadManifest)(top);
|
|
600
670
|
if (!manifest) {
|
|
@@ -603,10 +673,8 @@ function reportBestPractices(top, slug) {
|
|
|
603
673
|
}
|
|
604
674
|
return;
|
|
605
675
|
}
|
|
606
|
-
const cmp = (0, manifest_1.compareToCatalog)(manifest,
|
|
607
|
-
process.stdout.write(cmp
|
|
608
|
-
? `· catalog v${cmp.catalogVersion} available (repo on v${cmp.repoVersion}) — run \`convene update\`\n`
|
|
609
|
-
: `· best practices up to date at v${cmp.repoVersion}\n`);
|
|
676
|
+
const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog);
|
|
677
|
+
process.stdout.write(`· ${(0, manifest_1.bestPracticesFreshnessLine)(cmp, source)}\n`);
|
|
610
678
|
for (const p of cmp.adopted) {
|
|
611
679
|
const flag = p.outdated ? ` (outdated → v${p.catalogVersion})` : '';
|
|
612
680
|
process.stdout.write(` ${p.id} @ ${p.manifestVersion} [${p.level}]${flag}\n`);
|