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
zikaron/hook/main.py ADDED
@@ -0,0 +1,118 @@
1
+ """The process entry point a harness's own hook configuration invokes.
2
+
3
+ A hook receives its event as JSON on **stdin** — `{hook_event_name, cwd, session_id}` always, plus
4
+ `prompt` for the prompt trigger and `agent_type` for the subagent trigger — and the same `command`
5
+ string is registered under every trigger it serves, dispatching here on `hook_event_name` from the
6
+ payload itself rather than on an argv flag.
7
+
8
+ Both harnesses would accept a per-trigger flag, so this is a choice rather than a constraint. One
9
+ identical command under every trigger keeps the installed entries uniform, and — the reason that
10
+ actually matters — the payload's own `hook_event_name` cannot disagree with the event that fired
11
+ it, while a flag copied into several config entries can be copied wrong exactly once and then
12
+ mis-dispatch every invocation of that trigger, silently.
13
+
14
+ **Trigger names are normalized before anything branches on them.** `zikaron.harness.spec` owns the
15
+ vocabulary; this module knows only the three events it resolves to. That is what keeps one
16
+ implementation serving both harnesses instead of two ladders of string comparisons drifting apart.
17
+
18
+ **Output channel is a property of the event, not a constant.** An earlier version of this module
19
+ wrote plain stdout unconditionally, which is correct for the spawn and prompt triggers and reaches
20
+ nobody at all for the subagent trigger — measured, not assumed. The seam answers which channel each
21
+ event uses and this module honours the answer.
22
+
23
+ **This process always exits 0, unconditionally.** `push.py`'s own module docstring states why: only
24
+ exit-0 stdout reaches the model, so a genuine failure is relayed through that channel rather than
25
+ through a non-zero exit or stderr, either of which would go unseen. `main()`'s own outermost
26
+ `contextlib.suppress(Exception)` is a *different*, narrower boundary again: it exists for whatever
27
+ none of the dispatched modules anticipated — a payload that is not even valid JSON, or a field a
28
+ harness's documented shape does not actually guarantee — and stays silent on stdout too, since at
29
+ that point this process may not even know which hook event it was invoked for, let alone have a
30
+ store-scoped failure to relay.
31
+ """
32
+
33
+ import contextlib
34
+ import json
35
+ import os
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ from zikaron.harness import detect
40
+ from zikaron.harness.spec import HookEvent, OutputChannel, channel_for, event_for
41
+ from zikaron.hook import push, spawn_warm, subagent_policy
42
+
43
+
44
+ def main() -> None:
45
+ """Read one JSON payload from stdin, dispatch on `hook_event_name`, emit whatever the dispatched
46
+ module returns (if anything) on that event's own channel, and exit 0 unconditionally.
47
+ """
48
+ with contextlib.suppress(Exception):
49
+ _run()
50
+ sys.exit(0)
51
+
52
+
53
+ def _run() -> None:
54
+ payload = json.loads(sys.stdin.read())
55
+ if not isinstance(payload, dict):
56
+ return
57
+ trigger = payload.get("hook_event_name")
58
+ event = event_for(trigger)
59
+ if event is None:
60
+ # A trigger this hook is not registered for, or one a harness adds later. Not a failure of
61
+ # anything this module owns: the process was invoked for a name it does not implement.
62
+ return
63
+ output = _dispatch(event, payload)
64
+ if output is not None:
65
+ _emit(event, output, trigger=str(trigger))
66
+
67
+
68
+ def _dispatch(event: HookEvent, payload: dict[str, object]) -> str | None:
69
+ """Whatever this event's own module wants emitted, or `None` for nothing at all."""
70
+ # **Not the payload's `cwd` directly** — that is the value this harness reports *live*, and
71
+ # under Claude Code it follows the agent's own `cd`, so keying a store on it put stores under
72
+ # log directories and Scala source trees while `zikaron-mcp` stayed on the real one. The seam
73
+ # decides; both clients ask it the same question. See `HarnessSpec.store_scope_dir`.
74
+ scope_dir = detect.current_spec().store_scope_dir(Path(str(payload.get("cwd", "."))))
75
+ if event is HookEvent.SPAWN:
76
+ return spawn_warm.run(scope_dir=scope_dir, payload_session_id=payload.get("session_id"))
77
+ if event is HookEvent.PROMPT:
78
+ prompt = payload.get("prompt")
79
+ if not isinstance(prompt, str):
80
+ return None
81
+ return push.run(
82
+ scope_dir=scope_dir,
83
+ payload_session_id=payload.get("session_id"),
84
+ prompt=prompt,
85
+ pid=os.getpid(),
86
+ )
87
+ return subagent_policy.run(scope_dir=scope_dir, agent_type=payload.get("agent_type"))
88
+
89
+
90
+ def _emit(event: HookEvent, output: str, *, trigger: str) -> None:
91
+ """Write `output` on the channel this event actually reaches a model through.
92
+
93
+ `sys.stdout.write`, not `print`, on the plain channel: `print` appends a trailing newline the
94
+ dispatched modules never included in their returned text, and the empty-store case is specified
95
+ as "prints nothing at all" — `print("")` would still emit a bare newline, which is not the same
96
+ observable as nothing. Writing exactly the returned string is what makes a successful surface
97
+ response reach the model byte-for-byte identical to what the service sent, and what makes the
98
+ write-policy text land verbatim too.
99
+
100
+ The structured channel echoes back the harness's own trigger name rather than reconstructing
101
+ one. The name has already been validated — it resolved to a known event — and echoing what
102
+ arrived is what keeps this correct if a harness ever serves one event under more than one
103
+ spelling.
104
+ """
105
+ if channel_for(event) is OutputChannel.STDOUT:
106
+ sys.stdout.write(output)
107
+ return
108
+ envelope = {"hookSpecificOutput": {"hookEventName": trigger, "additionalContext": output}}
109
+ # `json.dumps` defaults, including `ensure_ascii`: escaping non-ASCII inflates the emitted text
110
+ # against a budget the harness measures on what it receives, so it is worth knowing by how much
111
+ # rather than assuming. Measured on the shipped policy: 13 non-ASCII characters, 65 characters
112
+ # of escaping, 1.2% — immaterial against this channel's budget, so the default stands and the
113
+ # decoded text is identical either way.
114
+ sys.stdout.write(json.dumps(envelope))
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
zikaron/hook/push.py ADDED
@@ -0,0 +1,183 @@
1
+ """`userPromptSubmit`: suppress, connect once, ask for the push block, print or relay a failure.
2
+
3
+ `architecture.md` §"Degraded modes" is normative for the whole sequence, and §"Subagent sessions"
4
+ for step 0. Every branch below reaches exactly one of three observable outcomes, and every one
5
+ exits 0: print `surface`'s text; print nothing (a suppressed subagent turn — not a failure); or
6
+ print a short, model-facing instruction asking the agent to relay the failure to the operator.
7
+ Every path, including an exception this module did not anticipate, reaches one of those three
8
+ rather than propagating: `run()` is the one function in this package that must never let an
9
+ exception escape uncaught, because a caller (`main.py`) treats a raised exception here identically
10
+ to any other unanticipated failure, with no carve-out for "unless something unexpected happened."
11
+
12
+ **On a failure, this always exits 0 and uses stdout, never stderr, to notify anyone at all —
13
+ because that is the one channel confirmed to reach the model on the harness this ships against**
14
+ (`architecture.md` §"Degraded modes" has the full measurement). `hook.log` still gets the exact,
15
+ verbatim, machine-readable record on every failure — the source of truth an operator can grep
16
+ directly — while the returned stdout text is a deliberately paraphrasable nudge pointing at that
17
+ log, never itself the record of what happened.
18
+ """
19
+
20
+ import socket
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from zikaron.core.errors import ZikaronError
25
+ from zikaron.harness import detect
26
+ from zikaron.hook import connect, envelope, failure, rpc, tripwire
27
+ from zikaron.service import paths
28
+
29
+ #: `architecture.md`: "enforce an internal deadline of ~2 s, far under the `timeout_ms` the harness
30
+ #: enforces on the whole hook command" — 10 s on this harness, and stated explicitly in the shipped
31
+ #: hook entry rather than inherited (`architecture.md` §"The install contract").
32
+ #: Budgets the *whole* sequence below — connect, spawn-if-absent, health poll, the `surface`
33
+ #: request itself — not only the connect phase `connect.HEALTH_POLL_DEADLINE_SECONDS` bounds on
34
+ #: its own. Checked once, right before the one blocking sequence that can exceed it, rather than
35
+ #: interleaved through every step: the alternative (a fresh deadline check between each socket
36
+ #: call) would not materially tighten the bound, since `connect.connect_once` and
37
+ #: `rpc.surface_once` are each already individually bounded by socket timeouts, and a mid-sequence
38
+ #: abort would still need to close whatever socket was open — no cheaper than letting the one
39
+ #: attempt run to its own conclusion and discarding the result if it overran.
40
+ _DEADLINE_SECONDS = 2.0
41
+
42
+ #: `architecture.md` §"MCP tool surface": `surface`'s own documented default limit.
43
+ _SURFACE_LIMIT = 5
44
+
45
+
46
+ def run(*, scope_dir: Path, payload_session_id: object, prompt: str, pid: int) -> str | None:
47
+ """Run the whole `userPromptSubmit` sequence and return what to print to stdout, or `None` if
48
+ nothing should be printed — the caller (`main.py`) owns the actual `print`, so this function
49
+ stays trivially testable without capturing real stdout.
50
+
51
+ Every failure this function detects is handled by exactly one `_degrade` call before
52
+ returning — there is no path that returns silently for a reason `hook.log` does not also
53
+ record, and no path that returns silently for a reason the return value itself does not also
54
+ carry as a model-facing relay instruction. That includes an exception this module did not
55
+ anticipate: the degraded boundary below catches a genuine `Exception`, not a finite list of
56
+ types, specifically so a defect in a dependency this function calls — or in this function's
57
+ own future edits — degrades through the documented channel rather than escaping uncaught into
58
+ `main.py`'s own outermost catch-all, which reports nothing on either channel.
59
+ """
60
+ started_at = time.monotonic()
61
+ spec = detect.current_spec()
62
+ env_session_id = detect.session_label(spec)
63
+ if envelope.is_subagent_session(
64
+ env_session_id=env_session_id, payload_session_id=payload_session_id
65
+ ):
66
+ # Print nothing, make no RPC. Under a harness that fires this hook for subagent sessions
67
+ # that is the whole story and not a failure at all. Under one where payload and
68
+ # environment session ids are invariantly equal it instead means the harness was
69
+ # misdetected — the single case `tripwire` records, and the only one that writes a line
70
+ # here.
71
+ tripwire.record_if_misdetected(spec=spec, scope_dir=scope_dir)
72
+ return None
73
+
74
+ # `hook_log_path` is resolved *before* the degraded boundary, deliberately: it is the one
75
+ # value `_degrade` itself needs to report *any* failure, including one raised by resolving
76
+ # everything else below it — so it cannot itself be inside the boundary it feeds.
77
+ store_dir = paths.store_dir(scope_dir)
78
+ hook_log_path = paths.hook_log_path(store_dir)
79
+
80
+ sock: socket.socket | None = None
81
+ try:
82
+ sock_path = connect.resolve_sock_path(store_dir)
83
+ store_db_path = paths.store_db_path(store_dir)
84
+ sock = connect.connect_once(
85
+ sock_path,
86
+ store_db_path=store_db_path,
87
+ store_id=None,
88
+ server_command=connect.default_server_command(sock_path, store_dir),
89
+ )
90
+ remaining = _DEADLINE_SECONDS - (time.monotonic() - started_at)
91
+ if remaining <= 0:
92
+ return _degrade(hook_log_path, "deadline_exceeded")
93
+ sock.settimeout(remaining)
94
+ client_envelope = envelope.build_envelope(session_id=env_session_id, pid=pid)
95
+ return rpc.surface_once(sock, prompt=prompt, limit=_SURFACE_LIMIT, envelope=client_envelope)
96
+ except (connect.HookTransportError, rpc.SurfaceRejectionError, ZikaronError) as error:
97
+ kind, code = _classify_failure(error)
98
+ return _degrade(hook_log_path, kind, code=code)
99
+ except Exception:
100
+ # Anything else — a send/recv failure, a malformed response, a timeout mid-request, or a
101
+ # genuinely unanticipated defect in this function's own future edits. Never logs the
102
+ # underlying exception's own message text: `_degrade`'s `kind` argument here is the fixed
103
+ # word "transport", which is what keeps `hook.log` holding only the closed vocabulary
104
+ # `architecture.md`'s error table names rather than arbitrary exception text that could
105
+ # in principle echo something this process read (a path, a partial response body).
106
+ return _degrade(hook_log_path, "transport")
107
+ finally:
108
+ if sock is not None:
109
+ sock.close()
110
+
111
+
112
+ #: `architecture.md`'s error table names five wire codes a `surface` call can plausibly receive:
113
+ #: contention, the three store-level open failures, and an identity mismatch this module never
114
+ #: reaches through `SurfaceRejectionError` (it is caught earlier, inside `connect.connect_once`,
115
+ #: as a `HookTransportError`). Mapped to the log kind so `hook.log` names the same vocabulary
116
+ #: `architecture.md` §"Errors" already uses, rather than inventing a second one.
117
+ _REJECTION_KINDS: dict[int, str] = {
118
+ -32020: "store_busy",
119
+ -32022: "reindexing",
120
+ -32023: "bad_config",
121
+ -32024: "schema_incompatible",
122
+ }
123
+
124
+
125
+ def _classify_failure(error: Exception) -> tuple[str, int | None]:
126
+ """Every exception `run()` catches, as the `(kind, code)` pair `_degrade` needs — one small
127
+ dispatch rather than a `return _degrade(...)` per `except` clause, since the growing set of
128
+ exception types this function must treat identically (a `SurfaceRejectionError`'s wire code,
129
+ a `ZikaronError`'s own error code, a `StoreIdentityError`'s fixed `-32030`, or nothing more
130
+ specific than "transport" for everything else) was pushing `run()` itself past a plain
131
+ function's reasonable return-statement count — a lint signal worth heeding rather than
132
+ suppressing, since the *reason* it fired is that `run()` was starting to do this module's
133
+ classification work inline as well as its own control flow.
134
+
135
+ A wire code the design's error table does not name (a programming error in the service, or a
136
+ future addition neither side has been told about yet) still gets something identifiable
137
+ rather than an uninformative default — `_REJECTION_KINDS.get(code, f"rejected_{code}")`.
138
+ """
139
+ if isinstance(error, connect.StoreIdentityError):
140
+ # Checked before the plain `HookTransportError` base class it subclasses: an identity
141
+ # mismatch is `architecture.md`'s own named code `-32030 store_identity`, not the generic
142
+ # "transport" the base class's other cause (no server ever became reachable) collapses
143
+ # to. Reachable through the unconditional store-*path* comparison
144
+ # `connect._verify_identity` always runs — the hook has no persistent connection across
145
+ # invocations to have learned a real `store_id` from the way a long-running client could,
146
+ # so its own identity check stays path-only rather than reading the store file for one,
147
+ # which would violate `architecture.md`'s own "never a reader of the store, under any
148
+ # failure."
149
+ return "store_identity", -32030
150
+ if isinstance(error, rpc.SurfaceRejectionError):
151
+ return _REJECTION_KINDS.get(error.code, f"rejected_{error.code}"), error.code
152
+ if isinstance(error, ZikaronError):
153
+ # `connect.connect_once`'s own docstring: `security.ensure_runtime_dir`'s `ZikaronError`
154
+ # on a hostile runtime directory "propagates as itself, since this single attempt has no
155
+ # fallback path to degrade *through* a runtime directory it does not trust" — true about
156
+ # *retrying*, but every failure kind must still reach `hook.log` and the model-facing
157
+ # relay identically, **with its own numeric code**, not merely its name —
158
+ # `architecture.md` names both wherever one exists, and omitting the code here would
159
+ # leave `hook.log` naming, for example, `bad_config` with no accompanying `-32023`.
160
+ return str(error.code.wire_name), int(error.code)
161
+ # `connect.HookTransportError`'s own "no server became reachable" case, and anything else the
162
+ # socket calls or JSON parsing can raise — a send/recv failure, a malformed response, a
163
+ # timeout mid-request. `architecture.md` draws no distinction between these and a plain
164
+ # transport failure.
165
+ return "transport", None
166
+
167
+
168
+ def _degrade(hook_log_path: Path, kind: str, *, code: int | None = None) -> str:
169
+ """Log the exact failure to `hook.log`, and return the model-facing relay instruction for it.
170
+
171
+ The two channels carry different content on purpose: `hook.log`'s line names the machine
172
+ vocabulary (`kind`, `code`) a future grep or an operator reading it directly can act on, while
173
+ the returned instruction is prose aimed at a model that will paraphrase it — it names `kind`
174
+ too, for a curious model to quote back, but its real job is pointing at `hook.log` as the
175
+ place the exact detail lives, since the instruction itself is not the source of truth.
176
+ """
177
+ failure.record_failure(hook_log_path, kind, code=code)
178
+ detail = f"{kind} ({code})" if code is not None else kind
179
+ return (
180
+ "The Zikaron memory hook failed to surface project memories for this turn "
181
+ f"(reason: {detail}). Please let the operator know the memory hook failed, and that "
182
+ "the exact error is recorded in .zikaron/hook.log if they want to look at it directly."
183
+ )
zikaron/hook/rpc.py ADDED
@@ -0,0 +1,85 @@
1
+ """Framing and sending exactly one `surface` request, and reading its one response line.
2
+
3
+ `architecture.md` §RPC: newline-delimited JSON-RPC 2.0. This module is deliberately not a copy of
4
+ `zikaron.service.rpc`'s own framing helpers — that module is stdlib-only too and reuse would be
5
+ free, but its whole surface (`parse_request`, `encode_result`, `encode_error`) is written from the
6
+ *server's* side of one exchange, parsing an incoming request and encoding an outgoing response;
7
+ this client only ever constructs one outgoing request and parses one incoming response, the
8
+ opposite pair, so there is nothing in that module this side would actually call. Mirrors the
9
+ identical hand-rolled shape `zikaron.service.lifecycle`'s own internal `_send_request` uses for
10
+ `health()`, restated here for `surface` rather than imported for the reason `connect.py`'s module
11
+ docstring gives.
12
+ """
13
+
14
+ import json
15
+ import socket
16
+
17
+ from zikaron.hook.envelope import HookEnvelope
18
+
19
+
20
+ class SurfaceRejectionError(Exception):
21
+ """The service answered with a JSON-RPC `error` object rather than a result.
22
+
23
+ Carries the wire fields verbatim so `push.py`'s failure-logging path can name the code
24
+ `architecture.md`'s error table gives — `store_busy`, `bad_config`, `reindexing`,
25
+ `schema_incompatible`, or anything else the service returns — without this module needing to
26
+ know what any of them mean.
27
+ """
28
+
29
+ def __init__(self, code: int, message: str) -> None:
30
+ self.code = code
31
+ self.message = message
32
+ super().__init__(f"{code}: {message}")
33
+
34
+
35
+ def surface_once(sock: socket.socket, *, prompt: str, limit: int, envelope: HookEnvelope) -> str:
36
+ """Send one `memory_surface(prompt, limit)` request and return the text it answered with.
37
+
38
+ A single `sendall`/`recv`-until-newline round trip — this client makes exactly one request per
39
+ process, so there is no held connection for a partial-send ambiguity to matter against: unlike
40
+ `zikaron.mcp.connection`'s own client, which must distinguish "nothing was sent" from "some of
41
+ it was" because it might otherwise wrongly retry a request the service already ran, this
42
+ process is about to exit either way and never retries anything, so `sendall`'s own
43
+ documented inability to report partial progress carries no risk here that a second attempt
44
+ could get wrong.
45
+
46
+ Raises:
47
+ OSError: the socket failed during send or receive.
48
+ ConnectionError: the connection closed before a full line arrived, or the response was
49
+ not a well-formed JSON-RPC envelope.
50
+ SurfaceRejection: the service answered with an `error` object.
51
+ """
52
+ request = {
53
+ "jsonrpc": "2.0",
54
+ "id": 1,
55
+ "method": "memory_surface",
56
+ "params": {
57
+ "prompt": prompt,
58
+ "limit": limit,
59
+ "client": envelope.as_client_object(),
60
+ },
61
+ }
62
+ sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
63
+ buffer = b""
64
+ while not buffer.endswith(b"\n"):
65
+ chunk = sock.recv(4096)
66
+ if not chunk:
67
+ raise ConnectionError("connection closed before a full response was read")
68
+ buffer += chunk
69
+ parsed = json.loads(buffer.decode("utf-8"))
70
+ if not isinstance(parsed, dict):
71
+ raise ConnectionError(f"response was not a JSON object: {parsed!r}")
72
+ if "result" in parsed:
73
+ result = parsed["result"]
74
+ text = result.get("text") if isinstance(result, dict) else None
75
+ if not isinstance(text, str):
76
+ raise ConnectionError(f"surface() did not return {{text}}: {result!r}")
77
+ return text
78
+ error = parsed.get("error")
79
+ if not isinstance(error, dict):
80
+ raise ConnectionError(f"response has neither a result nor an error object: {parsed!r}")
81
+ code = error.get("code")
82
+ message = error.get("message")
83
+ if not isinstance(code, int) or not isinstance(message, str):
84
+ raise ConnectionError(f"error object is missing code/message: {error!r}")
85
+ raise SurfaceRejectionError(code, message)
@@ -0,0 +1,81 @@
1
+ """`agentSpawn`: print the write policy, spawn the detached warm helper, never fail.
2
+
3
+ `architecture.md` §"Warming" is normative: "the hook process prints the write policy — static
4
+ text, no RPC, so it can never fail — and spawns a detached warm helper, then exits." The split
5
+ matters, and the section says why: "the `agentSpawn` hook talks to the service" and "the
6
+ `agentSpawn` path makes no RPC" cannot both describe one process, so this module's own body makes
7
+ no socket call at all — it prints a constant string and asks the OS to start a *different*
8
+ process, `zikaron.hook.warm_helper`, which does the work that can fail.
9
+
10
+ §"Subagent sessions" applies here identically to `push.py`: "When they **differ**, this is a
11
+ subagent session and the hook **prints nothing and exits 0** — for `userPromptSubmit` *and*
12
+ `agentSpawn`."
13
+ """
14
+
15
+ import contextlib
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from zikaron.harness import detect
21
+ from zikaron.hook import connect, envelope, tripwire, write_policy
22
+ from zikaron.service import paths
23
+
24
+
25
+ def run(*, scope_dir: Path, payload_session_id: object) -> str | None:
26
+ """Return the write-policy text to print, or `None` in a subagent session — and, as a side
27
+ effect on the non-subagent path, best-effort spawn the detached warm helper.
28
+
29
+ Never raises: the only real action on the critical path is holding a string constant, and the
30
+ spawn is deliberately best-effort — a `subprocess.Popen` failure here (a missing interpreter,
31
+ an unreadable working directory) is caught and produces no output change at all, since
32
+ `architecture.md` gives the warm helper no observable effect on this hook's own behaviour one
33
+ way or the other. It exists purely to make the *next* `userPromptSubmit` warmer than it would
34
+ otherwise be; failing to start it changes nothing about what `agentSpawn` itself prints.
35
+
36
+ The one part that is not a bare constant is the operator's optional
37
+ `.zikaron/write-policy.md` override (`write_policy.read_policy`), and it is read inside the same
38
+ kind of whole-body guard as the spawn — so the never-fail property survives even a reader that
39
+ raises for a reason its own six documented conditions did not anticipate: this function still
40
+ returns the shipped text.
41
+ """
42
+ spec = detect.current_spec()
43
+ env_session_id = detect.session_label(spec)
44
+ if envelope.is_subagent_session(
45
+ env_session_id=env_session_id, payload_session_id=payload_session_id
46
+ ):
47
+ tripwire.record_if_misdetected(spec=spec, scope_dir=scope_dir)
48
+ return None
49
+ _spawn_warm_helper_best_effort(scope_dir)
50
+ return write_policy.resolved_policy_text(scope_dir, spec=spec)
51
+
52
+
53
+ def _spawn_warm_helper_best_effort(scope_dir: Path) -> None:
54
+ """Best-effort, whole-operation: path derivation and the spawn itself are both allowed to
55
+ fail with no effect on what `run()` returns. Wrapping only the `Popen` call itself in
56
+ `contextlib.suppress(OSError)` would leave path derivation (`resolve_sock_path`'s own
57
+ `Path.resolve()`, which can raise on a symlink loop) unguarded — a failure there would
58
+ propagate straight past this function, past `run()` (which has no `try` of its own), and into
59
+ `main.py`'s own outermost catch-all, which for `agentSpawn` would then suppress the
60
+ **write-policy print itself**, not merely the warm-up this function exists to attempt.
61
+ Catching a genuine `Exception` around the whole body, not a narrower list, is what keeps that
62
+ guarantee true regardless of which step inside it fails.
63
+ """
64
+ with contextlib.suppress(Exception):
65
+ store_dir = paths.store_dir(scope_dir)
66
+ sock_path = connect.resolve_sock_path(store_dir)
67
+ command = [
68
+ sys.executable,
69
+ "-m",
70
+ "zikaron.hook.warm_helper",
71
+ str(sock_path),
72
+ str(store_dir),
73
+ ]
74
+ subprocess.Popen( # noqa: S603 — a fixed argv this process itself constructed, not
75
+ # built from request or payload data.
76
+ command,
77
+ start_new_session=True,
78
+ stdin=subprocess.DEVNULL,
79
+ stdout=subprocess.DEVNULL,
80
+ stderr=subprocess.DEVNULL,
81
+ )
@@ -0,0 +1,57 @@
1
+ """`SubagentStart`: deliver the write policy to a subagent, and nothing else.
2
+
3
+ `design/harness.md` §Subagents is normative. The spawn trigger fires once per session, so a subagent
4
+ would otherwise inherit Zikaron's tools having never seen the write policy at all. This path exists
5
+ to close that gap and does nothing more.
6
+
7
+ **Policy-only, stated because each omission is deliberate.** It does not spawn the warm helper — the
8
+ session's own spawn trigger already did, and a second spawn per subagent is pure cost against
9
+ nothing. It makes no RPC and opens no socket. It does not consult the session comparison the spawn
10
+ and prompt paths make: a subagent is identified here by the agent identity its own payload carries,
11
+ which is the exact mechanism the older, session-comparing rule existed to approximate, and the
12
+ relationship between a subagent payload's session id and the environment's is not something this
13
+ path needs to assume.
14
+
15
+ **The exclusion is exact, which is the whole reason this trigger is worth having.** The other
16
+ harness suppresses *every* subagent because its payload carries no agent identity to discriminate
17
+ on. Here the payload names the agent type, so the policy goes to every subagent except the
18
+ consolidator — whose policy is its own system prompt, and which must not be handed the primary
19
+ agent's write instructions on top of it.
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ from zikaron.harness import detect
25
+ from zikaron.hook import write_policy
26
+
27
+ #: The agent type whose own system prompt already carries its instructions, and the one subagent
28
+ #: this path stays silent for.
29
+ #:
30
+ #: A guarded copy rather than an import: the canonical declaration lives with the installer that
31
+ #: writes the agent config, and importing that module would pull the whole installer onto a
32
+ #: critical path that is measured in milliseconds. A test asserts the two agree, which is the same
33
+ #: arrangement the request envelope's own copy uses — the test pays the import cost, the hook does
34
+ #: not.
35
+ CONSOLIDATOR_AGENT_TYPE = "zikaron-consolidator"
36
+
37
+
38
+ def run(*, scope_dir: Path, agent_type: object) -> str | None:
39
+ """Return the write-policy text to deliver to this subagent, or `None` for the consolidator.
40
+
41
+ `agent_type` is typed `object` because it arrives as untrusted JSON from the harness's own
42
+ stdin delivery. Anything that is not exactly the consolidator's type — a missing field, a
43
+ non-string, an agent type this build has never heard of — receives the policy, which is the
44
+ safe direction to be wrong in: an agent that holds the memory write verbs and has not been
45
+ told the write policy is the failure this path exists to prevent, while an agent that receives
46
+ a policy it did not need has merely read a few thousand characters.
47
+
48
+ Never raises, for the same reason every other hook path does not: the caller's contract is to
49
+ exit 0 having printed something or nothing, and `resolved_policy_text` absorbs any failure
50
+ beneath it and falls back to the shipped constant.
51
+ """
52
+ if agent_type == CONSOLIDATOR_AGENT_TYPE:
53
+ return None
54
+ # Resolved through the seam rather than assumed to be the one harness that sends this trigger:
55
+ # a path that hard-coded a harness here would be the forked code path the single-implementation
56
+ # rule exists to prevent, however unreachable the other branch looks today.
57
+ return write_policy.resolved_policy_text(scope_dir, spec=detect.current_spec())
@@ -0,0 +1,54 @@
1
+ """The one signal that this process misdetected its harness.
2
+
3
+ A process tree running under an enclosing session of a marked harness inherits that harness's
4
+ marker and its session variable. The root failure is **misdetection** — the wrong channel table and
5
+ the wrong injection budget follow from it, not only a stale session label — and link coverage
6
+ cannot see it: both clients read the environment by construction, so both read the same stale
7
+ value, they agree, and coverage reads as healthy while the inner session's work is attributed to
8
+ the outer, live one.
9
+
10
+ **Why the scoping is load-bearing rather than tidy.** The naive rule — log whenever the payload's
11
+ session id differs from the environment's — is the *identical* predicate as
12
+ `envelope.is_subagent_session`. Under a harness that fires its ordinary hooks for subagent sessions,
13
+ that divergence is the routine, expected case, so an unscoped tripwire would fire on every subagent
14
+ turn: it would drown the signal and pollute a log whose whole value is a closed vocabulary of
15
+ failure kinds. So the tripwire fires only under a harness where payload and environment are
16
+ invariantly equal, and that property travels as a field on the harness spec rather than as an
17
+ identity test against a particular harness here.
18
+
19
+ **On that divergence the hook prints nothing and makes no RPC**, which the callers own and this
20
+ module does not. Making no call is what delivers the reasoning, not merely printing nothing: a push
21
+ resolved under a stale label would be injected into — and counted against — a different, live
22
+ session's instrument stream, and the surfacing event is emitted by the service, so a call made but
23
+ not printed would corrupt the instrument anyway. A lost push is recoverable through pull, the arm
24
+ that depends on no hook at all.
25
+ """
26
+
27
+ import contextlib
28
+ from pathlib import Path
29
+
30
+ from zikaron.harness.spec import HarnessSpec
31
+ from zikaron.hook import failure
32
+ from zikaron.service import paths
33
+
34
+ #: The one log kind this module writes. Named as its own fixed label so `hook.log`'s vocabulary
35
+ #: stays closed — every line in that file is a fixed kind and an optional wire error code, never
36
+ #: prompt or memory content.
37
+ SESSION_ENV_MISMATCH = "session_env_mismatch"
38
+
39
+
40
+ def record_if_misdetected(*, spec: HarnessSpec, scope_dir: Path) -> None:
41
+ """Write one `session_env_mismatch` line, if this harness is one where the divergence the
42
+ caller just observed means a misdetection rather than a subagent.
43
+
44
+ Called on the suppression path of a hook that has already decided to print nothing, so it must
45
+ never raise: the caller's guarantee is that it exits quietly, and a tripwire that turned a
46
+ silent suppression into a crash would cost more than the signal it buys. Best-effort as a
47
+ whole rather than per-step, so a failure while deriving the log path is swallowed exactly like
48
+ a failure while writing to it.
49
+ """
50
+ if spec.fires_hooks_for_subagent_sessions:
51
+ return
52
+ with contextlib.suppress(Exception):
53
+ store_dir = paths.store_dir(scope_dir)
54
+ failure.record_failure(paths.hook_log_path(store_dir), SESSION_ENV_MISMATCH)