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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|