leos-agent 6.1.1 → 7.0.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -11
  3. package/adapters/cursor/agents/executor.md +2 -2
  4. package/adapters/cursor/agents/implementer.md +2 -2
  5. package/adapters/cursor/agents/review-lens.md +22 -0
  6. package/adapters/cursor/agents/reviewer.md +3 -2
  7. package/adapters/opencode/agents.json +44 -5
  8. package/adapters/opencode/plugin.js +435 -47
  9. package/config/MCP_PINS.md +17 -0
  10. package/config/models.json +647 -33
  11. package/hooks/bash-guard.py +51 -9
  12. package/hooks/session-start.py +27 -0
  13. package/package.json +19 -8
  14. package/roles/executor.md +2 -2
  15. package/roles/implementer.md +2 -2
  16. package/roles/review-lens.md +20 -0
  17. package/roles/reviewer.md +3 -2
  18. package/scripts/doctor.py +520 -0
  19. package/scripts/ghreview.py +558 -0
  20. package/scripts/jsonc_bridge.cjs +23 -0
  21. package/scripts/memory.py +744 -0
  22. package/scripts/render_adapters.py +253 -121
  23. package/scripts/resolve_attach_target.py +389 -0
  24. package/scripts/setup.py +1753 -0
  25. package/skills/brainstorming/SKILL.md +3 -1
  26. package/skills/debugging/SKILL.md +4 -2
  27. package/skills/delegation/SKILL.md +10 -8
  28. package/skills/doctor/SKILL.md +124 -0
  29. package/skills/executing-plans/SKILL.md +2 -1
  30. package/skills/finishing-a-branch/SKILL.md +4 -2
  31. package/skills/freshness/SKILL.md +131 -0
  32. package/skills/memory/SKILL.md +154 -0
  33. package/skills/resolve-ticket/SKILL.md +275 -0
  34. package/skills/review-pr/SKILL.md +327 -0
  35. package/skills/setup/SKILL.md +199 -0
  36. package/skills/setup/agents/openai.yaml +5 -0
  37. package/skills/test-first/SKILL.md +3 -1
  38. package/skills/using-leo/SKILL.md +18 -6
  39. package/skills/using-leo/references/claude-mapping.md +23 -1
  40. package/skills/using-leo/references/codex-mapping.md +18 -9
  41. package/skills/using-leo/references/cursor-mapping.md +19 -6
  42. package/skills/using-leo/references/hermes-mapping.md +18 -7
  43. package/skills/using-leo/references/opencode-mapping.md +18 -9
  44. package/skills/verification/SKILL.md +9 -1
  45. package/skills/visual-verification/SKILL.md +115 -0
  46. package/skills/watch-review/SKILL.md +128 -0
  47. package/skills/watch-review/agents/openai.yaml +5 -0
  48. package/skills/worktrees/SKILL.md +3 -1
  49. package/skills/writing-plans/SKILL.md +2 -1
  50. package/skills/writing-skills/SKILL.md +141 -0
  51. package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
  52. package/vendor/jsonc-parser-3.3.1/README.md +26 -0
  53. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
  54. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
  55. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
  56. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
  57. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
  58. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
  59. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
  60. package/vendor/jsonc-parser-3.3.1/package.json +37 -0
  61. package/workflows/cost-tiered-fix.js +32 -4
@@ -1,6 +1,12 @@
1
- // Leo's OpenCode bridge: registers the skills dir by path (OpenCode names
2
- // each skill from its own frontmatter, so there is no leo: prefix here as
3
- // there is on Claude Code and Hermes), injects the
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
4
10
  // generated subagent roster from adapters/opencode/agents.json, assembles
5
11
  // the using-leo policy plus the OpenCode mapping appendix and hands it to
6
12
  // OpenCode through config.instructions (belt) and the
@@ -10,15 +16,22 @@
10
16
  // Node builtins only, ESM. No external dependencies. No runtime frontmatter
11
17
  // parsing and no env-var tier overrides: agents.json and this policy are
12
18
  // both generated by scripts/render_adapters.py from config/models.json, the
13
- // single source of truth every other harness also reads.
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.)
14
22
  //
15
23
  // Every read below is wrapped so a missing file degrades to "no
16
24
  // skills/agents/policy" rather than breaking session start — same fail-open
17
- // posture as hooks/session-start.py.
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.
18
29
 
19
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
30
+ import { readFile, writeFile, mkdir, readdir, stat, rm, utimes, chmod } from 'node:fs/promises';
31
+ import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
20
32
  import path from 'node:path';
21
33
  import os from 'node:os';
34
+ import { createHash } from 'node:crypto';
22
35
  import { fileURLToPath } from 'node:url';
23
36
  import { spawn } from 'node:child_process';
24
37
 
@@ -30,6 +43,29 @@ function localStateRoot() {
30
43
  return process.env.LEOS_AGENT_LOCAL_PATH || path.join(os.homedir(), '.leos-agent-local');
31
44
  }
