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/cli.py ADDED
@@ -0,0 +1,1719 @@
1
+ #!/usr/bin/env python3
2
+ """The `whykit` command.
3
+
4
+ Zero runtime dependencies beyond the standard library. Every subcommand runs
5
+ in-process rather than shelling out to a sibling script, so the tool behaves the
6
+ same whether it was installed with pip, run with `pipx run`, or called from a
7
+ checkout via `scripts/whykit.py`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import errno
14
+ import os
15
+ import re
16
+ import shlex
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import traceback
21
+ from pathlib import Path
22
+ from typing import NoReturn
23
+
24
+ from . import __version__
25
+ from .contract import (
26
+ CONTRACT_VERSION,
27
+ ISSUES_URL,
28
+ argv_wants_json,
29
+ describe_os_error,
30
+ emit_error,
31
+ error_payload,
32
+ vault_not_found,
33
+ )
34
+ from .lint import find_vault_root, is_markdown_name, is_vault_root, iter_markdown, lint as run_lint, rel
35
+ from .completion import SHELLS, render_completion
36
+ from .messages import print_no_vault
37
+ from .vault_index import VaultIndex
38
+ from .console import emit_machine, harden_stdio
39
+
40
+ TEMPLATE_DIR = Path(__file__).resolve().parent / "template"
41
+
42
+ VAULT_GITIGNORE = """# Raw exports, staged before normalization. Never commit these directly.
43
+ .import-staging/
44
+
45
+ # This repository holds knowledge, never credentials.
46
+ .env
47
+ *.key
48
+ *.pem
49
+
50
+ # OS and editor noise
51
+ .DS_Store
52
+ Thumbs.db
53
+ .obsidian/workspace.json
54
+ .obsidian/workspace-mobile.json
55
+
56
+ # WhyKit's own write lock, crash-recovery journal and parse cache. Machine state, not knowledge.
57
+ .whykit/mutation.lock
58
+ .whykit/transactions/
59
+ .whykit/cache/
60
+ """
61
+ VAULT_GITIGNORE_MARKER = "# WhyKit protective defaults (managed by whykit init)"
62
+ # Points at the repository when running from a checkout; harmless in a wheel.
63
+ SOURCE_ROOT = Path(__file__).resolve().parents[2]
64
+ _PROJECT_NAME_RE = re.compile(r"(?m)^name\s*=\s*[\"']whykit[\"']")
65
+ # macOS (and similar) expose these as root-level directory aliases. Following
66
+ # them does not redirect a user-named vault into a different tree; it is how
67
+ # the OS spells the same path. Refuse every other symlink on the init path.
68
+ _ROOT_DIR_ALIASES = frozenset({"var", "tmp", "etc", "private"})
69
+
70
+
71
+ def _resolve_vault(explicit: str | None) -> Path | None:
72
+ if explicit:
73
+ path = Path(explicit).expanduser().resolve()
74
+ return path if is_vault_root(path) else None
75
+ return find_vault_root()
76
+
77
+
78
+ def _absolute_without_following_symlinks(raw: str) -> Path:
79
+ """Expand ``~`` and make the path absolute without resolving symlinks."""
80
+ return Path(os.path.abspath(os.path.expanduser(raw)))
81
+
82
+
83
+ def _first_symlink_on_path(path: Path) -> Path | None:
84
+ """Return the first refused symlink from the filesystem root through ``path``.
85
+
86
+ Root-level OS aliases such as macOS ``/var`` → ``/private/var`` are allowed
87
+ when they appear as an ancestor. The named target itself is never followed
88
+ when it is a symlink, and any non-alias ancestor symlink is refused so
89
+ init cannot write through a redirect into another directory.
90
+ """
91
+ path = Path(path)
92
+ if not path.is_absolute():
93
+ raise ValueError(f"path must be absolute: {path}")
94
+ anchor = Path(path.anchor)
95
+ current = anchor
96
+ for part in path.parts[1:]:
97
+ current = current / part
98
+ try:
99
+ if not current.is_symlink():
100
+ continue
101
+ except OSError:
102
+ return None
103
+ is_root_alias_ancestor = (
104
+ current.parent == anchor
105
+ and current.name in _ROOT_DIR_ALIASES
106
+ and current != path
107
+ )
108
+ if is_root_alias_ancestor:
109
+ continue
110
+ return current
111
+ return None
112
+
113
+
114
+ def _is_whykit_package_source_root(path: Path) -> bool:
115
+ """True when ``path`` is a WhyKit package checkout (``src/whykit`` + project pyproject)."""
116
+ pyproject = path / "pyproject.toml"
117
+ package_dir = path / "src" / "whykit"
118
+ if pyproject.is_symlink() or package_dir.is_symlink():
119
+ return False
120
+ if not pyproject.is_file() or not package_dir.is_dir():
121
+ return False
122
+ if not (package_dir / "cli.py").is_file() and not (package_dir / "__init__.py").is_file():
123
+ return False
124
+ try:
125
+ text = pyproject.read_text(encoding="utf-8")
126
+ except OSError:
127
+ return False
128
+ return _PROJECT_NAME_RE.search(text) is not None
129
+
130
+
131
+ def _stamp_vault_dates(target: Path, today: dt.date | None = None) -> None:
132
+ """Make a newly generated vault honest about when its starter files were created."""
133
+ value = (today or dt.date.today()).isoformat()
134
+ for path in iter_markdown(target):
135
+ try:
136
+ text = path.read_text(encoding="utf-8")
137
+ except OSError:
138
+ continue
139
+ if not text.startswith("---\n"):
140
+ continue
141
+ end = text.find("\n---\n", 4)
142
+ if end < 0:
143
+ continue
144
+ front = text[:end]
145
+ front = re.sub(r"(?m)^created:\s*.*$", f"created: {value}", front, count=1)
146
+ front = re.sub(r"(?m)^last_updated:\s*.*$", f"last_updated: {value}", front, count=1)
147
+ path.write_text(front + text[end:], encoding="utf-8")
148
+
149
+ def cmd_init(args: argparse.Namespace) -> int:
150
+ import tempfile
151
+
152
+ from .io import atomic_write_bytes, atomic_write_text, safe_vault_dir, safe_vault_target
153
+
154
+ json_mode = bool(getattr(args, "json", False))
155
+ if getattr(args, "minimal", False) and getattr(args, "full", False):
156
+ return emit_error("usage", "--minimal and --full cannot be used together", json_mode=json_mode)
157
+
158
+ minimal_layout = not getattr(args, "full", False)
159
+ # Do not Path.resolve(): that follows symlinks and can write outside the
160
+ # path the user named. Expand ~ and absolutize only.
161
+ target = _absolute_without_following_symlinks(args.target)
162
+ linked = _first_symlink_on_path(target)
163
+ if linked is not None:
164
+ return emit_error("unsafe_path", f"refusing to initialize through a symlink: {linked}", json_mode=json_mode)
165
+ if _is_whykit_package_source_root(target):
166
+ return emit_error(
167
+ "invalid_target",
168
+ f"refusing to initialize inside WhyKit's own package source tree: {target}",
169
+ json_mode=json_mode,
170
+ )
171
+ if target.exists() and not target.is_dir():
172
+ return emit_error("invalid_target", f"refusing to initialize a non-directory target: {target}", json_mode=json_mode)
173
+ if target.exists() and any(target.iterdir()) and not args.force:
174
+ return emit_error(
175
+ "target_exists",
176
+ f"refusing to write into non-empty directory: {target}",
177
+ hint="use --force only when you have reviewed the destination",
178
+ json_mode=json_mode,
179
+ )
180
+ if not TEMPLATE_DIR.is_dir():
181
+ return emit_error("missing_dependency", "template is missing from this installation", json_mode=json_mode)
182
+
183
+ # Prepare the selected layout in an isolated staging directory first.
184
+ # The vendor-neutral layout trims the workstream-rich starter here, never
185
+ # against an existing adopter directory.
186
+ with tempfile.TemporaryDirectory(prefix="whykit-init-") as tmp:
187
+ prepared = Path(tmp) / "vault"
188
+ shutil.copytree(TEMPLATE_DIR, prepared)
189
+ if minimal_layout:
190
+ from .init_layout import apply_minimal_layout
191
+ apply_minimal_layout(prepared)
192
+ _stamp_vault_dates(prepared)
193
+
194
+ target.mkdir(parents=True, exist_ok=True)
195
+ try:
196
+ gitignore = safe_vault_target(target, ".gitignore")
197
+ if gitignore.exists() and not gitignore.is_file():
198
+ raise RuntimeError(f"expected a regular .gitignore file: {gitignore}")
199
+ existing_ignore = gitignore.read_text(encoding="utf-8") if gitignore.exists() else None
200
+ except (OSError, RuntimeError, ValueError) as exc:
201
+ return emit_error("unsafe_path", f"refusing unsafe init destination: {exc}", json_mode=json_mode)
202
+ preserved = 0
203
+ for source in sorted(prepared.rglob("*")):
204
+ relative = source.relative_to(prepared)
205
+ try:
206
+ if source.is_dir():
207
+ safe_vault_dir(target, relative)
208
+ continue
209
+ destination = safe_vault_target(target, relative)
210
+ except (OSError, RuntimeError, ValueError) as exc:
211
+ return emit_error("unsafe_path", f"refusing unsafe init destination: {exc}", json_mode=json_mode)
212
+ if destination.is_symlink():
213
+ return emit_error("unsafe_path", f"refusing to overwrite symlink: {destination}", json_mode=json_mode)
214
+ if destination.exists():
215
+ if not destination.is_file():
216
+ return emit_error(
217
+ "invalid_target",
218
+ f"refusing to replace a non-file destination: {destination}",
219
+ json_mode=json_mode,
220
+ )
221
+ if not args.force:
222
+ return emit_error("target_exists", f"refusing to overwrite existing file: {destination}", json_mode=json_mode)
223
+ preserved += 1
224
+ continue
225
+ destination.parent.mkdir(parents=True, exist_ok=True)
226
+ atomic_write_bytes(destination, source.read_bytes())
227
+
228
+ # Deliberately no LICENSE: the vault holds the adopter's own knowledge, and
229
+ # copying WhyKit's Apache-2.0 text into it would appear to license their
230
+ # company's strategy and decisions under it.
231
+ if existing_ignore is None:
232
+ atomic_write_text(gitignore, VAULT_GITIGNORE)
233
+ elif not existing_ignore.endswith(VAULT_GITIGNORE):
234
+ separator = "" if not existing_ignore or existing_ignore.endswith("\n") else "\n"
235
+ atomic_write_text(
236
+ gitignore,
237
+ existing_ignore + separator + "\n" + VAULT_GITIGNORE_MARKER + "\n" + VAULT_GITIGNORE,
238
+ )
239
+
240
+ if json_mode:
241
+ import json
242
+ emit_machine(json.dumps({
243
+ "contract_version": CONTRACT_VERSION,
244
+ "root": str(target),
245
+ "layout": "minimal" if minimal_layout else "full",
246
+ "preserved": preserved,
247
+ }, ensure_ascii=False, indent=2))
248
+ return 0
249
+ print(f"WhyKit vault created: {target}")
250
+ if preserved:
251
+ print(f"Preserved {preserved} existing template file(s); only missing files were added.")
252
+ if minimal_layout:
253
+ print("Layout: vendor-neutral (default)")
254
+ else:
255
+ print("Layout: full starter with optional workstreams (--full)")
256
+ print()
257
+ print("Next, in order:")
258
+ print(" 1. Answer every TODO in AGENTS.md - that file is the contract agents work under.")
259
+ print(" 2. Replace the starter content in 00-context/ before treating anything as canonical.")
260
+ # Match the README cold-install path (`uv sync` + `uv run whykit` from the
261
+ # checkout), where bare `whykit` is not on PATH. An installed tool has no
262
+ # checkout to run from, so it gets the bare command instead.
263
+ if _is_whykit_package_source_root(SOURCE_ROOT):
264
+ print(f" 3. From the WhyKit checkout: uv run whykit lint --root {target}")
265
+ else:
266
+ print(f" 3. Check it: whykit lint --root {target}")
267
+ return 0
268
+
269
+
270
+ def cmd_lint(args: argparse.Namespace) -> int:
271
+ argv: list[str] = list(args.paths)
272
+ if args.root:
273
+ argv += ["--root", args.root]
274
+ for flag in ("strict", "quiet", "json", "no_orphans", "no_secrets"):
275
+ if getattr(args, flag, False):
276
+ argv.append("--" + flag.replace("_", "-"))
277
+ if getattr(args, "today", None):
278
+ argv += ["--today", args.today]
279
+ if getattr(args, "format", None):
280
+ argv += ["--format", args.format]
281
+ from .lint import main as lint_main
282
+ return lint_main(argv)
283
+
284
+
285
+
286
+ def cmd_new(args: argparse.Namespace) -> int:
287
+ from .scaffold import main as new_main
288
+ argv: list[str] = []
289
+ if args.root:
290
+ argv += ["--root", args.root]
291
+ argv.append(args.new_kind)
292
+ if args.new_kind == "decision":
293
+ if args.title is not None:
294
+ argv.append(args.title)
295
+ if args.from_path:
296
+ argv += ["--from", args.from_path]
297
+ if args.write:
298
+ argv.append("--write")
299
+ argv += ["--status", args.status]
300
+ if args.owner:
301
+ argv += ["--owner", args.owner]
302
+ if args.sensitivity:
303
+ argv += ["--sensitivity", args.sensitivity]
304
+ for source_id in args.source_ids:
305
+ argv += ["--source", source_id]
306
+ if args.review_by:
307
+ argv += ["--review-by", args.review_by]
308
+ if args.supersedes:
309
+ argv += ["--supersedes", args.supersedes]
310
+ if args.json:
311
+ argv.append("--json")
312
+ elif args.new_kind == "evidence":
313
+ argv += ["--source", args.source_name, "--location", args.location, "--type", args.evidence_type, "--claims", args.claims]
314
+ if args.date:
315
+ argv += ["--date", args.date]
316
+ if args.accessed:
317
+ argv += ["--accessed", args.accessed]
318
+ if args.sensitivity:
319
+ argv += ["--sensitivity", args.sensitivity]
320
+ if args.json:
321
+ argv.append("--json")
322
+ else:
323
+ argv += [args.title, "--workstream", args.workstream, "--type", args.doc_type]
324
+ if args.owner:
325
+ argv += ["--owner", args.owner]
326
+ if args.sensitivity:
327
+ argv += ["--sensitivity", args.sensitivity]
328
+ if args.link_from:
329
+ argv += ["--link-from", args.link_from]
330
+ if args.json:
331
+ argv.append("--json")
332
+ return new_main(argv)
333
+
334
+
335
+ def cmd_status(args: argparse.Namespace) -> int:
336
+ from .status import main as status_main
337
+ argv: list[str] = []
338
+ if args.root:
339
+ argv += ["--root", args.root]
340
+ if args.json:
341
+ argv.append("--json")
342
+ if args.today:
343
+ argv += ["--today", args.today]
344
+ if args.due_days is not None:
345
+ argv += ["--due-days", str(args.due_days)]
346
+ if args.strict:
347
+ argv.append("--strict")
348
+ return status_main(argv)
349
+
350
+
351
+ def _json_format(args: argparse.Namespace, command: str) -> str | None:
352
+ """Fold the shared ``--json`` flag into a command's ``--format`` choice."""
353
+ if not getattr(args, "json", False):
354
+ return args.format or "json"
355
+ if args.format not in (None, "json"):
356
+ emit_error(
357
+ "usage",
358
+ f"--json conflicts with --format {args.format}\nhint: pass one of them to `whykit {command}`",
359
+ json_mode=True,
360
+ )
361
+ return None
362
+ return "json"
363
+
364
+
365
+ def cmd_graph(args: argparse.Namespace) -> int:
366
+ from .graph import main as graph_main
367
+ fmt = _json_format(args, "graph")
368
+ if fmt is None:
369
+ return 2
370
+ argv: list[str] = []
371
+ if args.root:
372
+ argv += ["--root", args.root]
373
+ argv += ["--format", fmt]
374
+ if args.canonical_only:
375
+ argv.append("--canonical-only")
376
+ if getattr(args, "output", None):
377
+ argv += ["--output", args.output]
378
+ return graph_main(argv)
379
+
380
+
381
+ def cmd_backlinks(args: argparse.Namespace) -> int:
382
+ from .backlinks import main as backlinks_main
383
+ argv = [args.target]
384
+ if args.root:
385
+ argv += ["--root", args.root]
386
+ if args.json:
387
+ argv.append("--json")
388
+ return backlinks_main(argv)
389
+
390
+
391
+ def cmd_impact(args: argparse.Namespace) -> int:
392
+ from .impact import main as impact_main
393
+ argv = [args.target]
394
+ if args.root:
395
+ argv += ["--root", args.root]
396
+ if args.json:
397
+ argv.append("--json")
398
+ return impact_main(argv)
399
+
400
+
401
+ def cmd_trace(args: argparse.Namespace) -> int:
402
+ from .trace import main as trace_main
403
+ argv: list[str] = []
404
+ if args.root:
405
+ argv += ["--root", args.root]
406
+ if args.decision:
407
+ argv += ["--decision", args.decision]
408
+ if args.today:
409
+ argv += ["--today", args.today]
410
+ if args.max_age_days is not None:
411
+ argv += ["--max-age-days", str(args.max_age_days)]
412
+ for flag in ("gaps_only", "strict", "json"):
413
+ if getattr(args, flag):
414
+ argv.append("--" + flag.replace("_", "-"))
415
+ return trace_main(argv)
416
+
417
+
418
+ def cmd_query(args: argparse.Namespace) -> int:
419
+ from .query import main as query_main
420
+ argv: list[str] = []
421
+ if args.text:
422
+ argv.append(args.text)
423
+ if args.root:
424
+ argv += ["--root", args.root]
425
+ for flag, value in (("--type", args.doc_type), ("--status", args.status), ("--owner", args.owner), ("--sensitivity", args.sensitivity), ("--source", args.source_id), ("--tag", args.tag)):
426
+ if value:
427
+ argv += [flag, value]
428
+ if args.canonical_only:
429
+ argv.append("--canonical-only")
430
+ argv += ["--limit", str(args.limit)]
431
+ if args.json:
432
+ argv.append("--json")
433
+ return query_main(argv)
434
+
435
+
436
+ def cmd_context(args: argparse.Namespace) -> int:
437
+ from .context import main as context_main
438
+ argv = [args.target, "--max-chars", str(args.max_chars)]
439
+ if args.root:
440
+ argv += ["--root", args.root]
441
+ if args.no_body:
442
+ argv.append("--no-body")
443
+ if args.json:
444
+ argv.append("--json")
445
+ return context_main(argv)
446
+
447
+
448
+ def cmd_pack(args: argparse.Namespace) -> int:
449
+ from .pack import main as pack_main
450
+ fmt = _json_format(args, "pack")
451
+ if fmt is None:
452
+ return 2
453
+ argv: list[str] = list(args.targets)
454
+ if args.root:
455
+ argv += ["--root", args.root]
456
+ if args.query:
457
+ argv += ["--query", args.query]
458
+ argv += ["--max-docs", str(args.max_docs), "--max-chars", str(args.max_chars), "--format", fmt]
459
+ if args.canonical_only:
460
+ argv.append("--canonical-only")
461
+ if getattr(args, "agent", None):
462
+ argv += ["--for", args.agent]
463
+ return pack_main(argv)
464
+
465
+
466
+ def cmd_review(args: argparse.Namespace) -> int:
467
+ from .review import main as review_main
468
+ argv: list[str] = []
469
+ if args.root:
470
+ argv += ["--root", args.root]
471
+ argv.append(args.review_command)
472
+ if args.review_command == "list":
473
+ if args.today:
474
+ argv += ["--today", args.today]
475
+ if args.due_days is not None:
476
+ argv += ["--due-days", str(args.due_days)]
477
+ if args.owner:
478
+ argv += ["--owner", args.owner]
479
+ if args.overdue_only:
480
+ argv.append("--overdue-only")
481
+ if args.json:
482
+ argv.append("--json")
483
+ else:
484
+ argv.append(args.target)
485
+ argv += ["--reviewer", args.reviewer]
486
+ if args.review_command == "record":
487
+ argv += ["--outcome", args.outcome]
488
+ if getattr(args, "write", False):
489
+ argv.append("--write")
490
+ if getattr(args, "expected_sha256", None):
491
+ argv += ["--expect-hash", args.expected_sha256]
492
+ if args.next_review:
493
+ argv += ["--next-review", args.next_review]
494
+ if getattr(args, "note_text", None):
495
+ argv += ["--note", args.note_text]
496
+ if args.today:
497
+ argv += ["--today", args.today]
498
+ if args.json:
499
+ argv.append("--json")
500
+ return review_main(argv)
501
+
502
+
503
+ def cmd_snapshot(args: argparse.Namespace) -> int:
504
+ from .snapshot import main_snapshot
505
+ argv: list[str] = []
506
+ if args.root:
507
+ argv += ["--root", args.root]
508
+ if args.today:
509
+ argv += ["--today", args.today]
510
+ if args.output:
511
+ argv += ["--output", args.output]
512
+ if args.compact:
513
+ argv.append("--compact")
514
+ if args.snapshot_format:
515
+ argv += ["--format", args.snapshot_format]
516
+ return main_snapshot(argv)
517
+
518
+
519
+ def cmd_verify_snapshot(args: argparse.Namespace) -> int:
520
+ from .snapshot import main_verify
521
+ argv = [args.snapshot]
522
+ if args.root:
523
+ argv += ["--root", args.root]
524
+ if args.today:
525
+ argv += ["--today", args.today]
526
+ if args.json:
527
+ argv.append("--json")
528
+ return main_verify(argv)
529
+
530
+
531
+ def cmd_check(args: argparse.Namespace) -> int:
532
+ from .check import main as check_main
533
+ argv = ["--profile", args.profile, "--head", args.head]
534
+ if args.root:
535
+ argv += ["--root", args.root]
536
+ if args.base:
537
+ argv += ["--base", args.base]
538
+ if args.today:
539
+ argv += ["--today", args.today]
540
+ if args.json:
541
+ argv.append("--json")
542
+ if args.format:
543
+ argv += ["--format", args.format]
544
+ return check_main(argv)
545
+
546
+
547
+ def cmd_policy(args: argparse.Namespace) -> int:
548
+ from .config import ConfigError, config_summary
549
+ vault = _resolve_vault(args.root)
550
+ if vault is None:
551
+ return vault_not_found(args.root, json_mode=args.json)
552
+ try:
553
+ payload = config_summary(vault)
554
+ except ConfigError as exc:
555
+ return emit_error("invalid_config", str(exc), json_mode=args.json)
556
+ if args.json:
557
+ import json
558
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
559
+ else:
560
+ print(f"WhyKit policy — {payload['source']}")
561
+ defaults = payload["config"]["defaults"]
562
+ print(f" default owner {defaults['owner']}")
563
+ print(f" default sensitivity {defaults['sensitivity']}")
564
+ print(f" decision review days {defaults['decision_review_days']}")
565
+ print(f" status due days {defaults['status_due_days']}")
566
+ for name, profile in payload["config"]["profiles"].items():
567
+ print(f" profile {name:<10} strict={profile['strict']} orphans={profile['orphans']} secrets={profile['secrets']} history={profile['history']}")
568
+ rules = payload["config"].get("rules") or {}
569
+ if rules:
570
+ print(f" custom rules {len(rules.get('custom', []))} (see `whykit rules`)")
571
+ print(f" rule overrides {', '.join(sorted(rules.get('overrides', {}))) or 'none'}")
572
+ return 0
573
+
574
+
575
+ def cmd_evidence(args: argparse.Namespace) -> int:
576
+ from .evidence import main as evidence_main
577
+ argv: list[str] = []
578
+ if args.root:
579
+ argv += ["--root", args.root]
580
+ argv.append(args.evidence_command)
581
+ if args.evidence_command == "list":
582
+ argv += ["--state", args.state]
583
+ if args.json:
584
+ argv.append("--json")
585
+ else:
586
+ argv.append(args.id)
587
+ argv += ["--why", args.reason]
588
+ if args.replaced_by:
589
+ argv += ["--replaced-by", args.replaced_by]
590
+ if args.today:
591
+ argv += ["--today", args.today]
592
+ if args.json:
593
+ argv.append("--json")
594
+ return evidence_main(argv)
595
+
596
+
597
+ def cmd_adopt(args: argparse.Namespace) -> int:
598
+ from .adopt import main as adopt_main
599
+ argv = [args.source]
600
+ if args.into:
601
+ argv += ["--into", args.into]
602
+ if args.owner:
603
+ argv += ["--owner", args.owner]
604
+ if args.profile:
605
+ argv += ["--profile", args.profile]
606
+ if args.compare is not None:
607
+ argv += ["--compare", args.compare]
608
+ if args.write:
609
+ argv.append("--write")
610
+ if args.json:
611
+ argv.append("--json")
612
+ return adopt_main(argv)
613
+
614
+
615
+ def cmd_history(args: argparse.Namespace) -> int:
616
+ from .immutability import main as immutability_main
617
+ argv: list[str] = []
618
+ if args.base is not None:
619
+ argv += ["--base", args.base]
620
+ if args.head is not None:
621
+ argv += ["--head", args.head]
622
+ if args.staged:
623
+ argv.append("--staged")
624
+ if args.root:
625
+ argv += ["--root", args.root]
626
+ if args.json:
627
+ argv.append("--json")
628
+ code = immutability_main(argv)
629
+ if code in (0, 1) and not args.staged and args.head in (None, "HEAD"):
630
+ _warn_uncommitted_markdown(_resolve_vault(args.root))
631
+ return code
632
+
633
+
634
+ def cmd_diff(args: argparse.Namespace) -> int:
635
+ from .diff import main as diff_main
636
+ argv = ["--base", args.base, "--head", args.head]
637
+ if args.root:
638
+ argv += ["--root", args.root]
639
+ if args.today:
640
+ argv += ["--today", args.today]
641
+ if args.format:
642
+ argv += ["--format", args.format]
643
+ if args.json:
644
+ argv.append("--json")
645
+ return diff_main(argv)
646
+
647
+
648
+ def _uncommitted_markdown(vault: Path) -> list[str]:
649
+ """Markdown under *vault* that differs from HEAD (staged, unstaged or new)."""
650
+ try:
651
+ result = subprocess.run(
652
+ ["git", "-C", str(vault), "status", "--porcelain", "--untracked-files=all", "--", "."],
653
+ capture_output=True, text=True, check=False,
654
+ )
655
+ except OSError:
656
+ return []
657
+ if result.returncode != 0:
658
+ return []
659
+ paths = []
660
+ for line in result.stdout.splitlines():
661
+ path = line[3:].split(" -> ")[-1].strip().strip('"')
662
+ if is_markdown_name(path):
663
+ paths.append(path)
664
+ return paths
665
+
666
+
667
+ def _warn_uncommitted_markdown(vault: Path | None) -> None:
668
+ """Say so when `history` compared commits but the edits are not committed yet.
669
+
670
+ `history` checks revisions, so an uncommitted rewrite of accepted reasoning
671
+ passes locally and only fails once it reaches CI. That reads as a false OK.
672
+ """
673
+ if vault is None:
674
+ return
675
+ pending = _uncommitted_markdown(vault)
676
+ if not pending:
677
+ return
678
+ sys.stdout.flush()
679
+ print(
680
+ f"note: {len(pending)} uncommitted Markdown change(s) were not checked; "
681
+ "history compares commits only (e.g. "
682
+ f"{pending[0]})",
683
+ file=sys.stderr,
684
+ )
685
+ print("hint: commit the change, then rerun `whykit history --base <ref>`", file=sys.stderr)
686
+
687
+
688
+ PRE_COMMIT_HOOK = """#!/bin/sh
689
+ # Installed by `whykit install-hooks`.
690
+ set -e
691
+ root=$(git rev-parse --show-toplevel)
692
+ cd "$root"
693
+ # __WHYKIT_VAULT_CD__
694
+
695
+ if ! command -v whykit >/dev/null 2>&1; then
696
+ echo "whykit is not on PATH; skipping the vault check." >&2
697
+ exit 0
698
+ fi
699
+
700
+ if ! whykit check --profile local; then
701
+ echo ""
702
+ echo "WhyKit local policy failed. Fix the errors before committing."
703
+ echo "To commit anyway (and you should have a reason): git commit --no-verify"
704
+ exit 1
705
+ fi
706
+ """
707
+
708
+
709
+ def _git_hooks_location(vault: Path) -> tuple[Path, str] | None:
710
+ """Where Git runs hooks for ``vault``, and the vault's path inside the work tree.
711
+
712
+ The vault may be a subdirectory of a larger repository or sit in a linked
713
+ worktree (where `.git` is a file), and `core.hooksPath` may move the hooks,
714
+ so ask Git instead of assuming `<vault>/.git/hooks`.
715
+ """
716
+ if not shutil.which("git"):
717
+ return None
718
+ result = subprocess.run(
719
+ ["git", "-C", str(vault), "rev-parse", "--git-path", "hooks", "--show-prefix"],
720
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
721
+ )
722
+ lines = result.stdout.splitlines()
723
+ if result.returncode != 0 or not lines:
724
+ return None
725
+ hooks = Path(lines[0])
726
+ if not hooks.is_absolute():
727
+ hooks = vault / hooks
728
+ prefix = lines[1].strip() if len(lines) > 1 else ""
729
+ return hooks, prefix
730
+
731
+
732
+ def _render_pre_commit_hook(prefix: str) -> str:
733
+ """The hook runs from the work-tree root; step into the vault when it is a subdirectory."""
734
+ line = f"cd -- {shlex.quote(prefix.rstrip('/'))}" if prefix else ""
735
+ return PRE_COMMIT_HOOK.replace("# __WHYKIT_VAULT_CD__\n", f"{line}\n" if line else "")
736
+
737
+
738
+ def cmd_install_hooks(args: argparse.Namespace) -> int:
739
+ vault = _resolve_vault(args.root)
740
+ if vault is None:
741
+ print_no_vault(args.root)
742
+ return 2
743
+ located = _git_hooks_location(vault)
744
+ if located is None:
745
+ print(f"not a git repository: {vault}", file=sys.stderr)
746
+ print("run `git init` first - the ledger's guarantees come from version control", file=sys.stderr)
747
+ return 2
748
+ hooks, prefix = located
749
+ target = hooks / "pre-commit"
750
+ if target.exists() and not args.force:
751
+ print(f"a pre-commit hook already exists: {target}", file=sys.stderr)
752
+ print("inspect it, then re-run with --force to replace it", file=sys.stderr)
753
+ return 2
754
+ hooks.mkdir(parents=True, exist_ok=True)
755
+ target.write_text(_render_pre_commit_hook(prefix), encoding="utf-8")
756
+ target.chmod(0o755)
757
+ print(f"pre-commit hook installed: {target}")
758
+ return 0
759
+
760
+
761
+ def _check(label: str, ok: bool, detail: str) -> bool:
762
+ print(f"{'OK' if ok else 'FAIL':<4} {label:<20} {detail}")
763
+ return ok
764
+
765
+
766
+ def _note(label: str, ok: bool, detail: str) -> None:
767
+ """Report something optional. Printing FAIL for a missing nicety trains people
768
+ to ignore the whole report, which is how a real FAIL gets missed."""
769
+ print(f"{'OK' if ok else '--':<4} {label:<20} {detail}")
770
+
771
+
772
+
773
+ def cmd_rules(args: argparse.Namespace) -> int:
774
+ from .rules import main as rules_main
775
+ argv: list[str] = []
776
+ if args.code:
777
+ argv.append(args.code)
778
+ if args.json:
779
+ argv.append("--json")
780
+ if args.markdown:
781
+ argv.append("--markdown")
782
+ if args.root:
783
+ argv += ["--root", args.root]
784
+ return rules_main(argv)
785
+
786
+ def cmd_doctor(args: argparse.Namespace) -> int:
787
+ import json
788
+ from .config import ConfigError, load_config
789
+
790
+ today: dt.date | None = None
791
+ if getattr(args, "today", None):
792
+ try:
793
+ today = dt.date.fromisoformat(args.today)
794
+ except ValueError:
795
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}\nhint: use YYYY-MM-DD", json_mode=args.json)
796
+ vault = _resolve_vault(args.root)
797
+ checks: list[dict[str, object]] = []
798
+
799
+ def record(label: str, ok: bool, detail: str, *, required: bool = True) -> None:
800
+ checks.append({"name": label, "passed": bool(ok), "detail": detail, "required": required})
801
+
802
+ record("python", sys.version_info >= (3, 11), sys.version.split()[0])
803
+ record("whykit", True, __version__)
804
+ if vault is None:
805
+ record("vault_root", False, "no vault found - run `whykit init <dir>` or pass --root")
806
+ payload: dict[str, object] = {"contract_version": CONTRACT_VERSION, "root": None, "passed": False, "checks": checks}
807
+ if args.json:
808
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
809
+ else:
810
+ for item in checks:
811
+ marker = "OK" if item["passed"] else "FAIL"
812
+ print(f"{marker:<4} {str(item['name']):<20} {item['detail']}")
813
+ return 1
814
+
815
+ record("vault_root", True, str(vault))
816
+ record("evidence_register", (vault / "00-context/evidence-register.md").exists(), "00-context/evidence-register.md")
817
+ record("decision_log", (vault / "06-decisions/decision-log.md").exists(), "06-decisions/decision-log.md")
818
+ record("review_log", (vault / "00-context/review-log.md").exists(), "00-context/review-log.md", required=False)
819
+ record("agent_contract", (vault / "AGENTS.md").exists(), "AGENTS.md")
820
+ try:
821
+ _config, config_path = load_config(vault)
822
+ except ConfigError as exc:
823
+ record("policy", False, str(exc))
824
+ else:
825
+ record("policy", True, rel(vault, config_path) if config_path else "built-in defaults")
826
+
827
+ git = shutil.which("git")
828
+ record("git", bool(git), git or "not found")
829
+ tracked = False
830
+ if git:
831
+ inside = subprocess.run(["git", "-C", str(vault), "rev-parse", "--git-dir"], capture_output=True, text=True, encoding="utf-8", errors="replace")
832
+ tracked = inside.returncode == 0
833
+ record("git_repository", tracked, "tracked" if tracked else "not a git repository yet - `git init`", required=False)
834
+ located = _git_hooks_location(vault) if tracked else None
835
+ hook_installed = bool(located and (located[0] / "pre-commit").exists())
836
+ record("pre_commit_hook", hook_installed, "installed" if hook_installed else "not installed - `whykit install-hooks`", required=False)
837
+
838
+ record("node_explorer", bool(shutil.which("node")), shutil.which("node") or "not found (only needed for `serve`)", required=False)
839
+
840
+ # Same findings as `whykit lint` and `whykit status`, so the counts agree.
841
+ files, findings = run_lint(vault, [], today=today)
842
+ errors = [f for f in findings if f.level == "error"]
843
+ warnings = [f for f in findings if f.level == "warning"]
844
+ record("vault_lint", not errors, f"{len(files)} files, {len(errors)} errors, {len(warnings)} warnings")
845
+
846
+ overdue = [f for f in findings if f.code in ("review_by.overdue", "decision.review_missing")]
847
+ record(
848
+ "review_hygiene",
849
+ not overdue,
850
+ "every approved decision has a live review date" if not overdue else f"{len(overdue)} document(s) need a review date or re-check",
851
+ required=False,
852
+ )
853
+ sensitive = _sensitive_docs(vault)
854
+ record("sensitive_docs", True, f"{len(sensitive)} confidential/restricted document(s)", required=False)
855
+
856
+ passed = all(bool(item["passed"]) for item in checks if item["required"])
857
+ payload = {"contract_version": CONTRACT_VERSION, "root": str(vault), "passed": passed, "checks": checks}
858
+ if args.json:
859
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
860
+ else:
861
+ for item in checks:
862
+ if item["passed"]:
863
+ marker = "OK"
864
+ elif item["required"]:
865
+ marker = "FAIL"
866
+ else:
867
+ marker = "--"
868
+ print(f"{marker:<4} {str(item['name']):<20} {item['detail']}")
869
+ return 0 if passed else 1
870
+
871
+
872
+ def _sensitive_docs(vault: Path) -> list[str]:
873
+ """Documents marked confidential or restricted (doctor inventory)."""
874
+ index = VaultIndex.load(vault)
875
+ sensitive: list[str] = []
876
+ for note in index.notes:
877
+ if str(note.front.get("sensitivity", "")).lower() in {"confidential", "restricted"}:
878
+ sensitive.append(index.relative(note.path))
879
+ return sensitive
880
+
881
+
882
+ def _non_public_docs(vault: Path) -> list[str]:
883
+ index = VaultIndex.load(vault)
884
+ from .sensitivity import note_sensitivity_level, classified_evidence, evidence_register_notes
885
+
886
+ result: list[str] = []
887
+ for note in index.notes:
888
+ if note_sensitivity_level(note) != 0:
889
+ result.append(index.relative(note.path))
890
+ active, retired, _, per_row = classified_evidence(index)
891
+ # The private viewer serves the raw table, including incomplete/placeholder rows.
892
+ if per_row:
893
+ result.extend(index.relative(note.path) for note in evidence_register_notes(index)
894
+ if index.relative(note.path) not in result)
895
+ result.extend(f"evidence:{key}" for key, row in (*active.items(), *retired.items()) if row["sensitivity"] != "public")
896
+ return result
897
+
898
+
899
+ def cmd_recover(args: argparse.Namespace) -> int:
900
+ import json
901
+ from .io import vault_mutation_lock
902
+
903
+ root = _resolve_vault(args.root)
904
+ if root is None:
905
+ return vault_not_found(args.root, json_mode=args.json)
906
+ with vault_mutation_lock(root):
907
+ pass # Recovery and terminal-journal collection finish before the lock yields.
908
+ if args.json:
909
+ emit_machine(json.dumps({"contract_version": 1, "recovery_completed": True}))
910
+ else:
911
+ print("Vault recovery completed.")
912
+ return 0
913
+
914
+ def cmd_explorer_index(args: argparse.Namespace) -> int:
915
+ argv: list[str] = []
916
+ if args.root:
917
+ argv += ["--root", args.root]
918
+ if getattr(args, "today", None):
919
+ argv += ["--today", args.today]
920
+ if getattr(args, "private", False):
921
+ argv += ["--private"]
922
+ if getattr(args, "publication_preview", False):
923
+ argv += ["--publication-preview"]
924
+ from .explorer_index import main as explorer_index_main
925
+ return explorer_index_main(argv)
926
+
927
+
928
+ REPOSITORY_URL = "https://github.com/CometWeb-io/whykit"
929
+
930
+
931
+ def _install_origin() -> dict | None:
932
+ """Where this copy was installed from: its PEP 610 ``direct_url.json``.
933
+
934
+ Installers write it for a local directory (``uv tool install ./whykit``,
935
+ ``pip install .``) or a VCS URL, and not for a package index.
936
+ """
937
+ import json
938
+ from importlib import metadata
939
+
940
+ try:
941
+ raw = metadata.distribution("whykit").read_text("direct_url.json")
942
+ except (metadata.PackageNotFoundError, OSError):
943
+ return None
944
+ if not raw:
945
+ return None
946
+ try:
947
+ data = json.loads(raw)
948
+ except ValueError:
949
+ return None
950
+ return data if isinstance(data, dict) else None
951
+
952
+
953
+ def _serve_hint(origin: dict | None, vault: str) -> list[str]:
954
+ """What to do when the running copy has no Explorer next to it."""
955
+ from urllib.parse import urlparse
956
+ from urllib.request import url2pathname
957
+
958
+ target = shlex.quote(str(Path(vault).expanduser().resolve()))
959
+ url = str((origin or {}).get("url") or "")
960
+ if url.startswith("file://") and isinstance((origin or {}).get("dir_info"), dict):
961
+ checkout = Path(url2pathname(urlparse(url).path))
962
+ if (checkout / "apps" / "explorer" / "package.json").is_file():
963
+ return [
964
+ f"This copy was installed from the checkout at {checkout}, which has the Explorer.",
965
+ "Run it from that checkout:",
966
+ f" cd {shlex.quote(str(checkout))} && uv run whykit serve {target}",
967
+ ]
968
+ return [
969
+ f"This copy was installed from {checkout}, which no longer has apps/explorer/.",
970
+ "Clone the repository and run `whykit serve` from the clone:",
971
+ f" git clone {REPOSITORY_URL}",
972
+ ]
973
+ vcs = (origin or {}).get("vcs_info")
974
+ if url and isinstance(vcs, dict) and vcs.get("vcs") == "git":
975
+ lines = [
976
+ "This copy was installed from Git. Clone the same source and run `whykit serve` from the clone:",
977
+ f" git clone {shlex.quote(url)} whykit",
978
+ ]
979
+ commit = str(vcs.get("commit_id") or "")
980
+ if re.fullmatch(r"[0-9a-f]{7,64}", commit):
981
+ lines.append(f" git -C whykit checkout {commit}")
982
+ lines.append(f" cd whykit && uv run whykit serve {target}")
983
+ return lines
984
+ return [
985
+ "Clone the repository and run `whykit serve` from the clone:",
986
+ f" git clone {REPOSITORY_URL}",
987
+ f" cd whykit && uv run whykit serve {target}",
988
+ ]
989
+
990
+
991
+ def cmd_lsp(args: argparse.Namespace) -> int:
992
+ argv: list[str] = ["--debounce", str(args.debounce)]
993
+ if args.root:
994
+ argv += ["--root", args.root]
995
+ if args.today:
996
+ argv += ["--today", args.today]
997
+ from .lsp import main as lsp_main
998
+ return lsp_main(argv)
999
+
1000
+
1001
+ def _serve_target(args: argparse.Namespace) -> str:
1002
+ """The vault ``serve`` should show: the positional argument or ``--root``."""
1003
+ positional, root = getattr(args, "vault", None), getattr(args, "root", None)
1004
+ if positional and root and Path(positional).expanduser().resolve() != Path(root).expanduser().resolve():
1005
+ raise ValueError(f"two different vaults given: {positional} and --root {root}; pass one")
1006
+ return positional or root or "."
1007
+
1008
+
1009
+ def cmd_serve(args: argparse.Namespace) -> int:
1010
+ try:
1011
+ target = _serve_target(args)
1012
+ except ValueError as exc:
1013
+ print(str(exc), file=sys.stderr)
1014
+ return 2
1015
+ explorer = SOURCE_ROOT / "apps" / "explorer"
1016
+ if not (explorer / "package.json").exists():
1017
+ print("The Explorer ships with the source repository, not the package.", file=sys.stderr)
1018
+ for line in _serve_hint(_install_origin(), target):
1019
+ print(line, file=sys.stderr)
1020
+ return 2
1021
+ npm = shutil.which("npm")
1022
+ if not npm:
1023
+ print("npm is required for the Explorer", file=sys.stderr)
1024
+ return 2
1025
+ vault = _resolve_vault(None if target == "." else target) or Path(target).expanduser().resolve()
1026
+ if not is_vault_root(vault):
1027
+ print(f"not a WhyKit vault: {vault}", file=sys.stderr)
1028
+ return 2
1029
+ loopback_hosts = {"127.0.0.1", "localhost", "::1"}
1030
+ if args.host not in loopback_hosts and not args.allow_sensitive_network:
1031
+ non_public = _non_public_docs(vault)
1032
+ if non_public:
1033
+ print("refusing to expose non-public vault content on a non-loopback interface", file=sys.stderr)
1034
+ print(f"{len(non_public)} non-public document(s) would be bundled into the Explorer", file=sys.stderr)
1035
+ print("use the default 127.0.0.1, or pass --allow-sensitive-network after reviewing the risk", file=sys.stderr)
1036
+ return 2
1037
+ if not (explorer / "node_modules").exists():
1038
+ print("Explorer dependencies are not installed. Run: npm --prefix apps/explorer install", file=sys.stderr)
1039
+ return 2
1040
+ env = os.environ.copy()
1041
+ env["WHYKIT_VAULT_DIR"] = str(vault)
1042
+ env["WHYKIT_EXPLORER_PRIVATE"] = "1"
1043
+ return subprocess.run([npm, "run", "dev", "--", "--host", args.host, "--port", str(args.port)],
1044
+ cwd=explorer, env=env).returncode
1045
+
1046
+
1047
+ ROOT_HELP = "vault root (default: nearest vault at or above the working directory)"
1048
+ TODAY_HELP = "evaluate review dates as of this YYYY-MM-DD date instead of today"
1049
+ JSON_HELP = "emit machine-readable JSON on stdout"
1050
+ SENSITIVITIES = ("public", "internal", "confidential", "restricted")
1051
+
1052
+ EPILOG = """\
1053
+ exit codes:
1054
+ 0 success (no errors; no warnings under --strict)
1055
+ 1 the check ran and found problems that should fail the build
1056
+ 2 the tool could not run: no vault, invalid input or configuration
1057
+ 70 internal error: a bug in WhyKit, not a problem with the vault
1058
+ 130 interrupted
1059
+
1060
+ Run `whykit <command> -h` for a command's options.
1061
+ Docs: https://github.com/CometWeb-io/whykit#readme"""
1062
+
1063
+ # Top-level help groups the commands by the job they do, so ~30 commands read
1064
+ # as five verbs rather than one flat list. Every command belongs to exactly one
1065
+ # group; tests/test_cli_consistency.py holds this table to the parser.
1066
+ COMMAND_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
1067
+ ("author", "create and change records", ("init", "adopt", "new", "review", "evidence")),
1068
+ ("check", "gate a vault or a change", ("lint", "check", "status", "trace", "history", "diff", "snapshot", "verify-snapshot")),
1069
+ ("explore", "find and hand over context", ("query", "context", "pack", "graph", "backlinks", "impact")),
1070
+ ("integrate", "connect hooks, viewers and shells", ("install-hooks", "explorer-index", "serve", "lsp", "completion")),
1071
+ ("maintain", "inspect policy, rules and setup", ("policy", "rules", "doctor", "recover")),
1072
+ )
1073
+
1074
+ COMMANDS_PREAMBLE = (
1075
+ "Vault commands take --root DIR before or after the command (default: the\n"
1076
+ "nearest vault), --json for one JSON document on stdout, and --today\n"
1077
+ "YYYY-MM-DD where a result depends on the date."
1078
+ )
1079
+
1080
+
1081
+ class _Parser(argparse.ArgumentParser):
1082
+ """ArgumentParser whose usage errors honour the machine contract.
1083
+
1084
+ argparse reports a bad command line before any command runs, so the
1085
+ ``--json`` choice is read from the raw arguments (see ``main``). Human
1086
+ output and the exit code (2) are exactly argparse's own.
1087
+ """
1088
+
1089
+ json_errors = False
1090
+ # The command path being parsed (e.g. "review list"), read from the raw
1091
+ # arguments in ``main`` so a top-level usage error can name its subcommand.
1092
+ command_path = ""
1093
+
1094
+ def error(self, message: str) -> NoReturn:
1095
+ hint = _usage_hint(message, _Parser.command_path)
1096
+ self.print_usage(sys.stderr)
1097
+ sys.stderr.write(f"{self.prog}: error: {message}\n")
1098
+ if hint:
1099
+ sys.stderr.write(f"hint: {hint}\n")
1100
+ if _Parser.json_errors:
1101
+ import json
1102
+ emit_machine(json.dumps(
1103
+ error_payload("usage", f"{self.prog}: error: {message}", hint or f"run `{self.prog} -h` for the accepted options"),
1104
+ ensure_ascii=False,
1105
+ indent=2,
1106
+ ))
1107
+ self.exit(2)
1108
+
1109
+
1110
+ def _usage_hint(message: str, command_path: str = "") -> str | None:
1111
+ """A next step for argparse errors where the message alone misleads."""
1112
+ if "invalid choice: 'accepted'" in message and "draft" in message:
1113
+ # Decision logs render the approved state as "Accepted", so people type it.
1114
+ return "decision logs display approved decisions as \"Accepted\"; create a draft, complete it, then use `whykit review approve`"
1115
+ if message.startswith("unrecognized arguments") and command_path:
1116
+ # argparse reports these from the top-level parser, whose usage line
1117
+ # lists no subcommand options at all.
1118
+ return f"`whykit {command_path} -h` lists the options of `whykit {command_path}`"
1119
+ return None
1120
+
1121
+
1122
+ class _TopParser(_Parser):
1123
+ """The ``whykit`` parser: help lists commands grouped by job, and a
1124
+ leading ``--root DIR`` is handed to the command that follows it."""
1125
+
1126
+ def parse_known_args(self, args=None, namespace=None): # type: ignore[no-untyped-def]
1127
+ raw = list(sys.argv[1:] if args is None else args)
1128
+ return super().parse_known_args(_hoist_root(raw, set(_subcommand_helps(self))), namespace)
1129
+
1130
+ def format_help(self) -> str:
1131
+ formatter = self._get_formatter()
1132
+ formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups)
1133
+ formatter.add_text(self.description)
1134
+ formatter.start_section("options")
1135
+ formatter.add_arguments([a for a in self._actions if a.option_strings])
1136
+ formatter.end_section()
1137
+ text = formatter.format_help().rstrip("\n") + "\n\n"
1138
+ return text + _grouped_commands(self) + "\n" + (self.epilog or "") + "\n"
1139
+
1140
+
1141
+ def _subcommand_helps(parser: argparse.ArgumentParser) -> dict[str, str]:
1142
+ for action in parser._actions: # argparse has no public introspection API
1143
+ if isinstance(action, argparse._SubParsersAction):
1144
+ return {str(choice.dest): str(choice.help or "") for choice in action._choices_actions}
1145
+ return {}
1146
+
1147
+
1148
+ def _grouped_commands(parser: argparse.ArgumentParser) -> str:
1149
+ import textwrap
1150
+
1151
+ helps = _subcommand_helps(parser)
1152
+ width = max(len(name) for name in helps) + 2
1153
+ columns = max(60, min(shutil.get_terminal_size().columns, 100) - 2)
1154
+ lines = ["commands:", *(f" {line}" for line in COMMANDS_PREAMBLE.splitlines()), ""]
1155
+ for job, blurb, names in COMMAND_GROUPS:
1156
+ lines.append(f" {job} - {blurb}")
1157
+ for name in names:
1158
+ wrapped = textwrap.wrap(helps.get(name, ""), max(20, columns - width - 4)) or [""]
1159
+ lines.append(f" {name:<{width}}{wrapped[0]}".rstrip())
1160
+ lines.extend(f" {'':<{width}}{rest}" for rest in wrapped[1:])
1161
+ return "\n".join(lines) + "\n"
1162
+
1163
+
1164
+ def _command_path(parser: argparse.ArgumentParser, raw: list[str]) -> str:
1165
+ """The subcommand names in *raw*, in order (e.g. ``review list``)."""
1166
+ path: list[str] = []
1167
+ current: argparse.ArgumentParser | None = parser
1168
+ for token in raw:
1169
+ if token == "--" or current is None:
1170
+ break
1171
+ children = next(
1172
+ (a.choices for a in current._actions if isinstance(a, argparse._SubParsersAction)), None
1173
+ )
1174
+ if not children:
1175
+ break
1176
+ if token in children:
1177
+ path.append(token)
1178
+ current = children[token]
1179
+ return " ".join(path)
1180
+
1181
+
1182
+ def _hoist_root(raw: list[str], commands: set[str]) -> list[str]:
1183
+ """Move a leading ``--root DIR`` behind the command name.
1184
+
1185
+ Every vault command accepts ``--root`` after its name; ``whykit --root DIR
1186
+ lint`` used to fail with "invalid choice: 'DIR'". Only a leading
1187
+ ``--root`` directly followed by a command is moved, so the command's own
1188
+ parser still decides whether it accepts the option.
1189
+ """
1190
+ if raw and raw[0] == "--root" and len(raw) >= 3 and raw[2] in commands:
1191
+ return [raw[2], raw[0], raw[1], *raw[3:]]
1192
+ if raw and raw[0].startswith("--root=") and len(raw) >= 2 and raw[1] in commands:
1193
+ return [raw[1], raw[0], *raw[2:]]
1194
+ return raw
1195
+
1196
+
1197
+ def _add_leaf_root(parser: argparse.ArgumentParser) -> None:
1198
+ """Accept ``--root`` after a nested action as well as before it.
1199
+
1200
+ ``whykit new --root V decision ...`` was the only spelling that worked,
1201
+ and ``whykit new decision ... --root V`` -- the form every other command
1202
+ accepts -- failed with "unrecognized arguments". SUPPRESS keeps a value
1203
+ given at the parent level from being overwritten by the leaf default.
1204
+ """
1205
+ parser.add_argument("--root", default=argparse.SUPPRESS, help=ROOT_HELP)
1206
+
1207
+
1208
+ def build_parser() -> argparse.ArgumentParser:
1209
+ parser = _TopParser(
1210
+ prog="whykit",
1211
+ description="WhyKit - a Git-native evidence and decision ledger.",
1212
+ epilog=EPILOG,
1213
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1214
+ )
1215
+ parser.add_argument("-V", "--version", action="version", version=f"whykit {__version__}")
1216
+ parser.add_argument(
1217
+ "--no-cache", action="store_true",
1218
+ help="do not read or update the parse cache in .whykit/cache/ (same as WHYKIT_NO_CACHE=1)",
1219
+ )
1220
+ sub = parser.add_subparsers(dest="command", metavar="<command>", title="commands")
1221
+
1222
+ init = sub.add_parser("init", help="create a new vault", description="Create a new WhyKit vault from the bundled template.")
1223
+ init.add_argument("target", help="directory to create (must be empty unless --force)")
1224
+ init.add_argument("--force", action="store_true", help="add missing template files in a non-empty directory without replacing existing files")
1225
+ init.add_argument(
1226
+ "--minimal",
1227
+ action="store_true",
1228
+ help="explicitly select the default vendor-neutral layout (legacy alias)",
1229
+ )
1230
+ init.add_argument(
1231
+ "--full",
1232
+ action="store_true",
1233
+ help="also create the optional starter workstreams (strategy, website, automation, operations, research) and their templates",
1234
+ )
1235
+ init.add_argument("--json", action="store_true", help=JSON_HELP)
1236
+ init.set_defaults(func=cmd_init)
1237
+
1238
+ lint_cmd = sub.add_parser("lint", help="check a vault", description="Check vault structure, links, evidence and decisions.")
1239
+ lint_cmd.add_argument("paths", nargs="*", help="Markdown files or directories to check, relative to the vault root (default: whole vault)")
1240
+ lint_cmd.add_argument("--root", help=ROOT_HELP)
1241
+ lint_cmd.add_argument("--strict", action="store_true", help="treat warnings as failures (exit 1)")
1242
+ lint_cmd.add_argument("--quiet", action="store_true", help="print only the summary line")
1243
+ lint_cmd.add_argument("--json", action="store_true", help=JSON_HELP)
1244
+ lint_cmd.add_argument("--no-orphans", action="store_true", help="skip orphan-note warnings")
1245
+ lint_cmd.add_argument("--no-secrets", action="store_true", help="skip the secret scan")
1246
+ lint_cmd.add_argument("--today", help=TODAY_HELP)
1247
+ lint_cmd.add_argument("--format", choices=("text", "json", "sarif", "github"), default=None, help="output format: text (default), json, sarif (SARIF 2.1.0) or github (workflow annotations)")
1248
+ lint_cmd.set_defaults(func=cmd_lint)
1249
+
1250
+ new = sub.add_parser("new", help="create a decision, evidence row or note", description="Create a record and keep the vault indexes in sync.")
1251
+ new.add_argument("--root", help=ROOT_HELP)
1252
+ new_sub = new.add_subparsers(dest="new_kind", required=True, metavar="<kind>", title="kinds")
1253
+ new_decision = new_sub.add_parser("decision", help="create a decision record and index row")
1254
+ new_decision.add_argument("title", nargs="?", help="decision title, e.g. \"Adopt usage-based pricing\" (optional with --from)")
1255
+ new_decision.add_argument("--from", dest="from_path", metavar="FILE", help="promote an existing ADR (MADR, Nygard, Y-statement or Polish headings), e.g. one staged by `adopt`; prints the mapping unless --write")
1256
+ new_decision.add_argument("--write", action="store_true", help="with --from: create the record and its decision-log row (default: dry run)")
1257
+ new_decision.add_argument("--owner", help="accountable person (default: policy defaults.owner)")
1258
+ new_decision.add_argument("--status", choices=("draft", "in_review"), default="draft", help="initial status (default: %(default)s)")
1259
+ new_decision.add_argument("--sensitivity", choices=SENSITIVITIES, help="default: policy defaults.sensitivity")
1260
+ new_decision.add_argument("--source", action="append", default=[], dest="source_ids", metavar="E-NNN", help="supporting evidence ID (repeatable)")
1261
+ new_decision.add_argument("--review-by", metavar="YYYY-MM-DD", help="date by which the decision must be re-checked")
1262
+ new_decision.add_argument("--supersedes", metavar="D-NNN", help="decision this one replaces")
1263
+ new_decision.add_argument("--json", action="store_true", help=JSON_HELP)
1264
+ _add_leaf_root(new_decision)
1265
+ new_decision.set_defaults(func=cmd_new)
1266
+
1267
+ new_evidence = new_sub.add_parser("evidence", help="append a source to the evidence register")
1268
+ new_evidence.add_argument("--source", required=True, dest="source_name", help="who or what produced the evidence")
1269
+ new_evidence.add_argument("--location", required=True, help="URL or path where the source can be checked")
1270
+ new_evidence.add_argument("--type", required=True, dest="evidence_type", help="kind of source, e.g. interview, analytics, report")
1271
+ new_evidence.add_argument("--claims", required=True, help="what the source supports, in one line")
1272
+ new_evidence.add_argument("--date", metavar="YYYY-MM-DD", help="when the source was produced (default: today)")
1273
+ new_evidence.add_argument("--accessed", metavar="YYYY-MM-DD", help="when it was last checked (default: today)")
1274
+ new_evidence.add_argument("--sensitivity", choices=SENSITIVITIES, help="optional row label; cannot lower the register floor")
1275
+ new_evidence.add_argument("--json", action="store_true", help=JSON_HELP)
1276
+ _add_leaf_root(new_evidence)
1277
+ new_evidence.set_defaults(func=cmd_new)
1278
+
1279
+ new_note = new_sub.add_parser("note", help="create a draft note in a workstream")
1280
+ new_note.add_argument("title", help="note title")
1281
+ new_note.add_argument("--workstream", required=True, help="existing workstream directory, e.g. notes")
1282
+ new_note.add_argument("--owner", help="default: policy defaults.owner")
1283
+ new_note.add_argument("--type", choices=("strategy", "research", "framework", "specification", "guide", "reference"), default="guide", dest="doc_type", help="document type (default: %(default)s)")
1284
+ new_note.add_argument("--sensitivity", choices=SENSITIVITIES, help="default: policy defaults.sensitivity")
1285
+ new_note.add_argument("--link-from", metavar="PATH", help="vault-relative Markdown map to link the new note from")
1286
+ new_note.add_argument("--json", action="store_true", help=JSON_HELP)
1287
+ _add_leaf_root(new_note)
1288
+ new_note.set_defaults(func=cmd_new)
1289
+
1290
+ status = sub.add_parser("status", help="summarize vault health and review queue")
1291
+ status.add_argument("--root", help=ROOT_HELP)
1292
+ status.add_argument("--json", action="store_true", help=JSON_HELP)
1293
+ status.add_argument("--today", help=TODAY_HELP)
1294
+ status.add_argument("--due-days", type=int, metavar="N", help="review window in days (default: policy defaults.status_due_days)")
1295
+ status.add_argument("--strict", action="store_true", help="exit 1 when anything needs attention")
1296
+ status.set_defaults(func=cmd_status)
1297
+
1298
+ graph = sub.add_parser("graph", help="export the vault wikilink graph")
1299
+ graph.add_argument("--root", help=ROOT_HELP)
1300
+ graph.add_argument("--format", choices=("json", "dot", "mermaid", "obsidian"), default=None, help="output format (default: json)")
1301
+ graph.add_argument("--json", action="store_true", help="shorthand for --format json")
1302
+ graph.add_argument("--canonical-only", action="store_true", help="only include canonical documents")
1303
+ graph.add_argument("--output", metavar="PATH", help="write inside the vault (e.g. .whykit/graph.json) instead of stdout")
1304
+ graph.set_defaults(func=cmd_graph)
1305
+
1306
+ backlinks = sub.add_parser("backlinks", help="list inbound links to a note, decision or evidence ID")
1307
+ backlinks.add_argument("target", help="vault-relative path, D-NNN or E-NNN")
1308
+ backlinks.add_argument("--root", help=ROOT_HELP)
1309
+ backlinks.add_argument("--json", action="store_true", help=JSON_HELP)
1310
+ backlinks.set_defaults(func=cmd_backlinks)
1311
+
1312
+ impact = sub.add_parser("impact", help="show what depends on evidence, a decision or a document")
1313
+ impact.add_argument("target", help="vault-relative path, D-NNN or E-NNN")
1314
+ impact.add_argument("--root", help=ROOT_HELP)
1315
+ impact.add_argument("--json", action="store_true", help=JSON_HELP)
1316
+ impact.set_defaults(func=cmd_impact)
1317
+
1318
+ trace = sub.add_parser("trace", help="trace decisions to their evidence and flag missing, retired or stale sources")
1319
+ trace.add_argument("--root", help=ROOT_HELP)
1320
+ trace.add_argument("--decision", metavar="D-NNN", help="trace only this decision")
1321
+ trace.add_argument("--today", help="evaluate evidence age as of this YYYY-MM-DD date instead of today")
1322
+ trace.add_argument("--max-age-days", type=int, metavar="N", help="treat evidence as stale after N days when policy has no age for its type")
1323
+ trace.add_argument("--gaps-only", action="store_true", help="list only live decisions with gaps")
1324
+ trace.add_argument("--strict", action="store_true", help="exit 1 when any live decision has a gap")
1325
+ trace.add_argument("--json", action="store_true", help=JSON_HELP)
1326
+ trace.set_defaults(func=cmd_trace)
1327
+
1328
+ query = sub.add_parser("query", help="query notes by text, metadata and evidence")
1329
+ query.add_argument("text", nargs="?", help="free-text search (optional)")
1330
+ query.add_argument("--root", help=ROOT_HELP)
1331
+ query.add_argument("--type", dest="doc_type", help="filter by document type")
1332
+ query.add_argument("--status", help="filter by status")
1333
+ query.add_argument("--owner", help="filter by owner")
1334
+ query.add_argument("--sensitivity", help="filter by sensitivity")
1335
+ query.add_argument("--source", dest="source_id", metavar="E-NNN", help="only records citing this evidence ID")
1336
+ query.add_argument("--tag", help="filter by tag")
1337
+ query.add_argument("--canonical-only", action="store_true", help="only canonical documents")
1338
+ query.add_argument("--limit", type=int, default=100, metavar="N", help="maximum results (default: %(default)s)")
1339
+ query.add_argument("--json", action="store_true", help=JSON_HELP)
1340
+ query.set_defaults(func=cmd_query)
1341
+
1342
+ context = sub.add_parser("context", help="build an evidence-aware context pack for one target")
1343
+ context.add_argument("target", help="vault-relative path, D-NNN or E-NNN")
1344
+ context.add_argument("--root", help=ROOT_HELP)
1345
+ context.add_argument("--max-chars", type=int, default=20_000, metavar="N", help="body character budget (default: %(default)s)")
1346
+ context.add_argument("--no-body", action="store_true", help="omit the document body")
1347
+ context.add_argument("--json", action="store_true", help=JSON_HELP)
1348
+ context.set_defaults(func=cmd_context)
1349
+
1350
+ pack = sub.add_parser("pack", help="build a bounded multi-record context bundle")
1351
+ pack.add_argument("targets", nargs="*", help="vault-relative paths, D-NNN or E-NNN")
1352
+ pack.add_argument("--root", help=ROOT_HELP)
1353
+ pack.add_argument("--query", help="add records matching this text query")
1354
+ pack.add_argument("--max-docs", type=int, default=8, metavar="N", help="maximum records (default: %(default)s)")
1355
+ pack.add_argument("--max-chars", type=int, default=30_000, metavar="N", help="total body character budget (default: %(default)s)")
1356
+ pack.add_argument("--canonical-only", action="store_true", help="only canonical documents")
1357
+ pack.add_argument("--format", choices=("json", "markdown"), default=None, help="output format (default: json)")
1358
+ pack.add_argument("--json", action="store_true", help="shorthand for --format json")
1359
+ pack.add_argument("--for", dest="agent", choices=("generic", "cursor", "claude", "codex"), default="generic", help="tailor the preamble to an agent host (default: %(default)s)")
1360
+ pack.set_defaults(func=cmd_pack)
1361
+
1362
+ review = sub.add_parser("review", help="list review work or record a review event")
1363
+ review.add_argument("--root", help=ROOT_HELP)
1364
+ review_sub = review.add_subparsers(dest="review_command", required=True, metavar="<action>", title="actions")
1365
+ review_list = review_sub.add_parser("list", help="show upcoming/overdue reviews")
1366
+ review_list.add_argument("--today", help=TODAY_HELP)
1367
+ review_list.add_argument("--due-days", type=int, metavar="N", help="review window in days (default: policy)")
1368
+ review_list.add_argument("--owner", help="only reviews owned by this person")
1369
+ review_list.add_argument("--overdue-only", action="store_true", help="hide reviews that are not yet due")
1370
+ review_list.add_argument("--json", action="store_true", help=JSON_HELP)
1371
+ _add_leaf_root(review_list)
1372
+ review_list.set_defaults(func=cmd_review)
1373
+ review_record = review_sub.add_parser("record", help="record a review event")
1374
+ review_record.add_argument("target", help="vault-relative path or D-NNN")
1375
+ review_record.add_argument("--reviewer", required=True, help="who performed the review")
1376
+ review_record.add_argument("--outcome", choices=("confirmed", "update-required", "supersede-required", "archived"), default="confirmed", help="review result (default: %(default)s)")
1377
+ review_record.add_argument("--next-review", metavar="YYYY-MM-DD", help="next review date")
1378
+ review_record.add_argument("--note", default="", dest="note_text", help="short note for the review log")
1379
+ review_record.add_argument("--today", help="record the review as of this YYYY-MM-DD date")
1380
+ review_record.add_argument("--json", action="store_true", help=JSON_HELP)
1381
+ _add_leaf_root(review_record)
1382
+ review_record.set_defaults(func=cmd_review)
1383
+
1384
+ review_approve = review_sub.add_parser("approve", help="preview approval, then apply the reviewed snapshot")
1385
+ review_approve.add_argument("target", help="vault-relative decision path or D-NNN")
1386
+ review_approve.add_argument("--reviewer", required=True, help="who reviewed the snapshot; an attribution, not authentication")
1387
+ review_approve.add_argument("--next-review", metavar="YYYY-MM-DD", help="default: policy decision_review_days after approval")
1388
+ review_approve.add_argument("--today", help="approval date as YYYY-MM-DD")
1389
+ review_approve.add_argument("--write", action="store_true", help="apply only the snapshot identified by --expect-hash")
1390
+ review_approve.add_argument("--expect-hash", dest="expected_sha256", help="expected_sha256 from a reviewed preview")
1391
+ review_approve.add_argument("--json", action="store_true", help=JSON_HELP)
1392
+ _add_leaf_root(review_approve)
1393
+ review_approve.set_defaults(func=cmd_review)
1394
+
1395
+ snapshot = sub.add_parser("snapshot", help="create a deterministic vault snapshot")
1396
+ snapshot.add_argument("--root", help=ROOT_HELP)
1397
+ snapshot.add_argument("--today", help=TODAY_HELP)
1398
+ snapshot.add_argument("--output", metavar="PATH", help="write the JSON snapshot here instead of stdout")
1399
+ snapshot.add_argument("--compact", action="store_true", help="emit compact JSON")
1400
+ snapshot.add_argument(
1401
+ "--format", dest="snapshot_format", choices=("v1", "v2"), default=None,
1402
+ help="snapshot format: v2 (default) hashes text with CRLF line endings and a UTF-8 BOM "
1403
+ "normalized, so checkouts on different platforms match; v1 hashes raw bytes",
1404
+ )
1405
+ snapshot.add_argument("--json", action="store_true", help="accepted for symmetry; stdout is always JSON unless --output is given")
1406
+ snapshot.set_defaults(func=cmd_snapshot)
1407
+
1408
+ verify_snapshot = sub.add_parser("verify-snapshot", help="compare the vault with a prior snapshot")
1409
+ verify_snapshot.add_argument("snapshot", help="baseline snapshot JSON (relative paths resolve against the vault root)")
1410
+ verify_snapshot.add_argument("--root", help=ROOT_HELP)
1411
+ verify_snapshot.add_argument("--today", help=TODAY_HELP)
1412
+ verify_snapshot.add_argument("--json", action="store_true", help=JSON_HELP)
1413
+ verify_snapshot.set_defaults(func=cmd_verify_snapshot)
1414
+
1415
+ check = sub.add_parser("check", help="run a local/CI/release policy gate")
1416
+ check.add_argument("--root", help=ROOT_HELP)
1417
+ check.add_argument("--profile", default="ci", help="policy profile: local, ci, release or a custom one (default: %(default)s)")
1418
+ check.add_argument("--base", metavar="REF", help="base Git ref for the immutable-history check")
1419
+ check.add_argument("--head", default="HEAD", metavar="REF", help="head Git ref (default: %(default)s)")
1420
+ check.add_argument("--today", help=TODAY_HELP)
1421
+ check.add_argument("--json", action="store_true", help=JSON_HELP)
1422
+ check.add_argument("--format", choices=("text", "json", "github"), default=None, help="output format: text (default), json or github (workflow annotations)")
1423
+ check.set_defaults(func=cmd_check)
1424
+
1425
+ policy = sub.add_parser("policy", help="show the effective repository-local policy")
1426
+ policy.add_argument("--root", help=ROOT_HELP)
1427
+ policy.add_argument("--json", action="store_true", help=JSON_HELP)
1428
+ policy.set_defaults(func=cmd_policy)
1429
+
1430
+ evidence = sub.add_parser("evidence", help="inspect and manage evidence lifecycle")
1431
+ evidence.add_argument("--root", help=ROOT_HELP)
1432
+ evidence_sub = evidence.add_subparsers(dest="evidence_command", required=True, metavar="<action>", title="actions")
1433
+ evidence_list = evidence_sub.add_parser("list", help="list evidence rows")
1434
+ evidence_list.add_argument("--state", choices=("all", "active", "retired"), default="all", help="which rows to list (default: %(default)s)")
1435
+ evidence_list.add_argument("--json", action="store_true", help=JSON_HELP)
1436
+ _add_leaf_root(evidence_list)
1437
+ evidence_list.set_defaults(func=cmd_evidence)
1438
+ evidence_retire = evidence_sub.add_parser("retire", help="retire active evidence")
1439
+ evidence_retire.add_argument("id", metavar="E-NNN", help="evidence ID to retire")
1440
+ evidence_retire.add_argument("--why", required=True, dest="reason", help="why the evidence no longer holds")
1441
+ evidence_retire.add_argument("--replaced-by", metavar="E-NNN", help="evidence that supersedes it")
1442
+ evidence_retire.add_argument("--today", help="retire as of this YYYY-MM-DD date")
1443
+ evidence_retire.add_argument("--json", action="store_true", help=JSON_HELP)
1444
+ _add_leaf_root(evidence_retire)
1445
+ evidence_retire.set_defaults(func=cmd_evidence)
1446
+
1447
+ adopt = sub.add_parser("adopt", help="inventory existing Markdown and stage it for a vault")
1448
+ adopt.add_argument("source", help="directory of existing Markdown to inventory")
1449
+ adopt.add_argument("--into", "--root", dest="into", metavar="VAULT", help="vault to stage into (default: nearest vault); --root is the same option, spelled as on every other command")
1450
+ adopt.add_argument("--owner", default="TODO", help="owner for adopted notes (default: %(default)s)")
1451
+ adopt.add_argument("--profile", choices=("generic", "adr-only", "obsidian-loose"), default="generic", help="how to interpret the source (default: %(default)s)")
1452
+ adopt.add_argument("--write", action="store_true", help="write the staged files (default: dry run)")
1453
+ adopt.add_argument("--json", action="store_true", help=JSON_HELP)
1454
+ adopt.add_argument("--compare", metavar="DIR", help="read-only preservation check at matching relative paths; cannot combine with --write")
1455
+ adopt.set_defaults(func=cmd_adopt)
1456
+
1457
+ history = sub.add_parser("history", help="verify that accepted decisions were not rewritten")
1458
+ history.add_argument("--base", metavar="REF", help="base commit or ref, e.g. origin/main (required unless --staged, which defaults to HEAD)")
1459
+ history.add_argument("--head", default=None, metavar="REF", help="head commit or ref (default: HEAD)")
1460
+ history.add_argument("--staged", action="store_true", help="check the staged changes against --base instead of a head commit (for pre-commit hooks)")
1461
+ history.add_argument("--root", help=ROOT_HELP)
1462
+ history.add_argument("--json", action="store_true", help=JSON_HELP)
1463
+ history.set_defaults(func=cmd_history)
1464
+
1465
+ diff = sub.add_parser(
1466
+ "diff",
1467
+ help="show what changed in decisions, evidence and lint findings between two commits",
1468
+ description="Compare the vault at the merge base of --base and --head with --head, read from Git objects without a checkout.",
1469
+ )
1470
+ diff.add_argument("--base", required=True, metavar="REF", help="base commit or ref, e.g. origin/main")
1471
+ diff.add_argument("--head", default="HEAD", metavar="REF", help="head commit or ref (default: %(default)s)")
1472
+ diff.add_argument("--root", help=ROOT_HELP)
1473
+ diff.add_argument("--today", help=TODAY_HELP)
1474
+ diff.add_argument(
1475
+ "--format", choices=("text", "json", "markdown", "github"), default=None,
1476
+ help="output format: text (default), json, markdown (a pull request comment) or github (workflow annotations)",
1477
+ )
1478
+ diff.add_argument("--json", action="store_true", help=JSON_HELP)
1479
+ diff.set_defaults(func=cmd_diff)
1480
+
1481
+ rules = sub.add_parser("rules", help="list lint rules or explain one rule code")
1482
+ rules.add_argument("code", nargs="?", help="rule code to explain, e.g. evidence.unknown_id")
1483
+ rules.add_argument("--json", action="store_true", help=JSON_HELP)
1484
+ rules.add_argument("--markdown", action="store_true", help="emit the complete Markdown table")
1485
+ rules.add_argument("--root", help="vault whose whykit.toml custom rules and overrides are listed too (default: nearest vault, if any)")
1486
+ rules.set_defaults(func=cmd_rules)
1487
+
1488
+ doctor = sub.add_parser("doctor", help="check prerequisites and vault integrity")
1489
+ doctor.add_argument("--root", help=ROOT_HELP)
1490
+ doctor.add_argument("--today", help=TODAY_HELP)
1491
+ doctor.add_argument("--json", action="store_true", help=JSON_HELP)
1492
+ doctor.set_defaults(func=cmd_doctor)
1493
+
1494
+ hooks = sub.add_parser("install-hooks", help="install the pre-commit vault check")
1495
+ hooks.add_argument("--root", help=ROOT_HELP)
1496
+ hooks.add_argument("--force", action="store_true", help="replace an existing pre-commit hook")
1497
+ hooks.set_defaults(func=cmd_install_hooks)
1498
+
1499
+ recover = sub.add_parser("recover", help="recover interrupted writes and collect completed transaction journals")
1500
+ recover.add_argument("--root", help=ROOT_HELP)
1501
+ recover.add_argument("--json", action="store_true", help=JSON_HELP)
1502
+ recover.set_defaults(func=cmd_recover)
1503
+
1504
+ explorer_index = sub.add_parser(
1505
+ "explorer-index",
1506
+ help="export the Explorer vault index from the canonical Python parser",
1507
+ )
1508
+ explorer_index.add_argument("--root", help=ROOT_HELP)
1509
+ explorer_index.add_argument("--today", help=TODAY_HELP)
1510
+ explorer_index.add_argument("--json", action="store_true", default=True, help="accepted for symmetry; output is always JSON")
1511
+ explorer_index.add_argument("--private", action="store_true", help="full local index; never publish this output")
1512
+ explorer_index.add_argument("--publication-preview", action="store_true", help="private withheld-path diagnostics; never publish this report")
1513
+ explorer_index.set_defaults(func=cmd_explorer_index)
1514
+
1515
+ serve = sub.add_parser("serve", help="run the optional Explorer (source checkout only)")
1516
+ serve.add_argument("vault", nargs="?", default=None, help="vault to serve (default: current directory)")
1517
+ serve.add_argument("--root", help="vault to serve, as on every other command (same as the positional vault)")
1518
+ serve.add_argument("--host", default="127.0.0.1", help="interface to bind (default: %(default)s)")
1519
+ serve.add_argument("--port", type=int, default=5173, help="port to listen on (default: %(default)s)")
1520
+ serve.add_argument("--allow-sensitive-network", action="store_true", help="allow non-loopback serving even when non-public docs are present")
1521
+ serve.set_defaults(func=cmd_serve)
1522
+
1523
+ lsp = sub.add_parser(
1524
+ "lsp",
1525
+ help="run the read-only language server for editors (stdio)",
1526
+ description="Run the read-only WhyKit language server over stdio: lint diagnostics, "
1527
+ "wikilink and ID completion, hover, go-to-definition and document links. It never writes a file.",
1528
+ )
1529
+ lsp.add_argument("--root", help="serve only this vault (default: the vault around each open file)")
1530
+ lsp.add_argument("--debounce", type=int, default=300, metavar="MS", help="milliseconds to wait after an edit before linting (default: %(default)s)")
1531
+ lsp.add_argument("--today", help=TODAY_HELP)
1532
+ lsp.add_argument("--stdio", action="store_true", help="accepted for editor clients that pass it; stdio is the only transport")
1533
+ lsp.set_defaults(func=cmd_lsp)
1534
+
1535
+ completion = sub.add_parser(
1536
+ "completion",
1537
+ help="print a shell completion script",
1538
+ description="Print a completion script for your shell.",
1539
+ epilog=COMPLETION_EPILOG,
1540
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1541
+ )
1542
+ completion.add_argument("shell", choices=SHELLS, help="target shell")
1543
+ completion.set_defaults(func=cmd_completion)
1544
+
1545
+ return parser
1546
+
1547
+
1548
+ COMPLETION_EPILOG = """\
1549
+ install:
1550
+ bash eval "$(whykit completion bash)" # add to ~/.bashrc
1551
+ zsh eval "$(whykit completion zsh)" # add to ~/.zshrc
1552
+ fish whykit completion fish > ~/.config/fish/completions/whykit.fish"""
1553
+
1554
+
1555
+ def cmd_completion(args: argparse.Namespace) -> int:
1556
+ print(render_completion(build_parser(), args.shell), end="")
1557
+ return 0
1558
+
1559
+
1560
+ def _wants_json(args: argparse.Namespace) -> bool:
1561
+ """Whether this invocation's stdout is a JSON document."""
1562
+ command = getattr(args, "command", None)
1563
+ if command == "explorer-index":
1564
+ return True
1565
+ if command == "snapshot":
1566
+ return not getattr(args, "output", None)
1567
+ if command in ("graph", "pack") and getattr(args, "format", None) in (None, "json"):
1568
+ return True
1569
+ if command in ("lint", "check", "diff") and getattr(args, "format", None) == "json":
1570
+ return True
1571
+ return bool(getattr(args, "json", False))
1572
+
1573
+
1574
+ _WINDOWS = sys.platform == "win32"
1575
+
1576
+ # Commands that only read the vault. They reuse and refresh the parse cache;
1577
+ # commands that write the vault, or read other directories (`diff` exports
1578
+ # commits into a temporary directory), never touch it.
1579
+ _CACHED_COMMANDS = frozenset({
1580
+ "lint", "check", "status", "snapshot", "verify-snapshot", "query", "context", "pack",
1581
+ "graph", "backlinks", "impact", "trace", "explorer-index", "doctor",
1582
+ })
1583
+
1584
+
1585
+ def _uses_parse_cache(args: argparse.Namespace) -> bool:
1586
+ if getattr(args, "no_cache", False):
1587
+ return False
1588
+ command = getattr(args, "command", None)
1589
+ if command == "review":
1590
+ return getattr(args, "review_command", None) == "list"
1591
+ return command in _CACHED_COMMANDS
1592
+
1593
+
1594
+ def _reader_gone(exc: OSError) -> bool:
1595
+ """Whether *exc* means the reader of stdout went away (`| head -1`).
1596
+
1597
+ POSIX raises BrokenPipeError (EPIPE). Windows reports a write to a pipe
1598
+ whose reader closed as EINVAL; that errno never carries a file name there,
1599
+ which keeps a real filesystem refusal on the io_error path.
1600
+ """
1601
+ if isinstance(exc, BrokenPipeError) or exc.errno == errno.EPIPE:
1602
+ return True
1603
+ return _WINDOWS and exc.errno == errno.EINVAL and exc.filename is None
1604
+
1605
+
1606
+ def _silence_stdout() -> None:
1607
+ """Point stdout at devnull so the interpreter's final flush stays quiet."""
1608
+ try:
1609
+ devnull = os.open(os.devnull, os.O_WRONLY)
1610
+ os.dup2(devnull, sys.stdout.fileno())
1611
+ except (OSError, ValueError):
1612
+ pass
1613
+
1614
+
1615
+ def _run_command(args: argparse.Namespace, func) -> int:
1616
+ """Bind config, data and rendered output to one read; discard raced output."""
1617
+ from contextlib import redirect_stdout
1618
+ import tempfile
1619
+ from .io import vault_read_lock, vault_mutation_lock
1620
+
1621
+ read_only = args.command in _CACHED_COMMANDS | {"policy", "rules", "history"}
1622
+ read_only = read_only or (args.command == "evidence" and getattr(args, "evidence_command", None) == "list")
1623
+ read_only = read_only or (args.command == "review" and args.review_command == "list")
1624
+ root = _resolve_vault(getattr(args, "root", None)) if read_only else None
1625
+ if args.command == "lint":
1626
+ from .lint import resolve_root
1627
+ resolved, _, _note = resolve_root(args.root, args.paths)
1628
+ root = resolved if is_vault_root(resolved) else None
1629
+ if root is None:
1630
+ return func(args)
1631
+ # Spill large reports to a private temporary file instead of doubling their RAM.
1632
+ scope = vault_mutation_lock(root) if args.command in {"graph", "snapshot"} and args.output else vault_read_lock(root)
1633
+ with tempfile.SpooledTemporaryFile(max_size=1024 * 1024, mode="w+", encoding="utf-8", errors="surrogateescape") as output:
1634
+ with redirect_stdout(output), scope:
1635
+ result = func(args)
1636
+ output.seek(0)
1637
+ stream = sys.stdout
1638
+ machine = _wants_json(args) or args.command in {"graph", "pack"} or (args.command in {"lint", "check"} and args.format in {"sarif", "github"})
1639
+ from .console import is_utf
1640
+ reconfigure = getattr(stream, "reconfigure", None)
1641
+ switch = machine and not is_utf(stream) and reconfigure is not None
1642
+ if switch:
1643
+ assert reconfigure is not None
1644
+ encoding, errors = stream.encoding, stream.errors
1645
+ stream.flush()
1646
+ reconfigure(encoding="utf-8", errors="surrogateescape")
1647
+ try:
1648
+ shutil.copyfileobj(output, stream)
1649
+ finally:
1650
+ if switch:
1651
+ assert reconfigure is not None
1652
+ stream.flush()
1653
+ reconfigure(encoding=encoding, errors=errors)
1654
+ return result
1655
+
1656
+ def main(argv: list[str] | None = None) -> int:
1657
+ # Before any output: an ASCII or legacy code-page console must degrade
1658
+ # glyphs, not abort the report with UnicodeEncodeError.
1659
+ harden_stdio()
1660
+ parser = build_parser()
1661
+ raw = list(sys.argv[1:] if argv is None else argv)
1662
+ raw = _hoist_root(raw, set(_subcommand_helps(parser)))
1663
+ _Parser.json_errors = argv_wants_json(raw)
1664
+ _Parser.command_path = _command_path(parser, raw)
1665
+ try:
1666
+ args = parser.parse_args(raw)
1667
+ finally:
1668
+ _Parser.json_errors = False
1669
+ _Parser.command_path = ""
1670
+ func = getattr(args, "func", None)
1671
+ if func is None:
1672
+ parser.print_help(sys.stderr)
1673
+ return 2
1674
+ try:
1675
+ from .parse_cache import persistent
1676
+
1677
+ with persistent(_uses_parse_cache(args)):
1678
+ code = _run_command(args, func)
1679
+ # Flush here, not at interpreter exit: a short report sits in the buffer
1680
+ # until then, and a reader that already left (`| head -1`) would turn
1681
+ # into an "Exception ignored" message and exit status 120.
1682
+ sys.stdout.flush()
1683
+ return code
1684
+ except KeyboardInterrupt:
1685
+ return emit_error("interrupted", "interrupted", json_mode=_wants_json(args))
1686
+ except OSError as exc:
1687
+ if _reader_gone(exc):
1688
+ # The reader went away (`whykit query | head`): exit 1 quietly,
1689
+ # without a second traceback from the final flush.
1690
+ _silence_stdout()
1691
+ return 1
1692
+ # A filesystem refusal is a user-facing condition, not a crash.
1693
+ # WHYKIT_DEBUG=1 restores the traceback for bug reports.
1694
+ if os.environ.get("WHYKIT_DEBUG"):
1695
+ raise
1696
+ return emit_error(
1697
+ "io_error",
1698
+ f"cannot complete `whykit {args.command}`: {describe_os_error(exc)}\n"
1699
+ "hint: check the path and its permissions; set WHYKIT_DEBUG=1 for a traceback",
1700
+ json_mode=_wants_json(args),
1701
+ )
1702
+ except Exception as exc: # noqa: BLE001 - the last line of defence for the contract
1703
+ # A bug in WhyKit. Keep stdout one JSON document under --json, and keep
1704
+ # the traceback (which can quote vault paths and content) off by default.
1705
+ if os.environ.get("WHYKIT_DEBUG"):
1706
+ traceback.print_exc(file=sys.stderr)
1707
+ detail = str(exc).strip().splitlines()
1708
+ reason = f"{type(exc).__name__}: {detail[0]}" if detail else type(exc).__name__
1709
+ return emit_error(
1710
+ "internal_error",
1711
+ f"internal error in `whykit {args.command}`: {reason}\n"
1712
+ f"hint: this is a bug in WhyKit; please report it at {ISSUES_URL} "
1713
+ "with the command you ran and the output of the same command under WHYKIT_DEBUG=1",
1714
+ json_mode=_wants_json(args),
1715
+ )
1716
+
1717
+
1718
+ if __name__ == "__main__":
1719
+ raise SystemExit(main())