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/impact.py ADDED
@@ -0,0 +1,272 @@
1
+ """Explain the reverse-dependency/blast radius of a WhyKit record."""
2
+ from __future__ import annotations
3
+
4
+ from .io import consistent_read
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from .contract import vault_not_found
12
+ from .graph import build_graph, document_node, wikilink_resolutions
13
+ from .lint import (
14
+ path_cache,
15
+ strip_markdown_suffix,
16
+ DECISION_ID_RE,
17
+ EVIDENCE_ID_RE,
18
+ evidence_register,
19
+ find_vault_root,
20
+ is_vault_root,
21
+ rel,
22
+ )
23
+ from .vault_index import VaultIndex
24
+ from .console import emit_machine
25
+
26
+
27
+ def _note_summary(root: Path, note) -> dict:
28
+ return {
29
+ "path": rel(root, note.path),
30
+ "title": str(note.front.get("title") or note.path.stem),
31
+ "type": str(note.front.get("type") or ""),
32
+ "status": str(note.front.get("status") or ""),
33
+ "owner": str(note.front.get("owner") or ""),
34
+ "sensitivity": str(note.front.get("sensitivity") or "internal"),
35
+ "source_of_truth": note.front.get("source_of_truth") is True,
36
+ }
37
+
38
+
39
+ def _evidence_ids(note) -> list[str]:
40
+ return list(note.cited_evidence)
41
+
42
+
43
+ def _decision_id(note) -> str | None:
44
+ value = str(note.front.get("decision_id") or "").strip()
45
+ return value if DECISION_ID_RE.fullmatch(value) else None
46
+
47
+
48
+ class _WikilinkView:
49
+ """Wikilink adjacency between documents, with graph nodes built on demand.
50
+
51
+ Equivalent to the ``wikilink`` edges and the node map of :func:`build_graph`,
52
+ without materialising every node and edge dict of the whole vault (at
53
+ 20,000 notes that graph is over a hundred megabytes, while one record needs
54
+ only its neighbours).
55
+ """
56
+
57
+ __slots__ = ("notes_by_id", "incoming", "outgoing", "nodes")
58
+
59
+ def __init__(self, root: Path, vault_index: VaultIndex) -> None:
60
+ ids: dict[Path, str] = {}
61
+
62
+ def node_id(path: Path) -> str:
63
+ value = ids.get(path)
64
+ if value is None:
65
+ value = ids[path] = strip_markdown_suffix(vault_index.relative(path))
66
+ return value
67
+
68
+ self.notes_by_id: dict[str, object] = {}
69
+ self.incoming: dict[str, set[str]] = {}
70
+ self.outgoing: dict[str, set[str]] = {}
71
+ for note in vault_index.notes:
72
+ source = node_id(note.path)
73
+ self.notes_by_id[source] = note
74
+ for _, resolved, ambiguous in wikilink_resolutions(note, vault_index):
75
+ if resolved is None or ambiguous:
76
+ continue
77
+ destination = node_id(resolved)
78
+ self.incoming.setdefault(destination, set()).add(source)
79
+ self.outgoing.setdefault(source, set()).add(destination)
80
+ # The full graph also holds `evidence:E-NNN` nodes, which win over a
81
+ # document whose path happens to spell the same id. Only then is the
82
+ # full node map needed to answer exactly as the graph does.
83
+ self.nodes: dict[str, dict] | None = None
84
+ if any(value.startswith("evidence:") for value in self.notes_by_id):
85
+ graph = build_graph(root, vault=vault_index)
86
+ self.nodes = {item["id"]: item for item in graph["nodes"]}
87
+
88
+ def node(self, value: str) -> dict | None:
89
+ if self.nodes is not None:
90
+ return self.nodes.get(value)
91
+ note = self.notes_by_id.get(value)
92
+ return None if note is None else document_node(note, value)
93
+
94
+ def neighbours(self, ids: list[str]) -> list[dict]:
95
+ out = []
96
+ for value in ids:
97
+ node = self.node(value)
98
+ if node is not None:
99
+ out.append(node)
100
+ return out
101
+
102
+
103
+ def _wikilink_view(root: Path, vault_index: VaultIndex) -> _WikilinkView:
104
+ """The wikilink view of *vault_index*, built once per vault parse.
105
+
106
+ The cache key carries the evidence register's stat signature, because the
107
+ rare full-graph fallback reads the register: a register rewritten under a
108
+ reused index is re-read.
109
+ """
110
+ register = root / "00-context" / "evidence-register.md"
111
+ try:
112
+ stat = register.stat()
113
+ stamp: tuple[int, int] | None = (stat.st_mtime_ns, stat.st_size)
114
+ except OSError:
115
+ stamp = None
116
+ key = ("impact.wikilinks", os.fspath(root), stamp)
117
+ cached = vault_index.derived.get(key)
118
+ if cached is None:
119
+ cached = vault_index.derived[key] = _WikilinkView(root, vault_index)
120
+ return cached
121
+
122
+
123
+ @consistent_read
124
+ @path_cache()
125
+ def analyze_impact(root: Path, target: str, *, vault: VaultIndex | None = None) -> dict:
126
+ vault_index = vault or VaultIndex.load(root)
127
+ notes = vault_index.notes
128
+ active, retired, _ = evidence_register(root)
129
+
130
+ if EVIDENCE_ID_RE.fullmatch(target):
131
+ row = active.get(target) or retired.get(target)
132
+ references = [
133
+ _note_summary(root, note)
134
+ for note in notes
135
+ if target in note.cited_evidence
136
+ and vault_index.relative(note.path) != "00-context/evidence-register.md"
137
+ ]
138
+ references.sort(key=lambda item: item["path"])
139
+ replacement = row.get("replaced_by", "") if row and target in retired else ""
140
+ return {
141
+ "contract_version": 1,
142
+ "target": target,
143
+ "kind": "evidence",
144
+ "exists": row is not None,
145
+ "state": "active" if target in active else "retired" if target in retired else "missing",
146
+ "record": row,
147
+ "replacement": replacement or None,
148
+ "references": references,
149
+ "reference_count": len(references),
150
+ }
151
+
152
+ if DECISION_ID_RE.fullmatch(target):
153
+ matches = [note for note in notes if _decision_id(note) == target]
154
+ note = matches[0] if len(matches) == 1 else None
155
+ if note is None:
156
+ return {
157
+ "contract_version": 1,
158
+ "target": target,
159
+ "kind": "decision",
160
+ "exists": False,
161
+ "ambiguous": len(matches) > 1,
162
+ "references": [],
163
+ "reference_count": 0,
164
+ }
165
+ node_id = strip_markdown_suffix(vault_index.relative(note.path))
166
+ view = _wikilink_view(root, vault_index)
167
+ incoming = view.neighbours(sorted(view.incoming.get(node_id, ())))
168
+ outgoing = view.neighbours(sorted(view.outgoing.get(node_id, ())))
169
+ superseded_by = sorted(
170
+ [_note_summary(root, item) | {"decision_id": _decision_id(item)} for item in notes if str(item.front.get("supersedes") or "").strip() == target],
171
+ key=lambda item: item["path"],
172
+ )
173
+ evidence = _evidence_ids(note)
174
+ return {
175
+ "contract_version": 1,
176
+ "target": target,
177
+ "kind": "decision",
178
+ "exists": True,
179
+ "record": _note_summary(root, note),
180
+ "supersedes": str(note.front.get("supersedes") or "").strip() or None,
181
+ "superseded_by": superseded_by,
182
+ "evidence": evidence,
183
+ "incoming": incoming,
184
+ "outgoing": outgoing,
185
+ "reference_count": len(incoming),
186
+ }
187
+
188
+ resolved, ambiguous = vault_index.resolve_link(target)
189
+ if resolved is None:
190
+ return {
191
+ "contract_version": 1,
192
+ "target": target,
193
+ "kind": "document",
194
+ "exists": False,
195
+ "ambiguous": ambiguous,
196
+ "incoming": [],
197
+ "outgoing": [],
198
+ "reference_count": 0,
199
+ }
200
+ note = vault_index.note_for(resolved)
201
+ node_id = strip_markdown_suffix(vault_index.relative(resolved))
202
+ view = _wikilink_view(root, vault_index)
203
+ incoming_ids = sorted(view.incoming.get(node_id, ()))
204
+ outgoing_ids = sorted(view.outgoing.get(node_id, ()))
205
+ return {
206
+ "contract_version": 1,
207
+ "target": target,
208
+ "kind": "document",
209
+ "exists": True,
210
+ "record": _note_summary(root, note) if note else {"path": rel(root, resolved)},
211
+ "evidence": _evidence_ids(note) if note else [],
212
+ "incoming": view.neighbours(incoming_ids),
213
+ "outgoing": view.neighbours(outgoing_ids),
214
+ "reference_count": len(incoming_ids),
215
+ }
216
+
217
+
218
+ def _human(report: dict) -> None:
219
+ target = report["target"]
220
+ if not report.get("exists"):
221
+ suffix = " (ambiguous)" if report.get("ambiguous") else ""
222
+ print(f"{target}: not found{suffix}")
223
+ return
224
+ print(f"Impact for {target} [{report['kind']}]")
225
+ if report["kind"] == "evidence":
226
+ print(f" state {report['state']}")
227
+ print(f" references {report['reference_count']}")
228
+ if report.get("replacement"):
229
+ print(f" replacement {report['replacement']}")
230
+ for item in report["references"][:30]:
231
+ print(f" {item['path']} ({item['status'] or 'no status'}, {item['owner'] or 'no owner'})")
232
+ return
233
+ record = report.get("record") or {}
234
+ print(f" document {record.get('path', '—')}")
235
+ print(f" backlinks {report.get('reference_count', 0)}")
236
+ evidence = report.get("evidence") or []
237
+ if evidence:
238
+ print(f" evidence {', '.join(evidence)}")
239
+ if report.get("supersedes"):
240
+ print(f" supersedes {report['supersedes']}")
241
+ if report.get("superseded_by"):
242
+ print(" superseded by")
243
+ for item in report["superseded_by"]:
244
+ print(f" {item.get('decision_id') or '—'} {item['path']}")
245
+ if report.get("incoming"):
246
+ print(" referenced by")
247
+ for item in report["incoming"][:30]:
248
+ print(f" {item['id']} ({item.get('status') or 'no status'})")
249
+ if report.get("outgoing"):
250
+ print(" links to")
251
+ for item in report["outgoing"][:30]:
252
+ print(f" {item['id']} ({item.get('status') or 'no status'})")
253
+
254
+
255
+ def main(argv: list[str] | None = None) -> int:
256
+ parser = argparse.ArgumentParser(
257
+ prog="whykit impact",
258
+ description="Show what depends on an evidence ID, decision ID or vault document before you change it.",
259
+ )
260
+ parser.add_argument("target", help="E-NNN, D-NNN, path, stem or alias")
261
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
262
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
263
+ args = parser.parse_args(argv)
264
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
265
+ if root is None or not is_vault_root(root):
266
+ return vault_not_found(args.root, json_mode=args.json)
267
+ report = analyze_impact(root, args.target)
268
+ if args.json:
269
+ emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
270
+ else:
271
+ _human(report)
272
+ return 0 if report.get("exists") else 1
whykit/init_layout.py ADDED
@@ -0,0 +1,205 @@
1
+ """Vault layout helpers for `whykit init`."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import re
6
+ from pathlib import Path
7
+
8
+ # Optional starter workstreams from the full template. Minimal layout drops them.
9
+ OPTIONAL_WORKSTREAMS = (
10
+ "01-strategy",
11
+ "02-discoverability",
12
+ "03-website",
13
+ "04-automation",
14
+ "05-operations",
15
+ "07-research",
16
+ )
17
+
18
+ OPTIONAL_TEMPLATES = (
19
+ "automation-spec-template.md",
20
+ "page-brief-template.md",
21
+ )
22
+
23
+ MINIMAL_HOME = """---
24
+ title: Home
25
+ aliases:
26
+ - Map of content
27
+ type: map-of-content
28
+ status: approved
29
+ owner: TODO
30
+ created: {today}
31
+ last_updated: {today}
32
+ source_of_truth: false
33
+ sensitivity: public
34
+ tags:
35
+ - map-of-content
36
+ ---
37
+
38
+ # Home
39
+
40
+ Map of content for this WhyKit vault. Keep it current — every session starts here.
41
+
42
+ ## Read first
43
+
44
+ - [[README|What this vault is]]
45
+ - [[AGENTS|Working rules for agents and people]]
46
+ - [[OBSIDIAN|Vault conventions]]
47
+ - [[INTEROP|Working across repositories]]
48
+
49
+ ## Foundations
50
+
51
+ - [[00-context/company|Organization context]]
52
+ - [[00-context/goals|Goals]]
53
+ - [[00-context/terminology|Terminology]]
54
+ - [[00-context/evidence-register|Evidence register]]
55
+ - [[00-context/review-log|Review log]]
56
+
57
+ ## Working notes
58
+
59
+ - [[notes/README|Freeform notes]]
60
+
61
+ ## Decisions
62
+
63
+ - [[06-decisions/decision-log|Decision log]]
64
+
65
+ ## Open threads
66
+
67
+ | Waiting on | What exactly | What it unblocks |
68
+ |---|---|---|
69
+ | | | |
70
+ """
71
+
72
+ MINIMAL_README = """---
73
+ title: Company knowledge vault
74
+ aliases: []
75
+ type: guide
76
+ status: approved
77
+ owner: TODO
78
+ created: {today}
79
+ last_updated: {today}
80
+ source_of_truth: false
81
+ sensitivity: public
82
+ tags: []
83
+ ---
84
+
85
+ # Company knowledge vault
86
+
87
+ An [Obsidian](https://obsidian.md)-compatible Markdown vault checked by
88
+ [WhyKit](https://github.com/CometWeb-io/whykit): evidence, decisions, and review
89
+ dates in Git — not a second database.
90
+
91
+ ## Layout
92
+
93
+ | Path | Role |
94
+ |---|---|
95
+ | `Home.md` | Map of content |
96
+ | `AGENTS.md` | Contract for people and agents |
97
+ | `00-context/` | Shared context and the evidence register |
98
+ | `06-decisions/` | Decision records and the decision log |
99
+ | `notes/` | Freeform working notes |
100
+ | `templates/` | Starters for new records |
101
+ | `whykit.toml` | Repository-local lint/CI policy |
102
+
103
+ Created with `whykit init` (the vendor-neutral default layout).
104
+ """
105
+
106
+ MINIMAL_NOTES_README = """---
107
+ title: Notes
108
+ aliases: []
109
+ type: guide
110
+ status: draft
111
+ owner: TODO
112
+ created: {today}
113
+ last_updated: {today}
114
+ source_of_truth: false
115
+ sensitivity: internal
116
+ tags: []
117
+ ---
118
+
119
+ # Notes
120
+
121
+ Freeform working notes. Link anything durable from `Home.md`. Promote decisions
122
+ into `06-decisions/` and sources into the evidence register — do not leave
123
+ material claims only in this folder.
124
+ """
125
+
126
+ MINIMAL_AGENTS_PURPOSE = """## Repository purpose
127
+
128
+ This repository is a durable evidence and decision ledger. It stores context,
129
+ accepted decisions, supporting evidence and review dates. It is not a task
130
+ tracker, not a CRM, and not a place for credentials.
131
+ """
132
+
133
+
134
+ def apply_minimal_layout(target: Path, *, today: dt.date | None = None) -> None:
135
+ """Trim a full template copy into a vendor-neutral minimal vault."""
136
+ today = today or dt.date.today()
137
+ stamp = today.isoformat()
138
+
139
+ for name in OPTIONAL_WORKSTREAMS:
140
+ path = target / name
141
+ if path.is_dir():
142
+ for child in sorted(path.rglob("*"), reverse=True):
143
+ if child.is_file():
144
+ child.unlink()
145
+ elif child.is_dir():
146
+ child.rmdir()
147
+ path.rmdir()
148
+
149
+ templates = target / "templates"
150
+ for name in OPTIONAL_TEMPLATES:
151
+ path = templates / name
152
+ if path.is_file():
153
+ path.unlink()
154
+
155
+ notes = target / "notes"
156
+ notes.mkdir(exist_ok=True)
157
+ (notes / "README.md").write_text(MINIMAL_NOTES_README.format(today=stamp), encoding="utf-8")
158
+ (target / "Home.md").write_text(MINIMAL_HOME.format(today=stamp), encoding="utf-8")
159
+ (target / "README.md").write_text(MINIMAL_README.format(today=stamp), encoding="utf-8")
160
+
161
+ agents = target / "AGENTS.md"
162
+ if agents.is_file():
163
+ text = agents.read_text(encoding="utf-8")
164
+ text = re.sub(
165
+ r"## Repository purpose\n\n.*?(?=\n## )",
166
+ MINIMAL_AGENTS_PURPOSE + "\n",
167
+ text,
168
+ count=1,
169
+ flags=re.S,
170
+ )
171
+ text = text.replace(
172
+ "This repository is the team's operating knowledge base. It stores\n"
173
+ "durable context, strategic documents, research, specifications and accepted\n"
174
+ "decisions. It is not a task tracker, not a CRM, and not a place for credentials.\n",
175
+ "",
176
+ )
177
+ text = re.sub(
178
+ r"\d+\. Create an ingestion record in `07-research/sources/` from\n"
179
+ r" `templates/source-ingestion-template.md`\.\n",
180
+ "3. Create an ingestion record (for example under `notes/` or `reports/`) from\n"
181
+ " `templates/source-ingestion-template.md`.\n",
182
+ text,
183
+ count=1,
184
+ )
185
+ agents.write_text(text, encoding="utf-8")
186
+
187
+ leftovers = removed_workstream_mentions(target)
188
+ if leftovers:
189
+ raise RuntimeError(
190
+ "minimal layout still points at a removed workstream: " + "; ".join(leftovers)
191
+ )
192
+
193
+
194
+ def removed_workstream_mentions(root: Path) -> list[str]:
195
+ """Paths of generated files that still name a workstream minimal layout deletes."""
196
+ needles = tuple(f"{name}/" for name in OPTIONAL_WORKSTREAMS)
197
+ hits: list[str] = []
198
+ for path in sorted(root.rglob("*")):
199
+ if not path.is_file() or path.suffix not in {".md", ".toml", ".txt", ".yml", ".yaml"}:
200
+ continue
201
+ text = path.read_text(encoding="utf-8")
202
+ found = [needle for needle in needles if needle in text]
203
+ if found:
204
+ hits.append(f"{path.relative_to(root)} ({', '.join(found)})")
205
+ return hits