whykit 0.3.1__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 (84) hide show
  1. whykit/__init__.py +21 -0
  2. whykit/adopt.py +776 -0
  3. whykit/backlinks.py +106 -0
  4. whykit/check.py +320 -0
  5. whykit/ci_formats.py +283 -0
  6. whykit/cli.py +1719 -0
  7. whykit/completion.py +193 -0
  8. whykit/config.py +233 -0
  9. whykit/console.py +217 -0
  10. whykit/context.py +204 -0
  11. whykit/contract.py +210 -0
  12. whykit/contract_schemas/backlinks-report.schema.json +61 -0
  13. whykit/contract_schemas/context-bundle.schema.json +27 -0
  14. whykit/contract_schemas/context-pack.schema.json +29 -0
  15. whykit/contract_schemas/impact-report.schema.json +122 -0
  16. whykit/contract_schemas/query-result.schema.json +105 -0
  17. whykit/contract_schemas/status-report.schema.json +176 -0
  18. whykit/contract_schemas/trace-report.schema.json +211 -0
  19. whykit/diff.py +823 -0
  20. whykit/evidence.py +178 -0
  21. whykit/explorer_index.py +554 -0
  22. whykit/graph.py +318 -0
  23. whykit/immutability.py +624 -0
  24. whykit/impact.py +272 -0
  25. whykit/init_layout.py +205 -0
  26. whykit/io.py +563 -0
  27. whykit/lint.py +2190 -0
  28. whykit/lsp.py +1241 -0
  29. whykit/markdown_code.py +107 -0
  30. whykit/mcp_server.py +2289 -0
  31. whykit/messages.py +46 -0
  32. whykit/pack.py +294 -0
  33. whykit/parse_cache.py +752 -0
  34. whykit/placeholders.py +84 -0
  35. whykit/promote.py +651 -0
  36. whykit/py.typed +0 -0
  37. whykit/query.py +174 -0
  38. whykit/review.py +527 -0
  39. whykit/rule_policy.py +1063 -0
  40. whykit/rules.py +274 -0
  41. whykit/scaffold.py +784 -0
  42. whykit/sensitivity.py +95 -0
  43. whykit/snapshot.py +319 -0
  44. whykit/status.py +167 -0
  45. whykit/tables.py +120 -0
  46. whykit/template/00-context/README.md +17 -0
  47. whykit/template/00-context/company.md +52 -0
  48. whykit/template/00-context/evidence-register.md +34 -0
  49. whykit/template/00-context/goals.md +38 -0
  50. whykit/template/00-context/review-log.md +22 -0
  51. whykit/template/00-context/terminology.md +34 -0
  52. whykit/template/01-strategy/README.md +7 -0
  53. whykit/template/02-discoverability/README.md +10 -0
  54. whykit/template/03-website/README.md +7 -0
  55. whykit/template/04-automation/README.md +10 -0
  56. whykit/template/05-operations/README.md +19 -0
  57. whykit/template/06-decisions/README.md +11 -0
  58. whykit/template/06-decisions/decision-log.md +38 -0
  59. whykit/template/07-research/README.md +10 -0
  60. whykit/template/AGENTS.md +163 -0
  61. whykit/template/Home.md +57 -0
  62. whykit/template/INTEROP.md +117 -0
  63. whykit/template/OBSIDIAN.md +92 -0
  64. whykit/template/README.md +51 -0
  65. whykit/template/assets/README.md +6 -0
  66. whykit/template/reports/README.md +7 -0
  67. whykit/template/templates/README.md +7 -0
  68. whykit/template/templates/automation-spec-template.md +70 -0
  69. whykit/template/templates/decision-record-template.md +65 -0
  70. whykit/template/templates/document-template.md +55 -0
  71. whykit/template/templates/experiment-template.md +62 -0
  72. whykit/template/templates/meeting-note-template.md +47 -0
  73. whykit/template/templates/page-brief-template.md +63 -0
  74. whykit/template/templates/research-note-template.md +53 -0
  75. whykit/template/templates/source-ingestion-template.md +43 -0
  76. whykit/template/whykit.toml +39 -0
  77. whykit/trace.py +272 -0
  78. whykit/vault_index.py +190 -0
  79. whykit-0.3.1.dist-info/METADATA +162 -0
  80. whykit-0.3.1.dist-info/RECORD +84 -0
  81. whykit-0.3.1.dist-info/WHEEL +4 -0
  82. whykit-0.3.1.dist-info/entry_points.txt +3 -0
  83. whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
  84. whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/review.py ADDED
