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,973 @@
1
+ """Putting the shipped artefacts on disk, including the one operation that touches a file the user
2
+ owns.
3
+
4
+ `architecture.md` §"The install contract" is normative. Three rules run through everything here:
5
+
6
+ - **Everything is checked before anything is written.** A `plan_*` function parses and guards the
7
+ file it would touch and produces the document it *would* write, touching nothing; `commit_merge`
8
+ writes it. An earlier arrangement wrote the shipped files first and discovered a malformed config
9
+ afterwards, which left a project half-installed — the exact outcome the contract's own ordering
10
+ exists to prevent.
11
+ - **Refuse rather than overwrite; keep rather than clobber.** A shipped file whose bytes are already
12
+ what this install ships is kept and reported; one that differs is backed up and refreshed, which
13
+ is what makes a re-run after an *upgrade* correct rather than merely safe. A Zikaron entry in a
14
+ file the user owns that differs from what this install would write is a *refusal*, because that
15
+ means another install owns it.
16
+ - **A merge is backed up first, and the first backup wins.** `<config>.bak` is written only when
17
+ nothing is there — not even a dangling symlink — because overwriting it on every run would replace
18
+ the pristine original with the copy the first install had already modified.
19
+ """
20
+
21
+ import errno
22
+ import json
23
+ import os
24
+ import shlex
25
+ import shutil
26
+ import tempfile
27
+ from collections.abc import Iterator
28
+ from contextlib import contextmanager
29
+ from dataclasses import dataclass, field
30
+ from fnmatch import fnmatch
31
+ from pathlib import Path
32
+ from typing import Final, NamedTuple
33
+
34
+ from zikaron.install.assets import SKILL_NAME
35
+ from zikaron.install.entries import (
36
+ CONSOLIDATOR_AGENT_NAME,
37
+ MCP_SERVER_NAME,
38
+ TOOL_SELECTOR,
39
+ Commands,
40
+ HookFormat,
41
+ hooks_array,
42
+ hooks_object,
43
+ mcp_servers_value,
44
+ )
45
+ from zikaron.install.harness import InstallError
46
+
47
+ _JSON_INDENT = 2
48
+
49
+ #: How a skill is named in `resources`. Skills are progressively loaded, unlike `file://` entries.
50
+ _SKILL_SCHEME: Final = "skill://"
51
+
52
+ #: Said in two places — after a merge and beside a printed fragment — because it is the one way an
53
+ #: array-format install differs from an object-format one in what it can promise.
54
+ ARRAY_FORMAT_OUTPUT_CAP_NOTE: Final = (
55
+ "The array hook format documents no `max_output_size` field, so those entries inherit the "
56
+ "harness default of 10240 bytes rather than the 65536 an object-format entry states."
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Targets:
62
+ """Where **kiro's** shipped artefacts go for one project directory.
63
+
64
+ Kiro-specific, and named so rather than generalised: Claude Code's four artefacts live at
65
+ different paths under a different dotdir and are computed by `ClaudeCodeTarget`. A single
66
+ `Targets` covering both would be a union type whose fields are half-null on either harness,
67
+ which is the shape `coding-standards.md` §2 asks us not to build.
68
+ """
69
+
70
+ project: Path
71
+
72
+ @property
73
+ def agents_dir(self) -> Path:
74
+ return self.project / ".kiro" / "agents"
75
+
76
+ @property
77
+ def consolidator_config(self) -> Path:
78
+ return self.agents_dir / f"{CONSOLIDATOR_AGENT_NAME}.json"
79
+
80
+ @property
81
+ def skill_file(self) -> Path:
82
+ return self.project / ".kiro" / "skills" / SKILL_NAME / "SKILL.md"
83
+
84
+
85
+ class ShippedFile(NamedTuple):
86
+ """One artefact this install owns outright: its path and the exact bytes that belong there.
87
+
88
+ The unit both harnesses' writers agree on. Kiro ships two, Claude Code ships two — and the
89
+ *merged* files are not among them on either harness, because a merge target belongs to the user
90
+ and a shipped file does not.
91
+ """
92
+
93
+ path: Path
94
+ content: str
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class Plan:
99
+ """One install, as parsed from the command line: where, with what, and how forcefully.
100
+
101
+ A value rather than six parameters threaded through every function here. The grouping is not an
102
+ invention to satisfy an argument-count rule: these are exactly what one invocation decided, they
103
+ travel together to every writer below, and bundling them means a new option is added in one
104
+ place instead of in four signatures.
105
+
106
+ `hook_format` is kiro's and is ignored by the Claude Code target, which has one hook shape. It
107
+ stays on the shared value rather than being pushed into a kiro-only bag because the alternative
108
+ — a per-harness options type — would make `main.py` build its arguments differently depending on
109
+ a flag it has not resolved yet.
110
+ """
111
+
112
+ project: Path
113
+ commands: Commands
114
+ model: str
115
+ hook_format: HookFormat
116
+ force: bool
117
+ trust_tools: bool
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class Report:
122
+ """What an install did, for `main.py` to print and for a test to assert on."""
123
+
124
+ created: list[Path] = field(default_factory=list)
125
+ replaced: list[Path] = field(default_factory=list)
126
+ skipped: list[Path] = field(default_factory=list)
127
+ #: The subset of `skipped` left alone because its backup failed, not because it was already
128
+ #: correct. Recorded rather than re-derived: the other two reasons are still readable from the
129
+ #: file, this one is not.
130
+ kept_unbacked: list[Path] = field(default_factory=list)
131
+ merged: list[Path] = field(default_factory=list)
132
+ backed_up: list[Path] = field(default_factory=list)
133
+ notes: list[str] = field(default_factory=list)
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class MergePlan:
138
+ """A merge that has been checked and not yet performed.
139
+
140
+ `document` is the finished JSON to write. Holding it as a value is what lets the caller order
141
+ the whole install "check everything, then write everything" — the checks live in the `plan_*`
142
+ functions, which read and refuse, and every write is in `commit_merge`.
143
+ """
144
+
145
+ path: Path
146
+ document: dict[str, object]
147
+ notes: tuple[str, ...]
148
+
149
+
150
+ def guard_shipped_targets(files: tuple[ShippedFile, ...], project: Path) -> None:
151
+ """Refuse any shipped path whose existing shape would make writing it fail or lie. Writes
152
+ nothing.
153
+
154
+ Two failures this closes, both reachable on an ordinary project and neither previously checked:
155
+
156
+ - A **directory** at `SKILL.md` was treated as an ordinary collision and reported as "kept", so
157
+ the install exited 0 while no loadable skill existed at all.
158
+ - A **regular file** where `.kiro/skills` or the skill's own directory belongs made the later
159
+ `mkdir(parents=True)` raise `NotADirectoryError` — *after* the consolidator config had been
160
+ written. A traceback and a half-install, from a path shape rather than a crash.
161
+
162
+ Raises:
163
+ InstallError: a final target exists and is not a regular file, or an ancestor that must be a
164
+ directory is not one.
165
+ """
166
+ for shipped in files:
167
+ target = shipped.path
168
+ if _occupied(target) and not target.is_file():
169
+ raise InstallError(
170
+ f"{target} is not a regular file, so it cannot be installed. Move it aside and "
171
+ "re-run."
172
+ )
173
+ for ancestor in _ancestors_within(target, project):
174
+ if _occupied(ancestor) and not ancestor.is_dir():
175
+ raise InstallError(
176
+ f"{ancestor} is not a directory, so {target.name} cannot be installed under "
177
+ "it. Move it aside and re-run."
178
+ )
179
+
180
+
181
+ def _occupied(path: Path) -> bool:
182
+ """Whether something is at `path` — a **dangling symlink included**.
183
+
184
+ `Path.exists()` alone is false for a dangling symlink, which is how a dangling `SKILL.md` link
185
+ previously passed the preflight, was reported as "kept", and let the install exit 0 with no
186
+ loadable skill anywhere. The pair of checks below then reads "occupied, and not the kind it must
187
+ be", which is true of a link to the wrong kind of thing as well as of a wrong thing.
188
+ """
189
+ return path.exists() or path.is_symlink()
190
+
191
+
192
+ def _ancestors_within(target: Path, project: Path) -> list[Path]:
193
+ """`target`'s parent directories, outermost first, stopping at `project`.
194
+
195
+ Bounded at the project, because everything above it belongs to the user's filesystem rather than
196
+ to this install: a check that walked to `/` would refuse on any unusual mount above a perfectly
197
+ ordinary project.
198
+ """
199
+ ancestors: list[Path] = []
200
+ current = target.parent
201
+ while current not in (project, current.parent):
202
+ ancestors.append(current)
203
+ current = current.parent
204
+ return list(reversed(ancestors))
205
+
206
+
207
+ def write_shipped_files(files: tuple[ShippedFile, ...], plan: Plan, report: Report) -> None:
208
+ """Write every shipped artefact, recording each outcome in `report`."""
209
+ for shipped in files:
210
+ _write_shipped(shipped, plan=plan, report=report)
211
+
212
+
213
+ def _write_shipped(shipped: ShippedFile, *, plan: Plan, report: Report) -> None:
214
+ """Create the file, refresh it if what is there is not what we ship, or keep and report it.
215
+
216
+ **Staleness is a content comparison**, and that is a fix rather than a simplification. The
217
+ predicate used to ask one narrow question — does this config name a *different install's*
218
+ interpreter — which caught a cloned repository and missed the case it was most likely to meet:
219
+ *same install, older version*. Upgrading Zikaron and re-running the installer left the previous
220
+ version's consolidator prompt in place, reported cheerfully as "already there", unless someone
221
+ thought to pass `--force`. A prompt that no longer matches the tools it describes is exactly the
222
+ confidently-stale artefact this project exists to prevent, and it was being produced by the
223
+ installer's own success path.
224
+
225
+ Comparing the bytes subsumes the old question — a different interpreter yields different content
226
+ — and extends it to every shipped file rather than only the JSON one, which is why kiro's skill
227
+ could previously never be refreshed at all (its staleness predicate was the constant `False`).
228
+ It also catches a hand-edited artefact, which is the one behaviour change: such a file is now
229
+ backed up and rewritten rather than silently kept. That is the right trade for a file the
230
+ installer owns, and it is safe because nothing is replaced without `<name>.bak` existing first.
231
+
232
+ A symlink at the target is treated as an existing file rather than followed, so neither the
233
+ stale path nor `--force` can be talked into writing through a link into somewhere else entirely.
234
+ """
235
+ path, content = shipped
236
+ exists = path.exists() or path.is_symlink()
237
+ if exists and plan.force and not path.is_symlink() and _read_or_empty(path) != content:
238
+ # **`--force` backs up too.** The content-comparison trade rests on "nothing is replaced
239
+ # without `<name>.bak` existing first", and this path made that false exactly where it
240
+ # matters most: `--force` is the flag the merge-conflict refusals *instruct* people to pass,
241
+ # so it arrives alongside an unrelated conflict rather than only when someone means
242
+ # "discard my edits". Best-effort:
243
+ # a backup that cannot be written must not block an override the user asked for
244
+ # explicitly, so it degrades to a note rather than a refusal.
245
+ try:
246
+ _back_up_once(path, report=report)
247
+ except InstallError as exc:
248
+ report.notes.append(
249
+ f"{path.name} could not be backed up before --force replaced it ({exc})."
250
+ )
251
+ if exists and not plan.force:
252
+ if path.is_symlink() or _read_or_empty(path) == content:
253
+ report.skipped.append(path)
254
+ return
255
+ try:
256
+ _back_up_once(path, report=report)
257
+ except InstallError as exc:
258
+ # A stale artefact is worth correcting, but not at the cost of losing what is there with
259
+ # no copy of it. Keeping a broken consolidator config is recoverable; replacing it
260
+ # unbacked is not, so this reports and leaves it.
261
+ report.skipped.append(path)
262
+ report.kept_unbacked.append(path)
263
+ report.notes.append(
264
+ f"{path.name} differs from what this install ships but could not be backed up "
265
+ f"({exc}), so it was left alone. Move it aside and re-run to get a correct one."
266
+ )
267
+ return
268
+ _replace(path, content)
269
+ report.replaced.append(path)
270
+ report.notes.append(
271
+ f"{path.name} differed from what this install ships and was rewritten — an older "
272
+ f"Zikaron, another install, or a local edit. The original is at {path.name}.bak."
273
+ )
274
+ return
275
+ if path.is_symlink():
276
+ path.unlink()
277
+ _replace(path, content)
278
+ (report.replaced if exists else report.created).append(path)
279
+
280
+
281
+ def _read_or_empty(path: Path) -> str:
282
+ try:
283
+ return path.read_text(encoding="utf-8")
284
+ except OSError:
285
+ return ""
286
+
287
+
288
+ def _replace(path: Path, content: str) -> None:
289
+ """Write `content` to `path` atomically, creating parents as needed.
290
+
291
+ Through a temporary file in the same directory and one rename, because for the agent config this
292
+ is a file the user owns: a crash or a full disk part-way through a plain write leaves them with
293
+ a truncated config the harness then refuses to load, and the `.bak` beside it is a worse remedy
294
+ than never having broken it.
295
+
296
+ The scratch file is created **exclusively, under a name nothing can predict**. A fixed
297
+ `<target>.tmp` would be *followed* if it happened to be a symlink — the same hole the check on
298
+ the final target closes one component along, and a destination check that leaves its own scratch
299
+ file
300
+ open to redirection has closed nothing.
301
+
302
+ An existing file's permission bits are carried over, because a rename replaces the inode and its
303
+ mode with it: a config that was `0644` must not come back `0600` as a side effect of how it was
304
+ written.
305
+ """
306
+ path.parent.mkdir(parents=True, exist_ok=True)
307
+ mode = path.stat().st_mode & 0o777 if path.is_file() and not path.is_symlink() else None
308
+ with _exclusive_temporary(path) as scratch:
309
+ _write_all(scratch.descriptor, content.encode("utf-8"))
310
+ if mode is not None:
311
+ os.fchmod(scratch.descriptor, mode)
312
+ scratch.path.replace(path)
313
+
314
+
315
+ def _write_all(descriptor: int, payload: bytes) -> None:
316
+ """Write every byte of `payload` to `descriptor`, then flush it to the device.
317
+
318
+ A single `os.write` is **not** enough, and that is the point of this function existing: a write
319
+ to a regular file may legally return short — the documented case being a partial write when the
320
+ space for the rest is not there — and ignoring the return value would let a truncated file be
321
+ renamed over a user's config while the install reported success. That is the exact failure the
322
+ atomic rewrite was introduced to close, reintroduced one layer down.
323
+
324
+ `fsync` before the caller publishes, because the rename is atomic against other *processes* and
325
+ says nothing about a crash: without it, a machine that loses power moments later can come back
326
+ with the rename applied and the contents not.
327
+
328
+ Raises:
329
+ OSError: any part of the write or the flush failed. Nothing is published, because the caller
330
+ publishes after this returns.
331
+ """
332
+ written = 0
333
+ while written < len(payload):
334
+ just_written = os.write(descriptor, payload[written:])
335
+ if just_written == 0:
336
+ raise OSError(errno.ENOSPC, "wrote no bytes and none were rejected", None)
337
+ written += just_written
338
+ os.fsync(descriptor)
339
+
340
+
341
+ class _Scratch(NamedTuple):
342
+ """An open descriptor and the path it was created at, held together.
343
+
344
+ Both, because the writing goes through the **descriptor** while the publication is by path: the
345
+ descriptor is what makes the write immune to the name being swapped underneath it, and the path
346
+ is
347
+ what `replace`/`link` needs.
348
+ """
349
+
350
+ descriptor: int
351
+ path: Path
352
+
353
+
354
+ @contextmanager
355
+ def _exclusive_temporary(destination: Path) -> Iterator[_Scratch]:
356
+ """A freshly created scratch file beside `destination`, held open, removed on any failure.
357
+
358
+ Beside it rather than under `/tmp`, so the final rename or link stays on one filesystem —
359
+ neither is atomic across devices, and `os.replace` raises. Created through `mkstemp`, which
360
+ opens with `O_CREAT | O_EXCL` under an unpredictable name, so no pre-existing name can
361
+ redirect it.
362
+
363
+ **The descriptor stays open until the publication.** Closing it and reopening the path by name
364
+ would reintroduce exactly the hole the exclusive creation closes: between the close and the
365
+ reopen, anything able to write in that directory could unlink the name and leave a symlink in
366
+ its place, and the reopen would follow it. Writing through the descriptor means the bytes go
367
+ to the inode this function created, whatever happens to the name.
368
+ """
369
+ descriptor, raw = tempfile.mkstemp(
370
+ dir=destination.parent, prefix=f".{destination.name}.", suffix=".tmp"
371
+ )
372
+ scratch = _Scratch(descriptor=descriptor, path=Path(raw))
373
+ try:
374
+ yield scratch
375
+ finally:
376
+ os.close(descriptor)
377
+ scratch.path.unlink(missing_ok=True)
378
+
379
+
380
+ def plan_kiro_merge(path: Path, plan: Plan, targets: Targets) -> MergePlan:
381
+ """Check a user's agent config and build the document a merge would write. Writes nothing.
382
+
383
+ The file's own format decides how hooks are written — object stays object, array stays array —
384
+ because the harness rewrites a config in whichever format it read, and a file carrying both
385
+ shapes has no defined meaning. `plan.hook_format` applies only when the file has no `hooks`
386
+ key at all.
387
+
388
+ Raises:
389
+ InstallError: the file is missing, not a JSON object, carries `hooks`/`mcpServers` in a
390
+ shape this cannot merge into, or already carries a Zikaron entry that differs from what
391
+ this install would write. The last is the one worth refusing loudly: it means a
392
+ previous install from a *different* interpreter, whose paths may point at a venv that
393
+ no longer has Zikaron in it. Replacing is almost always right, and doing it silently
394
+ would hide that the user has two installs.
395
+ """
396
+ document = _load_agent_config(path)
397
+ hook_format = _detect_format(document, path=path, requested=plan.hook_format)
398
+ _guard_mergeable_shapes(document, path=path)
399
+ _guard_existing_entries(document, plan.commands, path=path, force=plan.force)
400
+ guard_backup_path(path)
401
+
402
+ merged = dict(document)
403
+ merged["hooks"] = _merged_hooks(document, plan.commands, hook_format)
404
+ merged["mcpServers"] = _merged_servers(document, plan.commands)
405
+ merged["tools"] = _selecting(document, "tools")
406
+ if plan.trust_tools:
407
+ merged["allowedTools"] = _selecting(document, "allowedTools")
408
+ settings, crew_notes = _merged_tools_settings(document)
409
+ if settings is not None:
410
+ merged["toolsSettings"] = settings
411
+ resources, resource_notes = _merged_resources(document, targets)
412
+ if resources is not None:
413
+ merged["resources"] = resources
414
+
415
+ notes = [ARRAY_FORMAT_OUTPUT_CAP_NOTE] if hook_format is HookFormat.ARRAY else []
416
+ notes.extend(crew_notes)
417
+ notes.extend(resource_notes)
418
+ if TOOL_SELECTOR not in _string_list(document.get("tools")):
419
+ notes.append(
420
+ f"Added `{TOOL_SELECTOR}` to {path.name}'s `tools` — without it Zikaron's tools are "
421
+ "simply absent, since `mcpServers` configures the server and `tools` selects from it."
422
+ )
423
+ if not plan.trust_tools and TOOL_SELECTOR not in _string_list(document.get("allowedTools")):
424
+ notes.append(
425
+ f"`{TOOL_SELECTOR}` is **not** in {path.name}'s `allowedTools` (--no-trust-tools), so "
426
+ "every Zikaron tool call asks your permission. Remove that flag, or add it by hand, to "
427
+ "let the agent record without interrupting you."
428
+ )
429
+ if "subagent" not in _string_list(document.get("tools")):
430
+ notes.append(
431
+ f"{path.name} does not list the `subagent` tool. The zikaron-consolidate skill spawns "
432
+ "the consolidator through it, so add it to `tools` to be able to consolidate. Granting "
433
+ "a built-in tool is your decision, so this install does not do it for you."
434
+ )
435
+ return MergePlan(path=path, document=merged, notes=tuple(notes))
436
+
437
+
438
+ def commit_merge(merge: MergePlan, report: Report) -> None:
439
+ """Back the config up if nothing is backed up yet, then write the planned document.
440
+
441
+ **A file that does not exist yet is not backed up**, and that is not merely an optimisation: a
442
+ backup is a copy of the user's own prior state, and there is none. Kiro never reaches this
443
+ branch — `--agent` must name an existing file and `main.py` refuses otherwise — but Claude
444
+ Code's two merge targets are fixed project paths that a fresh project simply does not have, and
445
+ attempting a copy there would refuse the whole install over the absence of a file we are about
446
+ to create.
447
+ """
448
+ if merge.path.exists() or merge.path.is_symlink():
449
+ _back_up_once(merge.path, report=report)
450
+ _replace(merge.path, json.dumps(merge.document, indent=_JSON_INDENT) + "\n")
451
+ report.merged.append(merge.path)
452
+ report.notes.extend(merge.notes)
453
+
454
+
455
+ def _load_agent_config(path: Path) -> dict[str, object]:
456
+ try:
457
+ raw = path.read_text(encoding="utf-8")
458
+ except OSError as exc:
459
+ raise InstallError(f"could not read {path}: {exc}") from exc
460
+ try:
461
+ document = json.loads(raw)
462
+ except json.JSONDecodeError as exc:
463
+ raise InstallError(f"{path} is not valid JSON: {exc}") from exc
464
+ if not isinstance(document, dict):
465
+ raise InstallError(f"{path} is not a JSON object")
466
+ return document
467
+
468
+
469
+ def _detect_format(document: dict[str, object], *, path: Path, requested: HookFormat) -> HookFormat:
470
+ """Which format this file's hooks are in, refusing a shape that is neither.
471
+
472
+ A present-but-unmergeable `hooks` value is refused rather than replaced: it is data the user put
473
+ there, this code cannot tell what they meant by it, and silently dropping it to install our own
474
+ two entries would destroy something to make room for something.
475
+ """
476
+ hooks = document.get("hooks")
477
+ if isinstance(hooks, dict):
478
+ return HookFormat.OBJECT
479
+ if isinstance(hooks, list):
480
+ return HookFormat.ARRAY
481
+ if "hooks" in document:
482
+ raise InstallError(
483
+ f"{path} has a `hooks` value that is neither an object nor an array "
484
+ f"({type(hooks).__name__}). Fix or remove it before installing; this refuses rather "
485
+ "than replace it."
486
+ )
487
+ return requested
488
+
489
+
490
+ def _guard_mergeable_shapes(document: dict[str, object], *, path: Path) -> None:
491
+ """Refuse any `hooks`/`tools` shape the merge below would silently drop.
492
+
493
+ The merge is written defensively — it filters a hook-map value that is not a list, and treats a
494
+ non-list `tools` as absent — and defensive filtering on a *write* path is data loss with a
495
+ reassuring shape. A trigger whose value is an object, or a `tools` value that is a string, is
496
+ something the user put there; this cannot know what they meant by it, and installing over it
497
+ would destroy something to make room for something.
498
+ """
499
+ hooks = document.get("hooks")
500
+ if isinstance(hooks, dict):
501
+ for trigger, value in hooks.items():
502
+ if not isinstance(value, list):
503
+ raise InstallError(
504
+ f"{path} has `hooks.{trigger}` as {type(value).__name__} rather than an array. "
505
+ "Fix or remove it before installing; this refuses rather than drop it."
506
+ )
507
+ for key in ("tools", "allowedTools"):
508
+ value = document.get(key)
509
+ if key in document and not isinstance(value, list):
510
+ raise InstallError(
511
+ f"{path} has a `{key}` value that is not an array ({type(value).__name__}). Fix or "
512
+ "remove it before installing; this refuses rather than replace it."
513
+ )
514
+ if "resources" in document and not isinstance(document.get("resources"), list):
515
+ raise InstallError(
516
+ f"{path} has a `resources` value that is not an array "
517
+ f"({type(document.get('resources')).__name__}). Fix or remove it before installing."
518
+ )
519
+ _guard_crew_shape(document, path=path)
520
+
521
+
522
+ def _guard_crew_shape(document: dict[str, object], *, path: Path) -> None:
523
+ """Refuse a `toolsSettings.crew` this cannot merge into rather than replacing it.
524
+
525
+ Same rule as every other shape guard here, one level deeper: the crew block gates which agents
526
+ may be spawned, so quietly rebuilding a malformed one could hand the user a config that spawns
527
+ more — or less — than they had written down.
528
+ """
529
+ settings = document.get("toolsSettings")
530
+ if "toolsSettings" in document and not isinstance(settings, dict):
531
+ raise InstallError(
532
+ f"{path} has a `toolsSettings` value that is not an object "
533
+ f"({type(settings).__name__}). Fix or remove it before installing."
534
+ )
535
+ if not isinstance(settings, dict):
536
+ return
537
+ present = [key for key in _CREW_KEYS if key in settings]
538
+ if len(present) > 1:
539
+ raise InstallError(
540
+ f"{path} has both `toolsSettings.crew` and `toolsSettings.agent_crew`. The harness "
541
+ "documents them as aliases for one block and gives no meaning to carrying both, so "
542
+ "this refuses rather than guess which one it reads. Keep one and re-run."
543
+ )
544
+ for crew_key in present:
545
+ crew = settings[crew_key]
546
+ if not isinstance(crew, dict):
547
+ raise InstallError(
548
+ f"{path} has a `toolsSettings.{crew_key}` value that is not an object "
549
+ f"({type(crew).__name__}). Fix or remove it before installing."
550
+ )
551
+ for list_key in ("availableAgents", "trustedAgents"):
552
+ if list_key in crew and not isinstance(crew[list_key], list):
553
+ raise InstallError(
554
+ f"{path} has `toolsSettings.{crew_key}.{list_key}` as "
555
+ f"{type(crew[list_key]).__name__} rather than an array. Fix or remove it "
556
+ "before installing."
557
+ )
558
+
559
+
560
+ def _guard_existing_entries(
561
+ document: dict[str, object], commands: Commands, *, path: Path, force: bool
562
+ ) -> None:
563
+ """Refuse a config already wired to a *different* Zikaron install, unless forced.
564
+
565
+ Both halves are checked, and the hook half was the gap: an earlier version guarded only the
566
+ `mcpServers` entry, so a config carrying a stale hook command and no server entry had that
567
+ command silently replaced. The two are installed together and must be refused together.
568
+ """
569
+ servers = document.get("mcpServers")
570
+ if "mcpServers" in document and not isinstance(servers, dict):
571
+ raise InstallError(
572
+ f"{path} has an `mcpServers` value that is not an object "
573
+ f"({type(servers).__name__}). Fix or remove it before installing."
574
+ )
575
+ if force:
576
+ return
577
+ has_server = isinstance(servers, dict) and MCP_SERVER_NAME in servers
578
+ existing_server = servers.get(MCP_SERVER_NAME) if isinstance(servers, dict) else None
579
+ expected_server = mcp_servers_value(commands, mode="primary")[MCP_SERVER_NAME]
580
+ if has_server and existing_server != expected_server:
581
+ raise InstallError(
582
+ f"{path} already has an `mcpServers.{MCP_SERVER_NAME}` entry pointing somewhere else "
583
+ f"({existing_server!r}). That is a previous install from a different interpreter. "
584
+ "Re-run with --force to replace it."
585
+ )
586
+ differing = _differing_zikaron_hooks(document, commands)
587
+ if differing:
588
+ raise InstallError(
589
+ f"{path} already has Zikaron hook entries that differ from what this install would "
590
+ f"write ({'; '.join(differing)}). That is either a previous install from a different "
591
+ "interpreter or an entry you edited. Re-run with --force to adopt this install's "
592
+ "entries."
593
+ )
594
+
595
+
596
+ def _differing_zikaron_hooks(document: dict[str, object], commands: Commands) -> list[str]:
597
+ """Every existing Zikaron hook entry that is not **exactly** what this install would write.
598
+
599
+ The contract's word is *differs*, and equality of one field is not equality of an entry: an
600
+ entry carrying the current command with `timeout_ms: 1`, or the reserved name with a changed
601
+ `trigger`, is something a person chose, and the merge below would replace it. So the comparison
602
+ is structural, against the generated entry for that entry's own trigger.
603
+
604
+ Recognition uses **both** identities the merge uses — an object-format entry by its command's
605
+ file name, an array-format entry by its reserved `name` — because guarding one while replacing
606
+ on either is how an entry comes to be rewritten silently.
607
+
608
+ Returned as descriptions rather than a boolean so the refusal can say *what* differs. That is
609
+ the only way a user can tell their own edit from a Zikaron version change, and therefore
610
+ whether
611
+ `--force` is the right answer.
612
+ """
613
+ hooks = document.get("hooks")
614
+ if isinstance(hooks, dict):
615
+ expected_by_trigger = {
616
+ trigger: entries[0] for trigger, entries in hooks_object(commands).items()
617
+ }
618
+ described = [
619
+ _describe_difference(entry, expected_by_trigger.get(trigger), where=trigger)
620
+ for trigger, entries in hooks.items()
621
+ if isinstance(entries, list)
622
+ for entry in entries
623
+ if _is_our_command(entry, commands)
624
+ ]
625
+ return [description for description in described if description is not None]
626
+ if not isinstance(hooks, list):
627
+ return []
628
+ expected_by_name = {entry["name"]: entry for entry in hooks_array(commands)}
629
+ descriptions: list[str] = []
630
+ for entry in hooks:
631
+ name = entry.get("name") if isinstance(entry, dict) else None
632
+ named = isinstance(name, str) and name in expected_by_name
633
+ if not (named or _is_our_command(entry, commands)):
634
+ continue
635
+ expected = expected_by_name.get(name) if isinstance(name, str) else None
636
+ description = _describe_difference(entry, expected, where=str(name or "an unnamed entry"))
637
+ if description is not None:
638
+ descriptions.append(description)
639
+ return descriptions
640
+
641
+
642
+ def _describe_difference(entry: object, expected: object, *, where: str) -> str | None:
643
+ """`None` if `entry` is exactly `expected`, else a short account of how the two differ."""
644
+ if entry == expected:
645
+ return None
646
+ if expected is None:
647
+ return f"{where}: not an entry this install writes"
648
+ if not isinstance(entry, dict) or not isinstance(expected, dict):
649
+ return f"{where}: {entry!r}"
650
+ differing = [
651
+ key for key in sorted(set(entry) | set(expected)) if entry.get(key) != expected.get(key)
652
+ ]
653
+ return f"{where}: {', '.join(differing)}"
654
+
655
+
656
+ def _entry_command(entry: object) -> str | None:
657
+ """The executable an entry runs, in either format's spelling, **unquoted**.
658
+
659
+ Unquoted because the stored string is shell source: what we write is `shlex.quote`'d, so the raw
660
+ value of a Zikaron entry is `'/path/to/zikaron-hook'` and a caller comparing `Path(...).name`
661
+ against `zikaron-hook` would be comparing against `zikaron-hook'`. A single-token command is
662
+ reported as that token; anything else — a pipeline, several words, unbalanced quotes — is
663
+ reported verbatim, since it is not a bare executable path and no caller should treat it as one.
664
+ """
665
+ if not isinstance(entry, dict):
666
+ return None
667
+ direct = entry.get("command")
668
+ if not isinstance(direct, str):
669
+ action = entry.get("action")
670
+ nested = action.get("command") if isinstance(action, dict) else None
671
+ direct = nested if isinstance(nested, str) else None
672
+ if direct is None:
673
+ return None
674
+ try:
675
+ words = shlex.split(direct)
676
+ except ValueError:
677
+ # Unbalanced quotes: not something to reinterpret, and the caller compares it as-is.
678
+ return direct
679
+ return words[0] if len(words) == 1 else direct
680
+
681
+
682
+ def guard_backup_path(path: Path) -> None:
683
+ """Refuse now if this file could not be backed up later. Creates nothing.
684
+
685
+ Split out of `_back_up_once` and called from `plan_merge` because the backup is the **last**
686
+ thing a merge does and the shipped files are written before it: leaving this check where it
687
+ happens would mean a blocked backup path was discovered only after two files had landed, which
688
+ is the half-install the plan/commit split exists to prevent. The same condition is re-tested
689
+ at commit, because a check and a use are two moments and this one is cheap.
690
+
691
+ Raises:
692
+ InstallError: something is at `<path>.bak` and it is not a regular file.
693
+ """
694
+ backup = path.with_name(path.name + ".bak")
695
+ if backup.is_file() and not backup.is_symlink():
696
+ return
697
+ if backup.exists() or backup.is_symlink():
698
+ raise InstallError(
699
+ f"{backup} exists and is not a regular file, so {path.name} cannot be backed up. "
700
+ "Move it aside and re-run."
701
+ )
702
+
703
+
704
+ def _back_up_once(path: Path, *, report: Report) -> None:
705
+ """Copy `path` to `<path>.bak`, unless something is already there.
706
+
707
+ The check is `is_file() and not is_symlink()`, not `exists()`, and both halves earn their keep.
708
+ `exists()` is **false** for a dangling symlink while `shutil.copy2` happily follows one, which
709
+ would write this config's contents to whatever the link names, outside the project entirely. And
710
+ anything at that path which is *not* a regular file — a directory, a fifo — is not a backup, so
711
+ treating its presence as "already backed up" would let a merge proceed with no recoverable copy.
712
+
713
+ Raises:
714
+ InstallError: the copy failed. A merge that cannot be backed up is refused rather than
715
+ performed, because an edit to a file the user owns with no recoverable copy of it is the
716
+ one outcome this whole function exists to prevent.
717
+ """
718
+ backup = path.with_name(path.name + ".bak")
719
+ if backup.is_file() and not backup.is_symlink():
720
+ report.notes.append(f"{backup.name} already existed and was left as it was.")
721
+ return
722
+ guard_backup_path(path)
723
+ try:
724
+ _copy_atomically(path, backup)
725
+ except FileExistsError:
726
+ # Another process published the first backup between the check above and this link. That
727
+ # copy wins, exactly as an already-present one would have.
728
+ report.notes.append(f"{backup.name} appeared while backing up and was left as it was.")
729
+ return
730
+ except OSError as exc:
731
+ raise InstallError(f"could not back {path} up to {backup.name}: {exc}") from exc
732
+ report.backed_up.append(backup)
733
+
734
+
735
+ def _copy_atomically(source: Path, destination: Path) -> None:
736
+ """Copy `source` to `destination` through an exclusively created temporary file.
737
+
738
+ Not `shutil.copy2` straight to the destination: that writes the final `.bak` in place, so a copy
739
+ interrupted part-way — a full disk, a killed process — leaves a *partial regular file* that the
740
+ next run would accept as the pristine first backup. A rename either happened or did not.
741
+ """
742
+ with _exclusive_temporary(destination) as scratch:
743
+ _write_all(scratch.descriptor, source.read_bytes())
744
+ shutil.copystat(source, scratch.path)
745
+ # `os.link`, not `replace`: a rename would overwrite a backup another process created
746
+ # between `_guard_backup_path` and here, and "the first backup wins" is the whole rule. A
747
+ # hard link fails with `EEXIST` instead, and the temporary is removed either way.
748
+ os.link(scratch.path, destination)
749
+
750
+
751
+ def _merged_hooks(
752
+ document: dict[str, object], commands: Commands, hook_format: HookFormat
753
+ ) -> object:
754
+ """This install's hook entries, folded into whatever the config already had.
755
+
756
+ Replacement is keyed on identity rather than appended blindly: an object-format entry is
757
+ identified by its `command`'s file name, an array-format entry by its `name`. Appending instead
758
+ would give a twice-installed project two hooks per trigger, and therefore two injected blocks
759
+ per
760
+ user message.
761
+ """
762
+ if hook_format is HookFormat.OBJECT:
763
+ return _merged_hooks_object(document, commands)
764
+ return _merged_hooks_array(document, commands)
765
+
766
+
767
+ def _merged_hooks_object(
768
+ document: dict[str, object], commands: Commands
769
+ ) -> dict[str, list[object]]:
770
+ existing = document.get("hooks")
771
+ merged: dict[str, list[object]] = (
772
+ {key: list(value) for key, value in existing.items() if isinstance(value, list)}
773
+ if isinstance(existing, dict)
774
+ else {}
775
+ )
776
+ for trigger, entries in hooks_object(commands).items():
777
+ kept = [entry for entry in merged.get(trigger, []) if not _is_our_command(entry, commands)]
778
+ merged[trigger] = [*kept, *entries]
779
+ return merged
780
+
781
+
782
+ def _merged_hooks_array(document: dict[str, object], commands: Commands) -> list[object]:
783
+ ours = hooks_array(commands)
784
+ our_names = {entry["name"] for entry in ours}
785
+ existing = document.get("hooks")
786
+ kept = [
787
+ entry
788
+ for entry in (existing if isinstance(existing, list) else [])
789
+ if not (isinstance(entry, dict) and entry.get("name") in our_names)
790
+ and not _is_our_command(entry, commands)
791
+ ]
792
+ return [*kept, *ours]
793
+
794
+
795
+ def _merged_servers(document: dict[str, object], commands: Commands) -> dict[str, object]:
796
+ servers = document.get("mcpServers")
797
+ merged: dict[str, object] = dict(servers) if isinstance(servers, dict) else {}
798
+ merged.update(mcp_servers_value(commands, mode="primary"))
799
+ return merged
800
+
801
+
802
+ def _merged_resources(
803
+ document: dict[str, object], targets: Targets
804
+ ) -> tuple[list[object] | None, list[str]]:
805
+ """The agent's `resources` with the skill declared, when declaring it is what makes it load.
806
+
807
+ Skills reach an agent through `resources`, and by default they *also* arrive without one: the
808
+ harness appends inherited resources (workspace skills among them) to whatever a config declares,
809
+ and only the `chat.disableInheritingDefaultResources` setting turns that off.
810
+
811
+ **The entry is written whenever nothing already covers the skill, including when `resources` is
812
+ absent.** An earlier version only left a note in that case, reasoning that inheritance covers
813
+ it — which leaves a *deterministically broken* install for the one documented configuration
814
+ where it does not, and a warning is not a delivered skill. Declaring resources does not disable
815
+ inheritance (the harness appends to what a config declares), so the entry is purely additive and
816
+ cannot narrow anything; the only cost is a redundant declaration in the common case, against a
817
+ skill that silently fails to load in the uncommon one.
818
+
819
+ A `skill://.kiro/skills/**/SKILL.md` glob already covers it, so a config carrying one gains
820
+ nothing.
821
+
822
+ Coverage is tested with `fnmatch`, whose `*` spans separators and so treats the `**` glob above
823
+ as matching. Both directions of error are benign: over-matching leaves the entry off a config
824
+ that inherits it anyway, and under-matching adds one that is redundant.
825
+ """
826
+ declared = document.get("resources")
827
+ listed: list[object] = list(declared) if isinstance(declared, list) else []
828
+ target = str(targets.skill_file.relative_to(targets.project))
829
+ covered = any(
830
+ isinstance(entry, str)
831
+ and entry.startswith(_SKILL_SCHEME)
832
+ and fnmatch(target, entry.removeprefix(_SKILL_SCHEME))
833
+ for entry in listed
834
+ )
835
+ if covered:
836
+ return None, []
837
+ return [*listed, _skill_resource(targets)], [
838
+ f"Added `{_skill_resource(targets)}` to `resources`, so the consolidation skill is "
839
+ "reachable whether or not inherited resources are switched on."
840
+ ]
841
+
842
+
843
+ def _skill_resource(targets: Targets) -> str:
844
+ """The `skill://` URI for the shipped skill, relative to the project."""
845
+ return _SKILL_SCHEME + str(targets.skill_file.relative_to(targets.project))
846
+
847
+
848
+ #: `toolsSettings.crew` gates which agents the `subagent` tool may spawn. `agent_crew` is a
849
+ #: documented alias for the same block, so a config using it must be *updated in place* rather than
850
+ #: given a second block whose relationship to the first is undefined.
851
+ _CREW_KEYS: Final = ("crew", "agent_crew")
852
+
853
+
854
+ def _merged_tools_settings(
855
+ document: dict[str, object],
856
+ ) -> tuple[dict[str, object] | None, list[str]]:
857
+ """The agent's `toolsSettings` with the consolidator added to its crew, and what to report.
858
+
859
+ **Without this the consolidation skill cannot run at all**, which is how the omission was
860
+ found — in real use, not in review. The skill spawns `zikaron-consolidator` through the
861
+ `subagent` tool, and `toolsSettings.crew.availableAgents` gates which agents that tool may
862
+ spawn: a config listing three of its own agents and not ours answers "Agents not available for
863
+ crew stages".
864
+
865
+ **The two lists are not symmetric, and treating them alike would break working configs.**
866
+ `availableAgents` is a *restriction*: the harness documents that an absent or empty list means
867
+ every agent is available. So adding our name to an empty one would *narrow* the config from "any
868
+ agent" to "only the consolidator" and silently break every other subagent the user has. It is
869
+ therefore extended only when it already lists something. **`trustedAgents` is left alone
870
+ entirely** — it grants a *subagent spawn*, which is wider than Zikaron's own tools, so the
871
+ install **reports** its absence and leaves the grant to the operator.
872
+
873
+ *This read "it is created if absent", which is the opposite of what the body does and of a
874
+ deliberate decision: the function only reads that key, to append a note; three tests assert it
875
+ is never written (`test_install_writer.py`, `test_install_main.py`); and `README.md` §Install
876
+ says the installer "never adds it to `trustedAgents`". **A maintainer reading this docstring as
877
+ the specification would find no such code, call it a bug, and silently grant the consolidator a
878
+ spawn permission the design withholds on purpose.***
879
+
880
+ Returns `(None, notes)` when there is nothing to change, so an install does not add an empty
881
+ `toolsSettings` block to a config that had none.
882
+ """
883
+ settings = document.get("toolsSettings")
884
+ if settings is None:
885
+ settings = {}
886
+ if not isinstance(settings, dict):
887
+ return None, []
888
+ crew_key = next((key for key in _CREW_KEYS if key in settings), "crew")
889
+ crew = settings.get(crew_key)
890
+ crew_dict: dict[str, object] = dict(crew) if isinstance(crew, dict) else {}
891
+
892
+ notes: list[str] = []
893
+ changed = False
894
+
895
+ available = crew_dict.get("availableAgents")
896
+ if isinstance(available, list) and available:
897
+ if CONSOLIDATOR_AGENT_NAME not in _string_list(available):
898
+ crew_dict["availableAgents"] = [*available, CONSOLIDATOR_AGENT_NAME]
899
+ changed = True
900
+ notes.append(
901
+ f"Added `{CONSOLIDATOR_AGENT_NAME}` to "
902
+ f"`toolsSettings.{crew_key}.availableAgents` — the consolidation skill spawns it "
903
+ "through the `subagent` tool, and that list gates which agents may be spawned."
904
+ )
905
+ else:
906
+ notes.append(
907
+ f"`toolsSettings.{crew_key}.availableAgents` is unset, which the harness reads as "
908
+ "*every* agent being available, so it was left alone rather than narrowed to one entry."
909
+ )
910
+
911
+ if CONSOLIDATOR_AGENT_NAME not in _string_list(crew_dict.get("trustedAgents")):
912
+ notes.append(
913
+ f"Starting a consolidation will ask your permission once, because "
914
+ f"`{CONSOLIDATOR_AGENT_NAME}` is not in `toolsSettings.{crew_key}.trustedAgents`. Add "
915
+ "it there if you would rather it did not — that grants a subagent spawn, which is a "
916
+ "wider thing than Zikaron's tools and so is left to you."
917
+ )
918
+
919
+ if not changed:
920
+ return None, notes
921
+ merged_settings = dict(settings)
922
+ merged_settings[crew_key] = crew_dict
923
+ return merged_settings, notes
924
+
925
+
926
+ def _selecting(document: dict[str, object], key: str) -> list[object]:
927
+ """The agent's `tools` or `allowedTools`, with `@zikaron` added if it is not already there.
928
+
929
+ **`tools` is not optional, and that was measured rather than assumed.** An agent carrying the
930
+ `mcpServers` entry and a `tools` list that did not name the server reported its own tools as
931
+ `code, dummy, execute_bash, fs_read, fs_write, glob, grep, todo_list, use_subagent` — every
932
+ memory tool absent, no warning anywhere. `mcpServers` configures a server; `tools` selects
933
+ from it.
934
+
935
+ **`allowedTools` is added too, by default, and the reason is the write policy rather than
936
+ convenience.** Without it, every `remember`, `amend` and `retire` interrupts the user for
937
+ approval, and the design's whole write posture is "err toward recording" — the prior art's own
938
+ measured failure was an agent that recorded too *little*. Per-write friction pushes directly
939
+ against the one behaviour the store depends on, and it trains a user to click through prompts,
940
+ which is worse than
941
+ not asking at all.
942
+
943
+ What is being trusted is narrow, and worth stating precisely rather than generously: tools that
944
+ read and write rows in SQLite files under the project, with no network and no effect outside
945
+ it. A mistaken write is **recoverable, not undoable** — `retire` withdraws a row from
946
+ ordinary retrieval and leaves it auditable, while an `amend` overwrites prose that nothing
947
+ restores. That is a weaker guarantee than "reversible", which is the word this used first and
948
+ which overstated it. `--no-trust-tools` leaves the entry out for anyone who wants the prompts,
949
+ and the install reports which it did either way.
950
+ """
951
+ existing = document.get(key)
952
+ listed: list[object] = list(existing) if isinstance(existing, list) else []
953
+ if TOOL_SELECTOR in _string_list(existing):
954
+ return listed
955
+ return [*listed, TOOL_SELECTOR]
956
+
957
+
958
+ def _is_our_command(entry: object, commands: Commands) -> bool:
959
+ """Whether an existing entry is a Zikaron hook, in either format's spelling.
960
+
961
+ Matches on the command's **file name**, not the whole path, so a previous install from another
962
+ venv is replaced rather than left beside this one — two `zikaron-hook` entries on one trigger
963
+ would inject two blocks per message, and the stale one would point at an interpreter that may no
964
+ longer have Zikaron installed.
965
+ """
966
+ command = _entry_command(entry)
967
+ return command is not None and Path(command).name == commands.hook.name
968
+
969
+
970
+ def _string_list(value: object) -> frozenset[str]:
971
+ if not isinstance(value, list):
972
+ return frozenset()
973
+ return frozenset(item for item in value if isinstance(item, str))