convene-cli 1.5.1 → 1.6.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.
@@ -3,6 +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.practiceBlurb = practiceBlurb;
6
7
  exports.pickPracticesInteractively = pickPracticesInteractively;
7
8
  /**
8
9
  * Interactive best-practices picker for `convene init` / `convene setup`.
@@ -16,6 +17,24 @@ exports.pickPracticesInteractively = pickPracticesInteractively;
16
17
  const promises_1 = __importDefault(require("node:readline/promises"));
17
18
  const select_1 = require("./select");
18
19
  const out = (m) => process.stdout.write(m + '\n');
20
+ /** Truncate to ~n chars on a soft boundary, adding an ellipsis when clipped. */
21
+ function truncate(s, n) {
22
+ if (s.length <= n)
23
+ return s;
24
+ return s.slice(0, n - 1).trimEnd() + '…';
25
+ }
26
+ /**
27
+ * The provenance/rationale context shown above each practice in the customizer, so
28
+ * a user can make an INFORMED adoption choice (feedback 52269531 — the picker used
29
+ * to show only a bare title). Renders the practice title, a production-learned
30
+ * marker (the provenance the Practice.productionLearned comment promises), and a
31
+ * truncated `why`. Pure + exported so it can be asserted without a TTY.
32
+ */
33
+ function practiceBlurb(p) {
34
+ const mark = p.productionLearned ? ' ★ production-learned' : '';
35
+ const why = p.why ? `\n why: ${truncate(p.why, 110)}` : '';
36
+ return ` ${p.title}${mark}${why}`;
37
+ }
19
38
  /** Count of opt-in (default-ON) practices in the named tiers — for the menu labels. */
20
39
  function presetCount(catalog, preset) {
21
40
  return (0, select_1.presetSelections)(catalog, preset).length;
@@ -55,22 +74,25 @@ async function pickPracticesInteractively(catalog) {
55
74
  }
56
75
  }
57
76
  /**
58
- * Customize from the recommended preset: for each chosen practice show
59
- * `title [defaultLevel]` and accept enter=keep, 's'=skip, or a level name from
60
- * availableLevels. Skimmable; unknown levels re-prompt-free (keep at default).
77
+ * Customize from the recommended preset: for each chosen practice show its title,
78
+ * provenance, and a one-line `why` (via practiceBlurb), then accept enter=keep,
79
+ * 's'=skip, or a level name from availableLevels. Skimmable; unknown levels
80
+ * re-prompt-free (keep at default).
61
81
  */
