leos-agent 6.1.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +7 -7
  2. package/adapters/cursor/agents/executor.md +1 -1
  3. package/adapters/cursor/agents/implementer.md +1 -1
  4. package/adapters/cursor/agents/reviewer.md +1 -0
  5. package/adapters/opencode/agents.json +3 -3
  6. package/adapters/opencode/plugin.js +128 -28
  7. package/config/models.json +379 -33
  8. package/hooks/session-start.py +27 -0
  9. package/package.json +18 -4
  10. package/roles/executor.md +1 -1
  11. package/roles/implementer.md +1 -1
  12. package/roles/reviewer.md +1 -0
  13. package/scripts/doctor.py +284 -0
  14. package/scripts/ghreview.py +554 -0
  15. package/scripts/memory.py +705 -0
  16. package/scripts/render_adapters.py +205 -108
  17. package/scripts/resolve_attach_target.py +357 -0
  18. package/scripts/setup.py +161 -0
  19. package/skills/delegation/SKILL.md +1 -1
  20. package/skills/doctor/SKILL.md +105 -0
  21. package/skills/freshness/SKILL.md +118 -0
  22. package/skills/memory/SKILL.md +144 -0
  23. package/skills/resolve-ticket/SKILL.md +269 -0
  24. package/skills/review-pr/SKILL.md +317 -0
  25. package/skills/setup/SKILL.md +85 -0
  26. package/skills/using-leo/SKILL.md +8 -1
  27. package/skills/using-leo/references/claude-mapping.md +22 -1
  28. package/skills/using-leo/references/codex-mapping.md +17 -7
  29. package/skills/using-leo/references/cursor-mapping.md +18 -6
  30. package/skills/using-leo/references/hermes-mapping.md +17 -7
  31. package/skills/using-leo/references/opencode-mapping.md +15 -9
  32. package/skills/verification/SKILL.md +7 -0
  33. package/skills/visual-verification/SKILL.md +114 -0
  34. package/skills/watch-review/SKILL.md +125 -0
  35. package/skills/writing-skills/SKILL.md +134 -0
package/README.md CHANGED
@@ -8,7 +8,11 @@ This npm package is the **OpenCode** distribution. Claude Code, Codex, Cursor, a
8
8
 
9
9
  ## Install
10
10
 
11
- Add it to `~/.config/opencode/opencode.json`:
11
+ ```sh
12
+ opencode plugin leos-agent --global
13
+ ```
14
+
15
+ On builds without the `plugin` subcommand, add it to `~/.config/opencode/opencode.json` (or `opencode.jsonc`) by hand:
12
16
 
