convene-cli 1.0.5 → 1.1.1
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 +103 -1
- package/dist/cache.js +260 -1
- package/dist/commands/auth.js +164 -0
- package/dist/commands/catchup.js +125 -0
- package/dist/commands/deploy.js +71 -0
- package/dist/commands/explain.js +59 -0
- package/dist/commands/fetch.js +77 -6
- package/dist/commands/gate-push.js +333 -0
- package/dist/commands/guard.js +315 -0
- package/dist/commands/init.js +193 -4
- package/dist/commands/lane.js +116 -0
- package/dist/commands/notify.js +4 -2
- package/dist/commands/post.js +55 -1
- package/dist/commands/session-start.js +105 -0
- package/dist/commands/setup.js +3 -0
- package/dist/commands/watch.js +147 -0
- package/dist/commands/worktree.js +63 -0
- package/dist/exit.js +49 -0
- package/dist/git.js +63 -2
- package/dist/githook.js +48 -10
- package/dist/hook.js +108 -2
- package/dist/index.js +108 -0
- package/dist/protocol.js +119 -25
- package/dist/render.js +181 -2
- package/dist/test-env.js +5 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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.worktree = worktree;
|
|
7
|
+
/**
|
|
8
|
+
* `convene worktree <branch>` — create an isolated git worktree for a parallel
|
|
9
|
+
* session. This is Convene's recommended default for running several coding agents
|
|
10
|
+
* on one repo at once: a checkout apiece stops them clobbering each other's
|
|
11
|
+
* uncommitted files AND (each worktree has its own basename) gives each a distinct
|
|
12
|
+
* bus identity, so they can see and coordinate with one another instead of
|
|
13
|
+
* collapsing into one session talking to itself.
|
|
14
|
+
*
|
|
15
|
+
* DIE-LOUD like the other interactive verbs (stderr + non-zero exit on failure).
|
|
16
|
+
* Pure git plumbing — does NOT require the repo to be on the Convene bus.
|
|
17
|
+
*/
|
|
18
|
+
const node_child_process_1 = require("node:child_process");
|
|
19
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
20
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
21
|
+
const git_1 = require("../git");
|
|
22
|
+
const ctx_1 = require("../ctx");
|
|
23
|
+
function refExists(ref, cwd) {
|
|
24
|
+
const r = (0, node_child_process_1.spawnSync)('git', ['rev-parse', '--verify', '--quiet', ref], { cwd, encoding: 'utf8' });
|
|
25
|
+
return r.status === 0;
|
|
26
|
+
}
|
|
27
|
+
function worktree(branch, opts = {}) {
|
|
28
|
+
const top = (0, git_1.gitToplevel)();
|
|
29
|
+
if (!top)
|
|
30
|
+
(0, ctx_1.die)('not a git repository — run inside a repo');
|
|
31
|
+
if (!branch || !branch.trim())
|
|
32
|
+
(0, ctx_1.die)('usage: convene worktree <branch> [--from <ref>] [--path <dir>]');
|
|
33
|
+
const base = (0, git_1.worktreeBasename)(top);
|
|
34
|
+
const safeBranch = branch.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'wt';
|
|
35
|
+
const dest = node_path_1.default.resolve(opts.path || node_path_1.default.join(node_path_1.default.dirname(top), `${base}-${safeBranch}`));
|
|
36
|
+
if (node_fs_1.default.existsSync(dest))
|
|
37
|
+
(0, ctx_1.die)(`destination already exists: ${dest}`);
|
|
38
|
+
const localExists = refExists(`refs/heads/${branch}`, top);
|
|
39
|
+
const remoteExists = !localExists && refExists(`refs/remotes/origin/${branch}`, top);
|
|
40
|
+
// Existing local branch → check it out; existing remote-only branch → create a
|
|
41
|
+
// local tracking branch; otherwise → new branch from --from (or HEAD).
|
|
42
|
+
const args = localExists
|
|
43
|
+
? ['worktree', 'add', dest, branch]
|
|
44
|
+
: remoteExists
|
|
45
|
+
? ['worktree', 'add', '-b', branch, dest, `origin/${branch}`]
|
|
46
|
+
: ['worktree', 'add', '-b', branch, dest, opts.from || 'HEAD'];
|
|
47
|
+
const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: top, stdio: 'inherit' });
|
|
48
|
+
if (r.status !== 0)
|
|
49
|
+
(0, ctx_1.die)(`git worktree add failed (exit ${r.status ?? '?'})`);
|
|
50
|
+
const branchNote = localExists ? '' : remoteExists ? ` (new, tracking origin/${branch})` : ' (new)';
|
|
51
|
+
process.stdout.write([
|
|
52
|
+
``,
|
|
53
|
+
`✓ worktree ready: ${dest}`,
|
|
54
|
+
` branch: ${branch}${branchNote}`,
|
|
55
|
+
``,
|
|
56
|
+
`Start a FRESH agent session inside it so it gets its own Convene identity:`,
|
|
57
|
+
` cd ${dest}`,
|
|
58
|
+
` # install deps for this package if needed, then launch your agent (e.g. \`claude\`)`,
|
|
59
|
+
``,
|
|
60
|
+
`Remove it when done: git worktree remove ${dest}`,
|
|
61
|
+
``,
|
|
62
|
+
].join('\n'));
|
|
63
|
+
}
|
package/dist/exit.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Windows-safe process exit for the hook commands.
|
|
4
|
+
*
|
|
5
|
+
* Every Convene hook (fetch, guard, gate-push, session-start, notify-push,
|
|
6
|
+
* catchup) writes its payload to stdout and then exits. stdout, when the binary
|
|
7
|
+
* runs as a hook, is a PIPE captured by the host tool (Claude Code / Codex), and
|
|
8
|
+
* a pipe write on Node is async/buffered. Calling `process.exit()` while that
|
|
9
|
+
* write is still in flight tears the event loop down mid-write — on Windows that
|
|
10
|
+
* aborts the process with a libuv assertion (`UV_HANDLE_CLOSING`, win/async.c)
|
|
11
|
+
* and a 127 exit code. The visible damage is that the host tool sees a failed
|
|
12
|
+
* hook and SILENTLY DROPS the block we had already printed (reported on
|
|
13
|
+
* convene-cli v1.0.5, Windows).
|
|
14
|
+
*
|
|
15
|
+
* We can't simply fall through to a natural exit: the CLI uses global `fetch`
|
|
16
|
+
* (undici keeps sockets alive for seconds), which would hold the prompt open
|
|
17
|
+
* past the latency budget — that's why the hooks force-exit in the first place.
|
|
18
|
+
* So the fix is to force-exit, but only AFTER stdout has drained.
|
|
19
|
+
*
|
|
20
|
+
* `exitClean(code)` waits for stdout to flush, then exits with `code`, capped by
|
|
21
|
+
* a short unref'd backstop so a stuck stream can never hold the prompt open.
|
|
22
|
+
* Idempotent: the watchdog and the cooperative exit path can both call it.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.exitClean = exitClean;
|
|
26
|
+
/** Cap on how long we wait for stdout to drain before forcing the exit. */
|
|
27
|
+
const FLUSH_CAP_MS = 500;
|
|
28
|
+
let exiting = false;
|
|
29
|
+
function exitClean(code) {
|
|
30
|
+
if (exiting)
|
|
31
|
+
return;
|
|
32
|
+
exiting = true;
|
|
33
|
+
const hardExit = () => process.exit(code);
|
|
34
|
+
try {
|
|
35
|
+
// Reader already gone / stream torn down — nothing left to flush.
|
|
36
|
+
if (process.stdout.writableEnded || process.stdout.destroyed)
|
|
37
|
+
return hardExit();
|
|
38
|
+
// An empty write's callback fires only after all previously buffered data has
|
|
39
|
+
// been handed to the OS (writes drain FIFO), i.e. once the pipe handle is
|
|
40
|
+
// quiescent and exiting no longer races an in-flight async write.
|
|
41
|
+
process.stdout.write('', () => hardExit());
|
|
42
|
+
// Backstop so we never hang on the flush; unref'd so it cannot, by itself,
|
|
43
|
+
// keep the loop alive past a clean drain.
|
|
44
|
+
setTimeout(hardExit, FLUSH_CAP_MS).unref();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
hardExit();
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/git.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.worktreeBasename = worktreeBasename;
|
|
|
8
8
|
exports.originRemote = originRemote;
|
|
9
9
|
exports.parseGitHubRemote = parseGitHubRemote;
|
|
10
10
|
exports.repoIsPublic = repoIsPublic;
|
|
11
|
+
exports.sessionDiscriminator = sessionDiscriminator;
|
|
11
12
|
exports.sessionId = sessionId;
|
|
12
13
|
exports.gitConfigGet = gitConfigGet;
|
|
13
14
|
exports.deriveHandle = deriveHandle;
|
|
@@ -18,6 +19,7 @@ exports.currentBranch = currentBranch;
|
|
|
18
19
|
exports.revListCount = revListCount;
|
|
19
20
|
exports.revParse = revParse;
|
|
20
21
|
exports.isAncestor = isAncestor;
|
|
22
|
+
exports.gitFetch = gitFetch;
|
|
21
23
|
exports.gitHooksDir = gitHooksDir;
|
|
22
24
|
exports.gitConfigSetLocal = gitConfigSetLocal;
|
|
23
25
|
exports.gitPathIsIgnored = gitPathIsIgnored;
|
|
@@ -100,9 +102,53 @@ async function repoIsPublic(cwd = process.cwd()) {
|
|
|
100
102
|
}
|
|
101
103
|
return null; // non-GitHub host and no gh ⇒ unknown
|
|
102
104
|
}
|
|
103
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* A short, stable per-process discriminator that tells apart concurrent sessions
|
|
107
|
+
* sharing ONE checkout (same member, same worktree basename). Without it, two
|
|
108
|
+
* Claude windows `cd`'d into the same repo both resolve to `<member>/<basename>`
|
|
109
|
+
* — the bus sees one identity talking to itself, and each treats the other's
|
|
110
|
+
* posts as its own. The signal:
|
|
111
|
+
*
|
|
112
|
+
* 1. `CONVENE_SESSION_SUFFIX` — an explicit override any tool/terminal can set
|
|
113
|
+
* (sanitized + capped). Lets Codex/plain shells opt a session into a stable
|
|
114
|
+
* distinct identity, and makes the behavior testable.
|
|
115
|
+
* 2. `CLAUDE_CODE_SESSION_ID` — Claude Code exports this to BOTH its hooks and
|
|
116
|
+
* every Bash tool call in the same session, so the derived suffix is
|
|
117
|
+
* identical across the `fetch`/`session-start` hooks AND any manual
|
|
118
|
+
* `convene post`/`lane`/`deploy` call — yet distinct across concurrent
|
|
119
|
+
* sessions. We hash it to a short, opaque, tag-safe token.
|
|
120
|
+
*
|
|
121
|
+
* Absent both (a plain human terminal) → '' → identity stays exactly
|
|
122
|
+
* `<member>/<basename>`, unchanged from before.
|
|
123
|
+
*/
|
|
124
|
+
function sessionDiscriminator() {
|
|
125
|
+
const override = (process.env.CONVENE_SESSION_SUFFIX || '').trim();
|
|
126
|
+
if (override) {
|
|
127
|
+
const clean = override.toLowerCase().replace(/[^a-z0-9-]+/g, '').slice(0, 8);
|
|
128
|
+
if (clean)
|
|
129
|
+
return clean;
|
|
130
|
+
}
|
|
131
|
+
const raw = (process.env.CLAUDE_CODE_SESSION_ID || '').trim();
|
|
132
|
+
if (!raw)
|
|
133
|
+
return '';
|
|
134
|
+
// djb2 → base36, 4 chars: stable for a given session id, collision-safe across
|
|
135
|
+
// the handful of sessions that realistically share one checkout.
|
|
136
|
+
let h = 5381;
|
|
137
|
+
for (let i = 0; i < raw.length; i++)
|
|
138
|
+
h = ((h * 33) ^ raw.charCodeAt(i)) >>> 0;
|
|
139
|
+
return h.toString(36).slice(-4).padStart(4, '0');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Derive the session tag "<member>/<worktree-basename>", with a "#<disc>" suffix
|
|
143
|
+
* when concurrent same-checkout sessions need disambiguating (see
|
|
144
|
+
* `sessionDiscriminator`). `#` is safe everywhere the tag travels: the server
|
|
145
|
+
* splits a session on its FIRST `/` (so member/worktree parsing is unaffected),
|
|
146
|
+
* `<member>/*` globs still match, and it round-trips through storage + display.
|
|
147
|
+
*/
|
|
104
148
|
function sessionId(member, toplevel) {
|
|
105
|
-
|
|
149
|
+
const base = `${member}/${worktreeBasename(toplevel)}`;
|
|
150
|
+
const disc = sessionDiscriminator();
|
|
151
|
+
return disc ? `${base}#${disc}` : base;
|
|
106
152
|
}
|
|
107
153
|
function gitConfigGet(key, cwd = process.cwd()) {
|
|
108
154
|
return git(['config', '--get', key], cwd);
|
|
@@ -173,6 +219,21 @@ function isAncestor(ancestor, descendant, cwd = process.cwd()) {
|
|
|
173
219
|
return false;
|
|
174
220
|
}
|
|
175
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Bounded `git fetch <remote> <ref>` for the deploy compat gate. The gate needs
|
|
224
|
+
* the real remote tip before checking ancestry so "behind" reflects origin, not
|
|
225
|
+
* a stale tracking ref. Returns true iff the fetch succeeded; a timeout/error
|
|
226
|
+
* returns false so the caller fails open rather than blocking on a slow network.
|
|
227
|
+
*/
|
|
228
|
+
function gitFetch(ref, remote = 'origin', cwd = process.cwd()) {
|
|
229
|
+
try {
|
|
230
|
+
const r = (0, node_child_process_1.spawnSync)('git', ['fetch', '--quiet', remote, ref], { cwd, timeout: 2500 });
|
|
231
|
+
return r.status === 0;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
176
237
|
/** Absolute path to this repo's hooks directory (resolves worktrees/submodules). */
|
|
177
238
|
function gitHooksDir(cwd = process.cwd()) {
|
|
178
239
|
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
|
}
|