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
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())
|
zikaron/hook/tripwire.py
ADDED
|
@@ -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)
|