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/adopt.py ADDED
@@ -0,0 +1,776 @@
1
+ #!/usr/bin/env python3
2
+ """Bring an existing pile of Markdown into a WhyKit vault.
3
+
4
+ Nobody adopting this tool starts from nothing. They start from a docs directory,
5
+ a folder of ADRs, a Notion export, or four years of meeting notes. `init` serves
6
+ the empty case; this serves the real one.
7
+
8
+ The design rule comes straight from the vault's own AGENTS.md: *raw platform
9
+ exports are never canonical notes.* So `adopt` never writes into a workstream.
10
+ It stages files under `.import-staging/`, records what it saw — including a
11
+ SHA-256 for every source — and leaves the judgement calls to a human. What it
12
+ automates is the inventory, which is the boring half and the half people skip.
13
+
14
+ whykit adopt ../old-docs
15
+ whykit adopt ../old-docs --profile obsidian-loose --json
16
+ whykit adopt ../old-docs --write
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import datetime as dt
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import re
26
+ import secrets
27
+ import shutil
28
+ import unicodedata
29
+ from collections import Counter
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+ from .contract import emit_error
34
+ from .io import atomic_write_bytes, atomic_write_text, safe_vault_dir, safe_vault_target, vault_mutation_lock
35
+ from .lint import (
36
+ EVIDENCE_ID_RE, DECISION_ID_RE, WIKILINK_RE, _split_table_row,
37
+ Note, check_front_matter, find_vault_root, is_markdown_name, is_vault_root, iter_markdown, load_note,
38
+ )
39
+ from .console import as_printed, emit_machine
40
+ from .immutability import _review_log_parts
41
+ from .sensitivity import SENSITIVITY_LEVEL, UNREADABLE_LEVEL, classify_evidence_register, note_sensitivity_level
42
+
43
+ SKIP_DIRS = {
44
+ ".git", ".obsidian", ".import-staging", "node_modules", "__pycache__",
45
+ ".venv", "venv", "dist", "build", ".next", ".cache",
46
+ }
47
+
48
+ # Michael Nygard's layout, MADR, adr-tools and log4brains all land in this shape.
49
+ ADR_FILENAME_RE = re.compile(r"^(?:adr[-_]?)?(\d{3,4})[-_]")
50
+ ADR_HEADING_RE = re.compile(r"(?mi)^#{1,3}\s*(status|context|decision|consequences)\b")
51
+ FRONT_MATTER_RE = re.compile(r"\A---\r?\n")
52
+
53
+ ASSESSMENTS = ("useful", "duplicate", "empty", "heading-only", "unsupported")
54
+ ADOPT_PROFILES = ("generic", "adr-only", "obsidian-loose")
55
+
56
+
57
+ @dataclass
58
+ class Candidate:
59
+ path: Path
60
+ relative: str
61
+ sha256: str
62
+ bytes: int
63
+ words: int
64
+ assessment: str
65
+ has_front_matter: bool
66
+ looks_like_decision: bool
67
+ duplicate_of: str | None = None
68
+ whykit_ready: bool = False
69
+ decision_id: str | None = None
70
+ format_warnings: list[str] = field(default_factory=list)
71
+
72
+ def destination_for(self, profile: str) -> str:
73
+ if self.assessment != "useful":
74
+ return "—"
75
+ if self.looks_like_decision or profile == "adr-only":
76
+ return "06-decisions/ (assign D-NNN)"
77
+ if profile == "obsidian-loose":
78
+ return "notes/ (normalize front matter)"
79
+ return "notes/ or a workstream (human choice)"
80
+
81
+
82
+ def _looks_like_decision(name: str, text: str) -> bool:
83
+ if ADR_FILENAME_RE.match(name):
84
+ return True
85
+ headings = {m.group(1).lower() for m in ADR_HEADING_RE.finditer(text)}
86
+ return {"context", "decision"}.issubset(headings)
87
+
88
+
89
+ def _whykit_ready(path: Path, source: Path, text: str, note: Note | None = None) -> bool:
90
+ """Check front matter with the same parser and rules as `whykit lint`.
91
+
92
+ This does not certify links, evidence or the containing vault.
93
+ """
94
+ note = note or load_note(path, text=text)
95
+ if not note.has_front:
96
+ return False
97
+ findings = []
98
+ try:
99
+ check_front_matter(source, note, findings, dt.date.today())
100
+ except (TypeError, ValueError):
101
+ # A malformed imported value must not crash a dry-run or score as ready.
102
+ return False
103
+ return not any(f.level == "error" for f in findings)
104
+
105
+
106
+ def _format_warnings(relative: str, text: str) -> list[str]:
107
+ """Point out known ledger-table layouts that `adopt` cannot normalize."""
108
+ name = Path(relative).name
109
+ warnings = []
110
+ if name not in {"evidence-register.md", "decision-log.md"}:
111
+ return warnings
112
+ for line in text.splitlines():
113
+ if not line.lstrip().startswith("|"):
114
+ continue
115
+ cells = _split_table_row(line)
116
+ if name == "evidence-register.md" and cells and EVIDENCE_ID_RE.fullmatch(cells[0]):
117
+ if len(cells) < 7:
118
+ warnings.append(f"{relative}: evidence row has {len(cells)} columns; WhyKit needs 7 (including Accessed and Location)")
119
+ break
120
+ elif name == "decision-log.md" and cells and DECISION_ID_RE.fullmatch(cells[0]):
121
+ if len(cells) < 6 or not WIKILINK_RE.search(cells[5]):
122
+ warnings.append(f"{relative}: decision row has no record wikilink in column 6; manual table mapping is required")
123
+ break
124
+ return warnings
125
+
126
+
127
+ def _has_front_matter(text: str) -> bool:
128
+ # A UTF-8 byte-order mark is not content; the linter skips it too.
129
+ return bool(FRONT_MATTER_RE.match(text.removeprefix("\ufeff")))
130
+
131
+
132
+ HEADING_LINE_RE = re.compile(r"(?m)^\s{0,3}#{1,6}(?:\s.*)?$")
133
+ HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
134
+ LIST_MARKER_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])(?:\s+\[[ xX]?\])?\s*")
135
+ # A line that only says the content is missing. Matched after list markers
136
+ # and emphasis are removed, case-insensitively.
137
+ PLACEHOLDER_LINE_RE = re.compile(
138
+ r"^(?:"
139
+ r"(?:todo|tbd|tba|fixme|xxx|wip)\b.*"
140
+ r"|n/?a|none|nothing yet|empty|placeholder|draft"
141
+ r"|(?:notes\s+)?(?:coming soon|to follow|to be (?:written|added|done|decided))"
142
+ r"|lorem ipsum\b.*"
143
+ r"|\.{2,}|…|-+|_+"
144
+ r")[\s.!:;…]*$",
145
+ re.I,
146
+ )
147
+ # `Date:` or `**Attendees:**` with nothing after it: a template field left blank.
148
+ EMPTY_FIELD_RE = re.compile(r"^[\w][\w ./&()-]{0,40}:$")
149
+ LINK_RE = re.compile(r"\[\[[^\]]+\]\]|\]\([^)]+\)|https?://\S+")
150
+ WORD_RE = re.compile(r"\w{2,}|\d")
151
+ # A short note is real content once it says this much: "Shipped the export."
152
+ # is a note; a lone "Draft" under a heading is not.
153
+ MIN_CONTENT_WORDS = 2
154
+ STUB_MAX_CHARS = 4000
155
+
156
+
157
+ def _content_words(text: str) -> int:
158
+ """Words a person wrote, not counting headings, placeholders and blank fields.
159
+
160
+ This is what separates a stub from a short note. A standup line, a one
161
+ sentence decision or a list of links is short but real; a heading followed
162
+ by ``TODO``, empty bullets or an unfilled template is not.
163
+ """
164
+ if len(text) > STUB_MAX_CHARS:
165
+ # Nobody writes kilobytes of placeholders; this also bounds the regexes.
166
+ return MIN_CONTENT_WORDS
167
+ text = HTML_COMMENT_RE.sub(" ", text)
168
+ words = 0
169
+ lines = HEADING_LINE_RE.sub("", text).splitlines()
170
+ for index, raw in enumerate(lines):
171
+ if words >= MIN_CONTENT_WORDS:
172
+ break
173
+ following = lines[index + 1].strip() if index + 1 < len(lines) else ""
174
+ if following.startswith("|") and set(following) <= set("|-: "):
175
+ continue # a table header: column names are not content
176
+ line = LIST_MARKER_RE.sub("", raw.strip())
177
+ line = line.strip("|").strip()
178
+ if not line or set(line) <= set("|-: "):
179
+ continue
180
+ plain = re.sub(r"[*_`~>]+", "", line).strip()
181
+ if not plain or PLACEHOLDER_LINE_RE.match(plain) or EMPTY_FIELD_RE.match(plain):
182
+ continue
183
+ if LINK_RE.search(line):
184
+ # A bare link is a pointer somebody kept on purpose.
185
+ words += MIN_CONTENT_WORDS
186
+ continue
187
+ words += len(WORD_RE.findall(plain))
188
+ return words
189
+
190
+
191
+ def _assess(text: str, *, profile: str, name: str) -> str:
192
+ stripped = text.strip()
193
+ if not stripped:
194
+ return "empty"
195
+ # Short is not the same as empty: a terse ADR or a note somebody gave front
196
+ # matter is structured work, and a two-line standup note is still a note.
197
+ # Only headings, placeholders and unfilled template fields are stubs.
198
+ structured = _has_front_matter(text) or _looks_like_decision(name, text)
199
+ if not structured and _content_words(text) < MIN_CONTENT_WORDS:
200
+ return "heading-only"
201
+ if profile == "adr-only" and not _looks_like_decision(name, text):
202
+ return "unsupported"
203
+ return "useful"
204
+
205
+
206
+ def _within(root: Path, path: Path) -> bool:
207
+ try:
208
+ path.resolve().relative_to(root.resolve())
209
+ return True
210
+ except (OSError, ValueError):
211
+ return False
212
+
213
+
214
+ def scan(source: Path, *, profile: str = "generic") -> list[Candidate]:
215
+ """Inventory Markdown under ``source`` without following file symlinks outside it."""
216
+ source = source.resolve()
217
+ seen: dict[str, str] = {}
218
+ out: list[Candidate] = []
219
+ # Real files first: a symlink is an alias, so it should be reported as the
220
+ # duplicate of its target, never the other way round.
221
+ for path in sorted(iter_markdown(source), key=lambda p: (p.is_symlink(), p)):
222
+ if not path.is_file() or any(part in SKIP_DIRS for part in path.relative_to(source).parts):
223
+ continue
224
+ if not _within(source, path):
225
+ continue
226
+ try:
227
+ data = path.read_bytes()
228
+ text = data.decode("utf-8")
229
+ except (UnicodeDecodeError, OSError):
230
+ continue
231
+ if "\0" in text:
232
+ # Valid UTF-8 but not text: a binary renamed to .md.
233
+ continue
234
+ digest = hashlib.sha256(data).hexdigest()
235
+ relative = path.relative_to(source).as_posix()
236
+ assessment = _assess(text, profile=profile, name=path.name)
237
+ duplicate_of = None
238
+ if digest in seen:
239
+ assessment, duplicate_of = "duplicate", seen[digest]
240
+ else:
241
+ seen[digest] = relative
242
+ note = load_note(path, text=text)
243
+ declared = str(note.front.get("decision_id") or "").strip() if note.has_front else ""
244
+ out.append(Candidate(
245
+ path=path,
246
+ relative=relative,
247
+ sha256=digest,
248
+ bytes=len(data),
249
+ words=len(text.split()),
250
+ assessment=assessment,
251
+ has_front_matter=_has_front_matter(text),
252
+ looks_like_decision=_looks_like_decision(path.name, text),
253
+ duplicate_of=duplicate_of,
254
+ whykit_ready=_whykit_ready(path, source, text, note),
255
+ decision_id=declared if DECISION_ID_RE.fullmatch(declared) else None,
256
+ format_warnings=_format_warnings(relative, text),
257
+ ))
258
+ out.sort(key=lambda c: c.path)
259
+ return out
260
+
261
+
262
+ def _vault_decision_ids(vault: Path) -> dict[str, str]:
263
+ """Map each decision ID already declared in the vault to its record path."""
264
+ owners: dict[str, str] = {}
265
+ folder = vault / "06-decisions"
266
+ if folder.is_symlink() or not folder.is_dir():
267
+ return owners
268
+ for path in sorted(child for child in folder.iterdir() if is_markdown_name(child.name)):
269
+ if path.is_symlink() or not path.is_file():
270
+ continue
271
+ try:
272
+ note = load_note(path)
273
+ except (OSError, UnicodeDecodeError):
274
+ continue
275
+ declared = str(note.front.get("decision_id") or "").strip()
276
+ if DECISION_ID_RE.fullmatch(declared):
277
+ owners.setdefault(declared, path.relative_to(vault).as_posix())
278
+ return owners
279
+
280
+
281
+ def id_conflicts(candidates: list[Candidate], vault: Path | None = None) -> list[str]:
282
+ """Report decision IDs claimed twice: within the import, or by the vault already.
283
+
284
+ Two ADR folders that both start at D-001 are the normal case, not the edge
285
+ case. Staging both is fine; finding out at lint time after normalizing is not.
286
+ """
287
+ claims: dict[str, list[str]] = {}
288
+ for candidate in candidates:
289
+ if candidate.assessment == "useful" and candidate.decision_id:
290
+ claims.setdefault(candidate.decision_id, []).append(candidate.relative)
291
+ existing = _vault_decision_ids(vault) if vault is not None else {}
292
+ conflicts = []
293
+ for decision_id, paths in sorted(claims.items()):
294
+ listed = ", ".join(f"`{path}`" for path in paths)
295
+ if len(paths) > 1:
296
+ conflicts.append(f"{decision_id} is declared by {len(paths)} source files: {listed}")
297
+ if decision_id in existing:
298
+ conflicts.append(f"{decision_id} ({listed}) is already used in the vault by `{existing[decision_id]}`")
299
+ return conflicts
300
+
301
+
302
+ def score_adoption(candidates: list[Candidate], vault: Path | None = None) -> dict:
303
+ total = len(candidates)
304
+ useful = [c for c in candidates if c.assessment == "useful"]
305
+ ready = [c for c in useful if c.whykit_ready]
306
+ decisions = [c for c in useful if c.looks_like_decision]
307
+ needs_front = [c for c in useful if not c.has_front_matter]
308
+ minutes = (
309
+ len(useful) * 3
310
+ + len(needs_front) * 5
311
+ + len(decisions) * 8
312
+ + sum(1 for c in candidates if c.assessment in {"heading-only", "unsupported"}) * 1
313
+ )
314
+ return {
315
+ "files": total,
316
+ "useful": len(useful),
317
+ "whykit_ready": len(ready),
318
+ "need_front_matter": len(needs_front),
319
+ "looks_like_decision": len(decisions),
320
+ "duplicates": sum(1 for c in candidates if c.assessment == "duplicate"),
321
+ "empty_or_stub": sum(1 for c in candidates if c.assessment in {"empty", "heading-only"}),
322
+ "unsupported": sum(1 for c in candidates if c.assessment == "unsupported"),
323
+ "estimated_minutes_to_first_green_lint": minutes,
324
+ "readiness_pct": round(100.0 * len(ready) / total, 1) if total else 0.0,
325
+ "format_warnings": [warning for c in candidates for warning in c.format_warnings],
326
+ "id_conflicts": id_conflicts(candidates, vault),
327
+ }
328
+
329
+
330
+ def compare_migration(source: Path, target: Path, candidates: list[Candidate]) -> dict:
331
+ """Compare same-path Markdown snapshots without rewriting either tree."""
332
+ files = []
333
+ scanned = {candidate.relative: candidate for candidate in candidates}
334
+ names = set(scanned)
335
+
336
+ def unreadable(exc: OSError) -> None:
337
+ names.add(Path(exc.filename or source).relative_to(source).as_posix())
338
+
339
+ for directory, folders, filenames in os.walk(source, followlinks=False, onerror=unreadable):
340
+ folder = Path(directory)
341
+ folders[:] = [name for name in folders if name not in SKIP_DIRS]
342
+ for name in folders[:]:
343
+ if (folder / name).is_symlink():
344
+ names.add((folder / name).relative_to(source).as_posix())
345
+ folders.remove(name)
346
+ names.update((folder / name).relative_to(source).as_posix() for name in filenames if is_markdown_name(name))
347
+ for relative in sorted(names):
348
+ path = source / relative
349
+ candidate = scanned.get(relative)
350
+ entry: dict = {"path": relative, "source_sha256": candidate.sha256 if candidate else None,
351
+ "target_sha256": None, "unchanged": False, "missing_wikilinks": [],
352
+ "missing_ids": [], "review_history": "not_checked", "issues": []}
353
+ try:
354
+ if candidate is None or not _within(source, path):
355
+ entry["issues"].append("unreadable_source")
356
+ files.append(entry)
357
+ continue
358
+ before = path.read_bytes()
359
+ if hashlib.sha256(before).hexdigest() != candidate.sha256:
360
+ entry["issues"].append("source_changed")
361
+ files.append(entry)
362
+ continue
363
+ destination = target / relative
364
+ if not _within(target, destination):
365
+ entry["issues"].append("unsafe_target")
366
+ files.append(entry)
367
+ continue
368
+ if not destination.is_file():
369
+ entry["issues"].append("missing_file")
370
+ files.append(entry)
371
+ continue
372
+ after = destination.read_bytes()
373
+ if b"\0" in after:
374
+ raise ValueError("binary target")
375
+ entry["target_sha256"] = hashlib.sha256(after).hexdigest()
376
+ entry["unchanged"] = before == after
377
+ old = load_note(path, text=before.decode("utf-8"))
378
+ new = load_note(destination, text=after.decode("utf-8"))
379
+ if old.front_error or new.front_error:
380
+ raise ValueError("unreadable front matter")
381
+ old_floor, new_floor = note_sensitivity_level(old), note_sensitivity_level(new)
382
+ lowered = new_floor < old_floor
383
+ if path.name.casefold() == "evidence-register.md":
384
+ old_active, old_retired, _, _ = classify_evidence_register(old)
385
+ new_active, new_retired, _, _ = classify_evidence_register(new)
386
+ new_rows = {**new_active, **new_retired}
387
+ for identifier, row in {**old_active, **old_retired}.items():
388
+ replacement = new_rows.get(identifier)
389
+ if replacement is None:
390
+ if "evidence_rows_lost" not in entry["issues"]:
391
+ entry["issues"].append("evidence_rows_lost")
392
+ continue
393
+ before_level = SENSITIVITY_LEVEL.get(row["sensitivity"], UNREADABLE_LEVEL) if "sensitivity" in row else old_floor
394
+ after_level = SENSITIVITY_LEVEL.get(replacement["sensitivity"], UNREADABLE_LEVEL) if "sensitivity" in replacement else new_floor
395
+ lowered = lowered or max(new_floor, after_level) < max(old_floor, before_level)
396
+ if lowered:
397
+ entry["issues"].append("sensitivity_lowered")
398
+ old_links = Counter(unicodedata.normalize("NFC", m.group(0).replace("\\|", "|")) for m in WIKILINK_RE.finditer(old.masked))
399
+ new_links = Counter(unicodedata.normalize("NFC", m.group(0).replace("\\|", "|")) for m in WIKILINK_RE.finditer(new.masked))
400
+ entry["missing_wikilinks"] = sorted((old_links - new_links).elements())
401
+ old_ids = set(EVIDENCE_ID_RE.findall(old.masked) + DECISION_ID_RE.findall(old.masked))
402
+ new_ids = set(EVIDENCE_ID_RE.findall(new.masked) + DECISION_ID_RE.findall(new.masked))
403
+ entry["missing_ids"] = sorted(old_ids - new_ids)
404
+ if entry["missing_wikilinks"]:
405
+ entry["issues"].append("wikilinks_lost")
406
+ if entry["missing_ids"]:
407
+ entry["issues"].append("ids_lost")
408
+ declared = old.front.get("decision_id")
409
+ if isinstance(declared, str) and DECISION_ID_RE.fullmatch(declared) and new.front.get("decision_id") != declared:
410
+ entry["issues"].append("decision_id_changed")
411
+ sources = old.front.get("source_ids")
412
+ if isinstance(sources, list) and all(isinstance(item, str) for item in sources):
413
+ replacement = new.front.get("source_ids")
414
+ if sources and (not isinstance(replacement, list) or not all(isinstance(item, str) for item in replacement)
415
+ or not set(sources).issubset(replacement)):
416
+ entry["issues"].append("source_ids_lost")
417
+ entry["review_history"] = "not_present"
418
+ if path.name.casefold() == "review-log.md":
419
+ old_log, new_log = _review_log_parts(old.text), _review_log_parts(new.text)
420
+ if before == after:
421
+ entry["review_history"] = "unchanged"
422
+ elif old_log is None or new_log is None:
423
+ entry["review_history"] = "unrecognized"
424
+ entry["issues"].append("history_unrecognized")
425
+ elif new_log[1][:len(old_log[1])] != old_log[1]:
426
+ entry["review_history"] = "changed"
427
+ entry["issues"].append("history_changed")
428
+ else:
429
+ entry["review_history"] = "preserved"
430
+ except (OSError, UnicodeError, ValueError):
431
+ entry["issues"].append("unreadable_file")
432
+ files.append(entry)
433
+ return {"target": str(target), "passed": bool(files) and not any(f["issues"] for f in files),
434
+ "files": files, "scope": "same-relative-path Markdown: links, IDs, declarations, sensitivity floors and review rows; prose meaning, code examples, attachments and Git history are not checked"}
435
+
436
+
437
+
438
+ def _migration_md(source: Path, candidates: list[Candidate], score: dict, profile: str) -> str:
439
+ lines = [
440
+ f"# Migration from `{source}`",
441
+ "",
442
+ f"Profile: `{profile}`. Generated by `whykit adopt`.",
443
+ "",
444
+ "## Score",
445
+ "",
446
+ f"- Files scanned: **{score['files']}**",
447
+ f"- Useful: **{score['useful']}** (front matter passes WhyKit checks: **{score['whykit_ready']}**)",
448
+ f"- Estimated human minutes to first green lint: **{score['estimated_minutes_to_first_green_lint']}**",
449
+ f"- Readiness: **{score['readiness_pct']}%**",
450
+ "- Scope: UTF-8 `*.md` only. Other assets need separate review; this is not a full vault lint.",
451
+ "",
452
+ "## Format warnings",
453
+ "",
454
+ *(f"- {warning}" for warning in score["format_warnings"]),
455
+ *(["- None detected."] if not score["format_warnings"] else []),
456
+ "",
457
+ "## ID conflicts",
458
+ "",
459
+ *(f"- {conflict}" for conflict in score["id_conflicts"]),
460
+ *(["- None detected."] if not score["id_conflicts"] else []),
461
+ "",
462
+ "## Bring into the vault",
463
+ "",
464
+ ]
465
+ for candidate in candidates:
466
+ if candidate.assessment != "useful":
467
+ continue
468
+ lines.append(f"- `{candidate.relative}` → {candidate.destination_for(profile)}")
469
+ lines.extend(["", "## Leave outside / discard", ""])
470
+ for candidate in candidates:
471
+ if candidate.assessment == "useful":
472
+ continue
473
+ reason = candidate.assessment
474
+ if candidate.duplicate_of:
475
+ reason += f" (same bytes as `{candidate.duplicate_of}`)"
476
+ lines.append(f"- `{candidate.relative}` — {reason}")
477
+ lines.extend([
478
+ "",
479
+ "## Suggested next steps",
480
+ "",
481
+ "1. Review staged files under `.import-staging/`.",
482
+ "2. Normalize front matter for notes that are not WhyKit-ready.",
483
+ "3. Allocate `D-NNN` / `E-NNN` where claims must stay durable.",
484
+ "4. Link durable notes from `Home.md`, then `whykit lint --strict`.",
485
+ "",
486
+ ])
487
+ return "\n".join(lines)
488
+
489
+
490
+ def _ingestion_record(
491
+ batch: str,
492
+ today: dt.date,
493
+ source: Path,
494
+ candidates: list[Candidate],
495
+ owner: str,
496
+ profile: str,
497
+ ) -> str:
498
+ rows = "\n".join(
499
+ f"| `{c.relative}` | `{c.sha256}` | {c.assessment} | | {c.destination_for(profile)} |"
500
+ for c in candidates
501
+ ) or "| | | | | |"
502
+ useful = sum(1 for c in candidates if c.assessment == "useful")
503
+ decisions = sum(1 for c in candidates if c.assessment == "useful" and c.looks_like_decision)
504
+ # This record is committed with the vault, so it names the source directory
505
+ # only; the absolute path would publish a local home directory.
506
+ return f"""---
507
+ title: "Source ingestion batch {batch}"
508
+ aliases: []
509
+ type: research
510
+ status: draft
511
+ owner: "{owner}"
512
+ created: {today.isoformat()}
513
+ last_updated: {today.isoformat()}
514
+ source_of_truth: false
515
+ sensitivity: internal
516
+ source_ids: []
517
+ tags: ["ingestion", "adoption"]
518
+ ---
519
+
520
+ # Source ingestion batch {batch}
521
+
522
+ ## Purpose
523
+
524
+ Record how one batch of existing material was assessed on import. Generated by
525
+ `whykit adopt --profile {profile}` from `{source.name or source}` on {today.isoformat()}.
526
+ Nothing here is canonical until a person normalizes it and changes its status.
527
+
528
+ ## Results
529
+
530
+ | Uploaded file | SHA-256 | Assessment | Evidence ID | Destination in repository |
531
+ |---|---|---|---|---|
532
+ {rows}
533
+
534
+ Scanned {len(candidates)} file(s): {useful} worth keeping, of which {decisions} look
535
+ like existing decision records.
536
+
537
+ ## Normalization performed
538
+
539
+ - Not yet assessed.
540
+
541
+ ## Review required
542
+
543
+ - Assign evidence IDs to sources that support a claim anywhere in the vault.
544
+ - Decide a sensitivity label per file before anything leaves `.import-staging/`.
545
+ """
546
+
547
+
548
+ def _ingestion_dir(vault: Path) -> Path:
549
+ legacy_parent = vault / "07-research"
550
+ if legacy_parent.is_dir() or (vault / "07-research" / "sources").is_dir():
551
+ return safe_vault_dir(vault, "07-research/sources")
552
+ return safe_vault_dir(vault, "notes")
553
+
554
+
555
+ def adopt(
556
+ source: Path,
557
+ vault: Path,
558
+ *,
559
+ write: bool,
560
+ owner: str,
561
+ profile: str = "generic",
562
+ today: dt.date | None = None,
563
+ ) -> tuple[list[Candidate], Path | None, Path | None, dict]:
564
+ today = today or dt.date.today()
565
+ candidates = scan(source, profile=profile)
566
+ score = score_adoption(candidates, vault)
567
+ if not write:
568
+ return candidates, None, None, score
569
+
570
+ with vault_mutation_lock(vault):
571
+ staging_root = safe_vault_dir(vault, ".import-staging")
572
+ batch = today.isoformat()
573
+ suffix = 1
574
+ while (staging_root / batch).exists() or (staging_root / batch).is_symlink():
575
+ suffix += 1
576
+ batch = f"{today.isoformat()}-{suffix}"
577
+ # Stage into a hidden directory and rename it into place only once it
578
+ # is complete. A failure removes everything this run created, and a
579
+ # crash leaves a `.partial-*` directory that no later run mistakes for
580
+ # a finished batch.
581
+ partial_rel = Path(".import-staging") / f".partial-{secrets.token_hex(6)}"
582
+ partial = safe_vault_dir(vault, partial_rel)
583
+ final = staging_root / batch
584
+ created = partial
585
+ try:
586
+ for candidate in candidates:
587
+ if candidate.assessment != "useful":
588
+ continue
589
+ target = safe_vault_target(vault, partial_rel / candidate.relative)
590
+ data = candidate.path.read_bytes()
591
+ current_digest = hashlib.sha256(data).hexdigest()
592
+ if current_digest != candidate.sha256:
593
+ raise RuntimeError(
594
+ f"source changed during adoption: {candidate.relative} "
595
+ f"({candidate.sha256[:12]} -> {current_digest[:12]})"
596
+ )
597
+ atomic_write_bytes(target, data)
598
+
599
+ migration_name = "MIGRATION.md"
600
+ suffix = 1
601
+ while (partial / migration_name).exists() or (partial / migration_name).is_symlink():
602
+ suffix += 1
603
+ migration_name = f"MIGRATION-{suffix}.md"
604
+ atomic_write_text(
605
+ safe_vault_target(vault, partial_rel / migration_name),
606
+ _migration_md(source, candidates, score, profile),
607
+ )
608
+ os.rename(partial, final)
609
+ created = final
610
+
611
+ sources_dir = _ingestion_dir(vault)
612
+ record_name = f"ingestion-{batch}.md"
613
+ record = sources_dir / record_name
614
+ suffix = 1
615
+ while record.exists() or record.is_symlink():
616
+ suffix += 1
617
+ record_name = f"ingestion-{batch}-{suffix}.md"
618
+ record = sources_dir / record_name
619
+ record = safe_vault_target(vault, record.relative_to(vault.resolve()))
620
+ atomic_write_text(record, _ingestion_record(batch, today, source, candidates, owner, profile))
621
+ except BaseException:
622
+ shutil.rmtree(created, ignore_errors=True)
623
+ raise
624
+ return candidates, record, final / migration_name, score
625
+
626
+
627
+ def _cells(text: str) -> int:
628
+ """Terminal columns *text* occupies once printed.
629
+
630
+ Wide and fullwidth characters take two; a console that cannot encode them
631
+ prints escapes instead, so measure the text in the form it is printed.
632
+ """
633
+ return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in as_printed(text))
634
+
635
+
636
+ def main(argv: list[str] | None = None) -> int:
637
+ parser = argparse.ArgumentParser(
638
+ prog="whykit adopt",
639
+ description="Inventory existing Markdown and stage it for a WhyKit vault.",
640
+ )
641
+ parser.add_argument("source", help="directory of existing Markdown to adopt")
642
+ parser.add_argument("--into", help="target vault (default: nearest vault at or above the working directory)")
643
+ parser.add_argument("--write", action="store_true", help="stage files and write an ingestion record")
644
+ parser.add_argument("--owner", default="TODO", help="owner recorded on the ingestion record")
645
+ parser.add_argument("--profile", choices=ADOPT_PROFILES, default="generic", help="adoption mapping profile")
646
+ parser.add_argument("--compare", metavar="DIR", help="read-only preservation check against files at the same relative paths")
647
+ parser.add_argument("--json", action="store_true", help="machine-readable inventory + score (implies dry-run shape)")
648
+ args = parser.parse_args(argv)
649
+ if args.compare is not None and (args.write or not args.compare):
650
+ return emit_error("usage", "--compare requires a directory and cannot be combined with --write", json_mode=args.json)
651
+
652
+ source = Path(args.source).expanduser().resolve()
653
+ if not source.is_dir():
654
+ return emit_error("invalid_target", f"not a directory: {source}", json_mode=args.json)
655
+
656
+ vault = Path(args.into).expanduser().resolve() if args.into else find_vault_root()
657
+ if vault is None or not is_vault_root(vault):
658
+ return emit_error(
659
+ "vault_not_found",
660
+ "no WhyKit vault found — run `whykit init <dir>` first, or pass --into",
661
+ json_mode=args.json,
662
+ )
663
+ if not args.compare and (source == vault or vault in source.parents or source in vault.parents):
664
+ return emit_error("invalid_target", "refusing to adopt overlapping source/vault directories", json_mode=args.json)
665
+ compare = Path(args.compare).expanduser().resolve() if args.compare else None
666
+ if compare is not None and (not compare.is_dir() or source.samefile(compare)):
667
+ return emit_error("invalid_target", "--compare must name a different existing directory", json_mode=args.json)
668
+
669
+ try:
670
+ candidates, record, migration, score = adopt(
671
+ source, vault, write=args.write, owner=args.owner, profile=args.profile,
672
+ )
673
+ except (OSError, RuntimeError) as exc:
674
+ return emit_error("io_error", f"adoption failed: {exc}", json_mode=args.json)
675
+ preservation = compare_migration(source, compare, candidates) if compare is not None else None
676
+
677
+ visible_files = [
678
+ path for path in source.rglob("*")
679
+ if path.is_file() and _within(source, path)
680
+ and not any(part in SKIP_DIRS for part in path.relative_to(source).parts)
681
+ ]
682
+ omitted = sorted(path.relative_to(source).as_posix() for path in visible_files if not is_markdown_name(path.name))
683
+ scanned = {candidate.relative for candidate in candidates}
684
+ unreadable_markdown = sorted(
685
+ path.relative_to(source).as_posix()
686
+ for path in visible_files if is_markdown_name(path.name)
687
+ and path.relative_to(source).as_posix() not in scanned
688
+ )
689
+ scope_note = (
690
+ "Only UTF-8 *.md files were scanned; "
691
+ f"{len(omitted)} other file(s) excluded"
692
+ + (f" (e.g. {', '.join(omitted[:5])})" if omitted else "")
693
+ + f"; {len(unreadable_markdown)} Markdown file(s) unreadable, binary or non-UTF-8"
694
+ + (f" (e.g. {', '.join(unreadable_markdown[:5])})" if unreadable_markdown else "")
695
+ + ". Front matter readiness is not full vault lint."
696
+ )
697
+
698
+ if args.json:
699
+ payload = {
700
+ "contract_version": 1,
701
+ "profile": args.profile,
702
+ "source": str(source),
703
+ "vault": str(vault),
704
+ "write": bool(args.write),
705
+ "score": score,
706
+ "scope_note": scope_note,
707
+ "candidates": [
708
+ {
709
+ "path": c.relative,
710
+ "assessment": c.assessment,
711
+ "destination": c.destination_for(args.profile),
712
+ "has_front_matter": c.has_front_matter,
713
+ "whykit_ready": c.whykit_ready,
714
+ "looks_like_decision": c.looks_like_decision,
715
+ "duplicate_of": c.duplicate_of,
716
+ "bytes": c.bytes,
717
+ "words": c.words,
718
+ "sha256": c.sha256,
719
+ }
720
+ for c in candidates
721
+ ],
722
+ "ingestion_record": record.relative_to(vault).as_posix() if record else None,
723
+ "migration": migration.relative_to(vault).as_posix() if migration else None,
724
+ }
725
+ if preservation is not None:
726
+ payload["preservation"] = preservation
727
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
728
+ return int(preservation is not None and not preservation["passed"])
729
+
730
+ if preservation is not None:
731
+ print(f"Preservation: {'PASS' if preservation['passed'] else 'FAIL'}")
732
+ print(preservation["scope"])
733
+ for item in preservation["files"]:
734
+ if item["issues"]:
735
+ print(f" {item['path']}: {', '.join(item['issues'])}")
736
+ return int(not preservation["passed"])
737
+
738
+ if not candidates:
739
+ print(f"no Markdown found under {source}")
740
+ print(scope_note)
741
+ return 0
742
+
743
+ counts = {a: sum(1 for c in candidates if c.assessment == a) for a in ASSESSMENTS}
744
+ decisions = [c for c in candidates if c.assessment == "useful" and c.looks_like_decision]
745
+ # Cap the column: one deeply nested file should not push every row off screen.
746
+ # Pad by terminal cells, not code points: East Asian wide characters take two.
747
+ width = min(max(_cells(c.relative) for c in candidates), 60)
748
+ for candidate in candidates:
749
+ marker = "decision?" if candidate.looks_like_decision and candidate.assessment == "useful" else ""
750
+ note = f" (same bytes as {candidate.duplicate_of})" if candidate.duplicate_of else ""
751
+ padding = " " * max(width - _cells(candidate.relative), 0)
752
+ print(f" {candidate.relative}{padding} {candidate.assessment:<12}{marker}{note}")
753
+
754
+ print(f"\n{len(candidates)} file(s): " + ", ".join(f"{v} {k}" for k, v in counts.items() if v))
755
+ print(
756
+ f"Score: {score['readiness_pct']}% front-matter-ready only; "
757
+ f"~{score['estimated_minutes_to_first_green_lint']} heuristic minutes to first green lint"
758
+ )
759
+ print(scope_note)
760
+ for warning in score["format_warnings"]:
761
+ print(f"Format warning: {warning}")
762
+ for conflict in score["id_conflicts"]:
763
+ print(f"ID conflict: {conflict}")
764
+ if decisions:
765
+ print(f"{len(decisions)} look like existing decision records — they need D-NNN IDs and log rows.")
766
+
767
+ if not args.write:
768
+ print("\nDry run. Nothing was written. Re-run with --write to stage these files")
769
+ print(f"under {vault}/.import-staging/ and create an ingestion record + MIGRATION.md.")
770
+ return 0
771
+
772
+ print(f"\nStaged under {vault / '.import-staging'}{os.sep}")
773
+ print(f"Ingestion record: {record.relative_to(vault).as_posix() if record else '—'}")
774
+ print(f"Migration guide: {migration.relative_to(vault).as_posix() if migration else '—'}")
775
+ print("Staging is gitignored on purpose. Normalize into a workstream before committing.")
776
+ return 0