fde-framework 0.1.0__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 (286) hide show
  1. fde/__init__.py +3 -0
  2. fde/architect.py +152 -0
  3. fde/cli.py +2107 -0
  4. fde/costing.py +292 -0
  5. fde/decide.py +297 -0
  6. fde/decompose.py +54 -0
  7. fde/deploy.py +371 -0
  8. fde/emit.py +1309 -0
  9. fde/evolution.py +265 -0
  10. fde/factlog.py +369 -0
  11. fde/framework/approaches/ansible-playbook.md +22 -0
  12. fde/framework/approaches/assisted-deterministic.md +37 -0
  13. fde/framework/approaches/audit-only.md +23 -0
  14. fde/framework/approaches/boundary-and-audit.md +18 -0
  15. fde/framework/approaches/cascade.md +32 -0
  16. fde/framework/approaches/classical-ml.md +21 -0
  17. fde/framework/approaches/compose.md +18 -0
  18. fde/framework/approaches/decision-log.md +21 -0
  19. fde/framework/approaches/deterministic-masking.md +18 -0
  20. fde/framework/approaches/deterministic.md +25 -0
  21. fde/framework/approaches/direct-call.md +10 -0
  22. fde/framework/approaches/episodic-store.md +17 -0
  23. fde/framework/approaches/explainability-record.md +14 -0
  24. fde/framework/approaches/field-match.md +15 -0
  25. fde/framework/approaches/finetune.md +29 -0
  26. fde/framework/approaches/fixed-sequence.md +19 -0
  27. fde/framework/approaches/gitops.md +18 -0
  28. fde/framework/approaches/governed-tools.md +18 -0
  29. fde/framework/approaches/graph-retrieval.md +22 -0
  30. fde/framework/approaches/judged.md +15 -0
  31. fde/framework/approaches/keyword-search.md +11 -0
  32. fde/framework/approaches/kubernetes-manifests.md +19 -0
  33. fde/framework/approaches/labelled-metrics.md +14 -0
  34. fde/framework/approaches/llm-extraction.md +26 -0
  35. fde/framework/approaches/llm-scrubbing.md +20 -0
  36. fde/framework/approaches/llm.md +23 -0
  37. fde/framework/approaches/local-embedding.md +28 -0
  38. fde/framework/approaches/managed-api.md +49 -0
  39. fde/framework/approaches/managed-embedding.md +22 -0
  40. fde/framework/approaches/manual-runbook.md +32 -0
  41. fde/framework/approaches/model-planner.md +19 -0
  42. fde/framework/approaches/ocr-pipeline.md +13 -0
  43. fde/framework/approaches/optimisation-reasoning.md +30 -0
  44. fde/framework/approaches/optimisation.md +19 -0
  45. fde/framework/approaches/passthrough.md +11 -0
  46. fde/framework/approaches/role-scoped-authority.md +19 -0
  47. fde/framework/approaches/segmentation.md +29 -0
  48. fde/framework/approaches/self-hosted.md +38 -0
  49. fde/framework/approaches/serverless-gpu.md +27 -0
  50. fde/framework/approaches/speech-transcription.md +21 -0
  51. fde/framework/approaches/structured-logs.md +11 -0
  52. fde/framework/approaches/systemd-unit.md +30 -0
  53. fde/framework/approaches/terraform-module.md +22 -0
  54. fde/framework/approaches/text-extraction.md +14 -0
  55. fde/framework/approaches/traced.md +24 -0
  56. fde/framework/approaches/vector-search.md +17 -0
  57. fde/framework/approaches/video-ingestion.md +16 -0
  58. fde/framework/approaches/windowed-ingestion.md +25 -0
  59. fde/framework/approaches/working-state.md +13 -0
  60. fde/framework/cases/churn-scoring.md +45 -0
  61. fde/framework/cases/route-planning.md +44 -0
  62. fde/framework/cases/structured-extraction.md +48 -0
  63. fde/framework/cases/studio-style.md +46 -0
  64. fde/framework/components/accountability.md +20 -0
  65. fde/framework/components/deployment.md +19 -0
  66. fde/framework/components/embedding.md +23 -0
  67. fde/framework/components/evaluation.md +13 -0
  68. fde/framework/components/governance.md +19 -0
  69. fde/framework/components/integration.md +13 -0
  70. fde/framework/components/memory.md +21 -0
  71. fde/framework/components/observability.md +13 -0
  72. fde/framework/components/perception.md +15 -0
  73. fde/framework/components/planning.md +15 -0
  74. fde/framework/components/provisioning.md +17 -0
  75. fde/framework/components/reasoning.md +20 -0
  76. fde/framework/components/redaction.md +18 -0
  77. fde/framework/components/representation.md +13 -0
  78. fde/framework/components/retrieval.md +13 -0
  79. fde/framework/components/serving.md +20 -0
  80. fde/framework/dimensions/accelerator.md +29 -0
  81. fde/framework/dimensions/access_model.md +25 -0
  82. fde/framework/dimensions/arrival_rate.md +19 -0
  83. fde/framework/dimensions/availability_target.md +23 -0
  84. fde/framework/dimensions/cheap_path_coverage.md +21 -0
  85. fde/framework/dimensions/confidence_calibrated.md +30 -0
  86. fde/framework/dimensions/container_competence.md +22 -0
  87. fde/framework/dimensions/corpus_size.md +13 -0
  88. fde/framework/dimensions/data_residency.md +36 -0
  89. fde/framework/dimensions/environment_lifetime.md +19 -0
  90. fde/framework/dimensions/existing_cluster.md +22 -0
  91. fde/framework/dimensions/existing_iac_tool.md +25 -0
  92. fde/framework/dimensions/external_systems.md +15 -0
  93. fde/framework/dimensions/hosting.md +45 -0
  94. fde/framework/dimensions/human_waiting.md +41 -0
  95. fde/framework/dimensions/input_format.md +29 -0
  96. fde/framework/dimensions/interpretability_required.md +24 -0
  97. fde/framework/dimensions/labelled_count.md +15 -0
  98. fde/framework/dimensions/latency_budget_ms.md +16 -0
  99. fde/framework/dimensions/licence_posture.md +28 -0
  100. fde/framework/dimensions/operates_after_handover.md +25 -0
  101. fde/framework/dimensions/output_shape.md +29 -0
  102. fde/framework/dimensions/provisioning_api.md +18 -0
  103. fde/framework/dimensions/query_pattern.md +25 -0
  104. fde/framework/dimensions/recall_span.md +22 -0
  105. fde/framework/dimensions/sensitivity_present.md +22 -0
  106. fde/framework/interfaces/Generator.md +5 -0
  107. fde/framework/interfaces/Guard.md +5 -0
  108. fde/framework/interfaces/Mapper.md +5 -0
  109. fde/framework/interfaces/ModelServer.md +5 -0
  110. fde/framework/interfaces/Parser.md +5 -0
  111. fde/framework/interfaces/Planner.md +5 -0
  112. fde/framework/interfaces/Retriever.md +5 -0
  113. fde/framework/interfaces/Scorer.md +5 -0
  114. fde/framework/interfaces/Store.md +5 -0
  115. fde/framework/interfaces/ToolBoundary.md +5 -0
  116. fde/framework/interfaces/Tracer.md +5 -0
  117. fde/framework/locales/eu-gdpr.md +51 -0
  118. fde/framework/locales/in-dpdp.md +46 -0
  119. fde/framework/patterns/ansible-playbook.md +9 -0
  120. fde/framework/patterns/assisted-deterministic.md +11 -0
  121. fde/framework/patterns/audit-only.md +9 -0
  122. fde/framework/patterns/boundary-and-audit.md +12 -0
  123. fde/framework/patterns/cascade-reasoning.md +10 -0
  124. fde/framework/patterns/cascade-representation.md +10 -0
  125. fde/framework/patterns/cascade-retrieval.md +10 -0
  126. fde/framework/patterns/classical-ml-reasoning.md +15 -0
  127. fde/framework/patterns/classical-ml.md +13 -0
  128. fde/framework/patterns/compose.md +9 -0
  129. fde/framework/patterns/decision-log.md +9 -0
  130. fde/framework/patterns/deterministic-masking.md +9 -0
  131. fde/framework/patterns/deterministic.md +12 -0
  132. fde/framework/patterns/direct-call.md +12 -0
  133. fde/framework/patterns/episodic-store.md +13 -0
  134. fde/framework/patterns/explainability-record.md +9 -0
  135. fde/framework/patterns/field-match.md +12 -0
  136. fde/framework/patterns/finetune-representation.md +14 -0
  137. fde/framework/patterns/finetune.md +12 -0
  138. fde/framework/patterns/fixed-sequence.md +12 -0
  139. fde/framework/patterns/gitops.md +9 -0
  140. fde/framework/patterns/governed-tools.md +13 -0
  141. fde/framework/patterns/graph-retrieval.md +14 -0
  142. fde/framework/patterns/judged.md +14 -0
  143. fde/framework/patterns/keyword-search.md +12 -0
  144. fde/framework/patterns/kubernetes-manifests.md +9 -0
  145. fde/framework/patterns/labelled-metrics.md +13 -0
  146. fde/framework/patterns/llm-representation.md +17 -0
  147. fde/framework/patterns/llm-scrubbing.md +9 -0
  148. fde/framework/patterns/llm.md +12 -0
  149. fde/framework/patterns/local-embedding.md +9 -0
  150. fde/framework/patterns/managed-api.md +12 -0
  151. fde/framework/patterns/managed-embedding.md +9 -0
  152. fde/framework/patterns/manual-runbook.md +9 -0
  153. fde/framework/patterns/model-planner.md +13 -0
  154. fde/framework/patterns/ocr-pipeline.md +13 -0
  155. fde/framework/patterns/optimisation-reasoning.md +15 -0
  156. fde/framework/patterns/optimisation.md +13 -0
  157. fde/framework/patterns/passthrough.md +12 -0
  158. fde/framework/patterns/role-scoped-authority.md +9 -0
  159. fde/framework/patterns/segmentation.md +12 -0
  160. fde/framework/patterns/self-hosted.md +14 -0
  161. fde/framework/patterns/serverless-gpu.md +12 -0
  162. fde/framework/patterns/speech-transcription.md +10 -0
  163. fde/framework/patterns/structured-logs.md +12 -0
  164. fde/framework/patterns/systemd-unit.md +9 -0
  165. fde/framework/patterns/terraform-module.md +9 -0
  166. fde/framework/patterns/text-extraction.md +12 -0
  167. fde/framework/patterns/traced.md +13 -0
  168. fde/framework/patterns/vector-search.md +14 -0
  169. fde/framework/patterns/video-ingestion.md +9 -0
  170. fde/framework/patterns/windowed-ingestion.md +12 -0
  171. fde/framework/patterns/working-state.md +12 -0
  172. fde/framework/stacks/langgraph.md +9 -0
  173. fde/framework/stacks/local-judge.md +9 -0
  174. fde/framework/stacks/mcp.md +9 -0
  175. fde/framework/stacks/ollama.md +19 -0
  176. fde/framework/stacks/openai-judge.md +9 -0
  177. fde/framework/stacks/opentelemetry.md +9 -0
  178. fde/framework/stacks/ortools.md +9 -0
  179. fde/framework/stacks/pgvector.md +9 -0
  180. fde/framework/stacks/plain-python.md +9 -0
  181. fde/framework/stacks/qdrant.md +9 -0
  182. fde/framework/stacks/tesseract.md +9 -0
  183. fde/framework/stacks/vllm.md +9 -0
  184. fde/framework/stacks/whisper.md +15 -0
  185. fde/framework/stacks/xgboost.md +9 -0
  186. fde/framework/templates/accountability/decision-log.plain.py.j2 +64 -0
  187. fde/framework/templates/accountability/explainability-record.plain.py.j2 +90 -0
  188. fde/framework/templates/deployment/compose.plain.py.j2 +36 -0
  189. fde/framework/templates/deployment/kubernetes-manifests.plain.py.j2 +36 -0
  190. fde/framework/templates/deployment/systemd-unit.plain.py.j2 +36 -0
  191. fde/framework/templates/embedding/local-embedding.plain.py.j2 +71 -0
  192. fde/framework/templates/embedding/managed-embedding.plain.py.j2 +76 -0
  193. fde/framework/templates/evaluation/field-match.plain.py.j2 +146 -0
  194. fde/framework/templates/evaluation/judged.local-judge.py.j2 +100 -0
  195. fde/framework/templates/evaluation/judged.openai-judge.py.j2 +70 -0
  196. fde/framework/templates/evaluation/judged.plain.py.j2 +89 -0
  197. fde/framework/templates/evaluation/labelled-metrics.plain.py.j2 +64 -0
  198. fde/framework/templates/evaluation/labelled-metrics.xgboost.py.j2 +72 -0
  199. fde/framework/templates/governance/audit-only.plain.py.j2 +98 -0
  200. fde/framework/templates/governance/boundary-and-audit.plain.py.j2 +142 -0
  201. fde/framework/templates/governance/role-scoped-authority.plain.py.j2 +63 -0
  202. fde/framework/templates/integration/direct-call.plain.py.j2 +63 -0
  203. fde/framework/templates/integration/governed-tools.mcp.py.j2 +124 -0
  204. fde/framework/templates/integration/governed-tools.plain.py.j2 +153 -0
  205. fde/framework/templates/memory/episodic-store.pgvector.py.j2 +118 -0
  206. fde/framework/templates/memory/episodic-store.plain.py.j2 +147 -0
  207. fde/framework/templates/memory/working-state.plain.py.j2 +48 -0
  208. fde/framework/templates/observability/structured-logs.plain.py.j2 +61 -0
  209. fde/framework/templates/observability/traced.opentelemetry.py.j2 +65 -0
  210. fde/framework/templates/observability/traced.plain.py.j2 +132 -0
  211. fde/framework/templates/perception/ocr-pipeline.plain.py.j2 +71 -0
  212. fde/framework/templates/perception/ocr-pipeline.tesseract.py.j2 +80 -0
  213. fde/framework/templates/perception/passthrough.plain.py.j2 +41 -0
  214. fde/framework/templates/perception/speech-transcription.plain.py.j2 +37 -0
  215. fde/framework/templates/perception/speech-transcription.whisper.py.j2 +39 -0
  216. fde/framework/templates/perception/text-extraction.plain.py.j2 +88 -0
  217. fde/framework/templates/perception/video-ingestion.plain.py.j2 +35 -0
  218. fde/framework/templates/perception/windowed-ingestion.plain.py.j2 +69 -0
  219. fde/framework/templates/planning/fixed-sequence.plain.py.j2 +46 -0
  220. fde/framework/templates/planning/model-planner.langgraph.py.j2 +89 -0
  221. fde/framework/templates/planning/model-planner.plain.py.j2 +82 -0
  222. fde/framework/templates/planning/optimisation.ortools.py.j2 +90 -0
  223. fde/framework/templates/planning/optimisation.plain.py.j2 +71 -0
  224. fde/framework/templates/provisioning/ansible-playbook.plain.py.j2 +29 -0
  225. fde/framework/templates/provisioning/gitops.plain.py.j2 +29 -0
  226. fde/framework/templates/provisioning/manual-runbook.plain.py.j2 +29 -0
  227. fde/framework/templates/provisioning/terraform-module.plain.py.j2 +29 -0
  228. fde/framework/templates/reasoning/cascade.plain.py.j2 +86 -0
  229. fde/framework/templates/reasoning/classical-ml.plain.py.j2 +74 -0
  230. fde/framework/templates/reasoning/classical-ml.xgboost.py.j2 +104 -0
  231. fde/framework/templates/reasoning/finetune.plain.py.j2 +73 -0
  232. fde/framework/templates/reasoning/llm.plain.py.j2 +114 -0
  233. fde/framework/templates/reasoning/optimisation.ortools.py.j2 +90 -0
  234. fde/framework/templates/reasoning/optimisation.plain.py.j2 +58 -0
  235. fde/framework/templates/redaction/deterministic-masking.plain.py.j2 +44 -0
  236. fde/framework/templates/redaction/llm-scrubbing.plain.py.j2 +40 -0
  237. fde/framework/templates/representation/assisted.plain.py.j2 +86 -0
  238. fde/framework/templates/representation/cascade.plain.py.j2 +119 -0
  239. fde/framework/templates/representation/classical-ml.plain.py.j2 +74 -0
  240. fde/framework/templates/representation/classical-ml.xgboost.py.j2 +104 -0
  241. fde/framework/templates/representation/deterministic.plain.py.j2 +101 -0
  242. fde/framework/templates/representation/finetune.plain.py.j2 +68 -0
  243. fde/framework/templates/representation/llm.plain.py.j2 +94 -0
  244. fde/framework/templates/representation/segmentation.plain.py.j2 +68 -0
  245. fde/framework/templates/retrieval/cascade.plain.py.j2 +86 -0
  246. fde/framework/templates/retrieval/graph-retrieval.pgvector.py.j2 +88 -0
  247. fde/framework/templates/retrieval/graph-retrieval.plain.py.j2 +74 -0
  248. fde/framework/templates/retrieval/graph-retrieval.qdrant.py.j2 +71 -0
  249. fde/framework/templates/retrieval/keyword-search.plain.py.j2 +104 -0
  250. fde/framework/templates/retrieval/vector-search.pgvector.py.j2 +100 -0
  251. fde/framework/templates/retrieval/vector-search.plain.py.j2 +84 -0
  252. fde/framework/templates/retrieval/vector-search.qdrant.py.j2 +60 -0
  253. fde/framework/templates/serving/managed-api.plain.py.j2 +74 -0
  254. fde/framework/templates/serving/self-hosted.ollama.py.j2 +47 -0
  255. fde/framework/templates/serving/self-hosted.plain.py.j2 +117 -0
  256. fde/framework/templates/serving/self-hosted.vllm.py.j2 +77 -0
  257. fde/framework/templates/serving/serverless-gpu.plain.py.j2 +62 -0
  258. fde/gates.py +480 -0
  259. fde/graph.py +435 -0
  260. fde/implement.py +250 -0
  261. fde/intake/__init__.py +0 -0
  262. fde/intake/answers.py +154 -0
  263. fde/intake/documents.py +121 -0
  264. fde/intake/interview.py +218 -0
  265. fde/intake/llm_reader.py +336 -0
  266. fde/intake/prose.py +387 -0
  267. fde/intake/samples.py +369 -0
  268. fde/models/__init__.py +0 -0
  269. fde/models/base.py +86 -0
  270. fde/models/fact.py +37 -0
  271. fde/models/profile.py +159 -0
  272. fde/models/respondent.py +34 -0
  273. fde/models/schema.py +430 -0
  274. fde/moves.py +136 -0
  275. fde/ops.py +349 -0
  276. fde/predicate.py +106 -0
  277. fde/realization.py +109 -0
  278. fde/registry.py +162 -0
  279. fde/scan.py +479 -0
  280. fde/space.py +172 -0
  281. fde/workflow.py +233 -0
  282. fde_framework-0.1.0.dist-info/METADATA +449 -0
  283. fde_framework-0.1.0.dist-info/RECORD +286 -0
  284. fde_framework-0.1.0.dist-info/WHEEL +4 -0
  285. fde_framework-0.1.0.dist-info/entry_points.txt +2 -0
  286. fde_framework-0.1.0.dist-info/licenses/LICENSE +202 -0