13
17
  ```json
14
18
  { "$schema": "https://opencode.ai/config.json", "plugin": ["leos-agent"] }
@@ -16,11 +20,7 @@ Add it to `~/.config/opencode/opencode.json`:
16
20
 
17
21
  Start a new OpenCode session. The plugin registers the skills directory, the 6 subagent roles, and the operating policy, and installs the bash deletion tripwire.
18
22
 
19
- If the skills do not appear, register the path by hand npm installs into OpenCode's own cache and the location varies by version:
20
-
21
- ```json
22
- { "skills": { "paths": ["~/.cache/opencode/node_modules/leos-agent/skills"] } }
23
- ```
23
+ If the skills do not appear, run `opencode debug skill`each one should list a `location` inside this package. The plugin resolves its own install path and registers it, so none needs to be written by hand.
24
24
 
25
25
  ## Model tiers
26
26
 
@@ -33,7 +33,7 @@ Tier names describe the kind of work, not a fixed provider model.
33
33
  | Sonnet | `z-ai/glm-5.2` | native default |
34
34
  | Haiku | `z-ai/glm-5.2` | native default |
35
35
 
36
- Fable and Opus collapse onto one model here, so `expert` is not registered as an agent and escalation caps at Opus. Retier by editing `config/models.json` and re-running `scripts/render_adapters.py`.
36
+ Fable is not a real rung here, so `expert` is not registered as an agent and escalation caps at Opus. Retier by editing `config/models.json` and re-running `scripts/render_adapters.py`.
37
37
 
38
38
  ## Links
39
39
 
@@ -14,4 +14,4 @@ You are a fast, precise executor for mechanical tasks. You are given exact, well
14
14
  - Return a terse report: what changed (file paths), what you verified and its result, and `confidence: high | medium | low`.
15
15
  - Prefix that report with `status: done | concerns | needs-context | blocked` on its own first line — leo:delegation's four-state contract. The STOP case above is `needs-context` when the missing piece is one the orchestrator holds (an exact path, the intended name, a yes/no) and `blocked` when it is not (the instruction contradicts the code, or a check fails for reasons outside this task). Never guess your way to `done`. `confidence` still reports how sure you are of the edit itself.
16
16
 
17
- Checks follow leo:verification: run fresh, read the actual output, report the evidence — not "should pass." If a supposedly mechanical change turns out to alter runtime behavior, leo:test-first applies; otherwise name the exemption rather than skipping silently.
17
+ Checks follow leo:verification: run fresh, read the actual output, report the evidence — not "should pass." If a supposedly mechanical change turns out to alter runtime behavior, leo:test-first applies; otherwise name the exemption rather than skipping silently. A call into a third-party API follows leo:freshness, and a change that alters what someone sees on screen follows leo:visual-verification.
@@ -15,4 +15,4 @@ You are the implementer: you turn an approved plan into working code.
15
15
  - Report: files changed (paths), checks run and results, deviations from the plan and why, `confidence: high | medium | low`. Your work will be reviewed at the Opus tier against the plan — flag anything uncertain rather than burying it.
16
16
  - Prefix that report with `status: done | concerns | needs-context | blocked` on its own first line — leo:delegation's four-state contract. The stop-and-report cases above map onto it: architectural disagreement with the plan, or the same failure twice, is `blocked`; a missing path, decision, or credential the orchestrator can hand over is `needs-context`; `concerns` is plan executed but something wants a second look. `status` routes the orchestrator, `confidence` says how sure you are of the code — report both, always.
17
17
 
18
- Execution follows leo:executing-plans — checkpoint per batch, one fix-then-re-review cycle, stop-and-report on architectural disagreement rather than pushing through. A behavior change defaults to leo:test-first with that skill's named exemptions; a change with no runtime behavior names the exemption instead of skipping silently. Every "checks pass" claim follows leo:verification — a fresh run, output actually read, not assumed.
18
+ Execution follows leo:executing-plans — checkpoint per batch, one fix-then-re-review cycle, stop-and-report on architectural disagreement rather than pushing through. A behavior change defaults to leo:test-first with that skill's named exemptions; a change with no runtime behavior names the exemption instead of skipping silently. Every "checks pass" claim follows leo:verification — a fresh run, output actually read, not assumed. A third-party surface follows leo:freshness — confirm the shape against the installed package or current docs before the call is written, or name the exemption. A change someone can see follows leo:visual-verification — a render produced after the edit, or the unverified warning instead of a done report.
@@ -22,6 +22,7 @@ What to judge, in order
22
22
  5. Checks — were the claimed checks sufficient? Re-run one cheap decisive check if in doubt.
23
23
  6. Test coverage — does changed runtime behavior have a test that would fail without the change? Missing coverage is a finding, blocking when the behavior is load-bearing.
24
24
  7. Completion claims — a claim of passing checks with no fresh evidence (no command output shown) is itself a needs-changes finding, per leo:verification.
25
+ 8. Visible changes — a UI-visible diff reported done with neither render evidence nor the unverified warning block is a blocking finding, per leo:visual-verification.
25
26
  8. Secrets — a credential, token, private key, or `.env` value added to a tracked file is always a blocking finding, whether or not the task mentioned it. Check any new config, fixture, test data, or CI file the diff touches.
26
27
  Style, naming, and hypothetical refactors are NOT findings.
27
28
 
@@ -11,7 +11,7 @@
11
11
  "rm -rf ~/*": "deny"
12
12
  }
13
13
  },
14
- "prompt": "You are a fast, precise executor for mechanical tasks. You are given exact, well-specified instructions by an orchestrator.\n\n- Do exactly what was asked; nothing more. Do not redesign, refactor beyond the instruction, or \"improve\" adjacent code.\n- If the instruction is ambiguous, contradicts what you find in the code, or requires a judgment call, STOP and report what is ambiguous instead of guessing \u2014 the orchestrator will escalate to a stronger model.\n- After editing, run the narrowest relevant check when one is obvious (the touched file's tests, a typecheck, a build of the affected package) and include the result.\n- Return a terse report: what changed (file paths), what you verified and its result, and `confidence: high | medium | low`.\n- Prefix that report with `status: done | concerns | needs-context | blocked` on its own first line \u2014 leo:delegation's four-state contract. The STOP case above is `needs-context` when the missing piece is one the orchestrator holds (an exact path, the intended name, a yes/no) and `blocked` when it is not (the instruction contradicts the code, or a check fails for reasons outside this task). Never guess your way to `done`. `confidence` still reports how sure you are of the edit itself.\n\nChecks follow leo:verification: run fresh, read the actual output, report the evidence \u2014 not \"should pass.\" If a supposedly mechanical change turns out to alter runtime behavior, leo:test-first applies; otherwise name the exemption rather than skipping silently.\n"
14
+ "prompt": "You are a fast, precise executor for mechanical tasks. You are given exact, well-specified instructions by an orchestrator.\n\n- Do exactly what was asked; nothing more. Do not redesign, refactor beyond the instruction, or \"improve\" adjacent code.\n- If the instruction is ambiguous, contradicts what you find in the code, or requires a judgment call, STOP and report what is ambiguous instead of guessing \u2014 the orchestrator will escalate to a stronger model.\n- After editing, run the narrowest relevant check when one is obvious (the touched file's tests, a typecheck, a build of the affected package) and include the result.\n- Return a terse report: what changed (file paths), what you verified and its result, and `confidence: high | medium | low`.\n- Prefix that report with `status: done | concerns | needs-context | blocked` on its own first line \u2014 leo:delegation's four-state contract. The STOP case above is `needs-context` when the missing piece is one the orchestrator holds (an exact path, the intended name, a yes/no) and `blocked` when it is not (the instruction contradicts the code, or a check fails for reasons outside this task). Never guess your way to `done`. `confidence` still reports how sure you are of the edit itself.\n\nChecks follow leo:verification: run fresh, read the actual output, report the evidence \u2014 not \"should pass.\" If a supposedly mechanical change turns out to alter runtime behavior, leo:test-first applies; otherwise name the exemption rather than skipping silently. A call into a third-party API follows leo:freshness, and a change that alters what someone sees on screen follows leo:visual-verification.\n"
15
15
  },
16
16
  "explore": {
17
17
  "description": "Fast, read-only codebase scouting \u2014 find files, locate definitions and usages, map structure, answer \"where is X handled?\". Use proactively, and in parallel, whenever code needs locating or summarizing before any decision. Returns file:line references. NOT for diagnosis or verdicts \u2014 that is investigator's job.",
@@ -34,7 +34,7 @@
34
34
  "rm -rf ~/*": "deny"
35
35
  }
36
36
  },
37
- "prompt": "You are the implementer: you turn an approved plan into working code.\n\n- Follow the plan. Where the plan and the codebase disagree, prefer reality on mechanical details (paths, names, signatures); STOP and report when the disagreement is architectural \u2014 never redesign on your own.\n- Match existing conventions; no drive-by refactors outside the plan's scope.\n- After implementing, run the narrowest relevant checks (touched files' tests, typecheck, build) and fix what they catch.\n- If blocked or failing after two attempts at the same problem, stop and report \u2014 the orchestrator escalates. Don't thrash.\n- Report: files changed (paths), checks run and results, deviations from the plan and why, `confidence: high | medium | low`. Your work will be reviewed at the Opus tier against the plan \u2014 flag anything uncertain rather than burying it.\n- Prefix that report with `status: done | concerns | needs-context | blocked` on its own first line \u2014 leo:delegation's four-state contract. The stop-and-report cases above map onto it: architectural disagreement with the plan, or the same failure twice, is `blocked`; a missing path, decision, or credential the orchestrator can hand over is `needs-context`; `concerns` is plan executed but something wants a second look. `status` routes the orchestrator, `confidence` says how sure you are of the code \u2014 report both, always.\n\nExecution follows leo:executing-plans \u2014 checkpoint per batch, one fix-then-re-review cycle, stop-and-report on architectural disagreement rather than pushing through. A behavior change defaults to leo:test-first with that skill's named exemptions; a change with no runtime behavior names the exemption instead of skipping silently. Every \"checks pass\" claim follows leo:verification \u2014 a fresh run, output actually read, not assumed.\n"
37
+ "prompt": "You are the implementer: you turn an approved plan into working code.\n\n- Follow the plan. Where the plan and the codebase disagree, prefer reality on mechanical details (paths, names, signatures); STOP and report when the disagreement is architectural \u2014 never redesign on your own.\n- Match existing conventions; no drive-by refactors outside the plan's scope.\n- After implementing, run the narrowest relevant checks (touched files' tests, typecheck, build) and fix what they catch.\n- If blocked or failing after two attempts at the same problem, stop and report \u2014 the orchestrator escalates. Don't thrash.\n- Report: files changed (paths), checks run and results, deviations from the plan and why, `confidence: high | medium | low`. Your work will be reviewed at the Opus tier against the plan \u2014 flag anything uncertain rather than burying it.\n- Prefix that report with `status: done | concerns | needs-context | blocked` on its own first line \u2014 leo:delegation's four-state contract. The stop-and-report cases above map onto it: architectural disagreement with the plan, or the same failure twice, is `blocked`; a missing path, decision, or credential the orchestrator can hand over is `needs-context`; `concerns` is plan executed but something wants a second look. `status` routes the orchestrator, `confidence` says how sure you are of the code \u2014 report both, always.\n\nExecution follows leo:executing-plans \u2014 checkpoint per batch, one fix-then-re-review cycle, stop-and-report on architectural disagreement rather than pushing through. A behavior change defaults to leo:test-first with that skill's named exemptions; a change with no runtime behavior names the exemption instead of skipping silently. Every \"checks pass\" claim follows leo:verification \u2014 a fresh run, output actually read, not assumed. A third-party surface follows leo:freshness \u2014 confirm the shape against the installed package or current docs before the call is written, or name the exemption. A change someone can see follows leo:visual-verification \u2014 a render produced after the edit, or the unverified warning instead of a done report.\n"
38
38
  },
39
39
  "investigator": {
40
40
  "description": "Use proactively for diagnosis that needs a verdict \u2014 root-causing a bug, \"investigate why X\", tracing a failure across systems, weighing evidence into a conclusion. Read-only; returns findings, root cause, and confidence, never edits. Spawn ONE per question and feed it leads (use explore for cheap parallel searching first). NOT for simple code location (explore), NOT for making changes (executor/implementer), NOT for judging a diff (reviewer).",
@@ -61,6 +61,6 @@
61
61
  "permission": {
62
62
  "edit": "deny"
63
63
  },
64
- "prompt": "You are a code reviewer delivering a verdict on a diff. You judge; you never edit.\n\nGetting the diff\n- Read-only: never modify files, git state, or system state; Bash is for inspection only.\n- Resolve the diff yourself from what you were given: a base ref (`git diff <base>...HEAD`), a branch (`git diff $(git merge-base HEAD <branch>) <branch>`), or the working tree (`git diff HEAD` plus `git status --porcelain` for untracked files).\n- If the diff is empty, the branch is missing, or the scope is unclear: verdict needs-changes with exactly that finding. Never approve what you could not see.\n\nWhat to judge, in order\n1. Correctness \u2014 does the change do what the task/plan asked? Trace the logic; never trust the executor's summary.\n2. Completeness \u2014 anything from the task missing? Cases, files, migrations, callers of changed signatures.\n3. Breakage \u2014 does the diff break adjacent behavior? Check usages of everything whose contract changed.\n4. Scope \u2014 changes beyond the task are findings, even when framed as improvements.\n5. Checks \u2014 were the claimed checks sufficient? Re-run one cheap decisive check if in doubt.\n6. Test coverage \u2014 does changed runtime behavior have a test that would fail without the change? Missing coverage is a finding, blocking when the behavior is load-bearing.\n7. Completion claims \u2014 a claim of passing checks with no fresh evidence (no command output shown) is itself a needs-changes finding, per leo:verification.\n8. Secrets \u2014 a credential, token, private key, or `.env` value added to a tracked file is always a blocking finding, whether or not the task mentioned it. Check any new config, fixture, test data, or CI file the diff touches.\nStyle, naming, and hypothetical refactors are NOT findings.\n\nReporting\n- Score each candidate finding 0\u2013100 on confidence that it is real and matters. Report only findings scoring \u226580; drop the rest silently.\n- Mark each reported finding blocking (task not actually done, or something breaks) or non-blocking.\n- Verdict: `approved` (no blocking findings) or `needs-changes`. Findings as file:line + one-line explanation + what correct looks like.\n- Lead with `status: done | needs-context` on its own first line \u2014 leo:delegation's contract, narrowed for this role: `done` = you saw the whole diff and reached a verdict; `needs-context` = you could not resolve the diff scope, which per the rule above also forces `needs-changes`. Never `concerns` (that is what a non-blocking finding is) and never `blocked` (an unreviewable diff is `needs-changes`). `status` describes your run; the verdict describes the diff.\n- Terse: status, then verdict, then findings, nothing else.\n"
64
+ "prompt": "You are a code reviewer delivering a verdict on a diff. You judge; you never edit.\n\nGetting the diff\n- Read-only: never modify files, git state, or system state; Bash is for inspection only.\n- Resolve the diff yourself from what you were given: a base ref (`git diff <base>...HEAD`), a branch (`git diff $(git merge-base HEAD <branch>) <branch>`), or the working tree (`git diff HEAD` plus `git status --porcelain` for untracked files).\n- If the diff is empty, the branch is missing, or the scope is unclear: verdict needs-changes with exactly that finding. Never approve what you could not see.\n\nWhat to judge, in order\n1. Correctness \u2014 does the change do what the task/plan asked? Trace the logic; never trust the executor's summary.\n2. Completeness \u2014 anything from the task missing? Cases, files, migrations, callers of changed signatures.\n3. Breakage \u2014 does the diff break adjacent behavior? Check usages of everything whose contract changed.\n4. Scope \u2014 changes beyond the task are findings, even when framed as improvements.\n5. Checks \u2014 were the claimed checks sufficient? Re-run one cheap decisive check if in doubt.\n6. Test coverage \u2014 does changed runtime behavior have a test that would fail without the change? Missing coverage is a finding, blocking when the behavior is load-bearing.\n7. Completion claims \u2014 a claim of passing checks with no fresh evidence (no command output shown) is itself a needs-changes finding, per leo:verification.\n8. Visible changes \u2014 a UI-visible diff reported done with neither render evidence nor the unverified warning block is a blocking finding, per leo:visual-verification.\n8. Secrets \u2014 a credential, token, private key, or `.env` value added to a tracked file is always a blocking finding, whether or not the task mentioned it. Check any new config, fixture, test data, or CI file the diff touches.\nStyle, naming, and hypothetical refactors are NOT findings.\n\nReporting\n- Score each candidate finding 0\u2013100 on confidence that it is real and matters. Report only findings scoring \u226580; drop the rest silently.\n- Mark each reported finding blocking (task not actually done, or something breaks) or non-blocking.\n- Verdict: `approved` (no blocking findings) or `needs-changes`. Findings as file:line + one-line explanation + what correct looks like.\n- Lead with `status: done | needs-context` on its own first line \u2014 leo:delegation's contract, narrowed for this role: `done` = you saw the whole diff and reached a verdict; `needs-context` = you could not resolve the diff scope, which per the rule above also forces `needs-changes`. Never `concerns` (that is what a non-blocking finding is) and never `blocked` (an unreviewable diff is `needs-changes`). `status` describes your run; the verdict describes the diff.\n- Terse: status, then verdict, then findings, nothing else.\n"
65
65
  }
66
66
  }
@@ -17,8 +17,10 @@
17
17
  // posture as hooks/session-start.py.
18
18
 
19
19
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
+ import { appendFileSync, mkdirSync } from 'node:fs';
20
21
  import path from 'node:path';
21
22
  import os from 'node:os';
23
+ import { createHash } from 'node:crypto';
22
24
  import { fileURLToPath } from 'node:url';
23
25
  import { spawn } from 'node:child_process';
24
26
 
@@ -30,6 +32,15 @@ function localStateRoot() {
30
32
  return process.env.LEOS_AGENT_LOCAL_PATH || path.join(os.homedir(), '.leos-agent-local');
31
33
  }
32
34
 
35
+ // One OpenCode process serves several project directories at once (its own log
36
+ // shows one run= creating an instance per directory), and ESM caches this
37
+ // module once per process, so nothing below may key off process.cwd() or hold
38
+ // per-directory state in a bare module variable. The directory arrives on
39
+ // PluginInput and is threaded through every consumer instead.
40
+ function directoryKey(directory) {
41
+ return createHash('sha256').update(directory).digest('hex').slice(0, 12);
42
+ }
43
+
33
44
  // A 6-line strip, not a YAML parser: the body starts after the second
34
45
  // leading '---' fence line. Anything malformed just returns the raw text.
35
46
  function stripFrontmatter(raw) {
@@ -43,7 +54,7 @@ function stripFrontmatter(raw) {
43
54
  return raw;
44
55
  }
45
56
 
46
- async function assemblePolicy() {
57
+ async function assemblePolicy(directory) {
47
58
  let skillRaw;
48
59
  try {
49
60
  skillRaw = await readFile(path.join(ROOT, 'skills', 'using-leo', 'SKILL.md'), 'utf8');
@@ -68,12 +79,55 @@ async function assemblePolicy() {
68
79
  // hooks/session-start.py:97 uses.
69
80
  combined = combined.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
70
81
 
71
- return LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
82
+ let policy = LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
83
+
84
+ // Memory rides in its own envelope after the policy. Spawned rather than
85
+ // reimplemented in JS: memory.py already owns the store, the projection and
86
+ // the marker engine, and a fourth copy of that renderer would drift.
87
+ const memory = await memoryBlock(directory);
88
+ if (memory) {
89
+ policy += '\n\n<leo-memory>\n' + memory + '\n</leo-memory>';
90
+ }
91
+ return policy;
92
+ }
93
+
94
+ // Hard-kills after 4s: OpenCode session start must not hang on a memory store
95
+ // living on a slow or unreachable filesystem. Any failure yields '' and the
96
+ // session proceeds with policy only.
97
+ //
98
+ // `memory.py session` renders the block for the repo it is run in, so the
99
+ // directory has to be the session's, not the server's: process.cwd() here
100
+ // would serve one project's repo-scoped memories to every other project the
101
+ // same OpenCode process is hosting.
102
+ function memoryBlock(directory) {
103
+ return new Promise((resolve) => {
104
+ let done = false;
105
+ const finish = (value) => { if (!done) { done = true; resolve(value); } };
106
+ try {
107
+ const child = spawn('python3', [path.join(ROOT, 'scripts', 'memory.py'), 'session'], {
108
+ cwd: directory,
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ });
111
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} finish(''); }, 4000);
112
+ let out = '';
113
+ child.stdout.on('data', (chunk) => { out += chunk.toString(); });
114
+ child.on('error', () => { clearTimeout(timer); finish(''); });
115
+ child.on('close', (code) => {
116
+ clearTimeout(timer);
117
+ finish(code === 0 ? out.trim() : '');
118
+ });
119
+ } catch {
120
+ finish('');
121
+ }
122
+ });
72
123
  }
73
124
 
74
- async function writePolicyFile(policy) {
125
+ // Named per directory: the memory envelope inside is repo-scoped, so a single
126
+ // shared filename would have each project's instance overwriting the file the
127
+ // other projects' config.instructions already point at.
128
+ async function writePolicyFile(policy, directory) {
75
129
  const dir = localStateRoot();
76
- const dest = path.join(dir, 'opencode-policy.md');
130
+ const dest = path.join(dir, `opencode-policy-${directoryKey(directory)}.md`);
77
131
  try {
78
132
  await mkdir(dir, { recursive: true });
79
133
  await writeFile(dest, policy, 'utf8');
@@ -83,21 +137,24 @@ async function writePolicyFile(policy) {
83
137
  }
84
138
  }
85
139
 
86
- let policyCache;
87
- async function getPolicy() {
88
- if (policyCache === undefined) {
89
- policyCache = await assemblePolicy();
140
+ // Keyed by directory for the same reason. A bare module variable is shared by
141
+ // every instance in the process, which would pin whichever project started
142
+ // first and serve its memories to the rest.
143
+ const policyCache = new Map();
144
+ async function getPolicy(directory) {
145
+ if (!policyCache.has(directory)) {
146
+ policyCache.set(directory, await assemblePolicy(directory));
90
147
  }
91
- return policyCache;
148
+ return policyCache.get(directory);
92
149
  }
93
150
 
94
- let policyPathCache;
95
- async function getPolicyPath() {
96
- if (policyPathCache === undefined) {
97
- const policy = await getPolicy();
98
- policyPathCache = policy ? await writePolicyFile(policy) : null;
151
+ const policyPathCache = new Map();
152
+ async function getPolicyPath(directory) {
153
+ if (!policyPathCache.has(directory)) {
154
+ const policy = await getPolicy(directory);
155
+ policyPathCache.set(directory, policy ? await writePolicyFile(policy, directory) : null);
99
156
  }
100
- return policyPathCache;
157
+ return policyPathCache.get(directory);
101
158
  }
102
159
 
103
160
  async function loadAgents() {
@@ -109,9 +166,41 @@ async function loadAgents() {
109
166
  }
110
167
  }
111
168
 
112
- let guardWarnedOnce = false;
169
+ // A guard that cannot run allows the command — the same posture as Claude
170
+ // Code and Codex, where PreToolUse blocks only on exit 2 and any other
171
+ // outcome is non-blocking. What must not happen is that it goes quiet: a
172
+ // one-shot warning latch meant the second and every later unguarded command
173
+ // passed with nothing said anywhere. Breadcrumb every one of them, to the
174
+ // same local log the other bootstraps write to.
175
+ function guardBreadcrumb(err) {
176
+ const reason = (err && err.message) || String(err);
177
+ try {
178
+ const dir = localStateRoot();
179
+ mkdirSync(dir, { recursive: true });
180
+ appendFileSync(
181
+ path.join(dir, 'opencode-guard.log'),
182
+ `${new Date().toISOString()} guard did not run, command allowed: ${reason}\n`,
183
+ );
184
+ } catch {
185
+ // A breadcrumb that cannot be written must not itself break the session.
186
+ }
187
+ console.error('[leo guard] guard did not run, allowing command:', reason);
188
+ }
189
+
190
+ export default async function leoPlugin(ctx) {
191
+ // The only place the session's directory is available. tool.execute.before
192
+ // receives just {tool, sessionID, callID} (@opencode-ai/plugin's own types),
193
+ // so reading a directory off that input yields undefined every time and
194
+ // falls through to the server's cwd — a different tree whenever one process
195
+ // hosts more than one project.
196
+ const directory = (ctx && (ctx.directory || ctx.worktree)) || process.cwd();
197
+
198
+ // OpenCode runs no session-start hook and exports no plugin-root variable,
199
+ // so scripts/doctor.py cannot tell this harness apart from a bare shell and
200
+ // used to inherit the bootstrap's "claude" default. Declare it, and do it
201
+ // here so the guard and memory subprocesses inherit it too.
202
+ process.env.LEOS_AGENT_HARNESS = 'opencode';
113
203
 
114
- export default async function leoPlugin(_ctx) {
115
204
  return {
116
205
  async config(config) {
117
206
  config.skills ||= {};
@@ -125,7 +214,7 @@ export default async function leoPlugin(_ctx) {
125
214
  config.agent ||= {};
126
215
  Object.assign(config.agent, agents);
127
216
 
128
- const policyPath = await getPolicyPath();
217
+ const policyPath = await getPolicyPath(directory);
129
218
  if (policyPath) {
130
219
  config.instructions ||= [];
131
220
  if (!config.instructions.includes(policyPath)) {
@@ -143,7 +232,7 @@ export default async function leoPlugin(_ctx) {
143
232
  if (!Array.isArray(system)) return;
144
233
  if (system.some((s) => typeof s === 'string' && s.includes(LEO_POLICY_MARKER))) return;
145
234
 
146
- const policy = await getPolicy();
235
+ const policy = await getPolicy(directory);
147
236
  if (policy) system.push(policy);
148
237
  },
149
238
 
@@ -152,15 +241,30 @@ export default async function leoPlugin(_ctx) {
152
241
  const command = output && output.args && output.args.command;
153
242
  if (typeof command !== 'string' || !command) return;
154
243
 
155
- const cwd = (input && (input.directory || input.worktree || input.cwd)) || process.cwd();
156
- const payload = JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd });
244
+ // bash-guard expands relative targets against this cwd, so the wrong
245
+ // one misjudges in both directions: a home-level delete read as safe,
246
+ // a project-local delete read as critical.
247
+ const payload = JSON.stringify({
248
+ tool_name: 'Bash',
249
+ tool_input: { command },
250
+ cwd: directory,
251
+ });
157
252
  const guardPath = path.join(ROOT, 'hooks', 'bash-guard.py');
158
253
 
159
254
  let exitCode;
160
255
  let stderr = '';
256
+ let timer;
161
257
  try {
162
258
  exitCode = await new Promise((resolve, reject) => {
163
259
  const proc = spawn('python3', [guardPath], { stdio: ['pipe', 'ignore', 'pipe'] });
260
+ // Every other guard channel is bounded — Claude Code, Codex and
261
+ // Cursor all set timeout 10 in their hook manifests, and Hermes
262
+ // runs in-process so it cannot hang on its own. Unbounded, a wedged
263
+ // python3 hangs the tool call forever with nothing shown to anyone.
264
+ timer = setTimeout(() => {
265
+ try { proc.kill('SIGKILL'); } catch {}
266
+ reject(new Error('bash-guard.py timed out after 10s'));
267
+ }, 10000);
164
268
  proc.stderr.on('data', (d) => {
165
269
  stderr += d.toString();
166
270
  });
@@ -170,14 +274,10 @@ export default async function leoPlugin(_ctx) {
170
274
  proc.stdin.end();
171
275
  });
172
276
  } 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
- }
277
+ guardBreadcrumb(err);
180
278
  return; // infra fail-open
279
+ } finally {
280
+ clearTimeout(timer);
181
281
  }
182
282
 
183
283
  if (exitCode === 2) {