convene-cli 1.12.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/cache.js +46 -0
- package/dist/commands/adopt.js +296 -0
- package/dist/commands/auth.js +75 -18
- package/dist/commands/explain.js +15 -0
- 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/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),
|
|
@@ -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
|
package/dist/commands/explain.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.explain = explain;
|
|
|
11
11
|
* offline agent still gets the essentials. The endpoint is unauthenticated, so
|
|
12
12
|
* this works even before `convene login`.
|
|
13
13
|
*/
|
|
14
|
+
const node_child_process_1 = require("node:child_process");
|
|
14
15
|
const config_1 = require("../config");
|
|
15
16
|
const api_1 = require("../api");
|
|
16
17
|
const brand_1 = require("../brand");
|
|
@@ -45,6 +46,20 @@ async function explain(question) {
|
|
|
45
46
|
return;
|
|
46
47
|
}
|
|
47
48
|
if (res.ok && res.json && res.json.matched === false) {
|
|
49
|
+
// The agent asked how something works and Convene had no curated answer —
|
|
50
|
+
// the cleanest in-session friction signal. Capture it fire-and-forget
|
|
51
|
+
// (detached + unref'd, exactly as `convene fetch` spawns `convene announce`)
|
|
52
|
+
// so the product learns from confusion automatically instead of relying on a
|
|
53
|
+
// voluntary `convene suggest`. Gated on creds: no key/member → no spawn.
|
|
54
|
+
if (q && cfg.apiKey && cfg.member) {
|
|
55
|
+
try {
|
|
56
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'friction', '--kind', 'unmatched-explain', '--q', q], { detached: true, stdio: 'ignore' });
|
|
57
|
+
child.unref();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* fail-open: friction capture must never break `explain` */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
48
63
|
// Unmatched query — point at the index + bundled essentials (still exit 0).
|
|
49
64
|
process.stdout.write(`No specific match for "${q}". ${brand_1.BRAND.product} basics:\n\n${bundledSummary(cfg.baseUrl)}\n`);
|
|
50
65
|
return;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.friction = friction;
|
|
4
|
+
/**
|
|
5
|
+
* `convene friction` — automatic in-session CONFUSION capture. Files ONE
|
|
6
|
+
* low-severity feature_feedback row (category 'friction') when an agent hits a
|
|
7
|
+
* product rough edge the platform should learn from — today: an unmatched
|
|
8
|
+
* `convene explain` query (the agent literally asked how something works and
|
|
9
|
+
* Convene had no curated answer — the cleanest possible friction signal). It is
|
|
10
|
+
* spawned fire-and-forget by the surface that detects the friction (exactly as
|
|
11
|
+
* `convene fetch` spawns `convene announce`), so it NEVER blocks that surface.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors `convene announce`'s posture:
|
|
14
|
+
* - FAIL-OPEN: any error / missing config / non-bus repo exits 0 silently; a 5s
|
|
15
|
+
* watchdog backstops a hang. It can never break the command that spawned it.
|
|
16
|
+
* - IDEMPOTENT: a deterministic server idempotency-key
|
|
17
|
+
* (`friction:<slug>:<instance>:<sigHash>`) is the authoritative dedupe; a local
|
|
18
|
+
* (instance, sigHash) sentinel spares the redundant post when the SAME signal
|
|
19
|
+
* recurs in a session.
|
|
20
|
+
* - PRIVACY: the captured text is the agent's own words about Convene-the-product
|
|
21
|
+
* — the same class of text `convene suggest` already mirrors to maintainers. It
|
|
22
|
+
* is CLIPPED and posted only behind a valid key + project membership (the
|
|
23
|
+
* authenticated-session gate). It never carries prompt text or work content
|
|
24
|
+
* beyond the question the agent typed.
|
|
25
|
+
*/
|
|
26
|
+
const node_crypto_1 = require("node:crypto");
|
|
27
|
+
const git_1 = require("../git");
|
|
28
|
+
const config_1 = require("../config");
|
|
29
|
+
const cache_1 = require("../cache");
|
|
30
|
+
const api_1 = require("../api");
|
|
31
|
+
const exit_1 = require("../exit");
|
|
32
|
+
const SIGNAL_MAX = 240;
|
|
33
|
+
function clip(s, max) {
|
|
34
|
+
const t = s.trim();
|
|
35
|
+
return t.length <= max ? t : t.slice(0, max - 1) + '…';
|
|
36
|
+
}
|
|
37
|
+
/** Stable short hash of the normalized signal — keys both the idempotency key and the local sentinel. */
|
|
38
|
+
function signalHash(kind, signal) {
|
|
39
|
+
const norm = `${kind}\n${signal.toLowerCase().replace(/\s+/g, ' ').trim()}`;
|
|
40
|
+
return (0, node_crypto_1.createHash)('sha256').update(norm).digest('hex').slice(0, 16);
|
|
41
|
+
}
|
|
42
|
+
/** Human-readable feedback body for a friction kind. */
|
|
43
|
+
function bodyFor(kind, signal) {
|
|
44
|
+
const q = clip(signal, SIGNAL_MAX);
|
|
45
|
+
switch (kind) {
|
|
46
|
+
case 'unmatched-explain':
|
|
47
|
+
return `Unmatched \`convene explain\` query (no curated answer): "${q}"`;
|
|
48
|
+
default:
|
|
49
|
+
return `[${kind}] ${q}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function run(opts) {
|
|
53
|
+
const kind = (opts.kind ?? '').trim();
|
|
54
|
+
const signal = (opts.q ?? '').trim();
|
|
55
|
+
if (!kind || !signal)
|
|
56
|
+
return; // no signal → nothing to capture
|
|
57
|
+
const top = (0, git_1.gitToplevel)();
|
|
58
|
+
if (!top)
|
|
59
|
+
return; // not a git repo → silent no-op
|
|
60
|
+
const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
|
|
61
|
+
if (!slug)
|
|
62
|
+
return; // repo not on the bus → silent no-op
|
|
63
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
64
|
+
const sig = signalHash(kind, signal);
|
|
65
|
+
const body = bodyFor(kind, signal);
|
|
66
|
+
if (opts.dryRun) {
|
|
67
|
+
process.stdout.write(body + '\n');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Already captured this exact signal this session — spare the redundant post.
|
|
71
|
+
if ((0, cache_1.frictionAlreadyPosted)(slug, instance, sig))
|
|
72
|
+
return;
|
|
73
|
+
// The authenticated-session gate: only post with a real key + member.
|
|
74
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
75
|
+
if (!cfg.apiKey || !cfg.member)
|
|
76
|
+
return;
|
|
77
|
+
const session = (0, git_1.sessionId)(cfg.member, top);
|
|
78
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
79
|
+
const idem = `friction:${slug}:${instance}:${sig}`;
|
|
80
|
+
const res = await api.post(slug, { type: 'feature_feedback', body, category: 'friction', severity: 'low', tags: [kind] }, idem, 4000);
|
|
81
|
+
if (res.ok) {
|
|
82
|
+
// Only mark on success so a transient failure retries on the next occurrence.
|
|
83
|
+
(0, cache_1.markFrictionPosted)(slug, instance, sig);
|
|
84
|
+
if (res.json?.message?.short_id) {
|
|
85
|
+
process.stdout.write(`convene: captured [FRICTION] ${res.json.message.short_id} (${kind})\n`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function friction(opts = {}) {
|
|
90
|
+
// Backstop: force-exit on every path so a keep-alive socket can't linger.
|
|
91
|
+
const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
|
|
92
|
+
watchdog.unref();
|
|
93
|
+
const done = () => {
|
|
94
|
+
clearTimeout(watchdog);
|
|
95
|
+
(0, exit_1.exitClean)(0);
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
await run(opts);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* fail-open: a friction capture must never break the surface that spawned it */
|
|
102
|
+
}
|
|
103
|
+
done();
|
|
104
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -223,72 +223,9 @@ function registerHook(noHook) {
|
|
|
223
223
|
log(hookSnippet());
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
* LAST among Bash PreToolUse hooks (awareness/ux #10) — the deploy gate runs, then
|
|
230
|
-
* the cheap halt/lane backstop. Each entry names the VERB its binary must support;
|
|
231
|
-
* a stale `convene` missing the verb is skipped (so it can't error on every boot).
|
|
232
|
-
*
|
|
233
|
-
* `convene watch` is NOT a Bash hook — it's a long-running detached daemon that
|
|
234
|
-
* `convene session-start` spawns from the SessionStart path (§4.4). Wiring it as a
|
|
235
|
-
* blocking Bash/PreToolUse entry would stall; launching from session-start keeps it
|
|
236
|
-
* off the discretionary tool path.
|
|
237
|
-
*/
|
|
238
|
-
const COORD_HOOKS = [
|
|
239
|
-
{
|
|
240
|
-
event: 'SessionStart',
|
|
241
|
-
matcher: 'startup|resume|clear',
|
|
242
|
-
command: 'convene session-start',
|
|
243
|
-
verb: 'session-start',
|
|
244
|
-
note: 'auto catch-up digest + mints the session-instance + launches `convene watch`',
|
|
245
|
-
},
|
|
246
|
-
{
|
|
247
|
-
event: 'PreToolUse',
|
|
248
|
-
matcher: 'Bash',
|
|
249
|
-
command: 'convene gate-push --stdin',
|
|
250
|
-
verb: 'gate-push',
|
|
251
|
-
note: 'deploy gate before a push (fail-open-loud)',
|
|
252
|
-
},
|
|
253
|
-
// guard is appended AFTER gate-push so it is LAST among Bash PreToolUse hooks.
|
|
254
|
-
{
|
|
255
|
-
event: 'PreToolUse',
|
|
256
|
-
matcher: 'Bash',
|
|
257
|
-
command: 'convene guard',
|
|
258
|
-
verb: 'guard',
|
|
259
|
-
note: 'halt + lane backstop for Bash (fail-open-loud)',
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
event: 'PreToolUse',
|
|
263
|
-
matcher: '.*',
|
|
264
|
-
command: 'convene guard --halt-only',
|
|
265
|
-
verb: 'guard',
|
|
266
|
-
note: 'cheap directed-halt backstop on every tool call',
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
event: 'PostToolUse',
|
|
270
|
-
matcher: 'Bash',
|
|
271
|
-
command: 'convene gate-push --post',
|
|
272
|
-
verb: 'gate-push',
|
|
273
|
-
note: 'release the deploy lane after a push (idempotent)',
|
|
274
|
-
},
|
|
275
|
-
{
|
|
276
|
-
event: 'PostToolUse',
|
|
277
|
-
matcher: 'Edit|Write|MultiEdit',
|
|
278
|
-
command: 'convene beat --stdin',
|
|
279
|
-
verb: 'beat',
|
|
280
|
-
note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
|
|
281
|
-
},
|
|
282
|
-
// Stop fires at every turn-end (no tool matcher); `convene wrap` is idempotent +
|
|
283
|
-
// debounced via the last-broadcast-sha cursor, so it posts at most one wrap per
|
|
284
|
-
// stretch of new committed work and stays silent on idle turns. Never blocks.
|
|
285
|
-
{
|
|
286
|
-
event: 'Stop',
|
|
287
|
-
command: 'convene wrap',
|
|
288
|
-
verb: 'wrap',
|
|
289
|
-
note: 'session-end wrap status when new committed work landed (idempotent, fail-open)',
|
|
290
|
-
},
|
|
291
|
-
];
|
|
226
|
+
// COORD_HOOKS (the canonical coordination-hook set, in install order) + the
|
|
227
|
+
// missingCoordHooks drift-check now live in ../hook as the SINGLE source of truth,
|
|
228
|
+
// so the writer here and the `convene doctor` drift-check can never disagree.
|
|
292
229
|
/**
|
|
293
230
|
* Wire the WP13 coordination hooks into a settings file (global or committed
|
|
294
231
|
* project), idempotent + merge-safe via ensureHook (deep-clone, never clobber,
|
|
@@ -297,7 +234,7 @@ const COORD_HOOKS = [
|
|
|
297
234
|
*/
|
|
298
235
|
function registerCoordinationHooks(settingsPath, label) {
|
|
299
236
|
let unparseable = false;
|
|
300
|
-
for (const h of COORD_HOOKS) {
|
|
237
|
+
for (const h of hook_1.COORD_HOOKS) {
|
|
301
238
|
if (!(0, hook_1.binarySupportsVerb)(h.verb)) {
|
|
302
239
|
log(`· ${label}: skipped \`${h.command}\` — installed \`convene\` lacks \`${h.verb}\` (upgrade the CLI to enable).`);
|
|
303
240
|
continue;
|
package/dist/commands/update.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.update = update;
|
|
7
|
+
exports.assessBindingCommitSafety = assessBindingCommitSafety;
|
|
7
8
|
exports.runUpdate = runUpdate;
|
|
8
9
|
/**
|
|
9
10
|
* `convene update` — Phase 4: check for + apply best-practices catalog updates.
|
|
@@ -147,6 +148,88 @@ function warnStaleCarriers(top, host) {
|
|
|
147
148
|
`(without --no-mcp) to fix: ${stale.join(', ')}`);
|
|
148
149
|
}
|
|
149
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The managed files a binding re-stamp REWRITES (a curated subset of CONVENE_PATHS) —
|
|
153
|
+
* the only ones a divergent origin edit can actually CONFLICT on. Deliberately excludes
|
|
154
|
+
* the append/merge-only members: `.gitignore` (idempotent append-only guard),
|
|
155
|
+
* `.claude/settings.json` (additive JSON hook-merge), `.githooks/pre-push` (regenerated,
|
|
156
|
+
* no user content), and `CONVENE_PROTOCOL.md` (write-if-absent — never rewritten). An
|
|
157
|
+
* unrelated origin edit to one of those auto-merges, so refusing on it is a false alarm.
|
|
158
|
+
*/
|
|
159
|
+
const BINDING_REWRITE_PATHS = [
|
|
160
|
+
'CLAUDE.md',
|
|
161
|
+
'AGENTS.md',
|
|
162
|
+
'.convene/project.json',
|
|
163
|
+
'.cursor/rules/convene.mdc',
|
|
164
|
+
'.clinerules/convene.md',
|
|
165
|
+
'.aider.conf.yml',
|
|
166
|
+
'.cursor/mcp.json',
|
|
167
|
+
'.vscode/mcp.json',
|
|
168
|
+
'.gemini/settings.json',
|
|
169
|
+
'.codex/config.toml',
|
|
170
|
+
];
|
|
171
|
+
/**
|
|
172
|
+
* Decide whether committing the convene-managed files on the LOCAL HEAD would collide
|
|
173
|
+
* with origin. The incident: `convene update --host … --commit` on a checkout whose
|
|
174
|
+
* origin had ALREADY re-pointed (and committed the same CLAUDE.md/AGENTS.md/.convene
|
|
175
|
+
* files) produced a commit guaranteed to conflict on coordination-critical files —
|
|
176
|
+
* manual git-archaeology in front of contributors.
|
|
177
|
+
*
|
|
178
|
+
* Fails OPEN on any unverifiable git state (no origin, detached HEAD, fetch failed,
|
|
179
|
+
* branch absent on origin) — exactly like the deploy compat gate (gate-push.ts), we
|
|
180
|
+
* never block a commit we cannot PROVE is unsafe. Only a CONFIRMED collision refuses.
|
|
181
|
+
*/
|
|
182
|
+
function assessBindingCommitSafety(top, host) {
|
|
183
|
+
if (!(0, git_1.originRemote)(top))
|
|
184
|
+
return { kind: 'ok', reason: 'no origin remote' };
|
|
185
|
+
const branch = (0, git_1.currentBranch)(top);
|
|
186
|
+
if (!branch)
|
|
187
|
+
return { kind: 'ok', reason: 'detached HEAD' };
|
|
188
|
+
if (!(0, git_1.gitFetch)(branch, 'origin', top))
|
|
189
|
+
return { kind: 'ok', reason: 'fetch failed (unverifiable)' };
|
|
190
|
+
const remote = (0, git_1.revParse)(`origin/${branch}`, top);
|
|
191
|
+
const head = (0, git_1.revParse)('HEAD', top);
|
|
192
|
+
if (!remote || !head)
|
|
193
|
+
return { kind: 'ok', reason: 'branch not on origin' };
|
|
194
|
+
// Origin tip is an ancestor of HEAD → local is up to date or ahead → a commit then
|
|
195
|
+
// push fast-forwards, no conflict possible.
|
|
196
|
+
if ((0, git_1.isAncestor)(remote, head, top))
|
|
197
|
+
return { kind: 'ok', reason: 'up to date / ahead of origin' };
|
|
198
|
+
// Behind or diverged. Does origin ALREADY carry this exact host binding? (A redundant
|
|
199
|
+
// re-commit here is the most common foot-gun — surface the friendlier pull message.)
|
|
200
|
+
const originProj = (0, git_1.gitShow)(`origin/${branch}`, '.convene/project.json', top);
|
|
201
|
+
if (originProj) {
|
|
202
|
+
try {
|
|
203
|
+
const b = JSON.parse(originProj)?.binding;
|
|
204
|
+
if (b?.host && (0, binding_1.normalizeHost)(b.host) === host)
|
|
205
|
+
return { kind: 'already-on-origin', branch };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* unparseable origin project.json → fall through to the managed-file diff */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Otherwise refuse ONLY if origin already changed a managed file the re-stamp
|
|
212
|
+
// REWRITES (BINDING_REWRITE_PATHS) — then a commit on top conflicts on it. A plain
|
|
213
|
+
// divergence that touched only append/merge-only managed files (or no managed file)
|
|
214
|
+
// is a routine rebase, not a convene-specific refusal.
|
|
215
|
+
const files = (0, git_1.changedPaths)('HEAD', `origin/${branch}`, BINDING_REWRITE_PATHS, top);
|
|
216
|
+
if (files.length)
|
|
217
|
+
return { kind: 'would-conflict', branch, files };
|
|
218
|
+
return { kind: 'ok', reason: 'diverged but origin did not touch managed files' };
|
|
219
|
+
}
|
|
220
|
+
/** The refusal/guidance message for a non-ok CommitSafety verdict. */
|
|
221
|
+
function bindingCommitMessage(s, host) {
|
|
222
|
+
if (s.kind === 'already-on-origin') {
|
|
223
|
+
return (`origin/${s.branch} already re-points to ${host} — pull (\`git pull --rebase\`), do not re-commit. ` +
|
|
224
|
+
`Re-stamping with --commit here would create a redundant commit that conflicts on .convene/project.json. (Override: --force.)`);
|
|
225
|
+
}
|
|
226
|
+
if (s.kind === 'would-conflict') {
|
|
227
|
+
return (`refusing to --commit: your branch is behind/diverged from origin/${s.branch}, which already changed ` +
|
|
228
|
+
`convene-managed files (${s.files.join(', ')}). Committing the re-stamp now will conflict on coordination-critical ` +
|
|
229
|
+
`files. Run \`git pull --rebase\` first, then re-run. (Override: --force.)`);
|
|
230
|
+
}
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
150
233
|
async function runBindingRefresh(top, opts) {
|
|
151
234
|
const existing = (0, config_1.loadProjectConfig)(top);
|
|
152
235
|
if (!existing?.slug) {
|
|
@@ -209,6 +292,13 @@ async function runBindingRefresh(top, opts) {
|
|
|
209
292
|
}
|
|
210
293
|
const skipAgentRules = opts.noAgentRules === true || opts.agentRules === false;
|
|
211
294
|
const skipMcp = opts.noMcp === true || opts.mcp === false;
|
|
295
|
+
// Divergence guard: a `--commit` that lands the re-stamped managed files on a local
|
|
296
|
+
// HEAD behind/diverged from an origin which ALSO changed those files conflicts on
|
|
297
|
+
// coordination-critical files (the VAcontractorCo incident). Assessed BEFORE any
|
|
298
|
+
// mutation so a refusal leaves the working tree clean. Only relevant under --commit.
|
|
299
|
+
const commitSafety = opts.commit
|
|
300
|
+
? assessBindingCommitSafety(top, host)
|
|
301
|
+
: { kind: 'ok', reason: 'no --commit' };
|
|
212
302
|
if (opts.check) {
|
|
213
303
|
log(`Dry run — \`convene update ${opts.host ? `--host ${host}` : '--refresh'}\` would:`);
|
|
214
304
|
log(` • re-render CLAUDE.md + AGENTS.md coordination blocks at ${host}`);
|
|
@@ -221,10 +311,24 @@ async function runBindingRefresh(top, opts) {
|
|
|
221
311
|
log(` • update ~/.convene/config.json baseUrl → ${host}`);
|
|
222
312
|
log(` • write the .convene/project.json binding stamp (schema 3${serverVerified ? ', server-confirmed' : ''})`);
|
|
223
313
|
log(opts.commit ? ' • commit exactly the convene files as one isolated commit' : ' • leave changes in the working tree (no commit without --commit)');
|
|
314
|
+
if (opts.commit && commitSafety.kind !== 'ok') {
|
|
315
|
+
log('');
|
|
316
|
+
log(opts.force
|
|
317
|
+
? ` ⚠ origin divergence detected (${commitSafety.kind}); --force is set, so --commit would proceed ANYWAY.`
|
|
318
|
+
: ` ✗ --commit would be REFUSED — ${bindingCommitMessage(commitSafety, host)}`);
|
|
319
|
+
}
|
|
224
320
|
log('');
|
|
225
|
-
log(
|
|
321
|
+
log(opts.commit
|
|
322
|
+
? 'Nothing written — origin was fetched only to assess the --commit divergence guard (no commit, no file changes). Re-run without --check to apply.'
|
|
323
|
+
: 'Nothing written (--check). Re-run without --check to apply.');
|
|
226
324
|
return;
|
|
227
325
|
}
|
|
326
|
+
// ENFORCE the divergence guard before mutating anything (clean tree on refusal).
|
|
327
|
+
if (opts.commit && commitSafety.kind !== 'ok') {
|
|
328
|
+
if (!opts.force)
|
|
329
|
+
(0, ctx_1.die)(bindingCommitMessage(commitSafety, host));
|
|
330
|
+
log(`⚠ --force: committing despite origin divergence (${commitSafety.kind}) — you will need to reconcile with origin afterward.`);
|
|
331
|
+
}
|
|
228
332
|
log(`${opts.host ? 'Re-pointing' : 'Refreshing'} Convene binding for "${slug}" at ${host}…`);
|
|
229
333
|
(0, init_1.writeCoordinationBlocks)(top, slug, member, host);
|
|
230
334
|
if (!skipAgentRules)
|
|
@@ -279,11 +383,18 @@ async function runBindingRefresh(top, opts) {
|
|
|
279
383
|
async function runUpdate(top, manifest, catalog, source, opts) {
|
|
280
384
|
const rows = classify(manifest, catalog, top);
|
|
281
385
|
const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog);
|
|
386
|
+
// Newly-available catalog practices this repo has NOT adopted — the delta `update`
|
|
387
|
+
// is otherwise blind to (it only bumps the already-adopted set). Surfaced in the dry
|
|
388
|
+
// run + as an apply pointer so the "update available → nothing to apply" dead-end
|
|
389
|
+
// becomes "the delta is N new practices; run `convene adopt <id>`". update NEVER
|
|
390
|
+
// auto-adopts — adoption stays a deliberate act (see `convene adopt`).
|
|
391
|
+
const adoptedIds = new Set(manifest.practices.map((e) => e.id));
|
|
392
|
+
const available = catalog.practices.filter((p) => !adoptedIds.has(p.id));
|
|
282
393
|
if (!opts.apply) {
|
|
283
|
-
printDryRun(rows, cmp.repoVersion, catalog.version, source, opts);
|
|
394
|
+
printDryRun(rows, cmp.repoVersion, catalog.version, source, opts, available);
|
|
284
395
|
return;
|
|
285
396
|
}
|
|
286
|
-
await applyUpdate(top, manifest, catalog, rows, opts);
|
|
397
|
+
await applyUpdate(top, manifest, catalog, rows, opts, available);
|
|
287
398
|
}
|
|
288
399
|
/** Build the per-practice rows: status vs. the live catalog + drift flags. */
|
|
289
400
|
function classify(manifest, catalog, top) {
|
|
@@ -313,11 +424,20 @@ function classify(manifest, catalog, top) {
|
|
|
313
424
|
};
|
|
314
425
|
});
|
|
315
426
|
}
|
|
316
|
-
function printDryRun(rows, repoVersion, catalogVersion, source, opts) {
|
|
427
|
+
function printDryRun(rows, repoVersion, catalogVersion, source, opts, available) {
|
|
317
428
|
const behind = repoVersion !== catalogVersion;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
429
|
+
const hasAdoptedBump = rows.some((r) => hasBump(r));
|
|
430
|
+
const bundledNote = source === 'bundled' ? ' (bundled — offline)' : '';
|
|
431
|
+
// The catalog watermark moving does NOT mean an adopted practice has an update: it
|
|
432
|
+
// also moves when the catalog merely ADDS practices. If nothing adopted has a real
|
|
433
|
+
// bump, don't imply adopted updates exist — that recreates the dead-end this
|
|
434
|
+
// surfacing was built to kill. The available-not-adopted block below carries the
|
|
435
|
+
// actual delta + the `convene adopt` pointer.
|
|
436
|
+
log(behind && !hasAdoptedBump
|
|
437
|
+
? `Adopted practices up to date at catalog v${repoVersion}; catalog is at v${catalogVersion}${bundledNote}`
|
|
438
|
+
: behind
|
|
439
|
+
? `Catalog update available: repo on v${repoVersion} → catalog v${catalogVersion}${bundledNote}`
|
|
440
|
+
: `Best practices up to date at catalog v${repoVersion}${bundledNote}`);
|
|
321
441
|
log('');
|
|
322
442
|
const idW = Math.max(8, ...rows.map((r) => r.id.length));
|
|
323
443
|
log(` ${pad('practice', idW)} ${pad('level', 9)} ${pad('version', 18)} change`);
|
|
@@ -343,18 +463,54 @@ function printDryRun(rows, repoVersion, catalogVersion, source, opts) {
|
|
|
343
463
|
const skippedMajor = rows.filter((r) => skipForMajor(r, opts));
|
|
344
464
|
const skippedDrift = rows.filter((r) => skipForDrift(r, opts));
|
|
345
465
|
const skippedPatchGate = rows.filter((r) => skipForPatchGate(r, opts));
|
|
346
|
-
|
|
466
|
+
const nothingToApply = applicable.length === 0 && skippedMajor.length === 0 && skippedDrift.length === 0 && skippedPatchGate.length === 0;
|
|
467
|
+
// The load-bearing fix: when there is nothing to apply to the ADOPTED set, do NOT
|
|
468
|
+
// dead-end on a bare "Nothing to apply." while newly-available practices go unseen —
|
|
469
|
+
// fall through to the available block below.
|
|
470
|
+
if (nothingToApply && available.length === 0) {
|
|
347
471
|
log('Nothing to apply.');
|
|
348
472
|
return;
|
|
349
473
|
}
|
|
350
|
-
if (
|
|
351
|
-
log(
|
|
474
|
+
if (nothingToApply) {
|
|
475
|
+
log('No updates to apply to your adopted practices.');
|
|
352
476
|
}
|
|
353
|
-
|
|
477
|
+
else {
|
|
478
|
+
if (applicable.length) {
|
|
479
|
+
log(`${applicable.length} practice(s) would update on \`convene update --apply\`.`);
|
|
480
|
+
}
|
|
481
|
+
reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
|
|
482
|
+
}
|
|
483
|
+
printAvailable(available, catalogVersion);
|
|
484
|
+
if (!nothingToApply) {
|
|
485
|
+
log('');
|
|
486
|
+
log('Next: `convene update --apply` (review with `git diff` and commit yourself — Convene never commits).');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* List catalog practices the repo has NOT adopted and point at `convene adopt`. The
|
|
491
|
+
* delta `convene update` cannot take (it only bumps the already-adopted set) — without
|
|
492
|
+
* this an agent that saw "Catalog update available: repo on vX → vY" runs `--apply`,
|
|
493
|
+
* gets "Nothing to apply", and never learns the new practices exist. No-op when empty.
|
|
494
|
+
*/
|
|
495
|
+
function printAvailable(available, catalogVersion) {
|
|
496
|
+
if (available.length === 0)
|
|
497
|
+
return;
|
|
354
498
|
log('');
|
|
355
|
-
log(
|
|
499
|
+
log(`${available.length} new practice(s) available in catalog v${catalogVersion} but NOT adopted here:`);
|
|
500
|
+
const aw = Math.max(8, ...available.map((p) => p.id.length));
|
|
501
|
+
for (const p of available)
|
|
502
|
+
log(` ${pad(p.id, aw)} ${pad(p.tier, 12)} ${p.title}`);
|
|
503
|
+
log('');
|
|
504
|
+
log('`convene update` only refreshes practices you already adopted — it never adopts new ones for you.');
|
|
505
|
+
log(`To add one: \`convene adopt <id>\` (e.g. \`convene adopt ${available[0].id}\`). See the why first: \`convene practices <id>\`.`);
|
|
506
|
+
}
|
|
507
|
+
/** One-line apply-path pointer to `convene adopt` for newly-available practices. */
|
|
508
|
+
function availablePointer(available) {
|
|
509
|
+
if (available.length === 0)
|
|
510
|
+
return;
|
|
511
|
+
log(` ${available.length} new practice(s) available but NOT adopted — \`convene update --apply\` never adopts new ones. Use \`convene adopt <id>\`.`);
|
|
356
512
|
}
|
|
357
|
-
async function applyUpdate(top, manifest, catalog, rows, opts) {
|
|
513
|
+
async function applyUpdate(top, manifest, catalog, rows, opts, available) {
|
|
358
514
|
const byId = new Map(catalog.practices.map((p) => [p.id, p]));
|
|
359
515
|
const toUpdate = rows.filter((r) => isApplicable(r, opts));
|
|
360
516
|
const skippedMajor = rows.filter((r) => skipForMajor(r, opts));
|
|
@@ -365,6 +521,7 @@ async function applyUpdate(top, manifest, catalog, rows, opts) {
|
|
|
365
521
|
log('Nothing applied — no practice was eligible.');
|
|
366
522
|
reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
|
|
367
523
|
reportRemoved(rows);
|
|
524
|
+
availablePointer(available); // never auto-adopts; just points at `convene adopt`
|
|
368
525
|
return;
|
|
369
526
|
}
|
|
370
527
|
// PRESERVE skipped (drifted / major / patch-gated) sections byte-for-byte: snapshot
|
|
@@ -415,6 +572,7 @@ async function applyUpdate(top, manifest, catalog, rows, opts) {
|
|
|
415
572
|
}
|
|
416
573
|
reportSkips(skippedMajor, skippedDrift, skippedPatchGate);
|
|
417
574
|
reportRemoved(rows);
|
|
575
|
+
availablePointer(available); // never auto-adopts; just points at `convene adopt`
|
|
418
576
|
log('');
|
|
419
577
|
log('Changes are in your working tree only. Review with `git diff` and commit yourself — Convene never commits.');
|
|
420
578
|
}
|
package/dist/git.js
CHANGED
|
@@ -20,6 +20,8 @@ exports.revListCount = revListCount;
|
|
|
20
20
|
exports.revParse = revParse;
|
|
21
21
|
exports.isAncestor = isAncestor;
|
|
22
22
|
exports.gitFetch = gitFetch;
|
|
23
|
+
exports.gitShow = gitShow;
|
|
24
|
+
exports.changedPaths = changedPaths;
|
|
23
25
|
exports.gitHooksDir = gitHooksDir;
|
|
24
26
|
exports.gitConfigSetLocal = gitConfigSetLocal;
|
|
25
27
|
exports.gitConfigUnsetLocal = gitConfigUnsetLocal;
|
|
@@ -240,6 +242,22 @@ function gitFetch(ref, remote = 'origin', cwd = process.cwd()) {
|
|
|
240
242
|
return false;
|
|
241
243
|
}
|
|
242
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Contents of `ref:relPath` (e.g. `origin/main:.convene/project.json`), or null when
|
|
247
|
+
* the ref or path does not exist there. Read-only, bounded, never throws — used to
|
|
248
|
+
* peek at what a remote tip carries without checking it out.
|
|
249
|
+
*/
|
|
250
|
+
function gitShow(ref, relPath, cwd = process.cwd()) {
|
|
251
|
+
return git(['show', `${ref}:${relPath}`], cwd);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* File paths that DIFFER between two refs, restricted to `paths` (files or dirs):
|
|
255
|
+
* `git diff --name-only refA refB -- <paths>`. Empty array when identical or on error.
|
|
256
|
+
*/
|
|
257
|
+
function changedPaths(refA, refB, paths, cwd = process.cwd()) {
|
|
258
|
+
const out = git(['diff', '--name-only', refA, refB, '--', ...paths], cwd);
|
|
259
|
+
return out ? out.split('\n').map((l) => l.trim()).filter(Boolean) : [];
|
|
260
|
+
}
|
|
243
261
|
/** Absolute path to this repo's hooks directory (resolves worktrees/submodules). */
|
|
244
262
|
function gitHooksDir(cwd = process.cwd()) {
|
|
245
263
|
const p = git(['rev-parse', '--git-path', 'hooks'], cwd);
|
package/dist/hook.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.HOOK_COMMAND = exports.SETTINGS_PATH = void 0;
|
|
6
|
+
exports.COORD_HOOKS = exports.HOOK_COMMAND = exports.SETTINGS_PATH = void 0;
|
|
7
7
|
exports.binarySupportsVerb = binarySupportsVerb;
|
|
8
8
|
exports.readSettingsRaw = readSettingsRaw;
|
|
9
9
|
exports.parseSettings = parseSettings;
|
|
@@ -13,6 +13,7 @@ exports.serializeSettings = serializeSettings;
|
|
|
13
13
|
exports.genericHookIsRegistered = genericHookIsRegistered;
|
|
14
14
|
exports.withGenericHook = withGenericHook;
|
|
15
15
|
exports.ensureHook = ensureHook;
|
|
16
|
+
exports.missingCoordHooks = missingCoordHooks;
|
|
16
17
|
exports.ensureHookRegistered = ensureHookRegistered;
|
|
17
18
|
exports.isConveneHookCommand = isConveneHookCommand;
|
|
18
19
|
exports.conveneHookFingerprint = conveneHookFingerprint;
|
|
@@ -174,6 +175,69 @@ function ensureHook(eventName, command, matcher, settingsPath = exports.SETTINGS
|
|
|
174
175
|
return 'manual';
|
|
175
176
|
}
|
|
176
177
|
}
|
|
178
|
+
exports.COORD_HOOKS = [
|
|
179
|
+
{
|
|
180
|
+
event: 'SessionStart',
|
|
181
|
+
matcher: 'startup|resume|clear',
|
|
182
|
+
command: 'convene session-start',
|
|
183
|
+
verb: 'session-start',
|
|
184
|
+
note: 'auto catch-up digest + mints the session-instance + launches `convene watch`',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
event: 'PreToolUse',
|
|
188
|
+
matcher: 'Bash',
|
|
189
|
+
command: 'convene gate-push --stdin',
|
|
190
|
+
verb: 'gate-push',
|
|
191
|
+
note: 'deploy gate before a push (fail-open-loud)',
|
|
192
|
+
},
|
|
193
|
+
// guard is appended AFTER gate-push so it is LAST among Bash PreToolUse hooks.
|
|
194
|
+
{
|
|
195
|
+
event: 'PreToolUse',
|
|
196
|
+
matcher: 'Bash',
|
|
197
|
+
command: 'convene guard',
|
|
198
|
+
verb: 'guard',
|
|
199
|
+
note: 'halt + lane backstop for Bash (fail-open-loud)',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
event: 'PreToolUse',
|
|
203
|
+
matcher: '.*',
|
|
204
|
+
command: 'convene guard --halt-only',
|
|
205
|
+
verb: 'guard',
|
|
206
|
+
note: 'cheap directed-halt backstop on every tool call',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
event: 'PostToolUse',
|
|
210
|
+
matcher: 'Bash',
|
|
211
|
+
command: 'convene gate-push --post',
|
|
212
|
+
verb: 'gate-push',
|
|
213
|
+
note: 'release the deploy lane after a push (idempotent)',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
event: 'PostToolUse',
|
|
217
|
+
matcher: 'Edit|Write|MultiEdit',
|
|
218
|
+
command: 'convene beat --stdin',
|
|
219
|
+
verb: 'beat',
|
|
220
|
+
note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
|
|
221
|
+
},
|
|
222
|
+
// Stop fires at every turn-end (no tool matcher); `convene wrap` is idempotent +
|
|
223
|
+
// debounced via the last-broadcast-sha cursor, so it posts at most one wrap per
|
|
224
|
+
// stretch of new committed work and stays silent on idle turns. Never blocks.
|
|
225
|
+
{
|
|
226
|
+
event: 'Stop',
|
|
227
|
+
command: 'convene wrap',
|
|
228
|
+
verb: 'wrap',
|
|
229
|
+
note: 'session-end wrap status when new committed work landed (idempotent, fail-open)',
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
/**
|
|
233
|
+
* COORD_HOOKS entries whose verb THIS binary supports but which are NOT present in
|
|
234
|
+
* `settings` — i.e. coordination wiring that has drifted from the canonical set.
|
|
235
|
+
* Verbs the binary lacks are skipped (mirroring registerCoordinationHooks), so an
|
|
236
|
+
* older CLI is never told to wire something it can't run.
|
|
237
|
+
*/
|
|
238
|
+
function missingCoordHooks(settings) {
|
|
239
|
+
return exports.COORD_HOOKS.filter((h) => binarySupportsVerb(h.verb) && !genericHookIsRegistered(settings, h.event, h.command, h.matcher));
|
|
240
|
+
}
|
|
177
241
|
/** Ensure the UserPromptSubmit hook is registered (idempotent, backs up). */
|
|
178
242
|
function ensureHookRegistered() {
|
|
179
243
|
const raw = readSettingsRaw();
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ const fetch_1 = require("./commands/fetch");
|
|
|
44
44
|
const notify_1 = require("./commands/notify");
|
|
45
45
|
const announce_1 = require("./commands/announce");
|
|
46
46
|
const wrap_1 = require("./commands/wrap");
|
|
47
|
+
const friction_1 = require("./commands/friction");
|
|
47
48
|
const post = __importStar(require("./commands/post"));
|
|
48
49
|
const inbox_1 = require("./commands/inbox");
|
|
49
50
|
const feedback_1 = require("./commands/feedback");
|
|
@@ -71,6 +72,7 @@ const watch_reap_1 = require("./commands/watch-reap");
|
|
|
71
72
|
const explain_1 = require("./commands/explain");
|
|
72
73
|
const practices_1 = require("./commands/practices");
|
|
73
74
|
const update_1 = require("./commands/update");
|
|
75
|
+
const adopt_1 = require("./commands/adopt");
|
|
74
76
|
const program = new commander_1.Command();
|
|
75
77
|
exports.program = program;
|
|
76
78
|
// Read the version from package.json so `convene --version` always tracks the
|
|
@@ -220,6 +222,14 @@ program
|
|
|
220
222
|
.option('--project <slug>')
|
|
221
223
|
.option('--dry-run', 'print the status it would post; do not post')
|
|
222
224
|
.action((opts) => (0, wrap_1.wrap)(opts));
|
|
225
|
+
program
|
|
226
|
+
.command('friction')
|
|
227
|
+
.description('auto-capture in-session confusion as a low-severity [FRICTION] feature_feedback (idempotent, fail-silent)')
|
|
228
|
+
.option('--kind <kind>', 'friction kind (e.g. unmatched-explain)')
|
|
229
|
+
.option('--q <text>', "the signal text — the agent's own words about Convene")
|
|
230
|
+
.option('--project <slug>')
|
|
231
|
+
.option('--dry-run', 'print what it would capture; do not post')
|
|
232
|
+
.action((opts) => (0, friction_1.friction)(opts));
|
|
223
233
|
const postCmd = program.command('post').description('post outbound coordination messages');
|
|
224
234
|
postCmd
|
|
225
235
|
.command('status <body>')
|
|
@@ -396,6 +406,14 @@ program
|
|
|
396
406
|
.option('--no-agent-rules', 'skip the cross-agent rule files during --refresh/--host')
|
|
397
407
|
.option('--no-mcp', 'skip the MCP carriers during --refresh/--host')
|
|
398
408
|
.action((opts) => (0, update_1.update)(opts));
|
|
409
|
+
program
|
|
410
|
+
.command('adopt <targets...>')
|
|
411
|
+
.description('adopt one or more catalog best practices into this repo (merges into the existing set, drift-safe; review in your working tree, never auto-commits)')
|
|
412
|
+
.option('--force', 're-level a locally-edited (drifted) practice anyway (overwrites your edits)')
|
|
413
|
+
.option('--no-hook', 'do not wire settingsHook guards (settingsJson deny + gitignore still apply)')
|
|
414
|
+
.option('--offline', 'skip the dashboard adoption report')
|
|
415
|
+
.option('--project <slug>', 'project slug (defaults to .convene/project.json)')
|
|
416
|
+
.action((targets, opts) => (0, adopt_1.adopt)(targets, opts));
|
|
399
417
|
program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
|
|
400
418
|
if (require.main === module) {
|
|
401
419
|
program.parseAsync(process.argv).catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "convene-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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://convene.live",
|