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.
Files changed (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. 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