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/doctor/main.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`zikaron doctor` — run the checks, print them, and exit non-zero if any failed.
|
|
2
|
+
|
|
3
|
+
Everything goes to standard output, remedies included: the whole report is what a person asked for,
|
|
4
|
+
and splitting it across two streams interleaves unpredictably the moment it is piped. The exit
|
|
5
|
+
status, not the stream, is what a script reads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from zikaron.doctor.checks import Finding, Outcome, run_all
|
|
16
|
+
from zikaron.harness import detect
|
|
17
|
+
|
|
18
|
+
#: Unlike `install` and `knowledge`, this command has no `python -m` form: it is new with the
|
|
19
|
+
#: umbrella, so there is no documented invocation predating it to keep working, and a package
|
|
20
|
+
#: needs a `__main__.py` to have one at all.
|
|
21
|
+
_DEFAULT_PROG: Final = "zikaron doctor"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parser(prog: str) -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog=prog,
|
|
27
|
+
description=(
|
|
28
|
+
"Check that this machine can run Zikaron, naming a remedy for anything it cannot."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--project",
|
|
33
|
+
type=Path,
|
|
34
|
+
default=None,
|
|
35
|
+
help="the project whose socket path to check. The default is the harness's own project "
|
|
36
|
+
"directory where it names one, else the current directory.",
|
|
37
|
+
)
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rendered(findings: Sequence[Finding]) -> list[str]:
|
|
42
|
+
"""The report as lines. Separate from printing them, so a test reads the report itself."""
|
|
43
|
+
width = max(len(finding.name) for finding in findings)
|
|
44
|
+
lines = []
|
|
45
|
+
for finding in findings:
|
|
46
|
+
lines.append(f"{finding.outcome.value:<4} {finding.name:<{width}} {finding.detail}")
|
|
47
|
+
if finding.remedy is not None:
|
|
48
|
+
lines.append(f"{'':<4} {'':<{width}} remedy: {finding.remedy}")
|
|
49
|
+
return lines
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: Sequence[str] | None = None, *, prog: str = _DEFAULT_PROG) -> int:
|
|
53
|
+
"""Report on this machine. Returns 1 if any check failed, 0 otherwise.
|
|
54
|
+
|
|
55
|
+
An absent model cache is not a failure: there is no install-time prefetch, so the first run of
|
|
56
|
+
this command on a new machine finds none, and a red first run would be reporting the design
|
|
57
|
+
rather than a problem.
|
|
58
|
+
"""
|
|
59
|
+
args = _parser(prog).parse_args(sys.argv[1:] if argv is None else argv)
|
|
60
|
+
store_dir = args.project or detect.current_spec().store_scope_dir(Path.cwd())
|
|
61
|
+
findings = run_all(store_dir=store_dir, environ=os.environ, platform=sys.platform)
|
|
62
|
+
for line in rendered(findings):
|
|
63
|
+
print(line)
|
|
64
|
+
return 1 if any(finding.outcome is Outcome.FAILED for finding in findings) else 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The one seam where the two supported harnesses differ, carried as data rather than branches."""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Which harness is running this process, and what its session label is.
|
|
2
|
+
|
|
3
|
+
The two questions both thin clients ask before anything else. `design/harness.md` §"Detection,
|
|
4
|
+
session identity, and the nesting limit" is normative.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from zikaron.harness.spec import SPECS, Harness, HarnessSpec
|
|
11
|
+
|
|
12
|
+
#: The namespace the service mints its own labels in. A client that finds a value in this namespace
|
|
13
|
+
#: in a harness's session variable must treat it as absent and send the bootstrap form, because the
|
|
14
|
+
#: prefix is how the service tells its own labels from a harness's — a distinction that is only
|
|
15
|
+
#: truthful if every client honours it rather than forwarding whatever the environment holds.
|
|
16
|
+
#: Applied to **whichever** harness's variable wins, so no harness can claim a minted label under
|
|
17
|
+
#: either name.
|
|
18
|
+
_MINTED_PREFIX = "zk-"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fallback_harness() -> Harness:
|
|
22
|
+
"""The harness detected by the *absence* of every marker.
|
|
23
|
+
|
|
24
|
+
Derived from the table rather than named here, so the fallback cannot silently disagree with
|
|
25
|
+
the spec that declares itself unmarked. Exactly one spec may be unmarked: none would make
|
|
26
|
+
detection undefined when no marker is set, and two would make it ambiguous.
|
|
27
|
+
"""
|
|
28
|
+
unmarked = [spec.harness for spec in SPECS.values() if spec.marker_variable is None]
|
|
29
|
+
if len(unmarked) != 1:
|
|
30
|
+
message = f"exactly one harness must be detected by absence of a marker, found {unmarked}"
|
|
31
|
+
raise ValueError(message)
|
|
32
|
+
return unmarked[0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
FALLBACK: Final = _fallback_harness()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current_harness() -> Harness:
|
|
39
|
+
"""Which harness spawned this process, by marker variable and nothing cleverer.
|
|
40
|
+
|
|
41
|
+
**A marker counts as present only when it is set to a non-empty value**, which is the same
|
|
42
|
+
reading `session_label` gives an empty session variable: set-to-nothing is not set. An empty
|
|
43
|
+
value is no evidence that this harness spawned anything, and detection is the one decision every
|
|
44
|
+
other harness-varying value hangs off — a misdetection applies the wrong channel table and the
|
|
45
|
+
wrong injection budget as well as the wrong session variable — so it takes the conservative
|
|
46
|
+
reading.
|
|
47
|
+
|
|
48
|
+
The asymmetry that does remain is narrower than emptiness: a *non-empty* session value is
|
|
49
|
+
forwarded as the label it claims to be, checked only against the service's reserved namespace,
|
|
50
|
+
while a marker is never read for more than its presence.
|
|
51
|
+
|
|
52
|
+
When several markers are somehow set at once the first in declaration order wins. That cannot
|
|
53
|
+
arise while only one harness is marked; it is stated so the outcome is deterministic rather
|
|
54
|
+
than incidental.
|
|
55
|
+
|
|
56
|
+
**A process tree running under an enclosing session of a marked harness inherits that marker
|
|
57
|
+
and is misdetected.** The known remedy, should it ever bite, needs no new mechanism: the hook
|
|
58
|
+
already holds its payload's own `session_id`, so it can select whichever harness's variable
|
|
59
|
+
actually equals it and detect by agreement instead. Recorded here so it does not have to be
|
|
60
|
+
rediscovered.
|
|
61
|
+
"""
|
|
62
|
+
for spec in SPECS.values():
|
|
63
|
+
if spec.marker_variable is not None and os.environ.get(spec.marker_variable):
|
|
64
|
+
return spec.harness
|
|
65
|
+
return FALLBACK
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def current_spec() -> HarnessSpec:
|
|
69
|
+
"""The full table row for the harness running this process."""
|
|
70
|
+
return SPECS[current_harness()]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def session_label(spec: HarnessSpec) -> str | None:
|
|
74
|
+
"""The `harness` rung of the two-rung session-label ladder: this harness's own session variable,
|
|
75
|
+
or `None` when it is absent, **empty**, or intrudes on the service's reserved minted namespace.
|
|
76
|
+
|
|
77
|
+
`None` means the bootstrap form — send `session_id: null` and adopt the label the service
|
|
78
|
+
mints. Both clients resolve through here, so a hook and an MCP server in one session speak as
|
|
79
|
+
one session under either harness, which is what keeps linked sessions, link coverage, the two
|
|
80
|
+
cross-client signals and the per-session recall instrument computable.
|
|
81
|
+
|
|
82
|
+
**An empty value is absence, matching the service's own definition of a label** rather than
|
|
83
|
+
being forwarded for the service to reject. The two must agree, because a client-side consumer
|
|
84
|
+
acts on this value before any request is made: the subagent-suppression check compares it to
|
|
85
|
+
the payload's session id, so an exported-but-empty variable would differ from every real
|
|
86
|
+
payload id and silently suppress every top-level push — no output, and under the harness that
|
|
87
|
+
logs divergence, one tripwire line per turn for a store that is working perfectly.
|
|
88
|
+
"""
|
|
89
|
+
value = os.environ.get(spec.session_variable)
|
|
90
|
+
if not value or value.startswith(_MINTED_PREFIX):
|
|
91
|
+
return None
|
|
92
|
+
return value
|
zikaron/harness/spec.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""`design/harness.md` §"The table", as data.
|
|
2
|
+
|
|
3
|
+
One `HarnessSpec` per supported harness, and every harness-varying value either a field on it or
|
|
4
|
+
derived from those fields. A test reads the design document itself and asserts this table against
|
|
5
|
+
it, so an edit on either side fails rather than drifting — the arrangement `coding-standards.md` §2
|
|
6
|
+
requires for anything the design states as a table.
|
|
7
|
+
|
|
8
|
+
**The trigger vocabulary is derived from the specs rather than written a second time.** Kiro's array
|
|
9
|
+
hook format accepts PascalCase trigger names generally, and `SessionStart` as an alias for
|
|
10
|
+
`agentSpawn`, so the two harnesses' names are one vocabulary with synonyms rather than two
|
|
11
|
+
languages. Building the map as the union of both specs' own trigger fields makes that structural: a
|
|
12
|
+
name either is some harness's stated trigger or is not a trigger at all, and there is no third place
|
|
13
|
+
for the two to disagree.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import enum
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Final, NamedTuple
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Harness(enum.Enum):
|
|
23
|
+
"""The supported harnesses. A closed set: detection returns one of these and never `None`,
|
|
24
|
+
since kiro is the fallback rather than a third "unknown" state.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
KIRO = "kiro"
|
|
28
|
+
CLAUDE_CODE = "claude-code"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HookEvent(enum.Enum):
|
|
32
|
+
"""What a hook invocation *means*, independent of which harness's trigger name delivered it.
|
|
33
|
+
|
|
34
|
+
Trigger names normalize many-to-three. `SPAWN` and `PROMPT` exist under both harnesses;
|
|
35
|
+
`SUBAGENT_START` arrives only under Claude Code, whose payload carries the `agent_type` that
|
|
36
|
+
makes a per-agent rule expressible at all.
|
|
37
|
+
|
|
38
|
+
`SubagentStop` is deliberately absent. It has no work to do: the only path that wanted it
|
|
39
|
+
existed to record which concrete model a consolidation run used, and that is unnecessary
|
|
40
|
+
because the harness refuses an unknown model id at spawn, so the configured id can simply be
|
|
41
|
+
trusted. Its absence is what keeps the `event` log's own rule — pushes come from the hook,
|
|
42
|
+
writes from the MCP client — true, since the hook has no write path at all.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
SPAWN = "spawn"
|
|
46
|
+
PROMPT = "prompt"
|
|
47
|
+
SUBAGENT_START = "subagent_start"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OutputChannel(enum.Enum):
|
|
51
|
+
"""How a hook's output reaches a model. **The two are not interchangeable per event**, which is
|
|
52
|
+
measured rather than assumed: plain exit-0 stdout from a `SubagentStart` hook reaches nobody —
|
|
53
|
+
not the subagent, not the parent — while the same text under
|
|
54
|
+
`hookSpecificOutput.additionalContext` reaches the subagent verbatim and stays invisible to the
|
|
55
|
+
parent, which is the isolation write-policy delivery wants.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
STDOUT = "stdout"
|
|
59
|
+
ADDITIONAL_CONTEXT = "additional_context"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BudgetUnit(enum.Enum):
|
|
63
|
+
"""What an injection budget counts. The distinction is load-bearing and was pinned by
|
|
64
|
+
experiment rather than inferred: an ASCII bisection cannot tell bytes from characters, and
|
|
65
|
+
~9,000 characters of a 3-byte-per-character script plus its ASCII markers — 27,016 bytes —
|
|
66
|
+
arrived whole under a 10,000-*character* cap.
|
|
67
|
+
|
|
68
|
+
**Which kind of character is now measured: UTF-16 code units**
|
|
69
|
+
(`research/claude-code-dogfood-checkpoint.md` §3). The earlier experiment could not settle it,
|
|
70
|
+
because the text it used lies in the Basic Multilingual Plane, where one code point is also
|
|
71
|
+
exactly one UTF-16 code unit. The astral rerun separates all three candidates: 6,000 astral code
|
|
72
|
+
points — 12,000 UTF-16 units — **truncate** under the 10,000 cap, while 4,600 (9,200 units)
|
|
73
|
+
arrive whole. So a `len()`-based count is **wrong**, not merely less conservative: it would pass
|
|
74
|
+
a block the harness then truncates.
|
|
75
|
+
|
|
76
|
+
**The member is still named `CHARACTERS`, and that name is now known to be the ambiguous word.**
|
|
77
|
+
Renaming it is the honest fix and was deliberately not done inside a checkpoint milestone; note
|
|
78
|
+
that `tests/test_harness_table.py` parses the design table's budget cell for one number and one
|
|
79
|
+
unit word, so a unit whose name contains a digit needs that parser taught first.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
BYTES = "bytes"
|
|
83
|
+
CHARACTERS = "characters"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class HarnessSpec(NamedTuple):
|
|
87
|
+
"""Everything that varies between harnesses, for one harness.
|
|
88
|
+
|
|
89
|
+
A `NamedTuple` rather than a frozen dataclass purely for import cost (see the package
|
|
90
|
+
docstring); it is immutable and typed either way.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
harness: Harness
|
|
94
|
+
marker_variable: str | None
|
|
95
|
+
session_variable: str
|
|
96
|
+
spawn_trigger: str
|
|
97
|
+
prompt_trigger: str
|
|
98
|
+
subagent_start_trigger: str | None
|
|
99
|
+
fires_hooks_for_subagent_sessions: bool
|
|
100
|
+
injection_budget: int
|
|
101
|
+
budget_unit: BudgetUnit
|
|
102
|
+
consolidator_model: str
|
|
103
|
+
harness_binary: str
|
|
104
|
+
#: The variable naming the directory this harness considers "the project", or `None` where the
|
|
105
|
+
#: harness exports none. Read by `store_scope_dir` below; see its docstring for why.
|
|
106
|
+
project_dir_variable: str | None
|
|
107
|
+
#: Whether this harness's consolidator subagent can be granted a tool that reads a file, and
|
|
108
|
+
#: therefore whether an over-large tool result may be written to one instead of returned.
|
|
109
|
+
#:
|
|
110
|
+
#: One field for both, deliberately, because they are one capability and the mismatches are
|
|
111
|
+
#: what hurt: granting the tool without spilling widens the consolidator's reach for nothing,
|
|
112
|
+
#: and spilling without granting it hands the model a path it cannot open, which is the same
|
|
113
|
+
#: stall this was built to end. Where this is false the client returns every payload inline,
|
|
114
|
+
#: the consolidator config gains no tool, and its prompt gains no text about files.
|
|
115
|
+
consolidator_can_read_files: bool
|
|
116
|
+
|
|
117
|
+
def exceeds_injection_budget(self, text: str) -> bool:
|
|
118
|
+
"""Whether `text` is larger than this harness will actually inject.
|
|
119
|
+
|
|
120
|
+
Measures in **this harness's own unit**. Comparing a byte length against a
|
|
121
|
+
character-denominated cap under-reports by up to the encoding's expansion factor, and
|
|
122
|
+
comparing a character length against a byte-denominated cap over-reports by the same — so
|
|
123
|
+
the unit travels with the number rather than being assumed by the caller.
|
|
124
|
+
|
|
125
|
+
Characters are counted as **UTF-16 code units**, not as `len(text)`. The two agree for
|
|
126
|
+
every character in the Basic Multilingual Plane and differ by a factor of two for astral
|
|
127
|
+
ones — and UTF-16 units are what a character-denominated harness was **measured** to count
|
|
128
|
+
(`research/claude-code-dogfood-checkpoint.md` §3; see `BudgetUnit`). This is therefore the
|
|
129
|
+
correct count rather than a conservative one: `len(text)` would pass 6,000 astral characters
|
|
130
|
+
against a 10,000 cap that truncates them.
|
|
131
|
+
|
|
132
|
+
`surrogatepass` on both branches keeps this total. A lone surrogate is legal in a Python
|
|
133
|
+
string decoded from JSON and would otherwise raise out of a bound check whose callers treat
|
|
134
|
+
it as a plain predicate.
|
|
135
|
+
"""
|
|
136
|
+
if self.budget_unit is BudgetUnit.BYTES:
|
|
137
|
+
measured = len(text.encode("utf-8", errors="surrogatepass"))
|
|
138
|
+
else:
|
|
139
|
+
measured = len(text.encode("utf-16-le", errors="surrogatepass")) // 2
|
|
140
|
+
return measured > self.injection_budget
|
|
141
|
+
|
|
142
|
+
def store_scope_dir(self, fallback: Path) -> Path:
|
|
143
|
+
"""The directory D17 scopes a store to — **the one function both clients must call**.
|
|
144
|
+
|
|
145
|
+
The defect this exists to end was two implementations of one decision. `zikaron-hook` keyed
|
|
146
|
+
the store on the harness-supplied payload `cwd`, which under Claude Code follows the
|
|
147
|
+
agent's own `cd`; `zikaron-mcp` keyed it on `Path.cwd()` of a process spawned once at
|
|
148
|
+
session start, which never moves. They agreed only while nobody changed directory, and
|
|
149
|
+
diverged silently when anyone did: push read a freshly-created empty store while pull kept
|
|
150
|
+
answering from the real one, and nothing anywhere said so. Measured on one live session:
|
|
151
|
+
**39 cwd transitions**, a store created under a log directory, and 20 pushes in another
|
|
152
|
+
session returning nothing across two hours
|
|
153
|
+
(`research/claude-code-dogfood-checkpoint.md` §"Store scoping").
|
|
154
|
+
|
|
155
|
+
Two rungs, deliberately the same shape as the session-label ladder: read this harness's own
|
|
156
|
+
project variable, and fall back to `fallback` when the harness exports none or the value is
|
|
157
|
+
unusable. Claude Code's variable is measured present in **both** clients' processes — the
|
|
158
|
+
hook's, and all six MCP server starts in `spikes/claude-code-harness/mcp.log` — which is
|
|
159
|
+
what the agreement property actually rests on, since one client reading it and the other
|
|
160
|
+
not would reproduce the split this exists to close.
|
|
161
|
+
|
|
162
|
+
**The fallback is not a degraded mode for kiro** — kiro exports no such variable (measured:
|
|
163
|
+
17 `KIRO_*` names across 42 probe records, none spatial) and appears not to need one,
|
|
164
|
+
because its shell restores the working directory rather than persisting it. That last
|
|
165
|
+
clause is an operator observation plus twelve days and ~8,000 events producing no stray
|
|
166
|
+
store; it is **not measured**, and `tests/test_harness_store_scope.py` pins the dependency
|
|
167
|
+
at the point where it would have to change. Kiro's rung is exactly its behaviour before this
|
|
168
|
+
function existed.
|
|
169
|
+
|
|
170
|
+
**Refusing a value is narrower than it sounds, and the nesting case is not covered by it.**
|
|
171
|
+
A value that does not name an existing absolute directory is refused in favour of
|
|
172
|
+
`fallback`. But the case `design/harness.md` §"The nesting limit" documents — a process
|
|
173
|
+
tree inheriting an enclosing Claude Code session's variables — inherits a directory that
|
|
174
|
+
*does* exist, so `is_dir()` passes and **this function adopts it**. That is a regression
|
|
175
|
+
this change introduces, stated rather than hidden: before it, a nested kiro session's MCP
|
|
176
|
+
client keyed the correct *inner* store through `Path.cwd()`; now, having misdetected the
|
|
177
|
+
harness from the inherited marker, it reads and writes the **enclosing project's** store —
|
|
178
|
+
its `remember` landing in the outer store and its `search` answering from the outer
|
|
179
|
+
project's lore. The hook side is partly guarded by the misdetection tripwire; the MCP write
|
|
180
|
+
path is not. The root cause is misdetection, and the remedy `detect.py` records
|
|
181
|
+
repairs the **hook only** — it turns on the hook's own payload `session_id`, which an MCP
|
|
182
|
+
client does not have — so the MCP half has a named cost and no named repair. This
|
|
183
|
+
function is deliberately not the place to invent one.
|
|
184
|
+
|
|
185
|
+
**When a value *is* refused, the two clients diverge again**, because they fall back to
|
|
186
|
+
different inputs — the hook to the harness's wandering payload `cwd`, the MCP client to its
|
|
187
|
+
fixed spawn cwd. So the refusal is safe for the store's *location* and not for the two
|
|
188
|
+
clients' *agreement*, in exactly the pathological case (a deleted project directory, a
|
|
189
|
+
container boundary) where nobody is watching.
|
|
190
|
+
"""
|
|
191
|
+
if self.project_dir_variable is None:
|
|
192
|
+
return fallback
|
|
193
|
+
named = os.environ.get(self.project_dir_variable)
|
|
194
|
+
if not named:
|
|
195
|
+
return fallback
|
|
196
|
+
candidate = Path(named)
|
|
197
|
+
# Absolute as well as existing: a *relative* value would be resolved against each
|
|
198
|
+
# client's own process cwd, which is precisely the pair of different directories this
|
|
199
|
+
# function exists to collapse. The harness sets an absolute path; this costs one call.
|
|
200
|
+
return candidate if candidate.is_absolute() and candidate.is_dir() else fallback
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#: Kiro states `max_output_size` explicitly in every object-format hook entry, so the budget here is
|
|
204
|
+
#: the value the installer writes rather than the harness's own 10240-byte default.
|
|
205
|
+
KIRO: Final = HarnessSpec(
|
|
206
|
+
harness=Harness.KIRO,
|
|
207
|
+
marker_variable=None,
|
|
208
|
+
session_variable="KIRO_SESSION_ID",
|
|
209
|
+
spawn_trigger="agentSpawn",
|
|
210
|
+
prompt_trigger="userPromptSubmit",
|
|
211
|
+
subagent_start_trigger=None,
|
|
212
|
+
# Kiro has no subagent trigger because it fires the ordinary hooks *for* a subagent session
|
|
213
|
+
# instead. So a payload whose `session_id` differs from the environment's is the routine,
|
|
214
|
+
# expected subagent case here — which is exactly why the tripwire that reads the same
|
|
215
|
+
# divergence as an anomaly must not fire under this harness.
|
|
216
|
+
fires_hooks_for_subagent_sessions=True,
|
|
217
|
+
injection_budget=65_536,
|
|
218
|
+
budget_unit=BudgetUnit.BYTES,
|
|
219
|
+
# Pinned to a concrete id, and it has to be: this harness substitutes its own default for an
|
|
220
|
+
# id it does not recognise, *silently*, so the installer validates membership against
|
|
221
|
+
# `chat --list-models` — a check an alias would fail, since that command lists ids.
|
|
222
|
+
consolidator_model="claude-sonnet-5",
|
|
223
|
+
harness_binary="kiro-cli",
|
|
224
|
+
# Measured, not assumed: the lifecycle probe captured every `KIRO_*` variable across 42
|
|
225
|
+
# records and none names a workspace or project directory.
|
|
226
|
+
project_dir_variable=None,
|
|
227
|
+
# What this harness does with an over-large MCP result is **unmeasured**. Rather than invent a
|
|
228
|
+
# remedy for behaviour nobody has observed, every payload is returned inline here exactly as
|
|
229
|
+
# before, and the consolidator keeps the four verbs and nothing else.
|
|
230
|
+
consolidator_can_read_files=False,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
#: Claude Code's budget is fixed: there is no configuration field to raise it, so unlike kiro's this
|
|
234
|
+
#: number is the harness's own and not something an installer chose.
|
|
235
|
+
CLAUDE_CODE: Final = HarnessSpec(
|
|
236
|
+
harness=Harness.CLAUDE_CODE,
|
|
237
|
+
marker_variable="CLAUDECODE",
|
|
238
|
+
session_variable="CLAUDE_CODE_SESSION_ID",
|
|
239
|
+
spawn_trigger="SessionStart",
|
|
240
|
+
prompt_trigger="UserPromptSubmit",
|
|
241
|
+
subagent_start_trigger="SubagentStart",
|
|
242
|
+
# `UserPromptSubmit` does not fire for subagents at all here; a subagent is reached through its
|
|
243
|
+
# own trigger, whose payload carries the agent identity kiro's cannot express. Payload and
|
|
244
|
+
# environment session ids are therefore invariantly equal in any hook this harness fires, which
|
|
245
|
+
# is what makes a divergence meaningful enough to log.
|
|
246
|
+
fires_hooks_for_subagent_sessions=False,
|
|
247
|
+
injection_budget=10_000,
|
|
248
|
+
budget_unit=BudgetUnit.CHARACTERS,
|
|
249
|
+
# An alias, and safely so: this harness refuses an unknown id loudly at spawn, so both rules
|
|
250
|
+
# `architecture.md` §"The consolidator's model" states are satisfied — the field is present
|
|
251
|
+
# explicitly, and the harness serves exactly what was asked for. A *pinned* default would rot
|
|
252
|
+
# instead: an install a year from now would ship last year's id, and once that id retires the
|
|
253
|
+
# consolidator fails at spawn. Experiments pin; the shipped default does not have to.
|
|
254
|
+
consolidator_model="sonnet",
|
|
255
|
+
harness_binary="claude",
|
|
256
|
+
project_dir_variable="CLAUDE_PROJECT_DIR",
|
|
257
|
+
# Measured: an over-large result is replaced wholesale by an error notice, and the harness's
|
|
258
|
+
# own spill file is one line of JSON that `Read` cannot paginate. A file this project writes
|
|
259
|
+
# can be paginated, so the capability is real — `research/claude-code-mcp-result-truncation.md`.
|
|
260
|
+
consolidator_can_read_files=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
SPECS: Final[dict[Harness, HarnessSpec]] = {
|
|
264
|
+
Harness.KIRO: KIRO,
|
|
265
|
+
Harness.CLAUDE_CODE: CLAUDE_CODE,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _trigger_vocabulary() -> dict[str, HookEvent]:
|
|
270
|
+
"""Every trigger name any supported harness sends, mapped to what it means.
|
|
271
|
+
|
|
272
|
+
Derived from the specs so the two cannot disagree — see the module docstring. A name claimed by
|
|
273
|
+
two harnesses for *different* events would be a genuine ambiguity rather than a synonym, so it
|
|
274
|
+
raises here at import rather than resolving silently to whichever spec was declared last.
|
|
275
|
+
"""
|
|
276
|
+
vocabulary: dict[str, HookEvent] = {}
|
|
277
|
+
for spec in SPECS.values():
|
|
278
|
+
named = (
|
|
279
|
+
(spec.spawn_trigger, HookEvent.SPAWN),
|
|
280
|
+
(spec.prompt_trigger, HookEvent.PROMPT),
|
|
281
|
+
(spec.subagent_start_trigger, HookEvent.SUBAGENT_START),
|
|
282
|
+
)
|
|
283
|
+
for trigger, event in named:
|
|
284
|
+
if trigger is None:
|
|
285
|
+
continue
|
|
286
|
+
if vocabulary.get(trigger, event) is not event:
|
|
287
|
+
message = f"trigger {trigger!r} means two different events"
|
|
288
|
+
raise ValueError(message)
|
|
289
|
+
vocabulary[trigger] = event
|
|
290
|
+
return vocabulary
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
TRIGGERS: Final[dict[str, HookEvent]] = _trigger_vocabulary()
|
|
294
|
+
|
|
295
|
+
#: Which channel each event's output must go out on. A function of the event alone, not of the
|
|
296
|
+
#: harness: the only harness variation is that kiro never produces `SUBAGENT_START` at all, so a
|
|
297
|
+
#: per-harness column here would carry one unreachable cell and no information.
|
|
298
|
+
CHANNELS: Final[dict[HookEvent, OutputChannel]] = {
|
|
299
|
+
HookEvent.SPAWN: OutputChannel.STDOUT,
|
|
300
|
+
HookEvent.PROMPT: OutputChannel.STDOUT,
|
|
301
|
+
HookEvent.SUBAGENT_START: OutputChannel.ADDITIONAL_CONTEXT,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def event_for(trigger: object) -> HookEvent | None:
|
|
306
|
+
"""What `trigger` means, or `None` if no supported harness sends that name.
|
|
307
|
+
|
|
308
|
+
`trigger` is typed `object` because it arrives as untrusted JSON from a harness's own stdin
|
|
309
|
+
delivery: a value that is absent, or present but not a string, is simply not a trigger this
|
|
310
|
+
hook implements, which is the same answer as an unrecognised name and not a failure of
|
|
311
|
+
anything this module owns.
|
|
312
|
+
"""
|
|
313
|
+
if not isinstance(trigger, str):
|
|
314
|
+
return None
|
|
315
|
+
return TRIGGERS.get(trigger)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def channel_for(event: HookEvent) -> OutputChannel:
|
|
319
|
+
"""The output channel `event`'s text must be written on."""
|
|
320
|
+
return CHANNELS[event]
|
zikaron/hook/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The hook client: push injection and policy delivery, on a hard interpreter-startup budget."""
|