leos-agent 6.1.0 → 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 +43 -0
  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 +131 -29
  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 +244 -97
  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 +16 -8
  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 ADDED
@@ -0,0 +1,43 @@
1
+ <!-- Generated by scripts/render_adapters.py; do not edit. -->
2
+
3
+ # Leo's Agent
4
+
5
+ Leo's Agent is a portable agent operating policy: cost-tiered model routing, specialist subagent roles, process skills, execute-then-review discipline, and a narrow catastrophic-command guard.
6
+
7
+ This npm package is the **OpenCode** distribution. Claude Code, Codex, Cursor, and Hermes each install it through their own plugin system — see [the repository](https://github.com/foxhatleo/leos-agent) for those.
8
+
9
+ ## Install
10
+
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:
16
+
17
+ ```json
18
+ { "$schema": "https://opencode.ai/config.json", "plugin": ["leos-agent"] }
19
+ ```
20
+
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.
22
+
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
+
25
+ ## Model tiers
26
+
27
+ Tier names describe the kind of work, not a fixed provider model.
28
+
29
+ | Tier | Model | Effort |
30
+ |---|---|---|
31
+ | Fable | `moonshotai/kimi-k3` | native default |
32
+ | Opus | `moonshotai/kimi-k3` | native default |
33
+ | Sonnet | `z-ai/glm-5.2` | native default |
34
+ | Haiku | `z-ai/glm-5.2` | native default |
35
+
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
+
38
+ ## Links
39
+
40
+ - [Repository and full documentation](https://github.com/foxhatleo/leos-agent)
41
+ - [Operating policy](https://github.com/foxhatleo/leos-agent/blob/main/plugins/leo/skills/using-leo/SKILL.md)
42
+
43
+ MIT licensed.
@@ -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
  }
@@ -1,4 +1,6 @@
1
- // Leo's OpenCode bridge: registers the leo:* skills dir, injects the
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
2
4
  // generated subagent roster from adapters/opencode/agents.json, assembles
3
5
  // the using-leo policy plus the OpenCode mapping appendix and hands it to
4
6
  // OpenCode through config.instructions (belt) and the
@@ -15,8 +17,10 @@
15
17
  // posture as hooks/session-start.py.
16
18
 
17
19
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
+ import { appendFileSync, mkdirSync } from 'node:fs';
18
21
  import path from 'node:path';
19
22
  import os from 'node:os';
23
+ import { createHash } from 'node:crypto';
20
24
  import { fileURLToPath } from 'node:url';
21
25
  import { spawn } from 'node:child_process';
22
26
 
@@ -28,6 +32,15 @@ function localStateRoot() {
28
32
  return process.env.LEOS_AGENT_LOCAL_PATH || path.join(os.homedir(), '.leos-agent-local');
29
33
  }
30
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
+
31
44
  // A 6-line strip, not a YAML parser: the body starts after the second
32
45
  // leading '---' fence line. Anything malformed just returns the raw text.
33
46
  function stripFrontmatter(raw) {
@@ -41,7 +54,7 @@ function stripFrontmatter(raw) {
41
54
  return raw;
42
55
  }
43
56
 
44
- async function assemblePolicy() {
57
+ async function assemblePolicy(directory) {
45
58
  let skillRaw;
46
59
  try {
47
60
  skillRaw = await readFile(path.join(ROOT, 'skills', 'using-leo', 'SKILL.md'), 'utf8');
@@ -66,12 +79,55 @@ async function assemblePolicy() {
66
79
  // hooks/session-start.py:97 uses.
67
80
  combined = combined.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
68
81
 
69
- 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
+ });
70
123
  }
71
124
 
72
- 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) {
73
129
  const dir = localStateRoot();
74
- const dest = path.join(dir, 'opencode-policy.md');
130
+ const dest = path.join(dir, `opencode-policy-${directoryKey(directory)}.md`);
75
131
  try {
76
132
  await mkdir(dir, { recursive: true });
77
133
  await writeFile(dest, policy, 'utf8');
@@ -81,21 +137,24 @@ async function writePolicyFile(policy) {
81
137
  }
82
138
  }
83
139
 
84
- let policyCache;
85
- async function getPolicy() {
86
- if (policyCache === undefined) {
87
- 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));
88
147
  }
89
- return policyCache;
148
+ return policyCache.get(directory);
90
149
  }
91
150
 
92
- let policyPathCache;
93
- async function getPolicyPath() {
94
- if (policyPathCache === undefined) {
95
- const policy = await getPolicy();
96
- 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);
97
156
  }
98
- return policyPathCache;
157
+ return policyPathCache.get(directory);
99
158
  }
100
159
 
101
160
  async function loadAgents() {
@@ -107,9 +166,41 @@ async function loadAgents() {
107
166
  }
108
167
  }
109
168
 
110
- 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';
111
203
 
112
- export default async function leoPlugin(_ctx) {
113
204
  return {
114
205
  async config(config) {
115
206
  config.skills ||= {};
@@ -123,7 +214,7 @@ export default async function leoPlugin(_ctx) {
123
214
  config.agent ||= {};
124
215
  Object.assign(config.agent, agents);
125
216
 
126
- const policyPath = await getPolicyPath();
217
+ const policyPath = await getPolicyPath(directory);
127
218
  if (policyPath) {
128
219
  config.instructions ||= [];
129
220
  if (!config.instructions.includes(policyPath)) {
@@ -141,7 +232,7 @@ export default async function leoPlugin(_ctx) {
141
232
  if (!Array.isArray(system)) return;
142
233
  if (system.some((s) => typeof s === 'string' && s.includes(LEO_POLICY_MARKER))) return;
143
234
 
144
- const policy = await getPolicy();
235
+ const policy = await getPolicy(directory);
145
236
  if (policy) system.push(policy);
146
237
  },
147
238
 
@@ -150,15 +241,30 @@ export default async function leoPlugin(_ctx) {
150
241
  const command = output && output.args && output.args.command;
151
242
  if (typeof command !== 'string' || !command) return;
152
243
 
153
- const cwd = (input && (input.directory || input.worktree || input.cwd)) || process.cwd();
154
- 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
+ });
155
252
  const guardPath = path.join(ROOT, 'hooks', 'bash-guard.py');
156
253
 
157
254
  let exitCode;
158
255
  let stderr = '';
256
+ let timer;
159
257
  try {
160
258
  exitCode = await new Promise((resolve, reject) => {
161
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);
162
268
  proc.stderr.on('data', (d) => {
163
269
  stderr += d.toString();
164
270
  });
@@ -168,14 +274,10 @@ export default async function leoPlugin(_ctx) {
168
274
  proc.stdin.end();
169
275
  });
170
276
  } catch (err) {
171
- if (!guardWarnedOnce) {
172
- guardWarnedOnce = true;
173
- console.error(
174
- '[leo guard] infra failure spawning bash-guard.py, allowing command:',
175
- err && err.message ? err.message : err,
176
- );
177
- }
277
+ guardBreadcrumb(err);
178
278
  return; // infra fail-open
279
+ } finally {
280
+ clearTimeout(timer);
179
281
  }
180
282
 
181
283
  if (exitCode === 2) {