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/sensitivity.py ADDED
@@ -0,0 +1,95 @@
1
+ """Shared fail-closed sensitivity classification for readers and exports."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+ from collections import Counter, defaultdict, deque
6
+
7
+ SENSITIVITY_LEVEL = {
8
+ "public": 0,
9
+ "internal": 1,
10
+ "confidential": 2,
11
+ "restricted": 3,
12
+ }
13
+
14
+ UNREADABLE_LEVEL = 99
15
+
16
+
17
+ def _sensitivity_level(value: object) -> int:
18
+ key = str(value or "internal").lower() or "internal"
19
+ return SENSITIVITY_LEVEL.get(key, 99)
20
+
21
+
22
+ def note_sensitivity_level(note: Any) -> int:
23
+ """The level a note is filtered at, failing closed when its label is unreadable.
24
+
25
+ A note without front matter, or with valid front matter that omits the
26
+ key, is `internal` (the documented default). Front matter that does not
27
+ parse, or a label spelt with different case or spacing (`Sensitivity:`),
28
+ may hide a stricter label WhyKit cannot read, so such a note is treated as
29
+ above every ceiling.
30
+ """
31
+ if note.front_error:
32
+ return UNREADABLE_LEVEL
33
+ front = note.front
34
+ value = front.get("sensitivity")
35
+ level = SENSITIVITY_LEVEL["internal"]
36
+ if "sensitivity" in front:
37
+ level = _sensitivity_level(value) if isinstance(value, str) and value else UNREADABLE_LEVEL
38
+ for key in front:
39
+ if key != "sensitivity" and str(key).strip().casefold() == "sensitivity":
40
+ level = UNREADABLE_LEVEL
41
+ return level
42
+
43
+
44
+ def evidence_register_notes(index: Any) -> list[Any]:
45
+ """Match captured register paths, including .MD and case aliases."""
46
+ return [note for note in index.notes
47
+ if index.relative(note.path).casefold() == "00-context/evidence-register.md"]
48
+
49
+
50
+ def classified_evidence(index: Any) -> tuple[dict, dict, list, bool]:
51
+ """Classify rows from the same captured bytes as their register's label."""
52
+ registers = evidence_register_notes(index)
53
+ if len(registers) != 1:
54
+ # Ambiguous registers must not expose any rows or raw candidate bodies.
55
+ return {}, {}, [], bool(registers)
56
+ return classify_evidence_register(registers[0])
57
+
58
+
59
+ def classify_evidence_register(note: Any) -> tuple[dict, dict, list, bool]:
60
+ """Classify one captured register with duplicate and replacement-chain floors."""
61
+ from .lint import _parse_evidence_register_text
62
+ from .tables import split_table_row
63
+
64
+ floor = note_sensitivity_level(note)
65
+ names = {level: name for name, level in SENSITIVITY_LEVEL.items()}
66
+ active, retired, occurrences = _parse_evidence_register_text(note.text)
67
+ per_row = any("sensitivity" in row for row in (*active.values(), *retired.values()))
68
+ for line in note.text.splitlines():
69
+ if line.lstrip().startswith("|"):
70
+ cells = [cell.casefold() for cell in split_table_row(line)]
71
+ if cells and cells[0] == "id" and ("sensitivity" in cells or len(cells) not in {5, 7}):
72
+ per_row = True
73
+ duplicates = {key for key, count in Counter(key for key, _ in occurrences).items() if count > 1}
74
+ per_row = per_row or bool(duplicates)
75
+ for rows in (active, retired):
76
+ for key, row in rows.items():
77
+ level = max(floor, SENSITIVITY_LEVEL.get(row["sensitivity"], UNREADABLE_LEVEL)) if "sensitivity" in row else floor
78
+ if key in duplicates:
79
+ level = UNREADABLE_LEVEL
80
+ row["sensitivity"] = names.get(level, "unclassified")
81
+ rows = {**active, **retired}
82
+ dependents: dict[str, list[str]] = defaultdict(list)
83
+ for key, row in retired.items():
84
+ replacement = row.get("replaced_by")
85
+ if replacement in rows:
86
+ dependents[replacement].append(key)
87
+ pending = deque(rows)
88
+ while pending:
89
+ key = pending.popleft()
90
+ level = SENSITIVITY_LEVEL.get(rows[key]["sensitivity"], UNREADABLE_LEVEL)
91
+ for dependent in dependents[key]:
92
+ if level > SENSITIVITY_LEVEL.get(rows[dependent]["sensitivity"], UNREADABLE_LEVEL):
93
+ rows[dependent]["sensitivity"] = names.get(level, "unclassified")
94
+ pending.append(dependent)
95
+ return active, retired, occurrences, per_row
whykit/snapshot.py ADDED
@@ -0,0 +1,319 @@
1
+ """Deterministic vault snapshots and drift verification."""
2
+ from __future__ import annotations
3
+
4
+ from .io import consistent_read
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .contract import emit_error, vault_not_found
14
+ from .graph import build_graph
15
+ from .io import atomic_write_text, safe_export_target
16
+ from .lint import collect_markdown, find_vault_root, is_markdown_name, is_vault_root, load_note, rel, path_cache
17
+ from .status import build_status
18
+ from .vault_index import VaultIndex
19
+ from .console import emit_machine
20
+
21
+ SNAPSHOT_FORMAT_V1 = "whykit.snapshot/v1"
22
+ SNAPSHOT_FORMAT_V2 = "whykit.snapshot/v2"
23
+ # What `whykit snapshot` writes. verify-snapshot reads every format in
24
+ # SNAPSHOT_FORMATS and re-hashes the vault the way the baseline was hashed.
25
+ SNAPSHOT_FORMAT = SNAPSHOT_FORMAT_V2
26
+ SNAPSHOT_FORMATS = {"v1": SNAPSHOT_FORMAT_V1, "v2": SNAPSHOT_FORMAT_V2}
27
+
28
+ # v1 hashes raw bytes, so a checkout with `core.autocrlf=true` (Windows) or an
29
+ # editor that adds a byte-order mark reports every file as changed. v2 hashes
30
+ # the text after this normalization, and records it in the snapshot so a
31
+ # reader never has to guess how a digest was computed.
32
+ NORMALIZATION_V2 = {"utf8_bom": "strip", "line_endings": "crlf-to-lf"}
33
+
34
+ _BOM = b"\xef\xbb\xbf"
35
+
36
+
37
+ def normalize_content(data: bytes) -> bytes:
38
+ """The bytes a v2 snapshot hashes: one leading UTF-8 BOM removed, CRLF as LF.
39
+
40
+ Lone CR and every other byte are kept, so any edit a reader could see is
41
+ still a change.
42
+ """
43
+ if data.startswith(_BOM):
44
+ data = data[len(_BOM):]
45
+ return data.replace(b"\r\n", b"\n")
46
+
47
+
48
+ def _sha256(path: Path) -> str:
49
+ digest = hashlib.sha256()
50
+ with path.open("rb") as handle:
51
+ for chunk in iter(lambda: handle.read(65536), b""):
52
+ digest.update(chunk)
53
+ return digest.hexdigest()
54
+
55
+
56
+ def _digest(path: Path, snapshot_format: str) -> tuple[str, int]:
57
+ """(sha256, byte count) of ``path`` as ``snapshot_format`` defines them."""
58
+ if snapshot_format == SNAPSHOT_FORMAT_V1:
59
+ return _sha256(path), path.stat().st_size
60
+ data = normalize_content(path.read_bytes())
61
+ return hashlib.sha256(data).hexdigest(), len(data)
62
+
63
+
64
+ def _snapshot_files(root: Path, vault: VaultIndex | None = None) -> list[Path]:
65
+ files = {note.path for note in vault.notes} if vault is not None else set(collect_markdown(root, []))
66
+ config = root / "whykit.toml"
67
+ if config.is_file():
68
+ files.add(config)
69
+ relative = vault.relative if vault is not None else (lambda path: rel(root, path))
70
+ return sorted(files, key=relative)
71
+
72
+
73
+ def _file_entry(
74
+ root: Path, path: Path, vault: VaultIndex | None = None, *, snapshot_format: str = SNAPSHOT_FORMAT,
75
+ ) -> dict[str, Any]:
76
+ sha256, size = _digest(path, snapshot_format)
77
+ entry: dict[str, Any] = {
78
+ "path": vault.relative(path) if vault is not None else rel(root, path),
79
+ "sha256": sha256,
80
+ "bytes": size,
81
+ }
82
+ if is_markdown_name(path.name):
83
+ note = (vault.note_for(path) if vault is not None else None) or load_note(path)
84
+ entry.update({
85
+ "title": str(note.front.get("title") or path.stem),
86
+ "type": str(note.front.get("type") or ""),
87
+ "status": str(note.front.get("status") or ""),
88
+ "owner": str(note.front.get("owner") or ""),
89
+ "sensitivity": str(note.front.get("sensitivity") or ""),
90
+ "source_of_truth": note.front.get("source_of_truth") is True,
91
+ "decision_id": str(note.front.get("decision_id") or "") or None,
92
+ "review_by": str(note.front.get("review_by") or "") or None,
93
+ })
94
+ return entry
95
+
96
+
97
+ def _snapshot_id(entries: list[dict[str, Any]]) -> str:
98
+ digest = hashlib.sha256()
99
+ for item in entries:
100
+ digest.update(item["path"].encode("utf-8"))
101
+ digest.update(b"\0")
102
+ digest.update(item["sha256"].encode("ascii"))
103
+ digest.update(b"\n")
104
+ return digest.hexdigest()
105
+
106
+
107
+ @consistent_read
108
+ @path_cache()
109
+ def build_snapshot(
110
+ root: Path, *, today: dt.date | None = None, snapshot_format: str = SNAPSHOT_FORMAT,
111
+ ) -> dict[str, Any]:
112
+ if snapshot_format not in SNAPSHOT_FORMATS.values():
113
+ raise ValueError(f"unsupported snapshot format: {snapshot_format!r}")
114
+ today = today or dt.date.today()
115
+ root = root.resolve()
116
+ # One parse feeds the file table, status and graph; each used to re-read
117
+ # every note, which tripled the cost of a snapshot on a large vault.
118
+ vault = VaultIndex.load(root)
119
+ entries = [
120
+ _file_entry(root, path, vault, snapshot_format=snapshot_format)
121
+ for path in _snapshot_files(root, vault)
122
+ ]
123
+ status = build_status(root, today=today, vault=vault)
124
+ graph = build_graph(root, vault=vault)
125
+ edges_by_type: dict[str, int] = {}
126
+ for edge in graph.get("edges", []):
127
+ edge_type = str(edge.get("type") or "wikilink")
128
+ edges_by_type[edge_type] = edges_by_type.get(edge_type, 0) + 1
129
+ payload: dict[str, Any] = {
130
+ "format": snapshot_format,
131
+ "contract_version": 1,
132
+ }
133
+ if snapshot_format != SNAPSHOT_FORMAT_V1:
134
+ payload["normalization"] = dict(NORMALIZATION_V2)
135
+ return payload | {
136
+ "snapshot_id": _snapshot_id(entries),
137
+ "as_of": today.isoformat(),
138
+ "files": entries,
139
+ "summary": {
140
+ "documents": status["documents"],
141
+ "canonical": status["canonical"],
142
+ "decisions": status["decisions"],
143
+ "evidence_active": status["evidence_active"],
144
+ "evidence_retired": status["evidence_retired"],
145
+ "errors": status["errors"],
146
+ "warnings": status["warnings"],
147
+ "review_due": len(status["review_queue"]),
148
+ "graph_nodes": len(graph.get("nodes", [])),
149
+ "graph_edges": len(graph.get("edges", [])),
150
+ "unresolved_links": len(graph.get("unresolved", [])),
151
+ "edges_by_type": dict(sorted(edges_by_type.items())),
152
+ },
153
+ }
154
+
155
+
156
+ @consistent_read
157
+ @path_cache()
158
+ def compare_snapshot(root: Path, baseline: dict[str, Any], *, today: dt.date | None = None) -> dict[str, Any]:
159
+ snapshot_format = baseline.get("format")
160
+ if snapshot_format not in SNAPSHOT_FORMATS.values():
161
+ supported = ", ".join(sorted(SNAPSHOT_FORMATS.values()))
162
+ raise ValueError(f"unsupported snapshot format: {snapshot_format!r} (this WhyKit reads {supported})")
163
+ if snapshot_format == SNAPSHOT_FORMAT_V2 and baseline.get("normalization") != NORMALIZATION_V2:
164
+ # Digests computed under another normalization cannot be compared;
165
+ # reporting every file as changed would be a false alarm.
166
+ raise ValueError(
167
+ f"unsupported snapshot normalization: {baseline.get('normalization')!r} "
168
+ f"(this WhyKit hashes {SNAPSHOT_FORMAT_V2} with {NORMALIZATION_V2})"
169
+ )
170
+ # Re-hash the vault the way the baseline was hashed: a v1 baseline keeps
171
+ # its exact byte semantics, so upgrading WhyKit never turns MATCH into DRIFT.
172
+ current = build_snapshot(root, today=today, snapshot_format=str(snapshot_format))
173
+ before = {item["path"]: item for item in baseline.get("files", []) if isinstance(item, dict) and item.get("path")}
174
+ after = {item["path"]: item for item in current["files"]}
175
+ added = sorted(set(after) - set(before))
176
+ removed = sorted(set(before) - set(after))
177
+ changed = sorted(path for path in set(before) & set(after) if before[path].get("sha256") != after[path].get("sha256"))
178
+ metadata_changed = sorted(
179
+ path for path in set(before) & set(after)
180
+ if before[path].get("sha256") == after[path].get("sha256") and before[path] != after[path]
181
+ )
182
+ content_matches = not (added or removed or changed or metadata_changed)
183
+ baseline_summary = baseline.get("summary", {}) if isinstance(baseline.get("summary"), dict) else {}
184
+ current_summary = current["summary"]
185
+ health_keys = ("errors", "warnings", "review_due", "unresolved_links")
186
+ health_delta = {
187
+ key: {
188
+ "before": baseline_summary.get(key),
189
+ "after": current_summary.get(key),
190
+ }
191
+ for key in health_keys
192
+ if baseline_summary.get(key) != current_summary.get(key)
193
+ }
194
+ return {
195
+ "contract_version": 1,
196
+ "format": snapshot_format,
197
+ "baseline_snapshot_id": baseline.get("snapshot_id"),
198
+ "current_snapshot_id": current["snapshot_id"],
199
+ # `matches` remains content identity for backwards compatibility. A
200
+ # vault can become review-due as time advances without a byte changing.
201
+ "matches": content_matches,
202
+ "content_matches": content_matches,
203
+ "health_changed": bool(health_delta),
204
+ "health_delta": health_delta,
205
+ "added": added,
206
+ "removed": removed,
207
+ "changed": changed,
208
+ "metadata_changed": metadata_changed,
209
+ "baseline_summary": baseline_summary,
210
+ "current_summary": current_summary,
211
+ }
212
+
213
+
214
+ def _load_baseline(path: Path) -> dict[str, Any]:
215
+ hint = "hint: create a baseline with `whykit snapshot --output <file>`"
216
+ try:
217
+ text = path.read_text(encoding="utf-8")
218
+ except FileNotFoundError:
219
+ raise ValueError(f"snapshot not found: {path}\n{hint}") from None
220
+ except (OSError, UnicodeDecodeError) as exc:
221
+ raise ValueError(f"cannot read snapshot {path}: {getattr(exc, 'strerror', None) or exc}") from None
222
+ try:
223
+ payload = json.loads(text)
224
+ except json.JSONDecodeError as exc:
225
+ raise ValueError(
226
+ f"not a WhyKit snapshot (invalid JSON at line {exc.lineno}, column {exc.colno}): {path}\n{hint}"
227
+ ) from None
228
+ if not isinstance(payload, dict):
229
+ raise ValueError(f"not a WhyKit snapshot (expected a JSON object): {path}\n{hint}")
230
+ return payload
231
+
232
+
233
+ def main_snapshot(argv: list[str] | None = None) -> int:
234
+ parser = argparse.ArgumentParser(prog="whykit snapshot", description="Create a deterministic content snapshot of a WhyKit vault.")
235
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
236
+ parser.add_argument("--today", help="evaluate health/review status as of this ISO date")
237
+ parser.add_argument("--output", help="write JSON to this path instead of stdout")
238
+ parser.add_argument("--compact", action="store_true", help="emit compact JSON")
239
+ parser.add_argument(
240
+ "--format", dest="snapshot_format", choices=tuple(SNAPSHOT_FORMATS), default="v2",
241
+ help="v2 (default) hashes text with CRLF and a UTF-8 BOM normalized; v1 hashes raw bytes",
242
+ )
243
+ args = parser.parse_args(argv)
244
+ # Without --output the command's stdout is JSON, so failures are too.
245
+ json_mode = not args.output
246
+ requested_root = Path(args.root).expanduser().absolute() if args.root else find_vault_root()
247
+ root = requested_root.resolve() if requested_root is not None else None
248
+ if root is None or not is_vault_root(root):
249
+ return vault_not_found(args.root, json_mode=json_mode)
250
+ today = None
251
+ if args.today:
252
+ try:
253
+ today = dt.date.fromisoformat(args.today)
254
+ except ValueError:
255
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=json_mode)
256
+ payload = build_snapshot(root, today=today, snapshot_format=SNAPSHOT_FORMATS[args.snapshot_format])
257
+ rendered = json.dumps(payload, ensure_ascii=False, indent=None if args.compact else 2, sort_keys=args.compact)
258
+ if args.output:
259
+ target = Path(args.output).expanduser()
260
+ try:
261
+ if target.is_absolute():
262
+ try:
263
+ relative = target.relative_to(root)
264
+ except ValueError:
265
+ relative = target.relative_to(requested_root)
266
+ else:
267
+ relative = target
268
+ target = safe_export_target(root, relative)
269
+ except (OSError, RuntimeError, ValueError) as exc:
270
+ return emit_error("unsafe_path", f"--output must be a safe path inside the vault: {exc}", json_mode=json_mode)
271
+ atomic_write_text(target, rendered + "\n")
272
+ print(f"snapshot {payload['snapshot_id']}: {rel(root, target)}")
273
+ else:
274
+ emit_machine(rendered)
275
+ return 0
276
+
277
+
278
+ def main_verify(argv: list[str] | None = None) -> int:
279
+ parser = argparse.ArgumentParser(prog="whykit verify-snapshot", description="Compare the vault with a prior WhyKit snapshot.")
280
+ parser.add_argument("snapshot", help="baseline snapshot JSON")
281
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
282
+ parser.add_argument("--today", help="evaluate health/review status as of this ISO date")
283
+ parser.add_argument("--json", action="store_true")
284
+ args = parser.parse_args(argv)
285
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
286
+ if root is None or not is_vault_root(root):
287
+ return vault_not_found(args.root, json_mode=args.json)
288
+ snapshot_path = Path(args.snapshot).expanduser()
289
+ if not snapshot_path.is_absolute():
290
+ snapshot_path = (root / snapshot_path).resolve()
291
+ today = None
292
+ if args.today:
293
+ try:
294
+ today = dt.date.fromisoformat(args.today)
295
+ except ValueError:
296
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
297
+ try:
298
+ report = compare_snapshot(root, _load_baseline(snapshot_path), today=today)
299
+ except ValueError as exc:
300
+ return emit_error("invalid_argument", str(exc), json_mode=args.json)
301
+ if args.json:
302
+ emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
303
+ else:
304
+ state = "MATCH" if report["matches"] else "DRIFT"
305
+ print(f"snapshot verification: {state}")
306
+ print(f" baseline {report.get('baseline_snapshot_id') or '—'}")
307
+ print(f" current {report['current_snapshot_id']}")
308
+ for key in ("added", "removed", "changed", "metadata_changed"):
309
+ values = report[key]
310
+ print(f" {key:<16} {len(values)}")
311
+ for value in values[:30]:
312
+ print(f" {value}")
313
+ if len(values) > 30:
314
+ print(f" … {len(values) - 30} more")
315
+ if report["health_changed"]:
316
+ print(" health changes")
317
+ for key, delta in report["health_delta"].items():
318
+ print(f" {key}: {delta['before']} -> {delta['after']}")
319
+ return 0 if report["matches"] else 1
whykit/status.py ADDED
@@ -0,0 +1,167 @@
1
+ """Human and machine-readable vault status."""
2
+ from __future__ import annotations
3
+
4
+ from .io import consistent_read
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import json
9
+ from dataclasses import asdict
10
+ from pathlib import Path
11
+
12
+ from .contract import emit_error, vault_not_found
13
+ from .config import ConfigError, load_config
14
+ from .lint import DECISION_ID_RE, evidence_register, find_vault_root, is_vault_root, lint, path_cache
15
+ from .vault_index import VaultIndex
16
+ from .console import emit_machine
17
+
18
+
19
+ def is_decision_record(front: dict) -> bool:
20
+ """True for a decision record with a real D-NNN.
21
+
22
+ The decision log and the decision template share `type: decision` but are
23
+ not decisions; counting them made `status` disagree with `trace` and the log.
24
+ """
25
+ return (
26
+ front.get("type") == "decision"
27
+ and front.get("status") != "template"
28
+ and DECISION_ID_RE.fullmatch(str(front.get("decision_id") or "").strip()) is not None
29
+ )
30
+
31
+
32
+ def build_review_queue(vault: VaultIndex, *, today: dt.date, due_days: int = 30) -> list[dict]:
33
+ """Approved records whose `review_by` falls on or before today + due_days.
34
+
35
+ Independent of lint findings, so `review list` does not pay for a full lint.
36
+ """
37
+ review_queue: list[dict] = []
38
+ horizon = today + dt.timedelta(days=max(0, due_days))
39
+ for note in vault.notes:
40
+ if note.front.get("status") != "approved":
41
+ continue
42
+ raw = str(note.front.get("review_by", "")).strip()
43
+ try:
44
+ date = dt.date.fromisoformat(raw)
45
+ except ValueError:
46
+ continue
47
+ if date <= horizon:
48
+ review_queue.append({
49
+ "path": vault.relative(note.path),
50
+ "title": str(note.front.get("title") or note.path.stem),
51
+ "owner": str(note.front.get("owner") or ""),
52
+ "review_by": date.isoformat(),
53
+ "days": (date - today).days,
54
+ "state": "overdue" if date < today else "due",
55
+ })
56
+ review_queue.sort(key=lambda item: (item["review_by"], item["path"]))
57
+ return review_queue
58
+
59
+
60
+ @consistent_read
61
+ @path_cache()
62
+ def build_status(
63
+ root: Path,
64
+ *,
65
+ today: dt.date | None = None,
66
+ due_days: int = 30,
67
+ vault: VaultIndex | None = None,
68
+ ) -> dict:
69
+ today = today or dt.date.today()
70
+ root = root.resolve()
71
+ vault = vault or VaultIndex.load(root)
72
+ notes = vault.notes
73
+ _, findings = lint(root, today=today, vault=vault)
74
+ active_evidence, retired_evidence, _ = evidence_register(root)
75
+
76
+ canonical = [n for n in notes if n.front.get("source_of_truth") is True and n.front.get("status") == "approved"]
77
+ decisions = [n for n in notes if is_decision_record(n.front)]
78
+ review_queue = build_review_queue(vault, today=today, due_days=due_days)
79
+ errors = [f for f in findings if f.level == "error"]
80
+ warnings = [f for f in findings if f.level == "warning"]
81
+ decision_states: dict[str, int] = {}
82
+ document_states: dict[str, int] = {}
83
+ sensitivity_counts: dict[str, int] = {}
84
+ ownership_gaps = 0
85
+ for note in notes:
86
+ status_value = str(note.front.get("status") or "")
87
+ if status_value:
88
+ document_states[status_value] = document_states.get(status_value, 0) + 1
89
+ sensitivity = str(note.front.get("sensitivity") or "")
90
+ if sensitivity:
91
+ sensitivity_counts[sensitivity] = sensitivity_counts.get(sensitivity, 0) + 1
92
+ if is_decision_record(note.front):
93
+ decision_states[status_value or "unknown"] = decision_states.get(status_value or "unknown", 0) + 1
94
+ if status_value == "approved" and str(note.front.get("owner") or "").strip() in {"", "TODO"}:
95
+ ownership_gaps += 1
96
+ by_code: dict[str, int] = {}
97
+ for finding in findings:
98
+ by_code[finding.code] = by_code.get(finding.code, 0) + 1
99
+
100
+ return {
101
+ "contract_version": 1,
102
+ "root": str(root),
103
+ "as_of": today.isoformat(),
104
+ "documents": len(notes),
105
+ "canonical": len(canonical),
106
+ "decisions": len(decisions),
107
+ "evidence_active": len(active_evidence),
108
+ "evidence_retired": len(retired_evidence),
109
+ "errors": len(errors),
110
+ "warnings": len(warnings),
111
+ "finding_codes": dict(sorted(by_code.items())),
112
+ "review_queue": review_queue,
113
+ "review_overdue": sum(1 for item in review_queue if item["state"] == "overdue"),
114
+ "decision_states": dict(sorted(decision_states.items())),
115
+ "document_states": dict(sorted(document_states.items())),
116
+ "sensitivity": dict(sorted(sensitivity_counts.items())),
117
+ "approved_without_owner": ownership_gaps,
118
+ "findings": [asdict(f) for f in findings],
119
+ }
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ parser = argparse.ArgumentParser(prog="whykit status", description="Summarize vault health and upcoming review work.")
124
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
125
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
126
+ parser.add_argument("--today", help="evaluate as of this ISO date")
127
+ parser.add_argument("--due-days", type=int, help="include reviews due within N days (default: policy setting)")
128
+ parser.add_argument("--strict", action="store_true", help="exit 1 when warnings exist as well as errors")
129
+ args = parser.parse_args(argv)
130
+
131
+ if args.today:
132
+ try:
133
+ today = dt.date.fromisoformat(args.today)
134
+ except ValueError:
135
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
136
+ else:
137
+ today = dt.date.today()
138
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
139
+ if root is None or not is_vault_root(root):
140
+ return vault_not_found(args.root, json_mode=args.json)
141
+ try:
142
+ config, _ = load_config(root)
143
+ except ConfigError as exc:
144
+ return emit_error("invalid_config", str(exc), json_mode=args.json)
145
+ due_days = args.due_days if args.due_days is not None else int(config["defaults"]["status_due_days"])
146
+ if due_days < 0:
147
+ return emit_error("invalid_argument", "--due-days must be >= 0", json_mode=args.json)
148
+ report = build_status(root, today=today, due_days=due_days)
149
+ if args.json:
150
+ emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
151
+ else:
152
+ print(f"WhyKit status — {report['as_of']}")
153
+ print(f" documents {report['documents']}")
154
+ print(f" canonical {report['canonical']}")
155
+ print(f" decisions {report['decisions']}")
156
+ print(f" evidence {report['evidence_active']} active / {report['evidence_retired']} retired")
157
+ print(f" lint {report['errors']} error(s), {report['warnings']} warning(s)")
158
+ queue = report["review_queue"]
159
+ print(f" review queue {len(queue)} due within {due_days} day(s); {report['review_overdue']} overdue")
160
+ for item in queue[:20]:
161
+ marker = "OVERDUE" if item["state"] == "overdue" else "DUE"
162
+ print(f" {marker:<7} {item['review_by']} {item['path']} ({item['owner'] or 'no owner'})")
163
+ if len(queue) > 20:
164
+ print(f" … {len(queue) - 20} more")
165
+ if report["errors"] or (args.strict and report["warnings"]):
166
+ return 1
167
+ return 0