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/scaffold.py
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
"""Create new WhyKit records without hand-editing indexes.
|
|
2
|
+
|
|
3
|
+
The file format stays Markdown + Git. These helpers only remove the repetitive,
|
|
4
|
+
error-prone work of allocating IDs, naming files and keeping the two registries
|
|
5
|
+
in sync.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import errno
|
|
11
|
+
import hashlib
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from .contract import describe_os_error, emit_error, vault_not_found
|
|
20
|
+
from .messages import parse_iso_date
|
|
21
|
+
from .io import apply_transaction, atomic_write_text, ensure_writable, safe_vault_target, vault_mutation_lock
|
|
22
|
+
from .config import ConfigError, load_config
|
|
23
|
+
from .lint import (
|
|
24
|
+
DECISION_ID_RE, EVIDENCE_ID_RE, _split_table_row, decision_log_rows, evidence_register,
|
|
25
|
+
find_vault_root, is_markdown_name, is_vault_root, load_note,
|
|
26
|
+
)
|
|
27
|
+
from .console import emit_machine, one_line
|
|
28
|
+
from .placeholders import (
|
|
29
|
+
ALTERNATIVES_EMPTY_ROW, ALTERNATIVES_HEADER, SCAFFOLD_EVIDENCE_TODO, SCAFFOLD_SECTION_PROMPTS,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DECISION_STATUS_TO_LOG = {
|
|
33
|
+
"draft": "proposed",
|
|
34
|
+
"in_review": "proposed",
|
|
35
|
+
"approved": "accepted",
|
|
36
|
+
"superseded": "superseded",
|
|
37
|
+
"archived": "archived",
|
|
38
|
+
}
|
|
39
|
+
VALID_SENSITIVITY = ("public", "internal", "confidential", "restricted")
|
|
40
|
+
VALID_NOTE_TYPES = ("strategy", "research", "framework", "specification", "guide", "reference")
|
|
41
|
+
ID_NUMBER_RE = re.compile(r"^[DE]-([0-9]+)$")
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from .promote import Promotion
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _yaml_string(value: str) -> str:
|
|
48
|
+
"""JSON strings are valid YAML scalars and avoid quote/comment surprises."""
|
|
49
|
+
return json.dumps(value, ensure_ascii=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Latin letters that NFKD does not split into a base letter plus a mark, so
|
|
53
|
+
# dropping non-ASCII would delete them outright ("Łódź" would become "odz").
|
|
54
|
+
_LATIN_FOLD = str.maketrans({
|
|
55
|
+
"ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ø": "o", "Ø": "O", "ß": "ss",
|
|
56
|
+
"æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE", "þ": "th", "Þ": "TH",
|
|
57
|
+
"ð": "d", "Ð": "D", "ı": "i", "ħ": "h", "Ħ": "H",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _slugify(value: str) -> str:
|
|
62
|
+
# A C-locale argv title carries surrogates for its UTF-8 bytes; recover the
|
|
63
|
+
# text first so the file name does not depend on the locale.
|
|
64
|
+
value = value.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
|
|
65
|
+
normalized = unicodedata.normalize("NFKD", value.translate(_LATIN_FOLD))
|
|
66
|
+
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
|
67
|
+
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
|
|
68
|
+
if slug:
|
|
69
|
+
return slug
|
|
70
|
+
# No Latin letters or digits at all (Chinese, Cyrillic, Greek…). File names
|
|
71
|
+
# stay ASCII, so derive a short stable suffix from the title instead of
|
|
72
|
+
# giving every such title the same name.
|
|
73
|
+
canonical = unicodedata.normalize("NFC", value).strip()
|
|
74
|
+
if not canonical:
|
|
75
|
+
return "record"
|
|
76
|
+
return "record-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:8]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _table_cell(value: str) -> str:
|
|
80
|
+
return " ".join(value.replace("|", r"\|").split())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _next_id(values: list[str], prefix: str) -> str:
|
|
84
|
+
numbers = []
|
|
85
|
+
for value in values:
|
|
86
|
+
match = ID_NUMBER_RE.match(value)
|
|
87
|
+
if match and value.startswith(prefix + "-"):
|
|
88
|
+
numbers.append(int(match.group(1)))
|
|
89
|
+
return f"{prefix}-{(max(numbers, default=0) + 1):03d}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _frontmatter_replace(text: str, key: str, value: str) -> str:
|
|
93
|
+
bom = "\ufeff" if text.startswith("\ufeff") else ""
|
|
94
|
+
text = text.removeprefix("\ufeff")
|
|
95
|
+
if not text.startswith("---\n"):
|
|
96
|
+
return text
|
|
97
|
+
end = text.find("\n---\n", 4)
|
|
98
|
+
if end < 0:
|
|
99
|
+
return text
|
|
100
|
+
head = text[:end]
|
|
101
|
+
pattern = re.compile(rf"(?m)^{re.escape(key)}:[ \t]*.*$")
|
|
102
|
+
replacement = f"{key}: {value}"
|
|
103
|
+
if pattern.search(head):
|
|
104
|
+
head = pattern.sub(replacement, head, count=1)
|
|
105
|
+
else:
|
|
106
|
+
head += "\n" + replacement
|
|
107
|
+
return bom + head + text[end:]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _append_table_row_text(text: str, header_prefix: str, row: str, today: dt.date, *, record_id: str, path_label: str = "table") -> str:
|
|
111
|
+
lines = text.splitlines()
|
|
112
|
+
header_idx = next((i for i, line in enumerate(lines) if line.strip().startswith(header_prefix)), None)
|
|
113
|
+
if header_idx is None or header_idx + 1 >= len(lines):
|
|
114
|
+
raise ValueError(f"expected Markdown table in {path_label}")
|
|
115
|
+
insert_at = header_idx + 2
|
|
116
|
+
placeholder_idx: int | None = None
|
|
117
|
+
while insert_at < len(lines) and lines[insert_at].lstrip().startswith("|"):
|
|
118
|
+
cells = [cell.strip() for cell in lines[insert_at].strip().strip("|").split("|")]
|
|
119
|
+
if cells and cells[0] == record_id:
|
|
120
|
+
populated = [cell for cell in cells[1:] if cell and cell not in {"proposed / accepted / superseded", "interview / report / analytics / vendor doc / internal"}]
|
|
121
|
+
if not populated:
|
|
122
|
+
placeholder_idx = insert_at
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(f"{record_id} already has a populated row in {path_label}")
|
|
125
|
+
insert_at += 1
|
|
126
|
+
if placeholder_idx is not None:
|
|
127
|
+
lines[placeholder_idx] = row
|
|
128
|
+
else:
|
|
129
|
+
lines.insert(insert_at, row)
|
|
130
|
+
updated = "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
131
|
+
return _frontmatter_replace(updated, "last_updated", today.isoformat())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _append_table_row(path: Path, header_prefix: str, row: str, today: dt.date, *, record_id: str) -> None:
|
|
135
|
+
text = path.read_text(encoding="utf-8")
|
|
136
|
+
atomic_write_text(
|
|
137
|
+
path,
|
|
138
|
+
_append_table_row_text(text, header_prefix, row, today, record_id=record_id, path_label=str(path)),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _update_decision_log_status_text(text: str, decision_id: str, status: str, today: dt.date) -> str:
|
|
143
|
+
lines = text.splitlines()
|
|
144
|
+
hits = 0
|
|
145
|
+
for index, line in enumerate(lines):
|
|
146
|
+
if not line.lstrip().startswith("|"):
|
|
147
|
+
continue
|
|
148
|
+
cells = _split_table_row(line)
|
|
149
|
+
if len(cells) < 6 or cells[0] != decision_id:
|
|
150
|
+
continue
|
|
151
|
+
hits += 1
|
|
152
|
+
cells[4] = status
|
|
153
|
+
rendered = []
|
|
154
|
+
for i, cell in enumerate(cells):
|
|
155
|
+
rendered.append(cell if i == 5 and cell.startswith("[[") else _table_cell(cell))
|
|
156
|
+
lines[index] = "| " + " | ".join(rendered) + " |"
|
|
157
|
+
if hits != 1:
|
|
158
|
+
raise ValueError(f"expected one decision-log row for {decision_id}, found {hits}")
|
|
159
|
+
updated = "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
160
|
+
return _frontmatter_replace(updated, "last_updated", today.isoformat())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _existing_decision_ids(vault: Path) -> list[str]:
|
|
164
|
+
values: list[str] = []
|
|
165
|
+
decisions = vault / "06-decisions"
|
|
166
|
+
if decisions.is_dir():
|
|
167
|
+
for path in decisions.iterdir():
|
|
168
|
+
if not is_markdown_name(path.name):
|
|
169
|
+
continue
|
|
170
|
+
match = re.match(r"d-([0-9]{3,})-", path.name)
|
|
171
|
+
if match:
|
|
172
|
+
values.append(f"D-{match.group(1)}")
|
|
173
|
+
values.extend(str(row["id"]) for row in decision_log_rows(vault))
|
|
174
|
+
return values
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _existing_evidence_ids(vault: Path) -> list[str]:
|
|
178
|
+
active, retired, _ = evidence_register(vault)
|
|
179
|
+
return [*active.keys(), *retired.keys()]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _decision_record_path(vault: Path, decision_id: str) -> Path:
|
|
183
|
+
number = decision_id.split("-", 1)[1]
|
|
184
|
+
matches = sorted(
|
|
185
|
+
path for path in (vault / "06-decisions").glob(f"d-{number}-*")
|
|
186
|
+
if is_markdown_name(path.name)
|
|
187
|
+
)
|
|
188
|
+
exact = [path for path in matches if str(load_note(path).front.get("decision_id") or "").strip() == decision_id]
|
|
189
|
+
if len(exact) != 1:
|
|
190
|
+
raise ValueError(f"{decision_id} does not resolve to exactly one decision record")
|
|
191
|
+
return exact[0]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _update_decision_log_status(path: Path, decision_id: str, status: str, today: dt.date) -> None:
|
|
195
|
+
text = path.read_text(encoding="utf-8")
|
|
196
|
+
atomic_write_text(path, _update_decision_log_status_text(text, decision_id, status, today))
|
|
197
|
+
|
|
198
|
+
def create_decision(
|
|
199
|
+
vault: Path,
|
|
200
|
+
title: str,
|
|
201
|
+
*,
|
|
202
|
+
owner: str = "TODO",
|
|
203
|
+
status: str = "draft",
|
|
204
|
+
sensitivity: str = "internal",
|
|
205
|
+
source_ids: list[str] | None = None,
|
|
206
|
+
review_by: str | None = None,
|
|
207
|
+
supersedes: str | None = None,
|
|
208
|
+
today: dt.date | None = None,
|
|
209
|
+
promotion: Promotion | None = None,
|
|
210
|
+
) -> tuple[str, Path]:
|
|
211
|
+
today = today or dt.date.today()
|
|
212
|
+
if promotion is not None and status not in {"draft", "in_review"}:
|
|
213
|
+
raise ValueError("imported decisions must start as draft or in_review; the original status is retained in provenance")
|
|
214
|
+
if status not in {"draft", "in_review"}:
|
|
215
|
+
raise ValueError("new decisions must start as draft or in_review; complete the record, then use `whykit review approve`")
|
|
216
|
+
title = title.strip() or (promotion.title if promotion else "")
|
|
217
|
+
if not title:
|
|
218
|
+
raise ValueError("decision title cannot be empty")
|
|
219
|
+
with vault_mutation_lock(vault):
|
|
220
|
+
if promotion is not None:
|
|
221
|
+
from .promote import promoted_from
|
|
222
|
+
|
|
223
|
+
earlier = promoted_from(vault, promotion.sha256)
|
|
224
|
+
if earlier:
|
|
225
|
+
raise FileExistsError(
|
|
226
|
+
f"{promotion.source_label} was already promoted as {earlier}\n"
|
|
227
|
+
"hint: edit that record, or supersede it with a new decision"
|
|
228
|
+
)
|
|
229
|
+
existing_decisions = _existing_decision_ids(vault)
|
|
230
|
+
decision_id = _next_id(existing_decisions, "D")
|
|
231
|
+
number = decision_id.split("-", 1)[1]
|
|
232
|
+
path = safe_vault_target(
|
|
233
|
+
vault,
|
|
234
|
+
Path("06-decisions") / f"d-{number}-{_slugify(title)}.md",
|
|
235
|
+
)
|
|
236
|
+
if path.exists():
|
|
237
|
+
raise FileExistsError(path)
|
|
238
|
+
if review_by:
|
|
239
|
+
parse_iso_date("--review-by", review_by)
|
|
240
|
+
if supersedes:
|
|
241
|
+
if not DECISION_ID_RE.fullmatch(supersedes):
|
|
242
|
+
raise ValueError("--supersedes must be a D-NNN identifier")
|
|
243
|
+
if supersedes not in set(existing_decisions):
|
|
244
|
+
raise ValueError(f"--supersedes points to unknown decision {supersedes}")
|
|
245
|
+
predecessor_path = _decision_record_path(vault, supersedes)
|
|
246
|
+
predecessor_note = load_note(predecessor_path)
|
|
247
|
+
if predecessor_note.front.get("status") != "approved":
|
|
248
|
+
raise ValueError(f"{supersedes} must be approved before it can be superseded")
|
|
249
|
+
source_ids = source_ids or []
|
|
250
|
+
invalid = [value for value in source_ids if not EVIDENCE_ID_RE.fullmatch(value)]
|
|
251
|
+
if invalid:
|
|
252
|
+
raise ValueError(f"invalid evidence ID(s): {', '.join(invalid)}")
|
|
253
|
+
known_evidence = set(_existing_evidence_ids(vault))
|
|
254
|
+
missing_evidence = [value for value in source_ids if value not in known_evidence]
|
|
255
|
+
if missing_evidence:
|
|
256
|
+
raise ValueError(f"unknown evidence ID(s): {', '.join(missing_evidence)}")
|
|
257
|
+
|
|
258
|
+
source_yaml = "[" + ", ".join(_yaml_string(value) for value in source_ids) + "]" if source_ids else "[]"
|
|
259
|
+
optional = ""
|
|
260
|
+
if review_by:
|
|
261
|
+
optional += f"review_by: {review_by}\n"
|
|
262
|
+
if supersedes:
|
|
263
|
+
optional += f"supersedes: {supersedes}\n"
|
|
264
|
+
prose = dict(SCAFFOLD_SECTION_PROMPTS)
|
|
265
|
+
evidence_lines = [f"- {sid}" for sid in source_ids] or [SCAFFOLD_EVIDENCE_TODO]
|
|
266
|
+
alternative_rows = [ALTERNATIVES_EMPTY_ROW]
|
|
267
|
+
consequences = "### Positive\n\n-\n\n### Negative and trade-offs\n\n-"
|
|
268
|
+
appendix = ""
|
|
269
|
+
if promotion is not None:
|
|
270
|
+
from .promote import render_original, render_provenance, render_sections
|
|
271
|
+
|
|
272
|
+
optional += render_provenance(promotion.provenance(today.isoformat()))
|
|
273
|
+
sections = render_sections(promotion, SCAFFOLD_SECTION_PROMPTS)
|
|
274
|
+
prose = {name: sections[name] for name in SCAFFOLD_SECTION_PROMPTS}
|
|
275
|
+
evidence_lines += [
|
|
276
|
+
f"- TODO — candidate source from the original record: <{url}>"
|
|
277
|
+
for url in promotion.evidence_candidates
|
|
278
|
+
]
|
|
279
|
+
if promotion.alternatives:
|
|
280
|
+
alternative_rows = [f"| {_table_cell(name)} | | | Not chosen; see the original record |" for name in promotion.alternatives]
|
|
281
|
+
general = sections["Consequences"]
|
|
282
|
+
positive = "\n".join(f"- {item}" for item in promotion.positive) or "-"
|
|
283
|
+
negative = "\n".join(f"- {item}" for item in promotion.negative) or "-"
|
|
284
|
+
consequences = (
|
|
285
|
+
(general + "\n\n" if general else "")
|
|
286
|
+
+ f"### Positive\n\n{positive}\n\n### Negative and trade-offs\n\n{negative}"
|
|
287
|
+
)
|
|
288
|
+
appendix = "\n" + render_original(promotion)
|
|
289
|
+
body = f'''---
|
|
290
|
+
title: {_yaml_string(title)}
|
|
291
|
+
aliases: []
|
|
292
|
+
type: decision
|
|
293
|
+
decision_id: {decision_id}
|
|
294
|
+
status: {status}
|
|
295
|
+
owner: {_yaml_string(owner)}
|
|
296
|
+
created: {today.isoformat()}
|
|
297
|
+
last_updated: {today.isoformat()}
|
|
298
|
+
source_of_truth: false
|
|
299
|
+
sensitivity: {sensitivity}
|
|
300
|
+
source_ids: {source_yaml}
|
|
301
|
+
tags: []
|
|
302
|
+
{optional}---
|
|
303
|
+
|
|
304
|
+
# Decision record: {title}
|
|
305
|
+
|
|
306
|
+
## Decision ID
|
|
307
|
+
|
|
308
|
+
{decision_id}
|
|
309
|
+
|
|
310
|
+
## Status
|
|
311
|
+
|
|
312
|
+
{DECISION_STATUS_TO_LOG[status].capitalize()}
|
|
313
|
+
|
|
314
|
+
## Context
|
|
315
|
+
|
|
316
|
+
{prose['Context']}
|
|
317
|
+
|
|
318
|
+
## Decision
|
|
319
|
+
|
|
320
|
+
{prose['Decision']}
|
|
321
|
+
|
|
322
|
+
## Rationale
|
|
323
|
+
|
|
324
|
+
{prose['Rationale']}
|
|
325
|
+
|
|
326
|
+
## Evidence
|
|
327
|
+
|
|
328
|
+
{chr(10).join(evidence_lines)}
|
|
329
|
+
|
|
330
|
+
## Alternatives considered
|
|
331
|
+
|
|
332
|
+
{ALTERNATIVES_HEADER}
|
|
333
|
+
|---|---|---|---|
|
|
334
|
+
{chr(10).join(alternative_rows)}
|
|
335
|
+
|
|
336
|
+
## Consequences
|
|
337
|
+
|
|
338
|
+
{consequences}
|
|
339
|
+
|
|
340
|
+
## Ownership and review
|
|
341
|
+
|
|
342
|
+
- Owner: {owner}
|
|
343
|
+
- Decided on: {today.isoformat() if status == 'approved' else 'TBD'}
|
|
344
|
+
- Review on: {review_by or 'TBD'}
|
|
345
|
+
- Supersedes: {supersedes or '—'}
|
|
346
|
+
- Superseded by: —
|
|
347
|
+
{appendix}'''
|
|
348
|
+
log = safe_vault_target(vault, "06-decisions/decision-log.md", create_parents=False)
|
|
349
|
+
if not log.exists():
|
|
350
|
+
raise FileNotFoundError(errno.ENOENT, "the decision log is missing", str(log))
|
|
351
|
+
log_before = log.read_text(encoding="utf-8")
|
|
352
|
+
row = (
|
|
353
|
+
f"| {decision_id} | {_table_cell(title)} | {today.isoformat()} | {_table_cell(owner)} | "
|
|
354
|
+
f"{DECISION_STATUS_TO_LOG[status]} | [[06-decisions/{path.stem}]] |"
|
|
355
|
+
)
|
|
356
|
+
updates: dict[Path, str] = {
|
|
357
|
+
path: body,
|
|
358
|
+
log: _append_table_row_text(
|
|
359
|
+
log_before, "| ID | Decision |", row, today, record_id=decision_id, path_label=str(log)
|
|
360
|
+
),
|
|
361
|
+
}
|
|
362
|
+
apply_transaction(vault, updates)
|
|
363
|
+
return decision_id, path
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _with_evidence_sensitivity(text: str, mode: str, inherited: str) -> str:
|
|
367
|
+
"""Extend one legacy table without declassifying any existing row."""
|
|
368
|
+
from .tables import evidence_table_bounds
|
|
369
|
+
|
|
370
|
+
lines = text.splitlines()
|
|
371
|
+
start, end, labeled = evidence_table_bounds(lines, mode)
|
|
372
|
+
if labeled:
|
|
373
|
+
return text
|
|
374
|
+
for index in range(start, end):
|
|
375
|
+
value = "Sensitivity" if index == start else "---" if index == start + 1 else inherited
|
|
376
|
+
if index > start + 1:
|
|
377
|
+
cells = _split_table_row(lines[index])
|
|
378
|
+
if len(cells) != (7 if mode == "active" else 5):
|
|
379
|
+
raise ValueError("cannot extend an evidence table with malformed rows")
|
|
380
|
+
if not cells[1]:
|
|
381
|
+
value = ""
|
|
382
|
+
line = lines[index].rstrip()
|
|
383
|
+
lines[index] = line + ("" if line.endswith("|") else " |") + f" {_table_cell(value)} |"
|
|
384
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _register_inherited_label(path: Path, text: str) -> str:
|
|
389
|
+
from .sensitivity import SENSITIVITY_LEVEL, note_sensitivity_level
|
|
390
|
+
|
|
391
|
+
level = note_sensitivity_level(load_note(path, text=text))
|
|
392
|
+
return next((name for name, value in SENSITIVITY_LEVEL.items() if value == level), "unclassified")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def create_evidence(
|
|
397
|
+
vault: Path,
|
|
398
|
+
*,
|
|
399
|
+
source: str,
|
|
400
|
+
location: str,
|
|
401
|
+
kind: str,
|
|
402
|
+
claims: str,
|
|
403
|
+
date: str | None = None,
|
|
404
|
+
accessed: str | None = None,
|
|
405
|
+
sensitivity: str | None = None,
|
|
406
|
+
today: dt.date | None = None,
|
|
407
|
+
) -> str:
|
|
408
|
+
today = today or dt.date.today()
|
|
409
|
+
if not source.strip() or not location.strip() or not kind.strip() or not claims.strip():
|
|
410
|
+
raise ValueError("evidence source, location, type and claims must be non-empty")
|
|
411
|
+
source_date = date or today.isoformat()
|
|
412
|
+
accessed_date = accessed or today.isoformat()
|
|
413
|
+
parse_iso_date("--date", source_date)
|
|
414
|
+
parse_iso_date("--accessed", accessed_date)
|
|
415
|
+
if sensitivity is not None and sensitivity not in VALID_SENSITIVITY:
|
|
416
|
+
raise ValueError("unsupported evidence sensitivity")
|
|
417
|
+
with vault_mutation_lock(vault):
|
|
418
|
+
evidence_id = _next_id(_existing_evidence_ids(vault), "E")
|
|
419
|
+
register = safe_vault_target(vault, "00-context/evidence-register.md", create_parents=False)
|
|
420
|
+
if not register.exists():
|
|
421
|
+
raise FileNotFoundError(errno.ENOENT, "the evidence register is missing", str(register))
|
|
422
|
+
from .tables import evidence_table_bounds
|
|
423
|
+
|
|
424
|
+
original = register.read_text(encoding="utf-8")
|
|
425
|
+
inherited = _register_inherited_label(register, original)
|
|
426
|
+
updated = _with_evidence_sensitivity(original, "active", inherited) if sensitivity is not None else original
|
|
427
|
+
lines = updated.splitlines()
|
|
428
|
+
header, end, labeled = evidence_table_bounds(lines, "active")
|
|
429
|
+
placeholder = next((index for index in range(header + 2, end)
|
|
430
|
+
if _split_table_row(lines[index])[0] == evidence_id), None)
|
|
431
|
+
values: tuple[str, ...] = (evidence_id, source, kind, source_date, accessed_date, location, claims)
|
|
432
|
+
if labeled:
|
|
433
|
+
prior = _split_table_row(lines[placeholder]) if placeholder is not None else []
|
|
434
|
+
values += (sensitivity or (prior[7] if len(prior) == 8 and prior[7] else inherited),)
|
|
435
|
+
row = "| " + " | ".join(
|
|
436
|
+
_table_cell(value)
|
|
437
|
+
for value in values
|
|
438
|
+
) + " |"
|
|
439
|
+
if placeholder is not None:
|
|
440
|
+
lines[placeholder] = row
|
|
441
|
+
else:
|
|
442
|
+
lines.insert(end, row)
|
|
443
|
+
updated = "\n".join(lines) + ("\n" if original.endswith("\n") else "")
|
|
444
|
+
atomic_write_text(register, _frontmatter_replace(updated, "last_updated", today.isoformat()))
|
|
445
|
+
return evidence_id
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _resolve_link_from(vault: Path, value: str) -> Path:
|
|
449
|
+
candidate = Path(value)
|
|
450
|
+
if candidate.is_absolute():
|
|
451
|
+
raise ValueError("--link-from must be a vault-relative Markdown path")
|
|
452
|
+
path = (vault / candidate).resolve()
|
|
453
|
+
try:
|
|
454
|
+
path.relative_to(vault.resolve())
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
raise ValueError("--link-from must stay inside the vault") from exc
|
|
457
|
+
if not is_markdown_name(path.name) or not path.is_file():
|
|
458
|
+
raise ValueError(f"--link-from is not an existing Markdown file: {value}")
|
|
459
|
+
return path
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _append_note_link(vault: Path, map_path: Path, note_path: Path, title: str, today: dt.date) -> str:
|
|
463
|
+
text = map_path.read_text(encoding="utf-8")
|
|
464
|
+
target = note_path.relative_to(vault).with_suffix("").as_posix()
|
|
465
|
+
if re.search(rf"\[\[{re.escape(target)}(?:\||\]\])", text):
|
|
466
|
+
return text
|
|
467
|
+
bullet = f"- [[{target}|{title}]]"
|
|
468
|
+
lines = text.splitlines()
|
|
469
|
+
heading = "## Linked notes"
|
|
470
|
+
try:
|
|
471
|
+
heading_idx = next(i for i, line in enumerate(lines) if line.strip() == heading)
|
|
472
|
+
except StopIteration:
|
|
473
|
+
if lines and lines[-1].strip():
|
|
474
|
+
lines.append("")
|
|
475
|
+
lines.extend([heading, "", bullet])
|
|
476
|
+
else:
|
|
477
|
+
insert_at = len(lines)
|
|
478
|
+
for i in range(heading_idx + 1, len(lines)):
|
|
479
|
+
if lines[i].startswith("## "):
|
|
480
|
+
insert_at = i
|
|
481
|
+
break
|
|
482
|
+
while insert_at > heading_idx + 1 and not lines[insert_at - 1].strip():
|
|
483
|
+
insert_at -= 1
|
|
484
|
+
lines.insert(insert_at, bullet)
|
|
485
|
+
updated = "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
486
|
+
updated = _frontmatter_replace(updated, "last_updated", today.isoformat())
|
|
487
|
+
return updated
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _note_exists_message(vault: Path, path: Path) -> str:
|
|
491
|
+
relative = path.relative_to(vault.resolve()).as_posix() if path.is_absolute() else path.as_posix()
|
|
492
|
+
return (
|
|
493
|
+
f"{relative} already exists\n"
|
|
494
|
+
"hint: open and extend that note, or give the new one a more specific title"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def create_note(
|
|
499
|
+
vault: Path,
|
|
500
|
+
title: str,
|
|
501
|
+
*,
|
|
502
|
+
workstream: str,
|
|
503
|
+
owner: str = "TODO",
|
|
504
|
+
doc_type: str = "guide",
|
|
505
|
+
sensitivity: str = "internal",
|
|
506
|
+
link_from: str | None = None,
|
|
507
|
+
today: dt.date | None = None,
|
|
508
|
+
) -> Path:
|
|
509
|
+
today = today or dt.date.today()
|
|
510
|
+
title = title.strip()
|
|
511
|
+
if not title:
|
|
512
|
+
raise ValueError("note title cannot be empty")
|
|
513
|
+
if doc_type not in VALID_NOTE_TYPES:
|
|
514
|
+
raise ValueError(f"unsupported type: {doc_type}")
|
|
515
|
+
workstream_path = Path(workstream)
|
|
516
|
+
if workstream_path.is_absolute() or ".." in workstream_path.parts:
|
|
517
|
+
raise ValueError("workstream must stay inside the vault")
|
|
518
|
+
root = vault.resolve(strict=True)
|
|
519
|
+
current = root
|
|
520
|
+
for part in workstream_path.parts:
|
|
521
|
+
current = current / part
|
|
522
|
+
if current.is_symlink():
|
|
523
|
+
raise ValueError(f"symlinked workstream refused: {workstream}")
|
|
524
|
+
if not current.is_dir():
|
|
525
|
+
raise ValueError(f"workstream does not exist: {workstream}")
|
|
526
|
+
try:
|
|
527
|
+
current.resolve(strict=True).relative_to(root)
|
|
528
|
+
except ValueError as exc:
|
|
529
|
+
raise ValueError("workstream must stay inside the vault") from exc
|
|
530
|
+
path = safe_vault_target(vault, workstream_path / f"{_slugify(title)}.md", create_parents=False)
|
|
531
|
+
if path.exists():
|
|
532
|
+
raise FileExistsError(_note_exists_message(vault, path))
|
|
533
|
+
with vault_mutation_lock(vault):
|
|
534
|
+
if path.exists():
|
|
535
|
+
raise FileExistsError(_note_exists_message(vault, path))
|
|
536
|
+
map_path = _resolve_link_from(vault, link_from) if link_from else None
|
|
537
|
+
if map_path:
|
|
538
|
+
# Fail before the note exists rather than half-way through.
|
|
539
|
+
ensure_writable(map_path)
|
|
540
|
+
body = f'''---
|
|
541
|
+
title: {_yaml_string(title)}
|
|
542
|
+
aliases: []
|
|
543
|
+
type: {doc_type}
|
|
544
|
+
status: draft
|
|
545
|
+
owner: {_yaml_string(owner)}
|
|
546
|
+
created: {today.isoformat()}
|
|
547
|
+
last_updated: {today.isoformat()}
|
|
548
|
+
source_of_truth: false
|
|
549
|
+
sensitivity: {sensitivity}
|
|
550
|
+
source_ids: []
|
|
551
|
+
tags: []
|
|
552
|
+
workstream: {_yaml_string(workstream)}
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
# {title}
|
|
556
|
+
|
|
557
|
+
## Purpose
|
|
558
|
+
|
|
559
|
+
What question or operating need does this document answer?
|
|
560
|
+
|
|
561
|
+
## Current state
|
|
562
|
+
|
|
563
|
+
Separate verified facts from hypotheses, recommendations and open questions.
|
|
564
|
+
|
|
565
|
+
## Evidence
|
|
566
|
+
|
|
567
|
+
- TODO — add E-NNN references for material factual claims.
|
|
568
|
+
|
|
569
|
+
## Open questions
|
|
570
|
+
|
|
571
|
+
-
|
|
572
|
+
'''
|
|
573
|
+
updates = {path: body}
|
|
574
|
+
if map_path:
|
|
575
|
+
updates[map_path] = _append_note_link(vault, map_path, path, title, today)
|
|
576
|
+
apply_transaction(vault, updates)
|
|
577
|
+
return path
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _promote(vault: Path, args: argparse.Namespace, *, owner: str, sensitivity: str, review_by: str | None) -> int:
|
|
581
|
+
"""`new decision --from FILE`: print the mapping, or with --write create the record."""
|
|
582
|
+
from .promote import PromotionError, build_promotion, promoted_from
|
|
583
|
+
|
|
584
|
+
if not args.from_path:
|
|
585
|
+
return emit_error(
|
|
586
|
+
"usage", "--write only applies with --from FILE",
|
|
587
|
+
hint='hint: whykit new decision "Title" creates a record directly; --write confirms a --from promotion',
|
|
588
|
+
json_mode=args.json,
|
|
589
|
+
)
|
|
590
|
+
source = Path(args.from_path).expanduser()
|
|
591
|
+
if not source.exists():
|
|
592
|
+
return emit_error("invalid_target", f"no such file: {args.from_path}", json_mode=args.json)
|
|
593
|
+
try:
|
|
594
|
+
promotion = build_promotion(vault, source, title=args.title)
|
|
595
|
+
except PromotionError as exc:
|
|
596
|
+
return emit_error("invalid_target", str(exc), json_mode=args.json)
|
|
597
|
+
except OSError as exc:
|
|
598
|
+
return emit_error("io_error", describe_os_error(exc), json_mode=args.json)
|
|
599
|
+
if args.status not in {"draft", "in_review"}:
|
|
600
|
+
return emit_error("operation_rejected", "imported decisions must start as draft or in_review; review the imported record before approval", json_mode=args.json)
|
|
601
|
+
earlier = promoted_from(vault, promotion.sha256)
|
|
602
|
+
if earlier and not args.write:
|
|
603
|
+
return emit_error(
|
|
604
|
+
"target_exists", f"{one_line(promotion.source_label)} was already promoted as {earlier}",
|
|
605
|
+
hint="hint: edit that record, or supersede it with a new decision",
|
|
606
|
+
json_mode=args.json,
|
|
607
|
+
)
|
|
608
|
+
if args.write:
|
|
609
|
+
try:
|
|
610
|
+
decision_id, path = create_decision(
|
|
611
|
+
vault, args.title or "", owner=owner, status=args.status, sensitivity=sensitivity,
|
|
612
|
+
source_ids=args.source_ids, review_by=review_by, supersedes=args.supersedes,
|
|
613
|
+
promotion=promotion,
|
|
614
|
+
)
|
|
615
|
+
except FileExistsError as exc:
|
|
616
|
+
return emit_error("target_exists", str(exc), json_mode=args.json)
|
|
617
|
+
except FileNotFoundError as exc:
|
|
618
|
+
return emit_error(
|
|
619
|
+
"vault_invalid", describe_os_error(exc),
|
|
620
|
+
hint="hint: restore the file from Git, or copy it from a fresh `whykit init` vault",
|
|
621
|
+
json_mode=args.json,
|
|
622
|
+
)
|
|
623
|
+
except ValueError as exc:
|
|
624
|
+
return emit_error("operation_rejected", str(exc), json_mode=args.json)
|
|
625
|
+
relative = path.relative_to(vault).as_posix()
|
|
626
|
+
else:
|
|
627
|
+
decision_id = _next_id(_existing_decision_ids(vault), "D")
|
|
628
|
+
relative = f"06-decisions/d-{decision_id.split('-', 1)[1]}-{_slugify(promotion.title)}.md"
|
|
629
|
+
title = (args.title or "").strip() or promotion.title
|
|
630
|
+
if args.json:
|
|
631
|
+
emit_machine(json.dumps({
|
|
632
|
+
"contract_version": 1,
|
|
633
|
+
"kind": "decision",
|
|
634
|
+
"id": decision_id,
|
|
635
|
+
"path": relative,
|
|
636
|
+
"write": bool(args.write),
|
|
637
|
+
"title": title,
|
|
638
|
+
"status": args.status,
|
|
639
|
+
"source": promotion.as_json(),
|
|
640
|
+
"mapping": promotion.mapping,
|
|
641
|
+
"unmapped": promotion.unmapped,
|
|
642
|
+
"evidence_candidates": promotion.evidence_candidates,
|
|
643
|
+
"warnings": promotion.warnings,
|
|
644
|
+
}, ensure_ascii=False, indent=2))
|
|
645
|
+
return 0
|
|
646
|
+
verb = "created" if args.write else "would create"
|
|
647
|
+
print(f"Promote {one_line(promotion.source_label)} ({promotion.shape})")
|
|
648
|
+
print(f" sha256 {promotion.sha256}")
|
|
649
|
+
print(f" {verb:<13} {decision_id}: {one_line(relative)} (status {args.status})")
|
|
650
|
+
print(f" title {one_line(title)}")
|
|
651
|
+
if promotion.mapping:
|
|
652
|
+
print(" mapping")
|
|
653
|
+
width = max(len(item["section"]) for item in promotion.mapping)
|
|
654
|
+
for item in promotion.mapping:
|
|
655
|
+
print(f" {item['section']:<{width}} <- {one_line(item['from'])}")
|
|
656
|
+
for name in promotion.unmapped:
|
|
657
|
+
print(f" unmapped {one_line(name)} (kept only under Original record)")
|
|
658
|
+
if promotion.source_status:
|
|
659
|
+
print(f" source status {one_line(promotion.source_status)} (kept in provenance; the record is {args.status})")
|
|
660
|
+
if promotion.evidence_candidates:
|
|
661
|
+
print(f" evidence {len(promotion.evidence_candidates)} link(s) listed under Evidence as TODO candidates")
|
|
662
|
+
for warning in promotion.warnings:
|
|
663
|
+
print(f" note: {one_line(warning)}")
|
|
664
|
+
if not args.write:
|
|
665
|
+
print("\nDry run. Nothing was written. Re-run with --write to create the record and its decision-log row.")
|
|
666
|
+
else:
|
|
667
|
+
print("The original text is kept under \"Original record\". Review the mapped sections, then approve it.")
|
|
668
|
+
return 0
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _vault(value: str | None) -> Path | None:
|
|
672
|
+
if value:
|
|
673
|
+
path = Path(value).expanduser().resolve()
|
|
674
|
+
return path if is_vault_root(path) else None
|
|
675
|
+
return find_vault_root()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def main(argv: list[str] | None = None) -> int:
|
|
679
|
+
parser = argparse.ArgumentParser(prog="whykit new", description="Create a record and keep WhyKit indexes in sync.")
|
|
680
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
681
|
+
sub = parser.add_subparsers(dest="kind", required=True)
|
|
682
|
+
|
|
683
|
+
decision = sub.add_parser("decision", help="create a decision record and decision-log row")
|
|
684
|
+
decision.add_argument("title", nargs="?")
|
|
685
|
+
decision.add_argument("--from", dest="from_path", metavar="FILE")
|
|
686
|
+
decision.add_argument("--write", action="store_true")
|
|
687
|
+
decision.add_argument("--owner")
|
|
688
|
+
decision.add_argument("--status", choices=("draft", "in_review"), default="draft")
|
|
689
|
+
decision.add_argument("--sensitivity", choices=VALID_SENSITIVITY)
|
|
690
|
+
decision.add_argument("--source", action="append", default=[], dest="source_ids", help="supporting E-NNN (repeatable)")
|
|
691
|
+
decision.add_argument("--review-by")
|
|
692
|
+
decision.add_argument("--supersedes")
|
|
693
|
+
decision.add_argument("--json", action="store_true")
|
|
694
|
+
|
|
695
|
+
evidence = sub.add_parser("evidence", help="append a source to the evidence register")
|
|
696
|
+
evidence.add_argument("--source", required=True, dest="source_name")
|
|
697
|
+
evidence.add_argument("--location", required=True)
|
|
698
|
+
evidence.add_argument("--type", required=True, dest="evidence_type")
|
|
699
|
+
evidence.add_argument("--claims", required=True)
|
|
700
|
+
evidence.add_argument("--date")
|
|
701
|
+
evidence.add_argument("--accessed")
|
|
702
|
+
evidence.add_argument("--sensitivity", choices=VALID_SENSITIVITY)
|
|
703
|
+
evidence.add_argument("--json", action="store_true")
|
|
704
|
+
|
|
705
|
+
note = sub.add_parser("note", help="create a draft note in an existing workstream")
|
|
706
|
+
note.add_argument("title")
|
|
707
|
+
note.add_argument("--workstream", required=True)
|
|
708
|
+
note.add_argument("--owner")
|
|
709
|
+
note.add_argument("--type", choices=VALID_NOTE_TYPES, default="guide", dest="doc_type")
|
|
710
|
+
note.add_argument("--sensitivity", choices=VALID_SENSITIVITY)
|
|
711
|
+
note.add_argument("--link-from", help="vault-relative Markdown map to link the new note from")
|
|
712
|
+
note.add_argument("--json", action="store_true")
|
|
713
|
+
|
|
714
|
+
args = parser.parse_args(argv)
|
|
715
|
+
vault = _vault(args.root)
|
|
716
|
+
if vault is None:
|
|
717
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
718
|
+
try:
|
|
719
|
+
config, _ = load_config(vault)
|
|
720
|
+
except ConfigError as exc:
|
|
721
|
+
return emit_error("invalid_config", str(exc), json_mode=args.json)
|
|
722
|
+
default_owner = str(config["defaults"]["owner"])
|
|
723
|
+
default_sensitivity = str(config["defaults"]["sensitivity"])
|
|
724
|
+
owner = getattr(args, "owner", None) or default_owner
|
|
725
|
+
sensitivity = getattr(args, "sensitivity", None) or default_sensitivity
|
|
726
|
+
review_by = getattr(args, "review_by", None)
|
|
727
|
+
if args.kind == "decision" and (args.from_path or args.write):
|
|
728
|
+
return _promote(vault, args, owner=owner, sensitivity=sensitivity, review_by=review_by)
|
|
729
|
+
if args.kind == "decision" and not (args.title or "").strip():
|
|
730
|
+
return emit_error(
|
|
731
|
+
"usage", "a decision needs a title",
|
|
732
|
+
hint='hint: whykit new decision "Adopt usage-based pricing", or --from FILE to promote an existing ADR',
|
|
733
|
+
json_mode=args.json,
|
|
734
|
+
)
|
|
735
|
+
try:
|
|
736
|
+
if args.kind == "decision":
|
|
737
|
+
decision_id, path = create_decision(
|
|
738
|
+
vault, args.title, owner=owner, status=args.status, sensitivity=sensitivity,
|
|
739
|
+
source_ids=args.source_ids, review_by=review_by, supersedes=args.supersedes,
|
|
740
|
+
)
|
|
741
|
+
payload = {"contract_version": 1, "kind": "decision", "id": decision_id, "path": path.relative_to(vault).as_posix()}
|
|
742
|
+
if args.json:
|
|
743
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
744
|
+
else:
|
|
745
|
+
print(f"created {decision_id}: {path.relative_to(vault).as_posix()}")
|
|
746
|
+
elif args.kind == "evidence":
|
|
747
|
+
evidence_id = create_evidence(
|
|
748
|
+
vault, source=args.source_name, location=args.location, kind=args.evidence_type,
|
|
749
|
+
claims=args.claims, date=args.date, accessed=args.accessed, sensitivity=args.sensitivity,
|
|
750
|
+
)
|
|
751
|
+
payload = {"contract_version": 1, "kind": "evidence", "id": evidence_id, "path": "00-context/evidence-register.md"}
|
|
752
|
+
if args.json:
|
|
753
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
754
|
+
else:
|
|
755
|
+
print(f"created {evidence_id}: 00-context/evidence-register.md")
|
|
756
|
+
else:
|
|
757
|
+
path = create_note(
|
|
758
|
+
vault, args.title, workstream=args.workstream, owner=owner,
|
|
759
|
+
doc_type=args.doc_type, sensitivity=sensitivity, link_from=args.link_from,
|
|
760
|
+
)
|
|
761
|
+
payload = {
|
|
762
|
+
"contract_version": 1, "kind": "note", "path": path.relative_to(vault).as_posix(),
|
|
763
|
+
"linked_from": args.link_from, "strict_linked": bool(args.link_from),
|
|
764
|
+
}
|
|
765
|
+
if args.json:
|
|
766
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
767
|
+
else:
|
|
768
|
+
print(f"created note: {path.relative_to(vault).as_posix()}")
|
|
769
|
+
if args.link_from:
|
|
770
|
+
print(f"linked from: {args.link_from}")
|
|
771
|
+
else:
|
|
772
|
+
print("note is not linked from a map; strict profiles may report note.orphan")
|
|
773
|
+
except FileExistsError as exc:
|
|
774
|
+
return emit_error("target_exists", str(exc), json_mode=args.json)
|
|
775
|
+
except FileNotFoundError as exc:
|
|
776
|
+
return emit_error(
|
|
777
|
+
"vault_invalid",
|
|
778
|
+
describe_os_error(exc),
|
|
779
|
+
hint="hint: restore the file from Git, or copy it from a fresh `whykit init` vault",
|
|
780
|
+
json_mode=args.json,
|
|
781
|
+
)
|
|
782
|
+
except ValueError as exc:
|
|
783
|
+
return emit_error("operation_rejected", str(exc), json_mode=args.json)
|
|
784
|
+
return 0
|