leos-agent 10.2.0 → 10.7.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 +174 -30
- package/hooks/README.md +93 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +16 -0
- package/index.js +112 -6
- package/package.json +2 -1
- package/rules/preferences.md +36 -32
- package/scripts/check.py +122 -1
- package/scripts/dispatch_guard.py +317 -0
- package/scripts/dispatch_log.py +240 -0
- package/scripts/ghreview.py +12 -0
- package/scripts/handoff.py +44 -21
- package/scripts/leo-install.py +146 -25
- package/scripts/measure_context.py +43 -0
- package/scripts/routing.py +420 -0
- package/scripts/state.py +8 -3
- package/scripts/usage_scan.py +455 -0
- package/scripts/watch_review.py +143 -44
- package/skills/doctor/SKILL.md +24 -6
- package/skills/handoff/SKILL.md +16 -6
- package/skills/handon/SKILL.md +26 -9
- package/skills/install/SKILL.md +1 -1
- package/skills/review-pr/SKILL.md +4 -4
- package/skills/review-pr/reference/procedure.md +18 -15
- package/skills/review-usage/SKILL.md +97 -0
- package/skills/review-usage/agents/openai.yaml +5 -0
- package/skills/review-usage/reference/sources.md +80 -0
- package/skills/tune-routing/SKILL.md +129 -0
- package/skills/tune-routing/agents/openai.yaml +5 -0
- package/skills/tune-routing/reference/harnesses.md +63 -0
- package/skills-claude/attach-pr/SKILL.md +14 -4
- package/skills-claude/watch-review/SKILL.md +74 -25
|
@@ -8,6 +8,7 @@ regressions remain visible without a tokenizer or network access.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
|
+
import importlib.util
|
|
11
12
|
import json
|
|
12
13
|
import re
|
|
13
14
|
import sys
|
|
@@ -19,9 +20,19 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
19
20
|
# the before/after output in the change that raises it.
|
|
20
21
|
LIMITS = {
|
|
21
22
|
"global_policy_bytes": 4_500,
|
|
23
|
+
# What an unconfigured machine actually installs. Rendering the routing region
|
|
24
|
+
# per harness dropped this below the old whole-file figure of 4497, and it must
|
|
25
|
+
# stay there: the model config exists to save money, so it may not cost
|
|
26
|
+
# always-loaded bytes to have. A configured harness exceeds this only by the
|
|
27
|
+
# length of the model names chosen, which is bounded and deliberate.
|
|
28
|
+
"rendered_policy_bytes": 4_497,
|
|
22
29
|
"codex_implicit_skill_metadata_bytes": 600,
|
|
23
30
|
"claude_implicit_skill_metadata_bytes": 800,
|
|
24
31
|
"codex_agent_description_bytes": 550,
|
|
32
|
+
"claude_agent_description_bytes": 550,
|
|
33
|
+
# Command descriptions are listed alongside skills in Claude Code and Cursor,
|
|
34
|
+
# so they are always-loaded context on the same terms as skill metadata.
|
|
35
|
+
"command_description_bytes": 400,
|
|
25
36
|
"review_dispatch_bytes": 3_500,
|
|
26
37
|
}
|
|
27
38
|
|
|
@@ -69,6 +80,20 @@ def agent_description(path):
|
|
|
69
80
|
return match.group(1)
|
|
70
81
|
|
|
71
82
|
|
|
83
|
+
def rendered_policy():
|
|
84
|
+
"""The installed payload body per harness, with no routing config present.
|
|
85
|
+
|
|
86
|
+
This is what a session actually loads -- rules/preferences.md on disk keeps a
|
|
87
|
+
harness-neutral default in its routing region, and the installer narrows it to
|
|
88
|
+
one harness. Measured with the config forced empty so the number is a property
|
|
89
|
+
of the repository, not of whoever runs it.
|
|
90
|
+
"""
|
|
91
|
+
spec = importlib.util.spec_from_file_location("leo_install_measure", ROOT / "scripts" / "leo-install.py")
|
|
92
|
+
installer = importlib.util.module_from_spec(spec)
|
|
93
|
+
spec.loader.exec_module(installer)
|
|
94
|
+
return {h: byte_len(installer.payload_body(ROOT, h, {})) for h in installer.HARNESSES}
|
|
95
|
+
|
|
96
|
+
|
|
72
97
|
def measurements():
|
|
73
98
|
portable = sorted((ROOT / "skills").glob("*/SKILL.md"))
|
|
74
99
|
claude_only = sorted((ROOT / "skills-claude").glob("*/SKILL.md"))
|
|
@@ -77,11 +102,26 @@ def measurements():
|
|
|
77
102
|
review_fm, review_body = frontmatter(ROOT / "skills" / "review-pr" / "SKILL.md")
|
|
78
103
|
del review_fm
|
|
79
104
|
agent_paths = sorted((ROOT / "payload" / "codex-agents").glob("*.toml"))
|
|
105
|
+
# Claude Code lists every plugin agent's name and description in the parent's
|
|
106
|
+
# always-loaded agent roster, so they are part of the static footprint too.
|
|
107
|
+
claude_agent_paths = sorted((ROOT / "agents").glob("*.md"))
|
|
108
|
+
claude_agent_bytes = 0
|
|
109
|
+
for path in claude_agent_paths:
|
|
110
|
+
fm, _ = frontmatter(path)
|
|
111
|
+
claude_agent_bytes += byte_len(field(fm, "name")) + byte_len(field(fm, "description"))
|
|
112
|
+
command_paths = sorted((ROOT / "commands").glob("*.md")) + sorted((ROOT / "commands-claude").glob("*.md"))
|
|
113
|
+
command_bytes = 0
|
|
114
|
+
for path in command_paths:
|
|
115
|
+
fm, _ = frontmatter(path)
|
|
116
|
+
command_bytes += byte_len(field(fm, "description"))
|
|
80
117
|
return {
|
|
81
118
|
"global_policy_bytes": byte_len(policy_body.strip()),
|
|
119
|
+
"rendered_policy_bytes": max(rendered_policy().values()),
|
|
82
120
|
"codex_implicit_skill_metadata_bytes": skill_metadata_bytes(portable, codex_implicit),
|
|
83
121
|
"claude_implicit_skill_metadata_bytes": skill_metadata_bytes(portable + claude_only, claude_implicit),
|
|
84
122
|
"codex_agent_description_bytes": sum(byte_len(agent_description(path)) for path in agent_paths),
|
|
123
|
+
"claude_agent_description_bytes": claude_agent_bytes,
|
|
124
|
+
"command_description_bytes": command_bytes,
|
|
85
125
|
"review_dispatch_bytes": byte_len(review_body.strip()),
|
|
86
126
|
}
|
|
87
127
|
|
|
@@ -99,6 +139,9 @@ def main(argv=None):
|
|
|
99
139
|
print("Static prompt footprint (bytes; tokens are roughly bytes / 4 for this prose)")
|
|
100
140
|
for name, value in values.items():
|
|
101
141
|
print(f" {name:38} {value:5} limit {LIMITS[name]:5}")
|
|
142
|
+
print(" rendered_policy_bytes is the worst case across harnesses; each one installs:")
|
|
143
|
+
for harness, value in sorted(rendered_policy().items()):
|
|
144
|
+
print(f" {harness:38} {value:5}")
|
|
102
145
|
print("This excludes conversation history, tool output, cache effects, and subagent work.")
|
|
103
146
|
|
|
104
147
|
over = {name: (value, LIMITS[name]) for name, value in values.items() if value > LIMITS[name]}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""routing: per-machine model routing for leos-agent's economical tier.
|
|
3
|
+
|
|
4
|
+
The tier only ever had teeth on Claude Code and Codex, because those are the two
|
|
5
|
+
harnesses whose model names the payload could hardcode. Every other harness fell
|
|
6
|
+
through to "use the current model", so fan-outs there ran at full price. Which
|
|
7
|
+
models a harness actually offers varies by machine and by what an IT department
|
|
8
|
+
allows, so the mapping cannot ship in the plugin -- it is machine-local config.
|
|
9
|
+
|
|
10
|
+
CONFIG lives beside the rest of leos-agent's data, at
|
|
11
|
+
${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/routing.json, never inside the
|
|
12
|
+
plugin: an upgrade, a reinstall, or an uninstall must never take it. Only `set`
|
|
13
|
+
and `unset` write it, and only when someone runs them -- the installer reads and
|
|
14
|
+
never writes, so an upgrade, a reinstall, or an --uninstall cannot touch it. A
|
|
15
|
+
missing file is not an error -- it means "the shipped defaults", which is
|
|
16
|
+
exactly the behaviour that predates this file.
|
|
17
|
+
|
|
18
|
+
{"cursor": {"runner": "grok-code-fast-1", "executor": "claude-sonnet-4.6"},
|
|
19
|
+
"opencode": {"runner": "anthropic/claude-haiku-4-5"},
|
|
20
|
+
"codex": {"runner": {"model": "gpt-5.6-luna", "effort": "low"}}}
|
|
21
|
+
|
|
22
|
+
Keys are harness names; each holds "runner" and/or "executor", independently. A
|
|
23
|
+
bare string is shorthand for {"model": ...}. Model strings are free-form and
|
|
24
|
+
never checked against a known-model list -- whatever the harness accepts goes in
|
|
25
|
+
verbatim. Only the keys are validated, and an unknown one is a hard error: a
|
|
26
|
+
typo that silently left a harness on the expensive model is the one failure this
|
|
27
|
+
file exists to prevent.
|
|
28
|
+
|
|
29
|
+
READ AT INSTALL TIME, NOT AT RUN TIME. leo-install.py renders the result into
|
|
30
|
+
the payload block it already writes, so a session pays nothing to know its own
|
|
31
|
+
routing -- no config read, no extra turn, no bytes beyond the dispatch line the
|
|
32
|
+
payload was always going to carry.
|
|
33
|
+
|
|
34
|
+
routing.py show [--harness H] what is configured, resolved
|
|
35
|
+
routing.py render --harness H the exact stanza the installer would inject
|
|
36
|
+
routing.py path the config file's path
|
|
37
|
+
routing.py set --harness H --runner M [--runner-effort E]
|
|
38
|
+
[--executor M] [--executor-effort E]
|
|
39
|
+
routing.py unset --harness H [--runner] [--executor]
|
|
40
|
+
|
|
41
|
+
Exit codes: 0 ok, 1 on a malformed config or a refused write, 2 on bad usage.
|
|
42
|
+
"""
|
|
43
|
+
import argparse
|
|
44
|
+
import copy
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import sys
|
|
48
|
+
|
|
49
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
+
# Same data root, and the same locking and atomic-write primitives: both
|
|
51
|
+
# machine-local JSON files are written the one way, so a half-written config can
|
|
52
|
+
# never survive a crash and two concurrent writers cannot lose an update.
|
|
53
|
+
from state import _data_root, _locked, atomic_write # noqa: E402
|
|
54
|
+
|
|
55
|
+
# The canonical harness list. leo-install.py imports it from here rather than
|
|
56
|
+
# the other way round: it imports this module to render, and its own filename is
|
|
57
|
+
# not an importable one.
|
|
58
|
+
HARNESSES = ("claude", "codex", "cursor", "hermes", "pi", "opencode")
|
|
59
|
+
|
|
60
|
+
CONFIG_NAME = "routing.json"
|
|
61
|
+
ROLES = ("runner", "executor")
|
|
62
|
+
FIELDS = ("model", "effort")
|
|
63
|
+
|
|
64
|
+
# Harnesses whose economical tier ships with models already baked in: Claude
|
|
65
|
+
# Code reads agents/*.md, Codex reads the installed profile TOMLs. Everything
|
|
66
|
+
# else inherits unless this file says otherwise.
|
|
67
|
+
BAKED = ("claude", "codex")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def config_path():
|
|
71
|
+
return os.path.join(_data_root(), CONFIG_NAME)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bad(message):
|
|
75
|
+
sys.exit(f"routing: {config_path()}: {message}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_raw():
|
|
79
|
+
"""The document exactly as written, or {} when there is no file.
|
|
80
|
+
|
|
81
|
+
set/unset merge into THIS rather than into load()'s output: load()
|
|
82
|
+
normalises a bare model string into an object and fills in effort: None, so
|
|
83
|
+
writing its result back would rewrite every other harness's entry as a side
|
|
84
|
+
effect of touching one. Reading twice is the price of leaving the rest of
|
|
85
|
+
Leo's file exactly as he wrote it.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
with open(config_path()) as fh:
|
|
89
|
+
return json.load(fh)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return {}
|
|
92
|
+
except json.JSONDecodeError as exc:
|
|
93
|
+
_bad(f"is not valid JSON ({exc})")
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
_bad(exc.strerror or str(exc))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load(harnesses=HARNESSES):
|
|
99
|
+
"""Parse and validate the config. Returns {} when there is no file."""
|
|
100
|
+
return validate(read_raw(), harnesses)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate(data, harnesses=HARNESSES):
|
|
104
|
+
"""Resolve a parsed document into {harness: {role: {model, effort}}}.
|
|
105
|
+
|
|
106
|
+
Shared by the read path and the write path, so a `set` can never produce a
|
|
107
|
+
file that `load` would go on to reject.
|
|
108
|
+
"""
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
_bad(f"top level is {type(data).__name__}, expected an object keyed by harness")
|
|
111
|
+
|
|
112
|
+
out = {}
|
|
113
|
+
for harness, entry in data.items():
|
|
114
|
+
if harness not in harnesses:
|
|
115
|
+
_bad(f"{harness!r} is not a harness; expected one of {', '.join(sorted(harnesses))}")
|
|
116
|
+
if not isinstance(entry, dict):
|
|
117
|
+
_bad(f"{harness}: expected an object with 'runner' and/or 'executor'")
|
|
118
|
+
roles = {}
|
|
119
|
+
for role, value in entry.items():
|
|
120
|
+
if role not in ROLES:
|
|
121
|
+
_bad(f"{harness}.{role}: unknown key; expected {' or '.join(ROLES)}")
|
|
122
|
+
if isinstance(value, str):
|
|
123
|
+
value = {"model": value}
|
|
124
|
+
if not isinstance(value, dict):
|
|
125
|
+
_bad(f"{harness}.{role}: expected a model name or an object, got {type(value).__name__}")
|
|
126
|
+
for field in value:
|
|
127
|
+
if field not in FIELDS:
|
|
128
|
+
_bad(f"{harness}.{role}.{field}: unknown field; expected {' or '.join(FIELDS)}")
|
|
129
|
+
model = value.get("model")
|
|
130
|
+
if not isinstance(model, str) or not model.strip():
|
|
131
|
+
_bad(f"{harness}.{role}: needs a non-empty 'model'")
|
|
132
|
+
effort = value.get("effort")
|
|
133
|
+
if effort is not None and (not isinstance(effort, str) or not effort.strip()):
|
|
134
|
+
_bad(f"{harness}.{role}.effort: must be a non-empty string when present")
|
|
135
|
+
roles[role] = {"model": model.strip(), "effort": effort.strip() if effort else None}
|
|
136
|
+
if roles:
|
|
137
|
+
out[harness] = roles
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def profile(config, harness, role):
|
|
142
|
+
"""The configured {model, effort} for one role, or None."""
|
|
143
|
+
return (config.get(harness) or {}).get(role)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _named(entry):
|
|
147
|
+
"""`model` or `model`/effort, for prose."""
|
|
148
|
+
if entry.get("effort"):
|
|
149
|
+
return f"`{entry['model']}`/{entry['effort']}"
|
|
150
|
+
return f"`{entry['model']}`"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def stanza(harness, config):
|
|
154
|
+
"""The dispatch lines for one harness. No trailing newline.
|
|
155
|
+
|
|
156
|
+
Kept deliberately short: this text is always-loaded on every turn of every
|
|
157
|
+
session, so it must cost less than the multi-harness prose it replaces.
|
|
158
|
+
"""
|
|
159
|
+
runner = profile(config, harness, "runner")
|
|
160
|
+
executor = profile(config, harness, "executor")
|
|
161
|
+
|
|
162
|
+
if harness == "claude":
|
|
163
|
+
# The Agent tool's model parameter overrides the agent definition's
|
|
164
|
+
# frontmatter, so a machine can retarget the tier without the installer
|
|
165
|
+
# ever writing into the plugin-owned agents/ directory.
|
|
166
|
+
if not runner and not executor:
|
|
167
|
+
return 'On Claude Code pass `subagent_type: "leo-runner"` or `"leo-executor"`;\nthe agent definitions carry the models.'
|
|
168
|
+
overrides = ", and ".join(
|
|
169
|
+
f'`subagent_type: "leo-{role}"` with `model: "{entry["model"]}"`'
|
|
170
|
+
for role, entry in (("runner", runner), ("executor", executor))
|
|
171
|
+
if entry
|
|
172
|
+
)
|
|
173
|
+
kept = "" if (runner and executor) else "\nThe other profile keeps the model its agent definition carries."
|
|
174
|
+
return "On Claude Code pass " + overrides + "." + kept
|
|
175
|
+
|
|
176
|
+
if harness == "codex":
|
|
177
|
+
# Config reaches Codex through the installed profile TOMLs, so the prose
|
|
178
|
+
# is the same either way -- and stays one line.
|
|
179
|
+
return "On Codex the installed `leo-runner` and `leo-executor` profiles carry\nthe models."
|
|
180
|
+
|
|
181
|
+
if not runner and not executor:
|
|
182
|
+
return "No cheaper profile is configured here: use the current model, and say\nrouting could not be applied."
|
|
183
|
+
|
|
184
|
+
if runner and executor:
|
|
185
|
+
assignment = f"Dispatch leo-runner at {_named(runner)} and leo-executor at {_named(executor)}."
|
|
186
|
+
elif runner:
|
|
187
|
+
assignment = f"Dispatch leo-runner at {_named(runner)}; leo-executor inherits."
|
|
188
|
+
else:
|
|
189
|
+
assignment = f"Dispatch leo-executor at {_named(executor)}; leo-runner inherits."
|
|
190
|
+
return assignment + "\nWhere this harness cannot set a model per spawn, inherit and say so."
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _entry(model, effort):
|
|
194
|
+
"""The on-disk shape for one role. No `effort: null` when there is none."""
|
|
195
|
+
entry = {"model": model}
|
|
196
|
+
if effort:
|
|
197
|
+
entry["effort"] = effort
|
|
198
|
+
return entry
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def apply_set(data, harness, roles):
|
|
202
|
+
"""roles is {role: (model, effort)}; each named role is replaced whole.
|
|
203
|
+
|
|
204
|
+
Mutates and returns the document it is given; edit() hands it a copy.
|
|
205
|
+
|
|
206
|
+
Wholesale, not merged: a `set` that kept a previously configured effort
|
|
207
|
+
would make it sticky and invisible. The caller prints what it displaced.
|
|
208
|
+
"""
|
|
209
|
+
entry = dict(data.get(harness) or {})
|
|
210
|
+
for role, (model, effort) in roles.items():
|
|
211
|
+
entry[role] = _entry(model, effort)
|
|
212
|
+
data[harness] = entry
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def apply_unset(data, harness, roles):
|
|
217
|
+
"""roles is a tuple of role names, or () for the whole harness.
|
|
218
|
+
|
|
219
|
+
Mutates and returns the document it is given; edit() hands it a copy.
|
|
220
|
+
"""
|
|
221
|
+
if harness not in data:
|
|
222
|
+
return data
|
|
223
|
+
if not roles:
|
|
224
|
+
del data[harness]
|
|
225
|
+
return data
|
|
226
|
+
entry = dict(data[harness])
|
|
227
|
+
for role in roles:
|
|
228
|
+
entry.pop(role, None)
|
|
229
|
+
# An empty harness key is a shape load() never returns, so never leave one.
|
|
230
|
+
if entry:
|
|
231
|
+
data[harness] = entry
|
|
232
|
+
else:
|
|
233
|
+
del data[harness]
|
|
234
|
+
return data
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def edit(mutate, write=True):
|
|
238
|
+
"""Read-modify-write under state's flock. Returns (before, after).
|
|
239
|
+
|
|
240
|
+
The lock and the directory creation live only on the write path, so a
|
|
241
|
+
--dry-run and every read subcommand still create nothing in the data root.
|
|
242
|
+
"""
|
|
243
|
+
if not write:
|
|
244
|
+
before = _sound()
|
|
245
|
+
return before, mutate(copy.deepcopy(before))
|
|
246
|
+
with _locked(config_path()):
|
|
247
|
+
before = _sound()
|
|
248
|
+
after = mutate(copy.deepcopy(before))
|
|
249
|
+
if after != before:
|
|
250
|
+
# Never write something load() would go on to reject.
|
|
251
|
+
validate(after)
|
|
252
|
+
atomic_write(config_path(), after)
|
|
253
|
+
return before, after
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _sound():
|
|
257
|
+
"""The raw document, refused unless it is one a write could safely edit.
|
|
258
|
+
|
|
259
|
+
Validating what is already there before touching it means a file Leo broke
|
|
260
|
+
by hand is reported, never repaired by overwriting -- and it keeps the
|
|
261
|
+
mutators free to assume the shape they were written for.
|
|
262
|
+
"""
|
|
263
|
+
data = read_raw()
|
|
264
|
+
validate(data)
|
|
265
|
+
return data
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _described(entry):
|
|
269
|
+
"""`model` plus `effort=x`, for the column output."""
|
|
270
|
+
if not entry:
|
|
271
|
+
return ""
|
|
272
|
+
effort = f" effort={entry['effort']}" if entry.get("effort") else ""
|
|
273
|
+
return f"{entry['model']}{effort}"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _resolved(data, harness, role):
|
|
277
|
+
"""One role of a raw document, in the normalised shape, or None."""
|
|
278
|
+
value = (data.get(harness) or {}).get(role)
|
|
279
|
+
if isinstance(value, str):
|
|
280
|
+
return {"model": value}
|
|
281
|
+
return value
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _fallback(harness):
|
|
285
|
+
return "shipped default" if harness in BAKED else "inherits the current model"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
NOTES = {
|
|
289
|
+
"claude": (
|
|
290
|
+
"note: claude bakes its models into agents/*.md; this layers a per-dispatch\n"
|
|
291
|
+
" `model:` override on top and never rewrites the plugin."
|
|
292
|
+
),
|
|
293
|
+
"codex": (
|
|
294
|
+
"note: codex bakes its models into the installed profile TOMLs; this\n"
|
|
295
|
+
" substitutes them there, and effort lands as model_reasoning_effort."
|
|
296
|
+
),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _finish(args, before, after, root_hint=True):
|
|
301
|
+
"""The trailing lines every write subcommand shares."""
|
|
302
|
+
path = config_path()
|
|
303
|
+
if args.dry_run:
|
|
304
|
+
print(json.dumps(after, indent=1, sort_keys=True))
|
|
305
|
+
print("dry run; nothing written")
|
|
306
|
+
return 0
|
|
307
|
+
if after == before:
|
|
308
|
+
where = f"{path} is already current" if os.path.exists(path) else f"no config at {path}"
|
|
309
|
+
print(f"nothing to write; {where}")
|
|
310
|
+
return 0
|
|
311
|
+
print(f"wrote {path}")
|
|
312
|
+
if root_hint:
|
|
313
|
+
installer = os.path.join(os.path.dirname(os.path.abspath(__file__)), "leo-install.py")
|
|
314
|
+
print(f"next: python3 {installer} {args.harness}")
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def cmd_set(args):
|
|
319
|
+
roles = {}
|
|
320
|
+
for role in ROLES:
|
|
321
|
+
model = getattr(args, role)
|
|
322
|
+
effort = getattr(args, f"{role}_effort")
|
|
323
|
+
if model is None:
|
|
324
|
+
if effort is not None:
|
|
325
|
+
sys.exit(f"routing: --{role}-effort needs --{role} in the same command")
|
|
326
|
+
continue
|
|
327
|
+
if not model.strip():
|
|
328
|
+
_bad(f"{args.harness}.{role}: needs a non-empty 'model'")
|
|
329
|
+
if effort is not None and not effort.strip():
|
|
330
|
+
_bad(f"{args.harness}.{role}.effort: must be a non-empty string when present")
|
|
331
|
+
roles[role] = (model.strip(), effort.strip() if effort else None)
|
|
332
|
+
if not roles:
|
|
333
|
+
sys.exit("routing: set needs --runner and/or --executor")
|
|
334
|
+
|
|
335
|
+
before, after = edit(lambda d: apply_set(d, args.harness, roles), write=not args.dry_run)
|
|
336
|
+
for role in ROLES:
|
|
337
|
+
if role not in roles:
|
|
338
|
+
continue
|
|
339
|
+
was = _resolved(before, args.harness, role)
|
|
340
|
+
now = _resolved(after, args.harness, role)
|
|
341
|
+
verb = "unchanged" if was == now else "set"
|
|
342
|
+
suffix = f" (was {_described(was)})" if was and was != now else ""
|
|
343
|
+
print(f"{verb:9} {args.harness:9} {role:9} {_described(now)}{suffix}")
|
|
344
|
+
if args.harness in NOTES and after != before:
|
|
345
|
+
print(NOTES[args.harness])
|
|
346
|
+
return _finish(args, before, after)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def cmd_unset(args):
|
|
350
|
+
roles = tuple(role for role in ROLES if getattr(args, role))
|
|
351
|
+
before, after = edit(lambda d: apply_unset(d, args.harness, roles), write=not args.dry_run)
|
|
352
|
+
touched = roles or ROLES
|
|
353
|
+
for role in touched:
|
|
354
|
+
was = _resolved(before, args.harness, role)
|
|
355
|
+
if was:
|
|
356
|
+
print(f"{'unset':9} {args.harness:9} {role:9} {_described(was)} -> {_fallback(args.harness)}")
|
|
357
|
+
if before == after:
|
|
358
|
+
print(f"{'unchanged':9} {args.harness:9} {'(both)':9} nothing configured")
|
|
359
|
+
return _finish(args, before, after)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_show(args):
|
|
363
|
+
config = load()
|
|
364
|
+
if not config:
|
|
365
|
+
print(f"no routing config at {config_path()}; every harness uses its shipped default")
|
|
366
|
+
return
|
|
367
|
+
for harness in sorted(config):
|
|
368
|
+
if args.harness and harness != args.harness:
|
|
369
|
+
continue
|
|
370
|
+
for role in ROLES:
|
|
371
|
+
entry = config[harness].get(role)
|
|
372
|
+
if entry:
|
|
373
|
+
effort = f" effort={entry['effort']}" if entry["effort"] else ""
|
|
374
|
+
print(f"{harness:9} {role:9} {entry['model']}{effort}")
|
|
375
|
+
for harness in sorted(set(BAKED) - set(config)):
|
|
376
|
+
if not args.harness or harness == args.harness:
|
|
377
|
+
print(f"{harness:9} {'(both)':9} shipped default")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main(argv):
|
|
381
|
+
parser = argparse.ArgumentParser(prog="routing.py", description=__doc__.splitlines()[0])
|
|
382
|
+
sub = parser.add_subparsers(dest="mode", required=True)
|
|
383
|
+
show = sub.add_parser("show", help="what is configured, resolved")
|
|
384
|
+
show.add_argument("--harness", choices=HARNESSES)
|
|
385
|
+
render = sub.add_parser("render", help="the stanza the installer would inject")
|
|
386
|
+
render.add_argument("--harness", choices=HARNESSES, required=True)
|
|
387
|
+
sub.add_parser("path", help="the config file's path")
|
|
388
|
+
|
|
389
|
+
setter = sub.add_parser("set", help="point one harness's roles at models on this machine")
|
|
390
|
+
setter.add_argument("--harness", choices=HARNESSES, required=True)
|
|
391
|
+
setter.add_argument("--runner", metavar="MODEL", help="replaces the runner entry whole")
|
|
392
|
+
setter.add_argument("--runner-effort", metavar="E", help="needs --runner; omitting it clears any effort")
|
|
393
|
+
setter.add_argument("--executor", metavar="MODEL", help="replaces the executor entry whole")
|
|
394
|
+
setter.add_argument("--executor-effort", metavar="E", help="needs --executor; omitting it clears any effort")
|
|
395
|
+
setter.add_argument("--dry-run", action="store_true", help="show the result, write nothing")
|
|
396
|
+
|
|
397
|
+
unsetter = sub.add_parser("unset", help="drop a harness's roles, back to its shipped default")
|
|
398
|
+
unsetter.add_argument("--harness", choices=HARNESSES, required=True)
|
|
399
|
+
unsetter.add_argument("--runner", action="store_true")
|
|
400
|
+
unsetter.add_argument("--executor", action="store_true")
|
|
401
|
+
unsetter.add_argument("--dry-run", action="store_true", help="show the result, write nothing")
|
|
402
|
+
|
|
403
|
+
args = parser.parse_args(argv)
|
|
404
|
+
# An explicit branch per mode: a bare else would silently route a new
|
|
405
|
+
# subcommand into render and crash on an attribute it does not have.
|
|
406
|
+
if args.mode == "path":
|
|
407
|
+
print(config_path())
|
|
408
|
+
elif args.mode == "show":
|
|
409
|
+
cmd_show(args)
|
|
410
|
+
elif args.mode == "render":
|
|
411
|
+
print(stanza(args.harness, load(HARNESSES)))
|
|
412
|
+
elif args.mode == "set":
|
|
413
|
+
return cmd_set(args)
|
|
414
|
+
elif args.mode == "unset":
|
|
415
|
+
return cmd_unset(args)
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
sys.exit(main(sys.argv[1:]))
|
package/scripts/state.py
CHANGED
|
@@ -43,13 +43,16 @@ def state_file(name):
|
|
|
43
43
|
if "/" in name or "\\" in name or ".." in name or os.path.isabs(name):
|
|
44
44
|
sys.exit(f"state: {name!r} is not a valid state name (no slashes, no .., not absolute)")
|
|
45
45
|
root = _data_root()
|
|
46
|
-
|
|
46
|
+
# 0700: the root holds handoffs and per-repo state — project context that
|
|
47
|
+
# is nobody else's business on a shared machine. Applies on creation only;
|
|
48
|
+
# an existing directory keeps whatever Leo set on it.
|
|
49
|
+
os.makedirs(root, mode=0o700, exist_ok=True)
|
|
47
50
|
return os.path.join(root, f"{name}.json")
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
@contextlib.contextmanager
|
|
51
54
|
def _locked(path):
|
|
52
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
55
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
53
56
|
fd = os.open(path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
54
57
|
try:
|
|
55
58
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
@@ -88,12 +91,14 @@ def deep_merge(base, patch):
|
|
|
88
91
|
|
|
89
92
|
|
|
90
93
|
def atomic_write(path, data):
|
|
91
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
94
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
92
95
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
|
|
93
96
|
try:
|
|
94
97
|
with os.fdopen(fd, "w") as fh:
|
|
95
98
|
json.dump(data, fh, indent=1, sort_keys=True)
|
|
96
99
|
fh.write("\n")
|
|
100
|
+
fh.flush()
|
|
101
|
+
os.fsync(fh.fileno())
|
|
97
102
|
os.replace(tmp, path)
|
|
98
103
|
except BaseException:
|
|
99
104
|
os.unlink(tmp)
|