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/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
@@ -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]
@@ -0,0 +1 @@
1
+ """The hook client: push injection and policy delivery, on a hard interpreter-startup budget."""