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,375 @@
1
+ """`python -m zikaron.install` — write Zikaron's artefacts into a project, for either harness.
2
+
3
+ `architecture.md` §"The install contract" is normative for every refusal here; `harness.md`
4
+ §"The installer's two targets" for what varies between the two. The order of operations is the
5
+ contract's own: **validate before writing anything**, because a half-installed project whose
6
+ consolidator names a model the harness will silently substitute is the one outcome that makes two
7
+ consolidation runs incomparable while both look healthy.
8
+
9
+ **This module is written once.** Which files exist and how each is merged lives behind
10
+ `HarnessTarget`; flow, preflight order, collision policy and backup discipline are here and are
11
+ shared. If a harness name appears below outside `_resolve_harness`, the seam has sprung a leak.
12
+
13
+ Reached as `zikaron install` and as `python -m zikaron.install`. Both are supported because each is
14
+ unreachable in the other's case: `uv tool install` puts no interpreter on `PATH`, and a host-Python
15
+ install with several virtualenvs needs the form that names which interpreter's Zikaron is being
16
+ installed.
17
+ """
18
+
19
+ import argparse
20
+ import os
21
+ import sys
22
+ from collections.abc import Sequence
23
+ from pathlib import Path
24
+ from typing import Final
25
+
26
+ from zikaron.harness.spec import CLAUDE_CODE, Harness
27
+ from zikaron.install.entries import Commands, HookFormat
28
+ from zikaron.install.harness import InstallError
29
+ from zikaron.install.targets import HarnessTarget, target_for
30
+ from zikaron.install.writer import (
31
+ Plan,
32
+ Report,
33
+ ShippedFile,
34
+ commit_merge,
35
+ guard_shipped_targets,
36
+ write_shipped_files,
37
+ )
38
+
39
+ #: `--harness auto`, the default. Resolved by evidence, and refused when there is none — see
40
+ #: `_resolve_harness`.
41
+ _AUTO = "auto"
42
+
43
+ #: The marker variable Claude Code exports into every process it spawns, **read from the seam**
44
+ #: rather than spelled again here: a second copy of the literal is the drift intent 1 exists to
45
+ #: prevent.
46
+ #:
47
+ #: What is deliberately *not* reused is `harness.detect.current_harness`, and the difference is the
48
+ #: whole point: that function answers "which harness am I running under", falling back to kiro when
49
+ #: the marker is absent because kiro exports none. The installer is asking a **different question**
50
+ #: — "which harness is this project set up for" — where that same fallback would silently write
51
+ #: kiro artefacts into a Claude Code project from a plain terminal and exit 0. So the *value* comes
52
+ #: from the seam and the *policy* does not.
53
+ _CLAUDE_MARKER: Final = CLAUDE_CODE.marker_variable
54
+
55
+
56
+ _MODULE_INVOCATION: Final = "python -m zikaron.install"
57
+
58
+
59
+ def _parser(prog: str) -> argparse.ArgumentParser:
60
+ parser = argparse.ArgumentParser(
61
+ prog=prog,
62
+ description="Install Zikaron's consolidator, consolidation skill, hook and MCP entries.",
63
+ )
64
+ parser.add_argument(
65
+ "--project",
66
+ type=Path,
67
+ default=Path.cwd(),
68
+ help="the project to install into (default: the current directory). At runtime the "
69
+ "store is keyed by the harness's own project directory where it names one, else the "
70
+ "working directory (D17)",
71
+ )
72
+ parser.add_argument(
73
+ "--harness",
74
+ choices=(_AUTO, *(harness.value for harness in Harness)),
75
+ default=_AUTO,
76
+ help="which harness to install for. The default resolves it from the project and the "
77
+ "environment, and refuses rather than guessing when neither says.",
78
+ )
79
+ parser.add_argument(
80
+ "--agent",
81
+ type=Path,
82
+ default=None,
83
+ help="kiro only: an existing agent config to merge the hook and MCP entries into. Backed "
84
+ "up to <config>.bak first.",
85
+ )
86
+ parser.add_argument(
87
+ "--print-only",
88
+ action="store_true",
89
+ help="print the entries instead of merging them, and write no shipped files either. Use "
90
+ "to see the config an install would add, in full, and which files it would create, "
91
+ "before letting it. The shipped files are listed by path, not by content.",
92
+ )
93
+ parser.add_argument(
94
+ "--model",
95
+ default=None,
96
+ help="the consolidator's model (default: this harness's own, which kiro validates against "
97
+ "its model list and Claude Code refuses at spawn if unknown)",
98
+ )
99
+ parser.add_argument(
100
+ "--format",
101
+ dest="hook_format",
102
+ type=HookFormat,
103
+ choices=tuple(HookFormat),
104
+ default=HookFormat.OBJECT,
105
+ help="kiro only: which hook format to write when the target config has none yet. A config "
106
+ "that already has hooks keeps its own format regardless.",
107
+ )
108
+ parser.add_argument(
109
+ "--no-trust-tools",
110
+ dest="trust_tools",
111
+ action="store_false",
112
+ help="do not pre-approve Zikaron's own tools, so every tool call asks permission. The "
113
+ "default trusts them: per-write prompts push against the write policy the whole store "
114
+ "depends on.",
115
+ )
116
+ parser.add_argument(
117
+ "--force",
118
+ action="store_true",
119
+ help="overwrite files this would otherwise refuse to touch",
120
+ )
121
+ return parser
122
+
123
+
124
+ def main(argv: Sequence[str] | None = None, *, prog: str = _MODULE_INVOCATION) -> int:
125
+ """Install, printing an account of what happened. Returns a process exit status.
126
+
127
+ Returns rather than calling `sys.exit`, so a test drives the whole command in-process and reads
128
+ both the status and the output — the `__main__` block below is the only place a status becomes
129
+ an exit.
130
+ """
131
+ args = _parser(prog).parse_args(sys.argv[1:] if argv is None else argv)
132
+ try:
133
+ report = _install(agent=args.agent, options=args)
134
+ except InstallError as exc:
135
+ print(f"install refused: {exc}", file=sys.stderr)
136
+ return 1
137
+ except OSError as exc:
138
+ # Every *predictable* path-shape problem is refused in the preflight above. This is the
139
+ # remainder — a full disk, a revoked permission, a filesystem that went read-only mid-run —
140
+ # reported as a failed install rather than as a traceback, which tells a user nothing they
141
+ # can act on.
142
+ print(f"install failed: {exc}", file=sys.stderr)
143
+ return 1
144
+ _print_report(report)
145
+ return 0
146
+
147
+
148
+ def _install(*, agent: Path | None, options: argparse.Namespace) -> Report:
149
+ """The whole install, in the contract's order: check everything, then write everything.
150
+
151
+ The ordering is load-bearing rather than tidy. `plan_merges` parses the files it would touch and
152
+ runs every refusal against them **before** the shipped files are written, so a malformed config
153
+ or a conflicting entry leaves the project exactly as it was. An earlier arrangement wrote the
154
+ shipped files first and found the problem afterwards, which is the half-install this order
155
+ prevents.
156
+
157
+ Raises:
158
+ InstallError: any refusal. Nothing has been written when one is raised.
159
+ """
160
+ project: Path = options.project
161
+ harness_choice, harness_notes = _resolve_harness(options.harness, project)
162
+ target = target_for(harness_choice)
163
+ commands = Commands.from_this_interpreter()
164
+ _refuse_missing_commands(commands)
165
+ _refuse_unusable_agent_flag(agent, target)
166
+ if not project.is_dir():
167
+ raise InstallError(f"{project} is not a directory")
168
+ if agent is not None and not agent.is_file():
169
+ raise InstallError(f"{agent} is not a file — --agent takes a path to an existing config")
170
+ if agent is not None and agent.is_symlink():
171
+ # Refused rather than followed, because publishing an atomic rewrite means replacing the
172
+ # directory entry — which would silently sever a config managed as a link into a dotfile
173
+ # repository, leaving a new local file and the real one untouched and disconnected. This is
174
+ # the opposite case from a symlinked *directory*, which a write through preserves.
175
+ raise InstallError(
176
+ f"{agent} is a symlink. Point --agent at the file it resolves to "
177
+ f"({agent.resolve()}), so this does not replace the link itself."
178
+ )
179
+
180
+ plan = Plan(
181
+ project=project,
182
+ commands=commands,
183
+ # `is None`, not `or`: an explicit `--model ""` is a mistake worth reporting, and truthiness
184
+ # would silently substitute this harness's default for it. Found by the test written for the
185
+ # shape check rather than by the shape check itself, which never saw the value.
186
+ model=(target.spec.consolidator_model if options.model is None else options.model),
187
+ hook_format=options.hook_format,
188
+ force=options.force,
189
+ trust_tools=options.trust_tools,
190
+ )
191
+ report = Report()
192
+ report.notes.extend(harness_notes)
193
+ if options.print_only:
194
+ # **A preview refuses nothing it does not have to.** The model check asks a *remote* harness
195
+ # whether an id exists, and the fragment below neither contains the model nor mentions it —
196
+ # so on a machine whose harness cannot be reached, a hard refusal here would block a preview
197
+ # in order to validate something the preview does not show. Downgraded to a note; a real
198
+ # install still refuses.
199
+ try:
200
+ target.refuse_absent_harness()
201
+ target.refuse_unknown_model(plan.model)
202
+ except InstallError as exc:
203
+ # "refused", not "could not be validated": the two reachable causes read very
204
+ # differently to whoever ran this. Kiro's is a genuinely unreachable harness; the shape
205
+ # check is local and deterministic, so the model *was* checked and failed. The
206
+ # exception's own text distinguishes them, so it is quoted rather than paraphrased.
207
+ report.notes.append(
208
+ f"a real install would refuse here ({exc}) — this preview does not, because its "
209
+ "whole job is to show you what would be written."
210
+ )
211
+ shipped = target.shipped_files(plan)
212
+ report.notes.append(target.fragment(plan))
213
+ report.notes.append(
214
+ "An install would also write, in full:\n"
215
+ + "\n".join(f"- {path}" for path, _content in shipped)
216
+ )
217
+ report.notes.extend(target.notes(plan))
218
+ return report
219
+
220
+ target.refuse_absent_harness()
221
+ target.refuse_unknown_model(plan.model)
222
+ shipped = target.shipped_files(plan)
223
+ _refuse_agent_aliasing_a_shipped_target(agent, shipped)
224
+
225
+ guard_shipped_targets(shipped, project)
226
+ merges = target.plan_merges(plan, agent)
227
+
228
+ write_shipped_files(shipped, plan, report)
229
+ for merge in merges:
230
+ commit_merge(merge, report)
231
+ if not merges:
232
+ # Kiro with no `--agent`: the shipped files land and the entries are printed for the user to
233
+ # paste, which is what a user re-running to refresh the consolidator expects. Expressed as
234
+ # "nothing was merged" rather than as "kiro without --agent" so it
235
+ # stays a fact about this install rather than a harness name leaking into shared flow.
236
+ report.notes.append(target.fragment(plan))
237
+ report.notes.extend(target.notes(plan))
238
+ report.notes.extend(
239
+ target.complaints_about([*report.created, *report.replaced, *report.merged])
240
+ )
241
+ return report
242
+
243
+
244
+ def _resolve_harness(requested: str, project: Path) -> tuple[Harness, list[str]]:
245
+ """Which harness to install for, refusing rather than guessing, plus anything worth saying.
246
+
247
+ Explicit wins. `auto` then reads **the project first**, because that is what the install is
248
+ *for*: a `.claude/` or a `.kiro/` directory says which harness this project is already set up
249
+ with, and it says so more specifically than whatever terminal the installer happens to be run
250
+ from. Only when the project says nothing does the environment marker get a vote.
251
+
252
+ **Both present, or neither and no marker, is a refusal.** The alternative is the failure this
253
+ corpus has already paid for once, in an agent whose `mcpServers` was configured and whose
254
+ `tools` did not select it: a working-looking install, exit 0, and no Zikaron tools anywhere. A
255
+ wrong-harness install has exactly that shape, and it is worse than an install that did not
256
+ happen.
257
+
258
+ **When the two sources *contradict*, the install says so.** Project evidence still wins, but a
259
+ lone `.kiro/` in a project opened from a Claude Code session is a real configuration — this
260
+ corpus's own migration advice is to *keep* `.kiro/` — and installing for kiro there produces the
261
+ working-looking-inert result for the session the user is actually in. Reported rather than
262
+ enforced: the evidence genuinely is ambiguous, and a note costs nothing while a refusal would
263
+ block the common case of installing for kiro from a Claude Code terminal.
264
+
265
+ Raises:
266
+ InstallError: `auto` found no evidence, or found both.
267
+ """
268
+ if requested != _AUTO:
269
+ return Harness(requested), []
270
+ has_claude = (project / ".claude").is_dir()
271
+ has_kiro = (project / ".kiro").is_dir()
272
+ marker_set = _CLAUDE_MARKER is not None and bool(os.environ.get(_CLAUDE_MARKER))
273
+ if has_claude and not has_kiro:
274
+ return Harness.CLAUDE_CODE, []
275
+ if has_kiro and not has_claude:
276
+ contradiction = (
277
+ [
278
+ f"{_CLAUDE_MARKER} is set, but {project} has only .kiro/ — installed for "
279
+ f"{Harness.KIRO.value}. Pass --harness {Harness.CLAUDE_CODE.value} if this project "
280
+ "is meant to use the session you are in."
281
+ ]
282
+ if marker_set
283
+ else []
284
+ )
285
+ return Harness.KIRO, contradiction
286
+ if has_claude and has_kiro:
287
+ evidence = "it has both .kiro/ and .claude/"
288
+ elif marker_set:
289
+ return Harness.CLAUDE_CODE, []
290
+ else:
291
+ evidence = f"it has neither .kiro/ nor .claude/, and {_CLAUDE_MARKER} is not set"
292
+ raise InstallError(
293
+ f"could not tell which harness {project} is for — {evidence}. Pass "
294
+ f"--harness {Harness.KIRO.value} or --harness {Harness.CLAUDE_CODE.value}. Guessing here "
295
+ "would install artefacts the harness never reads, which exits 0 and leaves no Zikaron "
296
+ "tools at all."
297
+ )
298
+
299
+
300
+ def _refuse_unusable_agent_flag(agent: Path | None, target: HarnessTarget) -> None:
301
+ """Refuse `--agent` on a harness that has no user-named config to merge into.
302
+
303
+ Refused rather than ignored: the flag is the one instruction the user gave about where their
304
+ configuration lives, and silently discarding it would install into two fixed project files
305
+ while they believed they had chosen otherwise.
306
+ """
307
+ if agent is None or target.accepts_agent_flag:
308
+ return
309
+ raise InstallError(
310
+ f"--agent has no meaning under --harness {target.name}: its hook and MCP entries go into "
311
+ "fixed project files (.claude/settings.local.json and .mcp.json), not into an agent config "
312
+ "you name. Drop the flag, or pass --print-only to see those entries in full."
313
+ )
314
+
315
+
316
+ def _refuse_agent_aliasing_a_shipped_target(
317
+ agent: Path | None, shipped: tuple[ShippedFile, ...]
318
+ ) -> None:
319
+ """Refuse an `--agent` that names one of the files this install writes itself.
320
+
321
+ `.kiro/agents/zikaron-consolidator.json` is a plausible thing to pick out of an agents
322
+ directory, and picking it used to produce a working-looking install that had broken
323
+ consolidation: the merge turned the consolidator's own config into a *primary* agent —
324
+ `--mode primary`, primary hooks — while keeping its identity and prompt, so the very first
325
+ `zikaron_memory_next_group` the prompt instructs has no tool behind it. Exit 0, and
326
+ consolidation
327
+ silently gone.
328
+
329
+ Compared by resolved path rather than by string, because the two can be the same file under
330
+ different spellings, and `--project .` makes that likely rather than exotic.
331
+ """
332
+ if agent is None:
333
+ return
334
+ resolved = agent.resolve()
335
+ for path, _content in shipped:
336
+ if resolved == path.resolve():
337
+ raise InstallError(
338
+ f"--agent names {path.name}, which this install writes itself. Point --agent at "
339
+ "the config for the agent you work in, not at one of Zikaron's own."
340
+ )
341
+
342
+
343
+ def _refuse_missing_commands(commands: Commands) -> None:
344
+ missing = commands.missing()
345
+ if not missing:
346
+ return
347
+ listed = ", ".join(str(path) for path in missing)
348
+ raise InstallError(
349
+ f"the shipped commands are not installed for this interpreter ({listed}). Run "
350
+ "`pip install -e .` (or `pip install zikaron`) into the environment you are installing "
351
+ "from — a hook whose command does not exist fails silently, because the harness does not "
352
+ "surface a hook's stderr."
353
+ )
354
+
355
+
356
+ def _print_report(report: Report) -> None:
357
+ for path in report.created:
358
+ print(f"wrote {path}")
359
+ for path in report.replaced:
360
+ print(f"replaced {path}")
361
+ for path in report.merged:
362
+ print(f"merged {path}")
363
+ for path in report.backed_up:
364
+ print(f"backed up {path}")
365
+ for path in report.skipped:
366
+ # The three reasons want different advice, and only one of them is still readable from the
367
+ # file afterwards.
368
+ if path in report.kept_unbacked:
369
+ print(f"kept {path} (differs, but could not be backed up — see below)")
370
+ elif path.is_symlink():
371
+ print(f"kept {path} (a symlink, left unfollowed — pass --force to replace it)")
372
+ else:
373
+ print(f"kept {path} (already exactly what this version ships)")
374
+ for note in report.notes:
375
+ print(f"\n{note}")