@@ -0,0 +1,527 @@
1
+ """Operational review queue and append-only review events."""
2
+ from __future__ import annotations
3
+
4
+ from .io import consistent_read
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import difflib
9
+ import hashlib
10
+ import json
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from .contract import TargetNotFound, describe_os_error, emit_error, vault_not_found
15
+ from .io import apply_transaction, safe_vault_target, vault_mutation_lock
16
+ from .config import CONFIG_FILE, ConfigError, load_config, parse_config
17
+ from .lint import _parse_evidence_register_text, check_front_matter, check_decision_review, check_decision_placeholders, lint, DECISION_ID_RE, _build_index, _parse_date, _resolve, collect_markdown, find_vault_root, is_vault_root, load_note, rel, path_cache, strip_markdown_suffix
18
+ from .scaffold import _frontmatter_replace, _table_cell, _update_decision_log_status_text
19
+ from .tables import review_table_header
20
+ from .immutability import RECORD_RE, _front_matter_lines, _review_log_parts, approval_record_hash
21
+ from .placeholders import PROSE_SECTIONS, split_sections
22
+ from .markdown_code import mask_code
23
+ from .status import build_review_queue
24
+ from .vault_index import VaultIndex
25
+ from .console import emit_machine, one_line
26
+
27
+ OUTCOMES = ("confirmed", "update-required", "supersede-required", "archived")
28
+
29
+
30
+ def _append_review_row(log_before: str, values: tuple[str, ...], today: dt.date) -> str:
31
+ lines = log_before.splitlines()
32
+ header_idx = review_table_header(lines)
33
+ if header_idx is None:
34
+ raise ValueError("review log table is missing or malformed")
35
+ insert_at = header_idx + 2
36
+ while insert_at < len(lines) and lines[insert_at].lstrip().startswith("|"):
37
+ insert_at += 1
38
+ lines.insert(insert_at, "| " + " | ".join(_table_cell(value) for value in values) + " |")
39
+ updated = "\n".join(lines) + ("\n" if log_before.endswith("\n") else "")
40
+ if review_table_header(updated.splitlines()) is None:
41
+ raise ValueError("review log table is missing or malformed")
42
+ return _touch_last_updated(updated, today)
43
+
44
+
45
+
46
+ def _reviewed_provenance(text: str) -> str:
47
+ """Change only the review flag, preserving producer extensions verbatim."""
48
+ parsed = _front_matter_lines(text)
49
+ if parsed is None:
50
+ raise ValueError("approval requires readable front matter")
51
+ lines, end = parsed
52
+ start = next((i for i in range(1, end) if re.match(r"^provenance[ \t]*:", lines[i])), None)
53
+ if start is None:
54
+ return text
55
+ stop = next((i for i in range(start + 1, end) if re.match(r"^[^\s#][^:]*:", lines[i])), end)
56
+ indent = next((match.group() for line in lines[start + 1:stop] if line.strip() and (match := re.match(r"^[ \t]+", line)) is not None), " ")
57
+ flag = next((i for i in range(start + 1, stop) if re.match(rf"^{re.escape(indent)}human_reviewed[ \t]*:", lines[i])), None)
58
+ if flag is None:
59
+ lines.insert(stop, indent + "human_reviewed: true\n")
60
+ else:
61
+ lines[flag] = indent + "human_reviewed: true\n"
62
+ return ("\ufeff" if text.startswith("\ufeff") else "") + "".join(lines)
63
+
64
+
65
+
66
+ @consistent_read
67
+ @path_cache()
68
+ def _approval_plan(root: Path, target: str, reviewer: str, today: dt.date, next_review: str | None) -> tuple[dict, dict[Path, str], dict[Path, bytes | None]]:
69
+ root = root.resolve()
70
+ reviewer = reviewer.strip()
71
+ if not reviewer or reviewer.casefold() == "todo" or any(ord(c) < 32 for c in reviewer) or one_line(reviewer) != reviewer:
72
+ raise ValueError("--reviewer must name a reviewer on one line")
73
+ index = VaultIndex.load(root)
74
+ if DECISION_ID_RE.fullmatch(target):
75
+ matches = [note for note in index.notes if note.front.get("decision_id") == target]
76
+ note = matches[0] if len(matches) == 1 else None
77
+ else:
78
+ path, ambiguous = index.resolve_link(target)
79
+ note = index.note_for(path) if path is not None and not ambiguous else None
80
+ if note is None:
81
+ raise TargetNotFound(f"decision is missing or ambiguous: {target}")
82
+ target_rel = rel(root, note.path)
83
+ if not RECORD_RE.fullmatch(target_rel) or not DECISION_ID_RE.fullmatch(str(note.front.get("decision_id") or "")):
84
+ raise ValueError("only a decision record can be approved")
85
+ if note.front_error or note.front.get("status") not in {"draft", "in_review"}:
86
+ raise ValueError("only readable draft/in_review decisions can be approved; never reset an accepted record")
87
+ owner = note.front.get("owner")
88
+ if not isinstance(owner, str) or not owner.strip() or owner.strip().casefold() == "todo":
89
+ raise ValueError("approval requires a real owner")
90
+ created = _parse_date(note.front.get("created") or "")
91
+ if created is None or today < created:
92
+ raise ValueError("approval date must be on or after the record's creation")
93
+ required_sections = {*PROSE_SECTIONS, "Evidence", "Alternatives considered", "Consequences"}
94
+ headings = [heading for heading, _, _ in split_sections(note.masked.splitlines())]
95
+ missing = required_sections - set(headings)
96
+ if missing or any(headings.count(heading) != 1 for heading in required_sections):
97
+ raise ValueError("approval requires exactly one of each decision section: " + ", ".join(sorted(required_sections)))
98
+ visible_lines = mask_code(note.text, keep_code=True).splitlines()
99
+ for heading, start, stop in split_sections(note.masked.splitlines()):
100
+ if heading in required_sections and not any(
101
+ line.strip() not in {"", "-", "*", "+", "—", "---"}
102
+ and not line.lstrip().startswith(("#", "```", "~~~"))
103
+ for line in visible_lines[start:stop]
104
+ ):
105
+ raise ValueError(f"approval requires content in the {heading} section")
106
+ if note.decision_placeholders:
107
+ raise ValueError("replace every decision placeholder before approval")
108
+ inputs: dict[Path, bytes | None] = {}
109
+
110
+ def capture(relative: str) -> tuple[Path, str | None]:
111
+ path = safe_vault_target(root, relative, create_parents=False)
112
+ data = path.read_bytes() if path.exists() else None
113
+ inputs[path] = data
114
+ try:
115
+ return path, data.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") if data is not None else None
116
+ except UnicodeDecodeError:
117
+ raise ValueError(f"approval input is not UTF-8: {relative}") from None
118
+
119
+ note_path, target_before = capture(target_rel)
120
+ assert target_before is not None
121
+ if load_note(note_path, text=target_before).text != note.text:
122
+ raise ValueError("record changed during preview; retry")
123
+ _, config_text = capture(CONFIG_FILE)
124
+ config = parse_config(config_text or "")
125
+ _, register_text = capture("00-context/evidence-register.md")
126
+ decision_log, decision_log_before = capture("06-decisions/decision-log.md")
127
+ if decision_log_before is None:
128
+ raise ValueError("decision log is missing")
129
+ review_log, review_before = capture("00-context/review-log.md")
130
+ review_before = review_before or _review_log_template(today, reviewer)
131
+ parsed_log = _review_log_parts(review_before)
132
+ if parsed_log is None:
133
+ raise ValueError("review log table is missing or malformed")
134
+ for row in parsed_log[1]:
135
+ link = re.fullmatch(r"\[\[([^\]|#]+)(?:\|[^\]]*)?\]\]", row[1])
136
+ if row[3] == "approved" and link and strip_markdown_suffix(link.group(1).strip()) == strip_markdown_suffix(target_rel):
137
+ raise ValueError("this record already has an approval event; never reset an accepted record")
138
+ active, retired, _ = _parse_evidence_register_text(register_text or "")
139
+ cited = set(note.cited_evidence)
140
+ if not cited or not cited <= set(active) or cited & set(retired):
141
+ raise ValueError("approval requires cited active evidence; missing or retired sources must be reviewed first")
142
+ _, findings = lint(root, orphans=False, today=today, vault=index, config=config)
143
+ blocked = [item for item in findings
144
+ if not (item.path == target_rel and item.code == "canonical.unapproved")
145
+ and (item.level == "error" or item.path == target_rel
146
+ or (item.code == "evidence.access_stale" and cited.intersection(re.findall(r"E-[0-9]{3,}", item.message))))]
147
+ if blocked:
148
+ raise ValueError("approval refused by vault checks: " + "; ".join(f"{item.path}: {item.code}" for item in blocked[:10]))
149
+ resolved_next = next_review or (today + dt.timedelta(days=int(config["defaults"]["decision_review_days"]))).isoformat()
150
+ next_date = _parse_date(resolved_next)
151
+ if next_date is None or next_date <= today:
152
+ raise ValueError("--next-review must be a real YYYY-MM-DD after the approval date")
153
+ updated_target = _frontmatter_replace(target_before, "status", "approved")
154
+ updated_target = _frontmatter_replace(updated_target, "review_by", resolved_next)
155
+ updated_target = re.sub(r"(?m)(^## Status\n[ \t]*\n)(?:Proposed|Draft|In review)[ \t]*$", r"\1Accepted", updated_target, count=1)
156
+ updated_target = updated_target.replace("- Decided on: TBD\n", f"- Decided on: {today.isoformat()}\n", 1)
157
+ updated_target = _touch_last_updated(_reviewed_provenance(updated_target), today)
158
+ candidate = load_note(note_path, text=updated_target)
159
+ final_findings: list = []
160
+ check_front_matter(root, candidate, final_findings, today)
161
+ check_decision_review(root, [candidate], final_findings)
162
+ check_decision_placeholders(root, [candidate], final_findings)
163
+ if final_findings:
164
+ raise ValueError("approval refused: " + "; ".join(item.code for item in final_findings))
165
+ decision_id = str(note.front["decision_id"])
166
+ updated_decision_log = _update_decision_log_status_text(decision_log_before, decision_id, "accepted", today)
167
+ updates = {note_path: updated_target, decision_log: updated_decision_log}
168
+ predecessor = note.front.get("supersedes")
169
+ if predecessor:
170
+ matches = [other for other in index.notes if other.front.get("decision_id") == predecessor]
171
+ if len(matches) != 1 or matches[0].front.get("status") != "approved":
172
+ raise ValueError("supersedes must name one currently approved decision")
173
+ previous_path, previous_text = capture(rel(root, matches[0].path))
174
+ assert previous_text is not None
175
+ if load_note(previous_path, text=previous_text).text != matches[0].text:
176
+ raise ValueError("predecessor changed during preview; retry")
177
+ previous_text = _frontmatter_replace(previous_text, "status", "superseded")
178
+ previous_text = _frontmatter_replace(previous_text, "superseded_by", decision_id)
179
+ updates[previous_path] = _touch_last_updated(previous_text, today)
180
+ updates[decision_log] = _update_decision_log_status_text(updates[decision_log], str(predecessor), "superseded", today)
181
+ snapshot = hashlib.sha256(inputs[note_path] or b"").hexdigest()
182
+ record_hash = approval_record_hash(updated_target)
183
+ receipt = f"record-sha256:{record_hash}; snapshot-sha256:{snapshot}"
184
+ updates[review_log] = _append_review_row(review_before, (
185
+ today.isoformat(), f"[[{strip_markdown_suffix(target_rel)}]]", reviewer, "approved",
186
+ str(note.front.get("review_by") or "—"), resolved_next, receipt,
187
+ ), today)
188
+ binding = {"updates": {rel(root, path): hashlib.sha256(text.encode("utf-8")).hexdigest() for path, text in updates.items()},
189
+ "root": str(root), "target": target_rel, "reviewer": reviewer, "date": today.isoformat(),
190
+ "next_review": resolved_next, "files": {rel(root, path): hashlib.sha256(data).hexdigest() if data is not None else None for path, data in inputs.items()}}
191
+ expected = hashlib.sha256(json.dumps(binding, sort_keys=True, ensure_ascii=True).encode("utf-8")).hexdigest()
192
+ for path, captured_bytes in inputs.items():
193
+ if (path.read_bytes() if path.exists() else None) != captured_bytes:
194
+ raise ValueError("approval inputs changed during preview; retry")
195
+ changes = []
196
+ for path, after in updates.items():
197
+ before = (inputs[path] or b"").decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
198
+ relative = rel(root, path)
199
+ diff = "".join(difflib.unified_diff(before.splitlines(keepends=True), after.splitlines(keepends=True), fromfile=relative, tofile=relative))
200
+ changes.append({"path": relative, "diff": diff})
201
+ return {"contract_version": 1, "applied": False, "expected_sha256": expected,
202
+ "snapshot_sha256": snapshot, "record_sha256": record_hash, "target": target_rel,
203
+ "decision_id": decision_id, "reviewer": reviewer, "date": today.isoformat(),
204
+ "next_review": resolved_next, "reviewed_record": target_before,
205
+ "evidence": [{"id": eid, **active[eid]} for eid in sorted(cited)],
206
+ "changes": changes}, updates, inputs
207
+
208
+
209
+
210
+ def approve_decision(root: Path, target: str, *, reviewer: str, today: dt.date | None = None,
211
+ next_review: str | None = None, write: bool = False, expected_sha256: str | None = None) -> dict:
212
+ """Preview a reviewable diff; apply only the same snapshot under the vault lock."""
213
+ today = today or dt.date.today()
214
+ if write and (expected_sha256 is None or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None):
215
+ raise ValueError("--write requires --expect-hash from a reviewed preview")
216
+ if not write:
217
+ return _approval_plan(root, target, reviewer, today, next_review)[0]
218
+ with vault_mutation_lock(root):
219
+ plan, updates, inputs = _approval_plan(root, target, reviewer, today, next_review)
220
+ if plan["expected_sha256"] != expected_sha256:
221
+ raise ValueError("approval preview is stale or its parameters changed; review a fresh preview")
222
+ for path, before in inputs.items():
223
+ if (path.read_bytes() if path.exists() else None) != before:
224
+ raise ValueError("approval inputs changed; review a fresh preview")
225
+ apply_transaction(root, updates)
226
+ return {**plan, "applied": True}
227
+
228
+
229
+
230
+ def _review_log_template(today: dt.date, owner: str = "TODO") -> str:
231
+ # Build the YAML scalar outside the f-string: Python 3.11 rejects backslashes
232
+ # inside f-string expression parts.
233
+ owner_yaml = json.dumps(owner, ensure_ascii=False)
234
+ return f'''---
235
+ title: "Review log"
236
+ aliases: []
237
+ type: reference
238
+ status: approved
239
+ owner: {owner_yaml}
240
+ created: {today.isoformat()}
241
+ last_updated: {today.isoformat()}
242
+ source_of_truth: false
243
+ sensitivity: internal
244
+ source_ids: []
245
+ tags: ["review", "governance"]
246
+ ---
247
+
248
+ # Review log
249
+
250
+ Append-only operational record of document and decision reviews. A review event
251
+ records that somebody re-checked a record; it does not prove the underlying
252
+ claim is true.
253
+
254
+ | Date | Target | Reviewer | Outcome | Previous review | Next review | Note |
255
+ |---|---|---|---|---|---|---|
256
+ '''
257
+
258
+
259
+ def _front_date(text: str, key: str) -> dt.date | None:
260
+ if not text.startswith("---\n"):
261
+ return None
262
+ end = text.find("\n---\n", 4)
263
+ if end < 0:
264
+ return None
265
+ match = re.search(rf"(?m)^{re.escape(key)}:[ \t]*(\S+)[ \t]*$", text[:end])
266
+ return _parse_date(match.group(1)) if match else None
267
+
268
+
269
+ def _touch_last_updated(text: str, today: dt.date) -> str:
270
+ # A back-dated review (--today in the past) must not move last_updated
271
+ # backwards: the file is being modified now, and a last_updated earlier than
272
+ # `created` is a lint error.
273
+ floor = max(filter(None, (_front_date(text, "created"), _front_date(text, "last_updated"))), default=today)
274
+ return _frontmatter_replace(text, "last_updated", max(today, floor).isoformat())
275
+
276
+
277
+ def _decision_record(root: Path, decision_id: str):
278
+ matches = []
279
+ for path in collect_markdown(root, []):
280
+ note = load_note(path)
281
+ if str(note.front.get("decision_id") or "").strip() == decision_id:
282
+ matches.append(note)
283
+ if len(matches) != 1:
284
+ return None, len(matches) > 1
285
+ return matches[0], False
286
+
287
+
288
+ def _resolve_note(root: Path, target: str):
289
+ notes = [load_note(path) for path in collect_markdown(root, [])]
290
+ if DECISION_ID_RE.fullmatch(target):
291
+ return _decision_record(root, target)
292
+ index = _build_index(notes)
293
+ path, ambiguous = _resolve(root, target, index)
294
+ if path is None:
295
+ return None, ambiguous
296
+ by_path = {note.path.resolve(): note for note in notes}
297
+ return by_path.get(path.resolve()), ambiguous
298
+
299
+
300
+ @consistent_read
301
+ @path_cache()
302
+ def review_queue(root: Path, *, today: dt.date | None = None, due_days: int = 30, owner: str | None = None, overdue_only: bool = False) -> list[dict]:
303
+ today = today or dt.date.today()
304
+ queue = build_review_queue(VaultIndex.load(root), today=today, due_days=due_days)
305
+ if owner:
306
+ needle = owner.casefold()
307
+ queue = [item for item in queue if needle in str(item.get("owner") or "").casefold()]
308
+ if overdue_only:
309
+ queue = [item for item in queue if item.get("state") == "overdue"]
310
+ return queue
311
+
312
+
313
+ def record_review(
314
+ root: Path,
315
+ target: str,
316
+ *,
317
+ reviewer: str,
318
+ outcome: str,
319
+ note_text: str = "",
320
+ next_review: str | None = None,
321
+ today: dt.date | None = None,
322
+ ) -> dict:
323
+ today = today or dt.date.today()
324
+ reviewer = reviewer.strip()
325
+ if not reviewer or reviewer == "TODO":
326
+ raise ValueError("--reviewer must name a real reviewer")
327
+ if outcome not in OUTCOMES:
328
+ raise ValueError(f"unsupported review outcome: {outcome}")
329
+
330
+ with vault_mutation_lock(root):
331
+ note, ambiguous = _resolve_note(root, target)
332
+ if note is None and not ambiguous:
333
+ raise TargetNotFound(f"review target is missing: {target}")
334
+ if note is None:
335
+ raise ValueError(f"review target is ambiguous: {target}")
336
+ if rel(root, note.path) == "00-context/review-log.md":
337
+ raise ValueError("the review log cannot review itself")
338
+
339
+ created = _parse_date(note.front.get("created") or "")
340
+ if created is not None and today < created:
341
+ raise ValueError(
342
+ f"review date {today.isoformat()} is before {rel(root, note.path)} was created ({created.isoformat()})"
343
+ )
344
+
345
+ previous_review = str(note.front.get("review_by") or "").strip() or "—"
346
+ resolved_next = next_review
347
+ if resolved_next:
348
+ try:
349
+ parsed_next = dt.date.fromisoformat(resolved_next)
350
+ except ValueError as exc:
351
+ raise ValueError("--next-review must be a real YYYY-MM-DD date") from exc
352
+ if parsed_next <= today:
353
+ raise ValueError("--next-review must be after the review date")
354
+ elif outcome == "confirmed":
355
+ config, _ = load_config(root)
356
+ days = int(config["defaults"]["decision_review_days"])
357
+ resolved_next = (today + dt.timedelta(days=days)).isoformat()
358
+
359
+ if outcome == "confirmed" and note.front.get("status") != "approved":
360
+ raise ValueError("only approved records can be confirmed; review/update the lifecycle state first")
361
+
362
+ log = safe_vault_target(root, "00-context/review-log.md")
363
+ log_before = log.read_text(encoding="utf-8") if log.exists() else _review_log_template(today)
364
+ target_rel = note.path.resolve().relative_to(root.resolve(strict=True))
365
+ note_path = safe_vault_target(root, target_rel, create_parents=False)
366
+ target_before = note_path.read_text(encoding="utf-8")
367
+
368
+ target_link = f"[[{strip_markdown_suffix(rel(root, note.path))}]]"
369
+ updated_log = _append_review_row(log_before, (
370
+ today.isoformat(), target_link, reviewer, outcome, previous_review, resolved_next or "—", note_text or "—"
371
+ ), today)
372
+
373
+ updates: dict = {log: updated_log}
374
+ if outcome == "confirmed" and resolved_next:
375
+ updated_target = _frontmatter_replace(target_before, "review_by", resolved_next)
376
+ updated_target = _touch_last_updated(updated_target, today)
377
+ updates[note_path] = updated_target
378
+ apply_transaction(root, updates)
379
+
380
+ return {
381
+ "contract_version": 1,
382
+ "date": today.isoformat(),
383
+ "target": rel(root, note.path),
384
+ "decision_id": str(note.front.get("decision_id") or "") or None,
385
+ "reviewer": reviewer,
386
+ "outcome": outcome,
387
+ "previous_review": previous_review if previous_review != "—" else None,
388
+ "next_review": resolved_next,
389
+ "note": note_text or None,
390
+ "log": "00-context/review-log.md",
391
+ }
392
+
393
+
394
+ def _queue_summary(count: int, today: dt.date, due_days: int, owner: str | None, overdue_only: bool) -> str:
395
+ """The closing line of `review list`, naming the window it searched.
396
+
397
+ "0 review(s)" alone reads as "nothing to review ever", when it only means
398
+ nothing falls inside this window.
399
+ """
400
+ if overdue_only:
401
+ window = f"overdue as of {today.isoformat()}"
402
+ else:
403
+ horizon = today + dt.timedelta(days=due_days)
404
+ window = f"overdue or due by {horizon.isoformat()} ({due_days} day(s) from {today.isoformat()})"
405
+ scope = f", owner matching {owner!r}" if owner else ""
406
+ return f"{count} review(s) {window}{scope}"
407
+
408
+
409
+ def main(argv: list[str] | None = None) -> int:
410
+ parser = argparse.ArgumentParser(prog="whykit review", description="List review work or record an auditable review event.")
411
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
412
+ sub = parser.add_subparsers(dest="review_command", required=True)
413
+
414
+ listing = sub.add_parser("list", help="show upcoming/overdue review work")
415
+ listing.add_argument("--today")
416
+ listing.add_argument("--due-days", type=int)
417
+ listing.add_argument("--owner")
418
+ listing.add_argument("--overdue-only", action="store_true")
419
+ listing.add_argument("--json", action="store_true")
420
+
421
+ record = sub.add_parser("record", help="append a review event and optionally schedule the next review")
422
+ record.add_argument("target", help="D-NNN, path, stem or alias")
423
+ record.add_argument("--reviewer", required=True)
424
+ record.add_argument("--outcome", choices=OUTCOMES, default="confirmed")
425
+ record.add_argument("--next-review")
426
+ record.add_argument("--note", default="", dest="note_text")
427
+ record.add_argument("--today")
428
+ record.add_argument("--json", action="store_true")
429
+
430
+ approval = sub.add_parser("approve", help="preview an approval; apply only the reviewed snapshot")
431
+ approval.add_argument("target", help="D-NNN, path, stem or alias")
432
+ approval.add_argument("--reviewer", required=True)
433
+ approval.add_argument("--next-review")
434
+ approval.add_argument("--today")
435
+ approval.add_argument("--write", action="store_true")
436
+ approval.add_argument("--expect-hash", dest="expected_sha256")
437
+ approval.add_argument("--json", action="store_true")
438
+
439
+ args = parser.parse_args(argv)
440
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
441
+ if root is None or not is_vault_root(root):
442
+ return vault_not_found(args.root, json_mode=args.json)
443
+
444
+ if args.review_command == "approve":
445
+ today = _parse_date(args.today) if args.today else dt.date.today()
446
+ if today is None:
447
+ return emit_error("invalid_argument", "--today must be a real YYYY-MM-DD", json_mode=args.json)
448
+ try:
449
+ result = approve_decision(root, args.target, reviewer=args.reviewer, today=today,
450
+ next_review=args.next_review, write=args.write,
451
+ expected_sha256=args.expected_sha256)
452
+ except ConfigError as exc:
453
+ return emit_error("invalid_config", str(exc), json_mode=args.json)
454
+ except TargetNotFound as exc:
455
+ return emit_error("not_found", str(exc), json_mode=args.json)
456
+ except ValueError as exc:
457
+ return emit_error("operation_rejected", str(exc), json_mode=args.json)
458
+ except OSError as exc:
459
+ return emit_error("io_error", describe_os_error(exc), json_mode=args.json)
460
+ if args.json:
461
+ emit_machine(json.dumps(result, ensure_ascii=False, indent=2))
462
+ else:
463
+ print(f"{'approved' if result['applied'] else 'approval preview'}: {one_line(result['target'])}")
464
+ print(f"reviewer: {one_line(result['reviewer'])}; next review: {result['next_review']}")
465
+ print(f"snapshot: {result['snapshot_sha256']}")
466
+ print("reviewed record:")
467
+ for line in result["reviewed_record"].splitlines():
468
+ print(one_line(line))
469
+ print("cited evidence:")
470
+ for evidence in result["evidence"]:
471
+ print(one_line(f"{evidence['id']}: {evidence['source']} | {evidence['location']} | {evidence['claims']}"))
472
+ for change in result["changes"]:
473
+ for line in change["diff"].splitlines():
474
+ print(one_line(line))
475
+ if not result["applied"]:
476
+ print(f"review this snapshot, then pass --write --expect-hash {result['expected_sha256']} with the same parameters")
477
+ return 0
478
+
479
+ if args.review_command == "list":
480
+ today = None
481
+ if args.today:
482
+ try:
483
+ today = dt.date.fromisoformat(args.today)
484
+ except ValueError:
485
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
486
+ try:
487
+ config, _ = load_config(root)
488
+ except ConfigError as exc:
489
+ return emit_error("invalid_config", str(exc), json_mode=args.json)
490
+ due_days = args.due_days if args.due_days is not None else int(config["defaults"]["status_due_days"])
491
+ if due_days < 0:
492
+ return emit_error("invalid_argument", "--due-days must be >= 0", json_mode=args.json)
493
+ queue = review_queue(root, today=today, due_days=due_days, owner=args.owner, overdue_only=args.overdue_only)
494
+ payload = {"contract_version": 1, "due_days": due_days, "count": len(queue), "reviews": queue}
495
+ if args.json:
496
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
497
+ else:
498
+ for item in queue:
499
+ marker = "OVERDUE" if item["state"] == "overdue" else "DUE"
500
+ print(f"{marker:<7} {item['review_by']} {item['path']} ({item['owner'] or 'no owner'})")
501
+ print(f"\n{_queue_summary(len(queue), today or dt.date.today(), due_days, args.owner, args.overdue_only)}")
502
+ return 0
503
+
504
+ today = None
505
+ if args.today:
506
+ try:
507
+ today = dt.date.fromisoformat(args.today)
508
+ except ValueError:
509
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
510
+ try:
511
+ result = record_review(
512
+ root, args.target, reviewer=args.reviewer, outcome=args.outcome,
513
+ note_text=args.note_text, next_review=args.next_review, today=today,
514
+ )
515
+ except TargetNotFound as exc:
516
+ return emit_error("not_found", str(exc), json_mode=args.json)
517
+ except ValueError as exc:
518
+ return emit_error("operation_rejected", str(exc), json_mode=args.json)
519
+ except OSError as exc:
520
+ return emit_error("io_error", describe_os_error(exc), json_mode=args.json)
521
+ if args.json:
522
+ emit_machine(json.dumps(result, ensure_ascii=False, indent=2))
523
+ else:
524
+ print(f"review recorded: {result['target']} -> {result['outcome']}")
525
+ if result.get("next_review"):
526
+ print(f"next review: {result['next_review']}")
527
+ return 0