convene-cli 1.9.0 → 1.11.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 +12 -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/fetch.js +43 -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 +43 -6
- package/dist/protocol.js +3 -2
- package/dist/render.js +11 -0
- package/dist/version.js +24 -0
- package/package.json +1 -1
package/dist/commands/update.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.update = update;
|
|
4
7
|
exports.runUpdate = runUpdate;
|
|
@@ -24,6 +27,8 @@ exports.runUpdate = runUpdate;
|
|
|
24
27
|
* Fail-soft throughout: offline → bundled catalog; a malformed manifest or any
|
|
25
28
|
* unexpected error prints a clear note and exits non-fatally for the dry run.
|
|
26
29
|
*/
|
|
30
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
31
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
27
32
|
const config_1 = require("../config");
|
|
28
33
|
const git_1 = require("../git");
|
|
29
34
|
const api_1 = require("../api");
|
|
@@ -31,6 +36,10 @@ const catalog_1 = require("../catalog");
|
|
|
31
36
|
const report_1 = require("../catalog/report");
|
|
32
37
|
const manifest_1 = require("../catalog/manifest");
|
|
33
38
|
const materialize_1 = require("../catalog/materialize");
|
|
39
|
+
const init_1 = require("./init");
|
|
40
|
+
const hook_1 = require("../hook");
|
|
41
|
+
const binding_1 = require("../binding");
|
|
42
|
+
const version_1 = require("../version");
|
|
34
43
|
const ctx_1 = require("../ctx");
|
|
35
44
|
const log = (m) => process.stdout.write(m + '\n');
|
|
36
45
|
/** A practice has an actual bump available to take (patch/minor/major). */
|
|
@@ -69,18 +78,198 @@ async function update(opts = {}) {
|
|
|
69
78
|
const top = (0, git_1.gitToplevel)();
|
|
70
79
|
if (!top)
|
|
71
80
|
(0, ctx_1.die)('not a git repository — run `convene update` inside a repo');
|
|
81
|
+
// Host-pin / freshness binding paths (`--refresh` / `--host`) are INDEPENDENT of
|
|
82
|
+
// the best-practices catalog — they re-render the managed surfaces + (re)write the
|
|
83
|
+
// schema-3 binding stamp, and must work even on a repo that adopted no practices.
|
|
84
|
+
// Route them before the manifest gate; the default + `--apply` keep the existing
|
|
85
|
+
// catalog-update flow unchanged.
|
|
86
|
+
if (opts.refresh || opts.host) {
|
|
87
|
+
return runBindingRefresh(top, opts);
|
|
88
|
+
}
|
|
72
89
|
const manifest = (0, config_1.loadManifest)(top);
|
|
73
90
|
if (!manifest) {
|
|
74
91
|
log('· no best practices adopted — nothing to update. Run `convene init` to choose some.');
|
|
75
92
|
return;
|
|
76
93
|
}
|
|
77
94
|
// Live catalog (fail-soft → bundled). Build an authed client only if we have a
|
|
78
|
-
// key; loadCatalog itself falls back on any failure, so this never blocks.
|
|
79
|
-
|
|
95
|
+
// key; loadCatalog itself falls back on any failure, so this never blocks. Honor a
|
|
96
|
+
// committed host pin so the catalog fetch + adoption report hit the bound bus.
|
|
97
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top);
|
|
80
98
|
const api = cfg.apiKey && cfg.member ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, (0, git_1.sessionId)(cfg.member, top), cfg.tool) : null;
|
|
81
99
|
const { catalog, source } = await (0, catalog_1.loadCatalog)(api);
|
|
82
100
|
await runUpdate(top, manifest, catalog, source, opts);
|
|
83
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* `convene update --refresh` / `--host <url>` — the deliberate host-pin / freshness
|
|
104
|
+
* actions. Re-render the managed surfaces (CLAUDE.md/AGENTS.md coordination blocks,
|
|
105
|
+
* cross-agent rules, all four MCP carriers) at the AUTHORITATIVE host, re-wire the
|
|
106
|
+
* `convene fetch` hook (committed + global), recompute the committed-hook
|
|
107
|
+
* fingerprint, and WRITE the schema-3 binding stamp. This is the ONLY path that
|
|
108
|
+
* stamps a binding — `init` never does (it would break the byte-identical schema-1
|
|
109
|
+
* onboarding), and `doctor` never does (it is read-only w.r.t. the binding).
|
|
110
|
+
*
|
|
111
|
+
* Rails (same discipline as init/refresh-docs): `--check` is a dry run that writes
|
|
112
|
+
* NOTHING; without `--commit` the changes land in the working tree only (never
|
|
113
|
+
* `git add -A`); with `--commit` exactly the CONVENE_PATHS land as one isolated
|
|
114
|
+
* commit. `--host` additionally VERIFIES the target via GET /me before stamping
|
|
115
|
+
* (must-fix A: a typed host that the server does not self-identify as is refused),
|
|
116
|
+
* and moves the global config baseUrl so the CLI and the carriers cannot drift.
|
|
117
|
+
*/
|
|
118
|
+
/** The four committable MCP carriers a re-point must keep in sync (see init.ts). */
|
|
119
|
+
const MCP_CARRIERS = ['.cursor/mcp.json', '.vscode/mcp.json', '.gemini/settings.json', '.codex/config.toml'];
|
|
120
|
+
/**
|
|
121
|
+
* After a refresh/re-point, warn about any MCP carrier whose embedded
|
|
122
|
+
* CONVENE_BASE_URL no longer matches the bound host — an unparseable carrier that
|
|
123
|
+
* was silently skipped, or a carrier deliberately not touched under --no-mcp.
|
|
124
|
+
* Advisory only (never throws / never fails the command); a carrier with no convene
|
|
125
|
+
* entry, or absent entirely, is not flagged. Matches the JSON (`"CONVENE_BASE_URL":
|
|
126
|
+
* "…"`) and TOML (`CONVENE_BASE_URL = "…"`) forms with one regex.
|
|
127
|
+
*/
|
|
128
|
+
function warnStaleCarriers(top, host) {
|
|
129
|
+
const want = (0, binding_1.normalizeHost)(host);
|
|
130
|
+
const stale = [];
|
|
131
|
+
for (const rel of MCP_CARRIERS) {
|
|
132
|
+
let body;
|
|
133
|
+
try {
|
|
134
|
+
body = node_fs_1.default.readFileSync(node_path_1.default.join(top, rel), 'utf8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
continue; // carrier absent — nothing to keep in sync
|
|
138
|
+
}
|
|
139
|
+
const m = body.match(/CONVENE_BASE_URL"?\s*[:=]\s*"([^"]+)"/);
|
|
140
|
+
if (!m)
|
|
141
|
+
continue; // present but carries no convene server entry
|
|
142
|
+
if ((0, binding_1.normalizeHost)(m[1]) !== want)
|
|
143
|
+
stale.push(`${rel} → ${m[1]}`);
|
|
144
|
+
}
|
|
145
|
+
if (stale.length) {
|
|
146
|
+
log(`⚠ ${stale.length} MCP carrier(s) still point elsewhere — re-run \`convene update --refresh\` ` +
|
|
147
|
+
`(without --no-mcp) to fix: ${stale.join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function runBindingRefresh(top, opts) {
|
|
151
|
+
const existing = (0, config_1.loadProjectConfig)(top);
|
|
152
|
+
if (!existing?.slug) {
|
|
153
|
+
(0, ctx_1.die)('this repo is not on Convene yet — run `convene setup` first; `convene update --refresh` only re-stamps an already-onboarded repo.');
|
|
154
|
+
}
|
|
155
|
+
const slug = existing.slug;
|
|
156
|
+
const repoCfg = (0, config_1.resolveConfigForRepo)(top, existing);
|
|
157
|
+
const member = repoCfg.member;
|
|
158
|
+
if (!member)
|
|
159
|
+
(0, ctx_1.die)('not configured — run `convene login` first (a binding records who stamped it).');
|
|
160
|
+
// ── Resolve the target host + (best-effort / required) server confirmation ──
|
|
161
|
+
let host;
|
|
162
|
+
let serverVerified = false;
|
|
163
|
+
let serverHost = null;
|
|
164
|
+
let deploymentId;
|
|
165
|
+
if (opts.host) {
|
|
166
|
+
// --host re-point: REFUSE to stamp unless the target self-identifies as itself.
|
|
167
|
+
host = (0, binding_1.normalizeHost)(opts.host);
|
|
168
|
+
const api = new api_1.ConveneApi(host, repoCfg.apiKey, (0, git_1.sessionId)(member, top), repoCfg.tool);
|
|
169
|
+
const me = await api.me(8000);
|
|
170
|
+
if (!me.ok || !me.json) {
|
|
171
|
+
(0, ctx_1.die)(`cannot reach ${host} to verify it (${me.status}: ${me.error ?? 'unreachable'}) — refusing to re-point.`);
|
|
172
|
+
}
|
|
173
|
+
serverHost = me.json.canonical_host ? (0, binding_1.normalizeHost)(me.json.canonical_host) : null;
|
|
174
|
+
deploymentId = me.json.deployment_id;
|
|
175
|
+
if (serverHost) {
|
|
176
|
+
if (serverHost !== host) {
|
|
177
|
+
(0, ctx_1.die)(`refusing to pin: ${host} self-identifies as canonical host ${serverHost}, not ${host} ` +
|
|
178
|
+
`(it may be a front-door/redirect). Pin to ${serverHost} instead, or check the URL.`);
|
|
179
|
+
}
|
|
180
|
+
serverVerified = true;
|
|
181
|
+
}
|
|
182
|
+
else if (!opts.yes) {
|
|
183
|
+
(0, ctx_1.die)(`the server at ${host} does not report a canonical host (older server) — cannot verify it is the bus you intend.\n` +
|
|
184
|
+
` server says: member=${me.json.member} org_id=${me.json.org_id} kind=${me.json.kind}\n` +
|
|
185
|
+
` re-run with --yes to pin anyway.`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
log(`⚠ ${host} did not self-identify (older server); pinning anyway per --yes ` +
|
|
189
|
+
`(member=${me.json.member} org_id=${me.json.org_id} kind=${me.json.kind}).`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// --refresh: re-stamp the host the repo already resolves to (pin if pinned, else
|
|
194
|
+
// ambient). Server confirmation is best-effort + NON-fatal here.
|
|
195
|
+
host = (0, binding_1.normalizeHost)(repoCfg.baseUrl);
|
|
196
|
+
if (repoCfg.apiKey) {
|
|
197
|
+
const api = new api_1.ConveneApi(host, repoCfg.apiKey, (0, git_1.sessionId)(member, top), repoCfg.tool);
|
|
198
|
+
const me = await api.me(8000);
|
|
199
|
+
if (me.ok && me.json) {
|
|
200
|
+
serverHost = me.json.canonical_host ? (0, binding_1.normalizeHost)(me.json.canonical_host) : null;
|
|
201
|
+
deploymentId = me.json.deployment_id;
|
|
202
|
+
if (serverHost && serverHost === host)
|
|
203
|
+
serverVerified = true;
|
|
204
|
+
else if (serverHost && serverHost !== host) {
|
|
205
|
+
log(`⚠ the server at ${host} self-identifies as ${serverHost} — stamping ${host} (what this repo talks to); \`convene doctor\` will flag the divergence.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const skipAgentRules = opts.noAgentRules === true || opts.agentRules === false;
|
|
211
|
+
const skipMcp = opts.noMcp === true || opts.mcp === false;
|
|
212
|
+
if (opts.check) {
|
|
213
|
+
log(`Dry run — \`convene update ${opts.host ? `--host ${host}` : '--refresh'}\` would:`);
|
|
214
|
+
log(` • re-render CLAUDE.md + AGENTS.md coordination blocks at ${host}`);
|
|
215
|
+
if (!skipAgentRules)
|
|
216
|
+
log(' • re-render the cross-agent rule files');
|
|
217
|
+
if (!skipMcp)
|
|
218
|
+
log(` • re-render all MCP carriers with CONVENE_BASE_URL=${host}`);
|
|
219
|
+
log(' • ensure the committed + global `convene fetch` hook');
|
|
220
|
+
if (opts.host)
|
|
221
|
+
log(` • update ~/.convene/config.json baseUrl → ${host}`);
|
|
222
|
+
log(` • write the .convene/project.json binding stamp (schema 3${serverVerified ? ', server-confirmed' : ''})`);
|
|
223
|
+
log(opts.commit ? ' • commit exactly the convene files as one isolated commit' : ' • leave changes in the working tree (no commit without --commit)');
|
|
224
|
+
log('');
|
|
225
|
+
log('Nothing written (--check). Re-run without --check to apply.');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
log(`${opts.host ? 'Re-pointing' : 'Refreshing'} Convene binding for "${slug}" at ${host}…`);
|
|
229
|
+
(0, init_1.writeCoordinationBlocks)(top, slug, member, host);
|
|
230
|
+
if (!skipAgentRules)
|
|
231
|
+
(0, init_1.writeAgentRules)(top, slug, member, host);
|
|
232
|
+
if (!skipMcp)
|
|
233
|
+
(0, init_1.writeMcpConfigs)(top, host);
|
|
234
|
+
// Re-wire the `convene fetch` hook in BOTH the committed repo settings (in
|
|
235
|
+
// CONVENE_PATHS — its fingerprint is stamped) and the per-machine global settings
|
|
236
|
+
// (advisory; never committed). Both idempotent.
|
|
237
|
+
(0, hook_1.ensureProjectHookRegistered)(top);
|
|
238
|
+
(0, hook_1.ensureHook)('UserPromptSubmit', hook_1.HOOK_COMMAND);
|
|
239
|
+
// Recompute the committed-hook fingerprint AFTER wiring so the stamp matches disk.
|
|
240
|
+
const hookFingerprint = (0, hook_1.conveneHookFingerprint)((0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top))));
|
|
241
|
+
const binding = {
|
|
242
|
+
host,
|
|
243
|
+
cliVersion: (0, version_1.cliVersion)(),
|
|
244
|
+
hookFingerprint,
|
|
245
|
+
stampedAt: new Date().toISOString(),
|
|
246
|
+
stampedBy: member,
|
|
247
|
+
serverVerified,
|
|
248
|
+
...(existing.bestPractices?.catalogVersion ? { catalogVersion: existing.bestPractices.catalogVersion } : {}),
|
|
249
|
+
...(deploymentId && deploymentId !== 'unknown' ? { deploymentId } : {}),
|
|
250
|
+
};
|
|
251
|
+
(0, config_1.writeBinding)(top, binding);
|
|
252
|
+
// A --host re-point moves the global config baseUrl too, or the CLI (which reads
|
|
253
|
+
// file.baseUrl) and the carriers diverge. Do it LAST — after the stamp + carriers
|
|
254
|
+
// have all been written — so a mid-sequence failure never leaves the global config
|
|
255
|
+
// pointed at <new> with the repo still UNPINNED (stamp-then-move keeps the pin the
|
|
256
|
+
// single authority).
|
|
257
|
+
if (opts.host)
|
|
258
|
+
(0, config_1.saveFileConfig)({ baseUrl: host });
|
|
259
|
+
log(`✓ .convene/project.json binding stamped — host ${host}, CLI v${binding.cliVersion}${serverVerified ? ', server-confirmed' : ''}.`);
|
|
260
|
+
// Carrier consistency: warn (do NOT fail) about any MCP carrier still pointing
|
|
261
|
+
// elsewhere — covers a carrier skipped because its JSON was unparseable, and the
|
|
262
|
+
// --no-mcp case where carriers were deliberately not touched. No freshness check
|
|
263
|
+
// reads a carrier's host, so without this a stale carrier would be silent.
|
|
264
|
+
warnStaleCarriers(top, host);
|
|
265
|
+
if (opts.commit) {
|
|
266
|
+
(0, init_1.commitConveneFiles)(top, opts.host ? `Re-point Convene to ${host}` : 'Refresh Convene binding to current template', opts.host ? 'the re-point' : 'the binding refresh');
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
log('');
|
|
270
|
+
log('Changes are in your working tree only. Review with `git diff` and commit yourself (or re-run with `--commit`).');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
84
273
|
/**
|
|
85
274
|
* Core of `convene update`, with the resolved (top, manifest, catalog) already in
|
|
86
275
|
* hand — the seam tests drive with a synthetic catalog (no fs/network resolution).
|
package/dist/commands/watch.js
CHANGED
|
@@ -125,7 +125,7 @@ async function loop(opts) {
|
|
|
125
125
|
const slug = opts.project || proj?.slug || null;
|
|
126
126
|
if (!slug)
|
|
127
127
|
return 0; // not on the bus → no-op
|
|
128
|
-
const cfg = (0, config_1.
|
|
128
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
|
|
129
129
|
const member = cfg.member;
|
|
130
130
|
const session = member ? (0, git_1.sessionId)(member, top) : null;
|
|
131
131
|
if (!cfg.apiKey || !session)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.wrap = wrap;
|
|
4
|
+
/**
|
|
5
|
+
* `convene wrap` — the end-of-session wrap. Wired as a Claude Code `Stop` hook, so
|
|
6
|
+
* it fires at every turn-end; it stays QUIET unless the session has landed NEW
|
|
7
|
+
* committed work the bus hasn't already heard about, so a session that finishes a
|
|
8
|
+
* piece of work broadcasts a one-line summary without the agent having to remember.
|
|
9
|
+
*
|
|
10
|
+
* FAIL-OPEN like `convene notify-push` (P0-FAILSAFE): any error / missing config /
|
|
11
|
+
* non-bus repo exits 0; a 5s watchdog backstops a hang. It NEVER blocks turn-end
|
|
12
|
+
* (always exits 0) — the auto-post is a backstop, never a gate (the agent is never
|
|
13
|
+
* wedged; that was the user's chosen posture).
|
|
14
|
+
*
|
|
15
|
+
* NOISE CONTROL (the whole point of a per-turn hook): the last-broadcast-sha cursor
|
|
16
|
+
* (shared with `announce` + `notify-push`) means wrap posts ONLY when HEAD has
|
|
17
|
+
* advanced past the last tip the bus heard about — so:
|
|
18
|
+
* - an idle turn with no new commits → silent (HEAD == cursor);
|
|
19
|
+
* - a session-start turn before any work → silent (cursor seeded by `announce`);
|
|
20
|
+
* - a commit the pre-push hook already announced → silent (push moved the cursor);
|
|
21
|
+
* - a genuine new commit since the bus last heard → ONE wrap, then advance.
|
|
22
|
+
* Uncommitted-only changes are intentionally NOT wrapped: a turn-end with only a
|
|
23
|
+
* dirty tree usually means mid-task, and auto-claiming "wrapped" there would be both
|
|
24
|
+
* noisy and frequently wrong.
|
|
25
|
+
*/
|
|
26
|
+
const git_1 = require("../git");
|
|
27
|
+
const config_1 = require("../config");
|
|
28
|
+
const cache_1 = require("../cache");
|
|
29
|
+
const api_1 = require("../api");
|
|
30
|
+
const exit_1 = require("../exit");
|
|
31
|
+
function clip(s, max) {
|
|
32
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
33
|
+
}
|
|
34
|
+
async function run(opts) {
|
|
35
|
+
const top = (0, git_1.gitToplevel)();
|
|
36
|
+
if (!top)
|
|
37
|
+
return; // not a git repo → silent no-op
|
|
38
|
+
const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
|
|
39
|
+
if (!slug)
|
|
40
|
+
return; // repo not on the bus → silent no-op
|
|
41
|
+
const head = (0, git_1.revParse)('HEAD', top);
|
|
42
|
+
if (!head)
|
|
43
|
+
return; // no commits at all → nothing to wrap
|
|
44
|
+
const last = (0, cache_1.readLastBroadcastSha)(slug);
|
|
45
|
+
if (head === last)
|
|
46
|
+
return; // the bus already knows this tip — stay quiet
|
|
47
|
+
if (!last) {
|
|
48
|
+
// No baseline yet (announce hasn't landed, or this is a brand-new cursor).
|
|
49
|
+
// Establish it SILENTLY at the current tip — never post a spurious "wrapped"
|
|
50
|
+
// for the commit the session merely started on.
|
|
51
|
+
(0, cache_1.writeLastBroadcastSha)(slug, head);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// last is set AND HEAD has moved past it → genuine new committed work.
|
|
55
|
+
const branch = (0, git_1.currentBranch)(top) ?? 'HEAD';
|
|
56
|
+
const subject = (0, git_1.commitSubject)(head, top) || '(no commit message)';
|
|
57
|
+
const short = (0, git_1.shortSha)(head, top) || head.slice(0, 7);
|
|
58
|
+
const n = (0, git_1.revListCount)(`${last}..${head}`, top);
|
|
59
|
+
const body = clip(n && n > 0
|
|
60
|
+
? `wrapped ${branch}: ${n} commit${n === 1 ? '' : 's'} since session start · ${subject} (${short})`
|
|
61
|
+
: `wrapped ${branch}: now at ${subject} (${short})`, 200);
|
|
62
|
+
if (opts.dryRun) {
|
|
63
|
+
process.stdout.write(body + '\n');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
67
|
+
if (!cfg.apiKey || !cfg.member)
|
|
68
|
+
return;
|
|
69
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
70
|
+
const session = (0, git_1.sessionId)(cfg.member, top);
|
|
71
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
72
|
+
const idem = `wrap:${slug}:${instance}:${head}`;
|
|
73
|
+
const res = await api.post(slug, { type: 'status', body }, idem, 4000);
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
(0, cache_1.writeLastBroadcastSha)(slug, head); // advance the cursor so we never re-wrap this tip
|
|
76
|
+
if (res.json?.message?.short_id) {
|
|
77
|
+
process.stdout.write(`convene: posted [STATUS] ${res.json.message.short_id} — ${body}\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function wrap(opts = {}) {
|
|
82
|
+
const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
|
|
83
|
+
watchdog.unref();
|
|
84
|
+
const done = () => {
|
|
85
|
+
clearTimeout(watchdog);
|
|
86
|
+
(0, exit_1.exitClean)(0);
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
await run(opts);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* fail-open: a Stop hook must never wedge turn-end */
|
|
93
|
+
}
|
|
94
|
+
done();
|
|
95
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -16,6 +16,8 @@ exports.saveFileConfig = saveFileConfig;
|
|
|
16
16
|
exports.writeProjectConfig = writeProjectConfig;
|
|
17
17
|
exports.loadManifest = loadManifest;
|
|
18
18
|
exports.writeManifest = writeManifest;
|
|
19
|
+
exports.writeBinding = writeBinding;
|
|
20
|
+
exports.resolveConfigForRepo = resolveConfigForRepo;
|
|
19
21
|
/**
|
|
20
22
|
* Config resolution with precedence:
|
|
21
23
|
* env (CONVENE_API_KEY, CONVENE_BASE_URL, CONVENE_MEMBER, ...)
|
|
@@ -26,6 +28,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
26
28
|
const node_os_1 = __importDefault(require("node:os"));
|
|
27
29
|
const node_path_1 = __importDefault(require("node:path"));
|
|
28
30
|
const brand_1 = require("./brand");
|
|
31
|
+
const binding_1 = require("./binding");
|
|
29
32
|
/** Home base, overridable for hermetic tests via CONVENE_HOME_OVERRIDE. */
|
|
30
33
|
function homeBase() {
|
|
31
34
|
return process.env.CONVENE_HOME_OVERRIDE || node_os_1.default.homedir();
|
|
@@ -128,9 +131,12 @@ function writeProjectConfig(toplevel, cfg) {
|
|
|
128
131
|
const dir = node_path_1.default.join(toplevel, '.convene');
|
|
129
132
|
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
130
133
|
const file = node_path_1.default.join(dir, 'project.json');
|
|
131
|
-
// schema
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
+
// schema 3 once a host-pin binding is present; else schema 2 once a best-
|
|
135
|
+
// practices manifest is present; else schema 1 (byte-identical to pre-catalog
|
|
136
|
+
// output). The ladder is additive: a binding-less / manifest-less file is
|
|
137
|
+
// unchanged, and older CLIs read a higher schema fine (plain JSON.parse, no
|
|
138
|
+
// schema validation — see loadProjectConfig).
|
|
139
|
+
const schema = cfg.binding ? 3 : cfg.bestPractices ? 2 : 1;
|
|
134
140
|
node_fs_1.default.writeFileSync(file, JSON.stringify({ schema, ...cfg }, null, 2) + '\n');
|
|
135
141
|
return file;
|
|
136
142
|
}
|
|
@@ -147,3 +153,47 @@ function writeManifest(toplevel, manifest) {
|
|
|
147
153
|
const { schema: _schema, ...rest } = existing;
|
|
148
154
|
return writeProjectConfig(toplevel, { ...rest, bestPractices: manifest });
|
|
149
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Merge a host-pin binding stamp into the repo's project.json, preserving
|
|
158
|
+
* slug/displayName/joinToken/bestPractices and bumping it to schema 3. Strips the
|
|
159
|
+
* stale on-disk `schema` first so writeProjectConfig re-derives it (the same
|
|
160
|
+
* round-trip-safe pattern as writeManifest). Written only by the deliberate
|
|
161
|
+
* `convene update --refresh` / `--host` paths.
|
|
162
|
+
*/
|
|
163
|
+
function writeBinding(toplevel, binding) {
|
|
164
|
+
const existing = loadProjectConfig(toplevel) ?? {};
|
|
165
|
+
const { schema: _schema, ...rest } = existing;
|
|
166
|
+
return writeProjectConfig(toplevel, { ...rest, binding });
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Test-escape: when CONVENE_IGNORE_BINDING is truthy, resolveConfigForRepo behaves
|
|
170
|
+
* exactly like resolveConfig (binding-blind). Set by the unit-test bootstrap so the
|
|
171
|
+
* suite — and a later-stamped `convene` repo whose project.json sits at the test
|
|
172
|
+
* cwd — never has a committed pin silently override the env/file the tests rely on.
|
|
173
|
+
* Power users (and a future e2e host-pin scenario) opt in explicitly.
|
|
174
|
+
*/
|
|
175
|
+
function bindingIgnored() {
|
|
176
|
+
const v = process.env.CONVENE_IGNORE_BINDING;
|
|
177
|
+
return v != null && v !== '' && v !== '0' && !/^(false|no|off)$/i.test(v);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Repo-aware config resolution: when the repo carries a committed `binding.host`
|
|
181
|
+
* pin, that host is AUTHORITATIVE (pin > env > global > default) — the whole point
|
|
182
|
+
* of host-pinning is that ambient config (a stale CONVENE_BASE_URL or global
|
|
183
|
+
* baseUrl) can never silently re-point a bound repo at the wrong bus. An unpinned
|
|
184
|
+
* repo (or the CONVENE_IGNORE_BINDING escape) is byte-identical to resolveConfig().
|
|
185
|
+
*
|
|
186
|
+
* `proj` may be passed by callers that already loaded project.json (the fetch /
|
|
187
|
+
* session-start hot paths) to avoid a second disk read. resolveConfig() itself is
|
|
188
|
+
* left untouched — only the callers that should honor the pin migrate to this.
|
|
189
|
+
*/
|
|
190
|
+
function resolveConfigForRepo(toplevel, proj) {
|
|
191
|
+
const base = resolveConfig();
|
|
192
|
+
if (bindingIgnored())
|
|
193
|
+
return base;
|
|
194
|
+
const p = proj !== undefined ? proj : loadProjectConfig(toplevel);
|
|
195
|
+
const pin = p?.binding?.host;
|
|
196
|
+
if (!pin)
|
|
197
|
+
return base;
|
|
198
|
+
return { ...base, baseUrl: (0, binding_1.normalizeHost)(pin) };
|
|
199
|
+
}
|
package/dist/ctx.js
CHANGED
|
@@ -13,7 +13,13 @@ function die(msg) {
|
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
15
|
function getContext(opts = {}) {
|
|
16
|
-
|
|
16
|
+
// Resolve repo-aware so a committed host pin governs the BUS-MUTATING commands
|
|
17
|
+
// too (post/answer/ack/resolve/inbox/lane/deploy all flow through here) — not
|
|
18
|
+
// just the diagnostic paths. The pin is authoritative over ambient config, so a
|
|
19
|
+
// stale CONVENE_BASE_URL can never silently re-point a bound repo's writes/lanes.
|
|
20
|
+
const top = (0, git_1.gitToplevel)();
|
|
21
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
22
|
+
const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
|
|
17
23
|
if (cfg.insecurePerms) {
|
|
18
24
|
process.stderr.write(`convene: WARNING ${cfg.configFile} is world/group-readable; run: chmod 600 ${cfg.configFile}\n`);
|
|
19
25
|
}
|
|
@@ -21,8 +27,7 @@ function getContext(opts = {}) {
|
|
|
21
27
|
die('not logged in — run `convene login`');
|
|
22
28
|
if (!cfg.member)
|
|
23
29
|
die('no member configured — run `convene login --member <handle>`');
|
|
24
|
-
const
|
|
25
|
-
const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
|
|
30
|
+
const slug = opts.project || proj?.slug || null;
|
|
26
31
|
const session = top ? (0, git_1.sessionId)(cfg.member, top) : `${cfg.member}/cli`;
|
|
27
32
|
return {
|
|
28
33
|
api: new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool),
|
package/dist/hook.js
CHANGED
|
@@ -15,6 +15,7 @@ exports.withGenericHook = withGenericHook;
|
|
|
15
15
|
exports.ensureHook = ensureHook;
|
|
16
16
|
exports.ensureHookRegistered = ensureHookRegistered;
|
|
17
17
|
exports.isConveneHookCommand = isConveneHookCommand;
|
|
18
|
+
exports.conveneHookFingerprint = conveneHookFingerprint;
|
|
18
19
|
exports.withoutConveneHooks = withoutConveneHooks;
|
|
19
20
|
exports.settingsIsEmpty = settingsIsEmpty;
|
|
20
21
|
exports.projectSettingsPath = projectSettingsPath;
|
|
@@ -29,6 +30,7 @@ exports.ensureProjectHookRegistered = ensureProjectHookRegistered;
|
|
|
29
30
|
*/
|
|
30
31
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
31
32
|
const node_path_1 = __importDefault(require("node:path"));
|
|
33
|
+
const node_crypto_1 = require("node:crypto");
|
|
32
34
|
const node_child_process_1 = require("node:child_process");
|
|
33
35
|
const config_1 = require("./config");
|
|
34
36
|
exports.SETTINGS_PATH = node_path_1.default.join((0, config_1.homeBase)(), '.claude', 'settings.json');
|
|
@@ -201,6 +203,41 @@ function ensureHookRegistered() {
|
|
|
201
203
|
function isConveneHookCommand(command) {
|
|
202
204
|
return typeof command === 'string' && /^convene(\s|$)/.test(command.trim());
|
|
203
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* A stable, content-addressed fingerprint of the convene-authored hook wiring in a
|
|
208
|
+
* Claude Code settings object — every convene hook command across every event,
|
|
209
|
+
* normalized (`event:command`), sorted, and hashed. Empty → 'none'.
|
|
210
|
+
*
|
|
211
|
+
* Used by the host-pin / freshness binding to detect when hook wiring has DRIFTED
|
|
212
|
+
* from what a repo was last reconciled against. Computed SEPARATELY for the
|
|
213
|
+
* COMMITTED repo `.claude/settings.json` (the value stamped into the binding, in
|
|
214
|
+
* CONVENE_PATHS) and the per-machine GLOBAL `~/.claude/settings.json` (a doctor
|
|
215
|
+
* advisory, never committed) — see must-fix B. Pure; reuses isConveneHookCommand so
|
|
216
|
+
* a foreign hook never perturbs the fingerprint.
|
|
217
|
+
*/
|
|
218
|
+
function conveneHookFingerprint(settings) {
|
|
219
|
+
const cmds = [];
|
|
220
|
+
const hooks = settings?.hooks;
|
|
221
|
+
if (hooks && typeof hooks === 'object') {
|
|
222
|
+
for (const event of Object.keys(hooks)) {
|
|
223
|
+
const groups = hooks[event];
|
|
224
|
+
if (!Array.isArray(groups))
|
|
225
|
+
continue;
|
|
226
|
+
for (const g of groups) {
|
|
227
|
+
if (!Array.isArray(g?.hooks))
|
|
228
|
+
continue;
|
|
229
|
+
for (const h of g.hooks) {
|
|
230
|
+
if (isConveneHookCommand(h?.command))
|
|
231
|
+
cmds.push(`${event}:${String(h.command).trim()}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (cmds.length === 0)
|
|
237
|
+
return 'none';
|
|
238
|
+
cmds.sort();
|
|
239
|
+
return (0, node_crypto_1.createHash)('sha256').update(cmds.join('\n')).digest('hex').slice(0, 12);
|
|
240
|
+
}
|
|
204
241
|
/**
|
|
205
242
|
* Return a new settings object (deep-clone) with every convene-authored hook
|
|
206
243
|
* removed, pruning emptied groups and emptied event arrays (and the `hooks` key
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.program = void 0;
|
|
37
38
|
/** Convene CLI entrypoint. */
|
|
38
39
|
const fs_1 = require("fs");
|
|
39
40
|
const path_1 = require("path");
|
|
@@ -41,6 +42,8 @@ const commander_1 = require("commander");
|
|
|
41
42
|
const brand_1 = require("./brand");
|
|
42
43
|
const fetch_1 = require("./commands/fetch");
|
|
43
44
|
const notify_1 = require("./commands/notify");
|
|
45
|
+
const announce_1 = require("./commands/announce");
|
|
46
|
+
const wrap_1 = require("./commands/wrap");
|
|
44
47
|
const post = __importStar(require("./commands/post"));
|
|
45
48
|
const inbox_1 = require("./commands/inbox");
|
|
46
49
|
const feedback_1 = require("./commands/feedback");
|
|
@@ -48,6 +51,7 @@ const auth_1 = require("./commands/auth");
|
|
|
48
51
|
const init_1 = require("./commands/init");
|
|
49
52
|
const offboard_1 = require("./commands/offboard");
|
|
50
53
|
const join_1 = require("./commands/join");
|
|
54
|
+
const reclaim_1 = require("./commands/reclaim");
|
|
51
55
|
const setup_1 = require("./commands/setup");
|
|
52
56
|
const migrate_1 = require("./commands/migrate");
|
|
53
57
|
const rotate_1 = require("./commands/rotate");
|
|
@@ -67,6 +71,7 @@ const explain_1 = require("./commands/explain");
|
|
|
67
71
|
const practices_1 = require("./commands/practices");
|
|
68
72
|
const update_1 = require("./commands/update");
|
|
69
73
|
const program = new commander_1.Command();
|
|
74
|
+
exports.program = program;
|
|
70
75
|
// Read the version from package.json so `convene --version` always tracks the
|
|
71
76
|
// published version (npm includes package.json in the tarball). dist/index.js
|
|
72
77
|
// sits one level below package.json; in dev (tsx) src/index.ts does too.
|
|
@@ -193,6 +198,18 @@ program
|
|
|
193
198
|
.option('--project <slug>')
|
|
194
199
|
.option('--dry-run', 'print the status it would post; do not post')
|
|
195
200
|
.action((opts) => (0, notify_1.notifyPush)(opts));
|
|
201
|
+
program
|
|
202
|
+
.command('announce')
|
|
203
|
+
.description('front-of-session auto-announce: post a one-line "started session on <branch>" [STATUS] (once per session/branch; fail-silent)')
|
|
204
|
+
.option('--project <slug>')
|
|
205
|
+
.option('--dry-run', 'print the status it would post; do not post')
|
|
206
|
+
.action((opts) => (0, announce_1.announce)(opts));
|
|
207
|
+
program
|
|
208
|
+
.command('wrap')
|
|
209
|
+
.description('Stop hook: post a one-line wrap [STATUS] when the session has landed new committed work (idempotent, fail-silent)')
|
|
210
|
+
.option('--project <slug>')
|
|
211
|
+
.option('--dry-run', 'print the status it would post; do not post')
|
|
212
|
+
.action((opts) => (0, wrap_1.wrap)(opts));
|
|
196
213
|
const postCmd = program.command('post').description('post outbound coordination messages');
|
|
197
214
|
postCmd
|
|
198
215
|
.command('status <body>')
|
|
@@ -289,7 +306,8 @@ program
|
|
|
289
306
|
.option('--practice <id[=level]>', 'best practice to adopt (id or id=level; repeatable)', (v, acc) => (acc.push(v), acc), [])
|
|
290
307
|
.option('--all-practices', 'adopt every catalog best practice at its default level')
|
|
291
308
|
.option('--no-practices', 'adopt no best practices (skip the catalog + interactive picker)')
|
|
292
|
-
.
|
|
309
|
+
.option('--reclaim', "self-recover: re-grant your own membership via the committed token instead of onboarding (use when you're locked out of an already-onboarded repo)")
|
|
310
|
+
.action((opts) => (opts.reclaim ? (0, reclaim_1.reclaim)(opts) : (0, init_1.init)(withPracticeOpts(opts))));
|
|
293
311
|
program
|
|
294
312
|
.command('off-board')
|
|
295
313
|
.alias('offboard')
|
|
@@ -297,6 +315,7 @@ program
|
|
|
297
315
|
.option('--yes', 'confirm non-interactively (required for agents/CI)')
|
|
298
316
|
.option('--remove-global', 'also remove the SHARED per-machine ~/.claude fetch hook (only if this is your last Convene repo)')
|
|
299
317
|
.option('--revoke-token', 'also revoke the committed join token server-side (owner-only)')
|
|
318
|
+
.option('--delete-project', 'also PERMANENTLY delete the project server-side (owner-only hard delete; off-board is local-only without this)')
|
|
300
319
|
.option('--no-commit', 'do not create the isolated off-board commit')
|
|
301
320
|
.option('--dry-run', 'print what would change; touch nothing')
|
|
302
321
|
.action((opts) => (0, offboard_1.offboard)(opts));
|
|
@@ -334,6 +353,15 @@ program
|
|
|
334
353
|
.option('--email <email>', 'your email (defaults to git user.email)')
|
|
335
354
|
.option('--base-url <url>', 'Convene base URL')
|
|
336
355
|
.action((opts) => (0, join_1.join)(opts));
|
|
356
|
+
program
|
|
357
|
+
.command('reclaim')
|
|
358
|
+
.description("self-recovery: re-grant your own membership when locked out of a repo you can still read (restores owner only from a server-recorded prior-owner row)")
|
|
359
|
+
.option('--slug <slug>', 'project slug (defaults to .convene/project.json)')
|
|
360
|
+
.option('--token <token>', 'committed join token (cvj_…); defaults to .convene/project.json or CONVENE_JOIN_TOKEN')
|
|
361
|
+
.option('--handle <handle>', 'your member handle (defaults to your git user.email)')
|
|
362
|
+
.option('--email <email>', 'your email (defaults to git user.email)')
|
|
363
|
+
.option('--base-url <url>', 'Convene base URL')
|
|
364
|
+
.action((opts) => (0, reclaim_1.reclaim)(opts));
|
|
337
365
|
program
|
|
338
366
|
.command('migrate')
|
|
339
367
|
.description('Observability cutover helper (runs init + prints reminders)')
|
|
@@ -345,14 +373,23 @@ program
|
|
|
345
373
|
.action((opts) => (0, migrate_1.migrate)(opts));
|
|
346
374
|
program
|
|
347
375
|
.command('update')
|
|
348
|
-
.description('check for + apply best-practices catalog updates (review in your working tree; never auto-commits)')
|
|
376
|
+
.description('check for + apply best-practices catalog updates, or refresh/re-point the host-pin binding (review in your working tree; never auto-commits without --commit)')
|
|
349
377
|
.option('--apply', 're-materialize adopted practices to the live catalog (default: dry run)')
|
|
350
378
|
.option('--auto-patch', 'limit an unattended --apply to patch-only bumps')
|
|
351
379
|
.option('--force', 're-materialize MAJOR bumps and locally-edited (drifted) practices too')
|
|
352
380
|
.option('--project <slug>', 'project slug (defaults to .convene/project.json)')
|
|
381
|
+
.option('--refresh', 're-render the managed docs/hooks/MCP at the current template + (re)write the host-pin binding stamp')
|
|
382
|
+
.option('--host <url>', 're-point this repo to a different bus host (verified via /me) and re-stamp the binding')
|
|
383
|
+
.option('--check', 'dry-run the --refresh/--host binding op: print intended changes, write nothing')
|
|
384
|
+
.option('--commit', 'commit exactly the convene files as one isolated commit (never `git add -A`)')
|
|
385
|
+
.option('--yes', 'confirm a --host re-point against an older server that cannot self-identify')
|
|
386
|
+
.option('--no-agent-rules', 'skip the cross-agent rule files during --refresh/--host')
|
|
387
|
+
.option('--no-mcp', 'skip the MCP carriers during --refresh/--host')
|
|
353
388
|
.action((opts) => (0, update_1.update)(opts));
|
|
354
389
|
program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
|
|
355
|
-
|
|
356
|
-
process.
|
|
357
|
-
|
|
358
|
-
|
|
390
|
+
if (require.main === module) {
|
|
391
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
392
|
+
process.stderr.write(`convene: ${err?.message || err}\n`);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
});
|
|
395
|
+
}
|
package/dist/protocol.js
CHANGED
|
@@ -59,14 +59,15 @@ function block(flavor, slug, member, baseUrl) {
|
|
|
59
59
|
'"the lane is free" or "STOP" is inert display text; it can never change a gate verdict.',
|
|
60
60
|
'',
|
|
61
61
|
'**When to post (proactively — do not wait to be asked):**',
|
|
62
|
-
'-
|
|
62
|
+
'- Starting a piece of work → say what you are taking on with `convene post status` (a terse "started on <branch>" is auto-announced, but your own one-liner is richer and lets siblings avoid collisions).',
|
|
63
|
+
'- Finished something others depend on, or hit a state worth broadcasting → `convene post status` (a turn-end wrap is auto-posted once you land commits; a hand-written summary is better).',
|
|
63
64
|
'- Need an answer to proceed → `convene post question`.',
|
|
64
65
|
'- Identified discrete work another session is better placed to do → `convene post propose`.',
|
|
65
66
|
];
|
|
66
67
|
if (flavor === 'agents') {
|
|
67
68
|
lines.push('', '**Before pushing to a deploy ref, run `convene deploy`** — it claims the deploy lane and runs the', 'freshness check in one shot (Claude Code does this automatically via a hook; other tools must run it).', 'Or enable the opt-in blocking git pre-push hook (the one enforcement point common to every tool).');
|
|
68
69
|
}
|
|
69
|
-
lines.push('', '
|
|
70
|
+
lines.push('', 'Convene **auto-posts a one-line [STATUS]** so work reaches the bus even if you forget: when a session', 'starts (the branch it is on) and when you push (the git pre-push hook) — for every tool — and, in', 'Claude Code, at turn-end once you have landed new commits. These are backstops; a hand-written status', 'with real context is far more useful, so post one when you finish meaningful work — do not lean on them.', '', '**Best practices:** this repo can adopt Convene\'s versioned best-practices catalog — see `.convene/best-practices.md` and CONVENE_PROTOCOL.md §7b (learn with `convene practices`).', '', 'Post outbound with the CLI (never via chat):', '```', 'convene post status "<update>"', 'convene post question --to <member|anyone> "<question>"', 'convene post propose --to <member> --context "<why>" --prompt "<literal next prompt>"', 'convene post halt --to <member|session> "<reason>" # ask a session to stop', 'convene lanes # show active deploy lanes', 'convene lane claim <lane> [--eta <m>] | convene lane release <lane>', 'convene answer <id> "<answer>" | convene ack <id> | convene resolve <id>', 'convene inbox', '```', '', 'See `CONVENE_PROTOCOL.md` for the full protocol.', brand_1.BRAND.blockEnd);
|
|
70
71
|
return lines.join('\n');
|
|
71
72
|
}
|
|
72
73
|
/** Managed block for **CLAUDE.md** — no manual-deploy line (the PreToolUse hook gates deploys). */
|