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.
- package/README.md +43 -0
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +1 -1
- package/adapters/cursor/agents/reviewer.md +1 -0
- package/adapters/opencode/agents.json +3 -3
- package/adapters/opencode/plugin.js +131 -29
- package/config/models.json +379 -33
- package/hooks/session-start.py +27 -0
- package/package.json +18 -4
- package/roles/executor.md +1 -1
- package/roles/implementer.md +1 -1
- package/roles/reviewer.md +1 -0
- package/scripts/doctor.py +284 -0
- package/scripts/ghreview.py +554 -0
- package/scripts/memory.py +705 -0
- package/scripts/render_adapters.py +244 -97
- package/scripts/resolve_attach_target.py +357 -0
- package/scripts/setup.py +161 -0
- package/skills/delegation/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +105 -0
- package/skills/freshness/SKILL.md +118 -0
- package/skills/memory/SKILL.md +144 -0
- package/skills/resolve-ticket/SKILL.md +269 -0
- package/skills/review-pr/SKILL.md +317 -0
- package/skills/setup/SKILL.md +85 -0
- package/skills/using-leo/SKILL.md +8 -1
- package/skills/using-leo/references/claude-mapping.md +22 -1
- package/skills/using-leo/references/codex-mapping.md +17 -7
- package/skills/using-leo/references/cursor-mapping.md +18 -6
- package/skills/using-leo/references/hermes-mapping.md +17 -7
- package/skills/using-leo/references/opencode-mapping.md +16 -8
- package/skills/verification/SKILL.md +7 -0
- package/skills/visual-verification/SKILL.md +114 -0
- package/skills/watch-review/SKILL.md +125 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
async function getPolicyPath() {
|
|
94
|
-
if (policyPathCache
|
|
95
|
-
const policy = await getPolicy();
|
|
96
|
-
policyPathCache
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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) {
|