62
82
  async function customize(catalog, rl) {
63
83
  const base = (0, select_1.presetSelections)(catalog, 'recommended');
64
84
  const byId = new Map(catalog.practices.map((p) => [p.id, p]));
65
85
  const result = [];
66
86
  out('');
67
- out('Customize — for each: [enter] keep · [s] skip · or type a level name.');
87
+ out('Customize — for each: [enter] keep the suggested level · [s] skip · or type a level name.');
68
88
  for (const sel of base) {
69
89
  const p = byId.get(sel.id);
70
90
  if (!p)
71
91
  continue;
72
92
  const levels = p.availableLevels.join('/');
73
- const ans = (await rl.question(` ${p.title} [${sel.level}] (${levels}): `)).trim().toLowerCase();
93
+ out('');
94
+ out(practiceBlurb(p));
95
+ const ans = (await rl.question(` [${sel.level}] (${levels}): `)).trim().toLowerCase();
74
96
  if (ans === 's' || ans === 'skip')
75
97
  continue;
76
98
  if (ans === '') {
@@ -6,9 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.login = login;
7
7
  exports.whoami = whoami;
8
8
  exports.assessLaneIdentity = assessLaneIdentity;
9
+ exports.assessSettingsIntegrity = assessSettingsIntegrity;
9
10
  exports.doctor = doctor;
10
11
  /** login / whoami / doctor. */
11
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_path_1 = __importDefault(require("node:path"));
12
14
  const node_child_process_1 = require("node:child_process");
13
15
  const brand_1 = require("../brand");
14
16
  const api_1 = require("../api");
@@ -152,6 +154,96 @@ function assessLaneIdentity(lanes, myHandle, haveInstance, staleSec = LANE_STALE
152
154
  }
153
155
  return { name, ok: true, detail: `holding ${mine.length} lane(s), all fresh and owned by this session` };
154
156
  }
157
+ /** Count non-overlapping occurrences of `sub` in `s`. */
158
+ function countOccurrences(s, sub) {
159
+ if (!sub)
160
+ return 0;
161
+ let n = 0;
162
+ let i = s.indexOf(sub);
163
+ while (i >= 0) {
164
+ n++;
165
+ i = s.indexOf(sub, i + sub.length);
166
+ }
167
+ return n;
168
+ }
169
+ /**
170
+ * Non-destructiveness assertion (feedback 3749eac9 / dhparmele): Convene earns
171
+ * authority by scope + ADDITIVE merge, never by overwriting user-owned files. This
172
+ * check confirms that posture is intact:
173
+ * - the global + project `.claude/settings.json` parse as JSON (a file Convene
174
+ * would otherwise leave untouched but also could not merge into — worth flagging);
175
+ * - any Convene hook coexists with user-owned hooks (additivity, not whole-object
176
+ * replace) — reported as a positive note, NEVER a failure;
177
+ * - the CLAUDE.md / AGENTS.md Convene marker blocks are well-formed (a balanced,
178
+ * non-inverted begin/end pair) so a `--refresh-docs` re-render stays surgical.
179
+ * Fails (ok:false) ONLY on genuine corruption (invalid JSON / malformed markers),
180
+ * never on a user simply having their own hooks. Diagnostic only — no auto-repair.
181
+ * Exported + parameterized so it can be asserted hermetically without a TTY/network.
182
+ */
183
+ function assessSettingsIntegrity(top, globalSettingsPath = hook_1.SETTINGS_PATH) {
184
+ const name = 'settings';
185
+ const problems = [];
186
+ const notes = [];
187
+ const settingsFiles = [{ label: 'global', file: globalSettingsPath }];
188
+ if (top)
189
+ settingsFiles.push({ label: 'project', file: (0, hook_1.projectSettingsPath)(top) });
190
+ for (const { label, file } of settingsFiles) {
191
+ if (!node_fs_1.default.existsSync(file))
192
+ continue;
193
+ let parsed;
194
+ try {
195
+ const raw = node_fs_1.default.readFileSync(file, 'utf8');
196
+ parsed = raw.trim() === '' ? {} : JSON.parse(raw);
197
+ }
198
+ catch {
199
+ problems.push(`${label} settings.json is not valid JSON — Convene leaves it untouched but cannot merge into it (${file})`);
200
+ continue;
201
+ }
202
+ if (parsed && typeof parsed === 'object' && parsed.hooks && typeof parsed.hooks === 'object') {
203
+ let convene = 0;
204
+ let foreign = 0;
205
+ for (const ev of Object.keys(parsed.hooks)) {
206
+ const groups = Array.isArray(parsed.hooks[ev]) ? parsed.hooks[ev] : [];
207
+ for (const g of groups) {
208
+ const isConvene = Array.isArray(g?.hooks) &&
209
+ g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('convene'));
210
+ if (isConvene)
211
+ convene++;
212
+ else
213
+ foreign++;
214
+ }
215
+ }
216
+ if (foreign > 0)
217
+ notes.push(`${label}: ${foreign} user-owned hook(s) preserved alongside ${convene} Convene hook(s)`);
218
+ }
219
+ }
220
+ if (top) {
221
+ for (const fname of ['CLAUDE.md', 'AGENTS.md']) {
222
+ const p = node_path_1.default.join(top, fname);
223
+ if (!node_fs_1.default.existsSync(p))
224
+ continue;
225
+ let content;
226
+ try {
227
+ content = node_fs_1.default.readFileSync(p, 'utf8');
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ const begins = countOccurrences(content, brand_1.BRAND.blockBegin);
233
+ const ends = countOccurrences(content, brand_1.BRAND.blockEnd);
234
+ if (begins !== ends || begins > 1) {
235
+ problems.push(`${fname} has a malformed Convene marker block (${begins} begin / ${ends} end) — repair the markers before a refresh`);
236
+ }
237
+ else if (begins === 1 && content.indexOf(brand_1.BRAND.blockBegin) > content.indexOf(brand_1.BRAND.blockEnd)) {
238
+ problems.push(`${fname} Convene marker block is inverted (begin after end) — repair the markers`);
239
+ }
240
+ }
241
+ }
242
+ if (problems.length)
243
+ return { name, ok: false, detail: problems.join('; ') };
244
+ const tail = notes.length ? ` (${notes.join('; ')})` : '';
245
+ return { name, ok: true, detail: `settings JSON valid + marker blocks intact; Convene edits are additive${tail}` };
246
+ }
155
247
  async function doctor(opts) {
156
248
  const checks = [];
157
249
  const cfg = (0, config_1.resolveConfig)();
@@ -228,6 +320,9 @@ async function doctor(opts) {
228
320
  ? 'UserPromptSubmit `convene fetch` registered'
229
321
  : 'hook NOT registered (run `convene init` or `convene doctor --fix`)',
230
322
  });
323
+ // 6b. settings non-destructiveness — Convene's edits stay additive + marker-scoped;
324
+ // user-owned hooks/files are never clobbered. Fails only on genuine corruption.
325
+ checks.push(assessSettingsIntegrity(top));
231
326
  // 7. watch heartbeat — a stale/absent heartbeat means the mid-task halt watcher
232
327
  // is DOWN (so directed halts won't surface between turns). Only meaningful for a
233
328
  // repo on the bus; --fix may (re)launch `convene watch` detached.
@@ -25,7 +25,9 @@ const cache_1 = require("../cache");
25
25
  const api_1 = require("../api");
26
26
  const exit_1 = require("../exit");
27
27
  const render_1 = require("../render");
28
- const FETCH_TIMEOUT_MS = 4000;
28
+ // Default 4000ms; overridable via CONVENE_FETCH_TIMEOUT_MS (tests drive it small for
29
+ // deterministic, load-independent latency-budget assertions). See config.ts.
30
+ const FETCH_TIMEOUT_MS = (0, config_1.resolveFetchTimeoutMs)();
29
31
  const WATCHDOG_MS = 6000;
30
32
  const MAX_ITEMS = 400;
31
33
  function emit(s) {
@@ -29,7 +29,9 @@ const render_1 = require("../render");
29
29
  const catchup_1 = require("./catchup");
30
30
  const exit_1 = require("../exit");
31
31
  const CACHE_TTL_SEC = 3;
32
- const FETCH_TIMEOUT_MS = 4000;
32
+ // Default 4000ms; overridable via CONVENE_FETCH_TIMEOUT_MS (tests drive it small for
33
+ // deterministic, load-independent latency-budget assertions). See config.ts.
34
+ const FETCH_TIMEOUT_MS = (0, config_1.resolveFetchTimeoutMs)();
33
35
  const WATCHDOG_MS = 6000;
34
36
  /** Catalog-version cache TTL for the behind-nudge — long enough to stay off the hot path. */
35
37
  const CATALOG_VERSION_TTL_SEC = 3600;
@@ -50,8 +52,9 @@ const CATALOG_VERSION_TTL_SEC = 3600;
50
52
  // fast blip, given the bus normally answers in ~0.15s.
51
53
  /** Total network budget for the feed fetch across all attempts (== FETCH_TIMEOUT_MS). */
52
54
  const FEED_BUDGET_MS = FETCH_TIMEOUT_MS;
53
- /** First-attempt timeout. ~500ms under FETCH_TIMEOUT_MS, leaving room for a retry. */
54
- const FEED_ATTEMPT_MS = 3500;
55
+ /** First-attempt timeout. ~500ms under FETCH_TIMEOUT_MS, leaving room for a retry.
56
+ * Derived from FETCH_TIMEOUT_MS so a small env override never exceeds the budget. */
57
+ const FEED_ATTEMPT_MS = Math.max(250, FETCH_TIMEOUT_MS - 500);
55
58
  /** A failure faster than this is treated as a transient blip worth one retry. */
56
59
  exports.FEED_FAST_FAIL_MS = 1200;
57
60
  /** Brief pause before the retry, to let a restarting task come back. */
@@ -209,7 +209,10 @@ function registerHook(noHook) {
209
209
  if (raw !== null)
210
210
  node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH + '.bak', raw);
211
211
  node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH, (0, hook_1.serializeSettings)((0, hook_1.withHook)(settings)));
212
- log(`Registered UserPromptSubmit hook in ${hook_1.SETTINGS_PATH}${raw !== null ? ' (backup: settings.json.bak)' : ''}.`);
212
+ log(`Registered the lightweight \`convene fetch\` UserPromptSubmit hook in ${hook_1.SETTINGS_PATH}${raw !== null ? ' (backup: settings.json.bak)' : ''}.`);
213
+ log(' ↳ This is the ONE global write: it fires in every repo (a silent no-op off the bus) and is ' +
214
+ 'ADDITIVE — your own hooks are left untouched. Prefer not to touch ~/.claude? Re-run with ' +
215
+ '`--no-hook`; the committed project `.claude/settings.json` hook still covers this repo.');
213
216
  }
214
217
  catch (err) {
215
218
  log(`Could not write settings (${err?.message}). Add this hook manually:`);
@@ -7,7 +7,8 @@ exports.sessionStart = sessionStart;
7
7
  * FAIL-OPEN (P0-FAILSAFE), copying the fetch.ts scaffold:
8
8
  * - hard watchdog at 6000ms → exit 0 no matter what (SessionStart's own default
9
9
  * timeout is 30s, which would stall a boot — we bound it ourselves);
10
- * - the network GET is bounded at 4000ms;
10
+ * - the network GET is bounded at FETCH_TIMEOUT_MS (default 4000ms; overridable
11
+ * via CONVENE_FETCH_TIMEOUT_MS for deterministic tests);
11
12
  * - any error / non-bus repo / DEGRADED emits NOTHING and exits 0.
12
13
  *
13
14
  * What it does on a fresh, authenticated bus repo:
@@ -26,7 +27,9 @@ const api_1 = require("../api");
26
27
  const render_1 = require("../render");
27
28
  const catchup_1 = require("./catchup");
28
29
  const exit_1 = require("../exit");
29
- const FETCH_TIMEOUT_MS = 4000;
30
+ // Default 4000ms; overridable via CONVENE_FETCH_TIMEOUT_MS (tests drive it small for
31
+ // deterministic, load-independent latency-budget assertions). See config.ts.
32
+ const FETCH_TIMEOUT_MS = (0, config_1.resolveFetchTimeoutMs)();
30
33
  const WATCHDOG_MS = 6000;
31
34
  const MAX_ITEMS = 400;
32
35
  // Don't relaunch the watch daemon if one stamped a heartbeat this recently — a
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.CACHE_DIR = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
7
7
  exports.homeBase = homeBase;
8
+ exports.resolveFetchTimeoutMs = resolveFetchTimeoutMs;
8
9
  exports.isWorldReadable = isWorldReadable;
9
10
  exports.loadFileConfig = loadFileConfig;
10
11
  exports.loadProjectConfig = loadProjectConfig;
@@ -28,6 +29,19 @@ const brand_1 = require("./brand");
28
29
  function homeBase() {
29
30
  return process.env.CONVENE_HOME_OVERRIDE || node_os_1.default.homedir();
30
31
  }
32
+ /**
33
+ * Network fetch timeout (ms) for the hook/catch-up paths. Overridable via
34
+ * CONVENE_FETCH_TIMEOUT_MS so the fail-open/timeout tests can drive the path
35
+ * deterministically with a small value instead of relying on the real 4s
36
+ * wall-clock — which, under full-suite serial load, collides with process-spawn
37
+ * + GC jitter and intermittently blows the latency-budget assertion. A garbage or
38
+ * non-positive value falls back to the default. Production default unchanged (4000).
39
+ */
40
+ function resolveFetchTimeoutMs(fallback = 4000) {
41
+ const raw = process.env.CONVENE_FETCH_TIMEOUT_MS;
42
+ const n = raw ? Number(raw) : NaN;
43
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
44
+ }
31
45
  exports.CONFIG_DIR = node_path_1.default.join(homeBase(), brand_1.BRAND.configDir);
32
46
  exports.CONFIG_FILE = node_path_1.default.join(exports.CONFIG_DIR, 'config.json');
33
47
  exports.CACHE_DIR = node_path_1.default.join(exports.CONFIG_DIR, 'cache');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "convene-cli",
3
- "version": "1.5.1",
3
+ "version": "1.6.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",