atif-sql 0.2.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 (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,228 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Value objects describing what a materialization pass sees and decides.
4
+
5
+ The decision pipeline is three pure pieces:
6
+
7
+ * :class:`SessionSource` — one session's source files and their mtimes, as
8
+ scanned (infrastructure feeds this in; the domain never stats a file).
9
+ * :class:`QuiescencePolicy` — the "is this session done being written?"
10
+ rule. A transcript is appended to many times per turn; converting a
11
+ session mid-write wastes a conversion and materializes a half-turn. A
12
+ session qualifies only once its newest source mtime is at least
13
+ ``quiesce_seconds`` old.
14
+ * :class:`MaterializationPlan` / :func:`build_plan` — the deterministic
15
+ partition of scanned sessions into to-materialize / up-to-date /
16
+ skipped-live, given the recorded watermark and "now".
17
+
18
+ Clock discipline: all ``*_ns`` values are **epoch nanoseconds** — mtimes from
19
+ ``os.stat().st_mtime_ns``, "now" from ``time.time_ns()`` passed in by the
20
+ caller. The domain never reads a clock, so the same inputs always yield the
21
+ same plan (pinned by the determinism tests).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import TYPE_CHECKING
28
+
29
+ from loguru import logger
30
+
31
+ from atif_corpus.domain.watermark import diff_source_mtimes
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Collection, Mapping, Sequence
35
+
36
+ #: Nanoseconds per second; policies are configured in seconds and convert
37
+ #: once at the boundary because ``st_mtime_ns`` is the native unit.
38
+ NANOS_PER_SECOND: int = 1_000_000_000
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class SessionSource:
43
+ """One discovered session: its id and every source file with its mtime.
44
+
45
+ ``source_mtimes`` covers the main ``<session_id>.jsonl`` PLUS every
46
+ side-file under the session dir (``subagents/agent-*.jsonl``,
47
+ ``subagents/workflows/wf_*/agent-*.jsonl``, and any deeper future
48
+ nesting) — the contract requires the watermark to notice a workflow
49
+ side-file changing even when the main transcript did not.
50
+ """
51
+
52
+ #: The session UUID (the main JSONL's stem).
53
+ session_id: str
54
+ #: Absolute path of the main ``<session_id>.jsonl`` transcript.
55
+ session_jsonl: str
56
+ #: ``{absolute path: epoch-ns mtime}`` for the main file + all side-files.
57
+ source_mtimes: Mapping[str, int]
58
+
59
+ @property
60
+ def newest_mtime_ns(self) -> int:
61
+ """The most recent write across all of this session's source files."""
62
+ return max(self.source_mtimes.values())
63
+
64
+ @property
65
+ def source_files(self) -> tuple[str, ...]:
66
+ """Sorted source paths — deterministic, for meta.json and diffing."""
67
+ return tuple(sorted(self.source_mtimes))
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class QuiescencePolicy:
72
+ """The rule for when a session is settled enough to convert.
73
+
74
+ A session is *quiescent* when its newest source mtime is at least
75
+ ``quiesce_seconds`` in the past. Nothing announces that a writer finished,
76
+ so silence is the only available signal.
77
+
78
+ SCOPE: the quiet period is per-SESSION, not per-file. One late write to
79
+ any of a session's source files holds the whole session back, which is
80
+ what keeps a multi-file session from being materialized half-written.
81
+ """
82
+
83
+ #: Seconds of source silence required before a session may materialize.
84
+ quiesce_seconds: int = 300
85
+ #: How far in the future an mtime may sit before it earns the clock warning.
86
+ #: The decision never uses it (a future mtime is never quiescent); it only
87
+ #: keeps sub-second jitter, such as a coarse filesystem timestamp or a stat
88
+ #: racing the clock read, from reading as a misconfigured host.
89
+ future_warn_seconds: float = 1.0
90
+
91
+ def is_quiescent(self, newest_mtime_ns: int, now_ns: int) -> bool:
92
+ """True when the newest source write is at least ``quiesce_seconds`` old.
93
+
94
+ The boundary is inclusive (``>=``): a session exactly at the
95
+ threshold counts as quiescent, so the edge is testable without
96
+ racing a clock.
97
+
98
+ A source mtime in the FUTURE (a clock step on the writing host, a
99
+ copied file carrying a bogus stamp) never satisfies the threshold,
100
+ so the session would starve in ``skipped_live`` silently. It stays
101
+ skipped — converting is still the wrong call — but it warns every
102
+ pass so the starvation is visible instead of mute. Only an mtime more
103
+ than ``future_warn_seconds`` ahead warns: the caller reads "now" after
104
+ the scan, so a smaller lead is jitter, not a clock problem.
105
+ """
106
+ age_ns = now_ns - newest_mtime_ns
107
+ if -age_ns > self.future_warn_seconds * NANOS_PER_SECOND:
108
+ logger.warning(
109
+ "quiescence: newest source mtime is {:.1f}s in the FUTURE; "
110
+ "session stays skipped until the clock catches up "
111
+ "(check the writing host's clock)",
112
+ -age_ns / NANOS_PER_SECOND,
113
+ )
114
+ return age_ns >= self.quiesce_seconds * NANOS_PER_SECOND
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class MaterializationPlan:
119
+ """The deterministic work order for one materialization pass.
120
+
121
+ All three partitions are sorted by session id so the same scan always
122
+ yields the same plan (and the same log output, and the same failure
123
+ ordering).
124
+ """
125
+
126
+ #: Sessions to (re)convert: quiescent AND stale relative to the watermark.
127
+ to_materialize: tuple[SessionSource, ...]
128
+ #: Sessions whose artifacts already reflect their sources; nothing to do.
129
+ up_to_date: tuple[SessionSource, ...]
130
+ #: Sessions still being written (not quiescent); revisit next pass.
131
+ skipped_live: tuple[SessionSource, ...]
132
+
133
+ @property
134
+ def is_noop(self) -> bool:
135
+ """True when the pass has nothing to convert."""
136
+ return not self.to_materialize
137
+
138
+
139
+ def owns_path(session_jsonl: str, path: str) -> bool:
140
+ """True when ``path`` is ``session_jsonl`` itself or one of its side-files.
141
+
142
+ The single definition of a session's watermark scope: the main JSONL
143
+ plus everything under ``<main-jsonl-stem>/``. Staleness and watermark
144
+ retention both key off this, so the two can never disagree about which
145
+ entries belong to a session. Keyed on the main path rather than a whole
146
+ :class:`SessionSource` so a session the scanner could NOT read — it has
147
+ a path but no mtimes — gets scoped by the same rule.
148
+ """
149
+ stem = session_jsonl.rsplit(".", 1)[0]
150
+ return path == session_jsonl or path.startswith(f"{stem}/")
151
+
152
+
153
+ def _is_stale(session: SessionSource, watermark: Mapping[str, int]) -> bool:
154
+ """True when the recorded watermark fails to describe this session's sources.
155
+
156
+ Delegates the comparison to
157
+ :func:`atif_corpus.domain.watermark.diff_source_mtimes` over this
158
+ session's scope: a source file added, changed in either mtime direction,
159
+ or *removed* since the last pass all make the session stale. Removal
160
+ counts because the materialized trajectory still embeds the vanished
161
+ file's records.
162
+ """
163
+ recorded = {
164
+ path: mtime for path, mtime in watermark.items() if owns_path(session.session_jsonl, path)
165
+ }
166
+ return not diff_source_mtimes(recorded, session.source_mtimes).is_empty
167
+
168
+
169
+ def build_plan(
170
+ sessions: Sequence[SessionSource],
171
+ *,
172
+ watermark: Mapping[str, int],
173
+ policy: QuiescencePolicy,
174
+ now_ns: int,
175
+ force: bool = False,
176
+ unmaterialized_session_ids: Collection[str] = (),
177
+ generation_stale_session_ids: Collection[str] = (),
178
+ ) -> MaterializationPlan:
179
+ """Partition scanned sessions into the three plan buckets.
180
+
181
+ Decision order per the contract: a session is (re)materialized when its
182
+ newest source mtime is quiescent AND the watermark says its sources
183
+ moved. ``force`` overrides ONLY the staleness check — a live session is
184
+ never converted even under force, because converting a half-written
185
+ transcript produces a wrong artifact rather than a stale one.
186
+
187
+ Parameters
188
+ ----------
189
+ sessions
190
+ Every session the scanner discovered under the source root.
191
+ watermark
192
+ The recorded ``{path: mtime_ns}`` map from the previous pass
193
+ (``watermark.json``); empty on first run.
194
+ policy
195
+ The quiescence rule.
196
+ now_ns
197
+ Epoch nanoseconds "now", read once by the caller — never here.
198
+ force
199
+ Re-materialize every quiescent session regardless of the watermark.
200
+ unmaterialized_session_ids
201
+ Sessions the watermark claims are current but whose artifact dir is
202
+ absent on disk (a pass killed mid-swap). Treated as stale so the
203
+ artifacts come back; the watermark alone cannot express this, since
204
+ it records SOURCE mtimes and knows nothing about the corpus dir.
205
+ generation_stale_session_ids
206
+ Sessions whose recorded provenance (``converter_schema``,
207
+ ``columnar_schema``) differs from what this pass would stamp, per
208
+ :func:`atif_corpus.domain.generation.generation_matches`. Treated as
209
+ stale: the source did not move, but the code that turns it into
210
+ artifacts did.
211
+ """
212
+ ordered = sorted(sessions, key=lambda s: s.session_id)
213
+ missing = set(unmaterialized_session_ids) | set(generation_stale_session_ids)
214
+ to_materialize: list[SessionSource] = []
215
+ up_to_date: list[SessionSource] = []
216
+ skipped_live: list[SessionSource] = []
217
+ for session in ordered:
218
+ if not policy.is_quiescent(session.newest_mtime_ns, now_ns):
219
+ skipped_live.append(session)
220
+ elif force or session.session_id in missing or _is_stale(session, watermark):
221
+ to_materialize.append(session)
222
+ else:
223
+ up_to_date.append(session)
224
+ return MaterializationPlan(
225
+ to_materialize=tuple(to_materialize),
226
+ up_to_date=tuple(up_to_date),
227
+ skipped_live=tuple(skipped_live),
228
+ )
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Map one corpus source root to a stable, human-legible directory key.
4
+
5
+ The slug is an ON-DISK NAME, not an internal identifier: it is the
6
+ ``<slug>`` in ``~/.atif-sql/corpus/<slug>/``, so this function's output is
7
+ part of every installed user's filesystem layout.
8
+
9
+ Changing the algorithm therefore ORPHANS existing corpora rather than
10
+ migrating them. ``materialize`` starts writing a fresh tree, ``status``
11
+ reports an empty corpus, ``query`` binds zero sessions, and the watermarks
12
+ and LanceDB embedding store at the no-longer-computed path are stranded,
13
+ re-earned only by a full re-materialize and re-embed. ``tests/test_slug.py``
14
+ freezes concrete outputs so such a change fails a test, not a user's install.
15
+
16
+ If a change here is genuinely wanted, it needs all three of: the frozen
17
+ values in ``tests/test_slug.py`` updated, a migration for corpora already on
18
+ disk, and the two call sites that compose the path
19
+ (``_default_corpus_root`` in :mod:`atif_corpus.infrastructure.settings` and
20
+ the ``--corpus-root`` fallback in ``atif_cli.app``) reviewed together.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import re
27
+ from pathlib import Path
28
+
29
+ #: Reserved slug for the interactive corpus at ``~/.claude`` — the root nearly
30
+ #: every user has. It is reserved rather than hashed so the common case has a
31
+ #: dirname a human can recognize in ``ls``.
32
+ DEFAULT_CORPUS_KEY = "default"
33
+
34
+ #: Reserved slug for the Codex rollout corpus at ``~/.codex/sessions``. Same
35
+ #: reasoning as :data:`DEFAULT_CORPUS_KEY`, and safe to add rather than a
36
+ #: breaking change: this workspace could not read a Codex rollout before
37
+ #: 2026-09-11, so no corpus can already occupy the hashed name this replaces
38
+ #: (``sessions-<8 hex>``). Reserved on the SESSIONS dir rather than on
39
+ #: ``~/.codex`` because the sessions dir is the source root a scan walks.
40
+ CODEX_CORPUS_KEY = "codex"
41
+
42
+ #: Any run of characters outside ``[a-z0-9]`` collapses to a single ``-``.
43
+ _SLUG_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
44
+
45
+ #: Cap on the legible dirname component; the hash suffix carries uniqueness.
46
+ _SLUG_MAX_NAME_LEN = 32
47
+
48
+
49
+ def corpus_slug(corpus_root: Path | str) -> str:
50
+ """Map one corpus root to a stable, human-legible directory key.
51
+
52
+ Pure: no I/O beyond path resolution. The interactive corpus
53
+ (``~/.claude``, however it is spelled) maps to the reserved key
54
+ :data:`DEFAULT_CORPUS_KEY`. Every other root maps to
55
+ ``<sanitized-dirname>-<8-hex sha256 of the resolved path>`` — legible
56
+ enough to eyeball in ``ls``, hashed enough that two roots sharing a
57
+ dirname (``…/alice/.claude`` vs ``…/bob/.claude``) never collide.
58
+
59
+ The Codex rollout root (``~/.codex/sessions``) maps to
60
+ :data:`CODEX_CORPUS_KEY` for the same legibility reason.
61
+
62
+ The root is ``expanduser().resolve()``-normalized first so symlinked
63
+ spellings of the same corpus agree on one key.
64
+ """
65
+ resolved = Path(corpus_root).expanduser().resolve()
66
+ if resolved == Path("~/.claude").expanduser().resolve():
67
+ return DEFAULT_CORPUS_KEY
68
+ if resolved == Path("~/.codex/sessions").expanduser().resolve():
69
+ return CODEX_CORPUS_KEY
70
+ name = _SLUG_SANITIZE_RE.sub("-", resolved.name.lower()).strip("-")[:_SLUG_MAX_NAME_LEN]
71
+ digest = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:8]
72
+ return f"{name}-{digest}" if name else digest
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Where an agent's transcripts live, as one value object per agent.
4
+
5
+ Discovery used to be a single hardcoded shape — project directories one level
6
+ under the source root, one JSONL per session — because Claude Code was the only
7
+ agent. Codex CLI writes nothing like it: rollouts sit three levels deep under
8
+ ``$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/`` and the session id is a uuid inside
9
+ the FILENAME rather than the whole stem. Both shapes are now described here, as
10
+ pure data plus pure functions, so the scanner walks one algorithm and the
11
+ per-agent knowledge lives in exactly one place.
12
+
13
+ Pure module: no filesystem access, no env, no logging. Everything it returns is
14
+ derived from a path's TEXT.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import PurePosixPath
21
+
22
+ from atif_corpus.domain.agents import AgentSource
23
+
24
+ #: A Codex rollout filename's fixed prefix, and the number of hyphen-separated
25
+ #: groups its trailing uuid occupies: ``rollout-<ts>-<8>-<4>-<4>-<4>-<12>.jsonl``.
26
+ _ROLLOUT_PREFIX = "rollout-"
27
+ _UUID_GROUPS = 5
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class SourceLayout:
32
+ """How one agent's transcripts are arranged under a source root.
33
+
34
+ ``transcript_depth`` is how many directory levels sit between the source
35
+ root and a transcript file: 1 for Claude Code (``<project>/<session>.jsonl``)
36
+ and 3 for Codex (``<YYYY>/<MM>/<DD>/rollout-*.jsonl``). The scanner descends
37
+ exactly that many levels, listing each one explicitly, which is what keeps a
38
+ directory it could not OPEN distinguishable from an empty one at every level
39
+ rather than only at the first.
40
+
41
+ ``has_side_files`` says whether a session can own a sibling directory of
42
+ additional transcripts. Claude Code sessions do (subagent and workflow
43
+ side-files, which the watermark must notice); a Codex sub-agent writes its
44
+ own rollout under its own session id, so a rollout is always alone.
45
+ """
46
+
47
+ agent: AgentSource
48
+ transcript_depth: int
49
+ has_side_files: bool
50
+
51
+ def is_transcript(self, name: str) -> bool:
52
+ """Whether a filename in a transcript directory is one of this agent's."""
53
+ if not name.endswith(".jsonl"):
54
+ return False
55
+ if self.agent is AgentSource.CODEX:
56
+ return name.startswith(_ROLLOUT_PREFIX)
57
+ return True
58
+
59
+ def session_id(self, name: str) -> str | None:
60
+ """The session id a transcript filename carries, or ``None`` if it carries none.
61
+
62
+ Claude Code names the file after the session, so the stem IS the id.
63
+ Codex prefixes a timestamp, so the id is the stem's trailing uuid — and
64
+ a name whose shape does not yield one is not a session this layout can
65
+ materialize, which the caller must skip rather than guess at.
66
+ """
67
+ if not self.is_transcript(name):
68
+ return None
69
+ stem = name.removesuffix(".jsonl")
70
+ if self.agent is not AgentSource.CODEX:
71
+ return stem or None
72
+ parts = stem.split("-")
73
+ if len(parts) < _UUID_GROUPS + 1:
74
+ return None
75
+ return "-".join(parts[-_UUID_GROUPS:])
76
+
77
+ def session_from_watermark_path(self, source_root: str, path: str) -> tuple[str, str] | None:
78
+ """``(session_id, main transcript path)`` for a recorded watermark path.
79
+
80
+ Used when a directory could not be LISTED: nothing was discovered
81
+ there, so the watermark is the only record of which sessions lived
82
+ inside it, and each recorded path must resolve back to a session id
83
+ WITHOUT touching the filesystem.
84
+
85
+ Returns ``None`` when the path is not under ``source_root`` or its shape
86
+ does not name a session.
87
+ """
88
+ root_prefix = f"{source_root.rstrip('/')}/"
89
+ if not path.startswith(root_prefix):
90
+ return None
91
+ segments = PurePosixPath(path[len(root_prefix) :]).parts
92
+ if len(segments) <= self.transcript_depth:
93
+ return None
94
+ if self.agent is AgentSource.CODEX:
95
+ # A rollout owns no side-files, so a recorded path IS a main
96
+ # transcript and its own name carries the id.
97
+ session_id = self.session_id(segments[self.transcript_depth])
98
+ return (session_id, path) if session_id else None
99
+ # Claude Code: `<project>/<session>.jsonl`, or a side-file under
100
+ # `<project>/<session>/...`. Either way the second segment names the
101
+ # session, so the main transcript is derivable from position alone.
102
+ project, entry = segments[0], segments[1]
103
+ session_id = entry.removesuffix(".jsonl")
104
+ if not session_id:
105
+ return None
106
+ return session_id, f"{root_prefix}{project}/{session_id}.jsonl"
107
+
108
+
109
+ #: Claude Code: `<source_root>/<project>/<session>.jsonl` plus a side-file tree
110
+ #: under `<source_root>/<project>/<session>/`.
111
+ CLAUDE_CODE_LAYOUT = SourceLayout(
112
+ agent=AgentSource.CLAUDE_CODE,
113
+ transcript_depth=1,
114
+ has_side_files=True,
115
+ )
116
+
117
+ #: Codex CLI: `<source_root>/<YYYY>/<MM>/<DD>/rollout-<ts>-<uuid>.jsonl`, one
118
+ #: file per session and no side-files.
119
+ CODEX_LAYOUT = SourceLayout(
120
+ agent=AgentSource.CODEX,
121
+ transcript_depth=3,
122
+ has_side_files=False,
123
+ )
124
+
125
+ _LAYOUTS: dict[AgentSource, SourceLayout] = {
126
+ AgentSource.CLAUDE_CODE: CLAUDE_CODE_LAYOUT,
127
+ AgentSource.CODEX: CODEX_LAYOUT,
128
+ }
129
+
130
+
131
+ def layout_for(agent: AgentSource) -> SourceLayout:
132
+ """The layout for ``agent``.
133
+
134
+ Every :class:`~atif_corpus.domain.agents.AgentSource` member has one, and
135
+ the lookup raises rather than defaulting: a new agent added to the enum
136
+ without a layout must fail loudly at the first scan instead of silently
137
+ scanning with Claude Code's shape and reporting an empty corpus.
138
+ """
139
+ try:
140
+ return _LAYOUTS[agent]
141
+ except KeyError as exc: # pragma: no cover — unreachable while the enum is closed
142
+ msg = f"no source layout declared for agent {agent!r}"
143
+ raise ValueError(msg) from exc
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Pure watermark math: which source files moved between two scans.
4
+
5
+ Compare two ``{path: mtime_ns}`` maps and partition into added / modified /
6
+ removed. ``touched`` (added then modified) are the sessions whose artifacts
7
+ must be re-materialized; ``removed`` are sessions whose source vanished. Pure
8
+ so the delta can be tested without a filesystem and so two identical scans
9
+ always produce the same plan.
10
+
11
+ Clock discipline: every ``mtime_ns`` here is a filesystem modification time
12
+ in **epoch nanoseconds** (``os.stat().st_mtime_ns``). Nothing in this module
13
+ reads a clock; "now" is always passed in by the caller.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class SourceDelta:
27
+ """Which source files moved between two watermark snapshots.
28
+
29
+ All three partitions are sorted tuples so a delta is deterministic and
30
+ comparable — the materialization plan embeds these paths, and set
31
+ ordering would make two identical scans plan different work orders.
32
+ """
33
+
34
+ #: Paths present in the current scan and absent from the previous one.
35
+ added: tuple[str, ...]
36
+ #: Paths present in both whose ``mtime_ns`` changed (either direction).
37
+ modified: tuple[str, ...]
38
+ #: Paths present in the previous scan and absent from the current one.
39
+ removed: tuple[str, ...]
40
+
41
+ @property
42
+ def touched(self) -> tuple[str, ...]:
43
+ """Paths whose artifacts must be re-materialized (``added`` then ``modified``)."""
44
+ return (*self.added, *self.modified)
45
+
46
+ @property
47
+ def is_empty(self) -> bool:
48
+ """True when nothing moved, so a materialization pass would be a no-op."""
49
+ return not (self.added or self.modified or self.removed)
50
+
51
+ @property
52
+ def changed_count(self) -> int:
53
+ """Total number of paths in the delta, across all three partitions."""
54
+ return len(self.added) + len(self.modified) + len(self.removed)
55
+
56
+
57
+ def diff_source_mtimes(
58
+ previous: Mapping[str, int],
59
+ current: Mapping[str, int],
60
+ ) -> SourceDelta:
61
+ """Partition two ``{path: mtime_ns}`` maps into added / modified / removed.
62
+
63
+ A path is ``modified`` only when its mtime **changed**. An mtime that went
64
+ backwards (a restored backup, a clock step on the writing host, a file
65
+ replaced by an older copy) also counts as modified: the recorded watermark
66
+ no longer describes the bytes on disk, so the session must be re-read.
67
+ Only an exactly-equal mtime is treated as unchanged.
68
+ """
69
+ added = sorted(path for path in current if path not in previous)
70
+ removed = sorted(path for path in previous if path not in current)
71
+ modified = sorted(
72
+ path
73
+ for path, mtime_ns in current.items()
74
+ if path in previous and previous[path] != mtime_ns
75
+ )
76
+ return SourceDelta(added=tuple(added), modified=tuple(modified), removed=tuple(removed))
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Infrastructure layer: filesystem scanning, atomic writes, settings, fakes.
4
+
5
+ Everything that touches the world (env, clock-adjacent file mtimes, disk)
6
+ lives here so the domain stays a pure function of its inputs.
7
+ """