leos-agent 7.0.0 → 10.2.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.
Files changed (96) hide show
  1. package/README.md +544 -28
  2. package/commands/handoff.md +11 -0
  3. package/commands/handon.md +10 -0
  4. package/commands/review-pr.md +9 -0
  5. package/commands-claude/watch-review.md +9 -0
  6. package/index.js +12 -0
  7. package/package.json +29 -14
  8. package/payload/codex-agents/leo-executor.toml +36 -0
  9. package/payload/codex-agents/leo-runner.toml +28 -0
  10. package/rules/preferences.md +97 -0
  11. package/scripts/check.py +244 -0
  12. package/scripts/ghreview.py +17 -3
  13. package/scripts/handoff.py +183 -0
  14. package/scripts/leo-install.py +509 -0
  15. package/scripts/measure_context.py +113 -0
  16. package/scripts/publish-npm.py +138 -0
  17. package/scripts/watch_review.py +169 -0
  18. package/skills/doctor/SKILL.md +73 -115
  19. package/skills/doctor/agents/openai.yaml +5 -0
  20. package/skills/handoff/SKILL.md +99 -0
  21. package/skills/handoff/agents/openai.yaml +5 -0
  22. package/skills/handon/SKILL.md +61 -0
  23. package/skills/install/SKILL.md +79 -0
  24. package/skills/install/agents/openai.yaml +5 -0
  25. package/skills/review-pr/SKILL.md +59 -318
  26. package/skills/review-pr/reference/lenses.md +67 -0
  27. package/skills/review-pr/reference/procedure.md +348 -0
  28. package/skills-claude/attach-pr/SKILL.md +178 -0
  29. package/skills-claude/watch-review/SKILL.md +91 -0
  30. package/adapters/cursor/agents/executor.md +0 -17
  31. package/adapters/cursor/agents/expert.md +0 -70
  32. package/adapters/cursor/agents/explore.md +0 -16
  33. package/adapters/cursor/agents/implementer.md +0 -18
  34. package/adapters/cursor/agents/investigator.md +0 -18
  35. package/adapters/cursor/agents/planner.md +0 -28
  36. package/adapters/cursor/agents/review-lens.md +0 -22
  37. package/adapters/cursor/agents/reviewer.md +0 -34
  38. package/adapters/opencode/agents.json +0 -105
  39. package/adapters/opencode/plugin.js +0 -576
  40. package/config/MCP_PINS.md +0 -17
  41. package/config/models.json +0 -676
  42. package/hooks/bash-guard.py +0 -583
  43. package/hooks/cursor-guard.py +0 -84
  44. package/hooks/hooks-cursor.json +0 -11
  45. package/hooks/hooks.json +0 -20
  46. package/hooks/session-start.py +0 -148
  47. package/roles/executor.md +0 -15
  48. package/roles/expert.md +0 -67
  49. package/roles/explore.md +0 -13
  50. package/roles/implementer.md +0 -16
  51. package/roles/investigator.md +0 -15
  52. package/roles/planner.md +0 -25
  53. package/roles/review-lens.md +0 -20
  54. package/roles/reviewer.md +0 -31
  55. package/scripts/doctor.py +0 -520
  56. package/scripts/jsonc_bridge.cjs +0 -23
  57. package/scripts/memory.py +0 -744
  58. package/scripts/render_adapters.py +0 -508
  59. package/scripts/setup.py +0 -1753
  60. package/settings.json +0 -7
  61. package/skills/.gitkeep +0 -0
  62. package/skills/brainstorming/SKILL.md +0 -111
  63. package/skills/debugging/SKILL.md +0 -100
  64. package/skills/delegation/SKILL.md +0 -143
  65. package/skills/executing-plans/SKILL.md +0 -117
  66. package/skills/finishing-a-branch/SKILL.md +0 -125
  67. package/skills/freshness/SKILL.md +0 -131
  68. package/skills/memory/SKILL.md +0 -154
  69. package/skills/resolve-ticket/SKILL.md +0 -275
  70. package/skills/setup/SKILL.md +0 -199
  71. package/skills/setup/agents/openai.yaml +0 -5
  72. package/skills/test-first/SKILL.md +0 -92
  73. package/skills/using-leo/SKILL.md +0 -101
  74. package/skills/using-leo/references/claude-mapping.md +0 -33
  75. package/skills/using-leo/references/codex-mapping.md +0 -33
  76. package/skills/using-leo/references/cursor-mapping.md +0 -35
  77. package/skills/using-leo/references/hermes-mapping.md +0 -37
  78. package/skills/using-leo/references/opencode-mapping.md +0 -39
  79. package/skills/verification/SKILL.md +0 -110
  80. package/skills/visual-verification/SKILL.md +0 -115
  81. package/skills/watch-review/SKILL.md +0 -128
  82. package/skills/watch-review/agents/openai.yaml +0 -5
  83. package/skills/worktrees/SKILL.md +0 -131
  84. package/skills/writing-plans/SKILL.md +0 -97
  85. package/skills/writing-skills/SKILL.md +0 -141
  86. package/vendor/jsonc-parser-3.3.1/LICENSE.md +0 -21
  87. package/vendor/jsonc-parser-3.3.1/README.md +0 -26
  88. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +0 -201
  89. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +0 -275
  90. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +0 -682
  91. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +0 -456
  92. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +0 -42
  93. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +0 -351
  94. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +0 -194
  95. package/vendor/jsonc-parser-3.3.1/package.json +0 -37
  96. package/workflows/cost-tiered-fix.js +0 -287
