zikaron 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. zikaron-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,306 @@
1
+ """The `consolidation_run` aggregate: one effectively-active run per store, and its lease.
2
+
3
+ `schema.md` invariants 15 and 17 and `architecture.md` §"Consolidation lifecycle" are normative. Two
4
+ rules run through everything here:
5
+
6
+ **"Effectively active" is the only run test anywhere in the system** — `status='active' AND
7
+ expires_at ≥ now`. A stored `'active'` row past its lease constrains nobody, *including the session
8
+ that owns it*, and that second half is not a nicety: `next_group` replans only when the caller has
9
+ no active run, so an owner whose lease lapsed would otherwise find its own run, be served a group
10
+ from it, and be rejected `group_expired` forever — with no way out, since `plan_groups` is a service
11
+ RPC and not one of the consolidator's four tools.
12
+
13
+ **Expiry is derived on the read side and stored only at plan time.** Every reader computes it from
14
+ `expires_at`; only `plan_groups` writes `status='expired'`. The alternative — having whichever call
15
+ noticed a lapsed lease perform the transition — would make a *rejected* call write
16
+ `consolidation_run.status`, which `architecture.md` §"What a rejected call does and does not change"
17
+ forbids. The observable consequence is that a store can hold an `active` row whose lease has passed;
18
+ that is a lazily-collected tombstone, not drift, because every reader already computes the same
19
+ answer from the column beside it.
20
+
21
+ **The lease arithmetic lives here, with the lease.** It **parses** rather than comparing strings —
22
+ not because string order is untrustworthy (`core.clock` states and pins the opposite: lexicographic
23
+ order on these strings agrees with temporal order) but because a lease is a start plus a duration,
24
+ and adding seconds is not something ordering can do for you. Parsing then makes the comparison that
25
+ follows independent of the format as well, which is the cheaper half of a step taken for another
26
+ reason. Every timestamp this module stores is either a `core.clock.timestamp` reading or
27
+ `lease_expiry`'s parse-add-`isoformat()` of one, and that derivation preserves the format — so
28
+ there is still one.
29
+ """
30
+
31
+ from collections.abc import Sequence
32
+ from dataclasses import dataclass
33
+ from datetime import datetime, timedelta
34
+ from enum import StrEnum
35
+ from typing import Final
36
+ from uuid import uuid4
37
+
38
+ import aiosqlite
39
+
40
+ from zikaron.core.clock import timestamp
41
+ from zikaron.core.consolidation import groups
42
+ from zikaron.core.consolidation.context import RunOwner
43
+ from zikaron.core.consolidation.groups import RunCounts
44
+ from zikaron.core.events import ConsolidateRunDetail, RunPhase
45
+ from zikaron.core.records.memory import CallParams, log_event
46
+
47
+
48
+ class RunStatus(StrEnum):
49
+ """`consolidation_run.status`'s five values, exactly as `schema.md`'s `CHECK` states them.
50
+
51
+ `ABANDONED` and `TAKEN_OVER` are one producer discriminated by one test — an explicit
52
+ `plan_groups` closing an unexpired run, called by its owner or by somebody else. Both are
53
+ recorded because a takeover is a supported operation with a real cost (the displaced worker's
54
+ work in progress), and because in the likeliest case nothing else distinguishes them: a user
55
+ retrying the skill in one kiro session presents the same `session_id` and only a different pid.
56
+ """
57
+
58
+ ACTIVE = "active"
59
+ COMPLETE = "complete"
60
+ EXPIRED = "expired"
61
+ ABANDONED = "abandoned"
62
+ TAKEN_OVER = "taken_over"
63
+
64
+
65
+ #: `RunStatus` → the `consolidate_run` phase recording the transition into it. `ACTIVE` is absent
66
+ #: because its phase is named differently — a run is `planned`, and then it *is* active — the one
67
+ #: place the two vocabularies do not coincide, and so the one place a mapping is needed at all.
68
+ _CLOSING_PHASE: Final[dict[RunStatus, RunPhase]] = {
69
+ RunStatus.COMPLETE: RunPhase.COMPLETE,
70
+ RunStatus.EXPIRED: RunPhase.EXPIRED,
71
+ RunStatus.ABANDONED: RunPhase.ABANDONED,
72
+ RunStatus.TAKEN_OVER: RunPhase.TAKEN_OVER,
73
+ }
74
+
75
+ _RUN_COLUMNS: Final = "run_id, session_id, pid, started_at, expires_at, status"
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Run:
80
+ """One `consolidation_run` row, exactly as stored.
81
+
82
+ `effective_status` is computed rather than stored, which is the whole content of the
83
+ derived-expiry rule: two readers of one row must reach the same conclusion about a lapsed lease
84
+ without either of them writing anything.
85
+ """
86
+
87
+ run_id: str
88
+ owner: RunOwner
89
+ started_at: str
90
+ expires_at: str
91
+ status: RunStatus
92
+
93
+ def has_lapsed(self, *, at: str) -> bool:
94
+ """Whether this run's lease has passed as of `at` — `expires_at < now`."""
95
+ return _parse(self.expires_at) < _parse(at)
96
+
97
+ def is_effectively_active(self, *, at: str) -> bool:
98
+ """`status='active' AND expires_at ≥ now`: the only run test the design defines."""
99
+ return self.status is RunStatus.ACTIVE and not self.has_lapsed(at=at)
100
+
101
+ def effective_status(self, *, at: str) -> RunStatus:
102
+ """The status a reader should act on: `EXPIRED` for a lapsed `ACTIVE` row, else the stored
103
+ one. Reported in `group_expired`'s payload beside the stored value, so a caller can tell a
104
+ lease that ran out from a run somebody replanned."""
105
+ if self.status is RunStatus.ACTIVE and self.has_lapsed(at=at):
106
+ return RunStatus.EXPIRED
107
+ return self.status
108
+
109
+
110
+ def _parse(stamp: str) -> datetime:
111
+ return datetime.fromisoformat(stamp)
112
+
113
+
114
+ def lease_expiry(started_at: str, *, seconds: int) -> str:
115
+ """`started_at` plus `seconds`, in the store's own timestamp format.
116
+
117
+ Derived from the stored `started_at` rather than from a second clock read, so that
118
+ `expires_at` minus `started_at` is exactly `run_lease` on every row — which is what lets a test
119
+ assert the lease length rather than assert it approximately.
120
+ """
121
+ return (_parse(started_at) + timedelta(seconds=seconds)).isoformat()
122
+
123
+
124
+ def _to_run(row: Sequence[object]) -> Run:
125
+ run_id, session_id, pid, started_at, expires_at, status = row
126
+ return Run(
127
+ run_id=str(run_id),
128
+ owner=RunOwner(session_id=str(session_id), pid=int(str(pid))),
129
+ started_at=str(started_at),
130
+ expires_at=str(expires_at),
131
+ status=RunStatus(str(status)),
132
+ )
133
+
134
+
135
+ async def load(db: aiosqlite.Connection, run_id: str) -> Run | None:
136
+ """One run by id, or `None` if the store holds no such row."""
137
+ rows = await db.execute_fetchall(
138
+ f"SELECT {_RUN_COLUMNS} FROM consolidation_run WHERE run_id = ?", # noqa: S608 — a source-level column list; the id is bound.
139
+ (run_id,),
140
+ )
141
+ found = list(rows)
142
+ return None if not found else _to_run(tuple(found[0]))
143
+
144
+
145
+ async def stored_active(db: aiosqlite.Connection) -> Run | None:
146
+ """The store's one `status='active'` run, whoever owns it and whether or not its lease holds.
147
+
148
+ Returns the **stored** row, deliberately, because two callers want different things from it: a
149
+ write verb needs to know whether the lease has lapsed in order to answer `group_expired`, and
150
+ `next_group` needs to know in order to replan. Deciding here would force one of them to
151
+ reconstruct what it was not told.
152
+
153
+ Raises:
154
+ ValueError: the store holds more than one `active` run, which invariant 15 forbids and only
155
+ a bug in this module can produce, since `plan_groups` closes every one of them before
156
+ creating another. Refused rather than resolved by picking the earliest: an
157
+ implementation that quietly served one of two active runs would keep a broken store
158
+ working while two consolidators mutated one journal, which is the precise failure the
159
+ invariant exists to prevent.
160
+ """
161
+ rows = await db.execute_fetchall(
162
+ f"SELECT {_RUN_COLUMNS} FROM consolidation_run WHERE status = 'active'" # noqa: S608 — a source-level column list; no request value is interpolated.
163
+ )
164
+ found = list(rows)
165
+ if len(found) > 1:
166
+ ids = sorted(str(row[0]) for row in found)
167
+ raise ValueError(f"invariant 15: {len(found)} active consolidation runs at once: {ids}")
168
+ return None if not found else _to_run(tuple(found[0]))
169
+
170
+
171
+ async def log_phase(
172
+ db: aiosqlite.Connection,
173
+ *,
174
+ run_id: str,
175
+ phase: RunPhase,
176
+ run_counts: RunCounts,
177
+ ctx: CallParams,
178
+ ) -> None:
179
+ """Emit the `consolidate_run` event for one transition, in the caller's own transaction."""
180
+ await log_event(
181
+ db,
182
+ ctx=ctx,
183
+ detail=ConsolidateRunDetail(
184
+ run_id=run_id,
185
+ phase=phase,
186
+ n_groups=run_counts.n_groups,
187
+ n_members=run_counts.n_members,
188
+ n_deferred=run_counts.n_deferred,
189
+ ),
190
+ memory_uuid=None,
191
+ )
192
+
193
+
194
+ async def create(
195
+ db: aiosqlite.Connection, *, owner: RunOwner, started_at: str, lease_seconds: int
196
+ ) -> Run:
197
+ """Insert a fresh `active` run owned by `owner`, with its lease set from `started_at`.
198
+
199
+ Emits no event: the `planned` phase's counts describe the groups this run holds, which do not
200
+ exist until the planner has written them, so the event belongs to the planner rather than here.
201
+ Assumes the caller's open transaction, and assumes the caller has already closed any
202
+ pre-existing `active` run — invariant 15 is enforced by that ordering, and this function
203
+ deliberately does not do it silently, since which status the old run gets is the planner's
204
+ decision to make and to record.
205
+ """
206
+ run = Run(
207
+ run_id=str(uuid4()),
208
+ owner=owner,
209
+ started_at=started_at,
210
+ expires_at=lease_expiry(started_at, seconds=lease_seconds),
211
+ status=RunStatus.ACTIVE,
212
+ )
213
+ await db.execute(
214
+ "INSERT INTO consolidation_run "
215
+ "(run_id, session_id, pid, started_at, expires_at, status) VALUES (?, ?, ?, ?, ?, ?)",
216
+ (
217
+ run.run_id,
218
+ run.owner.session_id,
219
+ run.owner.pid,
220
+ run.started_at,
221
+ run.expires_at,
222
+ run.status.value,
223
+ ),
224
+ )
225
+ return run
226
+
227
+
228
+ async def close(
229
+ db: aiosqlite.Connection, *, run_id: str, status: RunStatus, ctx: CallParams
230
+ ) -> bool:
231
+ """Transition one `active` run to a terminal status, emitting its phase event if it moved.
232
+
233
+ A **guarded** update — `WHERE status='active'` — so it is idempotent under a retry and so two
234
+ paths that both notice a run is finished cannot emit two `complete` events for it. The event is
235
+ emitted only when the update actually changed a row, which is what makes the guard load-bearing
236
+ rather than decorative: `next_group`'s loop and the write verb that dispositions the last member
237
+ can both reach the same conclusion in the same transaction.
238
+
239
+ Counts are read **after** the transition, so a `complete` event's `n_deferred` reflects every
240
+ group this run ended up deferring.
241
+
242
+ Returns:
243
+ Whether this call performed the transition.
244
+
245
+ Raises:
246
+ KeyError: `status` is `ACTIVE`, which is not a close. Raised rather than silently accepted
247
+ because the guarded update would then be a no-op that looks like an already-closed run.
248
+ """
249
+ phase = _CLOSING_PHASE[status]
250
+ cursor = await db.execute(
251
+ "UPDATE consolidation_run SET status = ? WHERE run_id = ? AND status = 'active'",
252
+ (status.value, run_id),
253
+ )
254
+ if cursor.rowcount == 0:
255
+ return False
256
+ await log_phase(
257
+ db, run_id=run_id, phase=phase, run_counts=await groups.run_counts(db, run_id), ctx=ctx
258
+ )
259
+ return True
260
+
261
+
262
+ async def refresh_lease(
263
+ db: aiosqlite.Connection, *, run_id: str, at: str, lease_seconds: int
264
+ ) -> None:
265
+ """Push one `active` run's `expires_at` out to `at` plus the lease.
266
+
267
+ Guarded on `status='active'`, so a run closed under the caller cannot be revived by a
268
+ refresh. Called only on a path that made progress — a serve that delivered a group, or a write
269
+ verb that dispositioned a member — never on a rejection and never on a `{conflict: true}`
270
+ response, which mutates nothing and so must not buy the lease more time.
271
+ """
272
+ await db.execute(
273
+ "UPDATE consolidation_run SET expires_at = ? WHERE run_id = ? AND status = 'active'",
274
+ (lease_expiry(at, seconds=lease_seconds), run_id),
275
+ )
276
+
277
+
278
+ def now() -> str:
279
+ """The current instant in the store's own timestamp format.
280
+
281
+ Re-exported from the shared clock so every module in this package reads one clock, and reads it
282
+ by a name that does not invite a second implementation. One call per transaction is the intent:
283
+ a serve's `served_at`, its members' `disposed_at` and its lease refresh should all name the same
284
+ instant, because they describe one event.
285
+ """
286
+ return timestamp()
287
+
288
+
289
+ async def close_if_finished(db: aiosqlite.Connection, *, run_id: str, ctx: CallParams) -> bool:
290
+ """Close one run `complete` if no group of it is `pending` or `served` any more.
291
+
292
+ Invariant 17 gives the run's `active → complete` transition exactly one condition and several
293
+ transactions that can observe it: the write verb that dispositions the last member of the last
294
+ open group, and the serve transaction, either when re-validation vacates a group empty or when
295
+ the loop finds nothing servable left. So this is a question every one of them asks, and asking
296
+ it through one function is what keeps them from disagreeing about whether `deferred` counts as
297
+ open — it does not, because a deferred group is terminal for the run and its members simply
298
+ return to the next plan.
299
+
300
+ Returns:
301
+ Whether this call performed the transition. `close`'s guarded update is what makes the
302
+ answer trustworthy when two of those observers land in one transaction.
303
+ """
304
+ if await groups.has_open_groups(db, run_id):
305
+ return False
306
+ return await close(db, run_id=run_id, status=RunStatus.COMPLETE, ctx=ctx)