convene-cli 1.2.0 → 1.4.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 +23 -0
- package/dist/cache.js +83 -1
- package/dist/catalog/catalog.generated.js +860 -0
- package/dist/catalog/index.js +26 -0
- package/dist/catalog/manifest.js +71 -0
- package/dist/catalog/materialize.js +516 -0
- package/dist/catalog/prompt.js +89 -0
- package/dist/catalog/report.js +45 -0
- package/dist/catalog/select.js +86 -0
- package/dist/catalog/types.js +14 -0
- package/dist/commands/auth.js +44 -0
- package/dist/commands/fetch.js +50 -0
- package/dist/commands/init.js +49 -0
- package/dist/commands/offboard.js +128 -14
- package/dist/commands/override.js +65 -0
- package/dist/commands/practice-guard.js +310 -0
- package/dist/commands/practices.js +110 -0
- package/dist/commands/setup.js +11 -1
- package/dist/commands/update.js +249 -0
- package/dist/config.js +19 -1
- package/dist/index.js +48 -2
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.override = override;
|
|
4
|
+
/**
|
|
5
|
+
* `convene override <id> --reason "<why>"` (Phase 3) — the attributed, short-lived
|
|
6
|
+
* bypass for a best-practice gate. Writes a LOCAL, expiry-based override token that
|
|
7
|
+
* `convene practice-guard <id>` honors → ALLOW, and best-effort posts an attributed
|
|
8
|
+
* [STATUS] to the bus so the bypass is auditable in real time.
|
|
9
|
+
*
|
|
10
|
+
* POSTURE:
|
|
11
|
+
* - DIE-LOUD on a missing/empty --reason (an unattributed override is the whole
|
|
12
|
+
* thing we are guarding against) and on a missing project.
|
|
13
|
+
* - FAIL-OPEN on the BUS: the local token is the source of truth for the gate;
|
|
14
|
+
* if the bus is unreachable we STILL write the token and WARN — an override must
|
|
15
|
+
* never be blocked by a flaky network. The status post is attribution, not
|
|
16
|
+
* authorization.
|
|
17
|
+
*/
|
|
18
|
+
const config_1 = require("../config");
|
|
19
|
+
const git_1 = require("../git");
|
|
20
|
+
const api_1 = require("../api");
|
|
21
|
+
const cache_1 = require("../cache");
|
|
22
|
+
const ctx_1 = require("../ctx");
|
|
23
|
+
const NET_TIMEOUT_MS = 2500; // explicit short timeout — never the api.ts 10s default
|
|
24
|
+
function fmtTtl(ms) {
|
|
25
|
+
const sec = Math.round(ms / 1000);
|
|
26
|
+
if (sec < 60)
|
|
27
|
+
return `${sec}s`;
|
|
28
|
+
const m = Math.round(sec / 60);
|
|
29
|
+
return m === 1 ? '1 minute' : `${m} minutes`;
|
|
30
|
+
}
|
|
31
|
+
async function override(id, opts = {}) {
|
|
32
|
+
if (!id || !id.trim())
|
|
33
|
+
(0, ctx_1.die)('override requires a practice <id> (e.g. `convene override protect-shared-files --reason "…"`)');
|
|
34
|
+
const reason = (opts.reason ?? '').trim();
|
|
35
|
+
if (!reason)
|
|
36
|
+
(0, ctx_1.die)('override requires --reason "<why>" — an unattributed override defeats the gate');
|
|
37
|
+
const top = (0, git_1.gitToplevel)();
|
|
38
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
39
|
+
const slug = opts.project || proj?.slug || null;
|
|
40
|
+
if (!slug)
|
|
41
|
+
(0, ctx_1.die)('no project — run inside a `convene init`-ed repo, or pass --project <slug>');
|
|
42
|
+
// 1) Write the LOCAL token first — this is what the gate honors. Source of truth.
|
|
43
|
+
const tok = (0, cache_1.writeOverrideToken)(slug, id, reason);
|
|
44
|
+
// 2) Best-effort attributed [STATUS] to the bus. FAIL-OPEN: a bus failure warns
|
|
45
|
+
// but never blocks — the local token already stands.
|
|
46
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
47
|
+
const member = cfg.member;
|
|
48
|
+
const session = member && top ? (0, git_1.sessionId)(member, top) : null;
|
|
49
|
+
let posted = false;
|
|
50
|
+
if (cfg.apiKey && session) {
|
|
51
|
+
try {
|
|
52
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
53
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
54
|
+
const res = await api.post(slug, { type: 'status', body: `practice ${id} gate overridden — ${reason}` }, (0, ctx_1.uuid)(), NET_TIMEOUT_MS);
|
|
55
|
+
posted = !!res.ok;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
posted = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`override active for ${id} (${fmtTtl(cache_1.OVERRIDE_TTL_MS)}) — the next edits gated by this practice will be allowed.\n`);
|
|
62
|
+
if (!posted) {
|
|
63
|
+
process.stderr.write('convene: WARNING bus unreachable — override applied locally but NOT announced to the channel.\n');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
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.targetPathFromPayload = targetPathFromPayload;
|
|
7
|
+
exports.isProtectedPath = isProtectedPath;
|
|
8
|
+
exports.practiceGuard = practiceGuard;
|
|
9
|
+
/**
|
|
10
|
+
* `convene practice-guard <id>` (Phase 3) — the LEVEL-AWARE best-practice gate.
|
|
11
|
+
* Wired as a PreToolUse `Write|Edit` hook by the catalog's settingsHook artifacts
|
|
12
|
+
* (e.g. protect-shared-files, worktree-per-session, pull-main-before-execution).
|
|
13
|
+
* This file is the verb; the WIRING is materialized by init/materialize.
|
|
14
|
+
*
|
|
15
|
+
* POSTURE = FAIL-OPEN-LOUD (P0-FAILSAFE), modeled EXACTLY on guard.ts/gate-push.ts:
|
|
16
|
+
* - A top-level watchdog force-exits 0 at WATCHDOG_MS=4000.
|
|
17
|
+
* - The hot path is PURELY LOCAL (manifest + git + cache) — there is NO network
|
|
18
|
+
* call on the verdict path, so latency is bounded by local git, not a socket.
|
|
19
|
+
* - Any error / timeout / unreadable state → exit 0 (ALLOW). NEVER die().
|
|
20
|
+
* - exit 2 (BLOCK) ONLY on a CONFIRMED hard violation of a hook-HARD practice
|
|
21
|
+
* (today: protect-shared-files matching a protected path). Every other
|
|
22
|
+
* practice/level is a soft reminder on stderr (exit 0).
|
|
23
|
+
*
|
|
24
|
+
* TRUST DISCIPLINE: the verdict is computed from the adopted manifest + local
|
|
25
|
+
* git/cache state ONLY. We NEVER read or trust any message body / bus payload for
|
|
26
|
+
* a verdict — the only thing we read off the wire is nothing.
|
|
27
|
+
*
|
|
28
|
+
* The adopted LEVEL for <id> comes from loadManifest:
|
|
29
|
+
* - not adopted, or advisory/ci/manual → exit 0 SILENTLY (the gate is inert at
|
|
30
|
+
* these levels; CI/advisory/manual practices are not mechanically blocked).
|
|
31
|
+
* - hook-soft → print the practice's reminder to stderr, exit 0 (ALLOW).
|
|
32
|
+
* - hook-hard → block (exit 2) ONLY on a confirmed positive, else exit 0.
|
|
33
|
+
*
|
|
34
|
+
* OVERRIDE: a live `convene override <id> --reason …` token (short TTL, local,
|
|
35
|
+
* session-scoped) is honored → exit 0 with a one-line note.
|
|
36
|
+
*/
|
|
37
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
38
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
39
|
+
const git_1 = require("../git");
|
|
40
|
+
const config_1 = require("../config");
|
|
41
|
+
const cache_1 = require("../cache");
|
|
42
|
+
const catalog_1 = require("../catalog");
|
|
43
|
+
const exit_1 = require("../exit");
|
|
44
|
+
const WATCHDOG_MS = 4000;
|
|
45
|
+
/** The Write/Edit target path out of a PreToolUse hook payload (file_path/path/notebook_path). */
|
|
46
|
+
function targetPathFromPayload(raw) {
|
|
47
|
+
if (!raw)
|
|
48
|
+
return '';
|
|
49
|
+
try {
|
|
50
|
+
const j = JSON.parse(raw);
|
|
51
|
+
const ti = j?.tool_input ?? {};
|
|
52
|
+
const p = ti.file_path ?? ti.path ?? ti.notebook_path;
|
|
53
|
+
return typeof p === 'string' ? p : '';
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Async, timeout-bounded stdin read — identical posture to guard.ts. */
|
|
60
|
+
function readStdin(timeoutMs) {
|
|
61
|
+
if (process.stdin.isTTY)
|
|
62
|
+
return Promise.resolve(null);
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let data = '';
|
|
65
|
+
let settled = false;
|
|
66
|
+
const finish = (v) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
process.stdin.removeAllListeners();
|
|
72
|
+
resolve(v);
|
|
73
|
+
};
|
|
74
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
75
|
+
process.stdin.setEncoding('utf8');
|
|
76
|
+
process.stdin.on('data', (c) => {
|
|
77
|
+
data += c;
|
|
78
|
+
});
|
|
79
|
+
process.stdin.on('end', () => finish(data));
|
|
80
|
+
process.stdin.on('error', () => finish(null));
|
|
81
|
+
process.stdin.resume();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Soft reminder / override note on stderr (PreToolUse surfaces stderr to the agent). */
|
|
85
|
+
function note(reason) {
|
|
86
|
+
process.stderr.write(reason + '\n');
|
|
87
|
+
}
|
|
88
|
+
/** Hard deny reason on stderr (exit 2 surfaces this to the agent). */
|
|
89
|
+
function blockReason(reason) {
|
|
90
|
+
process.stderr.write(reason + '\n');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A compact "why + source" suffix for a practice, drawn from the catalog. Hitting a
|
|
94
|
+
* gate is the highest-frequency moment an agent could LEARN the rationale, so every
|
|
95
|
+
* reminder/block cites the practice's `why` (the failure mode it prevents) and a
|
|
96
|
+
* `sourceUrls[0]` reference right there — turning enforcement into in-context
|
|
97
|
+
* teaching with zero new infra (the catalog is already loaded; no hot-path network).
|
|
98
|
+
* Empty when the practice or its `why` is unknown. Pure + local.
|
|
99
|
+
*/
|
|
100
|
+
function whySuffix(id) {
|
|
101
|
+
const p = catalog_1.CATALOG.practices.find((x) => x.id === id);
|
|
102
|
+
if (!p || !p.why)
|
|
103
|
+
return '';
|
|
104
|
+
const src = p.sourceUrls && p.sourceUrls.length > 0 ? `\n source: ${p.sourceUrls[0]}` : '';
|
|
105
|
+
return `\n why: ${p.why}${src}`;
|
|
106
|
+
}
|
|
107
|
+
/** The one-line reminder for a practice: its catalog `title` + why/source (lean). */
|
|
108
|
+
function reminderFor(id) {
|
|
109
|
+
const p = catalog_1.CATALOG.practices.find((x) => x.id === id);
|
|
110
|
+
if (!p)
|
|
111
|
+
return `convene: best practice ${id} applies here.`;
|
|
112
|
+
return `convene: ${p.title} — ${p.id}.${whySuffix(id)}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* DEFAULT protected paths for protect-shared-files (used when the repo has not
|
|
116
|
+
* customized a set). Returns true iff the Write/Edit target is shared/global and
|
|
117
|
+
* must not be edited by a non-owner session. Matched against the path RELATIVE to
|
|
118
|
+
* the repo toplevel so a root-config match is anchored at the root, not anywhere.
|
|
119
|
+
*/
|
|
120
|
+
function isProtectedPath(targetAbsOrRel, top) {
|
|
121
|
+
if (!targetAbsOrRel)
|
|
122
|
+
return false;
|
|
123
|
+
// Normalize to a repo-relative POSIX path. Resolve symlinks on BOTH sides
|
|
124
|
+
// best-effort so a /var → /private/var (macOS) or other symlinked toplevel can't
|
|
125
|
+
// make an in-repo target look external. realpath only the longest EXISTING
|
|
126
|
+
// ancestor (the leaf may not exist yet for a Write).
|
|
127
|
+
let rel = targetAbsOrRel;
|
|
128
|
+
if (node_path_1.default.isAbsolute(rel)) {
|
|
129
|
+
rel = node_path_1.default.relative(realpathBest(top), realpathBest(rel));
|
|
130
|
+
}
|
|
131
|
+
rel = rel.split(node_path_1.default.sep).join('/');
|
|
132
|
+
if (!rel || rel.startsWith('../'))
|
|
133
|
+
return false; // outside the repo → not ours to gate
|
|
134
|
+
const base = rel.split('/').pop() || rel;
|
|
135
|
+
// Lockfiles — anywhere in the tree.
|
|
136
|
+
const LOCKFILES = new Set([
|
|
137
|
+
'package-lock.json',
|
|
138
|
+
'yarn.lock',
|
|
139
|
+
'pnpm-lock.yaml',
|
|
140
|
+
'Cargo.lock',
|
|
141
|
+
'go.sum',
|
|
142
|
+
'poetry.lock',
|
|
143
|
+
]);
|
|
144
|
+
if (LOCKFILES.has(base))
|
|
145
|
+
return true;
|
|
146
|
+
// Any path under a `migrations/` directory (at any depth).
|
|
147
|
+
if (/(^|\/)migrations\//.test(rel))
|
|
148
|
+
return true;
|
|
149
|
+
// Root-level shared config files (anchored at the repo root only).
|
|
150
|
+
const segments = rel.split('/');
|
|
151
|
+
if (segments.length === 1) {
|
|
152
|
+
if (base === 'tsconfig.json' || base === 'Dockerfile')
|
|
153
|
+
return true;
|
|
154
|
+
if (/^\.eslintrc(\..+)?$/.test(base))
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Realpath a path, resolving symlinks on the longest EXISTING ancestor and
|
|
161
|
+
* re-appending the non-existent leaf segments. Falls back to the input on any
|
|
162
|
+
* error — a best-effort canonicalization, never a throw.
|
|
163
|
+
*/
|
|
164
|
+
function realpathBest(p) {
|
|
165
|
+
try {
|
|
166
|
+
return node_fs_1.default.realpathSync(p);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* leaf may not exist yet — resolve the deepest existing ancestor */
|
|
170
|
+
}
|
|
171
|
+
let dir = p;
|
|
172
|
+
const tail = [];
|
|
173
|
+
for (let i = 0; i < 64; i++) {
|
|
174
|
+
const parent = node_path_1.default.dirname(dir);
|
|
175
|
+
if (parent === dir)
|
|
176
|
+
break; // reached the root
|
|
177
|
+
tail.unshift(node_path_1.default.basename(dir));
|
|
178
|
+
dir = parent;
|
|
179
|
+
try {
|
|
180
|
+
return node_path_1.default.join(node_fs_1.default.realpathSync(dir), ...tail);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* keep walking up */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return p;
|
|
187
|
+
}
|
|
188
|
+
/** Resolve the adopted level for <id> from the repo manifest, or null if unadopted. */
|
|
189
|
+
function adoptedLevel(top, id) {
|
|
190
|
+
const manifest = (0, config_1.loadManifest)(top);
|
|
191
|
+
if (!manifest)
|
|
192
|
+
return null;
|
|
193
|
+
const entry = manifest.practices.find((p) => p.id === id);
|
|
194
|
+
return entry ? entry.level : null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The branch is CONFIRMED behind origin/main iff origin/main is NOT an ancestor of
|
|
198
|
+
* HEAD. Purely local (no fetch on the hot path — `pull-main-before-execution`
|
|
199
|
+
* deliberately does not do a slow network fetch per the spec); uses the existing
|
|
200
|
+
* tracking ref. Any uncertainty → false (no reminder), never a slow path.
|
|
201
|
+
*/
|
|
202
|
+
function behindOriginMain(top) {
|
|
203
|
+
try {
|
|
204
|
+
const head = (0, git_1.revParse)('HEAD', top);
|
|
205
|
+
const remote = (0, git_1.revParse)('origin/main', top) ?? (0, git_1.revParse)('origin/master', top);
|
|
206
|
+
if (!head || !remote)
|
|
207
|
+
return false;
|
|
208
|
+
if (head === remote)
|
|
209
|
+
return false;
|
|
210
|
+
// behind ⇔ remote is not an ancestor of HEAD AND there are commits we lack.
|
|
211
|
+
if ((0, git_1.isAncestor)(remote, head, top))
|
|
212
|
+
return false; // we are ahead or equal → not behind
|
|
213
|
+
const behindCount = (0, git_1.revListCount)(`${head}..${remote}`, top);
|
|
214
|
+
return (behindCount ?? 0) > 0;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function run(opts, id) {
|
|
221
|
+
if (!id)
|
|
222
|
+
return 0; // no practice id → nothing to gate
|
|
223
|
+
const top = (0, git_1.gitToplevel)();
|
|
224
|
+
if (!top)
|
|
225
|
+
return 0; // not a git repo → no-op
|
|
226
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
227
|
+
const slug = opts.project || proj?.slug || null;
|
|
228
|
+
if (!slug)
|
|
229
|
+
return 0; // not on the bus → no-op
|
|
230
|
+
const level = adoptedLevel(top, id);
|
|
231
|
+
// Unadopted, or a non-mechanical level → the gate is inert. SILENT exit 0.
|
|
232
|
+
if (level == null || level === 'advisory' || level === 'ci' || level === 'manual')
|
|
233
|
+
return 0;
|
|
234
|
+
// A live override token for (slug,id) → ALLOW with a one-line note (works at any
|
|
235
|
+
// mechanical level, including hook-hard).
|
|
236
|
+
const ovr = (0, cache_1.readLiveOverrideToken)(slug, id);
|
|
237
|
+
if (ovr) {
|
|
238
|
+
note(`convene: override active for ${id} — "${ovr.reason}" — proceeding (gate bypassed).`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
// Read the PreToolUse payload (the Write/Edit target path).
|
|
242
|
+
const raw = opts.stdin ? await readStdin(1500) : null;
|
|
243
|
+
const target = targetPathFromPayload(raw);
|
|
244
|
+
// ── Per-practice checks ─────────────────────────────────────────────────────
|
|
245
|
+
switch (id) {
|
|
246
|
+
case 'protect-shared-files': {
|
|
247
|
+
const hit = isProtectedPath(target, top);
|
|
248
|
+
if (!hit)
|
|
249
|
+
return 0; // a normal file → allow (zero noise)
|
|
250
|
+
if (level === 'hook-hard') {
|
|
251
|
+
blockReason(`convene: BLOCKED — ${shortRel(target, top)} is a shared/global file (lockfile, migration, or root config) ` +
|
|
252
|
+
`protected by practice protect-shared-files. Editing it across sessions reliably breaks integration. ` +
|
|
253
|
+
`If this session OWNS this change: \`convene override protect-shared-files --reason "<why>"\`, then retry.` +
|
|
254
|
+
whySuffix(id));
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
// hook-soft → remind, allow.
|
|
258
|
+
note(`convene: ${shortRel(target, top)} is a shared/global file — protect-shared-files. ` +
|
|
259
|
+
`Only edit it if this session is the assigned owner; otherwise sequence the change.` +
|
|
260
|
+
whySuffix(id));
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
case 'worktree-per-session': {
|
|
264
|
+
// hook-soft: if >1 live session shares this checkout, nudge toward a worktree
|
|
265
|
+
// (reuse the doctor signal). Best-effort; any uncertainty → no nudge.
|
|
266
|
+
const live = (0, cache_1.liveSessionCount)(slug, 15 * 60);
|
|
267
|
+
if (live > 1) {
|
|
268
|
+
note(`convene: ${live} live sessions share this checkout — worktree-per-session. ` +
|
|
269
|
+
`Run each concurrent agent in its own worktree (\`convene worktree <branch>\`) to avoid silent clobbering.` +
|
|
270
|
+
whySuffix(id));
|
|
271
|
+
}
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
case 'pull-main-before-execution': {
|
|
275
|
+
// hook-soft, best-effort, LOCAL only (no slow fetch on the hot path).
|
|
276
|
+
if (behindOriginMain(top)) {
|
|
277
|
+
const b = (0, git_1.currentBranch)(top) ?? 'this branch';
|
|
278
|
+
note(`convene: ${b} is behind origin/main — pull-main-before-execution. ` +
|
|
279
|
+
`Rebase onto origin/main and re-validate the plan before editing.` +
|
|
280
|
+
whySuffix(id));
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
default:
|
|
285
|
+
// plan-mode-before-edits / focused-subagents-least-privilege / any other id:
|
|
286
|
+
// a soft reminder from the catalog title. Never blocks.
|
|
287
|
+
note(reminderFor(id));
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Short, repo-relative display of a path for the reason line. */
|
|
292
|
+
function shortRel(target, top) {
|
|
293
|
+
if (!target)
|
|
294
|
+
return 'this file';
|
|
295
|
+
const rel = node_path_1.default.isAbsolute(target) ? node_path_1.default.relative(top, target) : target;
|
|
296
|
+
return rel.split(node_path_1.default.sep).join('/') || target;
|
|
297
|
+
}
|
|
298
|
+
async function practiceGuard(id, opts = {}) {
|
|
299
|
+
let code = 0;
|
|
300
|
+
const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), WATCHDOG_MS);
|
|
301
|
+
watchdog.unref();
|
|
302
|
+
try {
|
|
303
|
+
code = await run(opts, id);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
code = 0; // fail-open: never block on our own error
|
|
307
|
+
}
|
|
308
|
+
clearTimeout(watchdog);
|
|
309
|
+
(0, exit_1.exitClean)(code);
|
|
310
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.practices = practices;
|
|
4
|
+
/**
|
|
5
|
+
* `convene practices [id]` — the LEARN surface for the best-practices catalog.
|
|
6
|
+
*
|
|
7
|
+
* Without an id: lists every catalog practice grouped by tier, marking the ones THIS
|
|
8
|
+
* repo adopted (and at what level), so a human or agent can see what is on offer and
|
|
9
|
+
* what is active. With an id: prints that practice in depth — what it is, WHY (the
|
|
10
|
+
* failure mode it prevents), its enforcement levels, provenance, and the source URLs
|
|
11
|
+
* the practice was distilled from.
|
|
12
|
+
*
|
|
13
|
+
* The `why` and `sourceUrls` are authored on every one of the catalog's practices but
|
|
14
|
+
* were rendered nowhere — the picker shows a bare title, the materialized doc and the
|
|
15
|
+
* dashboard show the `what`/id. This command is where a repo LEARNS the rationale,
|
|
16
|
+
* before or after adopting. It deliberately lives OFF the always-loaded path (a doc
|
|
17
|
+
* imported into CLAUDE.md every turn would tax the agent — Gloaguen et al. 2026), so
|
|
18
|
+
* the heavy rationale is on-demand here, not in the hot path.
|
|
19
|
+
*
|
|
20
|
+
* Reads the bundled offline mirror (CATALOG) — network-free, instant, fail-soft. The
|
|
21
|
+
* repo manifest (loadManifest) only annotates adoption; its absence is fine (every
|
|
22
|
+
* practice then shows as available/not-adopted).
|
|
23
|
+
*/
|
|
24
|
+
const git_1 = require("../git");
|
|
25
|
+
const config_1 = require("../config");
|
|
26
|
+
const catalog_1 = require("../catalog");
|
|
27
|
+
const out = (s) => process.stdout.write(s + '\n');
|
|
28
|
+
/** id → adopted level, from the repo manifest. Empty (and never throws) when none. */
|
|
29
|
+
function adoptedLevels() {
|
|
30
|
+
const m = new Map();
|
|
31
|
+
try {
|
|
32
|
+
const top = (0, git_1.gitToplevel)();
|
|
33
|
+
if (!top)
|
|
34
|
+
return m;
|
|
35
|
+
const manifest = (0, config_1.loadManifest)(top);
|
|
36
|
+
if (manifest)
|
|
37
|
+
for (const e of manifest.practices)
|
|
38
|
+
m.set(e.id, e.level);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* fail-soft: no manifest annotations, list still works */
|
|
42
|
+
}
|
|
43
|
+
return m;
|
|
44
|
+
}
|
|
45
|
+
/** Entry point: detail view when an id is given, else the grouped list. Never throws. */
|
|
46
|
+
function practices(id, _opts = {}) {
|
|
47
|
+
const adopted = adoptedLevels();
|
|
48
|
+
const wanted = (id ?? '').trim();
|
|
49
|
+
if (wanted)
|
|
50
|
+
printDetail(wanted, adopted);
|
|
51
|
+
else
|
|
52
|
+
printList(adopted);
|
|
53
|
+
}
|
|
54
|
+
/** The grouped list: every practice by tier, adopted ones marked with their level. */
|
|
55
|
+
function printList(adopted) {
|
|
56
|
+
out(`Convene best-practices catalog v${catalog_1.CATALOG.version} — ${catalog_1.CATALOG.practices.length} practices, ` +
|
|
57
|
+
`${adopted.size} adopted in this repo.`);
|
|
58
|
+
out('Run `convene practices <id>` for the why + sources behind any one.');
|
|
59
|
+
// Pad ids to a common width so titles line up; ✓ marks an adopted practice.
|
|
60
|
+
const idWidth = Math.min(34, catalog_1.CATALOG.practices.reduce((w, p) => Math.max(w, p.id.length), 0));
|
|
61
|
+
for (const tier of catalog_1.CATALOG.tiers) {
|
|
62
|
+
const inTier = catalog_1.CATALOG.practices.filter((p) => p.tier === tier.id);
|
|
63
|
+
if (inTier.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
out('');
|
|
66
|
+
out(`${tier.name}`);
|
|
67
|
+
for (const p of inTier) {
|
|
68
|
+
const lvl = adopted.get(p.id);
|
|
69
|
+
const mark = lvl ? `✓ [${lvl}]` : '·';
|
|
70
|
+
out(` ${mark.padEnd(13)} ${p.id.padEnd(idWidth)} ${p.title}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The depth view for one practice: what / why / levels / provenance / sources. */
|
|
75
|
+
function printDetail(id, adopted) {
|
|
76
|
+
const p = catalog_1.CATALOG.practices.find((x) => x.id === id);
|
|
77
|
+
if (!p) {
|
|
78
|
+
out(`No practice "${id}" in catalog v${catalog_1.CATALOG.version}.`);
|
|
79
|
+
const near = catalog_1.CATALOG.practices.filter((x) => x.id.includes(id) || id.includes(x.id)).slice(0, 5);
|
|
80
|
+
if (near.length) {
|
|
81
|
+
out('Did you mean:');
|
|
82
|
+
for (const n of near)
|
|
83
|
+
out(` ${n.id}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
out('Run `convene practices` to list them all.');
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const lvl = adopted.get(p.id);
|
|
91
|
+
out(p.title);
|
|
92
|
+
out(`${p.id} · ${p.tier} / ${p.category} · v${p.version}`);
|
|
93
|
+
out(lvl ? `adopted here at: ${lvl}` : `not adopted here (suggested level: ${p.defaultLevel})`);
|
|
94
|
+
out(`enforcement levels: ${p.availableLevels.join(', ')}`);
|
|
95
|
+
if (p.productionLearned) {
|
|
96
|
+
out('provenance: production-learned at the BrightAI Opportunity Explorer');
|
|
97
|
+
}
|
|
98
|
+
out('');
|
|
99
|
+
out('WHAT');
|
|
100
|
+
out(` ${p.what}`);
|
|
101
|
+
out('');
|
|
102
|
+
out('WHY');
|
|
103
|
+
out(` ${p.why}`);
|
|
104
|
+
if (p.sourceUrls && p.sourceUrls.length > 0) {
|
|
105
|
+
out('');
|
|
106
|
+
out('SOURCES');
|
|
107
|
+
for (const u of p.sourceUrls)
|
|
108
|
+
out(` - ${u}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -31,7 +31,17 @@ async function setup(opts) {
|
|
|
31
31
|
}
|
|
32
32
|
else {
|
|
33
33
|
log('This repo is not on Convene yet — onboarding it…');
|
|
34
|
-
await (0, init_1.init)({
|
|
34
|
+
await (0, init_1.init)({
|
|
35
|
+
slug: opts.slug,
|
|
36
|
+
email: opts.email,
|
|
37
|
+
force: opts.force,
|
|
38
|
+
yes: opts.yes,
|
|
39
|
+
commit: opts.commit,
|
|
40
|
+
tier: opts.tier,
|
|
41
|
+
practice: opts.practice,
|
|
42
|
+
allPractices: opts.allPractices,
|
|
43
|
+
noPractices: opts.noPractices,
|
|
44
|
+
});
|
|
35
45
|
}
|
|
36
46
|
log('');
|
|
37
47
|
log('— Connected. Quick usage —');
|