zikaron 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|