convene-cli 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +10 -0
- package/dist/brand.js +2 -2
- package/dist/cache.js +75 -85
- package/dist/commands/adopt.js +296 -0
- package/dist/commands/auth.js +75 -18
- package/dist/commands/enroll.js +72 -0
- package/dist/commands/explain.js +15 -0
- package/dist/commands/friction.js +104 -0
- package/dist/commands/init.js +4 -67
- package/dist/commands/join.js +20 -2
- package/dist/commands/update.js +171 -13
- package/dist/commands/watch.js +30 -48
- package/dist/git.js +18 -0
- package/dist/hook.js +65 -1
- package/dist/index.js +28 -0
- package/package.json +2 -2
package/dist/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/commands/watch.js
CHANGED
|
@@ -17,21 +17,24 @@ exports.watch = watch;
|
|
|
17
17
|
* - A bounded run (no live config / not on the bus) exits 0 silently so a
|
|
18
18
|
* SessionStart launch on a non-bus repo is a no-op.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
20
|
+
* RENDERING IS NOT THIS DAEMON'S JOB. Directed halts reach the agent's context
|
|
21
|
+
* via the server-truth PULL surfaces (fetch / session-open / lane-state), which
|
|
22
|
+
* query the server — the single authority for halt state. This daemon only:
|
|
23
|
+
* - stamps a liveness heartbeat each iteration (the health line reads its age,
|
|
24
|
+
* surfacing DEGRADED if the watcher is down);
|
|
25
|
+
* - optionally fires a best-effort desktop notification per matched halt
|
|
26
|
+
* (--notify; off by default — never enabled by the SessionStart spawn);
|
|
27
|
+
* - persists a MONOTONIC poll cursor so a relaunch (every SessionStart) resumes
|
|
28
|
+
* incrementally instead of re-scanning the backlog.
|
|
29
|
+
* It formerly appended matched halts to a per-slug jsonl for a reader to drain,
|
|
30
|
+
* but that reader was never wired up and a local cache could drift from server
|
|
31
|
+
* truth and mislead — so the jsonl was removed in favor of the pull path.
|
|
27
32
|
*
|
|
28
|
-
* TRUST: the
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* lane-state; watch only narrows the window for non-deploy turns.
|
|
33
|
+
* TRUST: the block DECISION is the guard's, from lane-state. A halt body is
|
|
34
|
+
* UNTRUSTED and is NEVER interpreted here — the notification is a fixed template
|
|
35
|
+
* that never splices the body. watch only narrows the window for non-deploy turns.
|
|
32
36
|
*
|
|
33
|
-
* --notify: best-effort desktop ping per
|
|
34
|
-
* verb's mechanism if present; otherwise silently skipped). Never blocks the loop.
|
|
37
|
+
* --notify: best-effort desktop ping per matched halt. Never blocks the loop.
|
|
35
38
|
*/
|
|
36
39
|
const node_child_process_1 = require("node:child_process");
|
|
37
40
|
const git_1 = require("../git");
|
|
@@ -80,30 +83,12 @@ function watchShouldExit(args) {
|
|
|
80
83
|
}
|
|
81
84
|
const HALT_TYPES = new Set(['halt', 'interrupt']);
|
|
82
85
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
83
|
-
/**
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
return null;
|
|
87
|
-
const type = typeof m.type === 'string' ? m.type : '';
|
|
88
|
-
if (!HALT_TYPES.has(type))
|
|
89
|
-
return null;
|
|
90
|
-
// seq is the messages.id (enrich exposes it as `seq`, falling back to numeric id).
|
|
91
|
-
const seq = typeof m.seq === 'number' ? m.seq : typeof m.id === 'number' ? m.id : Number(m.id);
|
|
92
|
-
if (!Number.isFinite(seq))
|
|
93
|
-
return null;
|
|
94
|
-
return {
|
|
95
|
-
seq,
|
|
96
|
-
type,
|
|
97
|
-
short_id: typeof m.short_id === 'string' ? m.short_id : null,
|
|
98
|
-
// from/to/body are DISPLAY/UNTRUSTED — copied verbatim, never interpreted here.
|
|
99
|
-
from: typeof m.from_handle === 'string' ? m.from_handle : typeof m.from === 'string' ? m.from : null,
|
|
100
|
-
to: typeof m.to === 'string' ? m.to : typeof m.to_member === 'string' ? m.to_member : null,
|
|
101
|
-
body: typeof m.body === 'string' ? m.body : null,
|
|
102
|
-
at: typeof m.created_at === 'string' ? m.created_at : null,
|
|
103
|
-
};
|
|
86
|
+
/** True if a server feed/poll message is a halt/interrupt control message. */
|
|
87
|
+
function isHaltMessage(m) {
|
|
88
|
+
return !!m && typeof m === 'object' && typeof m.type === 'string' && HALT_TYPES.has(m.type);
|
|
104
89
|
}
|
|
105
90
|
/** Best-effort desktop notification; never blocks or throws. */
|
|
106
|
-
function notifyBestEffort(
|
|
91
|
+
function notifyBestEffort() {
|
|
107
92
|
try {
|
|
108
93
|
if (process.platform === 'darwin') {
|
|
109
94
|
// A halt is a control signal; the body is UNTRUSTED so we do NOT splice it
|
|
@@ -132,10 +117,9 @@ async function loop(opts) {
|
|
|
132
117
|
return 0; // can't authenticate → silent no-op
|
|
133
118
|
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
134
119
|
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
135
|
-
// Resume
|
|
136
|
-
// re-
|
|
137
|
-
|
|
138
|
-
let cursor = (0, cache_1.readWatchHighWater)(slug);
|
|
120
|
+
// Resume from the persisted poll cursor so a relaunch (every SessionStart)
|
|
121
|
+
// resumes incrementally instead of re-scanning the backlog from zero.
|
|
122
|
+
let cursor = (0, cache_1.readWatchCursor)(slug);
|
|
139
123
|
let backoff = BACKOFF_BASE_MS;
|
|
140
124
|
let iterations = 0;
|
|
141
125
|
const limit = typeof opts.maxIterations === 'number' ? opts.maxIterations : Infinity;
|
|
@@ -195,19 +179,17 @@ async function loop(opts) {
|
|
|
195
179
|
backoff = BACKOFF_BASE_MS; // recovered
|
|
196
180
|
const msgs = Array.isArray(res.json.messages) ? res.json.messages : [];
|
|
197
181
|
for (const m of msgs) {
|
|
198
|
-
|
|
199
|
-
if (!entry)
|
|
182
|
+
if (!isHaltMessage(m))
|
|
200
183
|
continue;
|
|
201
|
-
(0, cache_1.appendWatchEntry)(slug, entry);
|
|
202
184
|
if (opts.notify)
|
|
203
|
-
notifyBestEffort(
|
|
185
|
+
notifyBestEffort();
|
|
204
186
|
}
|
|
205
|
-
// Advance the resume cursor
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
// forever. The reader's high-water only advances over rendered halts.
|
|
209
|
-
if (typeof res.json.cursor === 'number' && res.json.cursor > cursor)
|
|
187
|
+
// Advance the resume cursor past EVERY message seen (incl. non-halts) so the
|
|
188
|
+
// daemon never re-fetches them, and PERSIST it so a relaunch resumes here.
|
|
189
|
+
if (typeof res.json.cursor === 'number' && res.json.cursor > cursor) {
|
|
210
190
|
cursor = res.json.cursor;
|
|
191
|
+
(0, cache_1.persistWatchCursor)(slug, cursor);
|
|
192
|
+
}
|
|
211
193
|
iterations++;
|
|
212
194
|
}
|
|
213
195
|
}
|
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,10 +44,12 @@ 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");
|
|
50
51
|
const auth_1 = require("./commands/auth");
|
|
52
|
+
const enroll_1 = require("./commands/enroll");
|
|
51
53
|
const init_1 = require("./commands/init");
|
|
52
54
|
const offboard_1 = require("./commands/offboard");
|
|
53
55
|
const join_1 = require("./commands/join");
|
|
@@ -70,6 +72,7 @@ const watch_reap_1 = require("./commands/watch-reap");
|
|
|
70
72
|
const explain_1 = require("./commands/explain");
|
|
71
73
|
const practices_1 = require("./commands/practices");
|
|
72
74
|
const update_1 = require("./commands/update");
|
|
75
|
+
const adopt_1 = require("./commands/adopt");
|
|
73
76
|
const program = new commander_1.Command();
|
|
74
77
|
exports.program = program;
|
|
75
78
|
// Read the version from package.json so `convene --version` always tracks the
|
|
@@ -94,6 +97,15 @@ program
|
|
|
94
97
|
.option('--api-key <key>', 'API key, or "-" to read from stdin (no shell history)')
|
|
95
98
|
.option('--base-url <url>', 'Convene base URL')
|
|
96
99
|
.action((opts) => (0, auth_1.login)(opts));
|
|
100
|
+
program
|
|
101
|
+
.command('enroll-device')
|
|
102
|
+
.description('connect THIS machine to your existing identity via an emailed link (no key copying)')
|
|
103
|
+
.option('--email <email>', 'email of your existing identity (defaults to git user.email)')
|
|
104
|
+
.option('--base-url <url>', 'Convene base URL')
|
|
105
|
+
.option('--label <label>', 'a label for this device (defaults to hostname)')
|
|
106
|
+
.action(async (opts) => {
|
|
107
|
+
await (0, enroll_1.enrollDevice)({ email: opts.email, baseUrl: opts.baseUrl, label: opts.label });
|
|
108
|
+
});
|
|
97
109
|
program.command('whoami').description('show identity, base URL, session, bus status').action(() => (0, auth_1.whoami)());
|
|
98
110
|
program
|
|
99
111
|
.command('fetch')
|
|
@@ -210,6 +222,14 @@ program
|
|
|
210
222
|
.option('--project <slug>')
|
|
211
223
|
.option('--dry-run', 'print the status it would post; do not post')
|
|
212
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));
|
|
213
233
|
const postCmd = program.command('post').description('post outbound coordination messages');
|
|
214
234
|
postCmd
|
|
215
235
|
.command('status <body>')
|
|
@@ -386,6 +406,14 @@ program
|
|
|
386
406
|
.option('--no-agent-rules', 'skip the cross-agent rule files during --refresh/--host')
|
|
387
407
|
.option('--no-mcp', 'skip the MCP carriers during --refresh/--host')
|
|
388
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));
|
|
389
417
|
program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
|
|
390
418
|
if (require.main === module) {
|
|
391
419
|
program.parseAsync(process.argv).catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
"homepage": "https://
|
|
6
|
+
"homepage": "https://convene.live",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/alex-hawkinson/Convene.git",
|