zikaron 0.1.0__py3-none-any.whl
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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""The JSON Zikaron adds to a harness configuration: hook entries, the MCP server, and the
|
|
2
|
+
consolidator agent config.
|
|
3
|
+
|
|
4
|
+
`architecture.md` §"Two hook formats, both inside the stable agent config" and §"The install
|
|
5
|
+
contract" are normative.
|
|
6
|
+
|
|
7
|
+
Dicts are the return type here and that is deliberate rather than a lapse from
|
|
8
|
+
`coding-standards.md` §2's types-not-dicts rule: these functions *are* the serialization boundary —
|
|
9
|
+
their output goes straight to `json.dump` into a file whose schema belongs to the harness, not to
|
|
10
|
+
us.
|
|
11
|
+
The typed values live on the input side (`Commands`, `HookFormat`), which is where a mistake would
|
|
12
|
+
otherwise be silent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import sysconfig
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Final, NamedTuple
|
|
21
|
+
|
|
22
|
+
from zikaron.harness.spec import CLAUDE_CODE, KIRO
|
|
23
|
+
from zikaron.hook.limits import HOOK_TIMEOUT_SECONDS, MAX_OUTPUT_SIZE, TIMEOUT_MS
|
|
24
|
+
from zikaron.install.assets import consolidator_prompt, identity_vocabulary
|
|
25
|
+
from zikaron.mcp.tool_names import CONSOLIDATOR_TOOLS, PRIMARY_TOOLS
|
|
26
|
+
|
|
27
|
+
#: The `mcpServers` key, and therefore the `@zikaron` selector. One declaration, because the name
|
|
28
|
+
#: appears in three places that must agree — the server map, `tools`, and `allowedTools` — and a
|
|
29
|
+
#: mismatch in the second silently produces an agent with no Zikaron tools at all (measured; see
|
|
30
|
+
#: `TOOL_SELECTOR`).
|
|
31
|
+
MCP_SERVER_NAME: Final = "zikaron"
|
|
32
|
+
|
|
33
|
+
#: How an agent config selects this server's tools. **Configuring `mcpServers` is not sufficient**,
|
|
34
|
+
#: which was measured rather than inferred: an agent carrying the server entry and a `tools` list
|
|
35
|
+
#: that did not name it reported its available tools as `code, dummy, execute_bash, fs_read,
|
|
36
|
+
#: fs_write, glob, grep, todo_list, use_subagent` — the whole memory surface absent, with no warning
|
|
37
|
+
#: anywhere. `tools` selects; `mcpServers` only configures.
|
|
38
|
+
TOOL_SELECTOR: Final = f"@{MCP_SERVER_NAME}"
|
|
39
|
+
|
|
40
|
+
#: The consolidator's own config file name, and hence its agent name (kiro derives one from the
|
|
41
|
+
#: other). Named in `architecture.md`'s distribution table, so it is not ours to vary.
|
|
42
|
+
CONSOLIDATOR_AGENT_NAME: Final = "zikaron-consolidator"
|
|
43
|
+
|
|
44
|
+
_HOOK_SCRIPT: Final = "zikaron-hook"
|
|
45
|
+
_MCP_SCRIPT: Final = "zikaron-mcp"
|
|
46
|
+
|
|
47
|
+
#: Kiro's two trigger names, **read from the seam rather than spelled again**. A second copy here
|
|
48
|
+
#: would drift: the harness-table drift guard covers spec↔design and nothing covers
|
|
49
|
+
#: spec↔installer, so a trigger renamed in the spec would move the *hook* (whose tests read the
|
|
50
|
+
#: spec) while the *installer* kept writing the old name — an installed config naming a trigger the
|
|
51
|
+
#: hook does not recognise, which fails as no output and exit 0.
|
|
52
|
+
_AGENT_SPAWN: Final = KIRO.spawn_trigger
|
|
53
|
+
_USER_PROMPT_SUBMIT: Final = KIRO.prompt_trigger
|
|
54
|
+
|
|
55
|
+
#: Shared by both harnesses' consolidator definitions, which is why it is a constant rather than a
|
|
56
|
+
#: literal inside either builder: it is the text a harness reads to decide the agent is relevant,
|
|
57
|
+
#: and two copies would let one harness's install drift into describing a different agent.
|
|
58
|
+
#: **"and nothing else" was false under Claude Code**, where `consolidator_agent_markdown` grants
|
|
59
|
+
#: `Read` alongside the four verbs because `consolidator_can_read_files` is true there — the spill
|
|
60
|
+
#: path needs it. The description is the only *prose* statement of this agent's surface and is what
|
|
61
|
+
#: a parent model is shown when choosing a subagent, so it stated the opposite of a deliberate
|
|
62
|
+
#: grant. Restated as the property that actually holds on both harnesses, and that is what D32 is
|
|
63
|
+
#: about: no reach into search or fetch. The `Read` grant stays disclosed where it is actionable —
|
|
64
|
+
#: the frontmatter's own `tools:` block, and the spill guidance that explains its bounds.
|
|
65
|
+
_CONSOLIDATOR_DESCRIPTION: Final = (
|
|
66
|
+
"Consolidates Zikaron's memory journal into long-term records. Spawned by the "
|
|
67
|
+
"zikaron-consolidate skill; carries the four consolidation verbs and no way to search or "
|
|
68
|
+
"fetch memory."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
#: Claude Code's three triggers, derived from the seam for the reason above. `SubagentStart` has no
|
|
72
|
+
#: kiro counterpart and is not optional: the write-policy-per-subagent path sits behind it, and
|
|
73
|
+
#: kiro reaches the same place by firing its ordinary hooks *for* a subagent session instead.
|
|
74
|
+
#: Registering only the first two would leave that path dead with nothing failing — no error, no log
|
|
75
|
+
#: line, just subagents that never see the policy. The `is not None` filter is a type narrowing and
|
|
76
|
+
#: not a real branch: this tuple is Claude Code's, and Claude Code has all three.
|
|
77
|
+
_CLAUDE_TRIGGERS: Final = tuple(
|
|
78
|
+
trigger
|
|
79
|
+
for trigger in (
|
|
80
|
+
CLAUDE_CODE.spawn_trigger,
|
|
81
|
+
CLAUDE_CODE.prompt_trigger,
|
|
82
|
+
CLAUDE_CODE.subagent_start_trigger,
|
|
83
|
+
)
|
|
84
|
+
if trigger is not None
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HookFormat(StrEnum):
|
|
89
|
+
"""Which of the two formats the harness accepts to write.
|
|
90
|
+
|
|
91
|
+
Not a preference: a config already using one format must be merged in that format, because the
|
|
92
|
+
harness rewrites the file in whichever it read and a mixed file has no defined meaning.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
OBJECT = "object"
|
|
96
|
+
ARRAY = "array"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Commands(NamedTuple):
|
|
100
|
+
"""The two absolute command paths every shipped entry names.
|
|
101
|
+
|
|
102
|
+
Resolved from the running interpreter rather than accepted as strings, so an install cannot
|
|
103
|
+
write a `command` pointing at an interpreter that does not have Zikaron in it — which would fail
|
|
104
|
+
at hook time, in a subprocess whose stderr the harness does not surface.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
hook: Path
|
|
108
|
+
mcp: Path
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_this_interpreter(cls) -> "Commands":
|
|
112
|
+
"""`<venv>/bin/zikaron-hook` and `<venv>/bin/zikaron-mcp` for the interpreter running this.
|
|
113
|
+
|
|
114
|
+
`sysconfig.get_path("scripts")` is the same directory `pip install` put those two console
|
|
115
|
+
scripts in, so this resolves correctly under a venv, a `--user` install or a system install
|
|
116
|
+
without needing to know which of the three it is.
|
|
117
|
+
"""
|
|
118
|
+
scripts = Path(sysconfig.get_path("scripts"))
|
|
119
|
+
return cls(hook=scripts / _HOOK_SCRIPT, mcp=scripts / _MCP_SCRIPT)
|
|
120
|
+
|
|
121
|
+
def missing(self) -> tuple[Path, ...]:
|
|
122
|
+
"""Whichever of the two is not an executable file, in path order.
|
|
123
|
+
|
|
124
|
+
Checked rather than assumed because the failure is otherwise invisible until a hook fires:
|
|
125
|
+
installing from a source checkout without `pip install` leaves both absent, and a hook whose
|
|
126
|
+
`command` does not exist produces no output on the one channel the harness reads.
|
|
127
|
+
|
|
128
|
+
**Executability, not merely existence.** A console script that exists without the execute
|
|
129
|
+
bit — a file copied out of a wheel by hand, an install onto a filesystem mounted
|
|
130
|
+
`noexec` — fails at exactly the moment this check exists to protect, and `is_file()` alone
|
|
131
|
+
would pass it.
|
|
132
|
+
"""
|
|
133
|
+
return tuple(
|
|
134
|
+
path
|
|
135
|
+
for path in (self.hook, self.mcp)
|
|
136
|
+
if not (path.is_file() and os.access(path, os.X_OK))
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def hooks_object(commands: Commands) -> dict[str, list[dict[str, object]]]:
|
|
141
|
+
"""The object-format `hooks` value: one entry per trigger, keyed by trigger name.
|
|
142
|
+
|
|
143
|
+
Both triggers point at the same executable, which dispatches on `hook_event_name` from its own
|
|
144
|
+
stdin payload — kiro's hook fields carry no per-trigger argument mechanism, so the payload is
|
|
145
|
+
the only place the trigger name is available (`zikaron/hook/main.py`).
|
|
146
|
+
"""
|
|
147
|
+
return {
|
|
148
|
+
_AGENT_SPAWN: [_object_entry(commands.hook)],
|
|
149
|
+
_USER_PROMPT_SUBMIT: [_object_entry(commands.hook)],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def hooks_array(commands: Commands) -> list[dict[str, object]]:
|
|
154
|
+
"""The array-format `hooks` value: a flat list carrying its own `trigger` per entry."""
|
|
155
|
+
return [
|
|
156
|
+
_array_entry(commands.hook, _AGENT_SPAWN),
|
|
157
|
+
_array_entry(commands.hook, _USER_PROMPT_SUBMIT),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def hooks_value(commands: Commands, hook_format: HookFormat) -> object:
|
|
162
|
+
"""Whichever of the two the caller asked for, for a caller that only serializes it.
|
|
163
|
+
|
|
164
|
+
Two typed builders and this one-line dispatcher, rather than a single function returning a
|
|
165
|
+
union:
|
|
166
|
+
`writer.py` merges into an existing value and needs the concrete type to do it, while the
|
|
167
|
+
fragment printer only hands the result to `json.dumps` and does not care.
|
|
168
|
+
"""
|
|
169
|
+
if hook_format is HookFormat.OBJECT:
|
|
170
|
+
return hooks_object(commands)
|
|
171
|
+
return hooks_array(commands)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def hook_command_string(hook_command: Path) -> str:
|
|
175
|
+
"""The `command` string for a hook entry: the executable path, **shell-quoted**.
|
|
176
|
+
|
|
177
|
+
Measured, not inferred: this field is run through a shell. An unquoted
|
|
178
|
+
`/home/me/My Projects/.venv/bin/zikaron-hook` produced
|
|
179
|
+
`/bin/bash: line 1: /home/me/My: No such file or directory` and exit 127 from the real harness,
|
|
180
|
+
with the hook contributing nothing — no write policy on spawn, no memories on a prompt — while
|
|
181
|
+
the install itself had exited 0, because the path *is* an executable file and every preflight
|
|
182
|
+
passed.
|
|
183
|
+
A single-quoted path was then measured to run correctly.
|
|
184
|
+
|
|
185
|
+
So a project or venv directory containing a space is enough to break the whole install, with no
|
|
186
|
+
attacker and nothing exotic. `shlex.quote` is applied here rather than at each call site so both
|
|
187
|
+
formats are quoted by construction.
|
|
188
|
+
|
|
189
|
+
`mcpServers`' own `command` is deliberately **not** quoted: that field takes a program and a
|
|
190
|
+
separate `args` array, which is exec-style rather than shell-style, and quoting it would make
|
|
191
|
+
the harness look for a file whose name contains the quotes.
|
|
192
|
+
"""
|
|
193
|
+
return shlex.quote(str(hook_command))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _object_entry(hook_command: Path) -> dict[str, object]:
|
|
197
|
+
"""One object-format entry: the command, and both limits stated rather than inherited."""
|
|
198
|
+
return {
|
|
199
|
+
"command": hook_command_string(hook_command),
|
|
200
|
+
"timeout_ms": TIMEOUT_MS,
|
|
201
|
+
"max_output_size": MAX_OUTPUT_SIZE,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _array_entry(hook_command: Path, trigger: str) -> dict[str, object]:
|
|
206
|
+
"""One array-format entry.
|
|
207
|
+
|
|
208
|
+
`max_output_size` is **absent**, and that is a fact about the format rather than an omission:
|
|
209
|
+
the harness documents that field for object-format entries only. An installer that added it here
|
|
210
|
+
anyway would be writing a key with no documented meaning and no way to tell whether it took
|
|
211
|
+
effect — so an array-format install inherits the 10240-byte default, which is why
|
|
212
|
+
`writer.py` reports that difference instead of hiding it.
|
|
213
|
+
"""
|
|
214
|
+
return {
|
|
215
|
+
"name": f"zikaron-{trigger}",
|
|
216
|
+
"trigger": trigger,
|
|
217
|
+
"action": {"type": "command", "command": hook_command_string(hook_command)},
|
|
218
|
+
# Seconds here, milliseconds in the object entry above — the one place kiro's two formats
|
|
219
|
+
# disagree about a value rather than a shape. Both read the same canonical seconds constant
|
|
220
|
+
# rather than converting from each other, so neither can drift into the other's unit.
|
|
221
|
+
"timeout": HOOK_TIMEOUT_SECONDS,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def mcp_servers_value(commands: Commands, *, mode: str) -> dict[str, object]:
|
|
226
|
+
"""The `mcpServers` entry for one mode: `primary` for a user's own agent, `consolidator` for
|
|
227
|
+
ours."""
|
|
228
|
+
return {
|
|
229
|
+
MCP_SERVER_NAME: {
|
|
230
|
+
"command": str(commands.mcp),
|
|
231
|
+
"args": ["--mode", mode],
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def claude_tool_vocabulary() -> dict[str, str]:
|
|
237
|
+
"""Bare tool name → the `mcp__<server>__<tool>` form Claude Code serves and shows the model.
|
|
238
|
+
|
|
239
|
+
Built from the two tool sets and the two server names rather than listed, so a tool added to
|
|
240
|
+
`zikaron.mcp.tool_names` is covered here automatically and a *renamed* one fails
|
|
241
|
+
`assets._guard_known_tools` loudly instead of shipping a name nothing serves.
|
|
242
|
+
"""
|
|
243
|
+
return {
|
|
244
|
+
**{name: f"mcp__{MCP_SERVER_NAME}__{name}" for name in PRIMARY_TOOLS},
|
|
245
|
+
**{name: f"mcp__{CONSOLIDATOR_AGENT_NAME}__{name}" for name in CONSOLIDATOR_TOOLS},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def claude_hooks_value(commands: Commands) -> dict[str, list[dict[str, object]]]:
|
|
250
|
+
"""The `hooks` value for `.claude/settings.local.json`: three triggers, one command each.
|
|
251
|
+
|
|
252
|
+
The doubled nesting is the harness's own and was confirmed by running it: an event maps to a
|
|
253
|
+
list of *groups*, each carrying an optional `matcher` and its own inner `hooks` list
|
|
254
|
+
(`research/claude-code-installer-probe.md` §1). No `matcher` is written — `UserPromptSubmit`
|
|
255
|
+
accepts none at all, and the other two must fire for every session and every subagent, which is
|
|
256
|
+
what omitting it means.
|
|
257
|
+
|
|
258
|
+
`timeout` is **seconds** here, measured rather than assumed, and `command` is *not*
|
|
259
|
+
shell-quoted: unlike kiro's hook field this is not documented as passing through a shell, and
|
|
260
|
+
quoting a path the harness execs directly would make it look for a file whose name contains the
|
|
261
|
+
quotes — the same reasoning `mcp_servers_value` already applies to its own `command`.
|
|
262
|
+
"""
|
|
263
|
+
entry: dict[str, object] = {
|
|
264
|
+
"hooks": [
|
|
265
|
+
{
|
|
266
|
+
"type": "command",
|
|
267
|
+
"command": str(commands.hook),
|
|
268
|
+
"timeout": HOOK_TIMEOUT_SECONDS,
|
|
269
|
+
}
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
return {trigger: [entry] for trigger in _CLAUDE_TRIGGERS}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def claude_mcp_servers_value(commands: Commands) -> dict[str, object]:
|
|
276
|
+
"""Both servers, for `.mcp.json`.
|
|
277
|
+
|
|
278
|
+
Two entries rather than kiro's one, and not a stylistic difference: a server must be registered
|
|
279
|
+
**session-wide** to be reachable by any subagent at all, so the consolidator's server cannot
|
|
280
|
+
live inside the consolidator's own definition the way it does under kiro. That is the same fact
|
|
281
|
+
that makes D32's other half unenforceable here, reported by the installer rather than hidden.
|
|
282
|
+
"""
|
|
283
|
+
return {
|
|
284
|
+
**mcp_servers_value(commands, mode="primary"),
|
|
285
|
+
CONSOLIDATOR_AGENT_NAME: {
|
|
286
|
+
"command": str(commands.mcp),
|
|
287
|
+
"args": ["--mode", "consolidator"],
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def consolidator_agent_markdown(commands: Commands, *, model: str) -> str:
|
|
293
|
+
"""The whole `.claude/agents/zikaron-consolidator.md` — YAML frontmatter, prompt as body.
|
|
294
|
+
|
|
295
|
+
Three things differ from kiro's JSON and each is measured rather than translated:
|
|
296
|
+
|
|
297
|
+
- **`tools` is a whole-server wildcard**, not the four verbs by name. An unrecognised tool name
|
|
298
|
+
in frontmatter refuses the spawn outright with "would be spawned with zero tools"
|
|
299
|
+
(`claude-code-harness-probe.md` §6), so an explicit list is a second declaration of a fact
|
|
300
|
+
`mcp/consolidator.py` owns whose drift mode is a consolidator that will not start. The
|
|
301
|
+
wildcard was measured to grant that server's tools and to **exclude** the primary server's
|
|
302
|
+
`search`/`fetch` (`installer-probe` §7), which is the whole of what D7 needs.
|
|
303
|
+
- **No `mcpServers` block.** A frontmatter `mcpServers:` key is silently ignored (probe §6);
|
|
304
|
+
registration is session-wide in `.mcp.json` or it does not happen.
|
|
305
|
+
- **The model may be an alias**, because this harness refuses an unknown id loudly at spawn
|
|
306
|
+
rather than substituting its default. `harness.md` §"The consolidator's model".
|
|
307
|
+
- **`Read` is granted here and withheld from kiro's config**, from the one seam field that also
|
|
308
|
+
decides whether the client writes an over-large result to a file at all. One field for both,
|
|
309
|
+
because the mismatches are what hurt: the tool without the file widens this agent's reach for
|
|
310
|
+
nothing, and the file without the tool hands it a path it cannot open. The prompt's two fills
|
|
311
|
+
follow the same field, so a config and a prompt can never disagree about which tools exist.
|
|
312
|
+
|
|
313
|
+
It widens D32, which withholds retrieval so that "code picks the candidates" is enforced
|
|
314
|
+
mechanically rather than by prose, and the widening is bounded by prose alone: this harness has
|
|
315
|
+
no per-subagent path rule, so `Read` is grantable but not scopable. A `PreToolUse` hook in this
|
|
316
|
+
agent's own frontmatter is the mechanical version if that ever needs to be more than a
|
|
317
|
+
convention.
|
|
318
|
+
|
|
319
|
+
`commands` is unused and stays in the signature deliberately: it keeps this builder's shape
|
|
320
|
+
identical to `consolidator_agent_config`'s, so the two targets call one interface, and the fact
|
|
321
|
+
that Claude Code needs no per-agent server registration is visible as an *unused argument* here
|
|
322
|
+
rather than as an asymmetry the caller has to know about.
|
|
323
|
+
"""
|
|
324
|
+
del commands
|
|
325
|
+
frontmatter = "\n".join(
|
|
326
|
+
(
|
|
327
|
+
"---",
|
|
328
|
+
f"name: {CONSOLIDATOR_AGENT_NAME}",
|
|
329
|
+
f"description: {_CONSOLIDATOR_DESCRIPTION}",
|
|
330
|
+
f"model: {model}",
|
|
331
|
+
"tools:",
|
|
332
|
+
f" - mcp__{CONSOLIDATOR_AGENT_NAME}",
|
|
333
|
+
*([" - Read"] if CLAUDE_CODE.consolidator_can_read_files else []),
|
|
334
|
+
"---",
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
body = consolidator_prompt(
|
|
338
|
+
claude_tool_vocabulary(), can_read_files=CLAUDE_CODE.consolidator_can_read_files
|
|
339
|
+
)
|
|
340
|
+
return f"{frontmatter}\n\n{body}\n"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def consolidator_agent_config(commands: Commands, *, model: str) -> dict[str, object]:
|
|
344
|
+
"""The whole `zikaron-consolidator.json`.
|
|
345
|
+
|
|
346
|
+
Every field is a decision `architecture.md` §"The install contract" states, and three are worth
|
|
347
|
+
re-reading here because their absence would be invisible:
|
|
348
|
+
|
|
349
|
+
- `tools` is `@zikaron` alone — and it has to be there at all, since `mcpServers` configures a
|
|
350
|
+
server while `tools` is what selects its tools. No `read`, `write` or `shell`: the design's
|
|
351
|
+
claim is that *code* selects the candidates, and a consolidator that can read the repository
|
|
352
|
+
can wander outside the group it was handed.
|
|
353
|
+
- `allowedTools` repeats it, because a subagent has no user to answer a permission prompt — an
|
|
354
|
+
available-but-not-allowed tool is one that fails at the moment it is needed. (The primary
|
|
355
|
+
agent's own config gets the same treatment by default, for a different reason that
|
|
356
|
+
`writer._selecting` sets out.)
|
|
357
|
+
- there is **no** `hooks` key. This session must not fire the push hook (it has no `search` to
|
|
358
|
+
spend a result on) and must not print the write policy (its policy is this prompt).
|
|
359
|
+
"""
|
|
360
|
+
return {
|
|
361
|
+
"name": CONSOLIDATOR_AGENT_NAME,
|
|
362
|
+
"description": _CONSOLIDATOR_DESCRIPTION,
|
|
363
|
+
"model": model,
|
|
364
|
+
"prompt": consolidator_prompt(
|
|
365
|
+
identity_vocabulary(), can_read_files=KIRO.consolidator_can_read_files
|
|
366
|
+
),
|
|
367
|
+
"tools": [TOOL_SELECTOR],
|
|
368
|
+
"allowedTools": [TOOL_SELECTOR],
|
|
369
|
+
"mcpServers": mcp_servers_value(commands, mode="consolidator"),
|
|
370
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Everything install asks a harness binary, and nothing else talks to one.
|
|
2
|
+
|
|
3
|
+
**Mostly kiro's**, and one question that is not: `binary_is_available` answers *is this harness
|
|
4
|
+
installed at all* for **either** harness, which is what `HarnessTarget.refuse_absent_harness` is
|
|
5
|
+
built on. The rest — the model list and the config validator — exist only because kiro substitutes
|
|
6
|
+
an unknown model silently, and have no Claude Code counterpart.
|
|
7
|
+
|
|
8
|
+
The reason these live here rather than inline in `main.py` is that all of them are *measurements of
|
|
9
|
+
the local machine* — the answers differ per install, and a test can substitute these functions
|
|
10
|
+
without simulating a CLI.
|
|
11
|
+
|
|
12
|
+
`architecture.md` §"The install contract" is normative, including the measured reason the second
|
|
13
|
+
question cannot be answered by the obvious command: `kiro-cli agent validate` accepts an unknown
|
|
14
|
+
`model` silently, so an installer that relied on it would leave the consolidator's model — the one
|
|
15
|
+
variable the design asks us to hold fixed and measure — set by a silent fallback instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from zikaron.harness.spec import KIRO
|
|
25
|
+
|
|
26
|
+
#: The harness binary every shipped entry ultimately depends on. **Read from the seam** rather than
|
|
27
|
+
#: spelled again: `refuse_absent_harness` checks `HarnessSpec.harness_binary` while the calls below
|
|
28
|
+
#: *exec* this constant, so two hand-maintained copies could refuse on one name and then run
|
|
29
|
+
#: another — reintroducing the drift the single field exists to prevent, right beside it.
|
|
30
|
+
#:
|
|
31
|
+
#: Resolved through `PATH` rather than pinned to a path — that part is about the *lookup*, not the
|
|
32
|
+
#: name: unlike Zikaron's own console scripts, which must name the interpreter they were installed
|
|
33
|
+
#: into, this is the user's own kiro, and whichever one their shell finds is the one that will run
|
|
34
|
+
#: the hooks.
|
|
35
|
+
KIRO_BINARY: Final = KIRO.harness_binary
|
|
36
|
+
|
|
37
|
+
#: Every harness call is bounded. A hung `kiro-cli` must fail the install loudly rather than leave a
|
|
38
|
+
#: terminal sitting on a subprocess with no output: generous enough for a cold start on a slow
|
|
39
|
+
#: machine, short enough that "it stopped" is distinguishable from "it is thinking".
|
|
40
|
+
_TIMEOUT_SECONDS: Final = 60.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InstallError(Exception):
|
|
44
|
+
"""An install could not proceed, with a message written for whoever ran the command.
|
|
45
|
+
|
|
46
|
+
Deliberately **not** `ZikaronError`: §7 of `coding-standards.md` makes that type the carrier of
|
|
47
|
+
the design's numeric *wire* codes, and every one of them describes something a client asked the
|
|
48
|
+
service to do. An absent `kiro-cli`, an unknown model id or a file that already exists are none
|
|
49
|
+
of those — giving them wire codes would put values on that contract that no RPC can ever return.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def binary_is_available(name: str) -> bool:
|
|
54
|
+
"""Whether `name` is on `PATH` at all.
|
|
55
|
+
|
|
56
|
+
Presence only — deliberately not a `--version` call. What the installer needs to know is whether
|
|
57
|
+
the harness that will *read* these artefacts exists on this machine, and that question should
|
|
58
|
+
not depend on the binary being authenticated, responsive, or willing to talk. One `PATH` lookup
|
|
59
|
+
also costs nothing on the common path where it succeeds.
|
|
60
|
+
"""
|
|
61
|
+
return shutil.which(name) is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def harness_is_available() -> bool:
|
|
65
|
+
"""Whether `kiro-cli` is on `PATH` at all."""
|
|
66
|
+
return binary_is_available(KIRO_BINARY)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def available_model_ids() -> frozenset[str]:
|
|
70
|
+
"""Every model id this harness will accept, from `kiro-cli chat --list-models -f json`.
|
|
71
|
+
|
|
72
|
+
The one authority for a model id, because nothing else on the machine is: the agent-config
|
|
73
|
+
validator checks schema only, and the harness's own documented behaviour on an unavailable model
|
|
74
|
+
is to fall back to its default — which is exactly the silent substitution
|
|
75
|
+
`architecture.md`'s no-silent-fallback rule exists to prevent.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
InstallError: the binary is absent, failed, timed out, or answered something this function
|
|
79
|
+
cannot read as a model list. Every one of those is refused rather than treated as "no
|
|
80
|
+
models found", because an empty set would make the caller reject a perfectly good id
|
|
81
|
+
for the wrong reason.
|
|
82
|
+
"""
|
|
83
|
+
if not harness_is_available():
|
|
84
|
+
raise InstallError(
|
|
85
|
+
f"{KIRO_BINARY} is not on PATH. The hook and MCP entries this installs are read by "
|
|
86
|
+
f"{KIRO_BINARY} itself, so an install cannot be verified without it."
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
completed = subprocess.run( # noqa: S603 — a fixed argv, no shell, nothing interpolated.
|
|
90
|
+
[KIRO_BINARY, "chat", "--list-models", "-f", "json"],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
timeout=_TIMEOUT_SECONDS,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
97
|
+
raise InstallError(f"could not run `{KIRO_BINARY} chat --list-models`: {exc}") from exc
|
|
98
|
+
if completed.returncode != 0:
|
|
99
|
+
raise InstallError(
|
|
100
|
+
f"`{KIRO_BINARY} chat --list-models` exited {completed.returncode}: "
|
|
101
|
+
f"{completed.stderr.strip() or '(no stderr)'}"
|
|
102
|
+
)
|
|
103
|
+
return _parse_model_ids(completed.stdout)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_model_ids(raw: str) -> frozenset[str]:
|
|
107
|
+
"""The `model_id` of every entry in a `--list-models -f json` payload.
|
|
108
|
+
|
|
109
|
+
Reads `model_id` specifically, not `model_name`: the two are equal for every model this harness
|
|
110
|
+
currently lists, which is precisely why picking either at random would work today and could stop
|
|
111
|
+
working silently. `model` in an agent config is an id.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(raw)
|
|
115
|
+
except json.JSONDecodeError as exc:
|
|
116
|
+
raise InstallError(
|
|
117
|
+
f"`{KIRO_BINARY} chat --list-models -f json` was not JSON: {exc}"
|
|
118
|
+
) from exc
|
|
119
|
+
models = payload.get("models") if isinstance(payload, dict) else None
|
|
120
|
+
if not isinstance(models, list):
|
|
121
|
+
raise InstallError("model list JSON has no `models` array")
|
|
122
|
+
ids = {
|
|
123
|
+
entry["model_id"]
|
|
124
|
+
for entry in models
|
|
125
|
+
if isinstance(entry, dict) and isinstance(entry.get("model_id"), str)
|
|
126
|
+
}
|
|
127
|
+
if not ids:
|
|
128
|
+
raise InstallError("model list JSON carried no readable `model_id` values")
|
|
129
|
+
return frozenset(ids)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_agent_config(path: Path) -> str | None:
|
|
133
|
+
"""Run `kiro-cli agent validate` on a written config; return its complaint, or `None` if clean.
|
|
134
|
+
|
|
135
|
+
A **report**, not a gate, and the distinction is the point: this catches a schema mistake in
|
|
136
|
+
what we just wrote, while the model check above catches the one thing this command provably
|
|
137
|
+
does not.
|
|
138
|
+
Returning the harness's own text rather than a boolean keeps its diagnostics — which name the
|
|
139
|
+
offending field — instead of replacing them with "invalid".
|
|
140
|
+
|
|
141
|
+
**A complaint is *output*, not a non-zero exit, and getting that backwards made this function a
|
|
142
|
+
no-op for three milestones.** Measured against kiro-cli 2.16.0, 2026-08-16:
|
|
143
|
+
|
|
144
|
+
| config | exit | stdout | stderr |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| valid | 0 | empty | empty |
|
|
147
|
+
| unparseable JSON | **0** | empty | `Error: Json supplied at … is invalid` |
|
|
148
|
+
| wrong field type | **0** | empty | `Error: … invalid type, expected a sequence` |
|
|
149
|
+
| absent file | **0** | empty | `Error: Encountered io error: No such file` |
|
|
150
|
+
|
|
151
|
+
The original returned `None` whenever the exit code was zero, which is *always* — so every
|
|
152
|
+
install reported a clean validation regardless of what it had written, and
|
|
153
|
+
the claim at the top of this docstring — that this "catches a schema mistake in what we just
|
|
154
|
+
wrote" — was false. `architecture.md` §"The install contract" records the measurement that
|
|
155
|
+
settled it: a complaint arrives on **stderr at exit 0**. *That claim was attributed to
|
|
156
|
+
`architecture.md` and appears in no design document; `git log -S` puts its origin in this
|
|
157
|
+
file's own docstring.* Nothing caught it because the unit fixtures encoded the belief the real
|
|
158
|
+
binary contradicts (errors ⇒ non-zero exit) and the one test against the real binary asserted
|
|
159
|
+
`is None`, which passed for the wrong reason. The non-zero branch is kept anyway: an exit code
|
|
160
|
+
this command does not currently produce would still mean something went wrong.
|
|
161
|
+
|
|
162
|
+
**A false positive here is cheap, which is what makes "any output is a complaint" the right
|
|
163
|
+
predicate rather than a lazy one.** If a future kiro wrote a deprecation warning to stderr on a
|
|
164
|
+
perfectly good config, this would relay it — and the cost is one printed line in an install that
|
|
165
|
+
still succeeds, because this is a *report* and nothing branches on it. The opposite error is the
|
|
166
|
+
one that actually happened: a predicate tuned to avoid noise reported nothing at all, for three
|
|
167
|
+
milestones, and no test could see it. Narrowing this to match `Error:` would trade a real
|
|
168
|
+
guarantee for tidiness against a format nobody has measured.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
completed = subprocess.run( # noqa: S603 — a fixed argv, no shell; only `path` varies.
|
|
172
|
+
[KIRO_BINARY, "agent", "validate", "--path", str(path)],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
timeout=_TIMEOUT_SECONDS,
|
|
176
|
+
check=False,
|
|
177
|
+
)
|
|
178
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
179
|
+
return f"could not run `{KIRO_BINARY} agent validate`: {exc}"
|
|
180
|
+
complaint = completed.stderr.strip() or completed.stdout.strip()
|
|
181
|
+
if completed.returncode != 0:
|
|
182
|
+
return complaint or (
|
|
183
|
+
f"`{KIRO_BINARY} agent validate` exited {completed.returncode} with no output"
|
|
184
|
+
)
|
|
185
|
+
return complaint or None
|