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,386 @@
1
+ """Dependency-aware discovery over existing SOPs; never executes a workflow.
2
+
3
+ Catalog metadata describes required reading separately from conditional
4
+ follow-ups. The SOP files remain the procedure and description owners.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from quirework.project import current_project
16
+
17
+ _NAME = re.compile(r"[a-z][a-z0-9-]*")
18
+ KINDS = {"procedure", "protocol", "gate"}
19
+
20
+
21
+ def _unique_object(pairs):
22
+ result = {}
23
+ for key, value in pairs:
24
+ if key in result:
25
+ raise ValueError(f"duplicate catalog key: {key}")
26
+ result[key] = value
27
+ return result
28
+
29
+
30
+ def _keys(value, expected, where):
31
+ if not isinstance(value, dict) or set(value) != set(expected):
32
+ raise ValueError(f"{where}: expected fields {', '.join(sorted(expected))}")
33
+
34
+
35
+ def _text(value, where, *, empty=False):
36
+ if not isinstance(value, str) or (not empty and not value.strip()):
37
+ raise ValueError(f"{where}: expected {'possibly empty ' if empty else ''}text")
38
+
39
+
40
+ def _strings(value, where):
41
+ if not isinstance(value, list) or any(not isinstance(x, str) for x in value):
42
+ raise ValueError(f"{where}: expected a list of strings")
43
+ if any(not x.strip() for x in value) or len(set(value)) != len(value):
44
+ raise ValueError(f"{where}: empty or duplicate item")
45
+
46
+
47
+ def reading_order(entries, name):
48
+ """Dependency-first closure; follow-up edges are deliberately not traversed."""
49
+ if name not in entries:
50
+ raise ValueError(f"unknown SOP: {name}")
51
+ result, done, active = [], set(), []
52
+
53
+ def visit(current):
54
+ if current in active:
55
+ raise ValueError(
56
+ "reading dependency cycle: " + " -> ".join(active + [current])
57
+ )
58
+ if current in done:
59
+ return
60
+ active.append(current)
61
+ for dependency in entries[current]["requires"]:
62
+ if dependency not in entries:
63
+ raise ValueError(f"{current}: unknown reading dependency {dependency}")
64
+ visit(dependency)
65
+ active.pop()
66
+ done.add(current)
67
+ result.append(current)
68
+
69
+ visit(name)
70
+ return result
71
+
72
+
73
+ def _named(path):
74
+ """A path as the project's own operator would type it."""
75
+ try:
76
+ return str(Path(path).relative_to(current_project().root))
77
+ except ValueError:
78
+ return str(path)
79
+
80
+
81
+ def _sops_root(root):
82
+ """The project's own procedure directory; its name is a project choice."""
83
+ return current_project().path_at(root, "sops.root")
84
+
85
+
86
+ def load(root=None):
87
+ """Validate metadata, complete SOP coverage and the required-reading DAG."""
88
+ if root is None:
89
+ root = current_project().root
90
+ root = Path(root)
91
+ catalog = current_project().path_at(root, "sops.catalog")
92
+ if not catalog.is_file():
93
+ # The first thing a new project asks the SOP layer, and an errno tells
94
+ # it nothing. Name the file and where its shape is written down.
95
+ raise ValueError(
96
+ f"no procedure catalog at {catalog}. A project declares its "
97
+ "procedures in one; docs/authoring.md has the shape."
98
+ )
99
+ data = json.loads(catalog.read_text(), object_pairs_hook=_unique_object)
100
+ _keys(data, {"version", "families", "entries"}, "catalog")
101
+ if type(data["version"]) is not int or data["version"] != 1:
102
+ raise ValueError("unsupported SOP catalog version")
103
+ if not isinstance(data["families"], list) or not data["families"]:
104
+ raise ValueError("families must be a nonempty list")
105
+ families = set()
106
+ for item in data["families"]:
107
+ _keys(item, {"id", "label", "question"}, "family")
108
+ for key in item:
109
+ _text(item[key], f"family {key}")
110
+ if not _NAME.fullmatch(item["id"]) or item["id"] in families:
111
+ raise ValueError(f"invalid or duplicate family: {item['id']}")
112
+ families.add(item["id"])
113
+ entries = data["entries"]
114
+ if not isinstance(entries, dict) or not entries:
115
+ raise ValueError("entries must be a nonempty object")
116
+ files = [
117
+ p
118
+ for directory in (
119
+ _sops_root(root),
120
+ _sops_root(root) / "workflows",
121
+ _sops_root(root) / "protocols",
122
+ )
123
+ for p in directory.glob("*.md")
124
+ if p.name != "README.md"
125
+ ]
126
+ if len({p.stem for p in files}) != len(files):
127
+ raise ValueError("duplicate SOP identifier across layers")
128
+ paths = {p.stem: p for p in files}
129
+ expected = set(paths)
130
+ if set(entries) != expected:
131
+ raise ValueError(
132
+ f"SOP coverage mismatch: uncataloged={sorted(expected - set(entries))}; "
133
+ f"missing files={sorted(set(entries) - expected)}"
134
+ )
135
+ from quirework.sops.commands import is_fragment
136
+ from quirework.sops.sections import check_modes
137
+
138
+ for name, entry in entries.items():
139
+ if not _NAME.fullmatch(name) or name in {
140
+ "list",
141
+ "explain",
142
+ "catalog",
143
+ "skills",
144
+ }:
145
+ raise ValueError(f"invalid SOP identifier: {name}")
146
+ _keys(
147
+ entry,
148
+ {"family", "kind", "skill", "requires", "mechanics", "followups", "note"},
149
+ name,
150
+ )
151
+ _text(entry["family"], f"{name} family")
152
+ _text(entry["kind"], f"{name} kind")
153
+ if entry["family"] not in families or entry["kind"] not in KINDS:
154
+ raise ValueError(f"{name}: unknown family or kind")
155
+ # Exposing a skill is a deliberate per-SOP decision, never a side
156
+ # effect of adding a file. The field is required, so a new SOP fails
157
+ # validation until someone says yes or no; that is the whole point.
158
+ if not isinstance(entry["skill"], bool):
159
+ raise ValueError(
160
+ f"{name}: skill must be true or false -- decide whether this "
161
+ f"SOP gets its own skill route"
162
+ )
163
+ if entry["kind"] == "protocol" and entry["skill"]:
164
+ raise ValueError(f"{name}: a protocol is shared reading, not a skill")
165
+ text = paths[name].read_text()
166
+ if is_fragment(text) != (entry["kind"] == "protocol"):
167
+ raise ValueError(
168
+ f"{name}: protocol kind disagrees with SOP fragment heading"
169
+ )
170
+ # A mode names sections by title, so renaming a heading would break
171
+ # it at the moment someone reads it. Here it breaks at the gate.
172
+ try:
173
+ check_modes(text)
174
+ except ValueError as exc:
175
+ raise ValueError(f"{name}: {exc}") from None
176
+ _text(entry["note"], f"{name} note", empty=True)
177
+ _strings(entry["requires"], f"{name} requires")
178
+ _strings(entry["mechanics"], f"{name} mechanics")
179
+ if not isinstance(entry["followups"], list):
180
+ raise ValueError(f"{name}: followups must be a list")
181
+ targets = set()
182
+ for edge in entry["followups"]:
183
+ _keys(edge, {"sop", "when"}, f"{name} followup")
184
+ _text(edge["sop"], f"{name} followup SOP")
185
+ _text(edge["when"], f"{name} followup condition")
186
+ if edge["sop"] not in entries or edge["sop"] in targets:
187
+ raise ValueError(f"{name}: unknown or duplicate followup {edge['sop']}")
188
+ targets.add(edge["sop"])
189
+ for name in entries:
190
+ reading_order(entries, name)
191
+ return data
192
+
193
+
194
+ def path_for(root, name):
195
+ matches = [
196
+ _sops_root(root) / folder / f"{name}.md"
197
+ for folder in ("", "workflows", "protocols")
198
+ ]
199
+ found = [p for p in matches if p.is_file()]
200
+ if len(found) != 1:
201
+ raise ValueError(f"unknown or ambiguous SOP: {name}")
202
+ return found[0]
203
+
204
+
205
+ def describe(root, name):
206
+ from quirework.sops.commands import _description
207
+
208
+ return _description(path_for(root, name).read_text(), name)
209
+
210
+
211
+ def explain(data, name, root=None):
212
+ if root is None:
213
+ root = current_project().root
214
+ entries = data["entries"]
215
+ order = reading_order(entries, name)
216
+ reads = []
217
+ for item in order:
218
+ path = path_for(root, item)
219
+ text = path.read_text()
220
+ reads.append(
221
+ {
222
+ "sop": item,
223
+ "path": str(path.relative_to(root)),
224
+ "lines": len(text.splitlines()),
225
+ "words": len(text.split()),
226
+ }
227
+ )
228
+ return {
229
+ "sop": name,
230
+ **entries[name],
231
+ "description": describe(root, name),
232
+ "reading_order": reads,
233
+ "reading_words": sum(r["words"] for r in reads),
234
+ "scope": "SOP reading dependencies only; root guidance, directory cards "
235
+ "and task-specific inputs still apply. No follow-up is executed.",
236
+ }
237
+
238
+
239
+ def listing(data, root=None):
240
+ if root is None:
241
+ root = current_project().root
242
+ lines = ["SOPs by task (existing names; no workflow is launched):"]
243
+ for family in data["families"]:
244
+ lines.extend(["", f"{family['label']} — {family['question']}"])
245
+ for name, entry in sorted(data["entries"].items()):
246
+ if entry["family"] == family["id"]:
247
+ suffix = f" [{entry['kind']}]" if entry["kind"] != "procedure" else ""
248
+ lines.append(f" {name}{suffix}: {describe(root, name)}")
249
+ lines.extend(
250
+ [
251
+ "",
252
+ "Use sop NAME to read a procedure; sop explain NAME to see its dependencies.",
253
+ ]
254
+ )
255
+ return "\n".join(lines) + "\n"
256
+
257
+
258
+ def markdown(data, root=None):
259
+ if root is None:
260
+ root = current_project().root
261
+ lines = [
262
+ "# SOP discovery catalog",
263
+ "",
264
+ f"Generated from `{_named(current_project().path('sops.catalog'))}`",
265
+ "and the SOP descriptions. These are",
266
+ "the task families, specialized procedures and shared protocols. Read the",
267
+ "selected workflow and instrument before use.",
268
+ "",
269
+ "Required reading is distinct from running another workflow. Conditional",
270
+ "follow-ups do not authorize or automatically launch work. Root guidance,",
271
+ "directory cards and task-specific inputs still apply.",
272
+ "",
273
+ ]
274
+ for family in data["families"]:
275
+ lines += [
276
+ f"## {family['label']}",
277
+ "",
278
+ family["question"],
279
+ "",
280
+ "| Procedure | Purpose | Required SOP reading |",
281
+ "|---|---|---|",
282
+ ]
283
+ for name, entry in sorted(data["entries"].items()):
284
+ if entry["family"] != family["id"]:
285
+ continue
286
+ deps = (
287
+ ", ".join(
288
+ f"[{d}](../{path_for(root, d).relative_to(_sops_root(root))})"
289
+ for d in entry["requires"]
290
+ )
291
+ or "None"
292
+ )
293
+ desc = describe(root, name).replace("|", r"\|")
294
+ suffix = f" ({entry['kind']})" if entry["kind"] != "procedure" else ""
295
+ lines.append(
296
+ f"| [{name}](../{path_for(root, name).relative_to(_sops_root(root))}){suffix} "
297
+ f"| {desc} | {deps} |"
298
+ )
299
+ lines.append("")
300
+ lines += ["## Execution notes", ""]
301
+ for name, entry in sorted(data["entries"].items()):
302
+ if entry["note"]:
303
+ lines += [f"- **{name}:** {entry['note']}"]
304
+ lines += [
305
+ "",
306
+ "The operator can inspect a deduplicated reading order and conditional",
307
+ "follow-ups with `quirework sop explain NAME`. The catalog is discovery",
308
+ "metadata, not a replacement for the SOP or a semantic completeness gate.",
309
+ "",
310
+ ]
311
+ # Match the repo's source-Markdown wrapper; generated content must round-trip.
312
+ from quirework.text.mdwrap import reflow_columns
313
+
314
+ return reflow_columns("\n".join(lines))
315
+
316
+
317
+ def main(argv=None):
318
+ parser = argparse.ArgumentParser(prog="quirework sop", description=__doc__)
319
+ sub = parser.add_subparsers(dest="action", required=True)
320
+ p = sub.add_parser("list", help="list the project's procedures")
321
+ p.add_argument("--json", action="store_true")
322
+ p = sub.add_parser(
323
+ "explain", help="a procedure's reading order, tools and follow-ups"
324
+ )
325
+ p.add_argument("name")
326
+ p.add_argument("--json", action="store_true")
327
+ p = sub.add_parser("catalog", help="check or write the generated catalog")
328
+ options = p.add_mutually_exclusive_group()
329
+ options.add_argument("--check", action="store_true")
330
+ options.add_argument("--write", action="store_true")
331
+ args = parser.parse_args(argv)
332
+ try:
333
+ data = load(current_project().root)
334
+ if args.action == "list":
335
+ print(
336
+ json.dumps(data, indent=2)
337
+ if args.json
338
+ else listing(data, current_project().root),
339
+ end="\n" if args.json else "",
340
+ )
341
+ elif args.action == "explain":
342
+ report = explain(data, args.name, current_project().root)
343
+ if args.json:
344
+ print(json.dumps(report, indent=2))
345
+ else:
346
+ print(f"{args.name}: {report['description']}\n")
347
+ print(
348
+ "Required SOP reading, dependencies first (not an execution plan):"
349
+ )
350
+ for row in report["reading_order"]:
351
+ print(f" {row['path']} ({row['words']:,} words)")
352
+ print(f"Total: {report['reading_words']:,} words\n")
353
+ print("Mechanical tools: " + ", ".join(report["mechanics"]))
354
+ if report["note"]:
355
+ print("Note: " + report["note"])
356
+ print("\nConditional follow-ups (operator decides whether triggered):")
357
+ for edge in report["followups"]:
358
+ print(f" {edge['sop']}: {edge['when']}")
359
+ if not report["followups"]:
360
+ print(" None cataloged.")
361
+ print("\n" + report["scope"])
362
+ else:
363
+ content = markdown(data, current_project().root)
364
+ path = current_project().path("sops.root") / "catalog" / "README.md"
365
+ if args.check:
366
+ if not path.exists() or path.read_text() != content:
367
+ raise ValueError(
368
+ "generated catalog is stale; run quirework sop catalog --write"
369
+ )
370
+ print(
371
+ f"SOP catalog: {len(data['entries'])} entries, reading graph and generated index valid"
372
+ )
373
+ elif args.write:
374
+ path.parent.mkdir(parents=True, exist_ok=True)
375
+ path.write_text(content)
376
+ print(f"Wrote {_named(path)}")
377
+ else:
378
+ print(content, end="")
379
+ except (ValueError, OSError) as exc:
380
+ print(f"sop catalog: {exc}", file=sys.stderr)
381
+ return 2
382
+ return 0
383
+
384
+
385
+ if __name__ == "__main__":
386
+ raise SystemExit(main())