fde/cli.py ADDED
@@ -0,0 +1,2107 @@
1
+ """The command line.
2
+
3
+ `fde kb validate` is strict by default because CI runs it, and a warning nobody
4
+ reads is not a check. `--lenient` exists for the hour when you are mid-way
5
+ through authoring content and the links do not resolve yet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from datetime import date
13
+ from pathlib import Path
14
+ from typing import Annotated
15
+
16
+ import typer
17
+ import yaml
18
+
19
+ from fde.architect import architect as build_architecture
20
+ from fde.emit import BuildRefused, emit
21
+ from fde.evolution import (
22
+ Observation,
23
+ Override,
24
+ Prediction,
25
+ calibration,
26
+ emit_case,
27
+ sweep_triggers,
28
+ )
29
+ from fde.factlog import Session, load_engagement, start_engagement
30
+ from fde.gates import HardGate, input_status, validate_baseline
31
+ from fde.graph import find_gaps, validate_links
32
+ from fde.intake.answers import parse_answer
33
+ from fde.intake.documents import UnreadableDocument, read_document
34
+ from fde.intake.interview import remaining_questions
35
+ from fde.intake.prose import parse_prose, restate
36
+ from fde.intake.samples import (
37
+ ContractConflict,
38
+ assess,
39
+ build_eval_set,
40
+ infer_contract,
41
+ infer_metrics,
42
+ load_pairs,
43
+ samples_to_facts,
44
+ )
45
+ from fde.models.base import Provenance
46
+ from fde.models.fact import Fact
47
+ from fde.models.profile import Profile
48
+ from fde.models.respondent import Respondent, Role
49
+ from fde.predicate import PredicateError, holds
50
+ from fde.registry import KINDS, RegistryError, is_empty, load_registry
51
+ from fde.scan import (
52
+ GPU,
53
+ Hardware,
54
+ detect,
55
+ finetune_feasible,
56
+ fits,
57
+ scan_facts,
58
+ suggest,
59
+ )
60
+ from fde.space import Contradiction, Space
61
+
62
+ app = typer.Typer(help="Take an engagement from problem statement to a runnable project.")
63
+ kb = typer.Typer(help="Inspect the knowledge base in framework/.")
64
+ app.add_typer(kb, name="kb")
65
+
66
+ def _default_registry_root() -> Path:
67
+ """A checkout's ./framework when present, else the copy in the wheel.
68
+
69
+ Local first, always: a contributor editing the corpus must see their
70
+ edits, not the packaged snapshot. The packaged copy is what makes
71
+ `pip install fde-framework` a working tool rather than a tool with no
72
+ knowledge base.
73
+ """
74
+ local = Path("framework")
75
+ if local.is_dir():
76
+ return local
77
+ try:
78
+ from importlib.resources import files
79
+
80
+ packaged = Path(str(files("fde") / "framework"))
81
+ if packaged.is_dir():
82
+ return packaged
83
+ except (ImportError, TypeError):
84
+ pass
85
+ return local
86
+
87
+
88
+ DEFAULT_ROOT = _default_registry_root()
89
+
90
+ # What `fde retro` writes, and the only shape ingest will treat as a filename.
91
+ CASE_ID = re.compile(r"case-[0-9a-f]{6,32}")
92
+
93
+
94
+ @kb.command("validate")
95
+ def kb_validate(
96
+ root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
97
+ lenient: Annotated[
98
+ bool, typer.Option(help="Report dangling links without failing.")
99
+ ] = False,
100
+ ) -> None:
101
+ """Check that everything parses and every cross-reference resolves."""
102
+ try:
103
+ registry = load_registry(root)
104
+ except RegistryError as exc:
105
+ typer.echo(str(exc), err=True)
106
+ raise typer.Exit(1) from exc
107
+
108
+ errors = validate_links(registry)
109
+ for error in errors:
110
+ typer.echo(f"{error.source}: {error.message}", err=not lenient)
111
+
112
+ counts = ", ".join(
113
+ f"{len(getattr(registry, kind))} {kind}"
114
+ for kind in KINDS
115
+ if getattr(registry, kind, None)
116
+ )
117
+ typer.echo(f"loaded {counts or 'nothing'}")
118
+
119
+ if errors and not lenient:
120
+ typer.echo(f"{len(errors)} broken reference(s)", err=True)
121
+ raise typer.Exit(1)
122
+
123
+
124
+ @app.command("start")
125
+ def start(
126
+ name: Annotated[str, typer.Argument(help="Engagement name.")],
127
+ base: Annotated[Path, typer.Option(help="Where engagements live.")] = Path("engagements"),
128
+ statement: Annotated[
129
+ str | None, typer.Option(help="The problem, in prose. Optional.")
130
+ ] = None,
131
+ ) -> None:
132
+ """Begin an engagement.
133
+
134
+ A statement is optional: an FDE who only answers questions is a supported
135
+ path, and so is pasting prose later.
136
+ """
137
+ try:
138
+ engagement = start_engagement(base, name, statement=statement)
139
+ except FileExistsError as exc:
140
+ typer.echo(str(exc), err=True)
141
+ raise typer.Exit(1) from exc
142
+
143
+ typer.echo(f"started {engagement.root}")
144
+ typer.echo(" facts/ one file per session, append-only")
145
+ typer.echo(" artifacts/ drop specs, schemas and sample pairs here")
146
+ if not statement:
147
+ typer.echo("\nNo statement yet. Add prose later, or start answering questions.")
148
+
149
+
150
+ @app.command("status")
151
+ def status(
152
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
153
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
154
+ ) -> None:
155
+ """What is known, what is contested, and who said it."""
156
+ engagement = _engagement(root)
157
+ profile = engagement.profile
158
+ # Lenient on purpose: status is the one command that must answer even
159
+ # with no registry in reach -- the gates fall back rather than fail.
160
+ try:
161
+ registry = load_registry(registry_root)
162
+ except RegistryError:
163
+ registry = None
164
+
165
+ # An empty profile is not an empty engagement: a baseline, a waiver or a
166
+ # restated problem are all state the gates judge, facts or no facts.
167
+ if profile.is_empty():
168
+ typer.echo("no facts recorded yet")
169
+
170
+ resolved = profile.values()
171
+ if resolved:
172
+ typer.echo(f"known ({len(resolved)})")
173
+ # Grouped by scope, so discovery reads as the systematic exercise it
174
+ # is -- and the empty group is as loud as the full one: a design with
175
+ # its functional scope settled and its non-functional scope blank is
176
+ # a specific, familiar kind of trouble.
177
+ by_scope: dict[str, list[str]] = {}
178
+ for dimension in sorted(resolved):
179
+ entry = registry.dimensions.get(dimension) if registry else None
180
+ scope = str(entry.scope) if entry else "other"
181
+ by_scope.setdefault(scope, []).append(dimension)
182
+ order = ("functional", "non_functional", "data", "environment",
183
+ "operational", "commercial", "other")
184
+ labels = {"functional": "functional scope",
185
+ "non_functional": "non-functional scope",
186
+ "data": "data scope", "environment": "environment",
187
+ "operational": "operations", "commercial": "commercial",
188
+ "other": "other"}
189
+ for scope in order:
190
+ dims = by_scope.get(scope)
191
+ if not dims:
192
+ continue
193
+ typer.echo(f" {labels[scope]}:")
194
+ for dimension in dims:
195
+ fact = profile.fact(dimension)
196
+ if fact is None:
197
+ # Peers standing together: several values, each with its
198
+ # own speaker, resolved as the union.
199
+ for peer in profile.peers(dimension):
200
+ shown = " ".join(str(peer.value).split())
201
+ typer.echo(
202
+ f" {dimension} += {shown} [{_who(peer)}]"
203
+ )
204
+ continue
205
+ shown = " ".join(str(fact.value).split())
206
+ typer.echo(f" {dimension} = {shown} [{_who(fact)}]")
207
+ if registry:
208
+ space = Space.from_registry(registry).apply(profile)
209
+ unsettled, implied = {}, []
210
+ for entry in registry.dimensions.values():
211
+ if entry.weight <= 0 or profile.resolved(entry.id):
212
+ continue
213
+ in_space = entry.values and entry.id in space.dimensions()
214
+ surviving = space.surviving(entry.id) if in_space else set()
215
+ if in_space and len(surviving) == 1:
216
+ # Settled by implication: an earlier answer pruned every
217
+ # other value. The interview will never offer it again,
218
+ # so listing it as open sends somebody to schedule a
219
+ # conversation the framework would refuse to have.
220
+ implied.append(f"{entry.id} = {next(iter(surviving))}")
221
+ continue
222
+ unsettled.setdefault(str(entry.scope), []).append(entry.id)
223
+ if implied:
224
+ typer.echo(
225
+ f" settled by implication -- {', '.join(sorted(implied))}"
226
+ )
227
+ gaps_line = " · ".join(
228
+ f"{labels.get(s, s)}: {', '.join(sorted(d))}"
229
+ for s, d in sorted(unsettled.items()) if d
230
+ )
231
+ if gaps_line:
232
+ typer.echo(f" still open -- {gaps_line}")
233
+
234
+ status = _gate_status(engagement, registry)
235
+ typer.echo(f"\n{status.completeness:.0%} of what gets decided is settled")
236
+
237
+ stored = engagement.gate_state().get("overrides", [])
238
+ applied_names = {o.gate for o in status.overridden}
239
+ idle = [w for w in stored if w["gate"] not in applied_names]
240
+ if idle:
241
+ # A waiver on file that does not take is state somebody wrote and
242
+ # nobody can see: progress on a partial baseline once un-waived the
243
+ # gate and nothing anywhere said why build stopped proceeding.
244
+ typer.echo(f"\nwaivers on file, not applied ({len(idle)})")
245
+ for waiver in idle:
246
+ gate_now = next((g for g in status.gates if g.name == waiver["gate"]), None)
247
+ if gate_now is None:
248
+ why = "no such gate"
249
+ elif gate_now.passed:
250
+ why = "the gate passes on its own"
251
+ else:
252
+ why = (f"granted against {waiver.get('against') or 'nothing recorded'!r}; "
253
+ f"the gate now says {gate_now.reason!r} -- waive again "
254
+ f"if the new problem is also accepted")
255
+ typer.echo(f" {waiver['gate']}: {why}")
256
+
257
+ if status.overridden:
258
+ typer.echo(f"\nwaived ({len(status.overridden)})")
259
+ for waived in status.overridden:
260
+ typer.echo(f" {waived.gate}: {waived.reason}")
261
+
262
+ blocking = status.blocked_by()
263
+ if blocking:
264
+ typer.echo(f"\nblocked by {len(blocking)}")
265
+ for name in blocking:
266
+ gate = status.gate(name)
267
+ mark = " [hard] " if gate.hard else " "
268
+ typer.echo(f"{mark}{name}: {gate.reason}")
269
+ if gate.remedy:
270
+ typer.echo(f" -> {gate.remedy}")
271
+
272
+ if status.missing_roles:
273
+ typer.echo(f"\nnobody has spoken for: {', '.join(status.missing_roles)}")
274
+
275
+ # Disagreement is the most valuable thing discovery produces. It goes last so
276
+ # it is the final thing on screen, and it is never summarised away.
277
+ disagreements = profile.disagreements()
278
+ if disagreements:
279
+ typer.echo(f"\nunresolved -- respondents disagree ({len(disagreements)})")
280
+ for d in disagreements:
281
+ typer.echo(f" {d.dimension}")
282
+ for fact in d.facts:
283
+ typer.echo(f" {_who(fact)} says {fact.value}")
284
+
285
+
286
+ def _who(fact) -> str:
287
+ """Name and role together.
288
+
289
+ The role is not decoration: it is what tells an FDE whose answer to weigh
290
+ for which dimension. A sponsor on latency and a user on latency are
291
+ different kinds of claim.
292
+ """
293
+ role = str(fact.respondent.role)
294
+ name = fact.respondent.name
295
+ return f"{name}, {role}" if name else role
296
+
297
+
298
+ @app.command("frame")
299
+ def frame(
300
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
301
+ text: Annotated[str | None, typer.Option(help="The brief, inline.")] = None,
302
+ file: Annotated[Path | None, typer.Option(help="A file holding the brief.")] = None,
303
+ reader: Annotated[str, typer.Option(
304
+ help="'deterministic' (default, offline) or 'llm': a model proposes "
305
+ "facts for what the deterministic pass left open, at the weakest "
306
+ "provenance, validated against the registry."
307
+ )] = "deterministic",
308
+ endpoint: Annotated[str | None, typer.Option(
309
+ help="OpenAI-compatible local server for --reader llm (vLLM/Ollama "
310
+ "on this machine). Without it, the hosted model is used -- "
311
+ "which the boundary doctrine only permits when data may leave."
312
+ )] = None,
313
+ model: Annotated[str | None, typer.Option(
314
+ help="Model name for --reader llm."
315
+ )] = None,
316
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
317
+ ) -> None:
318
+ """Read prose into facts, and play back what was understood."""
319
+ if not text and not file:
320
+ typer.echo("Give me --text or --file.", err=True)
321
+ raise typer.Exit(1)
322
+
323
+ try:
324
+ body = read_document(file) if file else (text or "")
325
+ except UnreadableDocument as exc:
326
+ typer.echo(str(exc), err=True)
327
+ raise typer.Exit(1) from exc
328
+ source = file.name if file else "brief"
329
+ registry = _registry(registry_root)
330
+ engagement = _engagement(root)
331
+
332
+ declines: list[str] = []
333
+ facts = parse_prose(body, registry, source=source, declines=declines)
334
+ typer.echo(restate(facts, registry))
335
+ for decline in declines:
336
+ typer.echo(f"\n declined -- {decline}")
337
+
338
+ # The follow-ups, right where the gap appears. The interview is the full
339
+ # instrument; these are the three questions that most change the design,
340
+ # so a statement never dead-ends at "correct anything wrong".
341
+ with_new = _with_all(engagement.profile, facts)
342
+ space_now = Space.from_registry(registry).apply(with_new)
343
+ follow_ups = remaining_questions(space_now, with_new, registry)[:3]
344
+ if follow_ups:
345
+ typer.echo("\nworth asking next (fde ask <eng> --role <who>):")
346
+ for question in follow_ups:
347
+ roles = "/".join(question.roles) if question.roles else "anyone"
348
+ typer.echo(f" - {question.asks} [{roles}]")
349
+
350
+ if reader == "llm":
351
+ from fde.intake.llm_reader import (
352
+ BoundaryRefusal,
353
+ ReaderUnavailable,
354
+ read_with_llm,
355
+ )
356
+
357
+ already = dict(engagement.profile.values())
358
+ already.update({f.dimension: f.value for f in facts})
359
+ try:
360
+ proposed, dropped = read_with_llm(
361
+ body, registry, already, endpoint=endpoint, model=model,
362
+ )
363
+ except (BoundaryRefusal, ReaderUnavailable) as exc:
364
+ typer.echo(f"\n{exc}", err=True)
365
+ proposed, dropped = [], []
366
+ if proposed:
367
+ typer.echo(
368
+ "\nThe model also read (weakest provenance -- any stated "
369
+ "answer outranks these; correct anything wrong):"
370
+ )
371
+ for fact in proposed:
372
+ shown = " ".join(str(fact.value).split())
373
+ typer.echo(f" - {fact.dimension} = {shown}")
374
+ facts = facts + proposed
375
+ for reason in dropped:
376
+ typer.echo(f" (refused from the model: {reason})")
377
+ elif reader != "deterministic":
378
+ typer.echo(f"{reader!r} is not a reader. One of: deterministic, llm.",
379
+ err=True)
380
+ raise typer.Exit(1)
381
+
382
+ # An empty session file is noise in an append-only log.
383
+ if not facts:
384
+ return
385
+
386
+ session_id = _next_session_id(engagement, "frame")
387
+ # The brief itself is retained beside the facts. Every fact carries a
388
+ # span pointing into this text; a span into a document nobody kept is a
389
+ # citation to nowhere -- and the (text, facts) pair is the training
390
+ # example a future fine-tuned reader learns from. Client data, in the
391
+ # engagement directory, never committed: same rules as everything here.
392
+ briefs = engagement.artifacts_dir / "briefs"
393
+ briefs.mkdir(parents=True, exist_ok=True)
394
+ (briefs / f"{session_id}.txt").write_text(body)
395
+
396
+ engagement.append(
397
+ Session(
398
+ session_id=session_id,
399
+ respondent=Respondent(role=Role.SYSTEM),
400
+ facts=facts,
401
+ )
402
+ )
403
+
404
+
405
+ @app.command("samples")
406
+ def samples_cmd(
407
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
408
+ file: Annotated[Path, typer.Option(help="A .jsonl of input/output pairs.")],
409
+ sensitive: Annotated[list[str] | None, typer.Option(
410
+ "--sensitive",
411
+ help="Mark a field as sensitive (repeatable). Declared beats "
412
+ "detected: the masking the build emits reads this list."
413
+ )] = None,
414
+ ) -> None:
415
+ """Read sample pairs: the contract, the metric, and the golden set.
416
+
417
+ The most valuable thing a client hands over. A brief describes the problem;
418
+ these describe the answer.
419
+ """
420
+ engagement = _engagement(root)
421
+ try:
422
+ body = file.read_text()
423
+ pairs = load_pairs(file)
424
+ contract = infer_contract(pairs)
425
+ except (ContractConflict, ValueError, OSError) as exc:
426
+ typer.echo(str(exc), err=True)
427
+ raise typer.Exit(1) from exc
428
+
429
+ # PII lives in inputs at least as often as in outputs, so a mark may
430
+ # name either side of the pair.
431
+ known_fields = set(contract.fields)
432
+ for pair in pairs:
433
+ if isinstance(pair.get("input"), dict):
434
+ known_fields.update(pair["input"])
435
+ unknown = sorted(set(sensitive or []) - known_fields)
436
+ if unknown:
437
+ typer.echo(
438
+ f"not fields in these pairs: {', '.join(unknown)}. "
439
+ f"Fields: {', '.join(sorted(known_fields))}", err=True,
440
+ )
441
+ raise typer.Exit(1)
442
+
443
+ # Copied before anything is reported, so a failure here cannot arrive
444
+ # after a success message has already scrolled past.
445
+ (engagement.artifacts_dir / "pairs.jsonl").write_text(body)
446
+ # The holdout stays with the engagement and never ships in a delivery:
447
+ # golden.jsonl sits readable beside the exam, so a green that was
448
+ # memorized from it is caught only by cases the implementer never saw.
449
+ from fde.intake.samples import split_pairs
450
+
451
+ holdout_ids = set(split_pairs(pairs).holdout_ids)
452
+ if holdout_ids:
453
+ (engagement.artifacts_dir / "holdout.jsonl").write_text("".join(
454
+ json.dumps(pair) + "\n" for pair in pairs
455
+ if pair.get("id") in holdout_ids
456
+ ))
457
+ if sensitive:
458
+ (engagement.artifacts_dir / "sensitive_fields.json").write_text(
459
+ json.dumps(sorted(set(sensitive)))
460
+ )
461
+
462
+ suite = build_eval_set(pairs)
463
+ typer.echo(f"{len(pairs)} pairs, {len(contract.fields)} fields\n")
464
+ for name, entry in sorted(contract.fields.items()):
465
+ marks = " ".join(
466
+ m for m in ("required" if entry.required else "optional", entry.sensitivity or "")
467
+ if m
468
+ )
469
+ typer.echo(f" {name:24} {entry.type:8} {marks}")
470
+
471
+ typer.echo(f"\nmetric: {', '.join(infer_metrics(contract))}")
472
+ typer.echo(
473
+ f"evals: {len(suite.golden)} golden, {len(suite.edge_case)} edge, "
474
+ f"{len(suite.adversarial)} adversarial"
475
+ )
476
+ unverified = sum(1 for pair in pairs if not pair.get("verified"))
477
+ if unverified:
478
+ typer.echo(
479
+ f"\n{unverified} pair(s) carry no `verified: true`, so they were "
480
+ f"kept for mining, never for measurement -- an unchecked example "
481
+ f"cannot be ground truth. Mark the ones a person has actually "
482
+ f"checked and re-run; the golden set is built only from those."
483
+ )
484
+ for warning in assess(pairs):
485
+ typer.echo(f"\n{warning}")
486
+
487
+ facts = samples_to_facts(pairs)
488
+ if sensitive and not any(f.dimension == "sensitivity_present" for f in facts):
489
+ facts.append(Fact("sensitivity_present", True, Provenance.ARTIFACT,
490
+ source="sample pairs, marked by hand"))
491
+ engagement.append(
492
+ Session(
493
+ session_id=_next_session_id(engagement, "samples"),
494
+ respondent=Respondent(role=Role.SYSTEM),
495
+ facts=facts,
496
+ )
497
+ )
498
+
499
+
500
+ @app.command("ask")
501
+ def ask(
502
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
503
+ role: Annotated[str, typer.Option(help="Who you are talking to.")],
504
+ name: Annotated[str | None, typer.Option(help="Their name, for the record.")] = None,
505
+ scope: Annotated[str | None, typer.Option(
506
+ help="Limit to one scope axis: functional, non_functional, data, "
507
+ "environment, operational, commercial."
508
+ )] = None,
509
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
510
+ ) -> None:
511
+ """Interview one person.
512
+
513
+ Questions are scoped to what this role can answer and ordered by how much
514
+ the answer changes. Press enter to skip anything -- an intake that cannot
515
+ get past an unknown is an intake that stops.
516
+ """
517
+ registry = _registry(registry_root)
518
+ engagement = _engagement(root)
519
+ try:
520
+ parsed_role = Role(role)
521
+ except ValueError as exc:
522
+ legal = ", ".join(r.value for r in Role if r is not Role.SYSTEM)
523
+ typer.echo(f"{role!r} is not a role here. Interviewable: {legal}", err=True)
524
+ raise typer.Exit(1) from exc
525
+ respondent = Respondent(role=parsed_role, name=name)
526
+
527
+ if scope:
528
+ from fde.models.schema import Scope
529
+
530
+ legal = [s.value for s in Scope]
531
+ if scope not in legal:
532
+ typer.echo(f"{scope!r} is not a scope axis. One of: {', '.join(legal)}",
533
+ err=True)
534
+ raise typer.Exit(1)
535
+
536
+ space = Space.from_registry(registry).apply(engagement.profile)
537
+ profile = engagement.profile
538
+ gathered: list[Fact] = []
539
+
540
+ # Declining to answer means "not from me, not now" -- never "this can never
541
+ # be known". Held beside the space rather than written into it, so a later
542
+ # answer can still settle it by cascade.
543
+ passed_on: set[str] = set()
544
+
545
+ while question := _next(space, profile, registry, role, passed_on, scope):
546
+ if question.contest_of:
547
+ typer.echo(f"\n {question.contest_of} -- confirm, correct, or skip.")
548
+ answer = _put(registry.dimensions[question.resolves], question)
549
+ if answer is None:
550
+ break # end of input: keep what we have
551
+ if question.contest_of:
552
+ # Asked and answered, either way: a confirmation must retire the
553
+ # question, or the same prompt is re-offered the moment the loop
554
+ # comes round -- the holder has not changed.
555
+ passed_on.add(question.resolves)
556
+ if answer.skipped:
557
+ passed_on.add(question.resolves)
558
+ continue
559
+
560
+ dimension_entry = registry.dimensions[question.resolves]
561
+ answered_values = (
562
+ list(answer.value) if isinstance(answer.value, tuple) else [answer.value]
563
+ )
564
+ new_facts = [
565
+ Fact(
566
+ question.resolves,
567
+ value,
568
+ Provenance.INTERVIEW,
569
+ kind=dimension_entry.kind,
570
+ # Stamped now, not only when the session is written: the live
571
+ # profile drives the contest offers, and a fact with no speaker
572
+ # was offered back to its own speaker as "system said X".
573
+ respondent=respondent,
574
+ additive=dimension_entry.multi_valued,
575
+ )
576
+ for value in answered_values
577
+ ]
578
+ try:
579
+ # A contested dimension stays out of the space: the space would
580
+ # call the second answer a contradiction, but two people
581
+ # differing is a finding, and the profile records it as one.
582
+ if (question.resolves in space.dimensions() and not question.contest_of
583
+ and not isinstance(answer.value, tuple)
584
+ and not dimension_entry.multi_valued):
585
+ space = space.answer(question.resolves, answer.value)
586
+ except Contradiction as exc:
587
+ typer.echo(f" that conflicts: {exc}")
588
+ continue
589
+
590
+ if question.contest_of:
591
+ _warn_if_impossible(question.resolves, answer.value, profile, registry)
592
+
593
+ gathered.extend(new_facts)
594
+ for new_fact in new_facts:
595
+ profile = _with(profile, new_fact)
596
+
597
+ if not gathered:
598
+ typer.echo("Nothing recorded.")
599
+ return
600
+
601
+ engagement.append(
602
+ Session(
603
+ session_id=_next_session_id(engagement, role),
604
+ respondent=respondent,
605
+ facts=gathered,
606
+ )
607
+ )
608
+ typer.echo(f"\nRecorded {len(gathered)} answer(s) from {respondent}.")
609
+
610
+
611
+ def _with_all(profile, facts):
612
+ for fact in facts:
613
+ profile = _with(profile, fact)
614
+ return profile
615
+
616
+
617
+ def _warn_if_impossible(dimension, value, profile, registry):
618
+ """A contested answer bypasses the space on purpose -- two people
619
+ differing is a finding. But a contesting value the rest of this
620
+ engagement's own answers rule out is not a difference of view, it is a
621
+ contradiction wearing one, and recording it silently lets a physically
622
+ impossible option stand as an open question."""
623
+ probe_profile = Profile()
624
+ probe_profile.ingest([
625
+ f
626
+ for d in profile.dimensions()
627
+ for f in profile.history(d)
628
+ if d != dimension
629
+ ])
630
+ probe = Space.from_registry(registry).apply(probe_profile)
631
+ if dimension in probe.dimensions() and value not in probe.surviving(dimension):
632
+ typer.echo(
633
+ f" recorded as disagreement -- but note: {value!r} is ruled out "
634
+ f"by other answers in this engagement, so one side of this "
635
+ f"disagreement is a contradiction, not a viewpoint."
636
+ )
637
+
638
+
639
+ def _put(dimension, question):
640
+ """Ask until the answer is usable, or the person declines to give one.
641
+
642
+ End of input ends the interview rather than aborting it: whatever was
643
+ gathered up to that point is still worth recording.
644
+ """
645
+ while True:
646
+ try:
647
+ reply = typer.prompt(f"\n{question.asks}", default="", show_default=False)
648
+ except (EOFError, typer.Abort):
649
+ return None
650
+ answer = parse_answer(dimension, reply)
651
+ if answer.usable or answer.skipped:
652
+ return answer
653
+ typer.echo(f" {answer.probe}")
654
+
655
+
656
+ def _next(space, profile, registry, role, passed_on, scope=None):
657
+ """The next question this person has not already declined."""
658
+ for question in remaining_questions(space, profile, registry, role=role, scope=scope):
659
+ if question.resolves not in passed_on:
660
+ return question
661
+ return None
662
+
663
+
664
+ def _with(profile: Profile, fact: Fact) -> Profile:
665
+ fresh = Profile()
666
+ for dimension in profile.dimensions():
667
+ fresh.ingest(profile.history(dimension))
668
+ fresh.ingest([fact])
669
+ return fresh
670
+
671
+
672
+ def _next_session_id(engagement, label: str) -> str:
673
+ existing = len(list(engagement.facts_dir.glob("*.yaml")))
674
+ return f"{existing + 1:04d}-{label}"
675
+
676
+
677
+ def _says_something(text: str | None) -> bool:
678
+ """Whether this is a sentence or an empty gesture.
679
+
680
+ str.strip() removes ASCII whitespace and nothing else, so a zero-width
681
+ space passes it -- which was enough to satisfy the one gate the
682
+ framework says cannot be worked around.
683
+ """
684
+ return bool(text) and any(ch.isalnum() for ch in text)
685
+
686
+
687
+ def _registry(root: Path):
688
+ """Load the registry or say plainly why not.
689
+
690
+ Engagement commands hit this from any working directory; the default
691
+ root is relative, so the classic failure is running from the wrong one
692
+ -- which deserves the one-line answer, not a stack trace.
693
+ """
694
+ try:
695
+ registry = load_registry(root)
696
+ except RegistryError as exc:
697
+ typer.echo(str(exc), err=True)
698
+ raise typer.Exit(1) from exc
699
+ if is_empty(registry):
700
+ # An entry-less directory is the wrong directory, not a partial
701
+ # registry. Deciding from one produces an architecture of nothing,
702
+ # and retro would rewrite a captured case with it.
703
+ typer.echo(
704
+ f"{root}: no registry entries here, so nothing can be decided "
705
+ f"from it. Point --registry at a registry.", err=True,
706
+ )
707
+ raise typer.Exit(1)
708
+ return registry
709
+
710
+
711
+ def _engagement(root: Path):
712
+ """Load an engagement or say plainly why not.
713
+
714
+ Every command goes through here: a missing directory or a corrupt session
715
+ file is a one-line explanation, never a traceback -- a stack trace at a
716
+ client site reads as the tool being broken rather than the input.
717
+ """
718
+ try:
719
+ engagement = load_engagement(root)
720
+ # Read once here so a hand-edited gates.yaml fails as a sentence
721
+ # from whichever command touched it, not as a TypeError deep in the
722
+ # gate logic.
723
+ engagement.gate_state()
724
+ return engagement
725
+ except FileNotFoundError as exc:
726
+ typer.echo(str(exc), err=True)
727
+ raise typer.Exit(1) from exc
728
+ except ValueError as exc:
729
+ typer.echo(f"cannot read the engagement: {exc}", err=True)
730
+ raise typer.Exit(1) from exc
731
+
732
+
733
+ def _reuse(engagement) -> set[str]:
734
+ """Stacks the client already operates, recorded by `fde reuse`.
735
+
736
+ Reuse beats adoption: a tool somebody already patches and pages for is
737
+ cheaper than the same capability standing beside it. This is the file
738
+ that finally feeds that rule -- the mechanism existed from the start
739
+ and nothing on the user's side could reach it.
740
+ """
741
+ marker = engagement.root / "reuse"
742
+ if not marker.exists():
743
+ return set()
744
+ return {line.strip() for line in marker.read_text().splitlines() if line.strip()}
745
+
746
+
747
+ def _overrides(engagement) -> dict[str, dict]:
748
+ """Recorded overrides, last one per component winning.
749
+
750
+ Read wherever an architecture is built, because an override recorded and
751
+ then ignored breaks the promise made when it was recorded.
752
+ """
753
+ path = engagement.root / "overrides.jsonl"
754
+ if not path.exists():
755
+ return {}
756
+ out: dict[str, dict] = {}
757
+ for line in path.read_text().splitlines():
758
+ if not line.strip():
759
+ continue
760
+ try:
761
+ record = json.loads(line)
762
+ except json.JSONDecodeError:
763
+ continue
764
+ if record.get("component") and record.get("chosen"):
765
+ out[record["component"]] = record
766
+ return out
767
+
768
+
769
+ def _gate_status(engagement, registry=None):
770
+ """The gates, judged against everything the engagement has recorded.
771
+
772
+ Waivers stored on disk are re-applied here rather than baked into the
773
+ verdict, so a hand-edited waiver of the hard gate simply does not take:
774
+ the gate stays in blocked_by, visibly, instead of quietly vanishing.
775
+ """
776
+ state = engagement.gate_state()
777
+ licences = None
778
+ if registry is not None:
779
+ # The architecture as it would build now, overrides included --
780
+ # the licence gate judges the combination, and only a built set of
781
+ # realizations knows the combination.
782
+ licences = build_architecture(
783
+ engagement.profile, registry, overrides=_overrides(engagement),
784
+ already_running=_reuse(engagement),
785
+ ).licences
786
+ status = input_status(
787
+ engagement.profile,
788
+ baseline=engagement.baseline(),
789
+ data_access=bool(state.get("data_access")),
790
+ security_review=bool(state.get("security_review")),
791
+ registry=registry,
792
+ licences=licences,
793
+ original_statement=(
794
+ engagement.original_statement().text if engagement.original_statement() else None
795
+ ),
796
+ current_statement=(
797
+ engagement.current_statement().text if engagement.current_statement() else None
798
+ ),
799
+ )
800
+ for waiver in state.get("overrides", []):
801
+ try:
802
+ status.override(
803
+ waiver["gate"], waiver["reason"], against=waiver["against"]
804
+ )
805
+ except (HardGate, ValueError, StopIteration):
806
+ # A waiver that cannot be applied is simply not applied: the
807
+ # gate stays standing, visibly, rather than vanishing.
808
+ continue
809
+ return status
810
+
811
+
812
+ def _refuse_if_blocked(engagement, registry=None, *, warn_only: bool = False) -> None:
813
+ status = _gate_status(engagement, registry)
814
+ blocking = status.blocked_by()
815
+ if not blocking:
816
+ return
817
+ for name in blocking:
818
+ gate = status.gate(name)
819
+ mark = "[hard] " if gate.hard else ""
820
+ typer.echo(f" {mark}{name}: {gate.reason}", err=True)
821
+ if gate.remedy:
822
+ typer.echo(f" -> {gate.remedy}", err=True)
823
+ if warn_only:
824
+ typer.echo(
825
+ "\nproceeding anyway -- a design is thinking, not a deliverable. "
826
+ "`fde build` will refuse until these clear.\n", err=True,
827
+ )
828
+ return
829
+ typer.echo(
830
+ "\nrefused: gates above are unsatisfied. Soft gates take "
831
+ "`fde waive <gate> --reason`; data access has no workaround, only "
832
+ "credentials that return real rows.", err=True,
833
+ )
834
+ raise typer.Exit(1)
835
+
836
+
837
+ def _write_compliance(out: Path, locale) -> None:
838
+ """The jurisdiction's demands, as a checklist with a date.
839
+
840
+ Produce-and-verify items, never rules: the framework decided the
841
+ architecture the same way it would anywhere, and this page says what
842
+ this place additionally requires the engagement to produce.
843
+ """
844
+ lines = [
845
+ f"# Compliance obligations -- {locale.name}",
846
+ "",
847
+ f"As of {locale.as_of or 'undated'}. Law churns like stacks do: verify "
848
+ f"each item with counsel before relying on it, and re-date this page "
849
+ f"when you do.",
850
+ "",
851
+ ]
852
+ for obligation in locale.obligations:
853
+ lines.append(f"## {obligation.id}")
854
+ lines.append("")
855
+ lines.append(obligation.produce)
856
+ if obligation.verify:
857
+ lines.append("")
858
+ lines.append(f"*Verify:* {obligation.verify}")
859
+ lines.append("")
860
+ (out / "COMPLIANCE.md").write_text("\n".join(lines) + "\n")
861
+
862
+
863
+ @app.command("baseline")
864
+ def baseline_cmd(
865
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
866
+ file: Annotated[Path, typer.Option(help="A YAML file of the measured fields.")],
867
+ ) -> None:
868
+ """Record the measured baseline: seven fields, sampled, with definitions.
869
+
870
+ Stored even when incomplete -- a partial baseline is honest state, and the
871
+ gate will say exactly what it still lacks.
872
+ """
873
+ engagement = _engagement(root)
874
+ try:
875
+ fields = yaml.safe_load(file.read_text()) or {}
876
+ except (OSError, yaml.YAMLError) as exc:
877
+ typer.echo(f"cannot read {file}: {exc}", err=True)
878
+ raise typer.Exit(1) from exc
879
+ if not isinstance(fields, dict):
880
+ typer.echo(f"{file}: expected a mapping of field to value", err=True)
881
+ raise typer.Exit(1)
882
+
883
+ engagement.record_baseline(fields)
884
+ result = validate_baseline(fields)
885
+ if result.ok:
886
+ typer.echo("baseline recorded -- re-measurable, sampled, complete")
887
+ else:
888
+ typer.echo(f"recorded, but not yet a baseline: {result.reason}")
889
+
890
+
891
+ @app.command("data-access")
892
+ def data_access_cmd(
893
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
894
+ note: Annotated[str, typer.Option(
895
+ help="What was connected to and what came back. Promised access is not access."
896
+ )],
897
+ ) -> None:
898
+ """Attest that credentials returned real data.
899
+
900
+ The note is the evidence: name the system and what it returned. An
901
+ attestation without one is a promise, and the gate exists because promises
902
+ are what cost three weeks.
903
+ """
904
+ if not _says_something(note):
905
+ typer.echo(
906
+ "the note is the evidence -- say what returned real rows. "
907
+ "(Invisible characters are not a note; str.strip() does not "
908
+ "remove them, so this is checked properly.)", err=True,
909
+ )
910
+ raise typer.Exit(1)
911
+ engagement = _engagement(root)
912
+ engagement.record_data_access(note=note, at=date.today().isoformat())
913
+ typer.echo("data access recorded")
914
+
915
+
916
+ @app.command("security-review")
917
+ def security_review_cmd(
918
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
919
+ note: Annotated[str, typer.Option(
920
+ help="Who reviewed it and what they looked at. A meeting that is "
921
+ "scheduled is not a review that happened."
922
+ )],
923
+ ) -> None:
924
+ """Record that the client's security function reviewed the design.
925
+
926
+ Fires only for systems living inside the client's environment or touching
927
+ their systems -- exactly the ones their InfoSec has jurisdiction over, and
928
+ exactly the ones stopped at the door when nobody scheduled the review.
929
+ """
930
+ if not _says_something(note):
931
+ typer.echo(
932
+ "the note is the evidence -- name who reviewed it and what they "
933
+ "looked at.", err=True,
934
+ )
935
+ raise typer.Exit(1)
936
+ engagement = _engagement(root)
937
+ engagement.record_security_review(note=note, at=date.today().isoformat())
938
+ typer.echo("security review recorded")
939
+
940
+
941
+ @app.command("waive")
942
+ def waive_cmd(
943
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
944
+ gate: Annotated[str, typer.Argument(help="Which gate to wave through.")],
945
+ reason: Annotated[str, typer.Option(help="Why. Lands in the risk section.")],
946
+ ) -> None:
947
+ """Override a soft gate, with the reason recorded.
948
+
949
+ You are on site and can see things a checklist cannot. The hard gate is the
950
+ exception: nothing can waive absent credentials.
951
+ """
952
+ engagement = _engagement(root)
953
+ status = _gate_status(engagement)
954
+ try:
955
+ against = status.gate(gate).reason
956
+ status.override(gate, reason)
957
+ except HardGate as exc:
958
+ typer.echo(str(exc), err=True)
959
+ raise typer.Exit(1) from exc
960
+ except ValueError as exc:
961
+ typer.echo(str(exc), err=True)
962
+ raise typer.Exit(1) from exc
963
+ except StopIteration:
964
+ names = ", ".join(g.name for g in status.gates)
965
+ typer.echo(f"no gate named {gate!r}. The gates: {names}", err=True)
966
+ raise typer.Exit(1) from None
967
+
968
+ engagement.record_waiver(
969
+ gate=gate, reason=reason, at=date.today().isoformat(), against=against
970
+ )
971
+ typer.echo(f"waived {gate} -- recorded, and carried into the project's RISKS.md")
972
+ typer.echo(f" covers: {against}")
973
+ typer.echo(" if this gate blocks for a different reason later, it blocks again")
974
+
975
+
976
+ @app.command("restate")
977
+ def restate_cmd(
978
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
979
+ text: Annotated[str | None, typer.Option(help="The problem as now stated.")] = None,
980
+ file: Annotated[Path | None, typer.Option(help="Or a file holding it.")] = None,
981
+ reason: Annotated[str, typer.Option(help="What changed and why.")] = "",
982
+ ) -> None:
983
+ """Record a new version of the problem statement.
984
+
985
+ Version 1 is never edited; drift is measured against it. Restating is how
986
+ the scope-drift gate gets something real to measure.
987
+ """
988
+ if not text and not file:
989
+ typer.echo("give --text or --file", err=True)
990
+ raise typer.Exit(1)
991
+ if not reason.strip():
992
+ typer.echo(
993
+ "a restatement needs --reason: scope that moves without one is "
994
+ "drift by definition", err=True,
995
+ )
996
+ raise typer.Exit(1)
997
+
998
+ engagement = _engagement(root)
999
+ body = text or file.read_text()
1000
+ engagement.revise_statement(body.strip(), reason=reason)
1001
+ typer.echo(
1002
+ f"statement v{len(engagement.statements)} recorded -- drift is still "
1003
+ f"measured against v1"
1004
+ )
1005
+
1006
+
1007
+ @app.command("reuse")
1008
+ def reuse_cmd(
1009
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1010
+ stacks: Annotated[list[str], typer.Argument(help="Stack ids the client already runs.")],
1011
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1012
+ ) -> None:
1013
+ """Record what the client already operates, so reuse can beat adoption.
1014
+
1015
+ A stack somebody already patches, backs up and pages for is cheaper than
1016
+ the same capability standing beside it -- the tenth workload on it costs
1017
+ almost nothing. Realization prefers these over anything newly adopted.
1018
+ """
1019
+ registry = _registry(registry_root)
1020
+ unknown = [s for s in stacks if s not in registry.stacks]
1021
+ if unknown:
1022
+ typer.echo(
1023
+ f"not stacks in this registry: {', '.join(unknown)}. Known: "
1024
+ f"{', '.join(sorted(registry.stacks))}", err=True,
1025
+ )
1026
+ raise typer.Exit(1)
1027
+
1028
+ engagement = _engagement(root)
1029
+ running = sorted(_reuse(engagement) | set(stacks))
1030
+ (engagement.root / "reuse").write_text("\n".join(running) + "\n")
1031
+ typer.echo(f"recorded as already running: {', '.join(running)}")
1032
+ typer.echo(" realization will prefer these wherever a pattern offers them")
1033
+
1034
+
1035
+ @app.command("locale")
1036
+ def locale_cmd(
1037
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1038
+ locale_id: Annotated[str, typer.Argument(help="A locale pack from the registry.")],
1039
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1040
+ ) -> None:
1041
+ """Apply a jurisdiction pack: presets at the weakest provenance, and
1042
+ obligations the build will carry into COMPLIANCE.md.
1043
+
1044
+ Presets are INFERRED, so anything anybody actually says outranks them --
1045
+ geography seeds answers, it never overrides people.
1046
+ """
1047
+ registry = _registry(registry_root)
1048
+ locale = registry.locales.get(locale_id)
1049
+ if locale is None:
1050
+ known = ", ".join(sorted(registry.locales)) or "none in this registry"
1051
+ typer.echo(f"{locale_id!r} is not a locale pack. Available: {known}", err=True)
1052
+ raise typer.Exit(1)
1053
+
1054
+ engagement = _engagement(root)
1055
+ facts = [
1056
+ Fact(dimension, value, Provenance.INFERRED, source=f"locale:{locale_id}")
1057
+ for dimension, value in locale.presets.items()
1058
+ ]
1059
+ if facts:
1060
+ engagement.append(
1061
+ Session(
1062
+ session_id=_next_session_id(engagement, f"locale-{locale_id}"),
1063
+ respondent=Respondent(role=Role.SYSTEM),
1064
+ facts=facts,
1065
+ )
1066
+ )
1067
+ (engagement.root / "locale").write_text(locale_id + "\n")
1068
+
1069
+ typer.echo(f"applied {locale.name} ({locale_id}), as of {locale.as_of or 'undated'}")
1070
+ if facts:
1071
+ typer.echo(f" presets ({len(facts)}, weakest provenance -- any stated "
1072
+ f"answer outranks them):")
1073
+ for fact in facts:
1074
+ typer.echo(f" {fact.dimension} = {fact.value}")
1075
+ typer.echo(f" obligations carried into the build: {len(locale.obligations)}")
1076
+
1077
+
1078
+ @app.command("architect")
1079
+ def architect_cmd(
1080
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1081
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1082
+ ) -> None:
1083
+ """Decide the design, and say what is still open."""
1084
+ registry = _registry(registry_root)
1085
+ engagement = _engagement(root)
1086
+ _refuse_if_blocked(engagement, registry, warn_only=True)
1087
+ overrides = _overrides(engagement)
1088
+ architecture = build_architecture(
1089
+ engagement.profile, registry, overrides=overrides,
1090
+ already_running=_reuse(engagement),
1091
+ )
1092
+
1093
+ typer.echo(f"topology {architecture.topology} [{architecture.fingerprint()}]\n")
1094
+ for component, decision in sorted(architecture.decisions.decided().items()):
1095
+ realization = architecture.realizations.get(component)
1096
+ via = f" via {realization.stack}" if realization else ""
1097
+ mark = " [overridden]" if component in overrides else ""
1098
+ typer.echo(f" {component:16} {decision.approach}{via}{mark}")
1099
+
1100
+ if architecture.decisions.undecided():
1101
+ typer.echo(f"\nnot decided: {', '.join(architecture.decisions.undecided())}")
1102
+ if architecture.disagreements:
1103
+ typer.echo(f"\nunresolved: {', '.join(d.dimension for d in architecture.disagreements)}")
1104
+ if architecture.copyleft_licences:
1105
+ typer.echo(f"\ncopyleft: {', '.join(architecture.copyleft_licences)}")
1106
+
1107
+
1108
+ @app.command("override")
1109
+ def override_cmd(
1110
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1111
+ component: Annotated[str, typer.Option(help="Which component.")],
1112
+ choose: Annotated[str, typer.Option(help="What to use instead.")],
1113
+ because: Annotated[str, typer.Option(help="Why. Recorded, never argued with.")],
1114
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1115
+ ) -> None:
1116
+ """Choose differently from the recommendation.
1117
+
1118
+ Never warns and never blocks. You are on site and know things the rules do
1119
+ not -- what is recorded is which rule was overridden, because that is the
1120
+ signal, and arguing with you would teach the framework nothing.
1121
+ """
1122
+ registry = _registry(registry_root)
1123
+ engagement = _engagement(root)
1124
+ from fde.decide import base_component
1125
+
1126
+ if base_component(component) not in registry.components:
1127
+ typer.echo(
1128
+ f"{component!r} is not a component in this registry. Components: "
1129
+ f"{', '.join(sorted(registry.components))} (an instance of a "
1130
+ f"fanned component works too, e.g. perception:images)", err=True,
1131
+ )
1132
+ raise typer.Exit(1)
1133
+ if choose not in registry.approaches:
1134
+ # A typo here silently turned a working component into one that
1135
+ # raises, and reported success at every step.
1136
+ for_component = sorted(
1137
+ a.id for a in registry.approaches.values()
1138
+ if not a.components or component in a.components
1139
+ )
1140
+ typer.echo(
1141
+ f"{choose!r} is not an approach in this registry. For "
1142
+ f"{component}: {', '.join(for_component) or 'nothing registered'}",
1143
+ err=True,
1144
+ )
1145
+ raise typer.Exit(1)
1146
+ chosen_serves = registry.approaches[choose].components
1147
+ if chosen_serves and component not in chosen_serves:
1148
+ # A real approach for the wrong slot is the same silent breakage as
1149
+ # a typo: the component becomes unrealizable with a success message.
1150
+ for_component = sorted(
1151
+ a.id for a in registry.approaches.values()
1152
+ if not a.components or component in a.components
1153
+ )
1154
+ typer.echo(
1155
+ f"{choose!r} serves {', '.join(chosen_serves)}, not {component}. "
1156
+ f"For {component}: {', '.join(for_component) or 'nothing registered'}",
1157
+ err=True,
1158
+ )
1159
+ raise typer.Exit(1)
1160
+
1161
+ # Against live state, overrides included: computing "what was
1162
+ # recommended" from a world where earlier overrides do not exist files a
1163
+ # revert as an override of the rule it agrees with.
1164
+ existing = _overrides(engagement)
1165
+ architecture = build_architecture(engagement.profile, registry, overrides=existing,
1166
+ already_running=_reuse(engagement))
1167
+
1168
+ decision = architecture.decisions.get(component)
1169
+ recommended = decision.approach if decision else None
1170
+
1171
+ # Conflicts come from what the registry declares, not from a list kept
1172
+ # here: the chosen approach's own avoid_when conditions, evaluated
1173
+ # against this profile. A new rule in the registry is flagged without a
1174
+ # code change.
1175
+ conflicts = []
1176
+ chosen_entry = registry.approaches.get(choose)
1177
+ if chosen_entry:
1178
+ for predicate in chosen_entry.avoid_when:
1179
+ try:
1180
+ if holds(predicate, engagement.profile, registry):
1181
+ conflicts.append(predicate)
1182
+ except PredicateError as exc:
1183
+ typer.echo(f" cannot evaluate {predicate!r}: {exc}", err=True)
1184
+
1185
+ record = Override(
1186
+ component=component, recommended=recommended or "nothing", chosen=choose,
1187
+ because=because, overrode_rule=recommended or "none", conflicts_with=conflicts,
1188
+ )
1189
+ # No pseudo-dimension fact. overrides.jsonl is the record -- append-only,
1190
+ # carrying the reason and what it overrode -- and `override.<component>`
1191
+ # in the fact log put a non-dimension beside real answers in `status`
1192
+ # and in the profile a future corpus would match engagements against.
1193
+ with (engagement.root / "overrides.jsonl").open("a") as handle:
1194
+ handle.write(json.dumps(record.__dict__) + "\n")
1195
+
1196
+ if recommended:
1197
+ typer.echo(f"recorded: {component} {recommended} -> {choose}")
1198
+ typer.echo(" honoured: `fde architect` and `fde build` now use your choice")
1199
+ else:
1200
+ # Nothing was recommended, so nothing was overridden. Still worth
1201
+ # recording: a component chosen where the framework had no opinion is
1202
+ # a gap in the corpus, not a disagreement with it.
1203
+ typer.echo(
1204
+ f"recorded: {component} -> {choose}\n"
1205
+ f" nothing was recommended here, so this is a gap in the corpus "
1206
+ f"rather than a disagreement with it"
1207
+ )
1208
+ if conflicts:
1209
+ # Flagged, not refused. It goes in the risk section rather than in the
1210
+ # way.
1211
+ typer.echo(f" conflicts with {', '.join(conflicts)} -- noted in the risks")
1212
+
1213
+
1214
+ @app.command("observe")
1215
+ def observe_cmd(
1216
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1217
+ trigger: Annotated[str, typer.Option(help="Which trigger fired, e.g. serving.graduate.")],
1218
+ measured: Annotated[list[str] | None, typer.Option(
1219
+ help="What was measured, as key=value. Repeatable."
1220
+ )] = None,
1221
+ today: Annotated[str, typer.Option(help="When it fired, for reproducibility.")] = "",
1222
+ ) -> None:
1223
+ """Record that a predicted trigger actually fired.
1224
+
1225
+ Trigger calibration is the strongest signal the framework collects,
1226
+ precisely because there is no counterfactual -- a trigger fired when
1227
+ predicted or it did not, and both are observable. But only if somebody
1228
+ writes the firing down.
1229
+ """
1230
+ engagement = _engagement(root)
1231
+
1232
+ stamp = today or date.today().isoformat()
1233
+ try:
1234
+ date.fromisoformat(stamp)
1235
+ except ValueError as exc:
1236
+ # Written unchecked, this lands in an append-only log and every later
1237
+ # retro dies on it, with no repair command.
1238
+ typer.echo(f"--today {stamp!r} is not a date (YYYY-MM-DD): {exc}", err=True)
1239
+ raise typer.Exit(1) from exc
1240
+
1241
+ values = {}
1242
+ for item in measured or []:
1243
+ key, sep, value = item.partition("=")
1244
+ if not sep or not key.strip() or not value.strip():
1245
+ typer.echo(
1246
+ f"--measured {item!r} is not key=value with both halves. A "
1247
+ f"measurement dropped silently is worse than one refused.", err=True,
1248
+ )
1249
+ raise typer.Exit(1)
1250
+ values[key.strip()] = value.strip()
1251
+
1252
+ # Warned, not refused: build may not have run yet. But a misspelled
1253
+ # trigger that is stored and then silently never counted is the shape of
1254
+ # a signal nobody knows they lost.
1255
+ predicted = {p["trigger"] for p in _jsonl(engagement.root / "predictions.jsonl")}
1256
+ if predicted and trigger not in predicted:
1257
+ typer.echo(
1258
+ f"warning: {trigger!r} was never predicted here, so it will not "
1259
+ f"be counted. Predicted: {', '.join(sorted(predicted)) or 'nothing'}",
1260
+ err=True,
1261
+ )
1262
+
1263
+ record = {
1264
+ "trigger": trigger,
1265
+ "observed_at": stamp,
1266
+ "measured": values,
1267
+ }
1268
+ with (engagement.root / "observations.jsonl").open("a") as handle:
1269
+ handle.write(json.dumps(record) + "\n")
1270
+ typer.echo(f"observed: {trigger} fired")
1271
+
1272
+
1273
+ def _jsonl(path: Path) -> list[dict]:
1274
+ if not path.exists():
1275
+ return []
1276
+ out = []
1277
+ for line in path.read_text().splitlines():
1278
+ if line.strip():
1279
+ try:
1280
+ out.append(json.loads(line))
1281
+ except json.JSONDecodeError:
1282
+ continue
1283
+ return out
1284
+
1285
+
1286
+ @app.command("retro")
1287
+ def retro_cmd(
1288
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1289
+ outcome: Annotated[str, typer.Option(help="What actually happened.")] = "",
1290
+ days: Annotated[int, typer.Option(help="How long it took.")] = 0,
1291
+ today: Annotated[str, typer.Option(help="Sweep date, for reproducibility.")] = "",
1292
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1293
+ ) -> None:
1294
+ """What this engagement taught. Capture only -- no rule is changed here.
1295
+
1296
+ Rules cannot be revised until engagements have outcomes, and pretending to
1297
+ revise on a handful would be borrowing rigour rather than having it. What
1298
+ this does is make sure nothing is lost in the meantime.
1299
+ """
1300
+ registry = _registry(registry_root)
1301
+ engagement = _engagement(root)
1302
+ overrides = _overrides(engagement)
1303
+ architecture = build_architecture(
1304
+ engagement.profile, registry, overrides=overrides,
1305
+ already_running=_reuse(engagement),
1306
+ )
1307
+
1308
+ stamp = today or date.today().isoformat()
1309
+ try:
1310
+ date.fromisoformat(stamp)
1311
+ except ValueError as exc:
1312
+ typer.echo(f"--today {stamp!r} is not a date (YYYY-MM-DD)", err=True)
1313
+ raise typer.Exit(1) from exc
1314
+
1315
+ # A retrospective on an engagement that never cleared its gates is worth
1316
+ # capturing -- "we never got data access" is a finding. What it must not
1317
+ # do is enter the corpus looking like a delivered engagement.
1318
+ blocked = _gate_status(engagement, registry).blocked_by()
1319
+ if blocked:
1320
+ typer.echo(
1321
+ f"note: {', '.join(blocked)} never cleared, so this case records "
1322
+ f"an engagement that was never built.", err=True,
1323
+ )
1324
+
1325
+ # Predictions date from when the build made them, where a build happened.
1326
+ # A prediction invented at sweep time is always "pending" and calibrates
1327
+ # nothing, which is how the strongest signal used to always read zero.
1328
+ recorded = {
1329
+ p["trigger"]: p
1330
+ for p in _jsonl(engagement.root / "predictions.jsonl")
1331
+ if isinstance(p.get("trigger"), str)
1332
+ }
1333
+ predictions = [
1334
+ Prediction(
1335
+ trigger=f"{component}.graduate",
1336
+ condition=decision.rationale,
1337
+ predicted_at=recorded.get(f"{component}.graduate", {}).get(
1338
+ "predicted_at", stamp
1339
+ ),
1340
+ horizon_days=90,
1341
+ )
1342
+ for component, decision in architecture.decisions.decided().items()
1343
+ ]
1344
+ by_trigger = {p.trigger: p for p in predictions}
1345
+ observations = []
1346
+ for number, record in enumerate(
1347
+ _jsonl(engagement.root / "observations.jsonl"), start=1
1348
+ ):
1349
+ # Hand-editing the log IS the repair path, so a hand-edited record
1350
+ # is skipped by name rather than dying three modules later.
1351
+ if record.get("trigger") not in by_trigger:
1352
+ continue
1353
+ observed_at = record.get("observed_at")
1354
+ try:
1355
+ date.fromisoformat(str(observed_at))
1356
+ except (TypeError, ValueError):
1357
+ typer.echo(
1358
+ f"observations.jsonl:{number}: observed_at {observed_at!r} is "
1359
+ f"not a date -- skipped", err=True,
1360
+ )
1361
+ continue
1362
+ observations.append(
1363
+ Observation.fired(by_trigger[record["trigger"]], at=observed_at,
1364
+ measured=record.get("measured", {}))
1365
+ )
1366
+ swept = sweep_triggers(predictions, observations=observations, today=stamp)
1367
+ report = calibration(swept)
1368
+
1369
+ case = emit_case(
1370
+ engagement=root.name,
1371
+ profile=engagement.profile.values(),
1372
+ decisions={c: d.approach for c, d in architecture.decisions.decided().items()},
1373
+ observations=swept,
1374
+ outcome=outcome or "not stated",
1375
+ days=days or None,
1376
+ reused=sorted({r.stack for r in architecture.realizations.values()}),
1377
+ # Every override, in order -- not the last per component. A revert is
1378
+ # a signal about the rule too, and keeping only the survivor drops
1379
+ # the interesting half of the pair.
1380
+ overrides=_jsonl(engagement.root / "overrides.jsonl"),
1381
+ blocked_gates=blocked,
1382
+ )
1383
+
1384
+ # Never silently over an earlier capture: case.json is the only place a
1385
+ # retrospective lives, and a typo'd --registry once rewrote a six-decision
1386
+ # case with a zero-decision one, exit 0 both times.
1387
+ case_path = engagement.root / "case.json"
1388
+ if case_path.exists():
1389
+ try:
1390
+ previous = json.loads(case_path.read_text() or "{}")
1391
+ except json.JSONDecodeError as exc:
1392
+ typer.echo(
1393
+ f"refused: {case_path} exists and cannot be read ({exc}). "
1394
+ f"Move it aside before capturing again.", err=True,
1395
+ )
1396
+ raise typer.Exit(1) from exc
1397
+ if not isinstance(previous, dict):
1398
+ previous = {}
1399
+ if len(previous.get("decisions", {})) > len(case["decisions"]):
1400
+ typer.echo(
1401
+ f"refused: {case_path} already records "
1402
+ f"{len(previous['decisions'])} decisions and this run found "
1403
+ f"{len(case['decisions'])}. Check --registry before "
1404
+ f"overwriting a fuller capture.", err=True,
1405
+ )
1406
+ raise typer.Exit(1)
1407
+ # The outcome and duration are the two fields a corpus actually
1408
+ # needs. Re-running retro with the flags forgotten once blanked both,
1409
+ # exit 0 -- so an earlier answer is kept unless a new one is given.
1410
+ if case["outcome"] == "not stated" and previous.get("outcome") not in (
1411
+ None, "not stated",
1412
+ ):
1413
+ case["outcome"] = previous["outcome"]
1414
+ typer.echo(f" outcome kept from the earlier capture: {case['outcome']}")
1415
+ if case["practice"].get("days") is None and isinstance(
1416
+ previous.get("practice"), dict
1417
+ ) and previous["practice"].get("days") is not None:
1418
+ case["practice"]["days"] = previous["practice"]["days"]
1419
+ case_path.write_text(json.dumps(case, indent=2, default=str))
1420
+
1421
+ typer.echo(f"case {case['id']} ({len(case['decisions'])} decisions)")
1422
+ typer.echo(f" triggers: {report['fired']} fired, "
1423
+ f"{report['expired_unfired']} expired unfired")
1424
+ typer.echo(f" evidence: {report['strength']} -- {report['why']}")
1425
+ if case["overrides"]:
1426
+ typer.echo(f" overrides: {len(case['overrides'])} carried into the case")
1427
+ if report.get("impossible"):
1428
+ typer.echo(
1429
+ f" ignored: {len(report['impossible'])} observation(s) dated "
1430
+ f"before the prediction they answer"
1431
+ )
1432
+ typer.echo("\nNothing in framework/ was changed. Revision needs a corpus -- "
1433
+ "review case.json, then `fde kb ingest-case` after sanitisation.")
1434
+
1435
+
1436
+ @app.command("build")
1437
+ def build_cmd(
1438
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1439
+ out: Annotated[Path, typer.Option(help="Where to write the project.")],
1440
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1441
+ ) -> None:
1442
+ """Emit the project. Refuses before writing anything if it would be unsound."""
1443
+ registry = _registry(registry_root)
1444
+ engagement = _engagement(root)
1445
+ _refuse_if_blocked(engagement, registry)
1446
+ architecture = build_architecture(
1447
+ engagement.profile, registry, overrides=_overrides(engagement),
1448
+ already_running=_reuse(engagement),
1449
+ )
1450
+ try:
1451
+ # Only waivers that actually applied at build time. Shipping every
1452
+ # stored waiver once told a client a risk was accepted that had in
1453
+ # fact been retired -- the baseline was on disk and complete.
1454
+ status = _gate_status(engagement, registry)
1455
+ applied = {o.gate for o in status.overridden}
1456
+ waivers = [
1457
+ w for w in engagement.gate_state().get("overrides", [])
1458
+ if w["gate"] in applied
1459
+ ]
1460
+ # Applied-only, exactly like waivers: an override that did not take
1461
+ # effect in THIS build (the component decided under different keys,
1462
+ # or a later override superseded it) must not appear in the client
1463
+ # document as a design change that was made.
1464
+ recorded_overrides = _jsonl(engagement.root / "overrides.jsonl")
1465
+ applied_overrides = [
1466
+ o for o in recorded_overrides
1467
+ if any(
1468
+ (key == o.get("component")
1469
+ or key.startswith(str(o.get("component")) + ":"))
1470
+ and d.approach == o.get("chosen")
1471
+ for key, d in architecture.decisions.items()
1472
+ )
1473
+ ]
1474
+ report = emit(architecture, out, registry=registry,
1475
+ templates=Path(registry_root) / "templates",
1476
+ pairs_path=Path(root) / "artifacts" / "pairs.jsonl",
1477
+ waivers=waivers,
1478
+ overrides=applied_overrides)
1479
+ except BuildRefused as exc:
1480
+ typer.echo(f"refused: {exc}", err=True)
1481
+ raise typer.Exit(1) from exc
1482
+
1483
+ # Predictions date from the build that made them. Recorded once per
1484
+ # trigger: the first build's claim is the one calibration judges.
1485
+ predictions_path = engagement.root / "predictions.jsonl"
1486
+ already = {p["trigger"] for p in _jsonl(predictions_path)}
1487
+ with predictions_path.open("a") as handle:
1488
+ for component in architecture.decisions.decided():
1489
+ trigger = f"{component}.graduate"
1490
+ if trigger not in already:
1491
+ handle.write(json.dumps(
1492
+ {"trigger": trigger, "predicted_at": date.today().isoformat()}
1493
+ ) + "\n")
1494
+
1495
+ locale_marker = engagement.root / "locale"
1496
+ if locale_marker.exists():
1497
+ locale_id = locale_marker.read_text().strip()
1498
+ locale = registry.locales.get(locale_id)
1499
+ if locale is None:
1500
+ # Silence here ships a project without the obligations page an
1501
+ # engagement believes it has -- compliance-grade silence. The
1502
+ # marker names a pack; the registry must know it or say so.
1503
+ typer.echo(
1504
+ f"refused after writing code: this engagement applied locale "
1505
+ f"{locale_id!r} and this registry does not know it. Re-run "
1506
+ f"`fde locale` with a known pack, or delete the engagement's "
1507
+ f"`locale` file if no jurisdiction applies.", err=True,
1508
+ )
1509
+ raise typer.Exit(1)
1510
+ _write_compliance(Path(out), locale)
1511
+
1512
+ typer.echo(f"wrote {out}")
1513
+ # The delivery is finishable, and the finishing move is one command.
1514
+ holdout_path = engagement.root / "artifacts" / "holdout.jsonl"
1515
+ hint = f"fde implement {out}"
1516
+ if holdout_path.exists():
1517
+ hint += f" --holdout {holdout_path}"
1518
+ typer.echo(f"next: {hint}")
1519
+ if architecture.decisions.undecided():
1520
+ typer.echo(
1521
+ f" {len(architecture.decisions.undecided())} component(s) raise on use -- "
1522
+ f"see ARCHITECTURE.md"
1523
+ )
1524
+ if architecture.unrealizable:
1525
+ typer.echo(
1526
+ f" unrealizable: {', '.join(sorted(architecture.unrealizable))} -- "
1527
+ f"raise on use, reasons in ARCHITECTURE.md"
1528
+ )
1529
+ if report.scaffolded:
1530
+ typer.echo(
1531
+ f" scaffolded (template missing): {', '.join(report.scaffolded)} -- "
1532
+ f"contracts fixed, bodies to write"
1533
+ )
1534
+
1535
+
1536
+ @app.command("scan")
1537
+ def scan_cmd(
1538
+ root: Annotated[Path | None, typer.Argument(help="Engagement to record into.")] = None,
1539
+ params_b: Annotated[float, typer.Option("--model-b", help="Model size in billions.")] = 8.0,
1540
+ precision: Annotated[str, typer.Option(help="bf16, int8 or int4.")] = "bf16",
1541
+ vram: Annotated[float | None, typer.Option(help="Per-card VRAM, if not on the box.")] = None,
1542
+ gpus: Annotated[int, typer.Option(help="How many such cards.")] = 1,
1543
+ ) -> None:
1544
+ """Whether this hardware runs that model, and what it supports.
1545
+
1546
+ Detects by default. The flags describe a machine you have been told about
1547
+ rather than one you are on -- useful for sizing a client's box from your own
1548
+ laptop, and never recorded as fact, because a specification somebody quoted
1549
+ is not a measurement and the framework decides by provenance.
1550
+ """
1551
+ if vram is None:
1552
+ detection = detect()
1553
+ hardware, measured = detection.hardware, detection.measured
1554
+ if detection.note:
1555
+ typer.echo(f" {detection.note}")
1556
+ else:
1557
+ hardware = Hardware(gpus=[GPU(f"card-{i}", vram_gb=vram) for i in range(gpus)])
1558
+ measured = False
1559
+
1560
+ if hardware.gpus:
1561
+ for gpu in hardware.gpus:
1562
+ typer.echo(f" {gpu.model} {gpu.vram_gb:.0f}GB sm {gpu.sm}")
1563
+ elif measured:
1564
+ typer.echo(" no accelerator")
1565
+ typer.echo(f" {hardware.total_vram_gb:.0f}GB total"
1566
+ f"{'' if vram is None else ' (stated, not measured)'}")
1567
+
1568
+ fit = fits(hardware, params_b, precision=precision)
1569
+ if not hardware.gpus:
1570
+ # Against no accelerator the fit arithmetic answers a question nobody
1571
+ # asked. What is wanted here is the size, and where it would have to run.
1572
+ typer.echo(
1573
+ f"\n{params_b:g}B at {precision}: {fit.weights_gb:.0f}GB of weights, "
1574
+ f"nothing to load them onto\n"
1575
+ f" -> quantise and run on the {hardware.ram_gb:.0f}GB of host memory "
1576
+ f"if nobody is waiting, or serve it somewhere else"
1577
+ )
1578
+ else:
1579
+ verdict = "fits" if fit.ok else f"does not fit, short {fit.shortfall_gb:.0f}GB"
1580
+ typer.echo(
1581
+ f"\n{params_b:g}B at {precision}: {verdict}\n"
1582
+ f" {fit.weights_gb:.0f}GB weights + {fit.kv_cache_gb:.0f}GB cache "
1583
+ f"against {fit.available_gb:.0f}GB usable"
1584
+ )
1585
+ if not fit.ok:
1586
+ typer.echo(" -> quantise, shrink the model, or add cards")
1587
+
1588
+ typer.echo("\nsupported here")
1589
+ for option in suggest(hardware):
1590
+ typer.echo(f" {option.id}\n {option.reason}\n costs: {option.cost}")
1591
+
1592
+ if hardware.gpus:
1593
+ adapt = finetune_feasible(hardware, params_b, method="full")
1594
+ if not adapt.ok:
1595
+ typer.echo(f"\nfull finetune: no -- {adapt.reason}")
1596
+
1597
+ # Which local models this box serves, for the judge, the reader, and
1598
+ # the implement loop -- sized from what was measured, dated like every
1599
+ # costing figure, because model releases move monthly.
1600
+ from fde.scan import MODEL_GUIDANCE_AS_OF, recommend_local_models
1601
+
1602
+ plan = recommend_local_models(hardware)
1603
+ typer.echo(f"\nlocal models (guidance as of {MODEL_GUIDANCE_AS_OF} -- "
1604
+ f"releases move monthly, verify before install)")
1605
+ typer.echo(f" runtime: {plan.runtime} -- {plan.runtime_reason}")
1606
+ typer.echo(f" usable memory budget: ~{plan.budget_gb}GB")
1607
+ typer.echo(f" judge + frame reader: {plan.judge_model} "
1608
+ f"({plan.serve_hint.format(model=plan.judge_model)})")
1609
+ typer.echo(f" implement-loop coder: {plan.coder_model} "
1610
+ f"({plan.serve_hint.format(model=plan.coder_model)})")
1611
+ typer.echo(f" then: export LLM_ENDPOINT={plan.endpoint}")
1612
+ typer.echo(
1613
+ " the judge is sized by the calibration gate, not the leaderboard: "
1614
+ "the smallest model whose agreement with your graders clears 0.8 on "
1615
+ "your data is the right one"
1616
+ )
1617
+ for note in plan.notes:
1618
+ typer.echo(f" note: {note}")
1619
+
1620
+ if root is None:
1621
+ return
1622
+ if not measured:
1623
+ # Two ways to get here, one message discipline: a stated spec is not a
1624
+ # measurement, and neither is a probe that could not read the machine.
1625
+ typer.echo(
1626
+ "\nnot recorded: only a successful measurement earns detected "
1627
+ "provenance"
1628
+ )
1629
+ return
1630
+
1631
+ engagement = _engagement(root)
1632
+ engagement.append(
1633
+ Session(
1634
+ session_id=_next_session_id(engagement, "scan"),
1635
+ respondent=Respondent(role=Role.SYSTEM),
1636
+ facts=scan_facts(hardware),
1637
+ )
1638
+ )
1639
+ typer.echo("\nrecorded as detected -- outranks anything stated about this box")
1640
+
1641
+
1642
+ @app.command("implement")
1643
+ def implement_cmd(
1644
+ project: Annotated[Path, typer.Argument(
1645
+ help="An emitted project directory (holds evals/ and app/)."
1646
+ )],
1647
+ agent_cmd: Annotated[str, typer.Option(
1648
+ "--agent-cmd",
1649
+ help="The coding agent, as a command reading its brief on stdin. "
1650
+ "Default: claude -p --permission-mode acceptEdits",
1651
+ )] = "claude -p --permission-mode acceptEdits",
1652
+ max_rounds: Annotated[int, typer.Option(
1653
+ help="The step cap. The loop is bounded, like everything this "
1654
+ "framework emits."
1655
+ )] = 5,
1656
+ check: Annotated[str | None, typer.Option(
1657
+ help="The command that decides green. Default: the same harness "
1658
+ "invocation the emitted CI runs."
1659
+ )] = None,
1660
+ holdout: Annotated[Path | None, typer.Option(
1661
+ help="A jsonl of pairs the delivery never shipped (fde samples "
1662
+ "writes <eng>/artifacts/holdout.jsonl). Green golden beside "
1663
+ "red holdout means the golden file was memorized."
1664
+ )] = None,
1665
+ ) -> None:
1666
+ """Drive a coding agent until the emitted evals pass, inside guardrails.
1667
+
1668
+ The harness is the stop condition; the evals, boundary, controls and
1669
+ decision documents are the fence -- hashed first, restored and loudly
1670
+ reported if the agent touches them. Every round lands in
1671
+ ops/implement-log.md.
1672
+ """
1673
+ from fde.implement import run_loop
1674
+
1675
+ project = Path(project)
1676
+ if not (project / "evals").is_dir() or not (project / "app").is_dir():
1677
+ typer.echo(
1678
+ f"{project}: not an emitted project (no evals/ and app/). "
1679
+ f"`fde build` writes one.", err=True,
1680
+ )
1681
+ raise typer.Exit(1)
1682
+
1683
+ report = run_loop(project, agent_cmd=agent_cmd, max_rounds=max_rounds,
1684
+ check=check, holdout=holdout)
1685
+ (project / "ops").mkdir(exist_ok=True)
1686
+ (project / "ops" / "implement-log.md").write_text(report.log())
1687
+
1688
+ for entry in report.rounds:
1689
+ state = "green" if entry.check_passed else "red"
1690
+ extras = f" -- {entry.violation}" if entry.violation else ""
1691
+ changed = f" ({len(entry.changed)} file(s) changed)" if entry.changed else ""
1692
+ typer.echo(f"round {entry.number}: {state}{changed}{extras}")
1693
+ typer.echo(f"\nstopped by: {report.stopped_by}. Log: ops/implement-log.md")
1694
+ if not report.done:
1695
+ raise typer.Exit(1)
1696
+
1697
+
1698
+ @app.command("triage")
1699
+ def triage_cmd(
1700
+ statement: Annotated[list[str], typer.Option(
1701
+ "--statement", help="A candidate problem, in prose (repeatable)."
1702
+ )],
1703
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
1704
+ ) -> None:
1705
+ """Rank candidate problems by what discovery can already decide.
1706
+
1707
+ Upstream of `fde start`, and honest about what it ranks: decidability,
1708
+ not business value. A statement that names its shape, its boundary and
1709
+ its numbers gives discovery a running start; one that names none of them
1710
+ costs a discovery phase before anything can be compared. The business
1711
+ case still comes from the baseline -- this only says which candidate is
1712
+ closest to being buildable as stated.
1713
+ """
1714
+ registry = _registry(registry_root)
1715
+ if len(statement) < 2:
1716
+ typer.echo("triage compares -- give at least two --statement candidates.",
1717
+ err=True)
1718
+ raise typer.Exit(1)
1719
+
1720
+ from fde.decompose import decompose
1721
+ from fde.gates import completeness
1722
+ from fde.intake.prose import parse_prose
1723
+
1724
+ rows = []
1725
+ for text in statement:
1726
+ profile = Profile()
1727
+ profile.ingest(parse_prose(text, registry))
1728
+ values = profile.values()
1729
+ components = decompose(profile, registry).components
1730
+ boundary = any(
1731
+ values.get(d) in entry.boundary_when
1732
+ for d, entry in registry.dimensions.items() if entry.boundary_when
1733
+ )
1734
+ lowered = text.lower()
1735
+ bundled = any(marker in lowered for marker in (
1736
+ "two workflows", "two functions", "two groups", "two teams",
1737
+ "two agentic", "two capabilities", "serving two", "for two ",
1738
+ ))
1739
+ rows.append({
1740
+ "text": text,
1741
+ "facts": len(values),
1742
+ "settled": completeness(profile, registry),
1743
+ "components": len(list(components)),
1744
+ "boundary": boundary,
1745
+ "bundled": bundled,
1746
+ })
1747
+
1748
+ rows.sort(key=lambda r: (-r["settled"], -r["facts"]))
1749
+ typer.echo("ranked by what the statement already settles:\n")
1750
+ for rank, row in enumerate(rows, 1):
1751
+ shown = row["text"] if len(row["text"]) <= 64 else row["text"][:61] + "..."
1752
+ typer.echo(f" {rank}. {shown}")
1753
+ typer.echo(
1754
+ f" {row['facts']} fact(s) read, {row['settled']:.0%} of what "
1755
+ f"gets decided settled, {row['components']} component(s) in scope"
1756
+ + (", crosses a data boundary" if row["boundary"] else "")
1757
+ )
1758
+ if row["bundled"]:
1759
+ typer.echo(
1760
+ " reads like more than one workflow in one statement -- "
1761
+ "worth splitting into separate engagements before fde start, "
1762
+ "since one pipeline gets built per engagement"
1763
+ )
1764
+ typer.echo(
1765
+ "\nThis ranks decidability, not value: the business case comes from "
1766
+ "the baseline, and the baseline comes after `fde start`. A candidate "
1767
+ "ranked low is under-described, not unworthy."
1768
+ )
1769
+
1770
+
1771
+ @app.command("cost")
1772
+ def cost_cmd(
1773
+ requests_per_day: Annotated[int | None, typer.Option(
1774
+ help="Expected daily volume. Read from the engagement's arrival_rate "
1775
+ "when --root is given and the interview settled it."
1776
+ )] = None,
1777
+ root: Annotated[Path | None, typer.Option(
1778
+ "--root", help="An engagement directory to read arrival_rate and "
1779
+ "human_waiting from, so discovery is not re-typed at the prompt."
1780
+ )] = None,
1781
+ params_b: Annotated[float, typer.Option("--model-b", help="Model size in billions.")] = 8.0,
1782
+ human_waiting: Annotated[
1783
+ bool | None, typer.Option(help="Is somebody waiting on each request?")
1784
+ ] = None,
1785
+ today: Annotated[str, typer.Option(help="For staleness checks; defaults to today.")] = "",
1786
+ price_per_seat: Annotated[float | None, typer.Option(
1787
+ help="Monthly price per seat: adds the unit-economics check -- "
1788
+ "whether a seat earns more than it burns."
1789
+ )] = None,
1790
+ workflows_per_day: Annotated[float, typer.Option(
1791
+ help="Workflows one seat runs daily, for the unit-economics check."
1792
+ )] = 8.0,
1793
+ steps: Annotated[int, typer.Option(
1794
+ help="Agent-loop steps per workflow. Unbounded loops price like this "
1795
+ "number being large."
1796
+ )] = 5,
1797
+ ) -> None:
1798
+ """Size the fleet and compare hosting, with every figure dated.
1799
+
1800
+ The naive figure is shown beside the real one because the gap is the
1801
+ finding: redundancy, peak and prefill multiply a fleet, and pricing each
1802
+ replica as one card quotes a large model at a third of its cost.
1803
+ """
1804
+ from fde.costing import compare_hosting, size_for
1805
+
1806
+ stamp = today or date.today().isoformat()
1807
+ if root is not None:
1808
+ values = _engagement(root).profile.values()
1809
+ if requests_per_day is None and values.get("arrival_rate") is not None:
1810
+ requests_per_day = int(values["arrival_rate"])
1811
+ typer.echo(f"arrival_rate from the engagement: {requests_per_day:,}/day")
1812
+ if human_waiting is None and values.get("human_waiting") is not None:
1813
+ human_waiting = values["human_waiting"] != "no"
1814
+ if requests_per_day is None:
1815
+ typer.echo(
1816
+ "no volume to size for -- pass --requests-per-day, or --root an "
1817
+ "engagement whose interview settled arrival_rate.", err=True,
1818
+ )
1819
+ raise typer.Exit(1)
1820
+ if human_waiting is None:
1821
+ human_waiting = True
1822
+
1823
+ plan = size_for(requests_per_day, params_b, today=stamp)
1824
+ comparison = compare_hosting(
1825
+ requests_per_day, params_b, human_waiting=human_waiting, today=stamp
1826
+ )
1827
+
1828
+ typer.echo(
1829
+ f"{params_b:g}B at {requests_per_day:,}/day"
1830
+ f"{' (interactive)' if human_waiting else ' (batch, nobody waiting)'}\n"
1831
+ )
1832
+ typer.echo(f" naive: {plan['naive_replicas']} replica(s)")
1833
+ typer.echo(
1834
+ f" real: {plan['replicas']} replica(s) x {plan['gpus_per_replica']} "
1835
+ f"card(s) = {plan['gpus']} cards"
1836
+ )
1837
+ for name, why in plan["factors"].items():
1838
+ typer.echo(f" {name}: {why}")
1839
+
1840
+ typer.echo(
1841
+ f"\n self-hosted ${comparison['self_hosted_monthly']:,.0f}/mo\n"
1842
+ f" managed ${comparison['managed_monthly']:,.0f}/mo\n"
1843
+ f" -> {comparison['recommendation']}: {comparison['why']}"
1844
+ )
1845
+ typer.echo(
1846
+ f"\n as of {plan['as_of']} -- {plan['rederive']}"
1847
+ )
1848
+
1849
+ if price_per_seat is not None:
1850
+ from fde.costing import unit_economics
1851
+
1852
+ coverage = None
1853
+ if root is not None:
1854
+ coverage = _engagement(root).profile.values().get("cheap_path_coverage")
1855
+ economics = unit_economics(
1856
+ workflows_per_day, price_per_seat, steps_per_workflow=steps,
1857
+ cheap_path_coverage=coverage, today=stamp,
1858
+ )
1859
+ typer.echo(
1860
+ f"\nunit economics at ${price_per_seat:.2f}/seat, "
1861
+ f"{workflows_per_day:g} workflows/day, {steps} step(s):"
1862
+ )
1863
+ typer.echo(f" ${economics['cost_per_workflow']:.4f}/workflow -> "
1864
+ f"${economics['cost_per_seat_month']:.2f}/seat-month in model spend")
1865
+ drowned = " -- UNDERWATER: every new user costs money"
1866
+ typer.echo(f" margin: ${economics['margin_per_seat']:.2f}/seat"
1867
+ + (drowned if economics["underwater"] else ""))
1868
+ for reason, new_margin in economics["levers"]:
1869
+ typer.echo(f" lever: {reason} -> margin ${new_margin:.2f}")
1870
+
1871
+
1872
+ @kb.command("ingest-case")
1873
+ def kb_ingest_case(
1874
+ case_file: Annotated[Path, typer.Argument(help="A case.json from `fde retro`.")],
1875
+ root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
1876
+ ) -> None:
1877
+ """Bring a captured case into the corpus -- as pending, never as reviewed.
1878
+
1879
+ This is the step that stops every engagement being a dead end. It is
1880
+ human-gated on purpose: the file lands with sanitization: pending, and
1881
+ nothing pending should ever reach a public repository. Review every field
1882
+ for anything identifying, then set sanitization: reviewed by hand.
1883
+ """
1884
+ try:
1885
+ case = json.loads(case_file.read_text())
1886
+ except (OSError, json.JSONDecodeError) as exc:
1887
+ typer.echo(f"cannot read {case_file}: {exc}", err=True)
1888
+ raise typer.Exit(1) from exc
1889
+
1890
+ if not isinstance(case, dict):
1891
+ typer.echo(
1892
+ f"{case_file}: expected a JSON object, found "
1893
+ f"{type(case).__name__} -- is this a case.json from retro?", err=True,
1894
+ )
1895
+ raise typer.Exit(1)
1896
+ case_id = case.get("id")
1897
+ if not case_id:
1898
+ typer.echo(f"{case_file}: no id field -- is this a case.json from retro?", err=True)
1899
+ raise typer.Exit(1)
1900
+ if not CASE_ID.fullmatch(str(case_id)):
1901
+ # The id becomes a filename. Untrusted JSON deciding where a file
1902
+ # lands is how `../` and absolute paths write outside the registry
1903
+ # -- and a case that arrives from elsewhere is exactly the untrusted
1904
+ # input this command exists to accept.
1905
+ typer.echo(
1906
+ f"{case_file}: {case_id!r} is not a case id. Expected the "
1907
+ f"anonymised form `fde retro` writes (case-<hex>).", err=True,
1908
+ )
1909
+ raise typer.Exit(1)
1910
+
1911
+ cases_dir = Path(root) / "cases"
1912
+ if not cases_dir.is_dir():
1913
+ # Never conjure a registry: a typo'd --root once created a whole
1914
+ # tree from nothing and reported success.
1915
+ typer.echo(
1916
+ f"{root}: not a registry (no cases/ directory). Point --root at "
1917
+ f"one rather than at a path to be created.", err=True,
1918
+ )
1919
+ raise typer.Exit(1)
1920
+
1921
+ target = cases_dir / f"{case_id}.md"
1922
+ if target.exists():
1923
+ typer.echo(f"{target}: already in the corpus. Cases are append-only; "
1924
+ f"a new retrospective makes a new case.", err=True)
1925
+ raise typer.Exit(1)
1926
+
1927
+ front = {k: v for k, v in case.items() if k != "sanitization"}
1928
+ front["sanitization"] = "pending"
1929
+ target.write_text(
1930
+ f"---\n{yaml.safe_dump(front, sort_keys=False)}---\n"
1931
+ f"Ingested from an engagement retrospective, not yet reviewed.\n\n"
1932
+ f"Before this can be committed anywhere: read every field for anything\n"
1933
+ f"that identifies a client, re-express what does, then set\n"
1934
+ f"`sanitization: reviewed` by hand. Pending cases are refused by the\n"
1935
+ f"sanitisation gate.\n"
1936
+ )
1937
+ typer.echo(f"wrote {target} [sanitization: pending]")
1938
+ typer.echo("review it, then set sanitization: reviewed -- the gate refuses "
1939
+ "pending cases")
1940
+
1941
+
1942
+ @kb.command("export-training")
1943
+ def kb_export_training(
1944
+ root: Annotated[Path, typer.Argument(help="The engagement directory.")],
1945
+ out: Annotated[Path, typer.Option(help="Where to write the .jsonl.")],
1946
+ ) -> None:
1947
+ """Export (brief, facts) pairs -- the fine-tune flywheel.
1948
+
1949
+ Every retained brief beside the facts it yielded, one JSON object per
1950
+ session. This is the corpus a fine-tuned reader learns from -- and the
1951
+ corpus's own doctrine for clients applies to the framework itself: a
1952
+ fine-tune earns adoption when the pairs cross a real threshold AND the
1953
+ measured hit rate of the base model falls short, not before. The output
1954
+ is client data; it belongs wherever the engagement does, never in a
1955
+ repository.
1956
+ """
1957
+ engagement = _engagement(root)
1958
+ briefs = engagement.root / "artifacts" / "briefs"
1959
+ if not briefs.is_dir():
1960
+ typer.echo(
1961
+ "no retained briefs here -- pairs come from `fde frame` runs "
1962
+ "made after briefs began to be retained. Re-frame the brief "
1963
+ "and re-export.", err=True,
1964
+ )
1965
+ raise typer.Exit(1)
1966
+
1967
+ sessions = {
1968
+ path.stem: Session.from_yaml(path.read_text(), path.stem)
1969
+ for path in sorted(engagement.facts_dir.glob("*.yaml"))
1970
+ }
1971
+ rows = []
1972
+ for brief_path in sorted(briefs.glob("*.txt")):
1973
+ session = sessions.get(brief_path.stem)
1974
+ if session is None:
1975
+ continue
1976
+ rows.append({
1977
+ "text": brief_path.read_text(),
1978
+ "facts": [
1979
+ {"dimension": f.dimension, "value": f.value,
1980
+ "span": list(f.span) if f.span else None}
1981
+ for f in session.facts
1982
+ ],
1983
+ })
1984
+ if not rows:
1985
+ typer.echo("no (brief, facts) pairs found", err=True)
1986
+ raise typer.Exit(1)
1987
+ out.write_text("".join(json.dumps(r) + "\n" for r in rows))
1988
+ typer.echo(
1989
+ f"wrote {len(rows)} pair(s) to {out}\n"
1990
+ f"doctrine: fine-tune the reader when the pairs number in the "
1991
+ f"thousands AND the measured base-model hit rate falls short -- "
1992
+ f"the same bar the corpus holds clients to."
1993
+ )
1994
+
1995
+
1996
+ @kb.command("suggest")
1997
+ def kb_suggest(
1998
+ text: Annotated[str | None, typer.Option(help="The brief, inline.")] = None,
1999
+ file: Annotated[Path | None, typer.Option(help="A file holding the brief.")] = None,
2000
+ endpoint: Annotated[str | None, typer.Option(
2001
+ help="OpenAI-compatible local model server. Without it the hosted "
2002
+ "model is used -- refused unless the text itself may leave."
2003
+ )] = None,
2004
+ model: Annotated[str | None, typer.Option(help="Model name.")] = None,
2005
+ registry_root: Annotated[Path, typer.Option("--registry")] = DEFAULT_ROOT,
2006
+ ) -> None:
2007
+ """Mine a brief for recogniser gaps -- the vocabulary treadmill, automated.
2008
+
2009
+ Runs the deterministic reader and a model reader over the same text and
2010
+ proposes recogniser phrases for exactly the delta: facts the model found,
2011
+ validated against the registry's declared values, that the vocabulary
2012
+ missed. Output is a review-ready diff for framework/dimensions/ -- it
2013
+ never edits the registry, because a recogniser is a content change that
2014
+ deserves a human eye and a test.
2015
+ """
2016
+ if not text and not file:
2017
+ typer.echo("Give me --text or --file.", err=True)
2018
+ raise typer.Exit(1)
2019
+ body = file.read_text() if file else (text or "")
2020
+ registry = _registry(registry_root)
2021
+
2022
+ from fde.intake.llm_reader import (
2023
+ BoundaryRefusal,
2024
+ ReaderUnavailable,
2025
+ suggest_recognisers,
2026
+ )
2027
+
2028
+ try:
2029
+ suggestions, dropped = suggest_recognisers(
2030
+ body, registry, endpoint=endpoint, model=model,
2031
+ )
2032
+ except (BoundaryRefusal, ReaderUnavailable) as exc:
2033
+ typer.echo(str(exc), err=True)
2034
+ raise typer.Exit(1) from exc
2035
+
2036
+ if not suggestions:
2037
+ typer.echo("the model found nothing the vocabulary missed")
2038
+ return
2039
+ typer.echo("recogniser candidates -- review, test, then edit the "
2040
+ "dimension file by hand:\n")
2041
+ for s in suggestions:
2042
+ typer.echo(f" framework/dimensions/{s['dimension']}.md")
2043
+ typer.echo(f" {s['value']}: add phrase {s['phrase']!r}")
2044
+ typer.echo(f" evidence: {s['evidence']!r}\n")
2045
+ for reason in dropped:
2046
+ typer.echo(f" (refused: {reason})")
2047
+
2048
+
2049
+ @kb.command("sweep")
2050
+ def kb_sweep(
2051
+ root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
2052
+ samples: Annotated[int, typer.Option(help="Fully specified profiles to try.")] = 300,
2053
+ seed: Annotated[int, typer.Option(help="Deterministic sampling seed.")] = 0,
2054
+ ) -> None:
2055
+ """Find profiles the registry cannot serve. Work items -- always exits 0.
2056
+
2057
+ `kb gaps` checks that approaches exist; this checks that one can fire.
2058
+ They disagree exactly where it hurts: a component with five approaches,
2059
+ all ruled out by one combination of honest answers, counts as covered
2060
+ and is undecidable.
2061
+ """
2062
+ try:
2063
+ registry = load_registry(root)
2064
+ except RegistryError as exc:
2065
+ typer.echo(str(exc), err=True)
2066
+ raise typer.Exit(1) from exc
2067
+
2068
+ from fde.graph import sweep_dead_zones
2069
+
2070
+ result = sweep_dead_zones(registry, samples=samples, seed=seed)
2071
+ dead = result["dead"]
2072
+ if not dead:
2073
+ typer.echo(f"{samples} fully specified profiles, every component decidable")
2074
+ return
2075
+
2076
+ typer.echo(f"{samples} profiles; components undecidable in some of them:\n")
2077
+ for component, entry in dead.items():
2078
+ typer.echo(f" {component:16} {entry['rate']:.1%}")
2079
+ example = ", ".join(f"{k}={v}" for k, v in sorted(entry["example"].items()))
2080
+ typer.echo(f" e.g. {example}")
2081
+ typer.echo(
2082
+ "\nSome are honest contradictions the design should surface, not fill. "
2083
+ "`fde architect` names the conflicting facts for any specific profile."
2084
+ )
2085
+
2086
+
2087
+ @kb.command("gaps")
2088
+ def kb_gaps(
2089
+ root: Annotated[Path, typer.Option(help="Registry directory.")] = DEFAULT_ROOT,
2090
+ ) -> None:
2091
+ """Report what the corpus is missing. Work items, not errors -- always exits 0."""
2092
+ try:
2093
+ registry = load_registry(root)
2094
+ except RegistryError as exc:
2095
+ typer.echo(str(exc), err=True)
2096
+ raise typer.Exit(1) from exc
2097
+
2098
+ gaps = find_gaps(registry, templates=root / "templates")
2099
+ for gap in gaps:
2100
+ typer.echo(f"{gap.kind}: {gap.detail}")
2101
+ typer.echo(f"{len(gaps)} gap(s)")
2102
+
2103
+
2104
+ if __name__ == "__main__": # pragma: no cover
2105
+ app()
2106
+
2107
+