quirework 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 (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1271 @@
1
+ """Where a project's captured sessions live, and which of them it accepted.
2
+
3
+ A provenance corpus has two halves that change at different speeds. The
4
+ sessions are large and, once settled, never edited: hundreds of megabytes of
5
+ transcripts that the work's own history should not have to carry, and that a
6
+ repository-wide grep or rewrite should not have to wade through. The decisions
7
+ about them are small and edited by people: which sessions count, which were
8
+ removed and why, which are about the apparatus. So the sessions go to a *store*
9
+ outside the checkout, and the decisions stay in the *ledger*, the directory
10
+ ``provenance.root`` names, which is versioned with the work.
11
+
12
+ The store has two areas. Capture writes into ``pending/``, where a session that
13
+ is still running is simply rewritten as it grows. ``accept`` copies a pending
14
+ capture into ``accepted/`` as an immutable *version*, and the ledger's
15
+ ``accepted.txt`` gains one line per file: its SHA-256 and its path in the
16
+ store. That list is what a git commit of the session used to mean ("this
17
+ session is evidence"), and it keeps what git used to guarantee: a clone can
18
+ tell whether the bytes it holds are the ones that were accepted.
19
+
20
+ A version is a directory named by a hash of what it holds, and nothing rewrites
21
+ one. Every checkout of a project shares its store, while each branch carries
22
+ its own ``accepted.txt``. If accepting a grown session overwrote the file an
23
+ older branch's list names, that branch's checks would fail over something it
24
+ never touched. With immutable versions, every list keeps resolving to exactly
25
+ the bytes it accepted.
26
+
27
+ The unit of acceptance is a *session*. For Claude Code, Codex and Antigravity
28
+ that is one transcript. For a web conversation it is every file captured from
29
+ it: the raw export, the rendered transcript, and the files and artifacts
30
+ extracted from it. A version holds the whole unit, so files a reader expects to
31
+ find side by side were accepted together.
32
+
33
+ The store is found by name. ``provenance.store`` names it under the provenance
34
+ home: ``$QUIREWORK_PROVENANCE_HOME`` when that is set, else ``provenance.home``
35
+ in the per-user configuration, else ``$XDG_DATA_HOME/quirework/provenance``.
36
+ The name is committed with the project, so a fresh worktree and a fresh clone
37
+ find the same store with nothing set per checkout. The per-user file is what
38
+ lets a hook launched outside the author's shell find it too, since such a hook
39
+ never sees a variable the shell's profile exported. ``provenance.store_dir`` names a
40
+ directory instead, for a project that keeps its sessions somewhere specific,
41
+ inside its own repository included. The store records an identity and
42
+ ``accepted.txt`` names it, so a ledger is refused a store that another project
43
+ created under the same name.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import fcntl
50
+ import hashlib
51
+ import os
52
+ import re
53
+ import shutil
54
+ import sys
55
+ import tempfile
56
+ import time
57
+ import tomllib
58
+ import uuid
59
+ from contextlib import contextmanager
60
+ from dataclasses import dataclass
61
+ from fnmatch import fnmatch
62
+ from pathlib import Path, PurePosixPath
63
+
64
+ from quirework.project import (
65
+ ConfigurationError,
66
+ current_project,
67
+ user_config_path,
68
+ user_settings,
69
+ )
70
+ from quirework.runs.storage import shown
71
+
72
+ CC, AGY, CODEX, WEB = "cc-sessions", "agy-sessions", "codex-sessions", "conversations"
73
+ FAMILIES = (CC, AGY, CODEX, WEB)
74
+ # The short names the ledger's lists, the catalog and `show` use.
75
+ KINDS = {"cc": CC, "agy": AGY, "codex": CODEX, "web": WEB}
76
+ KIND_OF = {family: kind for kind, family in KINDS.items()}
77
+ # The families whose captures carry their source's modification time, so the
78
+ # time says when the session last grew rather than when it was captured.
79
+ LIVE_FAMILIES = (CC, AGY, CODEX)
80
+
81
+ MANIFEST = "accepted.txt"
82
+ REMOVED = "removed-sessions.txt"
83
+ MAKING = "making-sessions.txt"
84
+ IDENTITY = "store.toml"
85
+
86
+ # A capture modified more recently than this may belong to a session that is
87
+ # still running, and accepting it would only accept a prefix of it.
88
+ SETTLE_SECONDS = 2 * 3600
89
+
90
+ _NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
91
+ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
92
+ _SID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
93
+ _SHA = re.compile(r"[0-9a-f]{64}")
94
+ _VERSION = re.compile(r"[0-9a-f]{16}")
95
+
96
+ _MANIFEST_HEADER = (
97
+ "# The sessions this project accepted into its provenance corpus: each\n"
98
+ "# file's SHA-256, then its path in the store. `quirework provenance\n"
99
+ "# accept` and `reject` write it; edit it only through them.\n"
100
+ )
101
+ _LIST_HEADERS = {
102
+ REMOVED: (
103
+ "# Sessions curated out of this project's corpus, one per line, as\n"
104
+ "# '<kind> <session id> # reason'. Capture never brings one back.\n"
105
+ ),
106
+ MAKING: (
107
+ "# Sessions about the apparatus rather than the work, one per line, as\n"
108
+ "# '<kind> <session id> # reason'. The cold-read reads them as scope\n"
109
+ "# 'making'; search, show and metrics read them like any other.\n"
110
+ ),
111
+ }
112
+
113
+
114
+ # --------------------------------------------------------------------------- #
115
+ # where the store and the ledger are
116
+ # --------------------------------------------------------------------------- #
117
+ @dataclass(frozen=True)
118
+ class Home:
119
+ """The directory named stores live under, and which setting chose it."""
120
+
121
+ path: Path
122
+ chosen_by: str # "environment", "user" or "default"
123
+
124
+ def describe(self):
125
+ """The directory and what chose it, for a person reading output."""
126
+ if self.chosen_by == "environment":
127
+ return f"{shown(self.path)}, from $QUIREWORK_PROVENANCE_HOME"
128
+ config = shown(user_config_path())
129
+ if self.chosen_by == "user":
130
+ return f"{shown(self.path)}, from provenance.home in {config}"
131
+ return (
132
+ f"{shown(self.path)}, the default: $QUIREWORK_PROVENANCE_HOME is unset "
133
+ f"and {config} sets no provenance.home"
134
+ )
135
+
136
+
137
+ def find_home():
138
+ """The provenance home: the variable, else the per-user file, else XDG data."""
139
+ configured = os.environ.get("QUIREWORK_PROVENANCE_HOME")
140
+ if configured:
141
+ path = Path(configured).expanduser()
142
+ if not path.is_absolute():
143
+ raise ConfigurationError(
144
+ f"QUIREWORK_PROVENANCE_HOME must be an absolute path, not {configured!r}"
145
+ )
146
+ return Home(path, "environment")
147
+ configured = user_settings().get("provenance", {}).get("home")
148
+ if configured is not None:
149
+ where = f"provenance.home in {shown(user_config_path())}"
150
+ if not isinstance(configured, str) or not configured.strip():
151
+ raise ConfigurationError(f"{where} must name a directory")
152
+ path = Path(configured).expanduser()
153
+ if not path.is_absolute():
154
+ raise ConfigurationError(
155
+ f"{where} must be an absolute path, not {configured!r}"
156
+ )
157
+ return Home(path, "user")
158
+ data = os.environ.get("XDG_DATA_HOME")
159
+ base = Path(data) if data and Path(data).is_absolute() else None
160
+ path = (base or Path.home() / ".local/share") / "quirework" / "provenance"
161
+ return Home(path, "default")
162
+
163
+
164
+ def home():
165
+ """The directory a store named by ``provenance.store`` lives under."""
166
+ return find_home().path
167
+
168
+
169
+ def describe_home(project=None):
170
+ """The home and what chose it, or ``None`` for a store named by directory."""
171
+ project = current_project() if project is None else project
172
+ if project.get("provenance.store_dir") is not None:
173
+ return None
174
+ return find_home().describe()
175
+
176
+
177
+ def location(project=None):
178
+ """The store directory this project's configuration selects."""
179
+ project = current_project() if project is None else project
180
+ name = project.get("provenance.store")
181
+ directory = project.get("provenance.store_dir")
182
+ if (name is None) == (directory is None):
183
+ raise ConfigurationError(
184
+ "declare exactly one of provenance.store, a name for the store under "
185
+ "the provenance home, or provenance.store_dir, a directory"
186
+ )
187
+ if directory is not None:
188
+ return project.path("provenance.store_dir")
189
+ if not isinstance(name, str) or not _NAME.fullmatch(name):
190
+ raise ConfigurationError(
191
+ f"provenance.store must be a plain name such as 'my-book', not {name!r}; "
192
+ "a path belongs in provenance.store_dir"
193
+ )
194
+ return (home() / name).resolve()
195
+
196
+
197
+ def ledger(project=None):
198
+ """The versioned directory that holds the decisions about the sessions."""
199
+ project = current_project() if project is None else project
200
+ return project.path("provenance.root", ".quirework/provenance")
201
+
202
+
203
+ def versioned(path):
204
+ """The repository holding ``path``, or ``None`` when no git work tree does."""
205
+ from quirework.provenance import worktrees
206
+
207
+ path = Path(path)
208
+ for candidate in (path, *path.parents):
209
+ if (candidate / ".git").exists():
210
+ return candidate if worktrees.common_git_dir(candidate) else None
211
+ return None
212
+
213
+
214
+ # --------------------------------------------------------------------------- #
215
+ # sessions, files and versions
216
+ # --------------------------------------------------------------------------- #
217
+ def unit_of(family, rel):
218
+ """The session a file in one family belongs to.
219
+
220
+ A transcript is its own session and is named by its stem. A web
221
+ conversation's files share its id: ``<id>.raw.json``, ``<id>.md``, and
222
+ whatever was extracted under ``files/<id>/`` and ``artifacts/<id>/``.
223
+ """
224
+ parts = PurePosixPath(rel).parts
225
+ if family == WEB and len(parts) > 2 and parts[0] in ("files", "artifacts"):
226
+ return parts[1]
227
+ return parts[-1].split(".", 1)[0]
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class Entry:
232
+ """One accepted file: its family, version, path within the family, hash."""
233
+
234
+ family: str
235
+ version: str
236
+ rel: str
237
+ sha: str
238
+
239
+ @property
240
+ def unit(self):
241
+ return unit_of(self.family, self.rel)
242
+
243
+ @property
244
+ def reference(self):
245
+ """How the file is named wherever a version would only get in the way."""
246
+ return f"{self.family}/{self.rel}"
247
+
248
+ @property
249
+ def stored(self):
250
+ return f"{self.family}/{self.version}/{self.rel}"
251
+
252
+
253
+ def _safe_rel(rel):
254
+ parts = PurePosixPath(rel).parts
255
+ return bool(parts) and not PurePosixPath(rel).is_absolute() and ".." not in parts
256
+
257
+
258
+ # Parsed manifests, keyed by the file's absolute path and checked against its
259
+ # inode, size and modification time before use. Every write replaces the file,
260
+ # so a changed list never matches a stale parse, and two ledgers never share an
261
+ # entry.
262
+ _PARSED = {}
263
+
264
+
265
+ def read_manifest(path):
266
+ """``(identity, entries)`` from a ledger's ``accepted.txt``.
267
+
268
+ Strict, because the file is written by this module and nothing else: a line
269
+ that does not parse is damage, and reading around it would quietly shrink
270
+ the corpus.
271
+ """
272
+ path = Path(path).absolute()
273
+ try:
274
+ st = path.stat()
275
+ except FileNotFoundError:
276
+ return None, []
277
+ signature = (st.st_ino, st.st_size, st.st_mtime_ns)
278
+ cached = _PARSED.get(str(path))
279
+ if cached is not None and cached[0] == signature:
280
+ return cached[1], list(cached[2])
281
+ identity, entries = _parse_manifest(path)
282
+ _PARSED[str(path)] = (signature, identity, tuple(entries))
283
+ return identity, entries
284
+
285
+
286
+ def _parse_manifest(path):
287
+ identity, entries, seen = None, [], set()
288
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
289
+ text = line.strip()
290
+ if not text or text.startswith("#"):
291
+ continue
292
+ where = f"{shown(path)}:{lineno}"
293
+ if identity is None:
294
+ word, _, value = text.partition(" ")
295
+ if word != "store" or not _UUID.fullmatch(value.strip()):
296
+ raise ConfigurationError(
297
+ f"{where}: the first entry must be 'store <id>', naming the "
298
+ "store this ledger belongs to"
299
+ )
300
+ identity = value.strip()
301
+ continue
302
+ sha, _, stored = text.partition(" ")
303
+ parts = PurePosixPath(stored.strip()).parts
304
+ if (
305
+ not _SHA.fullmatch(sha)
306
+ or len(parts) < 3
307
+ or parts[0] not in FAMILIES
308
+ or not _VERSION.fullmatch(parts[1])
309
+ or not _safe_rel("/".join(parts[2:]))
310
+ ):
311
+ raise ConfigurationError(
312
+ f"{where}: not '<sha256> <family>/<version>/<file>': {text!r}"
313
+ )
314
+ entry = Entry(parts[0], parts[1], "/".join(parts[2:]), sha)
315
+ if entry.reference in seen:
316
+ raise ConfigurationError(f"{where}: {entry.reference} is listed twice")
317
+ seen.add(entry.reference)
318
+ entries.append(entry)
319
+ if identity is None:
320
+ raise ConfigurationError(f"{shown(path)} names no store")
321
+ return identity, entries
322
+
323
+
324
+ def write_manifest(path, identity, entries):
325
+ lines = [_MANIFEST_HEADER, f"store {identity}\n"]
326
+ lines += [
327
+ f"{e.sha} {e.stored}\n" for e in sorted(entries, key=lambda e: e.reference)
328
+ ]
329
+ _write_atomic(Path(path), "".join(lines))
330
+
331
+
332
+ def _write_atomic(path, text):
333
+ path.parent.mkdir(parents=True, exist_ok=True)
334
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
335
+ try:
336
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
337
+ fh.write(text)
338
+ os.replace(tmp, path)
339
+ except BaseException:
340
+ Path(tmp).unlink(missing_ok=True)
341
+ raise
342
+
343
+
344
+ def sha256_file(path):
345
+ digest = hashlib.sha256()
346
+ with open(path, "rb") as fh:
347
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
348
+ digest.update(chunk)
349
+ return digest.hexdigest()
350
+
351
+
352
+ def version_of(files):
353
+ """The name of the version holding ``{rel: sha}``: a hash of exactly that."""
354
+ digest = hashlib.sha256()
355
+ for rel, sha in sorted(files.items()):
356
+ digest.update(f"{rel}\t{sha}\n".encode())
357
+ return digest.hexdigest()[:16]
358
+
359
+
360
+ # --------------------------------------------------------------------------- #
361
+ # the store
362
+ # --------------------------------------------------------------------------- #
363
+ def _identity(root):
364
+ path = Path(root) / IDENTITY
365
+ if not path.is_file():
366
+ return None
367
+ try:
368
+ text = path.read_text(encoding="utf-8")
369
+ except OSError as exc:
370
+ raise ConfigurationError(f"cannot read {shown(path)}: {exc}") from exc
371
+ return identity_in(text, shown(path))
372
+
373
+
374
+ def identity_in(text, where):
375
+ """The store id a ``store.toml``'s text records; ``where`` names it in errors.
376
+
377
+ Separate from reading the file because a backup reads the copy at its
378
+ destination, which may be a remote that only hands back the text.
379
+ """
380
+ try:
381
+ value = tomllib.loads(text).get("id")
382
+ except tomllib.TOMLDecodeError as exc:
383
+ raise ConfigurationError(f"cannot read {where}: {exc}") from exc
384
+ if not isinstance(value, str) or not _UUID.fullmatch(value):
385
+ raise ConfigurationError(f"{where} records no valid store id")
386
+ return value
387
+
388
+
389
+ @dataclass(frozen=True)
390
+ class Store:
391
+ root: Path
392
+ ledger: Path
393
+ identity: str
394
+
395
+ @property
396
+ def manifest(self):
397
+ return self.ledger / MANIFEST
398
+
399
+ def entries(self):
400
+ identity, entries = read_manifest(self.manifest)
401
+ if identity != self.identity:
402
+ raise ConfigurationError(
403
+ f"{shown(self.manifest)} changed its store while it was read"
404
+ )
405
+ return entries
406
+
407
+ def pending_dir(self, family):
408
+ return self.root / "pending" / family
409
+
410
+ def accepted_dir(self, family):
411
+ return self.root / "accepted" / family
412
+
413
+ def path(self, entry):
414
+ return self.accepted_dir(entry.family) / entry.version / entry.rel
415
+
416
+ def pending_files(self, family):
417
+ """``{rel: path}`` for every pending file of one family."""
418
+ base = self.pending_dir(family)
419
+ if not base.is_dir():
420
+ return {}
421
+ return {
422
+ p.relative_to(base).as_posix(): p
423
+ for p in sorted(base.rglob("*"))
424
+ if p.is_file()
425
+ and not any(part.startswith(".") for part in p.relative_to(base).parts)
426
+ }
427
+
428
+ def pending_units(self):
429
+ """``{(family, unit): {rel: path}}`` for everything waiting to be accepted."""
430
+ units = {}
431
+ for family in FAMILIES:
432
+ for rel, path in self.pending_files(family).items():
433
+ units.setdefault((family, unit_of(family, rel)), {})[rel] = path
434
+ return units
435
+
436
+
437
+ def _relocate(project):
438
+ """How to point this project at a store kept somewhere else, as a clause."""
439
+ if project.get("provenance.store_dir") is not None:
440
+ return "correct provenance.store_dir"
441
+ return (
442
+ "point $QUIREWORK_PROVENANCE_HOME or provenance.home in "
443
+ f"{shown(user_config_path())} at the directory that holds it"
444
+ )
445
+
446
+
447
+ def _no_store(project, root, led):
448
+ """Why there is no store at ``root``, and what to do about it.
449
+
450
+ A missing store is a first run only when the ledger has accepted nothing.
451
+ Once it has, the store exists somewhere, and the likelier cause is a home
452
+ chosen by other settings than the ones the store was made under: a hook
453
+ whose harness never saw the variable a shell profile exports finds the
454
+ default home instead. ``store init`` there would make a second, empty store
455
+ under the same identity, and later captures would land in it unremarked.
456
+ """
457
+ chosen = describe_home(project)
458
+ where = (
459
+ f"there is no provenance store at {shown(root)}; its home is {chosen}"
460
+ if chosen
461
+ else f"there is no provenance store at {shown(root)}, the directory "
462
+ "provenance.store_dir names"
463
+ )
464
+ _, entries = read_manifest(led / MANIFEST)
465
+ if not entries:
466
+ return f"{where}. Run `quirework provenance store init` to create it"
467
+ return (
468
+ f"{where}. {shown(led / MANIFEST)} lists {len(entries)} accepted "
469
+ "file(s), so this project's store is likely elsewhere on this machine: "
470
+ f"{_relocate(project)}. On a machine that has none of them, `quirework "
471
+ "provenance store init` creates an empty store here to copy them into"
472
+ )
473
+
474
+
475
+ def open_store(project=None):
476
+ """The project's store, refused unless it exists and its ledger names it."""
477
+ project = current_project() if project is None else project
478
+ root = location(project)
479
+ led = ledger(project)
480
+ stored = _identity(root)
481
+ if stored is None:
482
+ raise ConfigurationError(_no_store(project, root, led))
483
+ listed, _ = read_manifest(led / MANIFEST)
484
+ if listed is None:
485
+ raise ConfigurationError(
486
+ f"{shown(led / MANIFEST)} is missing, so nothing binds this ledger to "
487
+ "its store; run `quirework provenance store init`"
488
+ )
489
+ if listed != stored:
490
+ raise ConfigurationError(
491
+ f"the store at {shown(root)} is not this project's: it is store "
492
+ f"{stored}, and {shown(led / MANIFEST)} names store {listed}. Give "
493
+ "this project its own provenance.store name"
494
+ )
495
+ return Store(root, led, stored)
496
+
497
+
498
+ def init(project=None):
499
+ """Create the store or bind the ledger to it; return ``(store, created)``.
500
+
501
+ A ledger that already names a store id keeps it, so a second machine or a
502
+ fresh clone creates an empty store under the identity its ledger was
503
+ accepted into, and copies the sessions in afterwards. A store that exists
504
+ is never adopted by a ledger that names nothing, since that is what two
505
+ projects choosing the same name looks like.
506
+ """
507
+ project = current_project() if project is None else project
508
+ root = location(project)
509
+ led = ledger(project)
510
+ listed, _ = read_manifest(led / MANIFEST)
511
+ stored = _identity(root)
512
+ if stored is not None:
513
+ if listed is None:
514
+ raise ConfigurationError(
515
+ f"a store already exists at {shown(root)}, and this ledger names "
516
+ "none. It belongs to another project unless this ledger's "
517
+ f"{MANIFEST} was lost; give this project its own provenance.store "
518
+ "name"
519
+ )
520
+ if listed != stored:
521
+ raise ConfigurationError(
522
+ f"the store at {shown(root)} is store {stored}, and "
523
+ f"{shown(led / MANIFEST)} names store {listed}"
524
+ )
525
+ return Store(root, led, stored), False
526
+ if root.exists() and any(root.iterdir()):
527
+ raise ConfigurationError(
528
+ f"{shown(root)} is not empty and is not a provenance store; "
529
+ "choose another location"
530
+ )
531
+ identity = listed or str(uuid.uuid4())
532
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
533
+ os.chmod(root, 0o700)
534
+ for family in FAMILIES:
535
+ (root / "pending" / family).mkdir(parents=True, exist_ok=True)
536
+ (root / "accepted" / family).mkdir(parents=True, exist_ok=True)
537
+ _write_atomic(
538
+ root / IDENTITY,
539
+ "# A Quirework provenance store. A project's ledger names this id in\n"
540
+ f"# its {MANIFEST}, and a ledger naming another id is refused.\n"
541
+ f'id = "{identity}"\n',
542
+ )
543
+ if listed is None:
544
+ write_manifest(led / MANIFEST, identity, [])
545
+ return Store(root, led, identity), True
546
+
547
+
548
+ @contextmanager
549
+ def locked(root):
550
+ """One writer at a time across every checkout sharing the store at ``root``.
551
+
552
+ The lock is taken on the store's own directory rather than on a lock file,
553
+ so a store kept inside a repository never gains a file nobody accepted.
554
+ ``accept`` and ``reject`` hold it, and so does a backup while it removes
555
+ what the store no longer has, which is what keeps a session from being
556
+ missing at the destination while it moves from pending to accepted.
557
+ """
558
+ fd = os.open(root, os.O_RDONLY)
559
+ try:
560
+ fcntl.flock(fd, fcntl.LOCK_EX)
561
+ yield
562
+ finally:
563
+ os.close(fd)
564
+
565
+
566
+ # --------------------------------------------------------------------------- #
567
+ # reading the corpus
568
+ # --------------------------------------------------------------------------- #
569
+ def _present(store, entries):
570
+ paths = [store.path(e) for e in entries]
571
+ missing = [e for e, p in zip(entries, paths) if not p.is_file()]
572
+ if missing:
573
+ raise ConfigurationError(
574
+ f"the store at {shown(store.root)} lacks {len(missing)} accepted "
575
+ f"file(s), e.g. {missing[0].stored}; run `quirework provenance store "
576
+ "verify`"
577
+ )
578
+ return paths
579
+
580
+
581
+ def files(family, pattern="*", project=None):
582
+ """Accepted files at the top of one family's sessions, in name order.
583
+
584
+ The corpus every reader shares. Files extracted below a web conversation
585
+ (``files/``, ``artifacts/``) are not sessions and are not listed.
586
+ """
587
+ store = open_store(project)
588
+ chosen = [
589
+ e
590
+ for e in sorted(store.entries(), key=lambda e: e.rel)
591
+ if e.family == family and "/" not in e.rel and fnmatch(e.rel, pattern)
592
+ ]
593
+ return _present(store, chosen)
594
+
595
+
596
+ def pending(family, pattern="*", project=None):
597
+ """Pending files at the top of one family's sessions, in name order."""
598
+ store = open_store(project)
599
+ return [
600
+ path
601
+ for rel, path in sorted(store.pending_files(family).items())
602
+ if "/" not in rel and fnmatch(rel, pattern)
603
+ ]
604
+
605
+
606
+ def path_of(family, rel, project=None):
607
+ """The accepted file a family-relative name refers to, or ``None``."""
608
+ store = open_store(project)
609
+ for entry in store.entries():
610
+ if entry.family == family and entry.rel == rel:
611
+ return _present(store, [entry])[0]
612
+ return None
613
+
614
+
615
+ def newest(family, rel, project=None):
616
+ """The latest capture of a file: pending if there is one, else accepted."""
617
+ store = open_store(project)
618
+ waiting = store.pending_dir(family) / rel
619
+ return waiting if waiting.is_file() else path_of(family, rel, project)
620
+
621
+
622
+ def split(path, project=None):
623
+ """``(family, rel)`` for a file in the store, or ``None`` for any other path.
624
+
625
+ A project that declares no store has none to hold the path, so the answer
626
+ is ``None`` there too, rather than a demand to configure one.
627
+ """
628
+ project = current_project() if project is None else project
629
+ if (
630
+ project.get("provenance.store") is None
631
+ and project.get("provenance.store_dir") is None
632
+ ):
633
+ return None
634
+ path = Path(path).resolve()
635
+ root = location(project)
636
+ if not path.is_relative_to(root):
637
+ return None
638
+ parts = path.relative_to(root).parts
639
+ if len(parts) >= 3 and parts[0] == "pending" and parts[1] in FAMILIES:
640
+ return parts[1], "/".join(parts[2:])
641
+ if len(parts) >= 4 and parts[0] == "accepted" and parts[1] in FAMILIES:
642
+ return parts[1], "/".join(parts[3:])
643
+ return None
644
+
645
+
646
+ def area(path, project=None):
647
+ """``'pending'`` or ``'accepted'`` for a file in the store, else ``None``."""
648
+ if split(path, project) is None:
649
+ return None
650
+ return Path(path).resolve().relative_to(location(project)).parts[0]
651
+
652
+
653
+ def family_of(path, project=None):
654
+ """The family a store file belongs to, or ``None`` outside the store."""
655
+ found = split(path, project)
656
+ return found[0] if found else None
657
+
658
+
659
+ def family_of_reference(name, project=None):
660
+ """The family a path or a recorded reference names, or ``None``.
661
+
662
+ A recorded reference (``cc-sessions/<id>.jsonl``) carries its family as
663
+ its first component; an absolute path is asked of the store.
664
+ """
665
+ if Path(name).is_absolute():
666
+ return family_of(name, project)
667
+ first = PurePosixPath(str(name)).parts[:1]
668
+ return first[0] if first and first[0] in FAMILIES else None
669
+
670
+
671
+ def reference(path, project=None):
672
+ """How a source is named in a record that must outlive where it is kept.
673
+
674
+ A file in the store is named by its family and its name there, never by
675
+ the store's location or the version holding it, so a cold-read report or a
676
+ catalog row keeps its key when the store moves. Anything else is named
677
+ relative to the project, or absolutely when it lies outside.
678
+ """
679
+ project = current_project() if project is None else project
680
+ found = split(path, project)
681
+ if found:
682
+ return f"{found[0]}/{found[1]}"
683
+ path = Path(path).resolve()
684
+ return (
685
+ str(path.relative_to(project.root))
686
+ if path.is_relative_to(project.root)
687
+ else str(path)
688
+ )
689
+
690
+
691
+ # --------------------------------------------------------------------------- #
692
+ # the ledger's lists
693
+ # --------------------------------------------------------------------------- #
694
+ @dataclass(frozen=True)
695
+ class Listed:
696
+ kind: str
697
+ session: str
698
+ reason: str
699
+
700
+
701
+ def _normal_id(value):
702
+ return value.lower() if _UUID.fullmatch(value.lower()) else value
703
+
704
+
705
+ def listed(name, project=None):
706
+ """The entries of one of the ledger's lists, in file order.
707
+
708
+ A line that names no session is reported and skipped, never guessed at:
709
+ every reader of the list goes through here, so none of them can disagree
710
+ with another about what a line means.
711
+ """
712
+ path = ledger(project) / name
713
+ out = []
714
+ if not path.exists():
715
+ return out
716
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
717
+ entry, _, reason = line.partition("#")
718
+ fields = entry.split()
719
+ if not fields:
720
+ continue
721
+ if len(fields) != 2 or fields[0] not in KINDS or not _SID.fullmatch(fields[1]):
722
+ print(
723
+ f"{name}:{lineno}: not '<kind> <session id>', ignoring "
724
+ f"{entry.strip()!r}",
725
+ file=sys.stderr,
726
+ )
727
+ continue
728
+ out.append(Listed(fields[0], _normal_id(fields[1]), reason.strip()))
729
+ return out
730
+
731
+
732
+ def removed_ids(kind=None, project=None):
733
+ """Ids curated out of the corpus, of one kind or of every kind."""
734
+ return {
735
+ e.session for e in listed(REMOVED, project) if kind is None or e.kind == kind
736
+ }
737
+
738
+
739
+ def add_to_list(name, kind, session, reason, project=None):
740
+ """Append one entry unless the session is already listed; True if added."""
741
+ if any(e.session == session for e in listed(name, project)):
742
+ return False
743
+ path = ledger(project) / name
744
+ text = path.read_text(encoding="utf-8") if path.exists() else _LIST_HEADERS[name]
745
+ if text and not text.endswith("\n"):
746
+ text += "\n"
747
+ text += f"{kind} {session} # {reason}\n" if reason else f"{kind} {session}\n"
748
+ _write_atomic(path, text)
749
+ return True
750
+
751
+
752
+ # --------------------------------------------------------------------------- #
753
+ # accepting and rejecting
754
+ # --------------------------------------------------------------------------- #
755
+ def _select(selectors, candidates, verb):
756
+ """The ``(family, unit)`` keys the selectors name, each by an id prefix."""
757
+ chosen = []
758
+ for selector in selectors:
759
+ sel = _normal_id(selector)
760
+ found = sorted(k for k in candidates if k[1] == sel) or sorted(
761
+ k for k in candidates if k[1].startswith(sel)
762
+ )
763
+ if not found:
764
+ raise ValueError(f"no session to {verb} matches '{selector}'")
765
+ if len(found) > 1:
766
+ names = ", ".join(f"{KIND_OF[f]}:{u}" for f, u in found)
767
+ raise ValueError(f"'{selector}' is ambiguous: {names}")
768
+ if found[0] not in chosen:
769
+ chosen.append(found[0])
770
+ return chosen
771
+
772
+
773
+ def settled(paths, family, now=None):
774
+ """Whether a capture has stopped growing long enough to accept."""
775
+ if family not in LIVE_FAMILIES:
776
+ return True
777
+ now = time.time() if now is None else now
778
+ return all(now - p.stat().st_mtime >= SETTLE_SECONDS for p in paths)
779
+
780
+
781
+ def _link_or_copy(source, target):
782
+ try:
783
+ os.link(source, target)
784
+ except OSError:
785
+ shutil.copy2(source, target)
786
+
787
+
788
+ def _freeze(directory):
789
+ for path in directory.rglob("*"):
790
+ if path.is_file():
791
+ path.chmod(path.stat().st_mode & ~0o222)
792
+
793
+
794
+ def _store_version(store, family, previous, waiting):
795
+ """Write the version holding ``previous`` overlaid with ``waiting``.
796
+
797
+ ``previous`` maps a file to the accepted entry it keeps; ``waiting`` maps a
798
+ file to the pending capture that replaces or adds it. Pending files are
799
+ hashed as copied, so a capture rewritten while this runs is either taken
800
+ whole or left pending, never recorded under the wrong hash.
801
+ """
802
+ base = store.accepted_dir(family)
803
+ base.mkdir(parents=True, exist_ok=True)
804
+ stage = Path(tempfile.mkdtemp(dir=base, prefix=".accepting-"))
805
+ try:
806
+ hashes, taken = {}, {}
807
+ for rel, entry in previous.items():
808
+ target = stage / rel
809
+ target.parent.mkdir(parents=True, exist_ok=True)
810
+ _link_or_copy(store.path(entry), target)
811
+ hashes[rel] = entry.sha
812
+ for rel, source in waiting.items():
813
+ target = stage / rel
814
+ target.parent.mkdir(parents=True, exist_ok=True)
815
+ target.unlink(missing_ok=True)
816
+ before = source.stat()
817
+ shutil.copy2(source, target)
818
+ hashes[rel] = sha256_file(target)
819
+ taken[rel] = (before.st_ino, before.st_size, before.st_mtime_ns)
820
+ version = version_of(hashes)
821
+ final = base / version
822
+ if final.exists():
823
+ for rel, sha in hashes.items():
824
+ if not (final / rel).is_file() or sha256_file(final / rel) != sha:
825
+ raise RuntimeError(
826
+ f"{shown(final)} exists but does not hold what its name "
827
+ "promises; run `quirework provenance store verify`"
828
+ )
829
+ else:
830
+ _freeze(stage)
831
+ os.rename(stage, final)
832
+ stage = None
833
+ return version, hashes, taken
834
+ finally:
835
+ if stage is not None:
836
+ shutil.rmtree(stage, ignore_errors=True)
837
+
838
+
839
+ def _clear_pending(store, family, waiting, taken):
840
+ """Remove the pending files that were accepted, unless they grew meanwhile."""
841
+ base = store.pending_dir(family)
842
+ for rel, source in waiting.items():
843
+ try:
844
+ st = source.stat()
845
+ except FileNotFoundError:
846
+ continue
847
+ if (st.st_ino, st.st_size, st.st_mtime_ns) == taken[rel]:
848
+ source.unlink()
849
+ parent = source.parent
850
+ while parent != base and parent.is_relative_to(base):
851
+ try:
852
+ parent.rmdir()
853
+ except OSError:
854
+ break
855
+ parent = parent.parent
856
+
857
+
858
+ def accept(selectors=(), everything=False, live=False, project=None):
859
+ """Accept pending sessions into this ledger; return ``(accepted, skipped)``.
860
+
861
+ Each session becomes one new version holding its previously accepted files
862
+ with its pending ones laid over them, and its lines in ``accepted.txt`` are
863
+ replaced. A session still growing is refused unless ``live`` says to take
864
+ the prefix captured so far. Named sessions are refused outright; with
865
+ ``everything`` a running one is skipped and reported instead, since the
866
+ session doing the accepting is always among them.
867
+ """
868
+ project = current_project() if project is None else project
869
+ store = open_store(project)
870
+ with locked(store.root):
871
+ units = store.pending_units()
872
+ keys = sorted(units) if everything else _select(selectors, units, "accept")
873
+ removed = removed_ids(project=project)
874
+ skipped = []
875
+ for family, unit in list(keys):
876
+ if unit in removed:
877
+ raise ValueError(
878
+ f"{KIND_OF[family]} {unit} is on {REMOVED}; take it off the "
879
+ "list before accepting it"
880
+ )
881
+ if not live and not settled(units[(family, unit)].values(), family):
882
+ if not everything:
883
+ raise ValueError(
884
+ f"{KIND_OF[family]} {unit} changed in the last "
885
+ f"{SETTLE_SECONDS // 3600} hours and may still be running; "
886
+ "accept it later, or pass --live to accept what was "
887
+ "captured"
888
+ )
889
+ keys.remove((family, unit))
890
+ skipped.append((family, unit))
891
+ entries = store.entries()
892
+ done = []
893
+ for family, unit in keys:
894
+ previous = {
895
+ e.rel: e for e in entries if e.family == family and e.unit == unit
896
+ }
897
+ waiting = units[(family, unit)]
898
+ version, hashes, taken = _store_version(store, family, previous, waiting)
899
+ entries = [
900
+ e for e in entries if not (e.family == family and e.unit == unit)
901
+ ] + [Entry(family, version, rel, sha) for rel, sha in hashes.items()]
902
+ write_manifest(store.manifest, store.identity, entries)
903
+ _clear_pending(store, family, waiting, taken)
904
+ done.append((family, unit, version, bool(previous)))
905
+ return done, skipped
906
+
907
+
908
+ def reject(selectors, reason, project=None):
909
+ """Curate sessions out: drop them from this ledger and list them as removed.
910
+
911
+ A pending capture is deleted. An accepted version stays in the store,
912
+ because another branch's ledger may still name it; it only leaves this
913
+ ledger's list.
914
+ """
915
+ if not reason or not reason.strip():
916
+ raise ValueError("say why with --reason; the removed list keeps the reason")
917
+ project = current_project() if project is None else project
918
+ store = open_store(project)
919
+ with locked(store.root):
920
+ units = store.pending_units()
921
+ entries = store.entries()
922
+ candidates = set(units) | {(e.family, e.unit) for e in entries}
923
+ keys = _select(selectors, candidates, "reject")
924
+ for family, unit in keys:
925
+ for path in units.get((family, unit), {}).values():
926
+ path.unlink()
927
+ entries = [
928
+ e for e in entries if not (e.family == family and e.unit == unit)
929
+ ]
930
+ write_manifest(store.manifest, store.identity, entries)
931
+ add_to_list(REMOVED, KIND_OF[family], unit, reason.strip(), project)
932
+ return keys
933
+
934
+
935
+ # --------------------------------------------------------------------------- #
936
+ # status and verification
937
+ # --------------------------------------------------------------------------- #
938
+ def accepted_versions(root):
939
+ """Every version under the store at ``root``, as ``<family>/<version>``.
940
+
941
+ Read from the directories rather than a ledger, since a version accepted on
942
+ any branch is in the store and a backup has to carry it.
943
+ """
944
+ found = set()
945
+ for family in FAMILIES:
946
+ base = Path(root) / "accepted" / family
947
+ if base.is_dir():
948
+ found.update(
949
+ f"{family}/{version.name}"
950
+ for version in base.iterdir()
951
+ if version.is_dir() and _VERSION.fullmatch(version.name)
952
+ )
953
+ return found
954
+
955
+
956
+ def elsewhere(store, entries=None):
957
+ """Sessions with a version in the store that this ledger does not list.
958
+
959
+ Another branch's ledger accepted them, or a branch that did was abandoned.
960
+ Either way their bytes are safe in the store and nothing here reads them.
961
+ """
962
+ entries = store.entries() if entries is None else entries
963
+ mine = {(e.family, e.unit) for e in entries}
964
+ found = set()
965
+ for family in FAMILIES:
966
+ base = store.accepted_dir(family)
967
+ if not base.is_dir():
968
+ continue
969
+ for version in base.iterdir():
970
+ if not _VERSION.fullmatch(version.name):
971
+ continue
972
+ for path in version.rglob("*"):
973
+ if path.is_file():
974
+ key = (
975
+ family,
976
+ unit_of(family, path.relative_to(version).as_posix()),
977
+ )
978
+ if key not in mine:
979
+ found.add(key)
980
+ return found
981
+
982
+
983
+ def verify(project=None):
984
+ """Problems with the accepted files, one line each; empty when sound."""
985
+ store = open_store(project)
986
+ problems = []
987
+ for entry in store.entries():
988
+ path = store.path(entry)
989
+ if not path.is_file():
990
+ problems.append(f"missing {entry.stored}")
991
+ elif sha256_file(path) != entry.sha:
992
+ problems.append(f"altered {entry.stored}")
993
+ return problems
994
+
995
+
996
+ def describe_location(store):
997
+ """One line saying where the store is and whether a repository holds it.
998
+
999
+ A store that is its own repository is the git backup's doing, and its
1000
+ backup line says so; only a repository around the store, such as the one
1001
+ a ``store_dir`` inside a project names, keeps it as part of something else.
1002
+ """
1003
+ repository = versioned(store.root)
1004
+ if repository is None or repository == Path(store.root):
1005
+ return f"{shown(store.root)} (outside version control)"
1006
+ return f"{shown(store.root)} (inside the repository at {shown(repository)})"
1007
+
1008
+
1009
+ def _age(seconds):
1010
+ # A live transcript can be written after status read the clock, so its
1011
+ # age comes out a moment below zero; it changed just now.
1012
+ seconds = max(0, seconds)
1013
+ if seconds < 3600:
1014
+ return f"{int(seconds // 60)} min"
1015
+ if seconds < 172800:
1016
+ return f"{int(seconds // 3600)} h"
1017
+ return f"{int(seconds // 86400)} days"
1018
+
1019
+
1020
+ def uncaptured():
1021
+ """The Claude Code sessions no capture holds as they stand, or ``None``.
1022
+
1023
+ Only Claude Code's, because it is the one harness captured unattended, by a
1024
+ ``SessionEnd`` hook, and the one whose own cleanup deletes the transcripts.
1025
+ A hook that fails does so as its session closes, with nobody reading, so
1026
+ without this its session would wait unseen until that cleanup took it.
1027
+ Codex and Antigravity are captured by hand, and their sweeps report what
1028
+ they did. ``None`` when Claude Code has never run in this project.
1029
+ """
1030
+ from quirework.provenance.ingestion import claude
1031
+
1032
+ return claude.survey()
1033
+
1034
+
1035
+ def cmd_status(_args):
1036
+ store = open_store()
1037
+ entries = store.entries()
1038
+ accepted = {(e.family, e.unit) for e in entries}
1039
+ units = store.pending_units()
1040
+ now = time.time()
1041
+ print(f"store {describe_location(store)}")
1042
+ chosen = describe_home()
1043
+ if chosen:
1044
+ print(f"home {chosen}")
1045
+ print(f"ledger {shown(store.manifest)}")
1046
+ print(f"accepted {len(accepted)} session(s), {len(entries)} file(s)")
1047
+ print(f"pending {len(units)} session(s)")
1048
+ earlier = None
1049
+ for (family, unit), found in sorted(
1050
+ units.items(), key=lambda kv: max(p.stat().st_mtime for p in kv[1].values())
1051
+ ):
1052
+ newest_mtime = max(p.stat().st_mtime for p in found.values())
1053
+ state = "grown" if (family, unit) in accepted else "new"
1054
+ if state == "grown" and family == CC:
1055
+ from quirework.provenance.ingestion import claude
1056
+
1057
+ if earlier is None:
1058
+ earlier = claude.accepted_versions(CC)
1059
+ if any(
1060
+ claude.subagents_only(path, earlier.get(name, ()))
1061
+ for name, path in found.items()
1062
+ ):
1063
+ state = "grown by its sub-agents only, dialogue unchanged"
1064
+ live = "" if settled(found.values(), family, now) else ", may still be running"
1065
+ print(
1066
+ f" {KIND_OF[family]:5} {unit} {state}, changed "
1067
+ f"{_age(now - newest_mtime)} ago{live}"
1068
+ )
1069
+ missed = uncaptured()
1070
+ if missed is not None:
1071
+ print(f"uncaptured {len(missed.due)} Claude Code session(s)")
1072
+ for path in sorted(missed.due, key=lambda p: p.stat().st_mtime):
1073
+ state = (
1074
+ "gained sub-agents since its capture, dialogue unchanged"
1075
+ if path in missed.subagents_only
1076
+ else "grown since its capture"
1077
+ if path in missed.grown
1078
+ else "never captured"
1079
+ )
1080
+ live = "" if settled([path], CC, now) else ", may still be running"
1081
+ print(
1082
+ f" {'cc':5} {path.stem} {state}, changed "
1083
+ f"{_age(now - path.stat().st_mtime)} ago{live}"
1084
+ )
1085
+ others = elsewhere(store, entries)
1086
+ if others:
1087
+ print(
1088
+ f"elsewhere {len(others)} session(s) in the store that this ledger does "
1089
+ "not list (accepted on another branch)"
1090
+ )
1091
+ from quirework.provenance import backup
1092
+
1093
+ source = backup.Source(store.root, store.identity)
1094
+ lines, due = backup.report(source, now)
1095
+ if lines is None:
1096
+ lines = [f"backup {backup.unconfigured(source)}"]
1097
+ for line in lines:
1098
+ print(line)
1099
+ if units:
1100
+ print(
1101
+ "next: read each with `quirework provenance show ID`, then "
1102
+ "`quirework provenance accept ID` or "
1103
+ "`quirework provenance reject ID --reason TEXT`"
1104
+ )
1105
+ if missed is not None and any(settled([p], CC, now) for p in missed.due):
1106
+ print(
1107
+ "next: capture the uncaptured sessions with `quirework provenance "
1108
+ "ingest claude`; one that is no longer running was missed by its "
1109
+ "capture hook, and Claude Code deletes a transcript 30 days after "
1110
+ "it last changed, by default"
1111
+ )
1112
+ if due:
1113
+ print(
1114
+ "next: back the store up with `quirework provenance store backup`; "
1115
+ "`--target NAME` runs one target"
1116
+ )
1117
+ return 0
1118
+
1119
+
1120
+ def cmd_verify(_args):
1121
+ store = open_store()
1122
+ problems = verify()
1123
+ for line in problems:
1124
+ print(line)
1125
+ count = len(store.entries())
1126
+ if problems:
1127
+ print(f"{len(problems)} of {count} accepted file(s) do not match the ledger")
1128
+ return 1
1129
+ print(f"verified {count} accepted file(s) against {shown(store.manifest)}")
1130
+ return 0
1131
+
1132
+
1133
+ def cmd_init(_args):
1134
+ if not current_project().get("provenance.enabled", False):
1135
+ raise ConfigurationError(
1136
+ "provenance is not enabled; set provenance.enabled = true and name "
1137
+ "the store before creating it"
1138
+ )
1139
+ store, created = init()
1140
+ if created:
1141
+ from quirework.provenance import backup
1142
+
1143
+ print(f"created store {store.identity} at {describe_location(store)}")
1144
+ chosen = describe_home()
1145
+ if chosen:
1146
+ print(f"its home is {chosen}")
1147
+ print(
1148
+ f"backup: {backup.unconfigured(backup.Source(store.root, store.identity))}"
1149
+ )
1150
+ missing = [e for e in store.entries() if not store.path(e).is_file()]
1151
+ if missing:
1152
+ print(
1153
+ f"the ledger lists {len(missing)} accepted file(s) this new store "
1154
+ "lacks; copy the store's accepted/ directory from a machine that "
1155
+ "has it, then run `quirework provenance store verify`. If this "
1156
+ "machine already keeps them in another store, this one is in the "
1157
+ f"wrong place: {_relocate(current_project())}, and delete the "
1158
+ "empty store just created"
1159
+ )
1160
+ else:
1161
+ print(f"store {store.identity} is ready at {describe_location(store)}")
1162
+ return 0
1163
+
1164
+
1165
+ BACKUP_HELP = """\
1166
+ Copy the store to the targets its backup.toml names, so that it is never
1167
+ the only copy of a session. Claude Code deletes its own transcripts 30
1168
+ days after they last changed, and from then on only the store has them.
1169
+
1170
+ backup.toml sits in the store, beside store.toml, rather than in the
1171
+ project's configuration, because what it names belongs to you and this
1172
+ machine:
1173
+
1174
+ # <store>/backup.toml
1175
+ [targets.gdrive]
1176
+ driver = "rclone"
1177
+ destination = "gdrive:quirework-provenance/my-book"
1178
+
1179
+ A store may declare several targets. The drivers:
1180
+
1181
+ rclone destination = "remote:path", on any remote `rclone config`
1182
+ has set up: Google Drive, SFTP, a crypt layer over either
1183
+ directory destination = an absolute path that already exists, such as
1184
+ a mounted share; never created, so an unmounted disk is
1185
+ refused rather than filled in its place
1186
+ git the store becomes its own repository and each run a commit;
1187
+ optional remote = a URL or path to push to, and branch =
1188
+ a name (main by default)
1189
+ command command = ["script", "arg", ...], a script at the store's
1190
+ top level, owned by you and writable by nobody else; it
1191
+ gets QUIREWORK_STORE, QUIREWORK_STORE_ID and
1192
+ QUIREWORK_BACKUP_TARGET
1193
+ module:function
1194
+ an installed extension that returns a backup Driver
1195
+
1196
+ For rclone and directory, Quirework decides what may change at the
1197
+ destination. An accepted file is never overwritten or deleted there. A
1198
+ pending file is deleted only once the version that replaced it has
1199
+ arrived, so a session is never missing in between. Names starting with
1200
+ a dot are skipped, and a destination holding another store is refused.
1201
+
1202
+ Each run's record and log go under
1203
+ $XDG_STATE_HOME/quirework/backup/<store id>/, and `quirework provenance
1204
+ store status` reports when each target last succeeded and what changed
1205
+ since. `quirework config template backup` prints an annotated file.
1206
+
1207
+ Exit status: 0 when every target finished, 1 when one failed or nothing
1208
+ was configured, 2 when the configuration cannot be read.
1209
+ """
1210
+
1211
+
1212
+ def main(argv=None):
1213
+ """``quirework provenance store``: create, inspect, verify and back up."""
1214
+ parser = argparse.ArgumentParser(
1215
+ prog="quirework provenance store",
1216
+ description="Create, inspect, verify and back up the store that holds "
1217
+ "the sessions.",
1218
+ )
1219
+ actions = parser.add_subparsers(dest="action", required=True, metavar="ACTION")
1220
+ actions.add_parser(
1221
+ "init",
1222
+ help="create this project's store, or bind a new ledger to it",
1223
+ description="Create this project's store under the identity the "
1224
+ "ledger's accepted.txt names, or bind a new ledger to a new store.",
1225
+ )
1226
+ actions.add_parser(
1227
+ "status",
1228
+ help="show where the store is, what is pending, and what capture missed",
1229
+ description="Show where the store is and which setting chose its home, "
1230
+ "the sessions waiting in its pending area, and the Claude Code "
1231
+ "sessions no capture holds as they stand. Writes nothing.",
1232
+ )
1233
+ actions.add_parser(
1234
+ "verify",
1235
+ help="hash every accepted file against the ledger's accepted.txt",
1236
+ description="Hash every accepted file and name any that is missing "
1237
+ "from the store or no longer matches the ledger's accepted.txt.",
1238
+ )
1239
+ backup = actions.add_parser(
1240
+ "backup",
1241
+ help="copy the store to the targets its backup.toml names",
1242
+ description=BACKUP_HELP,
1243
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1244
+ )
1245
+ backup.add_argument(
1246
+ "--target",
1247
+ action="append",
1248
+ metavar="NAME",
1249
+ help="back up to this target only; repeat it for several. Without it, "
1250
+ "every target the store declares",
1251
+ )
1252
+ backup.add_argument(
1253
+ "--all",
1254
+ action="store_true",
1255
+ dest="every_store",
1256
+ help="back up every store under the provenance home instead of this "
1257
+ "project's; needs no project, which is what a timer runs",
1258
+ )
1259
+ backup.add_argument(
1260
+ "--check",
1261
+ action="store_true",
1262
+ help="read backup.toml and reach each destination, copying nothing",
1263
+ )
1264
+ args = parser.parse_args(argv)
1265
+ if args.action == "backup":
1266
+ from quirework.provenance import backup as backups
1267
+
1268
+ return backups.cmd_backup(args)
1269
+ return {"init": cmd_init, "status": cmd_status, "verify": cmd_verify}[args.action](
1270
+ args
1271
+ )