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,379 @@
1
+ """The hook's own single-attempt connect-or-start sequence.
2
+
3
+ `architecture.md` §Lifecycle's five-step start-if-absent sequence is normative, and
4
+ `zikaron.service.lifecycle.connect_start_if_absent` already implements it — but that module also
5
+ imports `zikaron.service.context` and `zikaron.service.server` for its unrelated `idle_self_stop`
6
+ function, which pulls `aiosqlite`, `sqlite-vec` and `fastembed`'s own dependency chain into any
7
+ process that imports the module at all, whichever function it actually calls (verified directly:
8
+ `import zikaron.service.lifecycle` adds `aiosqlite`, `numpy` and `sqlite_vec` to `sys.modules`).
9
+ Importing it here would violate this package's own stdlib-only contract before a single request
10
+ is ever sent, so this module re-implements the same five steps rather than importing that one —
11
+ including the `flock`: two racing callers can each observe the socket absent at the same instant,
12
+ and without a lock serializing the vet-and-clear-stale-socket-and-spawn sequence, one caller can
13
+ unlink a socket the *other* has just bound and is already live on, forcing a second, redundant
14
+ service to start against a store the first is already correctly serving.
15
+
16
+ **This reimplementation is narrower than the one it mirrors in exactly one direction, and
17
+ nowhere else**: no *second outer attempt* after this one's own five-step sequence fails.
18
+ `lifecycle.py`'s own sequence is that whole five-step race-safe procedure and runs to
19
+ completion or raises — there is no looping inside it to narrow — but
20
+ `zikaron.mcp.connection.ServiceConnection` wraps a call to it in an outer retry (`_MAX_ATTEMPTS =
21
+ 2`) for a long-running process that can afford to try the sequence a second time when an
22
+ already-established connection turns out to be dead. The hook has no equivalent connection to
23
+ have gone stale — every invocation is a fresh, single-shot process — and a user's message is
24
+ waiting on it, so this module runs the five-step sequence exactly once: one connect, one lock, one
25
+ recheck, one possible spawn, one poll to one deadline, then whatever the outcome is. A failure
26
+ here is handed straight to the caller's degraded-mode path rather than retried, which is what
27
+ keeps a slow or wedged service from costing the user two deadlines instead of one.
28
+
29
+ Reuses `zikaron.service.paths` and `zikaron.service.security` directly: both were verified to
30
+ import cleanly with no non-stdlib package added to `sys.modules`, so borrowing their path
31
+ derivation and filesystem-vetting logic does not compromise this package's own contract, and it
32
+ keeps the *safety* rules — never unlink an unvetted socket, never trust a hostile runtime
33
+ directory — identical to the ones `zikaron-service` and `zikaron-mcp` already enforce, rather than
34
+ a third, independently-written copy of the same two checks that could drift from the other two.
35
+ """
36
+
37
+ import errno
38
+ import fcntl
39
+ import json
40
+ import os
41
+ import socket
42
+ import subprocess
43
+ import sys
44
+ import time
45
+ from collections.abc import Sequence
46
+ from dataclasses import dataclass
47
+ from pathlib import Path
48
+
49
+ from zikaron.service import paths, security
50
+
51
+ #: How long this single attempt waits, in total, for a freshly spawned server to answer a genuine
52
+ #: `health()` — not merely accept a connection. `push.py`'s own internal deadline
53
+ #: (`architecture.md`: "enforce an internal deadline of ~2 s") is the budget for the *whole*
54
+ #: `userPromptSubmit` hook, connect-through-print included, so this value must leave room for the
55
+ #: request and response after it. Set well under that: a cold spawn racing this deadline and
56
+ #: losing is exactly the case that must degrade (log to `hook.log`, relay on stdout) rather than
57
+ #: make the user wait for the `timeout_ms` kiro enforces on the whole hook command — 10 s on this
58
+ #: harness, and stated explicitly in the shipped entry (`architecture.md` §"The install contract").
59
+ HEALTH_POLL_DEADLINE_SECONDS = 1.2
60
+ _HEALTH_POLL_INTERVAL_SECONDS = 0.05
61
+ _CONNECT_TIMEOUT_SECONDS = 0.3
62
+
63
+ #: `architecture.md`: "`ECONNREFUSED` on an existing socket file is the signature of a service
64
+ #: that died without cleaning up — unlink and respawn rather than reporting an error," and
65
+ #: `ENOENT` is the plainer case of a socket that was never created. Mirrors
66
+ #: `zikaron.service.lifecycle._ABSENT_SERVER_ERRNOS` exactly, restated here rather than imported
67
+ #: since importing that module at all is what this file exists to avoid.
68
+ _ABSENT_SERVER_ERRNOS = frozenset({errno.ENOENT, errno.ECONNREFUSED})
69
+
70
+
71
+ class HookTransportError(Exception):
72
+ """No server became reachable within the deadline.
73
+
74
+ `push.py` catches this alongside every other failure kind and logs it to `hook.log` plus a
75
+ stdout relay, per `architecture.md`'s "Degraded modes". `StoreIdentityError` is the one
76
+ subclass of this that names a more specific reason — a reachable server that answered, but
77
+ disagreed about which store it was serving — kept as a subclass rather than a sibling
78
+ exception so a caller that only wants "did this single attempt succeed" can still catch this
79
+ one base type, while a caller that wants to log the design's own `-32030 store_identity`
80
+ code specifically can check for the subclass first.
81
+ """
82
+
83
+
84
+ class StoreIdentityError(HookTransportError):
85
+ """A reachable server's `health()` disagreed with this client's own resolved
86
+ `store_db_path`/`store_id` — `architecture.md`'s `-32030 store_identity`, §"Store identity is
87
+ verified, not assumed". Carries the two values the design's own error payload names
88
+ (`{expected, actual}`) so a caller can log them rather than only a formatted message string.
89
+ """
90
+
91
+ def __init__(self, *, expected: str, actual: str) -> None:
92
+ self.expected = expected
93
+ self.actual = actual
94
+ super().__init__(f"store_identity: expected={expected} actual={actual}")
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class Health:
99
+ """`health()`'s result, exactly as `push.py`'s identity check needs it."""
100
+
101
+ store_path: str
102
+ store_id: str
103
+
104
+
105
+ def connect_once(
106
+ sock_path: Path, *, store_db_path: Path, store_id: str | None, server_command: Sequence[str]
107
+ ) -> socket.socket:
108
+ """One pass through start-if-absent, implementing `architecture.md`'s full five-step
109
+ sequence exactly: connect; on absence, take the `flock`, connect again (another client may
110
+ have won the race while this one reached the lock), and only then vet-and-spawn-and-poll;
111
+ verify identity; return a connected socket.
112
+
113
+ **This is one outer attempt with an internal lock, not two outer attempts.** The design's own
114
+ lock exists to prevent exactly this race: two hooks (or a hook racing `zikaron-mcp`'s own
115
+ start-if-absent) can both observe the socket absent, and without serialization the second one
116
+ can vet-and-unlink the *first* one's now-live socket — the first server's own file, not a
117
+ stale leftover — before spawning a second server
118
+ at the identical path. `architecture.md`'s own five steps are one race-safe *unit*: connect,
119
+ lock, recheck, spawn-if-still-absent, release. Nothing here retries that whole unit a second
120
+ time if it still fails after the lock is released; the "single attempt" property is about not
121
+ looping the five-step unit, not about omitting a step inside it.
122
+
123
+ Returns:
124
+ A connected, unverified-timeout socket — `push.py` sets its own request timeout before
125
+ using it, since this socket's timeout is still whatever `_CONNECT_TIMEOUT_SECONDS` left it
126
+ at.
127
+
128
+ Raises:
129
+ HookTransportError: no server became reachable before the deadline, or a reachable one's
130
+ `health()` disagreed with `store_db_path`/`store_id`.
131
+ OSError: a filesystem or vetting failure distinct from "the server is absent" —
132
+ `zikaron.service.security.ensure_runtime_dir`'s own `ZikaronError` on a hostile runtime
133
+ directory propagates as itself, since this single attempt has no fallback path to
134
+ degrade *through* a runtime directory it does not trust.
135
+ """
136
+ sock, health = _connect_or_start_under_lock(sock_path, server_command=server_command)
137
+ _verify_identity(health, store_db_path=store_db_path, store_id=store_id, sock=sock)
138
+ return sock
139
+
140
+
141
+ def _connect_or_start_under_lock(
142
+ sock_path: Path, *, server_command: Sequence[str]
143
+ ) -> tuple[socket.socket, Health]:
144
+ """Steps 1 through 5 of `architecture.md`'s start-if-absent sequence, exactly: an unlocked
145
+ connect first (the ordinary warm case never needs the lock at all), then — only on absence —
146
+ the lock, a second connect under it, and the spawn-if-still-absent-and-poll branch, releasing
147
+ the lock on every exit path.
148
+ """
149
+ security.ensure_runtime_dir(sock_path.parent, uid=security.current_uid())
150
+ sock = _try_connect(sock_path)
151
+ if sock is not None:
152
+ # Already listening (the ordinary warm case): no lock needed at all, since nothing is
153
+ # about to be spawned or unlinked. This connection has never been asked `health()` yet,
154
+ # so one call is genuinely needed here.
155
+ try:
156
+ health = _health(sock)
157
+ except BaseException:
158
+ sock.close()
159
+ raise
160
+ return sock, health
161
+
162
+ lock_path = paths.lock_path(sock_path)
163
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
164
+ try:
165
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
166
+ # Step 3: "another client may have won and already started it" — recheck under the lock
167
+ # before touching the socket file at all, so a live socket the other client just bound is
168
+ # never mistaken for a stale one.
169
+ sock = _try_connect(sock_path)
170
+ if sock is not None:
171
+ try:
172
+ health = _health(sock)
173
+ except BaseException:
174
+ sock.close()
175
+ raise
176
+ return sock, health
177
+ _vet_and_clear_stale_socket(sock_path)
178
+ _spawn_detached(server_command)
179
+ # `_poll_until_reachable` already confirmed readiness with its own `health()` call before
180
+ # returning — a **second** call over the identical connection would ask a server that
181
+ # answers one request per connection (the real `zikaron-service`'s own JSON-RPC loop
182
+ # answers many, but nothing in this function's own contract may assume a peer's request
183
+ # count) for a response it has no obligation to send twice. `_poll_until_reachable`
184
+ # therefore hands back the `Health` it already has, not merely the socket.
185
+ return _poll_until_reachable(sock_path)
186
+ finally:
187
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
188
+ os.close(lock_fd)
189
+
190
+
191
+ def _verify_identity(
192
+ health: Health, *, store_db_path: Path, store_id: str | None, sock: socket.socket
193
+ ) -> None:
194
+ """`architecture.md`'s handshake: `store_path` always, `store_id` whenever this client has an
195
+ independent one to compare against. `None` covers this client's own permanent case — every
196
+ invocation is a fresh, single-shot process with no independent id to have read, never merely
197
+ "this store does not exist on disk yet" — per §"Store identity is verified, not assumed"'s
198
+ own hook exception.
199
+ """
200
+ if store_db_path != Path(health.store_path):
201
+ sock.close()
202
+ raise StoreIdentityError(expected=str(store_db_path), actual=health.store_path)
203
+ if store_id is not None and store_id != health.store_id:
204
+ sock.close()
205
+ raise StoreIdentityError(expected=store_id, actual=health.store_id)
206
+
207
+
208
+ def _try_connect(sock_path: Path) -> socket.socket | None:
209
+ """A bare connection attempt: the socket on success, `None` if nobody is listening yet.
210
+
211
+ Any `OSError` *other* than the absent-server signature propagates rather than being folded
212
+ into `None` — an `EACCES` or a permissions problem on the socket file itself is not "nobody is
213
+ listening," and treating it as a reason to spawn a second server here would paper over a real
214
+ failure this single attempt has no business recovering from silently.
215
+ """
216
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
217
+ sock.settimeout(_CONNECT_TIMEOUT_SECONDS)
218
+ try:
219
+ sock.connect(str(sock_path))
220
+ except OSError as error:
221
+ sock.close()
222
+ if error.errno in _ABSENT_SERVER_ERRNOS:
223
+ return None
224
+ raise
225
+ return sock
226
+
227
+
228
+ def _vet_and_clear_stale_socket(sock_path: Path) -> None:
229
+ """Unlink `sock_path` if, and only if, it is a real socket this uid owns.
230
+
231
+ `architecture.md`: "never unlink a socket you have not vetted" — identical rule and identical
232
+ helper (`zikaron.service.security.vet_socket_for_unlink`) to the one `zikaron.service.lifecycle`
233
+ already enforces, so a stale-socket decision made here agrees with the one the service and the
234
+ MCP client would have made in this function's place.
235
+ """
236
+ if security.vet_socket_for_unlink(sock_path, uid=security.current_uid()):
237
+ sock_path.unlink()
238
+
239
+
240
+ def _spawn_detached(server_command: Sequence[str]) -> None:
241
+ """Start the service, detached, and forget it — this single attempt does not wait on the
242
+ child beyond polling its socket, and does not reap it: a short-lived hook process exits long
243
+ before the spawned service's own eventual idle self-stop, so there is no zombie-avoidance
244
+ reason for this process to hold a `Popen` handle the way `zikaron.service.lifecycle`'s own
245
+ reaping thread exists for a *long-running* spawning client. Whatever process this hook's own
246
+ parent (kiro) reaps this child under is outside this function's concern once the fork
247
+ succeeds.
248
+ """
249
+ subprocess.Popen( # noqa: S603 — `server_command` is caller-supplied, the fixed argv this
250
+ # process's own caller decided to spawn, never built from request data.
251
+ list(server_command),
252
+ start_new_session=True,
253
+ stdin=subprocess.DEVNULL,
254
+ stdout=subprocess.DEVNULL,
255
+ stderr=subprocess.DEVNULL,
256
+ )
257
+
258
+
259
+ def _poll_until_reachable(sock_path: Path) -> tuple[socket.socket, Health]:
260
+ """Poll for a genuine `health()` response, not merely a successful connect, until
261
+ `HEALTH_POLL_DEADLINE_SECONDS` elapses.
262
+
263
+ Mirrors `zikaron.service.lifecycle._poll_until_reachable`'s own reasoning exactly: a freshly
264
+ spawned process can accept a connection before it is able to answer anything, so a poll that
265
+ stopped at `connect()` would hand the caller a socket the service is not yet serving on.
266
+ Answering `health()` is the readiness signal because it is the first thing that requires the
267
+ store to be open.
268
+
269
+ It does **not** mean the model is loaded. The service binds while a deferred load is still
270
+ running, deliberately, so a `health()` answer bounds the wait for the *store* and not for the
271
+ first embedding — the request after it may still block on the model.
272
+
273
+ Returns:
274
+ The connected socket and the `Health` this function's own poll attempt already read from
275
+ it — the caller must not call `_health` a second time over this identical connection, since
276
+ a peer that answers one request per accepted connection would have nothing left to answer
277
+ with.
278
+
279
+ Raises:
280
+ HookTransportError: the deadline elapsed with no genuine `health()` response.
281
+ """
282
+ deadline = time.monotonic() + HEALTH_POLL_DEADLINE_SECONDS
283
+ while time.monotonic() < deadline:
284
+ sock = _try_connect(sock_path)
285
+ if sock is None:
286
+ time.sleep(_HEALTH_POLL_INTERVAL_SECONDS)
287
+ continue
288
+ try:
289
+ health = _health(sock)
290
+ except (OSError, ConnectionError, KeyError, ValueError, TypeError):
291
+ sock.close()
292
+ time.sleep(_HEALTH_POLL_INTERVAL_SECONDS)
293
+ continue
294
+ else:
295
+ return sock, health
296
+ raise HookTransportError(f"no server became reachable at {sock_path} before the deadline")
297
+
298
+
299
+ def _health(sock: socket.socket) -> Health:
300
+ """Send `health()` and parse its result, requiring a genuine JSON boolean `true` for `ready`
301
+ — mirrors `zikaron.service.lifecycle._health`'s own reasoning for rejecting a merely-truthy
302
+ value rather than coercing one.
303
+
304
+ Raises:
305
+ OSError: the socket failed during send or receive.
306
+ ConnectionError: the connection closed before a full line arrived, or the response was
307
+ not the expected shape.
308
+ KeyError: a required field (`store_path`, `store_id`, `ready`) was absent from the result.
309
+ ValueError, TypeError: the response was not valid JSON, or `ready` was present but not the
310
+ JSON boolean `true`.
311
+ """
312
+ request = {"jsonrpc": "2.0", "id": 1, "method": "health", "params": {}}
313
+ sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
314
+ buffer = b""
315
+ while not buffer.endswith(b"\n"):
316
+ chunk = sock.recv(4096)
317
+ if not chunk:
318
+ raise ConnectionError("connection closed before a full response was read")
319
+ buffer += chunk
320
+ parsed = json.loads(buffer.decode("utf-8"))
321
+ if not isinstance(parsed, dict):
322
+ raise ConnectionError(f"response was not a JSON object: {parsed!r}")
323
+ result = parsed.get("result")
324
+ if not isinstance(result, dict):
325
+ raise ConnectionError(f"health() did not return a result: {parsed!r}")
326
+ ready = result["ready"]
327
+ if ready is not True:
328
+ raise ConnectionError(f"health() reported ready={ready!r}, not the JSON boolean true")
329
+ return Health(store_path=str(result["store_path"]), store_id=str(result["store_id"]))
330
+
331
+
332
+ def default_server_command(sock_path: Path, store_dir: Path) -> list[str]:
333
+ """The argv `zikaron.service.main` expects — identical shape to
334
+ `zikaron.service.lifecycle.default_server_command`, restated rather than imported for the same
335
+ reason every other reuse in this module is direct rather than through `lifecycle.py`.
336
+ """
337
+ return [sys.executable, "-m", "zikaron.service.main", str(sock_path), str(store_dir)]
338
+
339
+
340
+ def resolve_sock_path(store_dir: Path) -> Path:
341
+ """Where this store's socket lives — `zikaron.service.paths`' own derivation, reused
342
+ directly since that module was verified to import with no non-stdlib dependency.
343
+
344
+ **This module deliberately has no function reading `meta.store_id` from an existing store,
345
+ and `connect_once`'s own identity check stays path-only, on every connection this client ever
346
+ makes.** `architecture.md` §"Store identity is verified, not assumed" states this as the
347
+ hook's own permanent, documented exception to the ordinary rule that a later connection has a
348
+ real `store_id` to compare against: every `userPromptSubmit` is a fresh, single-shot process
349
+ with no connection of its own from a previous invocation to have learned anything from, and
350
+ the only way to get a `store_id` to compare against would be to read it directly from
351
+ `memory.db` — precisely the store access §"Degraded modes" forbids unconditionally ("the hook
352
+ is never a reader of the store, under any failure"). Two alternatives were considered and
353
+ rejected on that same rule: reading `meta.store_id` directly with bare `sqlite3` (measured to
354
+ violate the no-store-access invariant this whole package exists to hold), and a small sidecar
355
+ file this client would write and read itself to remember an id across invocations (found to be
356
+ circular — whatever a later call would compare against was itself written by an earlier call
357
+ to the same, possibly-now-stale service, so a service that has gone stale without restarting
358
+ reports the identical value both times and the sidecar never disagrees with itself).
359
+
360
+ **Path-only comparison is not, on its own, a complete identity check** — it cannot by
361
+ construction distinguish a correctly-running service from one still serving a store that was
362
+ deleted and recreated at the identical path, since the path being compared never changed
363
+ either way. That gap is closed on the *service* side instead: `architecture.md` §"Idle
364
+ self-stop" has the service's own 30 s poll also detect the store it has open no longer being
365
+ the one currently on disk at its own path, and stop rather than keep serving it — the only
366
+ side with an independent way to notice, since it can `stat` the path without opening the file
367
+ it already holds. Given that, path-only is the correct, sufficient check for this client:
368
+ the one case it cannot resolve on its own resolves itself within one more poll cycle either
369
+ way, with no fallback read attempted here in the meantime.
370
+
371
+ Raises:
372
+ ZikaronError: `paths.socket_path`'s own `BAD_CONFIG` when the derived path will not fit
373
+ `sun_path`.
374
+ """
375
+ resolved_store_dir = store_dir.resolve()
376
+ runtime_dir = paths.runtime_dir(
377
+ xdg_runtime_dir=os.environ.get("XDG_RUNTIME_DIR"), uid=os.getuid()
378
+ )
379
+ return paths.socket_path(runtime_dir, resolved_store_dir, platform=sys.platform)
@@ -0,0 +1,106 @@
1
+ """The subagent-suppression check, and this client's `client` envelope.
2
+
3
+ `architecture.md` §"Subagent sessions — the push hook suppresses itself" is normative for
4
+ `is_subagent_session`; §"The request envelope" for `build_envelope`.
5
+
6
+ **States the envelope's four fields locally rather than importing them, and the reason is
7
+ measured.** This module originally reused `zikaron.service.envelope.ClientEnvelope` and
8
+ `zikaron.core.events.ClientKind` on the argument that both are stdlib-only, so reuse cost nothing —
9
+ verified at the time, and wrong about the cost that matters. Stdlib-only is not the same as cheap:
10
+ measured on this machine, importing `zikaron.core.events` alone costs **28 ms** over a
11
+ socket/json/os/pathlib/subprocess floor and `zikaron.service.envelope` **34 ms** (it pulls in
12
+ `core.events`, `core.errors` and `dataclasses`, whose own `inspect` import is most of it), against
13
+ a whole-process hook budget of ~48 ms without them and ~68 ms with. This client runs **once per
14
+ user message**, so that is a per-turn tax on the exact critical path the design's argument for
15
+ keeping the hook thin is about — and `architecture.md` §Components quotes ~20 ms for this client, a
16
+ figure the reuse quietly tripled. `dataclasses` is avoided for the same reason `logging` is: a
17
+ `NamedTuple` gives the same frozen, typed, four-field value for no measurable import.
18
+
19
+ Two copies of a wire contract is exactly the drift this corpus keeps finding, so the copy is
20
+ **guarded rather than trusted**: `tests/test_hook_envelope.py` asserts this module's field names and
21
+ its `kind` literal against `ClientEnvelope`'s own fields and `ClientKind.HOOK.value`, which is the
22
+ same drift-guard shape `tests/design_tables.py` uses for the design's own tables. The test pays the
23
+ import
24
+ cost; the hook does not.
25
+ """
26
+
27
+ from typing import NamedTuple
28
+
29
+ #: The wire value for this client's `client.kind`, i.e. `ClientKind.HOOK.value`. A literal here, an
30
+ #: enum there, and a test that they agree — see this module's docstring.
31
+ CLIENT_KIND = "hook"
32
+
33
+
34
+ class HookEnvelope(NamedTuple):
35
+ """This client's `client` envelope: the four fields `architecture.md` §"The request envelope"
36
+ states, in the shape `zikaron.service.envelope.ClientEnvelope` declares them.
37
+
38
+ A `NamedTuple` rather than a frozen dataclass purely for import cost (module docstring); it is
39
+ immutable and typed either way. `as_client_object()` is the one place the wire spelling of these
40
+ fields is written, so `rpc.py` composes a request without restating them.
41
+ """
42
+
43
+ session_id: str | None
44
+ kind: str
45
+ pid: int
46
+ op_id: str | None
47
+
48
+ def as_client_object(self) -> dict[str, object]:
49
+ """The `client` member of an outgoing request's `params`."""
50
+ return {
51
+ "session_id": self.session_id,
52
+ "kind": self.kind,
53
+ "pid": self.pid,
54
+ "op_id": self.op_id,
55
+ }
56
+
57
+
58
+ def is_subagent_session(*, env_session_id: str | None, payload_session_id: object) -> bool:
59
+ """Whether this invocation is running inside a subagent session, per
60
+ `architecture.md`'s exact rule: the hook reads the harness's session variable from its
61
+ environment and compares it to its payload's `session_id`. When they **differ**, this is a
62
+ subagent session.
63
+
64
+ **Under Claude Code this comparison is never reached for a genuine subagent**, because
65
+ `UserPromptSubmit` does not fire for subagents at all — the harness closes the door this rule
66
+ was built to guard. The rule stays because kiro still needs it, and because under Claude Code
67
+ a divergence means something else entirely: payload and environment are invariantly equal
68
+ there, so a difference is a misdetected harness rather than a subagent. `tripwire.py` owns
69
+ that reading; this function only states the comparison.
70
+
71
+ Resolving the environment side is `zikaron.harness.detect.session_label`'s job, not this
72
+ module's — the variable's *name* is harness-varying data and belongs in the one table that
73
+ carries such data.
74
+
75
+ A pure comparison, deliberately taking both values as already-extracted arguments rather than
76
+ reading the environment or a payload dict itself — the caller (`push.py`, `spawn_warm.py`)
77
+ owns *when* those reads happen (before any RPC, before any other action, per the same
78
+ section's ordering), and this function only states the comparison, so it can be tested against
79
+ every combination of present/absent/matching/differing without touching `os.environ` or
80
+ constructing a payload.
81
+
82
+ `payload_session_id` is typed `object` rather than `str | None`: the payload is untrusted JSON
83
+ from kiro's own stdin delivery, and a value that is present but not a string (or not present at
84
+ all) is treated as "no payload session id to differ from" — i.e. not a subagent session by this
85
+ check alone — exactly as `architecture.md` states for the *other* absence case: "when
86
+ `KIRO_SESSION_ID` is absent entirely, it proceeds normally." A malformed payload is a separate
87
+ concern the caller's own JSON handling surfaces as an ordinary failure later, not a reason for
88
+ this pure comparison to raise.
89
+
90
+ **When `env_session_id` is `None`, this always returns `False`** — "when... `KIRO_SESSION_ID`
91
+ is absent entirely, it proceeds normally" is unconditional on the payload's own value, so a
92
+ missing environment variable never suppresses a top-level session merely because the payload
93
+ happens to carry some session id.
94
+ """
95
+ if env_session_id is None:
96
+ return False
97
+ return payload_session_id != env_session_id
98
+
99
+
100
+ def build_envelope(*, session_id: str | None, pid: int) -> HookEnvelope:
101
+ """This request's `client` envelope: the resolved session label (or `None` for the bootstrap
102
+ form), `kind="hook"`, this process's own pid, and no `op_id` — the service mints one per call
103
+ when a client sends none (`zikaron.service.envelope.resolve`), and a single-request process has
104
+ nothing of its own to correlate across calls the way a long-running MCP connection does.
105
+ """
106
+ return HookEnvelope(session_id=session_id, kind=CLIENT_KIND, pid=pid, op_id=None)
@@ -0,0 +1,104 @@
1
+ """`hook.log`: exactly one line per failed `userPromptSubmit`, written without `logging`.
2
+
3
+ `architecture.md` §"Degraded modes": on any failure, the hook must log to `hook.log` — "a **direct
4
+ file write, not `logging`**: `open(path, "a")` and one `.write()` call, then close."
5
+ `coding-standards.md` §6 gives the number the rule rests on: `import logging` alone costs ~15 ms
6
+ on this machine, in the same range as the whole stdlib-thinness argument the hook exists to
7
+ satisfy, and a process that writes one line and exits has no log lifecycle for `logging`'s
8
+ formatters and handlers to manage.
9
+
10
+ `hook.log` is `0600` and this process's alone (`architecture.md` §"Paths"): never `service.log` or
11
+ `warmup.log`, and no other process shares this path, so the append below needs no cross-process
12
+ locking — the one property that would have made `logging.FileHandler` safe to use here in the
13
+ first place, and does not hold for `service.log`/`warmup.log`'s multi-process cases either.
14
+ """
15
+
16
+ import contextlib
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from zikaron.core.store.permissions import ensure_store_dir
22
+
23
+
24
+ def record_failure(hook_log_path: Path, kind: str, *, code: int | None = None) -> None:
25
+ """Append one line naming `kind` (and `code`, if this failure carried a wire error code) to
26
+ `hook_log_path`, creating both the file (at mode `0600`) and, if absent, its parent
27
+ directory (at `0700`, symlink-refusing) as needed.
28
+
29
+ **Ensuring the parent directory exists is required, not optional**, because this function is
30
+ the hook's failure path and the most common way to reach it is a service that has never run
31
+ for this project at all — the ordinary first-ever session, where `.zikaron/` does not exist
32
+ yet either. Skipping that step would let `os.open`'s own `ENOENT` on a missing parent be
33
+ swallowed by the same `except OSError` this docstring's next paragraph describes, making the
34
+ very failure this function exists to make durable the one case guaranteed to be lost.
35
+ `ensure_store_dir` is the identical helper `zikaron.core.store.store.Store.create` calls for
36
+ the same directory — reused rather than reimplemented, so the hook's own directory creation
37
+ cannot silently disagree with the service's about what "exists correctly" means (real
38
+ directory, not a symlink, exactly `0700`).
39
+
40
+ Never raises past its own attempt: this is the *last* thing a failure path does, called after
41
+ every other action (the RPC attempt, any socket cleanup) has already failed or been abandoned,
42
+ so a second failure here — the disk full, a permissions problem this process cannot fix — must
43
+ not propagate and turn a silent degraded push into a crashing one. `architecture.md`'s own
44
+ error table has no code for "the hook could not even log its own failure"; there is nothing to
45
+ report it as, and nothing downstream is waiting for this call to succeed. Swallowing the
46
+ failure here is therefore the one deliberate exception to `coding-standards.md` §7's "never
47
+ swallow an exception silently" — the section's own text names it: "except in the hook, where
48
+ the design requires exit 0... even there the failure goes to `hook.log`", and this is the
49
+ function that tries to make that true, on a best-effort basis, when even that attempt does not
50
+ land.
51
+
52
+ The file is opened with `O_CREAT | O_APPEND`, not appended through `Path.open("a")`'s own
53
+ higher-level call, and a **freshly created** file's mode is forced to exactly `0600` with
54
+ `os.fchmod` after opening, rather than trusted to the `mode` argument `os.open` itself
55
+ accepts. `os.open`'s `mode` parameter is **not** umask-independent — POSIX `open(2)` applies
56
+ `mode & ~umask`, so a restrictive process umask (an unusual but legal `0600`, say) can leave a
57
+ newly created file at `0000` even though `0o600` was passed — confirmed directly on this
58
+ machine rather than assumed.
59
+ `os.fchmod` on the already-open descriptor sets the mode outright, independent of umask,
60
+ closing that gap. **Creation is detected with `O_EXCL` first** — attempted before the
61
+ ordinary `O_CREAT | O_APPEND` open — specifically so the `fchmod` step only ever runs on a
62
+ file this exact call created: an *existing* `hook.log` must keep whatever mode it already
63
+ has, per this function's own stated contract below, and `fchmod`-ing it back to `0600` on
64
+ every append would silently re-tighten a mode an operator had deliberately widened.
65
+ """
66
+ line = _format_line(kind, code=code)
67
+ with contextlib.suppress(Exception):
68
+ # Never narrowed to `OSError` alone: `ensure_store_dir` raises `ZikaronError` — a plain
69
+ # `Exception`, not an `OSError` subclass — for a hostile or symlinked `.zikaron`, and that
70
+ # failure must be swallowed here exactly like any other, per this function's own contract
71
+ # that it is the *last* thing a failure path does and must never itself become the reason
72
+ # a degraded push crashes instead of relaying.
73
+ ensure_store_dir(hook_log_path.parent)
74
+ try:
75
+ fd = os.open(str(hook_log_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
76
+ except FileExistsError:
77
+ # The ordinary case after the first failure of a session: append without touching
78
+ # the mode, per this function's own contract — an existing file keeps whatever mode
79
+ # it already has.
80
+ fd = os.open(str(hook_log_path), os.O_APPEND | os.O_WRONLY)
81
+ else:
82
+ # This call won the creation race: force the exact mode now, on the descriptor this
83
+ # process itself just opened, before any content is written to it.
84
+ os.fchmod(fd, 0o600)
85
+ try:
86
+ os.write(fd, line.encode("utf-8"))
87
+ finally:
88
+ os.close(fd)
89
+
90
+
91
+ def _format_line(kind: str, *, code: int | None) -> str:
92
+ """One newline-terminated line: an ISO-8601 UTC timestamp, the failure kind, and the wire
93
+ error code when this failure carried one.
94
+
95
+ Deliberately excludes anything from the prompt or a memory record: `architecture.md`
96
+ §"Filesystem security" states plainly that `hook.log` "log[s] only a fixed failure-kind label
97
+ and an error code, never prompt or memory content, so neither can hold a leaked secret" — a
98
+ guarantee this function keeps by construction, since its only inputs are the failure
99
+ classification and an optional integer, never the prompt text or any RPC response body.
100
+ """
101
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
102
+ if code is None:
103
+ return f"{timestamp} {kind}\n"
104
+ return f"{timestamp} {kind} {code}\n"
zikaron/hook/limits.py ADDED
@@ -0,0 +1,61 @@
1
+ """The numbers a shipped hook entry states.
2
+
3
+ `architecture.md` §"The install contract" is normative. All are written into every installed hook
4
+ entry rather than inherited from the harness's own defaults, and all live here rather than in
5
+ `zikaron/install/` so there is exactly one place the installer reads them from.
6
+
7
+ **The injection budget is kiro-only**: Claude Code offers no field to raise it. This module is
8
+ deliberately not the general home for "what a harness will accept" — `zikaron.harness.spec` is, and
9
+ the runtime budget check reads it there.
10
+
11
+ **The timeout is one number in seconds, deliberately, and the conversions live at the edges.** Kiro
12
+ states `timeout_ms` in *milliseconds*; kiro's own array format states `timeout` in *seconds*; Claude
13
+ Code states `timeout` in *seconds* (measured, `research/claude-code-installer-probe.md` §2). That is
14
+ three spellings of one budget, and the corpus has already recorded the seconds-versus-milliseconds
15
+ trap between the first two. Keeping the canonical value in the coarser unit means the mistake that
16
+ matters — writing kiro's `10000` into a seconds field, which installs a **10,000-second** budget
17
+ during which a wedged hook blocks every user message with nothing reporting a problem — cannot be
18
+ made by copying a constant, because no constant here carries that number.
19
+ """
20
+
21
+ from typing import Final
22
+
23
+ from zikaron.harness.spec import KIRO
24
+
25
+ #: The hook budget every shipped entry states, in **seconds**. Equal to kiro's own documented
26
+ #: default, and stated anyway: the corpus recorded that default as 30 s for two milestones on the
27
+ #: strength of the public docs, and a budget the hook's ~2 s internal deadline is sized against
28
+ #: should not be a number a harness release can move underneath it. Five times the internal
29
+ #: deadline, so our own failure path — which produces a `hook.log` line *and* a model-facing relay —
30
+ #: always fires before the harness's kill, which produces neither.
31
+ #:
32
+ #: It also has to clear the *tightest* default in the harness table, which is measured and is not
33
+ #: the one a casual reading finds: Claude Code defaults `UserPromptSubmit` to **30 s** against 600 s
34
+ #: for other events. 10 s sits inside that, so stating it never *raises* a budget we would not
35
+ #: otherwise have had.
36
+ HOOK_TIMEOUT_SECONDS: Final = 10
37
+
38
+ #: The same budget in kiro's object-format unit. Derived rather than declared, so the two can never
39
+ #: disagree about how long a hook may run.
40
+ TIMEOUT_MS: Final = HOOK_TIMEOUT_SECONDS * 1_000
41
+
42
+ #: `max_output_size` for a shipped kiro entry, in bytes. **Read from the harness table rather than
43
+ #: declared here**, because the same number is what the runtime oversize check measures an
44
+ #: operator's policy override against: two agreeing declarations of one harness's budget is the
45
+ #: drift shape this corpus has already paid for, and the table is the side that a design-document
46
+ #: test reads.
47
+ #:
48
+ #: Its margin is what makes overrun unreachable rather than merely unlikely, and both real worst
49
+ #: cases are asserted in the suite rather than estimated: the shipped policy text is 6599 bytes, and
50
+ #: a five-row push block is bounded by the `gist` character bound at 6,429 UTF-16 code units — at
51
+ #: most 19,287 bytes of UTF-8 at the 3-bytes-per-unit ceiling. Both figures are stated in the unit
52
+ #: they are measured in, which this module of all places has to get right: a units count wearing a
53
+ #: byte suffix is exactly the conflation the runtime budget check exists to prevent.
54
+ #:
55
+ #: **Exact counts rather than a rounded `kB`, and the policy figure is pinned.** It read `~6.1 kB`
56
+ #: across a change that moved the policy from 6098 to 6230 bytes, and was defensible either way —
57
+ #: 6098 decimal and 6230 binary both round to 6.1 — so no reader could tell a live figure from a
58
+ #: stale one. A prefix that means two things is the same conflation the paragraph above claims to
59
+ #: have avoided, one level down. `architecture.md` carried the fifth staleness of this one number
60
+ #: and is guarded; this was its unguarded second site.
61
+ MAX_OUTPUT_SIZE: Final = KIRO.injection_budget