quirework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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."""