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