convene-cli 1.0.5 → 1.1.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 +92 -1
- package/dist/cache.js +211 -0
- package/dist/commands/auth.js +149 -0
- package/dist/commands/catchup.js +123 -0
- package/dist/commands/deploy.js +71 -0
- package/dist/commands/fetch.js +71 -4
- package/dist/commands/gate-push.js +331 -0
- package/dist/commands/guard.js +313 -0
- package/dist/commands/init.js +187 -3
- package/dist/commands/lane.js +116 -0
- package/dist/commands/post.js +31 -1
- package/dist/commands/session-start.js +103 -0
- package/dist/commands/watch.js +147 -0
- package/dist/git.js +16 -0
- package/dist/githook.js +48 -10
- package/dist/hook.js +108 -2
- package/dist/index.js +88 -0
- package/dist/protocol.js +104 -22
- package/dist/render.js +176 -1
- package/dist/test-env.js +5 -0
- package/package.json +1 -1
package/dist/commands/post.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resolve = exports.decline = exports.accept = exports.ack = void 0;
|
|
3
|
+
exports.resolve = exports.decline = exports.accept = exports.ack = exports.postInterrupt = exports.postHalt = void 0;
|
|
4
4
|
exports.postStatus = postStatus;
|
|
5
5
|
exports.postQuestion = postQuestion;
|
|
6
6
|
exports.postPropose = postPropose;
|
|
@@ -42,6 +42,36 @@ async function postPropose(opts) {
|
|
|
42
42
|
});
|
|
43
43
|
process.stdout.write(`posted [PROPOSE-PROMPT] ${m.short_id} to ${opts.to}\n`);
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* `convene post halt|interrupt --to <member|session> '<reason>'` — DIE-LOUD
|
|
47
|
+
* (getContext + die on failure). Posts a `halt`/`interrupt` control message with
|
|
48
|
+
* the reason as the (UNTRUSTED, display-only) body. The TARGET is required: a
|
|
49
|
+
* halt MUST be directed (`--to`), never broadcast — an undirected halt is
|
|
50
|
+
* meaningless and the server-side authz keys on the directed member/session.
|
|
51
|
+
*
|
|
52
|
+
* `--to` is interpreted as a MEMBER handle by default; a value containing `/` is
|
|
53
|
+
* treated as a SESSION GLOB (`<member>/<basename>`). The server enforces the owner
|
|
54
|
+
* authz for a cross-member target — this verb does not pre-judge it.
|
|
55
|
+
*/
|
|
56
|
+
async function postHaltLike(type, reason, opts) {
|
|
57
|
+
if (!opts.to)
|
|
58
|
+
(0, ctx_1.die)(`${type} requires --to <member|session> (a halt must be directed, never broadcast)`);
|
|
59
|
+
if (!reason || !reason.trim())
|
|
60
|
+
(0, ctx_1.die)(`${type} requires a <reason>`);
|
|
61
|
+
// A target containing '/' is a session glob; otherwise it's a member handle.
|
|
62
|
+
const isGlob = opts.to.includes('/');
|
|
63
|
+
const m = await send(opts.project ?? '__cwd__', {
|
|
64
|
+
type,
|
|
65
|
+
body: reason,
|
|
66
|
+
...(isGlob ? { to_session_glob: opts.to } : { to: opts.to }),
|
|
67
|
+
});
|
|
68
|
+
const label = type === 'halt' ? 'HALT' : 'INTERRUPT';
|
|
69
|
+
process.stdout.write(`posted [${label}] ${m.short_id} to ${opts.to}\n`);
|
|
70
|
+
}
|
|
71
|
+
const postHalt = (reason, opts) => postHaltLike('halt', reason, opts);
|
|
72
|
+
exports.postHalt = postHalt;
|
|
73
|
+
const postInterrupt = (reason, opts) => postHaltLike('interrupt', reason, opts);
|
|
74
|
+
exports.postInterrupt = postInterrupt;
|
|
45
75
|
async function answer(id, body, opts) {
|
|
46
76
|
const m = await send(opts.project ?? '__cwd__', { type: 'answer', in_reply_to: id, body });
|
|
47
77
|
process.stdout.write(`answered ${id} (${m.short_id})\n`);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sessionStart = sessionStart;
|
|
4
|
+
/**
|
|
5
|
+
* `convene session-start` — the SessionStart hook command (startup|resume|clear).
|
|
6
|
+
*
|
|
7
|
+
* FAIL-OPEN (P0-FAILSAFE), copying the fetch.ts scaffold:
|
|
8
|
+
* - hard watchdog at 6000ms → exit 0 no matter what (SessionStart's own default
|
|
9
|
+
* timeout is 30s, which would stall a boot — we bound it ourselves);
|
|
10
|
+
* - the network GET is bounded at 4000ms;
|
|
11
|
+
* - any error / non-bus repo / DEGRADED emits NOTHING and exits 0.
|
|
12
|
+
*
|
|
13
|
+
* What it does on a fresh, authenticated bus repo:
|
|
14
|
+
* 1. Mints an opaque session-instance UUID into CACHE_DIR/<slug>.instance — the
|
|
15
|
+
* server stamps holder_instance from this (sent as X-Convene-Session-Instance).
|
|
16
|
+
* 2. Fetches + advances the catch-up cursor IN ONE server transaction and
|
|
17
|
+
* emits the <convene-session-open> block (the auto-greeting).
|
|
18
|
+
* 3. Writes a per-boot dedup sentinel keyed by the instance so the first
|
|
19
|
+
* UserPromptSubmit `fetch` of this boot suppresses a duplicate rollup.
|
|
20
|
+
*/
|
|
21
|
+
const node_child_process_1 = require("node:child_process");
|
|
22
|
+
const git_1 = require("../git");
|
|
23
|
+
const config_1 = require("../config");
|
|
24
|
+
const cache_1 = require("../cache");
|
|
25
|
+
const api_1 = require("../api");
|
|
26
|
+
const render_1 = require("../render");
|
|
27
|
+
const catchup_1 = require("./catchup");
|
|
28
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
29
|
+
const WATCHDOG_MS = 6000;
|
|
30
|
+
const MAX_ITEMS = 400;
|
|
31
|
+
// Don't relaunch the watch daemon if one stamped a heartbeat this recently — a
|
|
32
|
+
// fresh resume/clear SessionStart shouldn't pile up duplicate watchers.
|
|
33
|
+
const WATCH_FRESH_SEC = 60;
|
|
34
|
+
/**
|
|
35
|
+
* Launch `convene watch` as a DETACHED background daemon (§4.4): the watch runs
|
|
36
|
+
* for the life of the session surfacing mid-task halts, so it must NOT be a
|
|
37
|
+
* blocking hook entry. Best-effort + fail-open: any error is swallowed; a launch
|
|
38
|
+
* failure never wedges the boot. Skipped if a recent heartbeat shows a watcher is
|
|
39
|
+
* already alive for this slug.
|
|
40
|
+
*/
|
|
41
|
+
function launchWatch(slug) {
|
|
42
|
+
try {
|
|
43
|
+
const age = (0, cache_1.watchHeartbeatAgeSec)(slug);
|
|
44
|
+
if (age !== null && age < WATCH_FRESH_SEC)
|
|
45
|
+
return; // already watching
|
|
46
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'watch'], {
|
|
47
|
+
detached: true,
|
|
48
|
+
stdio: 'ignore',
|
|
49
|
+
});
|
|
50
|
+
child.unref();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* fail-open: a missing watcher only narrows mid-turn halt awareness */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function emit(s) {
|
|
57
|
+
process.stdout.write(s + '\n');
|
|
58
|
+
}
|
|
59
|
+
async function run(opts) {
|
|
60
|
+
const top = (0, git_1.gitToplevel)();
|
|
61
|
+
if (!top)
|
|
62
|
+
return; // not a git repo → silent no-op
|
|
63
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
64
|
+
if (!proj?.slug)
|
|
65
|
+
return; // not on the bus → silent no-op
|
|
66
|
+
const slug = proj.slug;
|
|
67
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
68
|
+
if (!cfg.apiKey || !cfg.member)
|
|
69
|
+
return; // not authenticated → silent (fail-open)
|
|
70
|
+
const member = cfg.member;
|
|
71
|
+
const session = (0, git_1.sessionId)(member, top);
|
|
72
|
+
// Mint a fresh instance for THIS boot (a fresh boot = a fresh instance).
|
|
73
|
+
const instance = (0, cache_1.mintSessionInstance)(slug);
|
|
74
|
+
// Launch the detached watch daemon from the SessionStart path (not a Bash hook).
|
|
75
|
+
launchWatch(slug);
|
|
76
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
77
|
+
const since = opts.since != null ? Number(opts.since) : undefined;
|
|
78
|
+
const res = await api.sessionOpen(slug, { since: Number.isFinite(since) ? since : undefined, advance: true, maxItems: MAX_ITEMS }, FETCH_TIMEOUT_MS);
|
|
79
|
+
// DEGRADED / failure → emit NOTHING (structural suppression). Still record the
|
|
80
|
+
// sentinel so the first fetch doesn't double-surface from its own cache path.
|
|
81
|
+
if (!res.ok || !res.json || res.json.degraded) {
|
|
82
|
+
(0, cache_1.markCatchupSurfaced)(slug, instance);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (opts.json) {
|
|
86
|
+
emit(JSON.stringify(res.json));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
emit((0, render_1.renderSessionOpenBlock)({ slug, member, session, digest: (0, catchup_1.toDigest)(res.json) }));
|
|
90
|
+
}
|
|
91
|
+
(0, cache_1.markCatchupSurfaced)(slug, instance);
|
|
92
|
+
}
|
|
93
|
+
async function sessionStart(opts = {}) {
|
|
94
|
+
const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
|
|
95
|
+
try {
|
|
96
|
+
await run(opts);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* fail-open: SessionStart must never wedge a boot */
|
|
100
|
+
}
|
|
101
|
+
clearTimeout(watchdog);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.watch = watch;
|
|
4
|
+
/**
|
|
5
|
+
* `convene watch` (WP12) — a DETACHED long-poll on the existing /poll stream,
|
|
6
|
+
* FILTERED to halt/interrupt, that surfaces mid-task halts for a heads-down
|
|
7
|
+
* agent BETWEEN turns. SessionStart launches it (the hook WIRING is the WP13
|
|
8
|
+
* capstone — this file is only the verb).
|
|
9
|
+
*
|
|
10
|
+
* POSTURE = FAIL-OPEN, self-healing (P0-FAILSAFE):
|
|
11
|
+
* - It NEVER crashes the session and NEVER blocks anything: it's a background
|
|
12
|
+
* loop that writes a file. It does not die(), does not getContext().
|
|
13
|
+
* - It self-heals via a Last-Event-ID resume cursor: a transport failure /
|
|
14
|
+
* timeout / parse error just sleeps with backoff and retries; the resume seq
|
|
15
|
+
* means a reconnect never re-reads or skips material.
|
|
16
|
+
* - A bounded run (no live config / not on the bus) exits 0 silently so a
|
|
17
|
+
* SessionStart launch on a non-bus repo is a no-op.
|
|
18
|
+
*
|
|
19
|
+
* APPEND-ONLY + HIGH-WATER (awareness/concurrency #5 — the lost-interrupt race):
|
|
20
|
+
* - Each matched halt/interrupt is APPENDED to a per-slug jsonl via
|
|
21
|
+
* appendWatchEntry. The daemon NEVER truncates the log.
|
|
22
|
+
* - Readers (fetch / doctor / the health line) render entries with seq >
|
|
23
|
+
* high-water and advance a MONOTONIC high-water with persistHighWater. The
|
|
24
|
+
* reader, not the writer, owns the cursor, so a read can never race an append
|
|
25
|
+
* into losing an interrupt.
|
|
26
|
+
*
|
|
27
|
+
* TRUST: the daemon writes the message TYPE + server-derived routing/handles +
|
|
28
|
+
* the (UNTRUSTED) body verbatim into the jsonl. The body is NEVER interpreted
|
|
29
|
+
* here — the consumer renders it inert. The block DECISION is the guard's, from
|
|
30
|
+
* lane-state; watch only narrows the window for non-deploy turns.
|
|
31
|
+
*
|
|
32
|
+
* --notify: best-effort desktop ping per surfaced halt (delegates to the notify
|
|
33
|
+
* verb's mechanism if present; otherwise silently skipped). Never blocks the loop.
|
|
34
|
+
*/
|
|
35
|
+
const node_child_process_1 = require("node:child_process");
|
|
36
|
+
const git_1 = require("../git");
|
|
37
|
+
const config_1 = require("../config");
|
|
38
|
+
const cache_1 = require("../cache");
|
|
39
|
+
const api_1 = require("../api");
|
|
40
|
+
const POLL_WAIT_SEC = 25; // server holds up to ~25s; capped at 50 server-side
|
|
41
|
+
const POLL_TIMEOUT_MS = POLL_WAIT_SEC * 1000 + 5000; // MUST exceed wait*1000
|
|
42
|
+
const BACKOFF_BASE_MS = 1000;
|
|
43
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
44
|
+
const HALT_TYPES = new Set(['halt', 'interrupt']);
|
|
45
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
/** Map a server feed/poll message to an inert WatchEntry. Returns null for non-halts. */
|
|
47
|
+
function toEntry(m) {
|
|
48
|
+
if (!m || typeof m !== 'object')
|
|
49
|
+
return null;
|
|
50
|
+
const type = typeof m.type === 'string' ? m.type : '';
|
|
51
|
+
if (!HALT_TYPES.has(type))
|
|
52
|
+
return null;
|
|
53
|
+
// seq is the messages.id (enrich exposes it as `seq`, falling back to numeric id).
|
|
54
|
+
const seq = typeof m.seq === 'number' ? m.seq : typeof m.id === 'number' ? m.id : Number(m.id);
|
|
55
|
+
if (!Number.isFinite(seq))
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
seq,
|
|
59
|
+
type,
|
|
60
|
+
short_id: typeof m.short_id === 'string' ? m.short_id : null,
|
|
61
|
+
// from/to/body are DISPLAY/UNTRUSTED — copied verbatim, never interpreted here.
|
|
62
|
+
from: typeof m.from_handle === 'string' ? m.from_handle : typeof m.from === 'string' ? m.from : null,
|
|
63
|
+
to: typeof m.to === 'string' ? m.to : typeof m.to_member === 'string' ? m.to_member : null,
|
|
64
|
+
body: typeof m.body === 'string' ? m.body : null,
|
|
65
|
+
at: typeof m.created_at === 'string' ? m.created_at : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Best-effort desktop notification; never blocks or throws. */
|
|
69
|
+
function notifyBestEffort(entry) {
|
|
70
|
+
try {
|
|
71
|
+
if (process.platform === 'darwin') {
|
|
72
|
+
// A halt is a control signal; the body is UNTRUSTED so we do NOT splice it
|
|
73
|
+
// into the notification — a fixed template only.
|
|
74
|
+
(0, node_child_process_1.spawnSync)('osascript', ['-e', 'display notification "An active halt is directed at this session." with title "Convene: halt"'], {
|
|
75
|
+
timeout: 1500,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* best-effort */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function loop(opts) {
|
|
84
|
+
const top = (0, git_1.gitToplevel)();
|
|
85
|
+
if (!top)
|
|
86
|
+
return 0; // not a git repo → no-op
|
|
87
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
88
|
+
const slug = opts.project || proj?.slug || null;
|
|
89
|
+
if (!slug)
|
|
90
|
+
return 0; // not on the bus → no-op
|
|
91
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
92
|
+
const member = cfg.member;
|
|
93
|
+
const session = member ? (0, git_1.sessionId)(member, top) : null;
|
|
94
|
+
if (!cfg.apiKey || !session)
|
|
95
|
+
return 0; // can't authenticate → silent no-op
|
|
96
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
97
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
98
|
+
// Resume cursor: start from the persisted high-water so a relaunch never
|
|
99
|
+
// re-reads material the reader already drained. (The reader's high-water is the
|
|
100
|
+
// canonical "already surfaced" mark; the daemon resumes from there.)
|
|
101
|
+
let cursor = (0, cache_1.readWatchHighWater)(slug);
|
|
102
|
+
let backoff = BACKOFF_BASE_MS;
|
|
103
|
+
let iterations = 0;
|
|
104
|
+
const limit = typeof opts.maxIterations === 'number' ? opts.maxIterations : Infinity;
|
|
105
|
+
// Heartbeat up-front so a just-launched watch reads as healthy immediately.
|
|
106
|
+
(0, cache_1.touchWatchHeartbeat)(slug);
|
|
107
|
+
while (iterations < limit) {
|
|
108
|
+
const res = await api.poll(slug, { since: cursor, wait: POLL_WAIT_SEC }, POLL_TIMEOUT_MS).catch(() => null);
|
|
109
|
+
// Every loop iteration stamps liveness — even an empty/failed poll proves the
|
|
110
|
+
// daemon is alive (the health line distinguishes "down" from "quiet").
|
|
111
|
+
(0, cache_1.touchWatchHeartbeat)(slug);
|
|
112
|
+
if (!res || !res.ok || !res.json) {
|
|
113
|
+
// Transport failure / timeout / parse error → self-heal with backoff.
|
|
114
|
+
await sleep(backoff);
|
|
115
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
backoff = BACKOFF_BASE_MS; // recovered
|
|
119
|
+
const msgs = Array.isArray(res.json.messages) ? res.json.messages : [];
|
|
120
|
+
for (const m of msgs) {
|
|
121
|
+
const entry = toEntry(m);
|
|
122
|
+
if (!entry)
|
|
123
|
+
continue;
|
|
124
|
+
(0, cache_1.appendWatchEntry)(slug, entry);
|
|
125
|
+
if (opts.notify)
|
|
126
|
+
notifyBestEffort(entry);
|
|
127
|
+
}
|
|
128
|
+
// Advance the resume cursor to the server's reported cursor (monotonic). This
|
|
129
|
+
// is the long-poll resume seq, NOT the reader's high-water — the daemon must
|
|
130
|
+
// move past EVERY message it saw (incl. non-halts) or it would re-fetch them
|
|
131
|
+
// forever. The reader's high-water only advances over rendered halts.
|
|
132
|
+
if (typeof res.json.cursor === 'number' && res.json.cursor > cursor)
|
|
133
|
+
cursor = res.json.cursor;
|
|
134
|
+
iterations++;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
async function watch(opts = {}) {
|
|
139
|
+
let code = 0;
|
|
140
|
+
try {
|
|
141
|
+
code = await loop(opts);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
code = 0; // fail-open: a background watcher never crashes the session
|
|
145
|
+
}
|
|
146
|
+
process.exit(code);
|
|
147
|
+
}
|
package/dist/git.js
CHANGED
|
@@ -18,6 +18,7 @@ exports.currentBranch = currentBranch;
|
|
|
18
18
|
exports.revListCount = revListCount;
|
|
19
19
|
exports.revParse = revParse;
|
|
20
20
|
exports.isAncestor = isAncestor;
|
|
21
|
+
exports.gitFetch = gitFetch;
|
|
21
22
|
exports.gitHooksDir = gitHooksDir;
|
|
22
23
|
exports.gitConfigSetLocal = gitConfigSetLocal;
|
|
23
24
|
exports.gitPathIsIgnored = gitPathIsIgnored;
|
|
@@ -173,6 +174,21 @@ function isAncestor(ancestor, descendant, cwd = process.cwd()) {
|
|
|
173
174
|
return false;
|
|
174
175
|
}
|
|
175
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Bounded `git fetch <remote> <ref>` for the deploy compat gate. The gate needs
|
|
179
|
+
* the real remote tip before checking ancestry so "behind" reflects origin, not
|
|
180
|
+
* a stale tracking ref. Returns true iff the fetch succeeded; a timeout/error
|
|
181
|
+
* returns false so the caller fails open rather than blocking on a slow network.
|
|
182
|
+
*/
|
|
183
|
+
function gitFetch(ref, remote = 'origin', cwd = process.cwd()) {
|
|
184
|
+
try {
|
|
185
|
+
const r = (0, node_child_process_1.spawnSync)('git', ['fetch', '--quiet', remote, ref], { cwd, timeout: 2500 });
|
|
186
|
+
return r.status === 0;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
176
192
|
/** Absolute path to this repo's hooks directory (resolves worktrees/submodules). */
|
|
177
193
|
function gitHooksDir(cwd = process.cwd()) {
|
|
178
194
|
const p = git(['rev-parse', '--git-path', 'hooks'], cwd);
|
package/dist/githook.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.GITHOOK_MARKER = exports.GITHOOKS_DIR = void 0;
|
|
6
|
+
exports.GITHOOK_MARKER_V1 = exports.GITHOOK_MARKER = exports.GITHOOKS_DIR = void 0;
|
|
7
7
|
exports.prePushScript = prePushScript;
|
|
8
8
|
exports.installGitHooks = installGitHooks;
|
|
9
9
|
/**
|
|
@@ -22,18 +22,54 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
22
22
|
const node_path_1 = __importDefault(require("node:path"));
|
|
23
23
|
const git_1 = require("./git");
|
|
24
24
|
exports.GITHOOKS_DIR = '.githooks';
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/** Current managed marker. Bumped v1→v2 for the opt-in blocking mode (WP13). */
|
|
26
|
+
exports.GITHOOK_MARKER = 'convene:githook v2';
|
|
27
|
+
/** Prior marker — a hook carrying it is still OURS (upgradeable in place). */
|
|
28
|
+
exports.GITHOOK_MARKER_V1 = 'convene:githook v1';
|
|
29
|
+
/** Markers that identify a pre-push hook as one WE authored (any version). */
|
|
30
|
+
function isOurHook(content) {
|
|
31
|
+
return content.includes(exports.GITHOOK_MARKER) || content.includes(exports.GITHOOK_MARKER_V1);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The committed pre-push script. Deterministic (P0-IDEMPOTENT) — a pure function
|
|
35
|
+
* of its inputs, so re-running init is byte-identical.
|
|
36
|
+
*
|
|
37
|
+
* DEFAULT mode is FAIL-OPEN (exit 0): it only posts a [STATUS] via `notify-push`
|
|
38
|
+
* and never blocks a push — preserving P0-FAILSAFE for every clone.
|
|
39
|
+
*
|
|
40
|
+
* OPT-IN BLOCKING mode (per-clone, never committed-on by default) is enabled by
|
|
41
|
+
* the local git config `convene.blockingPush=true` (or env CONVENE_BLOCKING_PUSH=1).
|
|
42
|
+
* When on, the hook calls `convene gate-push` and propagates its exit code, so a
|
|
43
|
+
* confirmed LANE_HELD / behind-HEAD can ABORT the push (exit 1) — the one
|
|
44
|
+
* cross-tool enforcement point (Codex/Cursor/human). It still fails OPEN on a
|
|
45
|
+
* bus-unreachable error: `gate-push` is itself fail-open-loud, so an unreachable
|
|
46
|
+
* bus yields exit 0 and the push proceeds.
|
|
47
|
+
*/
|
|
27
48
|
function prePushScript() {
|
|
28
49
|
return ([
|
|
29
50
|
'#!/usr/bin/env sh',
|
|
30
|
-
`# ${exports.GITHOOK_MARKER} —
|
|
31
|
-
'#
|
|
32
|
-
|
|
33
|
-
'# (or
|
|
34
|
-
'
|
|
35
|
-
'
|
|
51
|
+
`# ${exports.GITHOOK_MARKER} — Convene pre-push hook.`,
|
|
52
|
+
'# DEFAULT: fail-OPEN — post a [STATUS] and never block the push.',
|
|
53
|
+
'# OPT-IN blocking gate (per clone, off by default — preserves the fail-open default):',
|
|
54
|
+
'# git config --local convene.blockingPush true (or env CONVENE_BLOCKING_PUSH=1)',
|
|
55
|
+
'# enables a deploy-lane gate that can ABORT the push (exit 1) on a confirmed',
|
|
56
|
+
'# conflict. It still fails open if the bus is unreachable.',
|
|
57
|
+
'# Disable entirely with: git config --unset core.hooksPath (or delete this file).',
|
|
58
|
+
'if ! command -v convene >/dev/null 2>&1; then',
|
|
59
|
+
' exit 0',
|
|
60
|
+
'fi',
|
|
61
|
+
'# Always post the after-push status (fail-open).',
|
|
62
|
+
'convene notify-push "$@" || true',
|
|
63
|
+
'# Opt-in blocking gate.',
|
|
64
|
+
'blocking="${CONVENE_BLOCKING_PUSH:-}"',
|
|
65
|
+
'if [ -z "$blocking" ]; then',
|
|
66
|
+
' blocking=$(git config --bool --get convene.blockingPush 2>/dev/null || true)',
|
|
36
67
|
'fi',
|
|
68
|
+
'case "$blocking" in',
|
|
69
|
+
' 1|true|yes|on)',
|
|
70
|
+
' convene gate-push --stdin || exit $?',
|
|
71
|
+
' ;;',
|
|
72
|
+
'esac',
|
|
37
73
|
'exit 0',
|
|
38
74
|
].join('\n') + '\n');
|
|
39
75
|
}
|
|
@@ -86,7 +122,9 @@ function installGitHooks(top) {
|
|
|
86
122
|
const desired = prePushScript();
|
|
87
123
|
const current = node_fs_1.default.existsSync(hookFile) ? node_fs_1.default.readFileSync(hookFile, 'utf8') : null;
|
|
88
124
|
// A pre-push we didn't author lives here — back away rather than overwrite it.
|
|
89
|
-
|
|
125
|
+
// A v1-marked hook IS ours (upgradeable in place to v2); a foreign/populated
|
|
126
|
+
// hook with neither marker is still refused.
|
|
127
|
+
if (current !== null && !isOurHook(current)) {
|
|
90
128
|
return { status: 'skipped-foreign', hooksPath: exports.GITHOOKS_DIR };
|
|
91
129
|
}
|
|
92
130
|
let fileResult;
|
package/dist/hook.js
CHANGED
|
@@ -4,11 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HOOK_COMMAND = exports.SETTINGS_PATH = void 0;
|
|
7
|
+
exports.binarySupportsVerb = binarySupportsVerb;
|
|
7
8
|
exports.readSettingsRaw = readSettingsRaw;
|
|
8
9
|
exports.parseSettings = parseSettings;
|
|
9
10
|
exports.hookIsRegistered = hookIsRegistered;
|
|
10
11
|
exports.withHook = withHook;
|
|
11
12
|
exports.serializeSettings = serializeSettings;
|
|
13
|
+
exports.genericHookIsRegistered = genericHookIsRegistered;
|
|
14
|
+
exports.withGenericHook = withGenericHook;
|
|
15
|
+
exports.ensureHook = ensureHook;
|
|
12
16
|
exports.ensureHookRegistered = ensureHookRegistered;
|
|
13
17
|
exports.projectSettingsPath = projectSettingsPath;
|
|
14
18
|
exports.ensureProjectHookRegistered = ensureProjectHookRegistered;
|
|
@@ -22,9 +26,46 @@ exports.ensureProjectHookRegistered = ensureProjectHookRegistered;
|
|
|
22
26
|
*/
|
|
23
27
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
24
28
|
const node_path_1 = __importDefault(require("node:path"));
|
|
29
|
+
const node_child_process_1 = require("node:child_process");
|
|
25
30
|
const config_1 = require("./config");
|
|
26
31
|
exports.SETTINGS_PATH = node_path_1.default.join((0, config_1.homeBase)(), '.claude', 'settings.json');
|
|
27
32
|
exports.HOOK_COMMAND = 'convene fetch';
|
|
33
|
+
/**
|
|
34
|
+
* Does the installed `convene` binary support a given verb? init uses this to skip
|
|
35
|
+
* wiring a hook whose binary can't service it — so a STALE npm-linked / globally
|
|
36
|
+
* installed `convene` (predating WP13's verbs) doesn't error on every boot
|
|
37
|
+
* (catchup/cross-tool #2). We probe `convene <verb> --help`: Commander exits 0 for
|
|
38
|
+
* a known command and non-zero (with "unknown command") for an unknown one.
|
|
39
|
+
*
|
|
40
|
+
* Hermetic-test override: `CONVENE_INIT_VERBS` short-circuits the probe. Set it to
|
|
41
|
+
* a comma-list of supported verbs (or `*` for "all supported", `-` / empty for
|
|
42
|
+
* "none supported") so init.test.ts never shells out to a real binary.
|
|
43
|
+
*/
|
|
44
|
+
function binarySupportsVerb(verb) {
|
|
45
|
+
const override = process.env.CONVENE_INIT_VERBS;
|
|
46
|
+
if (override !== undefined) {
|
|
47
|
+
if (override === '*')
|
|
48
|
+
return true;
|
|
49
|
+
const set = new Set(override
|
|
50
|
+
.split(',')
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter(Boolean));
|
|
53
|
+
return set.has(verb);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const r = (0, node_child_process_1.spawnSync)('convene', [verb, '--help'], { timeout: 4000, encoding: 'utf8' });
|
|
57
|
+
if (r.error || typeof r.status !== 'number')
|
|
58
|
+
return false;
|
|
59
|
+
if (r.status !== 0)
|
|
60
|
+
return false;
|
|
61
|
+
// Defense in depth: Commander prints "unknown command" to stderr on a miss.
|
|
62
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
|
63
|
+
return !/unknown command/i.test(out);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
28
69
|
function readSettingsRaw(p = exports.SETTINGS_PATH) {
|
|
29
70
|
try {
|
|
30
71
|
return node_fs_1.default.readFileSync(p, 'utf8');
|
|
@@ -64,6 +105,70 @@ function withHook(settings) {
|
|
|
64
105
|
function serializeSettings(settings) {
|
|
65
106
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
66
107
|
}
|
|
108
|
+
// ── Generic hook registration (WP2) ──────────────────────────────────────────
|
|
109
|
+
// The existing withHook/ensureHookRegistered above are UserPromptSubmit-specific
|
|
110
|
+
// and stay INTACT (init.ts still uses them). This generalizes registration to
|
|
111
|
+
// ANY Claude Code hook event (SessionStart, PreToolUse, PostToolUse, …) with an
|
|
112
|
+
// optional matcher. WIRING new events into init.ts is deferred to WP13 — these
|
|
113
|
+
// are the helpers WP13 will call.
|
|
114
|
+
/**
|
|
115
|
+
* Is a hook with this command AND matcher already registered for this event?
|
|
116
|
+
* Matching on command (substring) AND matcher AND event prevents both
|
|
117
|
+
* double-registration and clobbering an unrelated hook on the same event.
|
|
118
|
+
*/
|
|
119
|
+
function genericHookIsRegistered(settings, eventName, command, matcher) {
|
|
120
|
+
const groups = settings?.hooks?.[eventName];
|
|
121
|
+
if (!Array.isArray(groups))
|
|
122
|
+
return false;
|
|
123
|
+
return groups.some((g) =>
|
|
124
|
+
// A group with no matcher matches a query with no matcher; otherwise exact.
|
|
125
|
+
(matcher === undefined ? g?.matcher === undefined : g?.matcher === matcher) &&
|
|
126
|
+
Array.isArray(g?.hooks) &&
|
|
127
|
+
g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(command)));
|
|
128
|
+
}
|
|
129
|
+
/** Return a new settings object with a generic hook ensured (deep-clone, idempotent). */
|
|
130
|
+
function withGenericHook(settings, eventName, command, matcher) {
|
|
131
|
+
const next = settings ? JSON.parse(JSON.stringify(settings)) : {};
|
|
132
|
+
next.hooks = next.hooks || {};
|
|
133
|
+
if (!Array.isArray(next.hooks[eventName]))
|
|
134
|
+
next.hooks[eventName] = [];
|
|
135
|
+
if (!genericHookIsRegistered(next, eventName, command, matcher)) {
|
|
136
|
+
const group = { hooks: [{ type: 'command', command }] };
|
|
137
|
+
if (matcher !== undefined)
|
|
138
|
+
group.matcher = matcher;
|
|
139
|
+
next.hooks[eventName].push(group);
|
|
140
|
+
}
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Ensure a generic hook (any event + optional matcher) in a settings file,
|
|
145
|
+
* idempotent + merge-safe. parseSettings → null (unparseable) returns 'manual'
|
|
146
|
+
* WITHOUT clobbering. Defaults to the global ~/.claude/settings.json; pass
|
|
147
|
+
* `settingsPath` for the repo-committed file.
|
|
148
|
+
*
|
|
149
|
+
* `backup` controls the .bak: ON (default) for the GLOBAL settings (it lives
|
|
150
|
+
* outside any repo, so a .bak is the only recovery). OFF for a repo-committed file
|
|
151
|
+
* — git history IS the backup, and a sibling .bak would litter the working tree
|
|
152
|
+
* (P0-IDEMPOTENT: no .bak inside the repo).
|
|
153
|
+
*/
|
|
154
|
+
function ensureHook(eventName, command, matcher, settingsPath = exports.SETTINGS_PATH, backup = settingsPath === exports.SETTINGS_PATH) {
|
|
155
|
+
const raw = readSettingsRaw(settingsPath);
|
|
156
|
+
const settings = parseSettings(raw);
|
|
157
|
+
if (settings === null)
|
|
158
|
+
return 'manual'; // unparseable — do NOT clobber
|
|
159
|
+
if (genericHookIsRegistered(settings, eventName, command, matcher))
|
|
160
|
+
return 'already';
|
|
161
|
+
try {
|
|
162
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(settingsPath), { recursive: true });
|
|
163
|
+
if (backup && raw !== null)
|
|
164
|
+
node_fs_1.default.writeFileSync(settingsPath + '.bak', raw);
|
|
165
|
+
node_fs_1.default.writeFileSync(settingsPath, serializeSettings(withGenericHook(settings, eventName, command, matcher)));
|
|
166
|
+
return 'registered';
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return 'manual';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
67
172
|
/** Ensure the UserPromptSubmit hook is registered (idempotent, backs up). */
|
|
68
173
|
function ensureHookRegistered() {
|
|
69
174
|
const raw = readSettingsRaw();
|
|
@@ -104,8 +209,9 @@ function ensureProjectHookRegistered(toplevel) {
|
|
|
104
209
|
return 'already';
|
|
105
210
|
try {
|
|
106
211
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(p), { recursive: true });
|
|
107
|
-
|
|
108
|
-
|
|
212
|
+
// NO .bak here: this is a repo-committed file — git history IS the backup, and a
|
|
213
|
+
// sibling .bak would litter the working tree (P0-IDEMPOTENT: no .bak inside the repo).
|
|
214
|
+
// Mirrors the generalized ensureHook's backup=(settingsPath===SETTINGS_PATH) discipline.
|
|
109
215
|
node_fs_1.default.writeFileSync(p, serializeSettings(withHook(settings)));
|
|
110
216
|
return 'registered';
|
|
111
217
|
}
|