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/evidence.py ADDED
@@ -0,0 +1,178 @@
1
+ """Evidence lifecycle operations."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import datetime as dt
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .contract import TargetNotFound, describe_os_error, emit_error, vault_not_found
10
+ from .io import consistent_read, atomic_write_text, safe_vault_target, vault_mutation_lock
11
+ from .impact import analyze_impact
12
+ from .lint import HISTORICAL_STATUSES, EVIDENCE_ID_RE, _split_table_row, evidence_register, find_vault_root, is_vault_root
13
+ from .scaffold import _frontmatter_replace, _table_cell, _with_evidence_sensitivity, _register_inherited_label
14
+ from .tables import evidence_table_bounds
15
+ from .console import emit_machine
16
+
17
+
18
+ @consistent_read
19
+ def list_evidence(root: Path, *, state: str = "all") -> dict:
20
+ active, retired, _ = evidence_register(root)
21
+ items: list[dict] = []
22
+ if state in {"all", "active"}:
23
+ for evidence_id, row in active.items():
24
+ items.append({"id": evidence_id, "state": "active", **row})
25
+ if state in {"all", "retired"}:
26
+ for evidence_id, row in retired.items():
27
+ items.append({"id": evidence_id, "state": "retired", **row})
28
+ items.sort(key=lambda item: item["id"])
29
+ return {"contract_version": 1, "state": state, "count": len(items), "evidence": items}
30
+
31
+
32
+ def retire_evidence(
33
+ root: Path,
34
+ evidence_id: str,
35
+ *,
36
+ reason: str,
37
+ replaced_by: str | None = None,
38
+ today: dt.date | None = None,
39
+ ) -> dict:
40
+ today = today or dt.date.today()
41
+ if not EVIDENCE_ID_RE.fullmatch(evidence_id):
42
+ raise ValueError("evidence ID must be E-NNN")
43
+ reason = reason.strip()
44
+ if not reason:
45
+ raise ValueError("--why must explain why the evidence is being retired")
46
+
47
+ with vault_mutation_lock(root):
48
+ active, retired, occurrences = evidence_register(root)
49
+ if evidence_id in retired:
50
+ raise ValueError(f"{evidence_id} is already retired")
51
+ row = active.get(evidence_id)
52
+ if row is None:
53
+ raise TargetNotFound(f"unknown active evidence: {evidence_id}")
54
+ if sum(key == evidence_id for key, _ in occurrences) != 1:
55
+ raise ValueError("evidence ID must identify exactly one populated row")
56
+ if replaced_by:
57
+ if not EVIDENCE_ID_RE.fullmatch(replaced_by):
58
+ raise ValueError("--replaced-by must be an E-NNN identifier")
59
+ if replaced_by == evidence_id:
60
+ raise ValueError("evidence cannot replace itself")
61
+ if replaced_by not in active:
62
+ raise ValueError(f"replacement evidence must exist and be active: {replaced_by}")
63
+
64
+ impact = analyze_impact(root, evidence_id)
65
+
66
+ path = safe_vault_target(root, "00-context/evidence-register.md", create_parents=False)
67
+ original = path.read_text(encoding="utf-8")
68
+ inherited = _register_inherited_label(path, original)
69
+ if "sensitivity" in row:
70
+ original = _with_evidence_sensitivity(original, "retired", inherited)
71
+ lines = original.splitlines()
72
+ active_header, active_end, _ = evidence_table_bounds(lines, "active")
73
+ active_start = active_header + 2
74
+ row_idx = None
75
+ source = str(row.get("source") or "")
76
+ for idx in range(active_start, active_end):
77
+ cells = _split_table_row(lines[idx])
78
+ if cells and cells[0] == evidence_id:
79
+ row_idx = idx
80
+ if len(cells) > 1:
81
+ source = cells[1]
82
+ break
83
+ if row_idx is None:
84
+ raise ValueError(f"could not locate active row for {evidence_id}")
85
+
86
+ del lines[row_idx]
87
+ # Recompute retired bounds after deletion because line indexes shifted.
88
+ _, retired_end, labeled = evidence_table_bounds(lines, "retired")
89
+ values: tuple[str, ...] = (
90
+ evidence_id,
91
+ source,
92
+ today.isoformat(),
93
+ reason,
94
+ replaced_by or "—",
95
+ )
96
+ if labeled:
97
+ values += (row.get("sensitivity", inherited),)
98
+ retired_row = "| " + " | ".join(_table_cell(value) for value in values) + " |"
99
+ lines.insert(retired_end, retired_row)
100
+ updated = "\n".join(lines) + ("\n" if original.endswith("\n") else "")
101
+ updated = _frontmatter_replace(updated, "last_updated", today.isoformat())
102
+ atomic_write_text(path, updated)
103
+
104
+ return {
105
+ "contract_version": 1,
106
+ "id": evidence_id,
107
+ "state": "retired",
108
+ "retired_on": today.isoformat(),
109
+ "why": reason,
110
+ "replaced_by": replaced_by,
111
+ "references": impact.get("reference_count", 0),
112
+ # Superseded and archived records cite what was believed at the
113
+ # time; only the remainder can still need a new citation.
114
+ "current_references": sum(
115
+ 1 for item in impact.get("references", [])
116
+ if str(item.get("status") or "") not in HISTORICAL_STATUSES
117
+ ),
118
+ }
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = argparse.ArgumentParser(prog="whykit evidence", description="Inspect and manage evidence lifecycle.")
123
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
124
+ sub = parser.add_subparsers(dest="evidence_command", required=True)
125
+
126
+ listing = sub.add_parser("list", help="list active/retired evidence")
127
+ listing.add_argument("--state", choices=("all", "active", "retired"), default="all")
128
+ listing.add_argument("--json", action="store_true")
129
+
130
+ retire = sub.add_parser("retire", help="move active evidence to the retired ledger")
131
+ retire.add_argument("id")
132
+ retire.add_argument("--why", required=True, dest="reason")
133
+ retire.add_argument("--replaced-by")
134
+ retire.add_argument("--today")
135
+ retire.add_argument("--json", action="store_true")
136
+
137
+ args = parser.parse_args(argv)
138
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
139
+ if root is None or not is_vault_root(root):
140
+ return vault_not_found(args.root, json_mode=args.json)
141
+
142
+ if args.evidence_command == "list":
143
+ payload = list_evidence(root, state=args.state)
144
+ if args.json:
145
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
146
+ else:
147
+ for item in payload["evidence"]:
148
+ replacement = f" -> {item.get('replaced_by')}" if item.get("replaced_by") else ""
149
+ print(f"{item['id']} {item['state']:<7} {item.get('source') or '—'}{replacement}")
150
+ print(f"\n{payload['count']} evidence item(s)")
151
+ return 0
152
+
153
+ today = None
154
+ if args.today:
155
+ try:
156
+ today = dt.date.fromisoformat(args.today)
157
+ except ValueError:
158
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
159
+ try:
160
+ payload = retire_evidence(root, args.id, reason=args.reason, replaced_by=args.replaced_by, today=today)
161
+ except TargetNotFound as exc:
162
+ return emit_error("not_found", str(exc), json_mode=args.json)
163
+ except ValueError as exc:
164
+ return emit_error("operation_rejected", str(exc), json_mode=args.json)
165
+ except OSError as exc:
166
+ return emit_error("io_error", describe_os_error(exc), json_mode=args.json)
167
+ if args.json:
168
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
169
+ else:
170
+ replacement = f"; replacement {payload['replaced_by']}" if payload.get("replaced_by") else ""
171
+ historical = payload["references"] - payload["current_references"]
172
+ print(
173
+ f"retired {payload['id']}{replacement}; "
174
+ f"{payload['current_references']} current and {historical} historical reference(s)"
175
+ )
176
+ if payload["current_references"]:
177
+ print(f"next: `whykit impact {payload['id']}` lists what still cites it")
178
+ return 0