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/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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.enrollDevice = enrollDevice;
|
|
7
|
+
/**
|
|
8
|
+
* `convene enroll-device` — connect THIS machine to your EXISTING identity without
|
|
9
|
+
* copying a key. Proves email ownership (an emailed confirm link) + device ownership
|
|
10
|
+
* (a CLI-held secret), then the server mints a fresh per-device key that ONLY this
|
|
11
|
+
* CLI can claim. Also auto-invoked by `convene setup`/`join` when the bus reports an
|
|
12
|
+
* identity already exists for your email (EMAIL_TAKEN).
|
|
13
|
+
*/
|
|
14
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
15
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
16
|
+
const api_1 = require("../api");
|
|
17
|
+
const config_1 = require("../config");
|
|
18
|
+
const git_1 = require("../git");
|
|
19
|
+
const ctx_1 = require("../ctx");
|
|
20
|
+
const log = (m) => process.stdout.write(m + '\n');
|
|
21
|
+
const sha256 = (s) => node_crypto_1.default.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
/**
|
|
24
|
+
* Run the device-enrollment flow. Returns true once a fresh key is saved to the
|
|
25
|
+
* local config; on timeout / expiry it prints guidance and exits the process.
|
|
26
|
+
*/
|
|
27
|
+
async function enrollDevice(opts) {
|
|
28
|
+
const top = (0, git_1.gitToplevel)();
|
|
29
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
30
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
|
|
31
|
+
const baseUrl = (opts.baseUrl || cfg.baseUrl).replace(/\/$/, '');
|
|
32
|
+
const email = opts.email || (0, git_1.gitUserEmail)(top ?? undefined) || undefined;
|
|
33
|
+
if (!email)
|
|
34
|
+
(0, ctx_1.die)('no email to enroll — pass `--email <you@example.com>` (or set git user.email)');
|
|
35
|
+
// CLI-held secret: send only its hash to start; present the raw secret at poll so
|
|
36
|
+
// ONLY this process can claim the minted key (a browser that clicks the link cannot).
|
|
37
|
+
const rawSecret = node_crypto_1.default.randomBytes(32).toString('base64url');
|
|
38
|
+
const secretHash = sha256(rawSecret);
|
|
39
|
+
const label = (opts.label || node_os_1.default.hostname() || 'cli').slice(0, 80);
|
|
40
|
+
const api = new api_1.ConveneApi(baseUrl, null);
|
|
41
|
+
const start = await api.enrollStart({ email: email, secret_hash: secretHash, device_label: label }, 10_000);
|
|
42
|
+
if (!start.ok || !start.json) {
|
|
43
|
+
(0, ctx_1.die)(`could not start device enrollment (${start.status}): ${start.error ?? 'unreachable'}`);
|
|
44
|
+
}
|
|
45
|
+
const { enrollment_id, poll_interval_ms, expires_in_s } = start.json;
|
|
46
|
+
const ttlS = expires_in_s ?? 900;
|
|
47
|
+
const mins = Math.max(1, Math.round(ttlS / 60));
|
|
48
|
+
log('');
|
|
49
|
+
log(`📧 We emailed a confirmation link to ${email}.`);
|
|
50
|
+
log(` Open it and click "Authorize device" to connect this machine (expires in ${mins} min).`);
|
|
51
|
+
log(` Waiting…`);
|
|
52
|
+
const intervalMs = Math.max(1000, poll_interval_ms ?? 2000);
|
|
53
|
+
const deadline = Date.now() + ttlS * 1000;
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
await sleep(intervalMs);
|
|
56
|
+
const poll = await api.enrollPoll(enrollment_id, rawSecret, 8000);
|
|
57
|
+
const st = poll.json?.status;
|
|
58
|
+
if (st === 'ok' && poll.json?.api_key) {
|
|
59
|
+
const member = poll.json.member?.handle ?? '';
|
|
60
|
+
(0, config_1.saveFileConfig)({ apiKey: poll.json.api_key, baseUrl, member });
|
|
61
|
+
log('');
|
|
62
|
+
log(`✓ Authorized as ${member}. This machine is connected (config saved 0600).`);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (st === 'expired') {
|
|
66
|
+
(0, ctx_1.die)('the confirmation link expired or was already used — run `convene setup` again to retry.');
|
|
67
|
+
}
|
|
68
|
+
// 'pending' / 'not_found' → the human hasn't confirmed yet; keep waiting.
|
|
69
|
+
}
|
|
70
|
+
(0, ctx_1.die)('timed out waiting for email confirmation — run `convene setup` again to retry.');
|
|
71
|
+
return false; // unreachable: die() exits.
|
|
72
|
+
}
|
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/join.js
CHANGED
|
@@ -15,6 +15,7 @@ const config_1 = require("../config");
|
|
|
15
15
|
const git_1 = require("../git");
|
|
16
16
|
const hook_1 = require("../hook");
|
|
17
17
|
const githook_1 = require("../githook");
|
|
18
|
+
const enroll_1 = require("./enroll");
|
|
18
19
|
const ctx_1 = require("../ctx");
|
|
19
20
|
const log = (m) => process.stdout.write(m + '\n');
|
|
20
21
|
async function join(opts) {
|
|
@@ -34,8 +35,25 @@ async function join(opts) {
|
|
|
34
35
|
// unauthenticated mode otherwise (creates a new identity + key).
|
|
35
36
|
const api = new api_1.ConveneApi(baseUrl, cfg.apiKey ?? null);
|
|
36
37
|
const res = await api.join(slug, { token, handle, email, display_name: handle, tool: 'cli' }, 10_000);
|
|
37
|
-
if (res.status === 409)
|
|
38
|
-
|
|
38
|
+
if (res.status === 409) {
|
|
39
|
+
const code = res.json?.code;
|
|
40
|
+
const keyHint = `paste your key (Settings → API key at ${brand_1.BRAND.siteUrl}/settings, or ~/.convene/config.json on your other machine)`;
|
|
41
|
+
if (code === 'EMAIL_TAKEN') {
|
|
42
|
+
// Same human, new machine: connect THIS machine to the existing identity with NO key
|
|
43
|
+
// copying — prove email ownership via an emailed link, then re-run join authenticated
|
|
44
|
+
// so the (now-known) member is added to this project (idempotent if already a member).
|
|
45
|
+
log(`An identity already exists for ${email ?? 'your email'} — connecting this machine to it (no key copying needed).`);
|
|
46
|
+
const ok = await (0, enroll_1.enrollDevice)({ email, baseUrl });
|
|
47
|
+
if (ok)
|
|
48
|
+
return join(opts);
|
|
49
|
+
return; // enrollDevice prints guidance + exits on failure
|
|
50
|
+
}
|
|
51
|
+
(0, ctx_1.die)(`The handle "${handle}" is already taken on this bus.\n` +
|
|
52
|
+
`If that's you (same person, another machine), connect to your existing identity:\n` +
|
|
53
|
+
` convene login --api-key - # ${keyHint}\n` +
|
|
54
|
+
`If you want a SEPARATE identity here, pick a different handle via your email:\n` +
|
|
55
|
+
` convene setup --email <a-different-email> # the handle is derived from the email's local part`);
|
|
56
|
+
}
|
|
39
57
|
if (res.status === 401 && cfg.apiKey)
|
|
40
58
|
(0, ctx_1.die)('join token is invalid/expired/revoked, or your saved key is bad — ask an owner');
|
|
41
59
|
if (res.status === 401)
|