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
quirework/core/usage.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""What ran, when, and in which project -- recorded locally, sent nowhere.
|
|
2
|
+
|
|
3
|
+
Quirework has grown a lot of commands and nobody knows which of them earn
|
|
4
|
+
their keep. This answers that from the only evidence that is not a guess:
|
|
5
|
+
the commands the CLI actually dispatched. One JSON object per invocation,
|
|
6
|
+
appended under the user's state directory, read back by `quirework usage`.
|
|
7
|
+
|
|
8
|
+
It is deliberately not a transcript. A record carries the command, the
|
|
9
|
+
procedure's name when the command names one, the project root, the exit
|
|
10
|
+
status and how long it took. COMMAND ARGUMENTS ARE NEVER RECORDED:
|
|
11
|
+
`provenance search` takes a verbatim phrase out of the author's notes and
|
|
12
|
+
`publish redpen` takes review prose, so a file that quietly accumulated argv
|
|
13
|
+
would be a private corpus nobody agreed to keep. The one argument that is
|
|
14
|
+
recorded is a procedure's name, and only once the project has resolved it
|
|
15
|
+
to a file under its own `sops.root`, because "which procedure" is the
|
|
16
|
+
question this module exists to answer.
|
|
17
|
+
|
|
18
|
+
The store is per-user, not per-project, which is the opposite of how
|
|
19
|
+
`Project.state_root` works and is the point: the interesting comparison is
|
|
20
|
+
across a person's books, so the project root is a FIELD here rather than a
|
|
21
|
+
directory. A project switches the whole thing off with `enabled = false`
|
|
22
|
+
under `[usage]`; `QUIREWORK_USAGE=0` switches it off for one process or
|
|
23
|
+
one shell, which is what a shared machine or a test harness wants.
|
|
24
|
+
|
|
25
|
+
Recording never costs a command its result. An unwritable store is
|
|
26
|
+
reported once on stderr and then left alone for the rest of the process --
|
|
27
|
+
degraded out loud rather than in silence, and never by failing the work the
|
|
28
|
+
author actually asked for.
|
|
29
|
+
|
|
30
|
+
Three kinds of invocation are outside the count, and a report read
|
|
31
|
+
without knowing that will understate them. `--help` is not use of a
|
|
32
|
+
command. The `config` and `usage` groups inspect the tool rather than do
|
|
33
|
+
its work. And a procedure read straight off disk by an agent following a
|
|
34
|
+
generated skill never reaches this code, so procedure counts are a floor
|
|
35
|
+
rather than a total.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
import tempfile
|
|
46
|
+
import time
|
|
47
|
+
from collections import Counter
|
|
48
|
+
from contextlib import contextmanager
|
|
49
|
+
from datetime import date, datetime, timedelta, timezone
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from quirework import __version__
|
|
53
|
+
from quirework.project import ConfigurationError, current_project
|
|
54
|
+
from quirework.runs.storage import lock, shown
|
|
55
|
+
|
|
56
|
+
SCHEMA = 1
|
|
57
|
+
PATTERN = "usage-%Y-%m.jsonl"
|
|
58
|
+
|
|
59
|
+
# Commands whose first argument names a procedure rather than carrying the
|
|
60
|
+
# author's material. Extend this only for an argument drawn from a set the
|
|
61
|
+
# tool itself defines; a bare word on any other command line is as likely to
|
|
62
|
+
# be a phrase out of the book as it is to be a keyword.
|
|
63
|
+
NAMED = {("sop", "show"), ("sop", "explain")}
|
|
64
|
+
|
|
65
|
+
# What a procedure's name can look like: a file stem, nothing else. A search
|
|
66
|
+
# phrase has spaces, a path has separators, an option has a leading dash, and
|
|
67
|
+
# none of the three gets past this.
|
|
68
|
+
STEM = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
|
|
69
|
+
|
|
70
|
+
_silenced = False # one warning per process, not one per command
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def enabled(project) -> bool:
|
|
74
|
+
"""Whether this project records anything, environment first.
|
|
75
|
+
|
|
76
|
+
The environment wins over the file for the same reason `QUIREWORK_PROJECT`
|
|
77
|
+
does: someone running on a borrowed machine, or a test harness driving a
|
|
78
|
+
project it did not write, needs an off switch that does not require
|
|
79
|
+
editing the book's configuration.
|
|
80
|
+
"""
|
|
81
|
+
override = os.environ.get("QUIREWORK_USAGE")
|
|
82
|
+
if override is not None:
|
|
83
|
+
return override.strip().lower() not in ("", "0", "off", "false", "no")
|
|
84
|
+
return bool(project.get("usage.enabled", True))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def store(project) -> Path:
|
|
88
|
+
"""The directory holding the records, shared by every project of this user."""
|
|
89
|
+
default = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
|
|
90
|
+
return project.path("usage.store", str(default / "quirework" / "usage"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _target(command, argv):
|
|
94
|
+
first = next((a for a in argv if not a.startswith("-")), None)
|
|
95
|
+
if tuple(command) not in NAMED or not first or not STEM.match(first):
|
|
96
|
+
return None
|
|
97
|
+
return first
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Outcome:
|
|
101
|
+
"""The status a dispatched command reached, filled in by the caller."""
|
|
102
|
+
|
|
103
|
+
__slots__ = ("status",)
|
|
104
|
+
|
|
105
|
+
def __init__(self):
|
|
106
|
+
self.status = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def record(command, argv=(), *, project):
|
|
111
|
+
"""Time one dispatched command and append its record when it finishes.
|
|
112
|
+
|
|
113
|
+
The caller sets `.status` on the yielded outcome. A command that raises
|
|
114
|
+
leaves it unset and is recorded with a null status, which is how the
|
|
115
|
+
report tells a failure apart from a clean non-zero exit.
|
|
116
|
+
"""
|
|
117
|
+
key = tuple(command)
|
|
118
|
+
outcome = Outcome()
|
|
119
|
+
if key[0] == "usage" or not enabled(project):
|
|
120
|
+
yield outcome
|
|
121
|
+
return
|
|
122
|
+
started = time.monotonic()
|
|
123
|
+
try:
|
|
124
|
+
yield outcome
|
|
125
|
+
finally:
|
|
126
|
+
entry = {
|
|
127
|
+
"schema": SCHEMA,
|
|
128
|
+
"at": _stamp(),
|
|
129
|
+
"command": " ".join(key),
|
|
130
|
+
"project": str(project.root),
|
|
131
|
+
"status": outcome.status,
|
|
132
|
+
"ms": round((time.monotonic() - started) * 1000),
|
|
133
|
+
"version": __version__,
|
|
134
|
+
}
|
|
135
|
+
target = _target(key, argv)
|
|
136
|
+
if target and outcome.status == 0:
|
|
137
|
+
# A name only gets kept once the project resolved it to a
|
|
138
|
+
# procedure. A failed lookup is the one case where the argument
|
|
139
|
+
# was not drawn from `sops.root`, and dropping it is what lets
|
|
140
|
+
# this file promise that a recorded name is a real procedure.
|
|
141
|
+
entry["target"] = target
|
|
142
|
+
append(_named(entry, project), project=project)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _stamp() -> str:
|
|
146
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _named(entry, project):
|
|
150
|
+
name = project.get("project.name")
|
|
151
|
+
if isinstance(name, str) and name.strip():
|
|
152
|
+
entry["name"] = name.strip()
|
|
153
|
+
return entry
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def seat(workflow, config, *, project=None):
|
|
157
|
+
"""Record that a model seat was launched: through which harness, on
|
|
158
|
+
which model, at what effort, for which kind of workflow.
|
|
159
|
+
|
|
160
|
+
This answers a question the command count cannot: whether a harness or
|
|
161
|
+
a model is used at all, across every book. It records nothing about the
|
|
162
|
+
seat's assignment. No prompt, no material name, no persona and no seat
|
|
163
|
+
id reach the store, for the same reason command arguments never do; a
|
|
164
|
+
file that accumulated them would be a private corpus nobody agreed to
|
|
165
|
+
keep. A run stopped by a provider is still a launch, so the record is
|
|
166
|
+
made before the process starts and carries no outcome.
|
|
167
|
+
"""
|
|
168
|
+
project = current_project() if project is None else project
|
|
169
|
+
if not enabled(project):
|
|
170
|
+
return
|
|
171
|
+
entry = {
|
|
172
|
+
"schema": SCHEMA,
|
|
173
|
+
"kind": "seat",
|
|
174
|
+
"at": _stamp(),
|
|
175
|
+
"project": str(project.root),
|
|
176
|
+
"workflow": workflow,
|
|
177
|
+
"harness": config["harness"],
|
|
178
|
+
"model": config["model"],
|
|
179
|
+
"effort": config.get("effort"),
|
|
180
|
+
"version": __version__,
|
|
181
|
+
}
|
|
182
|
+
append(_named(entry, project), project=project)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def append(entry, *, project):
|
|
186
|
+
"""Add one record, or say once why this run cannot.
|
|
187
|
+
|
|
188
|
+
A command's result must not depend on whether a metrics file was
|
|
189
|
+
writable, so the failure is reported and then dropped rather than
|
|
190
|
+
raised. It is reported once: a full disk would otherwise print the same
|
|
191
|
+
line for every command in a build.
|
|
192
|
+
"""
|
|
193
|
+
global _silenced
|
|
194
|
+
if _silenced:
|
|
195
|
+
return
|
|
196
|
+
line = json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n"
|
|
197
|
+
try:
|
|
198
|
+
directory = store(project)
|
|
199
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
200
|
+
path = directory / datetime.now(timezone.utc).strftime(PATTERN)
|
|
201
|
+
with (
|
|
202
|
+
lock(directory / ".lock", wait=True),
|
|
203
|
+
path.open("a", encoding="utf-8") as f,
|
|
204
|
+
):
|
|
205
|
+
f.write(line)
|
|
206
|
+
except (OSError, ConfigurationError) as exc:
|
|
207
|
+
_silenced = True
|
|
208
|
+
print(f"quirework: not recording usage this run: {exc}", file=sys.stderr)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read(project, *, since=None, here=False):
|
|
212
|
+
"""Every record in the store, oldest first, with a count of torn lines.
|
|
213
|
+
|
|
214
|
+
A line that will not parse is a partial write from an interrupted
|
|
215
|
+
command, not a bug in the reader. It is counted and reported rather than
|
|
216
|
+
skipped in silence, because a store that is quietly losing records
|
|
217
|
+
should say so.
|
|
218
|
+
|
|
219
|
+
`since` accepts either form `when` does, and is normalized here rather
|
|
220
|
+
than at the command line, so no caller can hand an age to a comparison
|
|
221
|
+
that only understands a date.
|
|
222
|
+
|
|
223
|
+
`here` means this project, not this directory. A book gets checked out
|
|
224
|
+
more than once -- a canon pass in one worktree while tooling moves in
|
|
225
|
+
another -- and a command run in either one is the same book's work, so
|
|
226
|
+
every checkout's copy of the project counts.
|
|
227
|
+
"""
|
|
228
|
+
directory = store(project)
|
|
229
|
+
since = when(since) if since else None
|
|
230
|
+
roots = None
|
|
231
|
+
if here:
|
|
232
|
+
from quirework.provenance import worktrees
|
|
233
|
+
|
|
234
|
+
roots = {str(p) for p in worktrees.project_trees(project.root)}
|
|
235
|
+
rows, damaged = [], 0
|
|
236
|
+
if not directory.is_dir():
|
|
237
|
+
return rows, damaged
|
|
238
|
+
for path in sorted(directory.glob("usage-*.jsonl")):
|
|
239
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
240
|
+
if not line.strip():
|
|
241
|
+
continue
|
|
242
|
+
try:
|
|
243
|
+
entry = json.loads(line)
|
|
244
|
+
except json.JSONDecodeError:
|
|
245
|
+
damaged += 1
|
|
246
|
+
continue
|
|
247
|
+
if not isinstance(entry, dict) or "at" not in entry:
|
|
248
|
+
damaged += 1
|
|
249
|
+
continue
|
|
250
|
+
if since and entry["at"][:10] < since:
|
|
251
|
+
continue
|
|
252
|
+
if roots is not None and entry.get("project") not in roots:
|
|
253
|
+
continue
|
|
254
|
+
rows.append(entry)
|
|
255
|
+
rows.sort(key=lambda e: e["at"])
|
|
256
|
+
return rows, damaged
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def when(text):
|
|
260
|
+
"""A `--since` or `--before` value as an ISO date.
|
|
261
|
+
|
|
262
|
+
Both forms a person reaches for are accepted, so nobody has to guess
|
|
263
|
+
which one this tool wanted: an age like `30d`, `12w` or `6m`, or a
|
|
264
|
+
literal date like `2026-01-01`.
|
|
265
|
+
"""
|
|
266
|
+
text = text.strip()
|
|
267
|
+
match = re.fullmatch(r"(\d+)\s*([dwm])", text, re.I)
|
|
268
|
+
if match:
|
|
269
|
+
scale = {"d": 1, "w": 7, "m": 30}[match[2].lower()]
|
|
270
|
+
# UTC, because that is the clock the `at` stamps it filters were
|
|
271
|
+
# written by; a local date is a day out on either side of midnight.
|
|
272
|
+
today = datetime.now(timezone.utc).date()
|
|
273
|
+
return (today - timedelta(days=int(match[1]) * scale)).isoformat()
|
|
274
|
+
try:
|
|
275
|
+
return date.fromisoformat(text).isoformat()
|
|
276
|
+
except ValueError as exc:
|
|
277
|
+
raise ValueError(f"not a date or an age like 30d: {text!r}") from exc
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_seat(entry) -> bool:
|
|
281
|
+
return entry.get("kind") == "seat"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def summarize(rows):
|
|
285
|
+
"""Counts by command, by procedure, by seat and by project, plus the span.
|
|
286
|
+
|
|
287
|
+
A record without a `kind` is a command; the field arrived with seat
|
|
288
|
+
records and older files never wrote it.
|
|
289
|
+
"""
|
|
290
|
+
commands = [e for e in rows if not _is_seat(e)]
|
|
291
|
+
seats = [e for e in rows if _is_seat(e)]
|
|
292
|
+
procedures = {}
|
|
293
|
+
for entry in commands:
|
|
294
|
+
target = entry.get("target")
|
|
295
|
+
if not target:
|
|
296
|
+
continue
|
|
297
|
+
seen = procedures.setdefault(
|
|
298
|
+
target, {"count": 0, "first": None, "last": None, "projects": {}}
|
|
299
|
+
)
|
|
300
|
+
seen["count"] += 1
|
|
301
|
+
# One name can be a procedure in several books, and the store is
|
|
302
|
+
# shared across all of them, so a count without its projects cannot
|
|
303
|
+
# be read back to the book it came from.
|
|
304
|
+
seen["projects"][entry["project"]] = (
|
|
305
|
+
seen["projects"].get(entry["project"], 0) + 1
|
|
306
|
+
)
|
|
307
|
+
at = entry["at"]
|
|
308
|
+
seen["first"] = at if seen["first"] is None else min(seen["first"], at)
|
|
309
|
+
seen["last"] = at if seen["last"] is None else max(seen["last"], at)
|
|
310
|
+
pairs = {}
|
|
311
|
+
for entry in seats:
|
|
312
|
+
key = f"{entry['harness']} {entry['model']}"
|
|
313
|
+
seen = pairs.setdefault(
|
|
314
|
+
key,
|
|
315
|
+
{
|
|
316
|
+
"harness": entry["harness"],
|
|
317
|
+
"model": entry["model"],
|
|
318
|
+
"count": 0,
|
|
319
|
+
"first": None,
|
|
320
|
+
"last": None,
|
|
321
|
+
"workflows": {},
|
|
322
|
+
"efforts": {},
|
|
323
|
+
"projects": {},
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
seen["count"] += 1
|
|
327
|
+
at = entry["at"]
|
|
328
|
+
seen["first"] = at if seen["first"] is None else min(seen["first"], at)
|
|
329
|
+
seen["last"] = at if seen["last"] is None else max(seen["last"], at)
|
|
330
|
+
for field, value in (
|
|
331
|
+
("workflows", entry.get("workflow")),
|
|
332
|
+
("efforts", entry.get("effort")),
|
|
333
|
+
("projects", entry["project"]),
|
|
334
|
+
):
|
|
335
|
+
seen[field][value] = seen[field].get(value, 0) + 1
|
|
336
|
+
names = {}
|
|
337
|
+
for entry in rows:
|
|
338
|
+
if entry.get("name"):
|
|
339
|
+
names[entry["project"]] = entry["name"]
|
|
340
|
+
return {
|
|
341
|
+
"events": len(commands),
|
|
342
|
+
"first": rows[0]["at"] if rows else None,
|
|
343
|
+
"last": rows[-1]["at"] if rows else None,
|
|
344
|
+
"commands": dict(Counter(e["command"] for e in commands).most_common()),
|
|
345
|
+
"procedures": dict(
|
|
346
|
+
sorted(procedures.items(), key=lambda kv: (-kv[1]["count"], kv[0]))
|
|
347
|
+
),
|
|
348
|
+
"seats": {
|
|
349
|
+
"count": len(seats),
|
|
350
|
+
"harnesses": dict(Counter(e["harness"] for e in seats).most_common()),
|
|
351
|
+
"workflows": dict(Counter(e["workflow"] for e in seats).most_common()),
|
|
352
|
+
"pairs": dict(
|
|
353
|
+
sorted(pairs.items(), key=lambda kv: (-kv[1]["count"], kv[0]))
|
|
354
|
+
),
|
|
355
|
+
},
|
|
356
|
+
"projects": dict(Counter(e["project"] for e in rows).most_common()),
|
|
357
|
+
"project_names": names,
|
|
358
|
+
"months": dict(sorted(Counter(e["at"][:7] for e in rows).items())),
|
|
359
|
+
"failures": sum(1 for e in commands if e.get("status") not in (0, None)),
|
|
360
|
+
"errors": sum(1 for e in commands if e.get("status") is None),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def prune(project, cutoff, *, dry_run=False):
|
|
365
|
+
"""Drop records older than a date, and any file left with nothing in it."""
|
|
366
|
+
directory = store(project)
|
|
367
|
+
dropped, files = 0, 0
|
|
368
|
+
if not directory.is_dir():
|
|
369
|
+
return dropped, files
|
|
370
|
+
with lock(directory / ".lock", wait=True):
|
|
371
|
+
for path in sorted(directory.glob("usage-*.jsonl")):
|
|
372
|
+
lines = [
|
|
373
|
+
line
|
|
374
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
375
|
+
if line.strip()
|
|
376
|
+
]
|
|
377
|
+
keep = []
|
|
378
|
+
for line in lines:
|
|
379
|
+
try:
|
|
380
|
+
entry = json.loads(line)
|
|
381
|
+
fresh = isinstance(entry, dict) and entry.get("at", "")[:10] >= (
|
|
382
|
+
cutoff
|
|
383
|
+
)
|
|
384
|
+
except json.JSONDecodeError:
|
|
385
|
+
fresh = False # a torn line has no date to defend it
|
|
386
|
+
if fresh:
|
|
387
|
+
keep.append(line)
|
|
388
|
+
if len(keep) == len(lines):
|
|
389
|
+
continue
|
|
390
|
+
dropped += len(lines) - len(keep)
|
|
391
|
+
if dry_run:
|
|
392
|
+
files += not keep
|
|
393
|
+
continue
|
|
394
|
+
if not keep:
|
|
395
|
+
path.unlink()
|
|
396
|
+
files += 1
|
|
397
|
+
continue
|
|
398
|
+
with tempfile.NamedTemporaryFile(
|
|
399
|
+
"w", dir=directory, delete=False, encoding="utf-8"
|
|
400
|
+
) as f:
|
|
401
|
+
f.write("\n".join(keep) + "\n")
|
|
402
|
+
temporary = f.name
|
|
403
|
+
os.replace(temporary, path)
|
|
404
|
+
return dropped, files
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _show(project, options):
|
|
408
|
+
rows, damaged = read(project, since=options.since, here=options.here)
|
|
409
|
+
report = summarize(rows)
|
|
410
|
+
if options.json:
|
|
411
|
+
print(json.dumps({**report, "store": str(store(project))}, indent=2))
|
|
412
|
+
return 0
|
|
413
|
+
where = shown(store(project))
|
|
414
|
+
if not rows:
|
|
415
|
+
print(f"No usage recorded in {where}.")
|
|
416
|
+
if not enabled(project):
|
|
417
|
+
print("Recording is off for this project.")
|
|
418
|
+
return 0
|
|
419
|
+
span = f"{report['first'][:10]} to {report['last'][:10]}"
|
|
420
|
+
print(
|
|
421
|
+
f"{report['events']} command(s) and {report['seats']['count']} seat "
|
|
422
|
+
f"launch(es) recorded, {span}, in {where}."
|
|
423
|
+
)
|
|
424
|
+
print("\nCommands")
|
|
425
|
+
for name, count in list(report["commands"].items())[: options.limit]:
|
|
426
|
+
print(f" {count:>6} {name}")
|
|
427
|
+
if report["procedures"]:
|
|
428
|
+
print("\nProcedures")
|
|
429
|
+
for name, seen in list(report["procedures"].items())[: options.limit]:
|
|
430
|
+
window = f"{seen['first'][:10]} to {seen['last'][:10]}"
|
|
431
|
+
print(f" {seen['count']:>6} {name:<28} {window}")
|
|
432
|
+
if report["seats"]["count"]:
|
|
433
|
+
print("\nSeats")
|
|
434
|
+
for key, seen in list(report["seats"]["pairs"].items())[: options.limit]:
|
|
435
|
+
kinds = ", ".join(f"{k} {v}" for k, v in seen["workflows"].items())
|
|
436
|
+
print(f" {seen['count']:>6} {key:<40} {kinds}")
|
|
437
|
+
print("\nProjects")
|
|
438
|
+
for path, count in list(report["projects"].items())[: options.limit]:
|
|
439
|
+
label = report["project_names"].get(path, "")
|
|
440
|
+
print(f" {count:>6} {shown(path):<40} {label}")
|
|
441
|
+
print("\nBy month")
|
|
442
|
+
for month, count in report["months"].items():
|
|
443
|
+
print(f" {count:>6} {month}")
|
|
444
|
+
if report["failures"] or report["errors"]:
|
|
445
|
+
print(
|
|
446
|
+
f"\n{report['failures']} non-zero exit(s), "
|
|
447
|
+
f"{report['errors']} command(s) that raised."
|
|
448
|
+
)
|
|
449
|
+
if damaged:
|
|
450
|
+
print(f"{damaged} unreadable record(s) skipped.", file=sys.stderr)
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _sops(project, options):
|
|
455
|
+
rows, damaged = read(project, since=options.since, here=options.here)
|
|
456
|
+
report = summarize(rows)
|
|
457
|
+
procedures = list(report["procedures"].items())[: options.limit]
|
|
458
|
+
if options.json:
|
|
459
|
+
print(json.dumps(dict(procedures), indent=2))
|
|
460
|
+
return 0
|
|
461
|
+
if not procedures:
|
|
462
|
+
print("No procedure reads recorded.")
|
|
463
|
+
print(
|
|
464
|
+
"Only `sop show` and `sop explain` are counted; a procedure an "
|
|
465
|
+
"agent reads\nstraight off disk never reaches Quirework."
|
|
466
|
+
)
|
|
467
|
+
return 0
|
|
468
|
+
for name, seen in procedures:
|
|
469
|
+
window = f"{seen['first'][:10]} to {seen['last'][:10]}"
|
|
470
|
+
print(f" {seen['count']:>6} {name:<32} {window}")
|
|
471
|
+
# Where the reads came from, since the store spans every book this
|
|
472
|
+
# user writes and the name alone does not say which one this was.
|
|
473
|
+
for root, count in sorted(seen["projects"].items(), key=lambda kv: -kv[1]):
|
|
474
|
+
label = report["project_names"].get(root, "")
|
|
475
|
+
share = f"{count:>6} " if len(seen["projects"]) > 1 else " " * 8
|
|
476
|
+
print(f" {share}{shown(root)} {label}".rstrip())
|
|
477
|
+
if damaged:
|
|
478
|
+
print(f"{damaged} unreadable record(s) skipped.", file=sys.stderr)
|
|
479
|
+
return 0
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _seats(project, options):
|
|
483
|
+
rows, damaged = read(project, since=options.since, here=options.here)
|
|
484
|
+
report = summarize(rows)
|
|
485
|
+
pairs = list(report["seats"]["pairs"].items())[: options.limit]
|
|
486
|
+
if options.json:
|
|
487
|
+
print(json.dumps(dict(pairs), indent=2))
|
|
488
|
+
return 0
|
|
489
|
+
if not pairs:
|
|
490
|
+
print("No seat launches recorded.")
|
|
491
|
+
print(
|
|
492
|
+
"Rooms, workshops, reviews, runs and experiments record one line per\n"
|
|
493
|
+
"seat they launch, with its harness and model and nothing about its "
|
|
494
|
+
"assignment."
|
|
495
|
+
)
|
|
496
|
+
return 0
|
|
497
|
+
for key, seen in pairs:
|
|
498
|
+
window = f"{seen['first'][:10]} to {seen['last'][:10]}"
|
|
499
|
+
kinds = ", ".join(f"{k} {v}" for k, v in seen["workflows"].items())
|
|
500
|
+
print(f" {seen['count']:>6} {key:<40} {window} {kinds}")
|
|
501
|
+
for root, count in sorted(seen["projects"].items(), key=lambda kv: -kv[1]):
|
|
502
|
+
label = report["project_names"].get(root, "")
|
|
503
|
+
share = f"{count:>6} " if len(seen["projects"]) > 1 else " " * 8
|
|
504
|
+
print(f" {share}{shown(root)} {label}".rstrip())
|
|
505
|
+
if damaged:
|
|
506
|
+
print(f"{damaged} unreadable record(s) skipped.", file=sys.stderr)
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _log(project, options):
|
|
511
|
+
rows, damaged = read(project, since=options.since, here=options.here)
|
|
512
|
+
recent = rows[-options.limit :]
|
|
513
|
+
if options.json:
|
|
514
|
+
print(json.dumps(recent, indent=2))
|
|
515
|
+
return 0
|
|
516
|
+
for entry in recent:
|
|
517
|
+
if _is_seat(entry):
|
|
518
|
+
what = f"seat {entry['workflow']} {entry['harness']} {entry['model']}"
|
|
519
|
+
print(
|
|
520
|
+
f" {entry['at']} {what:<34} {'':>8} {'':<8} {shown(entry['project'])}"
|
|
521
|
+
)
|
|
522
|
+
continue
|
|
523
|
+
status = entry.get("status")
|
|
524
|
+
mark = (
|
|
525
|
+
"ok" if status == 0 else ("raised" if status is None else f"exit {status}")
|
|
526
|
+
)
|
|
527
|
+
what = entry["command"] + (f" {entry['target']}" if entry.get("target") else "")
|
|
528
|
+
print(
|
|
529
|
+
f" {entry['at']} {what:<34} {entry.get('ms', 0):>6}ms "
|
|
530
|
+
f"{mark:<8} {shown(entry['project'])}"
|
|
531
|
+
)
|
|
532
|
+
if damaged:
|
|
533
|
+
print(f"{damaged} unreadable record(s) skipped.", file=sys.stderr)
|
|
534
|
+
return 0
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _prune(project, options):
|
|
538
|
+
cutoff = when(options.before)
|
|
539
|
+
dropped, files = prune(project, cutoff, dry_run=options.dry_run)
|
|
540
|
+
verb = "would remove" if options.dry_run else "removed"
|
|
541
|
+
print(f"{verb} {dropped} record(s) before {cutoff}, {files} empty file(s).")
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def main(argv=None) -> int:
|
|
546
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
547
|
+
parser = argparse.ArgumentParser(
|
|
548
|
+
prog="quirework usage",
|
|
549
|
+
description="Which commands and procedures this installation actually "
|
|
550
|
+
"runs, and which model seats it launches. Recorded on this machine, "
|
|
551
|
+
"read only by this command, and sent nowhere.",
|
|
552
|
+
epilog="Disable recording with `enabled = false` under [usage] in "
|
|
553
|
+
"quirework.toml, or QUIREWORK_USAGE=0 for one shell.",
|
|
554
|
+
)
|
|
555
|
+
verbs = parser.add_subparsers(dest="verb")
|
|
556
|
+
|
|
557
|
+
def shared(name, help_text, limit):
|
|
558
|
+
p = verbs.add_parser(name, prog=f"quirework usage {name}", help=help_text)
|
|
559
|
+
p.add_argument(
|
|
560
|
+
"--since",
|
|
561
|
+
metavar="WHEN",
|
|
562
|
+
help="only records since a date (2026-01-01) or an age (30d, 12w)",
|
|
563
|
+
)
|
|
564
|
+
p.add_argument(
|
|
565
|
+
"--here",
|
|
566
|
+
action="store_true",
|
|
567
|
+
help="only records made in this project, in any of its checkouts",
|
|
568
|
+
)
|
|
569
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
570
|
+
p.add_argument(
|
|
571
|
+
"--limit", type=int, default=limit, metavar="N", help="rows to print"
|
|
572
|
+
)
|
|
573
|
+
return p
|
|
574
|
+
|
|
575
|
+
shared("show", "summarize recorded usage", 20)
|
|
576
|
+
shared("sops", "rank procedures by recorded reads", 40)
|
|
577
|
+
shared("seats", "rank harnesses and models by seats launched", 40)
|
|
578
|
+
shared("log", "read the most recent records", 20)
|
|
579
|
+
remove = verbs.add_parser(
|
|
580
|
+
"prune", prog="quirework usage prune", help="remove old records"
|
|
581
|
+
)
|
|
582
|
+
remove.add_argument(
|
|
583
|
+
"--before",
|
|
584
|
+
metavar="WHEN",
|
|
585
|
+
default="365d",
|
|
586
|
+
help="remove records older than this date or age (default: 365d)",
|
|
587
|
+
)
|
|
588
|
+
remove.add_argument(
|
|
589
|
+
"--dry-run", action="store_true", help="report what would go, remove nothing"
|
|
590
|
+
)
|
|
591
|
+
options = parser.parse_args(argv or ["show"])
|
|
592
|
+
project = current_project()
|
|
593
|
+
handlers = {
|
|
594
|
+
"show": _show,
|
|
595
|
+
"sops": _sops,
|
|
596
|
+
"seats": _seats,
|
|
597
|
+
"log": _log,
|
|
598
|
+
"prune": _prune,
|
|
599
|
+
}
|
|
600
|
+
return handlers[options.verb or "show"](project, options)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
if __name__ == "__main__":
|
|
604
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Delivery services."""
|