convene-cli 1.8.0 → 1.10.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 +23 -0
- package/dist/binding-local.js +38 -0
- package/dist/binding.js +90 -0
- package/dist/cache.js +85 -0
- package/dist/catalog/catalog.generated.js +27 -2
- package/dist/commands/announce.js +89 -0
- package/dist/commands/auth.js +145 -7
- package/dist/commands/catchup.js +1 -1
- package/dist/commands/feedback.js +88 -0
- package/dist/commands/fetch.js +36 -1
- package/dist/commands/gate-push.js +1 -1
- package/dist/commands/guard.js +1 -1
- package/dist/commands/init.js +27 -4
- package/dist/commands/join.js +1 -1
- package/dist/commands/notify.js +12 -4
- package/dist/commands/offboard.js +35 -1
- package/dist/commands/override.js +1 -1
- package/dist/commands/reclaim.js +88 -0
- package/dist/commands/rotate.js +10 -3
- package/dist/commands/session-start.js +9 -1
- package/dist/commands/update.js +191 -2
- package/dist/commands/watch.js +1 -1
- package/dist/commands/wrap.js +95 -0
- package/dist/config.js +53 -3
- package/dist/ctx.js +8 -3
- package/dist/hook.js +37 -0
- package/dist/index.js +52 -6
- package/dist/protocol.js +3 -2
- package/dist/render.js +63 -0
- package/dist/version.js +24 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -86,6 +86,17 @@ class ConveneApi {
|
|
|
86
86
|
const p = slug ? `/projects/${encodeURIComponent(slug)}/inbox` : '/inbox';
|
|
87
87
|
return this.request('GET', p, { timeoutMs });
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* GET /projects/:slug/feedback — the maintainer-facing feature_feedback list
|
|
91
|
+
* (read counterpart to `convene suggest`). Member-gated server-side; returns
|
|
92
|
+
* `{ items: FeedbackItem[], role }` with metadata flattened (lifecycle /
|
|
93
|
+
* category / upvotes / source_*). Read-only — the `convene feedback` command
|
|
94
|
+
* is fail-open, so callers pass a short explicit timeout.
|
|
95
|
+
*/
|
|
96
|
+
listFeedback(slug, opts = {}, timeoutMs) {
|
|
97
|
+
const qs = opts.limit ? `?limit=${opts.limit}` : '';
|
|
98
|
+
return this.request('GET', `/projects/${encodeURIComponent(slug)}/feedback${qs}`, { timeoutMs });
|
|
99
|
+
}
|
|
89
100
|
/**
|
|
90
101
|
* GET /poll — the long-poll stream `convene watch` consumes. `since` is the
|
|
91
102
|
* resume cursor (Last-Event-ID semantics, a messages.id); `wait` is the server
|
|
@@ -143,6 +154,18 @@ class ConveneApi {
|
|
|
143
154
|
join(slug, body, timeoutMs) {
|
|
144
155
|
return this.request('POST', `/projects/${encodeURIComponent(slug)}/join`, { body, timeoutMs });
|
|
145
156
|
}
|
|
157
|
+
/** Self-recovery: re-grant your own membership via the committed join token (no bearer
|
|
158
|
+
* auth required). Restores OWNER only from a server-recorded prior-owner row. */
|
|
159
|
+
reclaim(slug, body, timeoutMs) {
|
|
160
|
+
return this.request('POST', `/projects/${encodeURIComponent(slug)}/reclaim`, { body, timeoutMs });
|
|
161
|
+
}
|
|
162
|
+
/** Owner: PERMANENTLY delete your own project (typed confirm_slug; owner-role-gated). */
|
|
163
|
+
deleteProjectOwner(slug, confirmSlug, timeoutMs) {
|
|
164
|
+
return this.request('DELETE', `/projects/${encodeURIComponent(slug)}`, {
|
|
165
|
+
body: { confirm_slug: confirmSlug },
|
|
166
|
+
timeoutMs,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
146
169
|
/** Self-serve: provision a brand-new global identity + key (no bearer auth). */
|
|
147
170
|
provision(body, timeoutMs) {
|
|
148
171
|
return this.request('POST', '/provision', { body, timeoutMs });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.localBindingDrift = localBindingDrift;
|
|
4
|
+
/**
|
|
5
|
+
* The local-only (network-free) host-pin drift line shared by the two hot paths —
|
|
6
|
+
* the `convene fetch` UserPromptSubmit nudge and the `convene session-start` boot
|
|
7
|
+
* banner. Kept separate from the pure `binding.ts` comparator because it touches
|
|
8
|
+
* the filesystem (the committed hook settings) and the running CLI version; both
|
|
9
|
+
* callers must NEVER hit the network on the boot/hot path, so this reads only local
|
|
10
|
+
* state and fails open to null.
|
|
11
|
+
*
|
|
12
|
+
* The host axis is intentionally absent: an authoritative pin makes the resolved
|
|
13
|
+
* host == the pin, so host divergence is a `convene doctor` concern (where /me adds
|
|
14
|
+
* server ground truth), not a hot-path one. What CAN drift locally is the CLI
|
|
15
|
+
* (older than the CLI that bound the repo — the stale-CLI problem) and the
|
|
16
|
+
* committed hook wiring (no longer matching the stamped fingerprint).
|
|
17
|
+
*/
|
|
18
|
+
const manifest_1 = require("./catalog/manifest");
|
|
19
|
+
const hook_1 = require("./hook");
|
|
20
|
+
const version_1 = require("./version");
|
|
21
|
+
function localBindingDrift(top, proj) {
|
|
22
|
+
try {
|
|
23
|
+
const binding = proj?.binding;
|
|
24
|
+
if (!binding)
|
|
25
|
+
return null;
|
|
26
|
+
if ((0, manifest_1.semverLt)((0, version_1.cliVersion)(), binding.cliVersion)) {
|
|
27
|
+
return `convene: CLI v${(0, version_1.cliVersion)()} is older than this repo's binding (v${binding.cliVersion}) — \`npm i -g convene-cli@latest\``;
|
|
28
|
+
}
|
|
29
|
+
const committed = (0, hook_1.conveneHookFingerprint)((0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top))));
|
|
30
|
+
if (committed !== binding.hookFingerprint) {
|
|
31
|
+
return 'convene: repo hook wiring changed since it was bound — run `convene update --refresh`';
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/binding.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeHost = normalizeHost;
|
|
4
|
+
exports.assessBinding = assessBinding;
|
|
5
|
+
/**
|
|
6
|
+
* Pure binding ⇄ live-state comparison — the local-only diff that powers the
|
|
7
|
+
* host-pin / freshness surfacing in `convene doctor`, `convene fetch`, and
|
|
8
|
+
* `convene session-start`.
|
|
9
|
+
*
|
|
10
|
+
* A repo's `.convene/project.json` `binding` stamp (schema 3) records what the
|
|
11
|
+
* repo was last RECONCILED against: the canonical bus host, the CLI version that
|
|
12
|
+
* stamped it, the catalog version, and a fingerprint of the COMMITTED hook wiring.
|
|
13
|
+
* This module compares that stamp against the live runtime truth (the resolved
|
|
14
|
+
* host, the running CLI, the server-echoed canonical host, the on-disk committed
|
|
15
|
+
* hook fingerprint) and classifies each axis.
|
|
16
|
+
*
|
|
17
|
+
* Modeled on `compareToCatalog` (catalog/manifest.ts): dependency-free and
|
|
18
|
+
* side-effect-free (no fs, no network, never throws) so it unit-tests offline and
|
|
19
|
+
* is safe to call on the fetch hot path. The ONLY non-stdlib dependency is the
|
|
20
|
+
* existing `semverLt` — the binding's CLI-version axis is the one semver-shaped
|
|
21
|
+
* dimension; the host axis is plain canonicalized string equality.
|
|
22
|
+
*/
|
|
23
|
+
const manifest_1 = require("./catalog/manifest");
|
|
24
|
+
/**
|
|
25
|
+
* Canonicalize a host/base-URL for equality so the pin and the resolved/ server-
|
|
26
|
+
* echoed host compare equal across cosmetic differences: trim, lowercase, strip a
|
|
27
|
+
* trailing-slash-only path, and drop an explicit DEFAULT port (:443 for https, :80
|
|
28
|
+
* for http) — `https://x` and `https://x:443/` address the same endpoint. Scheme is
|
|
29
|
+
* preserved (http vs https is a REAL difference — the in-process test server). Pure
|
|
30
|
+
* + never throws: a value the URL parser rejects falls back to trim/lowercase/strip.
|
|
31
|
+
*/
|
|
32
|
+
function normalizeHost(s) {
|
|
33
|
+
const trimmed = s.trim();
|
|
34
|
+
try {
|
|
35
|
+
const u = new URL(trimmed);
|
|
36
|
+
const scheme = u.protocol.toLowerCase(); // e.g. 'https:'
|
|
37
|
+
const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : '';
|
|
38
|
+
const port = u.port && u.port !== defaultPort ? `:${u.port}` : '';
|
|
39
|
+
const tail = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '');
|
|
40
|
+
return `${scheme}//${u.hostname}${port}${tail}`.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return trimmed.toLowerCase().replace(/\/+$/, '');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compare a committed binding stamp against live runtime state. Pure; never throws.
|
|
48
|
+
*
|
|
49
|
+
* Host axis (HARD): a mismatch iff the EFFECTIVE host differs from the pin, OR the
|
|
50
|
+
* server actually reached (via /me) self-identifies as a different canonical host
|
|
51
|
+
* than the pin. An absent server host is fail-open (cannot contradict ⇒ not a
|
|
52
|
+
* mismatch) — never brick a new CLI against an old server that omits the field.
|
|
53
|
+
* CLI axis (SOFT): directional via semverLt — `behind` when the running CLI is
|
|
54
|
+
* older than the stamping CLI (update your CLI), `ahead` when newer (consider
|
|
55
|
+
* re-stamping). bumpClass is deliberately NOT used: it collapses "ahead" to
|
|
56
|
+
* "none" and so cannot express the stale-CLI direction this feature targets.
|
|
57
|
+
* Hook axis (SOFT): committed fingerprint vs the stamped one; `unknown` when the
|
|
58
|
+
* committed fingerprint is unreadable.
|
|
59
|
+
*/
|
|
60
|
+
function assessBinding(stamp, live) {
|
|
61
|
+
const stampHost = normalizeHost(stamp.host);
|
|
62
|
+
const resolvedHost = normalizeHost(live.host);
|
|
63
|
+
const serverHost = live.serverHost ? normalizeHost(live.serverHost) : null;
|
|
64
|
+
let hostStatus;
|
|
65
|
+
if (resolvedHost !== stampHost)
|
|
66
|
+
hostStatus = 'mismatch';
|
|
67
|
+
else if (serverHost !== null && serverHost !== stampHost)
|
|
68
|
+
hostStatus = 'mismatch';
|
|
69
|
+
else
|
|
70
|
+
hostStatus = 'ok';
|
|
71
|
+
let cliStatus = 'ok';
|
|
72
|
+
if ((0, manifest_1.semverLt)(live.cliVersion, stamp.cliVersion))
|
|
73
|
+
cliStatus = 'behind';
|
|
74
|
+
else if ((0, manifest_1.semverLt)(stamp.cliVersion, live.cliVersion))
|
|
75
|
+
cliStatus = 'ahead';
|
|
76
|
+
const committed = live.committedHookFingerprint ?? null;
|
|
77
|
+
let hookStatus;
|
|
78
|
+
if (committed === null)
|
|
79
|
+
hookStatus = 'unknown';
|
|
80
|
+
else if (committed === stamp.hookFingerprint)
|
|
81
|
+
hookStatus = 'ok';
|
|
82
|
+
else
|
|
83
|
+
hookStatus = 'drift';
|
|
84
|
+
return {
|
|
85
|
+
host: { stamp: stampHost, resolved: resolvedHost, serverHost, status: hostStatus },
|
|
86
|
+
cli: { stamp: stamp.cliVersion, running: live.cliVersion, status: cliStatus },
|
|
87
|
+
hook: { stamp: stamp.hookFingerprint, committed, status: hookStatus },
|
|
88
|
+
hostMismatch: hostStatus === 'mismatch',
|
|
89
|
+
};
|
|
90
|
+
}
|
package/dist/cache.js
CHANGED
|
@@ -9,6 +9,8 @@ exports.writeCache = writeCache;
|
|
|
9
9
|
exports.ageSeconds = ageSeconds;
|
|
10
10
|
exports.readCatalogVersion = readCatalogVersion;
|
|
11
11
|
exports.writeCatalogVersion = writeCatalogVersion;
|
|
12
|
+
exports.readCliVersionCheck = readCliVersionCheck;
|
|
13
|
+
exports.writeCliVersionCheck = writeCliVersionCheck;
|
|
12
14
|
exports.readSessionInstance = readSessionInstance;
|
|
13
15
|
exports.mintSessionInstance = mintSessionInstance;
|
|
14
16
|
exports.ensureSessionInstance = ensureSessionInstance;
|
|
@@ -19,6 +21,10 @@ exports.markCatchupSurfaced = markCatchupSurfaced;
|
|
|
19
21
|
exports.catchupAlreadySurfaced = catchupAlreadySurfaced;
|
|
20
22
|
exports.markAutoIsolated = markAutoIsolated;
|
|
21
23
|
exports.autoIsolatedAlready = autoIsolatedAlready;
|
|
24
|
+
exports.announceAlreadyPosted = announceAlreadyPosted;
|
|
25
|
+
exports.markAnnounced = markAnnounced;
|
|
26
|
+
exports.readLastBroadcastSha = readLastBroadcastSha;
|
|
27
|
+
exports.writeLastBroadcastSha = writeLastBroadcastSha;
|
|
22
28
|
exports.readWatchHighWater = readWatchHighWater;
|
|
23
29
|
exports.persistHighWater = persistHighWater;
|
|
24
30
|
exports.appendWatchEntry = appendWatchEntry;
|
|
@@ -103,6 +109,33 @@ function writeCatalogVersion(version) {
|
|
|
103
109
|
/* best-effort */
|
|
104
110
|
}
|
|
105
111
|
}
|
|
112
|
+
function cliVersionFile() {
|
|
113
|
+
return node_path_1.default.join(config_1.CACHE_DIR, 'cli-version.json');
|
|
114
|
+
}
|
|
115
|
+
/** The cached latest-published CLI version if present AND within `ttlSec`, else null. */
|
|
116
|
+
function readCliVersionCheck(ttlSec) {
|
|
117
|
+
try {
|
|
118
|
+
const e = JSON.parse(node_fs_1.default.readFileSync(cliVersionFile(), 'utf8'));
|
|
119
|
+
if (!e || typeof e.version !== 'string' || typeof e.fetchedAt !== 'number')
|
|
120
|
+
return null;
|
|
121
|
+
if ((Date.now() - e.fetchedAt) / 1000 >= ttlSec)
|
|
122
|
+
return null;
|
|
123
|
+
return e.version || null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Persist the latest-published CLI version (best-effort; never throws). */
|
|
130
|
+
function writeCliVersionCheck(version) {
|
|
131
|
+
try {
|
|
132
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
133
|
+
node_fs_1.default.writeFileSync(cliVersionFile(), JSON.stringify({ fetchedAt: Date.now(), version }), { mode: 0o600 });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* best-effort */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
106
139
|
// ── session-instance id (WP2) ────────────────────────────────────────────────
|
|
107
140
|
// An opaque per-session UUID minted at SessionStart and persisted in CACHE_DIR.
|
|
108
141
|
// Sent as X-Convene-Session-Instance so the server can stamp holder_instance and
|
|
@@ -305,6 +338,58 @@ function autoIsolatedAlready(slug, instance) {
|
|
|
305
338
|
* incumbent forces a relocation.
|
|
306
339
|
*/
|
|
307
340
|
exports.LIVE_SESSION_RECENT_SEC = 3 * 60;
|
|
341
|
+
function announcedFile(slug) {
|
|
342
|
+
return slugFile(scoped(slug), 'announced');
|
|
343
|
+
}
|
|
344
|
+
/** True iff this exact (instance, branch) has already been announced. */
|
|
345
|
+
function announceAlreadyPosted(slug, instance, branch) {
|
|
346
|
+
try {
|
|
347
|
+
const e = JSON.parse(node_fs_1.default.readFileSync(announcedFile(slug), 'utf8'));
|
|
348
|
+
return !!e && e.instance === instance && e.branch === branch;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Record that (instance, branch) has been announced. Best-effort; never throws. */
|
|
355
|
+
function markAnnounced(slug, instance, branch) {
|
|
356
|
+
try {
|
|
357
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
358
|
+
node_fs_1.default.writeFileSync(announcedFile(slug), JSON.stringify({ instance, branch }), { mode: 0o600 });
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
/* best-effort */
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ── last-broadcast-sha (announce ↔ wrap ↔ push dedup) ─────────────────────────
|
|
365
|
+
// The most recent HEAD sha this session has already told the bus about — written
|
|
366
|
+
// by `convene announce` (the session-start tip), `convene wrap` (a turn-end wrap),
|
|
367
|
+
// and `convene notify-push` (a push). `convene wrap` (a Stop hook, so it fires at
|
|
368
|
+
// every turn-end) reads it to stay quiet: it only posts when HEAD has advanced
|
|
369
|
+
// PAST this sha, so it never re-wraps a tip the bus already heard about (from the
|
|
370
|
+
// announce, a prior wrap, or the pre-push hook) and never spams an idle session.
|
|
371
|
+
function broadcastShaFile(slug) {
|
|
372
|
+
return slugFile(scoped(slug), 'broadcast-sha');
|
|
373
|
+
}
|
|
374
|
+
/** The last HEAD sha broadcast to the bus for this session, or null. */
|
|
375
|
+
function readLastBroadcastSha(slug) {
|
|
376
|
+
try {
|
|
377
|
+
return node_fs_1.default.readFileSync(broadcastShaFile(slug), 'utf8').trim() || null;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Record the HEAD sha just broadcast to the bus. Best-effort; never throws. */
|
|
384
|
+
function writeLastBroadcastSha(slug, sha) {
|
|
385
|
+
try {
|
|
386
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
387
|
+
node_fs_1.default.writeFileSync(broadcastShaFile(slug), sha + '\n', { mode: 0o600 });
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* best-effort */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
308
393
|
function watchFile(slug) {
|
|
309
394
|
return slugFile(slug, 'watch.jsonl');
|
|
310
395
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CATALOG_VERSION = exports.CATALOG = void 0;
|
|
4
4
|
exports.CATALOG = {
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.1.0",
|
|
6
6
|
"tiers": [
|
|
7
7
|
{
|
|
8
8
|
"id": "essentials",
|
|
@@ -213,6 +213,31 @@ exports.CATALOG = {
|
|
|
213
213
|
"https://editorconfig.org/"
|
|
214
214
|
]
|
|
215
215
|
},
|
|
216
|
+
{
|
|
217
|
+
"id": "announce-session-intent",
|
|
218
|
+
"version": "1.0.0",
|
|
219
|
+
"title": "Announce what you are working on at session start and end",
|
|
220
|
+
"category": "Parallel & Multi-Session Coordination",
|
|
221
|
+
"tier": "essentials",
|
|
222
|
+
"what": "At the start of a work session, broadcast what you are taking on; when you wrap (or land/push commits), broadcast what you did — `convene post status \"<one line>\"`. Do not rely on memory or on others reading your diff. Convene also auto-posts a terse backstop at these points (a \"started on <branch>\" line on the first prompt for every tool, a push status via the pre-push hook, and — in Claude Code — a turn-end wrap once commits land), but a hand-written line with real context is the goal; the auto-posts only guarantee a session is never dark.",
|
|
223
|
+
"why": "The most common multi-session failure is a silent session: an agent (often a non-Claude tool with no SessionStart/Stop hook) works for an hour and no peer knows where it is, so two sessions collide or duplicate work. Inbound context is automatic and cross-tool; outbound was voluntary prose and got skipped — a Codex session edited the marketing site and never touched the bus. Per `hooks-for-must-haves`, the fix is to make the announce automatic (a hook), with the agent line as enrichment. Production-learned at Convene.",
|
|
224
|
+
"defaultLevel": "advisory",
|
|
225
|
+
"availableLevels": [
|
|
226
|
+
"advisory"
|
|
227
|
+
],
|
|
228
|
+
"optInDefault": "on",
|
|
229
|
+
"productionLearned": true,
|
|
230
|
+
"artifacts": [
|
|
231
|
+
{
|
|
232
|
+
"kind": "claudeMd",
|
|
233
|
+
"body": "Post what you are taking on at the start of a work session and what you did when you wrap — `convene post status \"<one line>\"` — so concurrent sessions across tools see who owns what.\nA terse start/wrap line is auto-posted as a backstop; your own hand-written status is richer and is what peers rely on."
|
|
234
|
+
}
|
|
235
|
+
],
|
|
236
|
+
"sourceUrls": [
|
|
237
|
+
"https://code.claude.com/docs/en/hooks",
|
|
238
|
+
"https://code.claude.com/docs/en/best-practices"
|
|
239
|
+
]
|
|
240
|
+
},
|
|
216
241
|
{
|
|
217
242
|
"id": "worktree-per-session",
|
|
218
243
|
"version": "1.0.0",
|
|
@@ -857,4 +882,4 @@ exports.CATALOG = {
|
|
|
857
882
|
}
|
|
858
883
|
]
|
|
859
884
|
};
|
|
860
|
-
exports.CATALOG_VERSION = "1.
|
|
885
|
+
exports.CATALOG_VERSION = "1.1.0";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.announce = announce;
|
|
4
|
+
/**
|
|
5
|
+
* `convene announce` — the front-of-session auto-announce. Posts ONE [STATUS]
|
|
6
|
+
* ("started session on <branch>") the first time a session is seen, so concurrent
|
|
7
|
+
* sessions across every tool know who is working on what BEFORE any push. This is
|
|
8
|
+
* the cross-tool keystone that closes the dark-session gap: it is spawned
|
|
9
|
+
* fire-and-forget by `convene fetch` on the first prompt of a session, which runs
|
|
10
|
+
* for BOTH Claude Code (UserPromptSubmit hook) and Codex (`fetch --codex-hook`).
|
|
11
|
+
*
|
|
12
|
+
* FAIL-OPEN, exactly like `convene notify-push` (P0-FAILSAFE): any error / missing
|
|
13
|
+
* config / non-bus repo exits 0 silently; a 5s watchdog backstops a hang. It NEVER
|
|
14
|
+
* blocks anything — it is detached from the prompt hot path.
|
|
15
|
+
*
|
|
16
|
+
* IDEMPOTENT on two levels: a local (instance, branch) sentinel spares the
|
|
17
|
+
* redundant post/spawn, and a deterministic server idempotency-key
|
|
18
|
+
* (`announce:<slug>:<instance>:<branch>`) is the authoritative dedupe so a retry
|
|
19
|
+
* or a same-instant double-spawn collapses to one message.
|
|
20
|
+
*
|
|
21
|
+
* PRIVACY: the body carries ONLY the branch name — never the user's prompt text.
|
|
22
|
+
* The bus is cross-member, so prompt text must never land on it automatically.
|
|
23
|
+
*/
|
|
24
|
+
const git_1 = require("../git");
|
|
25
|
+
const config_1 = require("../config");
|
|
26
|
+
const cache_1 = require("../cache");
|
|
27
|
+
const api_1 = require("../api");
|
|
28
|
+
const exit_1 = require("../exit");
|
|
29
|
+
function clip(s, max) {
|
|
30
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
31
|
+
}
|
|
32
|
+
async function run(opts) {
|
|
33
|
+
const top = (0, git_1.gitToplevel)();
|
|
34
|
+
if (!top)
|
|
35
|
+
return; // not a git repo → silent no-op
|
|
36
|
+
const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
|
|
37
|
+
if (!slug)
|
|
38
|
+
return; // repo not on the bus → silent no-op
|
|
39
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
40
|
+
// The instance is minted at SessionStart for Claude Code; for Codex (no
|
|
41
|
+
// SessionStart) ensure one here so the (instance, branch) sentinel is stable.
|
|
42
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
43
|
+
const branch = (0, git_1.currentBranch)(top); // null on a detached HEAD
|
|
44
|
+
const branchKey = branch ?? 'detached';
|
|
45
|
+
// Already announced this (instance, branch) — nothing to do (skip in dry-run so
|
|
46
|
+
// it always prints what it WOULD post).
|
|
47
|
+
if (!opts.dryRun && (0, cache_1.announceAlreadyPosted)(slug, instance, branchKey))
|
|
48
|
+
return;
|
|
49
|
+
const body = clip(branch ? `started session on ${branch}` : 'started session (detached HEAD)', 200);
|
|
50
|
+
if (opts.dryRun) {
|
|
51
|
+
process.stdout.write(body + '\n');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Credentials are only needed for the real post (dry-run works offline).
|
|
55
|
+
if (!cfg.apiKey || !cfg.member)
|
|
56
|
+
return;
|
|
57
|
+
const session = (0, git_1.sessionId)(cfg.member, top);
|
|
58
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
59
|
+
const idem = `announce:${slug}:${instance}:${branchKey}`;
|
|
60
|
+
const res = await api.post(slug, { type: 'status', body }, idem, 4000);
|
|
61
|
+
if (res.ok) {
|
|
62
|
+
// Only mark on success so a transient failure retries on the next prompt.
|
|
63
|
+
(0, cache_1.markAnnounced)(slug, instance, branchKey);
|
|
64
|
+
// Seed the broadcast cursor with the session-start tip so a turn-end `wrap`
|
|
65
|
+
// only fires once HEAD has advanced past it (no spurious wrap of the start tip).
|
|
66
|
+
const head = (0, git_1.revParse)('HEAD', top);
|
|
67
|
+
if (head)
|
|
68
|
+
(0, cache_1.writeLastBroadcastSha)(slug, head);
|
|
69
|
+
if (res.json?.message?.short_id) {
|
|
70
|
+
process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} — ${body}\n`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function announce(opts = {}) {
|
|
75
|
+
// Backstop: force-exit on every path so a keep-alive socket can't linger.
|
|
76
|
+
const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
|
|
77
|
+
watchdog.unref();
|
|
78
|
+
const done = () => {
|
|
79
|
+
clearTimeout(watchdog);
|
|
80
|
+
(0, exit_1.exitClean)(0);
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
await run(opts);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* fail-open: a coordination post must never break a prompt or a boot */
|
|
87
|
+
}
|
|
88
|
+
done();
|
|
89
|
+
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.login = login;
|
|
|
7
7
|
exports.whoami = whoami;
|
|
8
8
|
exports.assessLaneIdentity = assessLaneIdentity;
|
|
9
9
|
exports.assessSettingsIntegrity = assessSettingsIntegrity;
|
|
10
|
+
exports.assessFreshness = assessFreshness;
|
|
10
11
|
exports.doctor = doctor;
|
|
11
12
|
/** login / whoami / doctor. */
|
|
12
13
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
@@ -17,6 +18,8 @@ const api_1 = require("../api");
|
|
|
17
18
|
const config_1 = require("../config");
|
|
18
19
|
const catalog_1 = require("../catalog");
|
|
19
20
|
const manifest_1 = require("../catalog/manifest");
|
|
21
|
+
const binding_1 = require("../binding");
|
|
22
|
+
const version_1 = require("../version");
|
|
20
23
|
const git_1 = require("../git");
|
|
21
24
|
const hook_1 = require("../hook");
|
|
22
25
|
const cache_1 = require("../cache");
|
|
@@ -83,13 +86,14 @@ async function login(opts) {
|
|
|
83
86
|
process.stdout.write(`Config saved to ${config_1.CONFIG_FILE} (0600).\n`);
|
|
84
87
|
}
|
|
85
88
|
async function whoami() {
|
|
86
|
-
const
|
|
89
|
+
const top = (0, git_1.gitToplevel)();
|
|
90
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
91
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top, proj); // report the host commands actually use (the pin, if any)
|
|
87
92
|
if (!cfg.apiKey) {
|
|
88
93
|
process.stdout.write('Not logged in. Run `convene login`.\n');
|
|
89
94
|
return;
|
|
90
95
|
}
|
|
91
|
-
const
|
|
92
|
-
const onBus = !!(0, config_1.loadProjectConfig)(top)?.slug;
|
|
96
|
+
const onBus = !!proj?.slug;
|
|
93
97
|
const session = cfg.member && top ? (0, git_1.sessionId)(cfg.member, top) : cfg.member ? `${cfg.member}/cli` : '(unknown)';
|
|
94
98
|
let serverMember = cfg.member;
|
|
95
99
|
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool);
|
|
@@ -100,7 +104,7 @@ async function whoami() {
|
|
|
100
104
|
process.stdout.write(`base: ${cfg.baseUrl}\n`);
|
|
101
105
|
process.stdout.write(`tool: ${cfg.tool}\n`);
|
|
102
106
|
process.stdout.write(`session: ${session}\n`);
|
|
103
|
-
process.stdout.write(`this repo on the bus: ${onBus ? `yes (${
|
|
107
|
+
process.stdout.write(`this repo on the bus: ${onBus ? `yes (${proj.slug})` : 'no'}\n`);
|
|
104
108
|
process.stdout.write(`server: ${me.ok ? 'reachable' : 'UNREACHABLE'}\n`);
|
|
105
109
|
// The API key is never printed.
|
|
106
110
|
}
|
|
@@ -255,9 +259,122 @@ function assessSettingsIntegrity(top, globalSettingsPath = hook_1.SETTINGS_PATH)
|
|
|
255
259
|
const tail = notes.length ? ` (${notes.join('; ')})` : '';
|
|
256
260
|
return { name, ok: true, detail: `settings JSON valid + marker blocks intact; Convene edits are additive${tail}` };
|
|
257
261
|
}
|
|
262
|
+
/** Cache TTL for the latest-published CLI version probe — long (24h), advisory. */
|
|
263
|
+
const CLI_VERSION_TTL_SEC = 24 * 60 * 60;
|
|
264
|
+
/**
|
|
265
|
+
* The latest published `convene-cli` version, 24h-cached and FAIL-SOFT: a cache hit
|
|
266
|
+
* returns instantly; a miss runs a bounded `npm view` and caches the result; any
|
|
267
|
+
* failure (offline / no npm / timeout) returns null so the cli-version arm is simply
|
|
268
|
+
* omitted — doctor never blocks on, or fails because of, this network probe.
|
|
269
|
+
*/
|
|
270
|
+
function latestCliVersion() {
|
|
271
|
+
const cached = (0, cache_1.readCliVersionCheck)(CLI_VERSION_TTL_SEC);
|
|
272
|
+
if (cached)
|
|
273
|
+
return cached;
|
|
274
|
+
try {
|
|
275
|
+
const r = (0, node_child_process_1.spawnSync)('npm', ['view', 'convene-cli', 'version'], { timeout: 4000, encoding: 'utf8' });
|
|
276
|
+
if (r.error || typeof r.status !== 'number' || r.status !== 0)
|
|
277
|
+
return null;
|
|
278
|
+
const v = (r.stdout || '').trim();
|
|
279
|
+
if (!/^\d+\.\d+\.\d+/.test(v))
|
|
280
|
+
return null;
|
|
281
|
+
(0, cache_1.writeCliVersionCheck)(v);
|
|
282
|
+
return v;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Pure freshness/host-pin assessment for `convene doctor` — mirrors
|
|
290
|
+
* assessLaneIdentity/assessSettingsIntegrity (takes inputs, returns Check[], no
|
|
291
|
+
* network/fs inside, unit-testable). Exactly ONE check is HARD:
|
|
292
|
+
* - host-pin (HARD, ok:false ⇒ doctor exits 1): the repo's pinned host diverges
|
|
293
|
+
* from the host the CLI reaches OR from the server's self-identified canonical
|
|
294
|
+
* host. An UNSTAMPED on-bus repo yields a single SOFT nudge instead.
|
|
295
|
+
* Every other arm is SOFT (ok:true, advisory — never flips the exit code):
|
|
296
|
+
* - hook-fp: committed hook wiring drifted from the stamp (names the committed file);
|
|
297
|
+
* - cli-stamp: running CLI older than the CLI that bound the repo;
|
|
298
|
+
* - cli-version: running CLI older than the latest published release.
|
|
299
|
+
* (Catalog freshness stays in reportBestPractices.) Off-bus → no checks.
|
|
300
|
+
*/
|
|
301
|
+
function assessFreshness(inp) {
|
|
302
|
+
const out = [];
|
|
303
|
+
if (!inp.onBus)
|
|
304
|
+
return out;
|
|
305
|
+
if (!inp.binding) {
|
|
306
|
+
out.push({
|
|
307
|
+
name: 'host-pin',
|
|
308
|
+
ok: true,
|
|
309
|
+
detail: 'repo not host-pinned — run `convene update --refresh` to bind it to this bus (records host + CLI + hook state)',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const a = (0, binding_1.assessBinding)(inp.binding, {
|
|
314
|
+
host: inp.resolvedHost,
|
|
315
|
+
cliVersion: inp.runningCliVersion,
|
|
316
|
+
serverHost: inp.serverHost,
|
|
317
|
+
committedHookFingerprint: inp.committedHookFingerprint,
|
|
318
|
+
});
|
|
319
|
+
out.push({
|
|
320
|
+
name: 'host-pin',
|
|
321
|
+
ok: !a.hostMismatch,
|
|
322
|
+
detail: a.hostMismatch
|
|
323
|
+
? `HOST MISMATCH — repo pinned to ${a.host.stamp} but ` +
|
|
324
|
+
(a.host.resolved !== a.host.stamp
|
|
325
|
+
? `resolving to ${a.host.resolved}`
|
|
326
|
+
: `the server self-identifies as ${a.host.serverHost}`) +
|
|
327
|
+
' (re-point deliberately with `convene update --host <url>`, or fix your config)'
|
|
328
|
+
: `pinned to ${a.host.stamp}${a.host.serverHost ? ' — server-confirmed' : ' (server host unverified)'}`,
|
|
329
|
+
});
|
|
330
|
+
// Ambient-vs-pin divergence: a stale CONVENE_BASE_URL / global baseUrl that
|
|
331
|
+
// disagrees with the pin. Pinned commands honor the pin (so this is SOFT, not a
|
|
332
|
+
// mismatch), but the user should know their environment points elsewhere — it is
|
|
333
|
+
// exactly the dev/prod confusion this feature exists to surface, and the
|
|
334
|
+
// authoritative resolvedHost would otherwise hide it.
|
|
335
|
+
if ((0, binding_1.normalizeHost)(inp.ambientHost) !== (0, binding_1.normalizeHost)(inp.binding.host)) {
|
|
336
|
+
out.push({
|
|
337
|
+
name: 'host-env',
|
|
338
|
+
ok: true,
|
|
339
|
+
detail: `your environment resolves to ${(0, binding_1.normalizeHost)(inp.ambientHost)} but this repo is pinned to ${(0, binding_1.normalizeHost)(inp.binding.host)} — the pin wins for convene commands; unset CONVENE_BASE_URL / fix ~/.convene/config.json to avoid confusion`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (a.hook.status === 'drift') {
|
|
343
|
+
out.push({
|
|
344
|
+
name: 'hook-fp',
|
|
345
|
+
ok: true,
|
|
346
|
+
detail: 'committed .claude/settings.json hook wiring changed since the repo was bound — `convene update --refresh` to re-stamp',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (a.cli.status === 'behind') {
|
|
350
|
+
out.push({
|
|
351
|
+
name: 'cli-stamp',
|
|
352
|
+
ok: true,
|
|
353
|
+
detail: `CLI v${a.cli.running} is older than the CLI that bound this repo (v${a.cli.stamp}) — \`npm i -g convene-cli@latest\``,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (inp.latestCliVersion && (0, manifest_1.semverLt)(inp.runningCliVersion, inp.latestCliVersion)) {
|
|
358
|
+
out.push({
|
|
359
|
+
name: 'cli-version',
|
|
360
|
+
ok: true,
|
|
361
|
+
detail: `convene-cli v${inp.latestCliVersion} available (running v${inp.runningCliVersion}) — \`npm i -g convene-cli@latest\``,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
258
366
|
async function doctor(opts) {
|
|
259
367
|
const checks = [];
|
|
260
|
-
|
|
368
|
+
// Resolve the git toplevel + committed project config UP FRONT so config
|
|
369
|
+
// resolution can honor a committed host pin (resolveConfigForRepo) — the same
|
|
370
|
+
// host the rest of doctor talks to and reports. `top`/`proj` are reused by the
|
|
371
|
+
// git/project/freshness checks below instead of being re-read.
|
|
372
|
+
const top = (0, git_1.gitToplevel)();
|
|
373
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
374
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
|
|
375
|
+
// /me response, captured by the auth check and REUSED by the freshness host arm
|
|
376
|
+
// (the server-echoed canonical_host) — never a second /me round-trip.
|
|
377
|
+
let meJson = null;
|
|
261
378
|
// 1. binary
|
|
262
379
|
checks.push({ name: 'binary', ok: true, detail: `convene running from ${process.argv[1] || process.execPath}` });
|
|
263
380
|
// 2. config + perms
|
|
@@ -290,6 +407,7 @@ async function doctor(opts) {
|
|
|
290
407
|
if (cfg.apiKey) {
|
|
291
408
|
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey);
|
|
292
409
|
const me = await api.me(6000);
|
|
410
|
+
meJson = me.json;
|
|
293
411
|
checks.push({
|
|
294
412
|
name: 'auth',
|
|
295
413
|
ok: me.ok,
|
|
@@ -300,10 +418,8 @@ async function doctor(opts) {
|
|
|
300
418
|
checks.push({ name: 'auth', ok: false, detail: 'skipped (no key)' });
|
|
301
419
|
}
|
|
302
420
|
// 4. git toplevel
|
|
303
|
-
const top = (0, git_1.gitToplevel)();
|
|
304
421
|
checks.push({ name: 'git', ok: !!top, detail: top ? `worktree: ${(0, git_1.worktreeBasename)(top)}` : 'not in a git repo' });
|
|
305
422
|
// 5. project on bus
|
|
306
|
-
const proj = (0, config_1.loadProjectConfig)(top);
|
|
307
423
|
checks.push({
|
|
308
424
|
name: 'project',
|
|
309
425
|
ok: !!proj?.slug,
|
|
@@ -438,6 +554,28 @@ async function doctor(opts) {
|
|
|
438
554
|
checks.push(assessLaneIdentity(rows, cfg.member ?? null, instance != null));
|
|
439
555
|
}
|
|
440
556
|
}
|
|
557
|
+
// 9. host-pin / freshness. The host arm is HARD (a pin/server divergence flips the
|
|
558
|
+
// exit code via the every() gate below — exactly the dev/prod-confusion guard);
|
|
559
|
+
// cli-version / hook-fp / cli-stamp are SOFT advisories. Inputs are gathered here
|
|
560
|
+
// (reusing the /me already fetched + a 24h-cached `npm view`); the verdict is the
|
|
561
|
+
// pure assessFreshness. doctor NEVER re-stamps/re-points — that is the deliberate
|
|
562
|
+
// `convene update --refresh` / `--host`; --fix touches only config perms + the
|
|
563
|
+
// GLOBAL hook + watch (never the binding).
|
|
564
|
+
if (top) {
|
|
565
|
+
const committedHookFingerprint = (0, hook_1.conveneHookFingerprint)((0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top))));
|
|
566
|
+
for (const c of assessFreshness({
|
|
567
|
+
binding: proj?.binding ?? null,
|
|
568
|
+
onBus: !!proj?.slug,
|
|
569
|
+
resolvedHost: cfg.baseUrl,
|
|
570
|
+
ambientHost: (0, config_1.resolveConfig)().baseUrl, // binding-BLIND, to catch a stale env/global host
|
|
571
|
+
runningCliVersion: (0, version_1.cliVersion)(),
|
|
572
|
+
serverHost: meJson?.canonical_host ?? null,
|
|
573
|
+
committedHookFingerprint,
|
|
574
|
+
latestCliVersion: cfg.apiKey ? latestCliVersion() : null,
|
|
575
|
+
})) {
|
|
576
|
+
checks.push(c);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
441
579
|
for (const c of checks) {
|
|
442
580
|
process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
|
|
443
581
|
}
|
package/dist/commands/catchup.js
CHANGED
|
@@ -65,7 +65,7 @@ async function runCatchup(opts) {
|
|
|
65
65
|
if (!proj?.slug)
|
|
66
66
|
return; // not on the bus → no-op
|
|
67
67
|
const slug = proj.slug;
|
|
68
|
-
const cfg = (0, config_1.
|
|
68
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
|
|
69
69
|
if (!cfg.apiKey || !cfg.member) {
|
|
70
70
|
if (failOpen)
|
|
71
71
|
return;
|