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
quirework/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Reusable tools for writing, research, and supervised reviews."""
2
+
3
+ __version__ = "0.1.0"
quirework/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
quirework/cli.py ADDED
@@ -0,0 +1,431 @@
1
+ """Task-oriented command discovery with lazy, side-effect-free help."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import sys
9
+ from dataclasses import dataclass
10
+
11
+ from . import __version__
12
+ from .project import ConfigurationError, Project, project_scope
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Command:
17
+ module: str
18
+ summary: str
19
+ component: str | None = None
20
+ prefix: tuple[str, ...] = ()
21
+ # The publisher a command is built on, when it is one backend's alone.
22
+ backend: str | None = None
23
+
24
+
25
+ GROUPS = {
26
+ "spec": "Read and check the spec, marks, sources, quotations and trail",
27
+ "publish": "Render the manuscript and inspect its editions",
28
+ "text": "Format and inspect writing",
29
+ "provenance": "Capture, organize, and search source history",
30
+ "sop": "Find procedures and operate supervised workflows",
31
+ "dev": "Repository checks and tooling maintenance",
32
+ "config": "Inspect and validate project configuration",
33
+ "usage": "Report which commands, procedures and seats get used",
34
+ }
35
+ COMMANDS = {
36
+ ("spec", "notes"): Command(
37
+ "manuscript.notes", "Inspect annotations and bindings", "manuscript"
38
+ ),
39
+ ("spec", "trail"): Command(
40
+ "manuscript.trail",
41
+ "Point spec sections at the sessions that shaped them",
42
+ "manuscript",
43
+ ),
44
+ ("spec", "quotes"): Command(
45
+ "manuscript.quotes", "Check quotations in annotations", "manuscript"
46
+ ),
47
+ ("spec", "sources"): Command(
48
+ "manuscript.sources",
49
+ "List the sources the work stands on and check citations of them",
50
+ "manuscript",
51
+ ),
52
+ ("spec", "units"): Command(
53
+ "manuscript.units",
54
+ "List the units and check references to them",
55
+ "manuscript",
56
+ ),
57
+ ("spec", "rename"): Command(
58
+ "manuscript.rename",
59
+ "Rename a spec section everywhere its slug is written",
60
+ "manuscript",
61
+ ),
62
+ ("publish", "build"): Command(
63
+ "publish.build", "Render the manuscript through its backend", "publish"
64
+ ),
65
+ ("publish", "figures"): Command(
66
+ "publish.figures",
67
+ "Build and inspect figure galleries",
68
+ "publish",
69
+ backend="quarto",
70
+ ),
71
+ ("publish", "copy"): Command(
72
+ "publish.proof",
73
+ "Render a proof prepared for one person",
74
+ "publish",
75
+ backend="quarto",
76
+ ),
77
+ ("publish", "kindle"): Command(
78
+ "publish.kindle",
79
+ "Deliver a copy to a Kindle library and read its notes back",
80
+ "publish",
81
+ ),
82
+ ("publish", "fonts"): Command(
83
+ "publish.fonts",
84
+ "Inspect required typefaces",
85
+ "publish",
86
+ backend="quarto",
87
+ ),
88
+ ("publish", "epubcheck"): Command("publish.epubcheck", "Validate an EPUB artifact"),
89
+ ("publish", "redpen"): Command(
90
+ "publish.redpen",
91
+ "Create and read review documents",
92
+ "publish",
93
+ backend="quarto",
94
+ ),
95
+ ("publish", "packet"): Command(
96
+ "publish.panelkit",
97
+ "Prepare a rendered review packet",
98
+ "publish",
99
+ backend="quarto",
100
+ ),
101
+ ("publish", "variant"): Command(
102
+ "publish.variant",
103
+ "Prepare a configured derived edition",
104
+ "publish",
105
+ backend="quarto",
106
+ ),
107
+ ("publish", "plan"): Command(
108
+ "publish.plans",
109
+ "Compile rendered artifacts into run plans",
110
+ backend="quarto",
111
+ ),
112
+ ("publish", "compare"): Command(
113
+ "publish.experiment",
114
+ "Prepare and operate chapter comparisons",
115
+ "publish",
116
+ backend="quarto",
117
+ ),
118
+ ("sop", "panel"): Command(
119
+ "sops.composition", "Compile declared panels into run plans"
120
+ ),
121
+ ("text", "wrap"): Command("text.mdwrap", "Wrap Markdown or check its formatting"),
122
+ ("text", "check"): Command("text.jargon", "Inspect configured terminology"),
123
+ ("text", "tells"): Command("text.tells", "Report machine habits in the prose"),
124
+ ("provenance", "search"): Command(
125
+ "provenance.search", "Search human turns", "provenance"
126
+ ),
127
+ ("provenance", "show"): Command(
128
+ "provenance.show", "Read a clean session transcript", "provenance"
129
+ ),
130
+ ("provenance", "catalog"): Command(
131
+ "provenance.catalog", "Build or validate the source catalog", "provenance"
132
+ ),
133
+ ("provenance", "coldread"): Command(
134
+ "provenance.coldread", "Inspect and operate authorship analysis", "provenance"
135
+ ),
136
+ ("provenance", "metrics"): Command(
137
+ "provenance.metrics", "Measure source history and contributions", "provenance"
138
+ ),
139
+ ("provenance", "input"): Command(
140
+ "provenance.humaninput", "Compile verbatim human contributions", "provenance"
141
+ ),
142
+ ("provenance", "transform"): Command(
143
+ "provenance.capture.transform",
144
+ "Render transcripts or recover captured artifacts",
145
+ "provenance",
146
+ ),
147
+ ("provenance", "capture"): Command(
148
+ "provenance.capture.commands",
149
+ "Capture supported web conversations",
150
+ "provenance",
151
+ ),
152
+ ("provenance", "store"): Command(
153
+ "provenance.store", "Create, inspect, verify and back up the session store"
154
+ ),
155
+ ("provenance", "accept"): Command(
156
+ "provenance.curate",
157
+ "Accept pending sessions into the corpus",
158
+ "provenance",
159
+ prefix=("accept",),
160
+ ),
161
+ ("provenance", "reject"): Command(
162
+ "provenance.curate",
163
+ "Curate sessions out of the corpus",
164
+ "provenance",
165
+ prefix=("reject",),
166
+ ),
167
+ ("provenance", "ingest"): Command(
168
+ "provenance.ingest", "Capture local sessions for later curation", "provenance"
169
+ ),
170
+ ("provenance", "rulings"): Command(
171
+ "provenance.rulings",
172
+ "Archive a review sheet's rulings as the author's turns",
173
+ "provenance",
174
+ ),
175
+ ("sop", "list"): Command(
176
+ "sops.commands", "List available procedures", prefix=("list",)
177
+ ),
178
+ ("sop", "show"): Command("sops.commands", "Read a procedure"),
179
+ ("sop", "explain"): Command(
180
+ "sops.commands", "Explain procedure prerequisites", prefix=("explain",)
181
+ ),
182
+ ("sop", "catalog"): Command(
183
+ "sops.commands", "Generate or check procedure discovery", prefix=("catalog",)
184
+ ),
185
+ ("sop", "skills"): Command(
186
+ "sops.commands", "Generate agent skill entry points", prefix=("skills",)
187
+ ),
188
+ ("sop", "personas"): Command("sops.personas", "Inspect and validate perspectives"),
189
+ ("sop", "models"): Command(
190
+ "providers.catalog", "Inspect model availability and capabilities"
191
+ ),
192
+ ("sop", "runs"): Command(
193
+ "runs.commands", "Prepare, start, recover, and inspect runs"
194
+ ),
195
+ ("sop", "workshop"): Command(
196
+ "sops.workshop", "Prepare and operate writing experiments"
197
+ ),
198
+ ("sop", "review"): Command("sops.review", "Review a configured source change"),
199
+ ("sop", "room"): Command("sops.room", "Operate a writers room over a shared board"),
200
+ ("sop", "roundtable"): Command(
201
+ "sops.roundtable", "Run AI models, as themselves, in turns towards one goal"
202
+ ),
203
+ ("sop", "blind"): Command("providers.blind", "Inspect and enforce run isolation"),
204
+ ("dev", "check"): Command("core.checks", "Run the configured repository checks"),
205
+ ("usage", "show"): Command(
206
+ "core.usage", "Summarize recorded command usage", prefix=("show",)
207
+ ),
208
+ ("usage", "sops"): Command(
209
+ "core.usage", "Rank procedures by recorded reads", prefix=("sops",)
210
+ ),
211
+ ("usage", "seats"): Command(
212
+ "core.usage", "Rank harnesses and models by seats launched", prefix=("seats",)
213
+ ),
214
+ ("usage", "log"): Command(
215
+ "core.usage", "Read the most recent records", prefix=("log",)
216
+ ),
217
+ ("usage", "prune"): Command(
218
+ "core.usage", "Remove old usage records", prefix=("prune",)
219
+ ),
220
+ }
221
+
222
+
223
+ def _parser(group=None):
224
+ if group is None:
225
+ lines = ["Work with:"]
226
+ lines += [
227
+ f" {name:12} {GROUPS[name]}"
228
+ for name in ("spec", "publish", "text", "provenance", "sop")
229
+ ]
230
+ lines += ["", "Maintain:"]
231
+ lines += [f" {name:12} {GROUPS[name]}" for name in ("dev", "config", "usage")]
232
+ p = argparse.ArgumentParser(
233
+ prog="quirework",
234
+ description="Tools for writing, research, and supervised reviews.",
235
+ epilog="\n".join(lines),
236
+ formatter_class=argparse.RawDescriptionHelpFormatter,
237
+ )
238
+ p.add_argument(
239
+ "--project",
240
+ "--config",
241
+ metavar="PATH",
242
+ help="project directory or configuration file",
243
+ )
244
+ p.add_argument(
245
+ "--version", action="version", version=f"quirework {__version__}"
246
+ )
247
+ p.add_argument("group", nargs="?", choices=GROUPS, metavar="GROUP")
248
+ else:
249
+ rows = [
250
+ (name, item.summary)
251
+ for (parent, name), item in COMMANDS.items()
252
+ if parent == group
253
+ ]
254
+ if group == "config":
255
+ rows = [
256
+ ("show", "Show effective settings and their origins"),
257
+ ("check", "Validate enabled project components"),
258
+ ("keys", "List every known setting, declared or not"),
259
+ ("template", "Print an annotated configuration template"),
260
+ ]
261
+ p = argparse.ArgumentParser(
262
+ prog=f"quirework {group}",
263
+ description=GROUPS[group],
264
+ epilog="Commands:\n"
265
+ + "\n".join(f" {name:12} {summary}" for name, summary in rows),
266
+ formatter_class=argparse.RawDescriptionHelpFormatter,
267
+ )
268
+ p.add_argument(
269
+ "command", nargs="?", choices=[name for name, _ in rows], metavar="COMMAND"
270
+ )
271
+ p.add_argument("arguments", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
272
+ return p
273
+
274
+
275
+ def invoke(command, argv=(), *, project):
276
+ key = tuple(command.split()) if isinstance(command, str) else tuple(command)
277
+ if key not in COMMANDS:
278
+ raise ConfigurationError(f"unknown command: {' '.join(key)}")
279
+ item = COMMANDS[key]
280
+ with project_scope(project):
281
+ # Help never validates a corpus, initializes a provider, or launches work.
282
+ help_only = any(a in ("--help", "-h") for a in argv)
283
+ if item.component and not help_only:
284
+ project.require(item.component)
285
+ scoped = project
286
+ if item.backend and not help_only:
287
+ # A command built on one backend's staging runs as the target that
288
+ # backend builds sees the project, and is refused by name where no
289
+ # target uses it.
290
+ from .publish import targets
291
+
292
+ scoped = targets.for_backend(item.backend, " ".join(key), project)
293
+ module = importlib.import_module("quirework." + item.module)
294
+ if help_only:
295
+ # Asking what a command does is not using it, and help stays free
296
+ # of side effects, so nothing is recorded on this path.
297
+ return module.main([*item.prefix, *argv]) or 0
298
+ from .core.usage import record
299
+
300
+ with record(key, argv, project=project) as outcome, project_scope(scoped):
301
+ outcome.status = module.main([*item.prefix, *argv]) or 0
302
+ return outcome.status
303
+
304
+
305
+ def main(argv=None):
306
+ parser = _parser()
307
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
308
+ if not args.group:
309
+ parser.print_help()
310
+ return 0
311
+ sub = _parser(args.group)
312
+ selected = sub.parse_args(args.arguments)
313
+ if not selected.command:
314
+ sub.print_help()
315
+ return 0
316
+ try:
317
+ if args.group == "config":
318
+ p = argparse.ArgumentParser(prog=f"quirework config {selected.command}")
319
+ if selected.command == "template":
320
+ from importlib.resources import files
321
+ from pathlib import Path
322
+
323
+ # Not every template is TOML: the cache-cleanup rule is a
324
+ # systemd-tmpfiles drop-in and has to keep that extension.
325
+ names = {
326
+ "backup": "backup.toml",
327
+ "book": "book.toml",
328
+ "site": "site.toml",
329
+ "run": "run.toml",
330
+ "terminology": "terminology.toml",
331
+ "tells": "tells.toml",
332
+ "tmpfiles": "tmpfiles.conf",
333
+ }
334
+ p.add_argument("kind", choices=tuple(names))
335
+ p.add_argument(
336
+ "--out", type=Path, help="create a new file instead of printing"
337
+ )
338
+ options = p.parse_args(selected.arguments)
339
+ text = (
340
+ files("quirework")
341
+ .joinpath("templates", names[options.kind])
342
+ .read_text(encoding="utf-8")
343
+ )
344
+ if options.out is not None:
345
+ options.out.parent.mkdir(parents=True, exist_ok=True)
346
+ with options.out.open("x", encoding="utf-8") as output:
347
+ output.write(text)
348
+ print(f"Created {options.out}")
349
+ else:
350
+ print(text, end="")
351
+ return 0
352
+ p.add_argument(
353
+ "--json",
354
+ action="store_true",
355
+ help="emit machine-readable configuration",
356
+ )
357
+ if selected.command == "keys":
358
+ p.add_argument(
359
+ "--undeclared",
360
+ action="store_true",
361
+ help="list only the keys left undeclared, and unknown ones",
362
+ )
363
+ options = p.parse_args(selected.arguments)
364
+ project = Project.load(args.project)
365
+ if selected.command == "keys":
366
+ from . import settings
367
+
368
+ if options.json:
369
+ print(json.dumps(settings.report(project), indent=2))
370
+ else:
371
+ print(settings.show(project, undeclared_only=options.undeclared))
372
+ return 0
373
+ if selected.command == "check":
374
+ if project.get("provenance.enabled", False):
375
+ project.require("provenance")
376
+ from .provenance import backup, store
377
+
378
+ opened = store.open_store(project)
379
+ print(f"Provenance store: {store.describe_location(opened)}")
380
+ chosen = store.describe_home(project)
381
+ if chosen:
382
+ print(f"Provenance home: {chosen}")
383
+ # Read and validated here, offline; `store backup --check`
384
+ # is what reaches each destination.
385
+ source = backup.Source(opened.root, opened.identity)
386
+ drivers = backup.targets(opened.root)
387
+ if drivers is None:
388
+ print(f"Provenance backup: {backup.unconfigured(source)}")
389
+ for driver in drivers or ():
390
+ print(
391
+ f"Provenance backup: {driver.target}, {driver.describe()}"
392
+ )
393
+ if project.get("manuscript"):
394
+ project.require("manuscript")
395
+ if project.get("publish"):
396
+ project.require("publish")
397
+ if project.get("usage.store") is not None:
398
+ # Resolve it here, where somebody is asking, instead of
399
+ # letting a typo surface as a warning on every command.
400
+ project.path("usage.store")
401
+ from . import settings
402
+
403
+ problems = settings.unknown(project)
404
+ if problems:
405
+ for line in problems:
406
+ print(line, file=sys.stderr)
407
+ print(
408
+ "Nothing reads these keys, so each is usually a "
409
+ "misspelling or a retired key. A key the project's own "
410
+ "code reads belongs in an [extensions] list, as "
411
+ "docs/configuration.md says. `quirework config keys` "
412
+ "lists every key Quirework reads.",
413
+ file=sys.stderr,
414
+ )
415
+ return 1
416
+ print("Configuration is valid.")
417
+ else:
418
+ print(json.dumps(project.describe(), indent=2))
419
+ return 0
420
+ return invoke(
421
+ (args.group, selected.command),
422
+ selected.arguments,
423
+ project=Project.load(args.project),
424
+ )
425
+ except (ConfigurationError, OSError, RuntimeError, ValueError) as exc:
426
+ print(f"quirework: {exc}", file=sys.stderr)
427
+ return 2
428
+
429
+
430
+ if __name__ == "__main__":
431
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Core services."""
@@ -0,0 +1,86 @@
1
+ """Placement shared by review-evidence guards and Markdown formatting.
2
+
3
+ A saved run record is evidence. Its files are kept exactly as the run wrote
4
+ them, so no formatter may reflow them and no advisory may treat them as
5
+ authority. The project declares which directories hold those records.
6
+
7
+ Directory names alone never endorse an analysis's conclusions.
8
+ """
9
+
10
+ from quirework.project import current_project
11
+
12
+ # An edition's blind drafts: one directory in each edition under
13
+ # `publish.variants`, holding what each drafting model returned, as returned.
14
+ EDITION_DRAFTS = "_raw"
15
+
16
+
17
+ def run_roots() -> tuple[str, ...]:
18
+ """Project-relative directories holding saved run records.
19
+
20
+ `runs.evidence_roots` lists them for a project that keeps several. A project
21
+ that keeps one names it as `runs.reviews` and needs nothing further. A project
22
+ with neither has no saved records, and nothing is treated as evidence.
23
+ """
24
+ project = current_project()
25
+ declared = project.get("runs.evidence_roots")
26
+ if declared:
27
+ return tuple(str(root).strip("/") for root in declared if str(root).strip("/"))
28
+ reviews = project.get("runs.reviews")
29
+ return (str(reviews).strip("/"),) if reviews else ()
30
+
31
+
32
+ def _within(path: str, root: str) -> tuple[list[str], list[str]] | None:
33
+ """The split path and root when `path` names a file inside a run record."""
34
+ parts = path.split("/")
35
+ segments = root.split("/")
36
+ return (parts, segments) if parts[: len(segments)] == segments else None
37
+
38
+
39
+ def run_operator_note(path: str) -> bool:
40
+ """Only a top-level underscore Markdown note belongs to the operator."""
41
+ for root in run_roots():
42
+ found = _within(path, root)
43
+ if found is None:
44
+ continue
45
+ parts, segments = found
46
+ name = parts[-1]
47
+ if (
48
+ len(parts) == len(segments) + 2
49
+ and name.startswith("_")
50
+ and name.endswith(".md")
51
+ ):
52
+ return True
53
+ return False
54
+
55
+
56
+ def edition_draft(path: str) -> bool:
57
+ """Whether a project-relative path is one of an edition's blind drafts.
58
+
59
+ They are evidence wherever the editions root is, without a declaration:
60
+ a draft is what a model wrote, and an edition is composed beside it, so
61
+ reflowing it would rewrite the very text the composition is checked
62
+ against.
63
+ """
64
+ project = current_project()
65
+ if project.get("publish.variants") is None:
66
+ return False
67
+ try:
68
+ root = project.path("publish.variants").relative_to(project.root).as_posix()
69
+ except ValueError:
70
+ return False
71
+ found = _within(path, root)
72
+ if found is None:
73
+ return False
74
+ parts, segments = found
75
+ return len(parts) > len(segments) + 2 and parts[len(segments) + 1] == EDITION_DRAFTS
76
+
77
+
78
+ def run_evidence(path: str) -> bool:
79
+ """Other files inside a recorded run stay verbatim, at any depth."""
80
+ if edition_draft(path):
81
+ return True
82
+ for root in run_roots():
83
+ found = _within(path, root)
84
+ if found is not None and len(found[0]) > len(found[1]) + 1:
85
+ return not run_operator_note(path)
86
+ return False