@@ -1,576 +0,0 @@
1
- // Leo's OpenCode bridge: registers a generated shadow copy of the skills
2
- // tree, with every skill renamed leo-<name>, in place of the source tree
3
- // (OpenCode's config schema gives skills only `paths` and `urls` — no
4
- // namespace or prefix knob — verified against
5
- // https://opencode.ai/config.json, and OpenCode requires a skill's
6
- // frontmatter `name:` to equal its containing directory name, so the only
7
- // way to get a leo-<name> identity here is a generated copy living under
8
- // that name; there is no such constraint on Claude Code or Hermes, which
9
- // read leo: off their own namespace instead), injects the
10
- // generated subagent roster from adapters/opencode/agents.json, assembles
11
- // the using-leo policy plus the OpenCode mapping appendix and hands it to
12
- // OpenCode through config.instructions (belt) and the
13
- // experimental.chat.system.transform hook (braces), and guards Bash
14
- // execution the same way the Claude Code harness does.
15
- //
16
- // Node builtins only, ESM. No external dependencies. No runtime frontmatter
17
- // parsing and no env-var tier overrides: agents.json and this policy are
18
- // both generated by scripts/render_adapters.py from config/models.json, the
19
- // single source of truth every other harness also reads. (The shadow-skills
20
- // frontmatter rewrite below is not an exception to that: it edits one
21
- // generated-at-runtime `name:` line, not the policy or the roster.)
22
- //
23
- // Every read below is wrapped so a missing file degrades to "no
24
- // skills/agents/policy" rather than breaking session start — same fail-open
25
- // posture as hooks/session-start.py. Shadow-skills generation follows suit,
26
- // but does not register the source tree on failure: bare skills would falsely
27
- // advertise identities OpenCode cannot provide. It leaves a doctor breadcrumb
28
- // and starts without Leo skills instead.
29
-
30
- import { readFile, writeFile, mkdir, readdir, stat, rm, utimes, chmod } from 'node:fs/promises';
31
- import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
32
- import path from 'node:path';
33
- import os from 'node:os';
34
- import { createHash } from 'node:crypto';
35
- import { fileURLToPath } from 'node:url';
36
- import { spawn } from 'node:child_process';
37
-
38
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
39
-
40
- const LEO_POLICY_MARKER = '<leo-policy>';
41
-
42
- function localStateRoot() {
43
- return process.env.LEOS_AGENT_LOCAL_PATH || path.join(os.homedir(), '.leos-agent-local');
44
- }
45
-
46
- async function ensureLocalStateRoot() {
47
- const dir = localStateRoot();
48
- await mkdir(dir, { recursive: true, mode: 0o700 });
49
- await chmod(dir, 0o700);
50
- return dir;
51
- }
52
-
53
- function ensureLocalStateRootSync() {
54
- const dir = localStateRoot();
55
- mkdirSync(dir, { recursive: true, mode: 0o700 });
56
- chmodSync(dir, 0o700);
57
- return dir;
58
- }
59
-
60
- // One OpenCode process serves several project directories at once (its own log
61
- // shows one run= creating an instance per directory), and ESM caches this
62
- // module once per process, so nothing below may key off process.cwd() or hold
63
- // per-directory state in a bare module variable. The directory arrives on
64
- // PluginInput and is threaded through every consumer instead.
65
- function directoryKey(directory) {
66
- return createHash('sha256').update(directory).digest('hex').slice(0, 12);
67
- }
68
-
69
- // A 6-line strip, not a YAML parser: the body starts after the second
70
- // leading '---' fence line. Anything malformed just returns the raw text.
71
- function stripFrontmatter(raw) {
72
- const lines = raw.split('\n');
73
- if (!lines.length || lines[0].trim() !== '---') return raw;
74
- for (let i = 1; i < lines.length; i++) {
75
- if (lines[i].trim() === '---') {
76
- return lines.slice(i + 1).join('\n').replace(/^\n+/, '');
77
- }
78
- }
79
- return raw;
80
- }
81
-
82
- // --- Shadow skills tree ----------------------------------------------------
83
- //
84
- // OpenCode requires a skill's frontmatter `name:` to equal its directory
85
- // name and offers no separate namespace, so `leo-<name>` identities can only
86
- // come from an actual directory called `leo-<name>`. This section builds one
87
- // generated copy of skills/ per content hash under localStateRoot() and
88
- // hands its path to config() in place of the source tree.
89
- //
90
- // The directory name is the cache key, the same shape as
91
- // opencode-policy-<sha256[:12]>.md below: a hash over every file's relative
92
- // path and content, so any change anywhere under skills/ (a new skill, an
93
- // edited SKILL.md, an edited reference file) yields a new directory rather
94
- // than silently reusing a stale one — the failure mode that matters most
95
- // here, since a stale shadow tree fails quietly rather than loudly.
96
-
97
- const SHADOW_SKILLS_PREFIX = 'opencode-skills';
98
- const SHADOW_COMPLETE_MARKER = '.leo-shadow-complete';
99
- // How long a shadow tree must go untouched before a sweep may delete it.
100
- // Generous on purpose: the cost of keeping one too long is a few hundred KB
101
- // of disk, the cost of deleting one too early is pulling the skills directory
102
- // out from under a running session.
103
- const STALE_TREE_MS = 7 * 24 * 60 * 60 * 1000;
104
-
105
- // Recursively lists every file under `dir`, as paths relative to `dir`, in a
106
- // sorted, deterministic order — both the hash and the copy below depend on
107
- // walking every run in the same order regardless of the filesystem's own
108
- // directory-entry ordering.
109
- async function walkFiles(dir) {
110
- const out = [];
111
- async function recurse(sub) {
112
- let entries;
113
- try {
114
- entries = await readdir(path.join(dir, sub), { withFileTypes: true });
115
- } catch {
116
- return;
117
- }
118
- entries.sort((a, b) => a.name.localeCompare(b.name));
119
- for (const entry of entries) {
120
- const rel = sub ? path.join(sub, entry.name) : entry.name;
121
- if (entry.isDirectory()) {
122
- await recurse(rel);
123
- } else if (entry.isFile()) {
124
- out.push(rel);
125
- }
126
- }
127
- }
128
- await recurse('');
129
- return out.sort();
130
- }
131
-
132
- // A sha256 over every file's relative path and content under skills/ — not
133
- // mtimes, which lie across a git checkout/clone and would leave the shadow
134
- // tree pinned to whatever happened to be on disk the first time this ran.
135
- async function hashSkillsTree(skillsDir, files) {
136
- const hash = createHash('sha256');
137
- for (const rel of files) {
138
- hash.update(rel.split(path.sep).join('/') + '\0');
139
- hash.update(await readFile(path.join(skillsDir, rel)));
140
- }
141
- return hash.digest('hex').slice(0, 12);
142
- }
143
-
144
- // Same 6-line strip as stripFrontmatter, but rewriting rather than removing:
145
- // finds the `name:` line inside the frontmatter fence and replaces it. Every
146
- // other line — including the rest of the frontmatter and the whole body —
147
- // is copied through untouched.
148
- function rewriteSkillName(raw, newName) {
149
- const lines = raw.split('\n');
150
- if (!lines.length || lines[0].trim() !== '---') return raw;
151
- for (let i = 1; i < lines.length; i++) {
152
- if (lines[i].trim() === '---') break;
153
- if (/^name:\s*/.test(lines[i])) {
154
- lines[i] = `name: ${newName}`;
155
- return lines.join('\n');
156
- }
157
- }
158
- return raw;
159
- }
160
-
161
- // Copies every file under skills/ into <shadowRoot>/leo-<skill>/..., which
162
- // preserves subdirectories (skills/using-leo/references/) and sibling files
163
- // for free since it walks the real tree rather than special-casing SKILL.md.
164
- // Only each skill's own SKILL.md is rewritten; everything else is copied
165
- // verbatim, byte for byte.
166
- async function buildShadowSkillsTree(shadowRoot, skillsDir, files) {
167
- for (const rel of files) {
168
- const parts = rel.split(path.sep);
169
- // A file directly under skills/ (e.g. .gitkeep) belongs to no skill
170
- // directory and is not part of what OpenCode reads, so it is hashed
171
- // (any change there still invalidates the cache) but not copied.
172
- if (parts.length < 2) continue;
173
- const skillName = parts[0];
174
- const destFile = path.join(shadowRoot, `leo-${skillName}`, ...parts.slice(1));
175
- await mkdir(path.dirname(destFile), { recursive: true });
176
- if (parts.length === 2 && parts[1] === 'SKILL.md') {
177
- const raw = await readFile(path.join(skillsDir, rel), 'utf8');
178
- await writeFile(destFile, rewriteSkillName(raw, `leo-${skillName}`), 'utf8');
179
- } else {
180
- await writeFile(destFile, await readFile(path.join(skillsDir, rel)));
181
- }
182
- }
183
- }
184
-
185
- // Deletes opencode-skills-* directories that nothing has claimed recently.
186
- //
187
- // Never deletes on hash mismatch alone. Two Leo payloads can legitimately
188
- // share one state root — the repo's own documented dev setup points `plugin`
189
- // at a working tree beside the installed npm package — and each returns its
190
- // own tree from config(). An eager sweep would delete the *other* live
191
- // instance's skills directory out from under it, and an upgrade would do the
192
- // same to any session already open. Every call touches its own marker, so a
193
- // tree in use stays young; only one untouched for STALE_TREE_MS is garbage.
194
- async function cleanupStaleShadowTrees(root, currentHash) {
195
- const currentName = `${SHADOW_SKILLS_PREFIX}-${currentHash}`;
196
- let entries;
197
- try {
198
- entries = await readdir(root, { withFileTypes: true });
199
- } catch {
200
- return;
201
- }
202
- const cutoff = Date.now() - STALE_TREE_MS;
203
- for (const entry of entries) {
204
- if (!entry.isDirectory()) continue;
205
- if (!entry.name.startsWith(`${SHADOW_SKILLS_PREFIX}-`)) continue;
206
- if (entry.name === currentName) continue;
207
- const dir = path.join(root, entry.name);
208
- try {
209
- // The marker is the liveness signal; a tree without one never finished
210
- // building, and its own directory mtime is the best evidence available.
211
- let seen;
212
- try {
213
- seen = (await stat(path.join(dir, SHADOW_COMPLETE_MARKER))).mtimeMs;
214
- } catch {
215
- seen = (await stat(dir)).mtimeMs;
216
- }
217
- if (seen >= cutoff) continue;
218
- await rm(dir, { recursive: true, force: true });
219
- } catch {
220
- // A stale tree left behind costs disk, not correctness.
221
- }
222
- }
223
- }
224
-
225
- // breadcrumb + fail-open sibling of guardBreadcrumb below, for the one other
226
- // place in this file where "cannot do the real thing" must still be loud
227
- // somewhere rather than just silently degrading.
228
- function shadowSkillsBreadcrumb(err) {
229
- const reason = (err && err.message) || String(err);
230
- try {
231
- const dir = ensureLocalStateRootSync();
232
- appendFileSync(
233
- path.join(dir, 'opencode-skills.log'),
234
- `${new Date().toISOString()} shadow skills tree generation failed, ` +
235
- `registering no Leo skills: ${reason}\n`,
236
- );
237
- } catch {
238
- // A breadcrumb that cannot be written must not itself break the session.
239
- }
240
- console.error('[leo skills] shadow tree generation failed; registering no Leo skills:', reason);
241
- }
242
-
243
- // Builds (or reuses, keyed by content hash) the shadow tree and returns its
244
- // path. A failed build returns null rather than the source skills/ directory:
245
- // registering bare names would be a misleading partial success.
246
- async function getShadowSkillsDir() {
247
- const skillsDir = path.resolve(ROOT, 'skills');
248
- let files;
249
- try {
250
- files = await walkFiles(skillsDir);
251
- if (!files.length) throw new Error('no files found under skills/');
252
- } catch (err) {
253
- shadowSkillsBreadcrumb(err);
254
- return null;
255
- }
256
-
257
- let hash;
258
- try {
259
- hash = await hashSkillsTree(skillsDir, files);
260
- } catch (err) {
261
- shadowSkillsBreadcrumb(err);
262
- return null;
263
- }
264
-
265
- let stateRoot;
266
- try {
267
- stateRoot = await ensureLocalStateRoot();
268
- } catch (err) {
269
- shadowSkillsBreadcrumb(err);
270
- return null;
271
- }
272
- const shadowRoot = path.join(stateRoot, `${SHADOW_SKILLS_PREFIX}-${hash}`);
273
- const marker = path.join(shadowRoot, SHADOW_COMPLETE_MARKER);
274
-
275
- let alreadyBuilt = false;
276
- try {
277
- await stat(marker);
278
- alreadyBuilt = true;
279
- } catch {
280
- // No marker: either never built, or a previous build crashed mid-way.
281
- // Either way, (re)build below.
282
- }
283
-
284
- if (!alreadyBuilt) {
285
- try {
286
- await mkdir(shadowRoot, { recursive: true });
287
- await buildShadowSkillsTree(shadowRoot, skillsDir, files);
288
- // Written last: its presence is the only thing that means "complete",
289
- // so a build killed partway through never looks done on the next call.
290
- await writeFile(marker, hash + '\n', 'utf8');
291
- } catch (err) {
292
- shadowSkillsBreadcrumb(err);
293
- return null;
294
- }
295
- }
296
-
297
- // Mark this tree as in use *now*, so a concurrently live instance's tree
298
- // never looks abandoned to whoever sweeps next.
299
- try {
300
- const now = new Date();
301
- await utimes(marker, now, now);
302
- } catch {
303
- // Losing the touch only risks an early sweep of this tree; the next
304
- // config() call rebuilds it.
305
- }
306
-
307
- try {
308
- await cleanupStaleShadowTrees(stateRoot, hash);
309
- } catch {
310
- // Best-effort; see cleanupStaleShadowTrees.
311
- }
312
-
313
- return shadowRoot;
314
- }
315
-
316
- async function assemblePolicy(directory) {
317
- let skillRaw;
318
- try {
319
- skillRaw = await readFile(path.join(ROOT, 'skills', 'using-leo', 'SKILL.md'), 'utf8');
320
- } catch {
321
- return null;
322
- }
323
- let body = stripFrontmatter(skillRaw).trimEnd();
324
-
325
- let mapping = '';
326
- try {
327
- mapping = (await readFile(
328
- path.join(ROOT, 'skills', 'using-leo', 'references', 'opencode-mapping.md'),
329
- 'utf8',
330
- )).trimEnd();
331
- } catch {
332
- mapping = '';
333
- }
334
-
335
- let combined = mapping ? body + '\n\n' + mapping + '\n' : body + '\n';
336
- // Substitute AFTER the append: the mapping's own placeholders (e.g. a
337
- // claude-mapping-style workflow path) resolve too — same ordering
338
- // hooks/session-start.py:97 uses.
339
- combined = combined.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
340
-
341
- return LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
342
- }
343
-
344
- async function getPolicy(directory) {
345
- // Policy source and mapping are immutable package inputs, so cache only
346
- // those. Memory is repo-local mutable state and must be projected anew on
347
- // every config() call in this long-lived OpenCode process.
348
- if (!policyInputsCache) policyInputsCache = await assemblePolicy();
349
- let policy = policyInputsCache;
350
- if (!policy) return null;
351
- const memory = await memoryBlock(directory);
352
- if (memory) {
353
- policy += '\n\n<leo-memory>\n' + memory + '\n</leo-memory>';
354
- }
355
- return policy;
356
- }
357
-
358
- // Hard-kills after 4s: OpenCode session start must not hang on a memory store
359
- // living on a slow or unreachable filesystem. Any failure yields '' and the
360
- // session proceeds with policy only.
361
- //
362
- // `memory.py session` renders the block for the repo it is run in, so the
363
- // directory has to be the session's, not the server's: process.cwd() here
364
- // would serve one project's repo-scoped memories to every other project the
365
- // same OpenCode process is hosting.
366
- function memoryBlock(directory) {
367
- return new Promise((resolve) => {
368
- let done = false;
369
- const finish = (value) => { if (!done) { done = true; resolve(value); } };
370
- try {
371
- const child = spawn('python3', [path.join(ROOT, 'scripts', 'memory.py'), 'session'], {
372
- cwd: directory,
373
- stdio: ['ignore', 'pipe', 'ignore'],
374
- });
375
- const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} finish(''); }, 4000);
376
- let out = '';
377
- child.stdout.on('data', (chunk) => { out += chunk.toString(); });
378
- child.on('error', () => { clearTimeout(timer); finish(''); });
379
- child.on('close', (code) => {
380
- clearTimeout(timer);
381
- finish(code === 0 ? out.trim() : '');
382
- });
383
- } catch {
384
- finish('');
385
- }
386
- });
387
- }
388
-
389
- // Named per directory: the memory envelope inside is repo-scoped, so a single
390
- // shared filename would have each project's instance overwriting the file the
391
- // other projects' config.instructions already point at.
392
- async function writePolicyFile(policy, directory) {
393
- const dir = localStateRoot();
394
- const dest = path.join(dir, `opencode-policy-${directoryKey(directory)}.md`);
395
- const digest = createHash('sha256').update(policy).digest('hex');
396
- const cached = policyPathCache.get(directory);
397
- try {
398
- await ensureLocalStateRoot();
399
- if (cached && cached.digest === digest) {
400
- await chmod(cached.path, 0o600);
401
- return cached.path;
402
- }
403
- await writeFile(dest, policy, { encoding: 'utf8', mode: 0o600 });
404
- await chmod(dest, 0o600);
405
- policyPathCache.set(directory, { digest, path: dest });
406
- return dest;
407
- } catch {
408
- return null;
409
- }
410
- }
411
-
412
- // Keyed by directory for the same reason. A bare module variable is shared by
413
- // every instance in the process, which would pin whichever project started
414
- // first and serve its memories to the rest.
415
- let policyInputsCache = null;
416
- const policyPathCache = new Map();
417
- async function getPolicyPath(directory) {
418
- const policy = await getPolicy(directory);
419
- return policy ? await writePolicyFile(policy, directory) : null;
420
- }
421
-
422
- async function loadAgents() {
423
- try {
424
- const raw = await readFile(path.join(ROOT, 'adapters', 'opencode', 'agents.json'), 'utf8');
425
- return JSON.parse(raw);
426
- } catch {
427
- return {};
428
- }
429
- }
430
-
431
- function agentCollisionBreadcrumb(name) {
432
- const message = `preserving user OpenCode agent definition for ${name}`;
433
- try {
434
- const dir = ensureLocalStateRootSync();
435
- appendFileSync(path.join(dir, 'opencode-agents.log'), `${new Date().toISOString()} ${message}\n`);
436
- } catch {}
437
- console.warn(`[leo agents] ${message}`);
438
- }
439
-
440
- function registerAgents(config, agents) {
441
- config.agent ||= {};
442
- for (const [role, definition] of Object.entries(agents)) {
443
- const name = `leo-${role}`;
444
- if (Object.prototype.hasOwnProperty.call(config.agent, name)) {
445
- agentCollisionBreadcrumb(name);
446
- continue;
447
- }
448
- config.agent[name] = definition;
449
- }
450
- }
451
-
452
- // A guard that cannot run allows the command — the same posture as Claude
453
- // Code and Codex, where PreToolUse blocks only on exit 2 and any other
454
- // outcome is non-blocking. What must not happen is that it goes quiet: a
455
- // one-shot warning latch meant the second and every later unguarded command
456
- // passed with nothing said anywhere. Breadcrumb every one of them, to the
457
- // same local log the other bootstraps write to.
458
- function guardBreadcrumb(err) {
459
- const reason = (err && err.message) || String(err);
460
- try {
461
- const dir = ensureLocalStateRootSync();
462
- appendFileSync(
463
- path.join(dir, 'opencode-guard.log'),
464
- `${new Date().toISOString()} guard did not run, command allowed: ${reason}\n`,
465
- );
466
- } catch {
467
- // A breadcrumb that cannot be written must not itself break the session.
468
- }
469
- console.error('[leo guard] guard did not run, allowing command:', reason);
470
- }
471
-
472
- export default async function leoPlugin(ctx) {
473
- // The only place the session's directory is available. tool.execute.before
474
- // receives just {tool, sessionID, callID} (@opencode-ai/plugin's own types),
475
- // so reading a directory off that input yields undefined every time and
476
- // falls through to the server's cwd — a different tree whenever one process
477
- // hosts more than one project.
478
- const directory = (ctx && (ctx.directory || ctx.worktree)) || process.cwd();
479
-
480
- // OpenCode runs no session-start hook and exports no plugin-root variable,
481
- // so scripts/doctor.py cannot tell this harness apart from a bare shell and
482
- // used to inherit the bootstrap's "claude" default. Declare it, and do it
483
- // here so the guard and memory subprocesses inherit it too.
484
- process.env.LEOS_AGENT_HARNESS = 'opencode';
485
-
486
- return {
487
- async config(config) {
488
- config.skills ||= {};
489
- config.skills.paths ||= [];
490
- const skillsDir = await getShadowSkillsDir();
491
- if (skillsDir && !config.skills.paths.includes(skillsDir)) {
492
- config.skills.paths.push(skillsDir);
493
- }
494
-
495
- const agents = await loadAgents();
496
- registerAgents(config, agents);
497
-
498
- const policyPath = await getPolicyPath(directory);
499
- if (policyPath) {
500
- config.instructions ||= [];
501
- if (!config.instructions.includes(policyPath)) {
502
- config.instructions.push(policyPath);
503
- }
504
- }
505
- },
506
-
507
- // Fallback only, for the two upstream reports that either the config
508
- // hook or this array mutation is silently discarded (superpowers#1087,
509
- // opencode#17100): whichever channel actually works, exactly one copy
510
- // of the policy lands, because this checks for the marker first.
511
- 'experimental.chat.system.transform': async (_input, output) => {
512
- const system = output && output.system;
513
- if (!Array.isArray(system)) return;
514
- if (system.some((s) => typeof s === 'string' && s.includes(LEO_POLICY_MARKER))) return;
515
-
516
- const policy = await getPolicy(directory);
517
- if (policy) system.push(policy);
518
- },
519
-
520
- 'tool.execute.before': async (input, output) => {
521
- if (!input || input.tool !== 'bash') return;
522
- const command = output && output.args && output.args.command;
523
- if (typeof command !== 'string' || !command) return;
524
-
525
- // bash-guard expands relative targets against this cwd, so the wrong
526
- // one misjudges in both directions: a home-level delete read as safe,
527
- // a project-local delete read as critical.
528
- const payload = JSON.stringify({
529
- tool_name: 'Bash',
530
- tool_input: { command },
531
- cwd: directory,
532
- });
533
- const guardPath = path.join(ROOT, 'hooks', 'bash-guard.py');
534
-
535
- let exitCode;
536
- let stderr = '';
537
- let timer;
538
- try {
539
- exitCode = await new Promise((resolve, reject) => {
540
- const proc = spawn('python3', [guardPath], { stdio: ['pipe', 'ignore', 'pipe'] });
541
- let settled = false;
542
- const succeed = (value) => { if (!settled) { settled = true; resolve(value); } };
543
- const fail = (err) => { if (!settled) { settled = true; reject(err); } };
544
- // Every other guard channel is bounded — Claude Code, Codex and
545
- // Cursor all set timeout 10 in their hook manifests, and Hermes
546
- // runs in-process so it cannot hang on its own. Unbounded, a wedged
547
- // python3 hangs the tool call forever with nothing shown to anyone.
548
- timer = setTimeout(() => {
549
- try { proc.kill('SIGKILL'); } catch {}
550
- fail(new Error('bash-guard.py timed out after 10s'));
551
- }, 10000);
552
- proc.stderr.on('data', (d) => {
553
- stderr += d.toString();
554
- });
555
- proc.on('error', fail);
556
- proc.on('close', (code) => succeed(code));
557
- // An early child exit can turn either write or end into EPIPE. Keep
558
- // handlers on both paths so it follows the existing breadcrumbed
559
- // fail-open route instead of becoming an unhandled Node error.
560
- proc.stdin.on('error', fail);
561
- proc.stdin.write(payload, (err) => { if (err) fail(err); });
562
- proc.stdin.end((err) => { if (err) fail(err); });
563
- });
564
- } catch (err) {
565
- guardBreadcrumb(err);
566
- return; // infra fail-open
567
- } finally {
568
- clearTimeout(timer);
569
- }
570
-
571
- if (exitCode === 2) {
572
- throw new Error(stderr.trim() || '[leo guard] blocked');
573
- }
574
- },
575
- };
576
- }
@@ -1,17 +0,0 @@
1
- # MCP executable pins
2
-
3
- Leo setup executes only the exact core package versions declared together in
4
- `models.json`'s `command` and `exactVersion` fields:
5
-
6
- - `@upstash/context7-mcp@3.2.5`
7
- - `@playwright/mcp@0.0.78`
8
- - `chrome-devtools-mcp@1.6.0`
9
- - `duckduckgo-mcp-server==0.5.0`
10
-
11
- To update a pin, review the upstream release and its vendor-owned package
12
- record, confirm that its command-line and MCP transport contract still match
13
- setup's rendered shape, then change the command argument and `exactVersion`
14
- in the same commit. Never use an unqualified package name, range, tag, or
15
- `@latest`. Run both Python 3.9 and 3.14 setup suites, render adapters with
16
- `--check`, and inspect a dry-run for every affected harness. The final diff
17
- must receive the normal Opus-tier review before release.