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,789 @@
1
+ """The one place a harness difference is allowed to take a different **shape** rather than a
2
+ different value.
3
+
4
+ `design/harness.md` §"The installer's two targets" is normative. Everywhere else in Zikaron — both
5
+ thin clients — a harness difference is data in `zikaron.harness.spec`, because forking doubles every
6
+ future change to the two components the design keeps deliberately thin. The installer cannot be
7
+ written that way, and the reason is structural rather than a concession: kiro's artefacts are
8
+ **one** file whose path the *user* supplies, and Claude Code's are **four** files at paths the
9
+ *project* fixes. That is not two values of one parameter.
10
+
11
+ **The split this module has to keep honest.** A *value* that differs (a session variable, a trigger
12
+ name, an injection budget, the consolidator's model default) is a `HarnessSpec` field with a row in
13
+ `harness.md` §"The table" and a drift-guard test. A *shape* that differs (which files exist, how
14
+ each is merged) is a method here. A value that migrates into a method is the seam failing, and the
15
+ symptom is a literal in this file that also appears in `spec.py`.
16
+
17
+ What does **not** vary lives in `main.py` and `writer.py` and is called by both implementations:
18
+ preflight order, the check-everything-then-write-everything split, collision policy, backup
19
+ discipline, atomic replacement.
20
+ """
21
+
22
+ import json
23
+ import re
24
+ from abc import ABC, abstractmethod
25
+ from pathlib import Path
26
+ from typing import Final
27
+
28
+ from zikaron.harness.spec import CLAUDE_CODE, KIRO, Harness, HarnessSpec
29
+ from zikaron.install import harness as harness_cli
30
+ from zikaron.install.assets import (
31
+ CLAUDE_CODE_SPAWN_INSTRUCTION,
32
+ KIRO_SPAWN_INSTRUCTION,
33
+ SKILL_NAME,
34
+ identity_vocabulary,
35
+ skill_markdown,
36
+ )
37
+ from zikaron.install.entries import (
38
+ CONSOLIDATOR_AGENT_NAME,
39
+ MCP_SERVER_NAME,
40
+ TOOL_SELECTOR,
41
+ Commands,
42
+ HookFormat,
43
+ claude_hooks_value,
44
+ claude_mcp_servers_value,
45
+ claude_tool_vocabulary,
46
+ consolidator_agent_config,
47
+ consolidator_agent_markdown,
48
+ hooks_value,
49
+ mcp_servers_value,
50
+ )
51
+ from zikaron.install.harness import InstallError
52
+ from zikaron.install.writer import (
53
+ ARRAY_FORMAT_OUTPUT_CAP_NOTE,
54
+ MergePlan,
55
+ Plan,
56
+ ShippedFile,
57
+ Targets,
58
+ guard_backup_path,
59
+ plan_kiro_merge,
60
+ )
61
+
62
+ _JSON_INDENT = 2
63
+
64
+ #: What may be written into `model:` in YAML frontmatter.
65
+ #:
66
+ #: **The first character must be alphanumeric, and that is the load-bearing half.** In YAML a `[`
67
+ #: only opens a flow sequence at the *start* of a scalar, so `sonnet[1m]` is a plain string while
68
+ #: `[1m]` is a one-element list — the same class of silent meaning-change as an embedded colon, and
69
+ #: the reason the anchor is not simply `[A-Za-z0-9._\[\]-]+`.
70
+ #:
71
+ #: **Brackets are permitted because they were measured, not assumed.** An earlier revision refused
72
+ #: them and its comment claimed "every id and alias either harness serves satisfies it" — an
73
+ #: unmeasured universal. Probing it refuted it: a subagent whose frontmatter said
74
+ #: `model: sonnet[1m]` **spawned normally** (`research/claude-code-installer-probe.md` §11), so the
75
+ #: long-context alias form is a legitimate value the installer was rejecting.
76
+ _MODEL_ID: Final = re.compile(r"[A-Za-z0-9][A-Za-z0-9._\[\]-]*")
77
+
78
+ #: The settings key that pre-approves project-scoped MCP servers. Written under the same
79
+ #: `--no-trust-tools` flag that governs kiro's `allowedTools`, because it answers the same question:
80
+ #: does this install trust its own tools, or make the user approve them one at a time.
81
+ _ENABLED_SERVERS_KEY: Final = "enabledMcpjsonServers"
82
+
83
+ #: Claude Code's permission key. **Distinct from `_ENABLED_SERVERS_KEY`, and the two are easy to
84
+ #: conflate**: that key governs whether a project-scoped server *loads*, while this one governs
85
+ #: whether each tool *call* is approved. Writing only the first leaves every
86
+ #: `zikaron_memory_remember` behind an approval prompt — the per-write friction `writer._selecting`
87
+ #: calls worse than not asking at all, installed silently and with nothing saying so.
88
+ _PERMISSIONS_KEY: Final = "permissions"
89
+ _ALLOW_KEY: Final = "allow"
90
+
91
+ #: Said whenever a Claude Code install completes, because the failure it warns about is silent and
92
+ #: total: a project-scoped server that has not been approved simply does not load, and the agent
93
+ #: then has no Zikaron tools with nothing anywhere saying why.
94
+ _APPROVAL_NOTE: Final = (
95
+ "Claude Code asks for approval twice over, and a default install answers both: "
96
+ f"`{_ENABLED_SERVERS_KEY}` for whether a project-scoped `.mcp.json` server **loads** at all, "
97
+ "and "
98
+ f"`{_PERMISSIONS_KEY}.{_ALLOW_KEY}` for whether each tool **call** goes through without a "
99
+ "prompt. Both were measured to work in an interactive session — no load prompt, and no "
100
+ "per-call prompt across six writes and eight reads — but nothing in the shipped software can "
101
+ "check either one, because a headless run approves everything "
102
+ "(`research/claude-code-installer-probe.md` §8). So if Zikaron's tools are "
103
+ "absent, check `/mcp` for a server pending approval before looking anywhere else — and if they "
104
+ "are present but every write interrupts you, it is the second key that did not take."
105
+ )
106
+
107
+ #: Said for the same reason as `_APPROVAL_NOTE`, about a gate that install-time config deliberately
108
+ #: does **not** answer. Measured in an operator-driven session: the consolidator's first `Read` of a
109
+ #: spilled payload raises a prompt, and the harness offers to allow that directory for the session.
110
+ #:
111
+ #: A `permissions.allow` entry for the runtime directory was considered and **rejected**. `Read` is
112
+ #: grantable but not scopable in subagent frontmatter, so this prompt is the one place D32's
113
+ #: widening becomes a check an operator answers rather than prose; the harness's own session-scoped
114
+ #: option is narrower than anything an installed settings entry could express; D10 makes
115
+ #: consolidation operator-invoked, so there is no unattended run for it to stall; and on a mature
116
+ #: store spilling is the *majority* path, so a permanent entry would have the widened grant
117
+ #: exercised silently on essentially every consolidation forever. What the note buys is that the
118
+ #: prompt arrives expected rather than mid-run and unexplained.
119
+ _SPILL_READ_NOTE: Final = (
120
+ "A group too large for this harness to deliver is written to a file and the consolidator is "
121
+ "given its path, so the first time a consolidation meets such a group — routine on a mature "
122
+ "store, possibly never on a small one — Claude Code will ask whether it may read from the "
123
+ "runtime directory (`$XDG_RUNTIME_DIR/zikaron`, or `/tmp/zikaron-<uid>` where that variable "
124
+ "is unset). That prompt is expected, **recurs in each new session** because the grant it "
125
+ "offers is session-scoped, and is deliberately not answered by this install: choose the "
126
+ "option allowing that directory for the session. A file-reading tool cannot be restricted to "
127
+ "one path in subagent config, so this prompt is the only point at which that grant is put to "
128
+ "you as a question."
129
+ )
130
+
131
+ #: The exposure D32 cannot mechanically prevent here, reported rather than passed over in silence.
132
+ _EXPOSURE_NOTE: Final = (
133
+ "Your primary agent can see the four consolidation verbs "
134
+ f"(`mcp__{CONSOLIDATOR_AGENT_NAME}__*`) as well as its own tools. That is not a "
135
+ "misconfiguration and it cannot be fixed here: a server must be registered session-wide to be "
136
+ "reachable by any subagent at all, and `permissions.deny` is global — denying a verb "
137
+ "unregisters it and the consolidator subagent is then refused at spawn with zero tools. Under "
138
+ "kiro the two tool sets are mechanically separate; here that half of the design is "
139
+ "prompt-only, and an unauthorized consolidation costs tokens and possibly a poorly-judged "
140
+ "long-term record."
141
+ )
142
+
143
+
144
+ class HarnessTarget(ABC):
145
+ """How one harness's artefacts are serialized and merged.
146
+
147
+ Deliberately narrow. Every method answers "what does *this* harness need on disk"; none of them
148
+ decides *whether* to write, in what order, or what to do on a collision — those are one
149
+ implementation in `main.py` and `writer.py` and stay that way.
150
+ """
151
+
152
+ #: The harness this target installs for, and the spec its values come from.
153
+ spec: HarnessSpec
154
+
155
+ @property
156
+ def name(self) -> str:
157
+ """The `--harness` value that selects this target."""
158
+ return str(self.spec.harness.value)
159
+
160
+ @property
161
+ @abstractmethod
162
+ def accepts_agent_flag(self) -> bool:
163
+ """Whether `--agent` means anything here.
164
+
165
+ Kiro merges into a config the user names; Claude Code's merge targets are fixed project
166
+ paths. Passing `--agent` to a target that has no use for it is **refused** rather than
167
+ ignored, because ignoring it would silently discard the one instruction the user gave about
168
+ where their configuration lives.
169
+ """
170
+
171
+ @abstractmethod
172
+ def shipped_files(self, plan: Plan) -> tuple[ShippedFile, ...]:
173
+ """The artefacts this install owns outright, path and exact content."""
174
+
175
+ @abstractmethod
176
+ def plan_merges(self, plan: Plan, agent: Path | None) -> tuple[MergePlan, ...]:
177
+ """Every merge this install would perform, checked and not yet written.
178
+
179
+ Raises:
180
+ InstallError: any refusal. Nothing has been written when one is raised — that ordering
181
+ is the contract's, and it is why this returns a value instead of writing.
182
+ """
183
+
184
+ @abstractmethod
185
+ def fragment(self, plan: Plan) -> str:
186
+ """What `--print-only` shows instead of merging: the entries, and the caveats."""
187
+
188
+ def refuse_absent_harness(self) -> None:
189
+ """Refuse when the harness that would *read* these artefacts is not on this machine.
190
+
191
+ **One rule for both harnesses, parameterized by a seam value** — `harness_binary` — rather
192
+ than two implementations, because the argument does not vary: an install writes files whose
193
+ only reader is that binary, and writing them where it does not exist produces exit 0, no
194
+ Zikaron tools, and nothing anywhere saying why. That is the same working-looking-inert
195
+ outcome `_resolve_harness` refuses to *guess* its way into, and refusing one while
196
+ permitting the other was an inconsistency rather than a design.
197
+
198
+ It was also, before this existed, an accident of implementation: kiro refused an absent
199
+ binary only as a **side effect** of its model check, so the message talked about model
200
+ validation rather than about the harness being missing, and Claude Code — which needs no
201
+ model check — did not refuse at all.
202
+
203
+ **Presence, not health.** A binary that is installed but unauthenticated still means the
204
+ harness is here and will read these files; whether the user can currently talk to it is a
205
+ different problem with a different remedy.
206
+
207
+ Raises:
208
+ InstallError: the harness binary is not on `PATH`.
209
+ """
210
+ if harness_cli.binary_is_available(self.spec.harness_binary):
211
+ return
212
+ raise InstallError(
213
+ f"{self.spec.harness_binary} is not on PATH, so nothing on this machine would read "
214
+ f"what a --harness {self.name} install writes. The hook and MCP entries name that "
215
+ "binary's own mechanisms, and installing them where it is absent exits 0 and leaves no "
216
+ "Zikaron tools at all. Install the harness first, or use --print-only to see exactly "
217
+ "what would be written."
218
+ )
219
+
220
+ @abstractmethod
221
+ def refuse_unknown_model(self, model: str) -> None:
222
+ """Refuse a model id this harness would not serve as asked.
223
+
224
+ Raises:
225
+ InstallError: the id is not one this harness accepts.
226
+ """
227
+
228
+ def notes(self, plan: Plan) -> list[str]:
229
+ """Anything true of this harness that the user should be told after a successful install."""
230
+ del plan
231
+ return []
232
+
233
+ def complaints_about(self, paths: list[Path]) -> list[str]:
234
+ """Whatever the harness itself dislikes about the files we just wrote.
235
+
236
+ A **report**, never a refusal — the install has already happened by the time this is called,
237
+ and telling the user what the harness objects to is more useful than an exception that
238
+ leaves them guessing which file it meant. Empty by default: only kiro ships a config
239
+ validator to ask.
240
+ """
241
+ del paths
242
+ return []
243
+
244
+
245
+ class KiroTarget(HarnessTarget):
246
+ """kiro-cli: two shipped files under `.kiro/`, and one merge into a user-named agent config.
247
+
248
+ Byte-for-byte what kiro already shipped, asserted by a regression guard rather than hoped for:
249
+ the risk in generalising the writer is that it quietly changes a working harness's output, and
250
+ "we did not mean to" is not evidence.
251
+ """
252
+
253
+ spec = KIRO
254
+
255
+ @property
256
+ def accepts_agent_flag(self) -> bool:
257
+ return True
258
+
259
+ def shipped_files(self, plan: Plan) -> tuple[ShippedFile, ...]:
260
+ targets = Targets(project=plan.project)
261
+ config = consolidator_agent_config(plan.commands, model=plan.model)
262
+ return (
263
+ ShippedFile(
264
+ targets.consolidator_config, json.dumps(config, indent=_JSON_INDENT) + "\n"
265
+ ),
266
+ ShippedFile(
267
+ targets.skill_file,
268
+ skill_markdown(identity_vocabulary(), KIRO_SPAWN_INSTRUCTION),
269
+ ),
270
+ )
271
+
272
+ def plan_merges(self, plan: Plan, agent: Path | None) -> tuple[MergePlan, ...]:
273
+ if agent is None:
274
+ return ()
275
+ return (plan_kiro_merge(agent, plan, Targets(project=plan.project)),)
276
+
277
+ def fragment(self, plan: Plan) -> str:
278
+ entries = json.dumps(
279
+ {
280
+ "hooks": hooks_value(plan.commands, plan.hook_format),
281
+ "mcpServers": mcp_servers_value(plan.commands, mode="primary"),
282
+ },
283
+ indent=_JSON_INDENT,
284
+ )
285
+ caveats = [
286
+ f"`{TOOL_SELECTOR}` must be in that agent's `tools`, or Zikaron's tools are simply "
287
+ "absent — `mcpServers` configures the server and `tools` selects from it. Put it in "
288
+ "`allowedTools` as well, or every Zikaron tool call will interrupt you for approval.",
289
+ "The agent also needs `subagent` among its `tools` for the zikaron-consolidate skill "
290
+ "to spawn the consolidator.",
291
+ ]
292
+ if plan.hook_format is HookFormat.ARRAY:
293
+ caveats.append(ARRAY_FORMAT_OUTPUT_CAP_NOTE)
294
+ return (
295
+ "Add these to the agent config you actually use (or re-run with --agent <path> to have "
296
+ "them merged in, with a backup):\n\n"
297
+ + entries
298
+ + "\n\n"
299
+ + "\n".join(f"- {caveat}" for caveat in caveats)
300
+ )
301
+
302
+ def refuse_unknown_model(self, model: str) -> None:
303
+ """`architecture.md`'s no-silent-fallback rule, enforced where a config is about to be
304
+ written.
305
+
306
+ The check is *membership*, not a regex or a prefix: the harness answers with an exact set,
307
+ and accepting anything outside it would install a config whose model the harness silently
308
+ replaces with its own default — leaving two consolidation runs incomparable while both look
309
+ healthy.
310
+ """
311
+ available = harness_cli.available_model_ids()
312
+ if model in available:
313
+ return
314
+ raise InstallError(
315
+ f"{model!r} is not a model this harness offers. `kiro-cli agent validate` would accept "
316
+ "it silently and the harness would fall back to its default, so it is refused here "
317
+ f"instead. Available: {', '.join(sorted(available))}"
318
+ )
319
+
320
+ def complaints_about(self, paths: list[Path]) -> list[str]:
321
+ """`kiro-cli agent validate`'s own text about any JSON we wrote, if it has any.
322
+
323
+ Its diagnostics name the offending field, so they are relayed rather than replaced with
324
+ "invalid". This catches a schema mistake in what we just wrote; the model check above
325
+ catches the one thing this command provably does not.
326
+ """
327
+ complaints: list[str] = []
328
+ for path in paths:
329
+ if path.suffix != ".json":
330
+ continue
331
+ complaint = harness_cli.validate_agent_config(path)
332
+ if complaint is not None:
333
+ complaints.append(f"{path}: `kiro-cli agent validate` says: {complaint}")
334
+ return complaints
335
+
336
+
337
+ class ClaudeCodeTarget(HarnessTarget):
338
+ """Claude Code: two shipped Markdown/JSON artefacts, and two merges into fixed project files."""
339
+
340
+ spec = CLAUDE_CODE
341
+
342
+ @property
343
+ def accepts_agent_flag(self) -> bool:
344
+ return False
345
+
346
+ def _settings(self, project: Path) -> Path:
347
+ """`.claude/settings.local.json`, never `settings.json`.
348
+
349
+ The `command` strings written here are absolute venv paths — machine-local by construction,
350
+ for the reason `architecture.md` §"The install contract" gives about console scripts — and
351
+ `settings.json` is the shared, checked-in layer. Writing machine-local paths into a file the
352
+ user commits would break every other clone of the repository.
353
+ """
354
+ return project / ".claude" / "settings.local.json"
355
+
356
+ def _mcp_config(self, project: Path) -> Path:
357
+ return project / ".mcp.json"
358
+
359
+ def shipped_files(self, plan: Plan) -> tuple[ShippedFile, ...]:
360
+ project = plan.project
361
+ return (
362
+ ShippedFile(
363
+ project / ".claude" / "agents" / f"{CONSOLIDATOR_AGENT_NAME}.md",
364
+ consolidator_agent_markdown(plan.commands, model=plan.model),
365
+ ),
366
+ ShippedFile(
367
+ project / ".claude" / "skills" / SKILL_NAME / "SKILL.md",
368
+ skill_markdown(claude_tool_vocabulary(), CLAUDE_CODE_SPAWN_INSTRUCTION),
369
+ ),
370
+ )
371
+
372
+ def plan_merges(self, plan: Plan, agent: Path | None) -> tuple[MergePlan, ...]:
373
+ del agent # refused upstream by `accepts_agent_flag`; never reaches here.
374
+ return (self._plan_settings(plan), self._plan_mcp(plan))
375
+
376
+ def _plan_settings(self, plan: Plan) -> MergePlan:
377
+ """Hook entries, and optionally the server pre-approval, merged into the settings file.
378
+
379
+ **The user's own hooks on our triggers survive**, which is not a nicety: `SessionStart` and
380
+ `UserPromptSubmit` are ordinary triggers a person may already be using, and this file is one
381
+ they edit by hand. So the merge replaces *our* group within each trigger's list and leaves
382
+ every other group exactly where it was — the same rule kiro's `_merged_hooks_object`
383
+ applies, for the same reason. An earlier version of this method assigned the whole trigger
384
+ key, which would have deleted a user's own hook and, worse, refused the install first on
385
+ the grounds that it "differed".
386
+ """
387
+ path = self._settings(plan.project)
388
+ _guard_backup_path_if_present(path)
389
+ document = _load_json_object_or_empty(path)
390
+ existing = document.get("hooks")
391
+ _refuse_unmergeable_shape(existing, dict, path=path, key="hooks")
392
+ by_trigger: dict[str, list[object]] = {}
393
+ for key, value in (existing or {}).items() if isinstance(existing, dict) else ():
394
+ _refuse_unmergeable_shape(value, list, path=path, key=f"hooks.{key}")
395
+ by_trigger[key] = list(value)
396
+ ours = claude_hooks_value(plan.commands)
397
+ _refuse_differing_hook_groups(by_trigger, ours, plan=plan, path=path)
398
+
399
+ merged = dict(document)
400
+ merged["hooks"] = {
401
+ **{key: value for key, value in by_trigger.items() if key not in ours},
402
+ **{
403
+ trigger: [
404
+ *(
405
+ group
406
+ for group in by_trigger.get(trigger, [])
407
+ if not _is_a_zikaron_hook_group(group, plan.commands)
408
+ ),
409
+ *groups,
410
+ ]
411
+ for trigger, groups in ours.items()
412
+ },
413
+ }
414
+ notes: list[str] = []
415
+ listed = document.get(_ENABLED_SERVERS_KEY)
416
+ _refuse_unmergeable_shape(listed, list, path=path, key=_ENABLED_SERVERS_KEY)
417
+ if plan.trust_tools:
418
+ merged[_ENABLED_SERVERS_KEY] = _with_servers(listed)
419
+ elif not _ZIKARON_SERVERS.issubset(_strings_in(listed)):
420
+ # **Conditioned on absence.** The merge never *removes* an
421
+ # entry, so a `--no-trust-tools` re-run over a previously-trusting install leaves both
422
+ # grants in the file — and an unconditional note would then assert the opposite of what
423
+ # the file now says. `plan_kiro_merge` already guards its equivalent note this way.
424
+ notes.append(
425
+ f"`{_ENABLED_SERVERS_KEY}` was **not** written (--no-trust-tools), so you will be "
426
+ "asked to approve both Zikaron servers before any Zikaron tool loads."
427
+ )
428
+ merged[_PERMISSIONS_KEY], allow_notes = _merged_permissions(
429
+ document, trust_tools=plan.trust_tools, path=path
430
+ )
431
+ notes.extend(allow_notes)
432
+ return MergePlan(path=path, document=merged, notes=tuple(notes))
433
+
434
+ def _plan_mcp(self, plan: Plan) -> MergePlan:
435
+ """Both servers merged into `.mcp.json`."""
436
+ path = self._mcp_config(plan.project)
437
+ _guard_backup_path_if_present(path)
438
+ document = _load_json_object_or_empty(path)
439
+ servers = claude_mcp_servers_value(plan.commands)
440
+ existing = document.get("mcpServers")
441
+ _refuse_unmergeable_shape(existing, dict, path=path, key="mcpServers")
442
+ _refuse_conflicting(existing, servers, path=path, key="mcpServers", force=plan.force)
443
+ merged = dict(document)
444
+ merged["mcpServers"] = {
445
+ **(existing if isinstance(existing, dict) else {}),
446
+ **servers,
447
+ }
448
+ return MergePlan(path=path, document=merged, notes=())
449
+
450
+ def fragment(self, plan: Plan) -> str:
451
+ # Built with this run's own flags rather than with the defaults, so the preview is a preview
452
+ # of *this* install: showing `enabledMcpjsonServers` beside `--no-trust-tools` would promise
453
+ # something the real run would withhold.
454
+ previewed: dict[str, object] = {"hooks": claude_hooks_value(plan.commands)}
455
+ if plan.trust_tools:
456
+ previewed[_ENABLED_SERVERS_KEY] = sorted(_ZIKARON_SERVERS)
457
+ previewed[_PERMISSIONS_KEY] = _merged_permissions(
458
+ {}, trust_tools=plan.trust_tools, path=self._settings(plan.project)
459
+ )[0]
460
+ settings = json.dumps(previewed, indent=_JSON_INDENT)
461
+ mcp = json.dumps(
462
+ {"mcpServers": claude_mcp_servers_value(plan.commands)}, indent=_JSON_INDENT
463
+ )
464
+ # The three advisory notes are deliberately *not* appended here, unlike the caveats kiro's
465
+ # fragment carries. `notes()` below returns them and every caller extends with it, so a copy
466
+ # here printed each one twice under `--print-only` — the one path that appends the fragment
467
+ # and the notes. Kiro is unaffected because its fragment's caveats are config-shape advice
468
+ # its `notes()` does not repeat, which is the arrangement this now matches.
469
+ return (
470
+ f"Add these to {self._settings(plan.project)}:\n\n{settings}\n\n"
471
+ f"and these to {self._mcp_config(plan.project)}:\n\n{mcp}"
472
+ )
473
+
474
+ def refuse_unknown_model(self, model: str) -> None:
475
+ """Refuse a model id that cannot be written into YAML frontmatter as given.
476
+
477
+ **There is no *availability* check here, and that is measured rather than assumed.**
478
+
479
+ Claude Code refuses an unrecognised model id at spawn, loudly, before any turn executes
480
+ (`research/claude-code-harness-probe.md` §7d) — the opposite of kiro's silent substitution,
481
+ which is the *sole* reason the `--list-models` membership check exists at all. The harness
482
+ enforces no-silent-fallback itself, so a check here would add a dependency on a command
483
+ Claude Code does not offer in order to re-derive a guarantee it already gives.
484
+
485
+ **What is checked is the id's *shape*.** This value is interpolated into YAML frontmatter by
486
+ string formatting, so `--model "a: b"` changes what the document *means* and a value with an
487
+ embedded newline injects a frontmatter key — both of which parse as valid YAML, so nothing
488
+ downstream complains. The harness eventually refuses the mangled model at spawn, by which
489
+ time the artefact has been claiming things the user never asked for. Every real id and alias
490
+ satisfies the pattern.
491
+
492
+ Raises:
493
+ InstallError: the id is not a plain single-line token.
494
+ """
495
+ if _MODEL_ID.fullmatch(model) is not None:
496
+ return
497
+ raise InstallError(
498
+ f"{model!r} cannot be written into YAML frontmatter as given. The consolidator's "
499
+ "`model:` must be a plain id or alias — letters, digits, dots, hyphens, underscores — "
500
+ "because a value carrying a colon or a newline would silently change what that file "
501
+ "means rather than fail."
502
+ )
503
+
504
+ def notes(self, plan: Plan) -> list[str]:
505
+ del plan
506
+ return [_APPROVAL_NOTE, _SPILL_READ_NOTE, _EXPOSURE_NOTE]
507
+
508
+
509
+ #: Both `.mcp.json` keys this install writes. Derived from the two names `entries.py` already owns
510
+ #: rather than re-listed, so a rename moves the pre-approval with the registration.
511
+ _ZIKARON_SERVERS: Final = frozenset({MCP_SERVER_NAME, CONSOLIDATOR_AGENT_NAME})
512
+
513
+ TARGETS: Final[dict[Harness, HarnessTarget]] = {
514
+ Harness.KIRO: KiroTarget(),
515
+ Harness.CLAUDE_CODE: ClaudeCodeTarget(),
516
+ }
517
+
518
+
519
+ def target_for(harness: Harness) -> HarnessTarget:
520
+ """The installer adapter for `harness`."""
521
+ return TARGETS[harness]
522
+
523
+
524
+ #: Shared by both conflict refusals below. They guard different shapes — a list of hook *groups*
525
+ #: matched by command, and a server *map* matched by key — which is why there are two of them; the
526
+ #: *explanation* is identical, so it is written once rather than kept as two hand-maintained copies.
527
+ _ANOTHER_INSTALL: Final = (
528
+ "That usually means another Zikaron install owns them, whose paths may point at a venv this "
529
+ "one knows nothing about. Re-run with --force to replace them."
530
+ )
531
+
532
+
533
+ def _guard_backup_path_if_present(path: Path) -> None:
534
+ """Run the backup-path check **at plan time**, when the target exists.
535
+
536
+ `plan_kiro_merge`'s docstring gives the reason: the backup is the
537
+ last thing a merge does and the shipped files are written before it, so leaving the check where
538
+ it happens means a blocked `<file>.bak` is discovered only after the artefacts have landed — the
539
+ half-install the plan/commit split exists to prevent. Omitting it here has a concrete symptom:
540
+ a **directory** at `.mcp.json.bak` produces two written files, one merged file, and *then*
541
+ "install refused". The same condition is re-tested at commit; a check and a use are
542
+ two moments and this one is cheap.
543
+
544
+ Raises:
545
+ InstallError: something is at `<path>.bak` and it is not a regular file.
546
+ """
547
+ if path.exists() or path.is_symlink():
548
+ guard_backup_path(path)
549
+
550
+
551
+ def _refuse_unmergeable_shape(value: object, expected: type, *, path: Path, key: str) -> None:
552
+ """Refuse a key we merge into whose value is not the shape it must be.
553
+
554
+ **Not defensive filtering, deliberately.** Treating a wrong-shaped value as absent and writing
555
+ ours over it is data loss wearing a reassuring shape — `writer.py`'s
556
+ `_guard_mergeable_shapes` makes the same argument for kiro, and its tests are named
557
+ `TestShapesThatWouldBeSilentlyDropped`. Whatever the user put there, this code cannot know what
558
+ they meant by it, and a settings file is a thing people hand-edit.
559
+
560
+ Absence is fine and is not this function's business: a key that is not there is exactly what a
561
+ fresh project has.
562
+
563
+ Raises:
564
+ InstallError: the key is present and is the wrong kind of thing.
565
+ """
566
+ if value is None or isinstance(value, expected):
567
+ return
568
+ raise InstallError(
569
+ f"{path} has {key} as {type(value).__name__}, which this cannot merge into — it must be "
570
+ f"{'an object' if expected is dict else 'an array'}. Whatever is there was put there "
571
+ "deliberately, so it is refused rather than replaced. Move it aside and re-run."
572
+ )
573
+
574
+
575
+ def _merged_permissions(
576
+ document: dict[str, object], *, trust_tools: bool, path: Path
577
+ ) -> tuple[dict[str, object], list[str]]:
578
+ """`permissions` with Zikaron's tools pre-approved, and whatever else was there left alone.
579
+
580
+ **The consolidator's server is allowed unconditionally**, and that is the same argument
581
+ `architecture.md` §"The install contract" already makes about kiro's `allowedTools`: a subagent
582
+ has no user to answer a permission prompt, so a tool that is available but not allowed is a tool
583
+ that hangs or fails at the moment the consolidator needs it. `--no-trust-tools` is a statement
584
+ about *your* writes, not about whether consolidation can run at all.
585
+
586
+ The primary server follows `--no-trust-tools`, which reproduces kiro's asymmetry exactly.
587
+
588
+ **Server-level wildcards rather than one entry per tool name**, matching the consolidator's
589
+ frontmatter grant for the same reason: there is no second list to drift from
590
+ `mcp/tool_names.py`. No count is written here — a wildcard exists precisely so nobody
591
+ maintains one.
592
+
593
+ That these entries actually remove the per-call prompt is **measured**, interactively
594
+ (`research/claude-code-dogfood-checkpoint.md` §1; `design/harness.md` §"Three approval gates"):
595
+ no per-call prompt across six writes and eight reads. **The install still states the approval
596
+ step in its output anyway**, and the reason survives the measurement rather than being retired
597
+ by it — no install-time or headless check can verify the effect, because a headless run
598
+ approves everything, so nothing in the shipped software can notice the day a key stops
599
+ working. `enabledMcpjsonServers` ships on the same standing, measured in the same session.
600
+
601
+ Raises:
602
+ InstallError: `permissions` or `permissions.allow` is present and is the wrong shape.
603
+ """
604
+ existing = document.get(_PERMISSIONS_KEY)
605
+ _refuse_unmergeable_shape(existing, dict, path=path, key=_PERMISSIONS_KEY)
606
+ permissions: dict[str, object] = dict(existing) if isinstance(existing, dict) else {}
607
+ allowed = permissions.get(_ALLOW_KEY)
608
+ _refuse_unmergeable_shape(allowed, list, path=path, key=f"{_PERMISSIONS_KEY}.{_ALLOW_KEY}")
609
+ listed: list[object] = list(allowed) if isinstance(allowed, list) else []
610
+
611
+ wanted = {f"mcp__{CONSOLIDATOR_AGENT_NAME}"}
612
+ if trust_tools:
613
+ wanted.add(f"mcp__{MCP_SERVER_NAME}")
614
+ present = _strings_in(allowed)
615
+ permissions[_ALLOW_KEY] = [*listed, *sorted(wanted - present)]
616
+
617
+ notes: list[str] = []
618
+ if not trust_tools and f"mcp__{MCP_SERVER_NAME}" not in present:
619
+ # Absence-conditioned for the same reason as the note above: nothing here removes a grant a
620
+ # previous default install added, so saying it was withheld would be false on a re-run.
621
+ notes.append(
622
+ f"`mcp__{MCP_SERVER_NAME}` was **not** added to `permissions.allow` "
623
+ "(--no-trust-tools), so every Zikaron tool call asks your approval. The consolidator's "
624
+ "own server is allowed regardless: a subagent has nobody to answer a prompt, so an "
625
+ "unapproved tool there fails at the moment consolidation needs it."
626
+ )
627
+ return permissions, notes
628
+
629
+
630
+ def _strings_in(value: object) -> set[str]:
631
+ """Every string in `value`, if it is a list at all. Typed against `object` because both callers
632
+ are reading a document the user wrote."""
633
+ if not isinstance(value, list):
634
+ return set()
635
+ return {entry for entry in value if isinstance(entry, str)}
636
+
637
+
638
+ def _with_servers(existing: object) -> list[object]:
639
+ """`existing` plus both Zikaron servers, order-stably and without duplicates.
640
+
641
+ Order-stable because this rewrites a list the user may have curated, and reordering it in a
642
+ diff for no reason is a change they have to read.
643
+ """
644
+ listed: list[object] = list(existing) if isinstance(existing, list) else []
645
+ return [*listed, *sorted(_ZIKARON_SERVERS - _strings_in(existing))]
646
+
647
+
648
+ def _load_json_object_or_empty(path: Path) -> dict[str, object]:
649
+ """The JSON object at `path`, or an empty one if nothing is there.
650
+
651
+ Absence is **normal** here and is the one difference from kiro's `_load_agent_config`, which
652
+ refuses it: kiro's merge target is a file the user named, so its absence is a mistake worth
653
+ reporting, while these two are fixed project paths that a fresh project legitimately lacks.
654
+ Anything *present* and unreadable is still refused — an empty dict for a malformed file would
655
+ silently discard whatever the user had.
656
+
657
+ Raises:
658
+ InstallError: the file exists and is not readable, or is not a JSON object.
659
+ """
660
+ if not (path.exists() or path.is_symlink()):
661
+ return {}
662
+ try:
663
+ raw = path.read_text(encoding="utf-8")
664
+ except OSError as exc:
665
+ raise InstallError(f"could not read {path}: {exc}") from exc
666
+ if not raw.strip():
667
+ # An empty file is how an editor leaves a config someone started and abandoned. Treating it
668
+ # as `{}` is both what the harness does and the only reading that lets an install proceed.
669
+ return {}
670
+ try:
671
+ document = json.loads(raw)
672
+ except json.JSONDecodeError as exc:
673
+ raise InstallError(f"{path} is not valid JSON: {exc}") from exc
674
+ if not isinstance(document, dict):
675
+ raise InstallError(f"{path} is not a JSON object, so there is nothing to merge into")
676
+ return document
677
+
678
+
679
+ def _commands_in(group: object) -> list[str]:
680
+ """Every `command` string inside one settings hook group, defensively.
681
+
682
+ Shared by the recogniser and the diagnostic so the two cannot disagree about what an entry *is*
683
+ — and typed against `object` throughout because one side of every comparison is a document the
684
+ user wrote.
685
+ """
686
+ if not isinstance(group, dict):
687
+ return []
688
+ entries = group.get("hooks")
689
+ if not isinstance(entries, list):
690
+ return []
691
+ return [
692
+ str(entry["command"])
693
+ for entry in entries
694
+ if isinstance(entry, dict) and isinstance(entry.get("command"), str)
695
+ ]
696
+
697
+
698
+ def _is_a_zikaron_hook_group(group: object, commands: Commands) -> bool:
699
+ """Whether a settings hook group is one Zikaron installed.
700
+
701
+ Matched on the command's **file name**, not its full path, and that is deliberate: a group
702
+ naming `/somewhere-else/venv/bin/zikaron-hook` belongs to a *different* Zikaron install, and
703
+ recognising it is exactly what lets this install replace it rather than leave two hooks firing
704
+ into the same store from two interpreters.
705
+ """
706
+ return any(Path(command).name == commands.hook.name for command in _commands_in(group))
707
+
708
+
709
+ def _refuse_differing_hook_groups(
710
+ by_trigger: dict[str, list[object]],
711
+ ours: dict[str, list[dict[str, object]]],
712
+ *,
713
+ plan: Plan,
714
+ path: Path,
715
+ ) -> None:
716
+ """Refuse when an existing **Zikaron** hook group is not exactly what this install would write.
717
+
718
+ The contract's word is *differs*, and only our own groups are examined — a user's unrelated
719
+ hook on the same trigger is theirs and is never compared, only preserved. A Zikaron group that
720
+ differs means another install owns it, whose absolute path may point at a venv that no longer
721
+ has Zikaron in it; replacing it silently would hide that the user has two installs, and the
722
+ refusal names the trigger so they can tell their own edit from a version change.
723
+
724
+ Raises:
725
+ InstallError: a Zikaron group differs and `--force` was not passed.
726
+ """
727
+ if plan.force:
728
+ return
729
+ differing = sorted(
730
+ f"{trigger}: {_describe_group_difference(group, groups)}"
731
+ for trigger, groups in ours.items()
732
+ for group in by_trigger.get(trigger, [])
733
+ if _is_a_zikaron_hook_group(group, plan.commands) and group not in groups
734
+ )
735
+ if not differing:
736
+ return
737
+ raise InstallError(
738
+ f"{path} already carries Zikaron hook entries that differ from what this install would "
739
+ f"write ({'; '.join(differing)}). {_ANOTHER_INSTALL}"
740
+ )
741
+
742
+
743
+ def _describe_group_difference(group: object, expected: list[dict[str, object]]) -> str:
744
+ """What is different about this group, not merely that something is.
745
+
746
+ Kiro's `_describe_difference` names the offending fields and its docstring gives the reason:
747
+ naming only the location leaves a user unable to tell **their own edit** from a Zikaron version
748
+ change, and therefore unable to decide whether `--force` is the right answer. The settings
749
+ refusal named only the trigger, which is the asymmetry this wording removes.
750
+ """
751
+ commanded = _commands_in(group)
752
+ wanted = [command for one in expected for command in _commands_in(one)]
753
+ if commanded != wanted:
754
+ return f"command is {commanded or 'absent'}, this install writes {wanted}"
755
+ return "same command, but the entry differs (timeout, matcher, or an extra field)"
756
+
757
+
758
+ def _refuse_conflicting(
759
+ existing: object,
760
+ ours: dict[str, object],
761
+ *,
762
+ path: Path,
763
+ key: str,
764
+ force: bool,
765
+ ) -> None:
766
+ """Refuse when a key we own is already present with different content.
767
+
768
+ The same rule kiro's merge applies to an `mcpServers` entry, for the same reason: a Zikaron
769
+ entry that differs from what this install would write means **another install owns it**, whose
770
+ absolute paths may point at a venv that no longer has Zikaron in it. Overwriting silently would
771
+ hide that the user has two installs; the refusal names the entry and the flag that overrides it.
772
+
773
+ Only the entries this install writes are compared. Anything else under the same key is the
774
+ user's and is merged around, never inspected.
775
+
776
+ Raises:
777
+ InstallError: a Zikaron-owned entry differs and `--force` was not passed.
778
+ """
779
+ if force or not isinstance(existing, dict):
780
+ return
781
+ differing = sorted(
782
+ name for name, value in ours.items() if name in existing and existing[name] != value
783
+ )
784
+ if not differing:
785
+ return
786
+ raise InstallError(
787
+ f"{path} already carries {key} entries that differ from what this install would write "
788
+ f"({', '.join(differing)}). {_ANOTHER_INSTALL}"
789
+ )