convene-cli 1.1.1 → 1.3.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/git.js CHANGED
@@ -22,6 +22,11 @@ exports.isAncestor = isAncestor;
22
22
  exports.gitFetch = gitFetch;
23
23
  exports.gitHooksDir = gitHooksDir;
24
24
  exports.gitConfigSetLocal = gitConfigSetLocal;
25
+ exports.gitConfigUnsetLocal = gitConfigUnsetLocal;
26
+ exports.pathIsTracked = pathIsTracked;
27
+ exports.hasStagedChanges = hasStagedChanges;
28
+ exports.gitAddPaths = gitAddPaths;
29
+ exports.gitCommit = gitCommit;
25
30
  exports.gitPathIsIgnored = gitPathIsIgnored;
26
31
  /** Cross-platform git helpers. No shell strings — spawn git directly (P0-XPLAT). */
27
32
  const node_child_process_1 = require("node:child_process");
@@ -251,6 +256,74 @@ function gitConfigSetLocal(key, value, cwd = process.cwd()) {
251
256
  return false;
252
257
  }
253
258
  }
259
+ /**
260
+ * Unset a repo-local git config value. Idempotent: git exits 5 when the key is
261
+ * absent, which we treat as success (the desired end-state is "unset").
262
+ */
263
+ function gitConfigUnsetLocal(key, cwd = process.cwd()) {
264
+ try {
265
+ const r = (0, node_child_process_1.spawnSync)('git', ['config', '--local', '--unset', key], { cwd, timeout: 2500 });
266
+ return r.status === 0 || r.status === 5;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ /** Is `relPath` tracked by git at the current index/HEAD? (a deleted-but-staged path still counts). */
273
+ function pathIsTracked(relPath, cwd = process.cwd()) {
274
+ try {
275
+ const r = (0, node_child_process_1.spawnSync)('git', ['ls-files', '--error-unmatch', '--', relPath], { cwd, timeout: 2500 });
276
+ return r.status === 0;
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ }
282
+ /** True iff there are staged changes in the index (git diff --cached exits 1 when so). */
283
+ function hasStagedChanges(cwd = process.cwd()) {
284
+ try {
285
+ const r = (0, node_child_process_1.spawnSync)('git', ['diff', '--cached', '--quiet'], { cwd, timeout: 2500 });
286
+ return r.status === 1;
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
292
+ /**
293
+ * Stage ONLY the given pathspecs (adds, modifications, AND deletions via -A), never
294
+ * a blanket `git add -A` of the whole tree — so an onboarding/off-board commit can
295
+ * never sweep in unrelated work. Returns true on success.
296
+ */
297
+ function gitAddPaths(paths, cwd = process.cwd()) {
298
+ if (paths.length === 0)
299
+ return true;
300
+ try {
301
+ const r = (0, node_child_process_1.spawnSync)('git', ['add', '-A', '--', ...paths], { cwd, timeout: 5000 });
302
+ return r.status === 0;
303
+ }
304
+ catch {
305
+ return false;
306
+ }
307
+ }
308
+ /**
309
+ * Commit ONLY the given pathspecs (`git commit -m <msg> -- <paths>`), leaving any
310
+ * other staged changes untouched — the isolated-commit guarantee. Returns the short
311
+ * SHA on success. Pass an empty `paths` to commit the current index as-is.
312
+ */
313
+ function gitCommit(message, paths = [], cwd = process.cwd()) {
314
+ try {
315
+ const args = ['commit', '-m', message];
316
+ if (paths.length)
317
+ args.push('--', ...paths);
318
+ const r = (0, node_child_process_1.spawnSync)('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
319
+ if (r.status !== 0)
320
+ return { ok: false };
321
+ return { ok: true, sha: shortSha('HEAD', cwd) ?? undefined };
322
+ }
323
+ catch {
324
+ return { ok: false };
325
+ }
326
+ }
254
327
  /**
255
328
  * True iff `relPath` is ignored by git (any .gitignore / info/exclude / global
256
329
  * core.excludesfile). `check-ignore -q` exits 0 when ignored, 1 when not, 128 on
package/dist/githook.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.GITHOOK_MARKER_V1 = exports.GITHOOK_MARKER = exports.GITHOOKS_DIR = void 0;
7
7
  exports.prePushScript = prePushScript;
8
8
  exports.installGitHooks = installGitHooks;
9
+ exports.uninstallGitHooks = uninstallGitHooks;
9
10
  /**
10
11
  * Committed git pre-push hook installer — the tool-agnostic half of "enforce bus
11
12
  * posting". Unlike the Claude-only `convene fetch` UserPromptSubmit hook (hook.ts),
@@ -152,3 +153,39 @@ function installGitHooks(top) {
152
153
  return { status: 'error', message: err?.message || 'unknown error' };
153
154
  }
154
155
  }
156
+ /**
157
+ * Inverse of installGitHooks (off-board). Removes `.githooks/pre-push` ONLY if it
158
+ * still carries our marker (never clobbers a hook someone else placed there), drops
159
+ * the `.githooks` dir if it empties, and unsets `core.hooksPath` ONLY when it points
160
+ * at our `.githooks` (a foreign hooks path is left exactly as-is). Never throws.
161
+ */
162
+ function uninstallGitHooks(top) {
163
+ try {
164
+ const dir = node_path_1.default.join(top, exports.GITHOOKS_DIR);
165
+ const hookFile = node_path_1.default.join(dir, 'pre-push');
166
+ const hooksPath = (0, git_1.gitConfigGet)('core.hooksPath', top);
167
+ const current = node_fs_1.default.existsSync(hookFile) ? node_fs_1.default.readFileSync(hookFile, 'utf8') : null;
168
+ if (current !== null && !isOurHook(current)) {
169
+ return { status: 'skipped-foreign' };
170
+ }
171
+ let removed = false;
172
+ if (current !== null) {
173
+ node_fs_1.default.rmSync(hookFile, { force: true });
174
+ removed = true;
175
+ try {
176
+ if (node_fs_1.default.readdirSync(dir).length === 0)
177
+ node_fs_1.default.rmdirSync(dir);
178
+ }
179
+ catch {
180
+ /* dir not empty or gone — fine */
181
+ }
182
+ }
183
+ // Only relinquish hook dispatch if WE are the ones pointing it at .githooks.
184
+ if (hooksPath === exports.GITHOOKS_DIR)
185
+ (0, git_1.gitConfigUnsetLocal)('core.hooksPath', top);
186
+ return { status: removed ? 'removed' : 'absent' };
187
+ }
188
+ catch (err) {
189
+ return { status: 'error', message: err?.message || 'unknown error' };
190
+ }
191
+ }
package/dist/hook.js CHANGED
@@ -14,6 +14,9 @@ exports.genericHookIsRegistered = genericHookIsRegistered;
14
14
  exports.withGenericHook = withGenericHook;
15
15
  exports.ensureHook = ensureHook;
16
16
  exports.ensureHookRegistered = ensureHookRegistered;
17
+ exports.isConveneHookCommand = isConveneHookCommand;
18
+ exports.withoutConveneHooks = withoutConveneHooks;
19
+ exports.settingsIsEmpty = settingsIsEmpty;
17
20
  exports.projectSettingsPath = projectSettingsPath;
18
21
  exports.ensureProjectHookRegistered = ensureProjectHookRegistered;
19
22
  /**
@@ -188,6 +191,59 @@ function ensureHookRegistered() {
188
191
  return 'manual';
189
192
  }
190
193
  }
194
+ // ── Hook removal (off-board / rollback) ───────────────────────────────────────
195
+ // The inverse of withHook/withGenericHook. Every hook convene init writes invokes
196
+ // the `convene` binary (`convene fetch`, `convene session-start`, `convene guard`,
197
+ // `convene gate-push …`), so a single predicate identifies all of them — no need to
198
+ // enumerate the WP13 table here. A foreign hook that merely shells out to something
199
+ // else is never matched.
200
+ /** A hook command is convene-authored iff it invokes the `convene` binary. */
201
+ function isConveneHookCommand(command) {
202
+ return typeof command === 'string' && /^convene(\s|$)/.test(command.trim());
203
+ }
204
+ /**
205
+ * Return a new settings object (deep-clone) with every convene-authored hook
206
+ * removed, pruning emptied groups and emptied event arrays (and the `hooks` key
207
+ * itself if it ends up empty). `removed` reports whether anything was stripped.
208
+ * Foreign hooks and all other settings keys are preserved untouched.
209
+ */
210
+ function withoutConveneHooks(settings) {
211
+ const next = settings ? JSON.parse(JSON.stringify(settings)) : {};
212
+ let removed = false;
213
+ const hooks = next.hooks;
214
+ if (hooks && typeof hooks === 'object') {
215
+ for (const event of Object.keys(hooks)) {
216
+ const groups = hooks[event];
217
+ if (!Array.isArray(groups))
218
+ continue;
219
+ const kept = [];
220
+ for (const g of groups) {
221
+ if (g && Array.isArray(g.hooks)) {
222
+ const before = g.hooks.length;
223
+ g.hooks = g.hooks.filter((h) => !isConveneHookCommand(h?.command));
224
+ if (g.hooks.length !== before)
225
+ removed = true;
226
+ if (g.hooks.length > 0)
227
+ kept.push(g); // drop a group whose hooks are all ours
228
+ }
229
+ else {
230
+ kept.push(g);
231
+ }
232
+ }
233
+ if (kept.length > 0)
234
+ hooks[event] = kept;
235
+ else
236
+ delete hooks[event];
237
+ }
238
+ if (Object.keys(hooks).length === 0)
239
+ delete next.hooks;
240
+ }
241
+ return { settings: next, removed };
242
+ }
243
+ /** A settings object with no keys at all — safe to delete the file rather than write `{}`. */
244
+ function settingsIsEmpty(settings) {
245
+ return !settings || Object.keys(settings).length === 0;
246
+ }
191
247
  /** The repo's COMMITTED, shared project settings file (vs settings.local.json, which is personal/gitignored). */
192
248
  function projectSettingsPath(toplevel) {
193
249
  return node_path_1.default.join(toplevel, '.claude', 'settings.json');
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ const post = __importStar(require("./commands/post"));
45
45
  const inbox_1 = require("./commands/inbox");
46
46
  const auth_1 = require("./commands/auth");
47
47
  const init_1 = require("./commands/init");
48
+ const offboard_1 = require("./commands/offboard");
48
49
  const join_1 = require("./commands/join");
49
50
  const setup_1 = require("./commands/setup");
50
51
  const migrate_1 = require("./commands/migrate");
@@ -56,14 +57,27 @@ const lane_1 = require("./commands/lane");
56
57
  const deploy_1 = require("./commands/deploy");
57
58
  const guard_1 = require("./commands/guard");
58
59
  const gate_push_1 = require("./commands/gate-push");
60
+ const practice_guard_1 = require("./commands/practice-guard");
61
+ const override_1 = require("./commands/override");
59
62
  const watch_1 = require("./commands/watch");
60
63
  const explain_1 = require("./commands/explain");
64
+ const update_1 = require("./commands/update");
61
65
  const program = new commander_1.Command();
62
66
  // Read the version from package.json so `convene --version` always tracks the
63
67
  // published version (npm includes package.json in the tarball). dist/index.js
64
68
  // sits one level below package.json; in dev (tsx) src/index.ts does too.
65
69
  const { version } = JSON.parse((0, fs_1.readFileSync)((0, path_1.resolve)(__dirname, '../package.json'), 'utf8'));
66
70
  program.name(brand_1.BRAND.bin).description('Convene — AI development coordination bus').version(version);
71
+ /**
72
+ * Commander maps `--no-practices` to `practices: false`; the catalog selection logic
73
+ * reads `noPractices`. Translate it (leaving every other option untouched) so init /
74
+ * setup see the field they expect.
75
+ */
76
+ function withPracticeOpts(opts) {
77
+ if (opts.practices === false)
78
+ opts.noPractices = true;
79
+ return opts;
80
+ }
67
81
  program
68
82
  .command('login')
69
83
  .description('authenticate and save config (0600)')
@@ -141,6 +155,18 @@ program
141
155
  .option('--dry-run', 'classify + report; do not gate or hit the network for the verdict')
142
156
  .option('--project <slug>')
143
157
  .action((opts) => (0, gate_push_1.gatePush)(opts));
158
+ program
159
+ .command('practice-guard <id>')
160
+ .description('PreToolUse/Stop hook: level-aware best-practice gate (fail-open-loud)')
161
+ .option('--stdin', 'read the PreToolUse JSON payload from stdin')
162
+ .option('--project <slug>')
163
+ .action((id, opts) => (0, practice_guard_1.practiceGuard)(id, opts));
164
+ program
165
+ .command('override <id>')
166
+ .description('grant a short-lived, attributed bypass for a best-practice gate')
167
+ .option('--reason <text>', 'why the gate is being overridden (required; attributed to the bus)')
168
+ .option('--project <slug>')
169
+ .action((id, opts) => (0, override_1.override)(id, opts));
144
170
  program
145
171
  .command('watch')
146
172
  .description('SessionStart-launched detached long-poll for directed halts (fail-open, self-healing)')
@@ -200,7 +226,8 @@ program.command('resolve <id>').description('resolve a question').action((id) =>
200
226
  program
201
227
  .command('inbox')
202
228
  .description('open questions/proposals addressed to you')
203
- .option('--all-projects', 'across all your projects')
229
+ .option('--all-projects', 'across all your projects (deliberate cross-project view)')
230
+ .option('--force', 'allow --all-projects even from a repo that is not on Convene')
204
231
  .option('--project <slug>')
205
232
  .option('--json')
206
233
  .action((opts) => (0, inbox_1.inbox)(opts));
@@ -228,9 +255,24 @@ program
228
255
  .option('--no-agent-rules', 'do not write Cursor/Cline/Gemini/Aider rule files (Claude/Codex via AGENTS.md still work)')
229
256
  .option('--no-mcp', 'do not write MCP client configs (.cursor/mcp.json, .vscode/mcp.json, .codex/config.toml, Gemini)')
230
257
  .option('--force', 'commit a join token even if the repo looks public (overrides the guard)')
231
- .option('--yes', 'non-interactive')
258
+ .option('--yes', 'confirm onboarding non-interactively (required for agents/CI)')
259
+ .option('--commit', 'commit ONLY the convene files as one isolated commit (never `git add -A`)')
232
260
  .option('--offline', 'write local files only (no API calls)')
233
- .action((opts) => (0, init_1.init)(opts));
261
+ .option('--tier <names>', 'best practices: comma-separated tiers to adopt (essentials,recommended,advanced)')
262
+ .option('--practice <id[=level]>', 'best practice to adopt (id or id=level; repeatable)', (v, acc) => (acc.push(v), acc), [])
263
+ .option('--all-practices', 'adopt every catalog best practice at its default level')
264
+ .option('--no-practices', 'adopt no best practices (skip the catalog + interactive picker)')
265
+ .action((opts) => (0, init_1.init)(withPracticeOpts(opts)));
266
+ program
267
+ .command('off-board')
268
+ .alias('offboard')
269
+ .description('cleanly remove this repo from Convene — the inverse of init (one isolated commit)')
270
+ .option('--yes', 'confirm non-interactively (required for agents/CI)')
271
+ .option('--remove-global', 'also remove the SHARED per-machine ~/.claude fetch hook (only if this is your last Convene repo)')
272
+ .option('--revoke-token', 'also revoke the committed join token server-side (owner-only)')
273
+ .option('--no-commit', 'do not create the isolated off-board commit')
274
+ .option('--dry-run', 'print what would change; touch nothing')
275
+ .action((opts) => (0, offboard_1.offboard)(opts));
234
276
  program
235
277
  .command('worktree <branch>')
236
278
  .description('create an isolated git worktree for a parallel session (one checkout per agent)')
@@ -249,7 +291,13 @@ program
249
291
  .option('--slug <slug>', 'project slug (defaults to the repo name / .convene/project.json)')
250
292
  .option('--email <email>', 'email for first-run self-provision (defaults to git user.email)')
251
293
  .option('--force', 'commit a join token even if the repo looks public')
252
- .action((opts) => (0, setup_1.setup)(opts));
294
+ .option('--yes', 'confirm onboarding non-interactively (required for agents/CI)')
295
+ .option('--commit', 'commit ONLY the convene files as one isolated commit')
296
+ .option('--tier <names>', 'best practices: comma-separated tiers to adopt (essentials,recommended,advanced)')
297
+ .option('--practice <id[=level]>', 'best practice to adopt (id or id=level; repeatable)', (v, acc) => (acc.push(v), acc), [])
298
+ .option('--all-practices', 'adopt every catalog best practice at its default level')
299
+ .option('--no-practices', 'adopt no best practices (skip the catalog + interactive picker)')
300
+ .action((opts) => (0, setup_1.setup)(withPracticeOpts(opts)));
253
301
  program
254
302
  .command('join')
255
303
  .description('self-provision: redeem a project join token for your own key + hook')
@@ -268,6 +316,14 @@ program
268
316
  .option('--yes')
269
317
  .option('--offline')
270
318
  .action((opts) => (0, migrate_1.migrate)(opts));
319
+ program
320
+ .command('update')
321
+ .description('check for + apply best-practices catalog updates (review in your working tree; never auto-commits)')
322
+ .option('--apply', 're-materialize adopted practices to the live catalog (default: dry run)')
323
+ .option('--auto-patch', 'limit an unattended --apply to patch-only bumps')
324
+ .option('--force', 're-materialize MAJOR bumps and locally-edited (drifted) practices too')
325
+ .option('--project <slug>', 'project slug (defaults to .convene/project.json)')
326
+ .action((opts) => (0, update_1.update)(opts));
271
327
  program.command('doctor').description('diagnose setup').option('--fix', 'attempt safe fixes').action((opts) => (0, auth_1.doctor)(opts));
272
328
  program.parseAsync(process.argv).catch((err) => {
273
329
  process.stderr.write(`convene: ${err?.message || err}\n`);
package/dist/protocol.js CHANGED
@@ -88,6 +88,18 @@ propose next-prompts to one another.
88
88
 
89
89
  Project: \`${slug}\` · Dashboard: ${baseUrl}/p/${slug}
90
90
 
91
+ ## Onboarding & off-boarding (a deliberate human action)
92
+ Connecting a repo to Convene — or removing it — is a deliberate choice, never an agent
93
+ side-effect. Onboard with \`convene setup\` (it auto-detects new-repo vs already-onboarded); an
94
+ agent / CI run must pass \`--yes\` to confirm intent, and \`--commit\` lands the footprint as one
95
+ isolated commit (never bundled into other work). Onboarding also **verifies your server-side
96
+ membership before writing anything**, so a repo can't end up half-connected (local hooks that
97
+ 403 every prompt). Private repos are first-class: each repo is its OWN project, scoped to the
98
+ members you add — the join token is committed into private repos by design. Remove a repo with
99
+ \`convene off-board\`: it strips only convene-managed content (keeping yours), deletes the files
100
+ convene created, unsets the hook path, and commits the removal in one shot. The per-machine
101
+ identity in \`~/.convene/config.json\` is shared across repos and is left intact.
102
+
91
103
  ## Identity
92
104
  - **Member** — a durable identity (e.g. \`${'alex'}\`), human or agent.
93
105
  - **Session** — a tag \`<member>/<worktree-basename>\`, with a short \`#<id>\` suffix
@@ -205,6 +217,8 @@ convene answer <id> "<answer>"
205
217
  convene ack <id> | convene resolve <id> | convene accept <id> | convene decline <id>
206
218
  convene lanes | convene lane claim <lane> | convene lane release <lane> | convene deploy
207
219
  convene catchup | convene inbox
220
+ convene setup [--yes] [--commit] # onboard/connect this repo (deliberate; --yes for agents/CI)
221
+ convene off-board [--yes] [--revoke-token] [--dry-run] # cleanly remove this repo (inverse of init)
208
222
  \`\`\`
209
223
  `;
210
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "convene-cli",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://dev.convene.live",