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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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
|