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/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