32
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
+
33
69
  // A 6-line strip, not a YAML parser: the body starts after the second
34
70
  // leading '---' fence line. Anything malformed just returns the raw text.
35
71
  function stripFrontmatter(raw) {
@@ -43,7 +79,241 @@ function stripFrontmatter(raw) {
43
79
  return raw;
44
80
  }
45
81
 
46
- async function assemblePolicy() {
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) {
47
317
  let skillRaw;
48
318
  try {
49
319
  skillRaw = await readFile(path.join(ROOT, 'skills', 'using-leo', 'SKILL.md'), 'utf8');
@@ -71,33 +341,82 @@ async function assemblePolicy() {
71
341
  return LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
72
342
  }
73
343
 
74
- async function writePolicyFile(policy) {
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) {
75
393
  const dir = localStateRoot();
76
- const dest = path.join(dir, 'opencode-policy.md');
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);
77
397
  try {
78
- await mkdir(dir, { recursive: true });
79
- await writeFile(dest, policy, 'utf8');
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 });
80
406
  return dest;
81
407
  } catch {
82
408
  return null;
83
409
  }
84
410
  }
85
411
 
86
- let policyCache;
87
- async function getPolicy() {
88
- if (policyCache === undefined) {
89
- policyCache = await assemblePolicy();
90
- }
91
- return policyCache;
92
- }
93
-
94
- let policyPathCache;
95
- async function getPolicyPath() {
96
- if (policyPathCache === undefined) {
97
- const policy = await getPolicy();
98
- policyPathCache = policy ? await writePolicyFile(policy) : null;
99
- }
100
- return policyPathCache;
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;
101
420
  }
102
421
 
103
422
  async function loadAgents() {
@@ -109,23 +428,74 @@ async function loadAgents() {
109
428
  }
110
429
  }
111
430
 
112
- let guardWarnedOnce = false;
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';
113
485
 
114
- export default async function leoPlugin(_ctx) {
115
486
  return {
116
487
  async config(config) {
117
488
  config.skills ||= {};
118
489
  config.skills.paths ||= [];
119
- const skillsDir = path.resolve(ROOT, 'skills');
120
- if (!config.skills.paths.includes(skillsDir)) {
490
+ const skillsDir = await getShadowSkillsDir();
491
+ if (skillsDir && !config.skills.paths.includes(skillsDir)) {
121
492
  config.skills.paths.push(skillsDir);
122
493
  }
123
494
 
124
495
  const agents = await loadAgents();
125
- config.agent ||= {};
126
- Object.assign(config.agent, agents);
496
+ registerAgents(config, agents);
127
497
 
128
- const policyPath = await getPolicyPath();
498
+ const policyPath = await getPolicyPath(directory);
129
499
  if (policyPath) {
130
500
  config.instructions ||= [];
131
501
  if (!config.instructions.includes(policyPath)) {
@@ -143,7 +513,7 @@ export default async function leoPlugin(_ctx) {
143
513
  if (!Array.isArray(system)) return;
144
514
  if (system.some((s) => typeof s === 'string' && s.includes(LEO_POLICY_MARKER))) return;
145
515
 
146
- const policy = await getPolicy();
516
+ const policy = await getPolicy(directory);
147
517
  if (policy) system.push(policy);
148
518
  },
149
519
 
@@ -152,32 +522,50 @@ export default async function leoPlugin(_ctx) {
152
522
  const command = output && output.args && output.args.command;
153
523
  if (typeof command !== 'string' || !command) return;
154
524
 
155
- const cwd = (input && (input.directory || input.worktree || input.cwd)) || process.cwd();
156
- const payload = JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd });
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
+ });
157
533
  const guardPath = path.join(ROOT, 'hooks', 'bash-guard.py');
158
534
 
159
535
  let exitCode;
160
536
  let stderr = '';
537
+ let timer;
161
538
  try {
162
539
  exitCode = await new Promise((resolve, reject) => {
163
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);
164
552
  proc.stderr.on('data', (d) => {
165
553
  stderr += d.toString();
166
554
  });
167
- proc.on('error', reject);
168
- proc.on('close', (code) => resolve(code));
169
- proc.stdin.write(payload);
170
- proc.stdin.end();
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); });
171
563
  });
172
564
  } catch (err) {
173
- if (!guardWarnedOnce) {
174
- guardWarnedOnce = true;
175
- console.error(
176
- '[leo guard] infra failure spawning bash-guard.py, allowing command:',
177
- err && err.message ? err.message : err,
178
- );
179
- }
565
+ guardBreadcrumb(err);
180
566
  return; // infra fail-open
567
+ } finally {
568
+ clearTimeout(timer);
181
569
  }
182
570
 
183
571
  if (exitCode === 2) {
@@ -0,0 +1,17 @@
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.