fux-engine 0.3.2__tar.gz → 0.4.0__tar.gz

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 (159) hide show
  1. {fux_engine-0.3.2 → fux_engine-0.4.0}/PKG-INFO +61 -10
  2. {fux_engine-0.3.2 → fux_engine-0.4.0}/README.md +58 -9
  3. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/__init__.py +1 -1
  4. fux_engine-0.4.0/fux/baseline.py +35 -0
  5. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/check.py +12 -3
  6. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/cli.py +18 -2
  7. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/clicmds.py +11 -6
  8. fux_engine-0.4.0/fux/cliconstitution.py +61 -0
  9. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/cliquery.py +1 -1
  10. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/config.py +5 -0
  11. fux_engine-0.4.0/fux/constitution.py +100 -0
  12. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/costledger.py +20 -9
  13. fux_engine-0.4.0/fux/critic.py +45 -0
  14. fux_engine-0.4.0/fux/criticllm.py +33 -0
  15. fux_engine-0.4.0/fux/criticloop.py +77 -0
  16. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/schema.json +14 -0
  17. fux_engine-0.4.0/fux/data/skills/critic/SKILL.md +55 -0
  18. fux_engine-0.4.0/fux/data/skills/debate/SKILL.md +68 -0
  19. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/fux/SKILL.md +10 -2
  20. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/savings/SKILL.md +10 -5
  21. fux_engine-0.4.0/fux/findings.py +47 -0
  22. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/fmwrite.py +3 -3
  23. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/gate.py +23 -6
  24. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/gitutil.py +5 -0
  25. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/mcpserver.py +1 -1
  26. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/savings.py +36 -14
  27. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux_engine.egg-info/PKG-INFO +61 -10
  28. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux_engine.egg-info/SOURCES.txt +13 -0
  29. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux_engine.egg-info/requires.txt +3 -0
  30. {fux_engine-0.3.2 → fux_engine-0.4.0}/pyproject.toml +4 -0
  31. fux_engine-0.4.0/tests/test_constitution_integrity.py +75 -0
  32. fux_engine-0.4.0/tests/test_constitution_tier.py +72 -0
  33. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_costledger.py +13 -0
  34. fux_engine-0.4.0/tests/test_critic_loop.py +65 -0
  35. fux_engine-0.4.0/tests/test_critic_split.py +57 -0
  36. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_lint_stats_gate.py +3 -1
  37. fux_engine-0.4.0/tests/test_no_llm_imports.py +58 -0
  38. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_savings.py +26 -0
  39. fux_engine-0.3.2/fux/findings.py +0 -26
  40. {fux_engine-0.3.2 → fux_engine-0.4.0}/LICENSE +0 -0
  41. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/__main__.py +0 -0
  42. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/assets/fux-icon.svg +0 -0
  43. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/assets/fux-lockup.svg +0 -0
  44. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/assets/fux-mark.svg +0 -0
  45. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/assets/graph_boot.js +0 -0
  46. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/assets/graph_template.html +0 -0
  47. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/astextract.py +0 -0
  48. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/bench.py +0 -0
  49. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/build.py +0 -0
  50. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/capture.py +0 -0
  51. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/cligraph.py +0 -0
  52. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/cliutil.py +0 -0
  53. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/community.py +0 -0
  54. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/components.py +0 -0
  55. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/context.py +0 -0
  56. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/coverage.py +0 -0
  57. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/copilot/prompts/fux-plan.prompt.md +0 -0
  58. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/copilot/prompts/fux.prompt.md +0 -0
  59. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/global/README.md +0 -0
  60. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/global/rules/async-everywhere.md +0 -0
  61. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/global/rules/doc-per-code-change.md +0 -0
  62. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/global/rules/files-max-100-lines.md +0 -0
  63. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/global/rules/no-secrets-in-vcs.md +0 -0
  64. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/hooks/_common.sh +0 -0
  65. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/hooks/post_tool_use.sh +0 -0
  66. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/hooks/session_start.sh +0 -0
  67. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/hooks/stop.sh +0 -0
  68. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/hooks/user_prompt_submit.sh +0 -0
  69. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/packs/indian-markets-tax/pack.toml +0 -0
  70. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/packs/indian-markets-tax/rules/capital-gains-equity.md +0 -0
  71. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/packs/indian-markets-tax/rules/market-hours-nse.md +0 -0
  72. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/adr/SKILL.md +0 -0
  73. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/distill/SKILL.md +0 -0
  74. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/fetch-rules/SKILL.md +0 -0
  75. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/plan/SKILL.md +0 -0
  76. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/data/skills/trace/SKILL.md +0 -0
  77. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/drift.py +0 -0
  78. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/embed.py +0 -0
  79. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/explain.py +0 -0
  80. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/feedback.py +0 -0
  81. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/fetchrules.py +0 -0
  82. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/fix.py +0 -0
  83. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/frontmatter.py +0 -0
  84. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/globs.py +0 -0
  85. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/governance.py +0 -0
  86. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/graph.py +0 -0
  87. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/graphhtml.py +0 -0
  88. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/graphquery.py +0 -0
  89. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/hookinstall.py +0 -0
  90. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/hookio.py +0 -0
  91. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/hooks.py +0 -0
  92. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/hybrid.py +0 -0
  93. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/impact.py +0 -0
  94. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/importer.py +0 -0
  95. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/index.py +0 -0
  96. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/initcmd.py +0 -0
  97. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/lint.py +0 -0
  98. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/loader.py +0 -0
  99. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/mine.py +0 -0
  100. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/model.py +0 -0
  101. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/narrative.py +0 -0
  102. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/pack.py +0 -0
  103. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/parity.py +0 -0
  104. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/paths.py +0 -0
  105. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/recall.py +0 -0
  106. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/report.py +0 -0
  107. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/scaffold.py +0 -0
  108. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/scalars.py +0 -0
  109. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/schema.py +0 -0
  110. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/seal.py +0 -0
  111. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/serve.py +0 -0
  112. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/settings.py +0 -0
  113. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/stats.py +0 -0
  114. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/templates/formula.md +0 -0
  115. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/templates/spec.md +0 -0
  116. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/touch.py +0 -0
  117. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/tour.py +0 -0
  118. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/uispec.py +0 -0
  119. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/usage.py +0 -0
  120. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/verify.py +0 -0
  121. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux/vexamples.py +0 -0
  122. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux_engine.egg-info/dependency_links.txt +0 -0
  123. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux_engine.egg-info/entry_points.txt +0 -0
  124. {fux_engine-0.3.2 → fux_engine-0.4.0}/fux_engine.egg-info/top_level.txt +0 -0
  125. {fux_engine-0.3.2 → fux_engine-0.4.0}/setup.cfg +0 -0
  126. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_ast_backend.py +0 -0
  127. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_astextract.py +0 -0
  128. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_bm25f_expand.py +0 -0
  129. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_capture_governance.py +0 -0
  130. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_centrality.py +0 -0
  131. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_check_fix.py +0 -0
  132. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_components.py +0 -0
  133. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_crossfile_calls.py +0 -0
  134. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_edge_confidence.py +0 -0
  135. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_embed_rerank.py +0 -0
  136. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_examples.py +0 -0
  137. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_feedback.py +0 -0
  138. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_fetch_rules.py +0 -0
  139. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_frontmatter.py +0 -0
  140. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_fuzz_mine.py +0 -0
  141. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_globs.py +0 -0
  142. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_graph_determinism.py +0 -0
  143. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_graphhtml.py +0 -0
  144. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_graphhtml_links.py +0 -0
  145. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_hookinstall.py +0 -0
  146. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_hybrid.py +0 -0
  147. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_impact.py +0 -0
  148. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_mcp.py +0 -0
  149. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_mcp_extra.py +0 -0
  150. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_pack.py +0 -0
  151. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_parity_import.py +0 -0
  152. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_recall_build_verify.py +0 -0
  153. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_recall_eval.py +0 -0
  154. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_resolution.py +0 -0
  155. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_schema_scaffold_init.py +0 -0
  156. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_seal.py +0 -0
  157. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_serve_sanitize.py +0 -0
  158. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_uispec.py +0 -0
  159. {fux_engine-0.3.2 → fux_engine-0.4.0}/tests/test_verify_hardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fux-engine
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Fux — a portable agent-aware knowledge engine: rules, memory, narrative, and graph in one frontmatter substrate.
5
5
  Author-email: arpit arya <arpitarya.me@gmail.com>
6
6
  License: MIT
@@ -31,6 +31,8 @@ Requires-Dist: tree-sitter>=0.23; extra == "ast"
31
31
  Requires-Dist: tree-sitter-language-pack>=0.7; extra == "ast"
32
32
  Provides-Extra: pdf
33
33
  Requires-Dist: pypdf>=4.0; extra == "pdf"
34
+ Provides-Extra: critic
35
+ Requires-Dist: anthropic>=0.40; extra == "critic"
34
36
  Provides-Extra: dev
35
37
  Requires-Dist: pytest>=8; extra == "dev"
36
38
  Requires-Dist: build>=1.0; extra == "dev"
@@ -41,15 +43,34 @@ Dynamic: license-file
41
43
 
42
44
  # Fux
43
45
 
44
- > A portable, agent-aware **knowledge engine**. One frontmatter substrate
45
- > derived **index**, **graph**, and **memory** views. `$0` deterministic
46
- > maintenance no mandatory LLM calls. Continuously referenced, cheaply
47
- > maintained.
46
+ > **Fux records *why* your code is the way it is — and checks it's still true.**
47
+ > A portable, agent-aware knowledge engine: one frontmatter substrate → derived
48
+ > **index**, **graph**, and **memory** views. `$0` and deterministic — zero
49
+ > third-party deps, no mandatory LLM calls. Author each rule once; your agent
50
+ > reads a cheap one-line index first and opens the full rule only when it's
51
+ > relevant — and `fux seal` lets `fux check` tell you when the governed code
52
+ > drifted out from under it.
53
+
54
+ > **New in v0.4.0 — the constitutional-app engine.** Govern where trust lives, opt-in and
55
+ > `$0`: give a rule a `tier` (`constitutional` · `standard` · `advisory`); author principles
56
+ > by two-agent **`/fux debate`**; **`fux ratify`** a rule into a tamper-evident constitution
57
+ > (sealed in **`.fux/constitution.lock`** — any later edit/add/delete is an always-blocking
58
+ > `tampered` finding); critique changes against the constitution with **`fux critic`** before
59
+ > they land (deterministic pass first, no LLM); and **`fux gate`** reports every ungoverned
60
+ > path (report-first, never blocks). The AI self-critique ships behind an opt-in **`[critic]`**
61
+ > extra — the default install stays model-free. Adopting it changes nothing until you opt in.
62
+
63
+ <!-- launch: replace the line below with the demo GIF — `fux why day-pnl` → rule + why +
64
+ governed code, then the Solar Terminal graph igniting the `governs` links.
65
+ Storyboard + capture script: docs/launch/gif-storyboard.md -->
66
+ <p align="center"><em>▶ demo GIF goes here — see <a href="docs/launch/gif-storyboard.md">docs/launch/gif-storyboard.md</a></em></p>
67
+
68
+ **Pronounced "fox."** (Say it like the animal — *fux* → *fox*.)
48
69
 
49
70
  Named after *Johann Joseph Fux*, author of *Gradus ad Parnassum* (1725) — the
50
71
  counterpoint treatise every composer learned the rules from. A tool that codifies
51
- and enforces rules, named after the man who wrote *the* rulebook. Sits beside
52
- `wagner`, `bach`, `orff`.
72
+ and enforces rules, named after the man who wrote *the* rulebook. (The name is
73
+ deliberate.) Sits beside `wagner`, `bach`, `orff`.
53
74
 
54
75
  Fux **unifies and replaces** three things a project usually runs separately — the
55
76
  structural graph (graphify), cross-session memory, and the narrative docs — and
@@ -73,12 +94,14 @@ So: write the *why* down once → it's found fast, stays correct, and never gets
73
94
  ## Why
74
95
 
75
96
  The *why* behind a formula — why current value not invested cost, why
76
- INR-normalize first, which cost-basis method — usually lives only as an inline
97
+ dollar-normalize first, which cost-basis method — usually lives only as an inline
77
98
  comment, invisible until someone greps for it. Fux makes that knowledge
78
99
  **first-class**: one entry, authored once, served back through a tiny index (read
79
100
  first) plus lazily-opened rules (read only when relevant). Lookups run ~5–10×
80
101
  cheaper and more correct on every later session — and you don't have to take that
81
- on faith: **`fux savings`** measures the multiplier from your own file sizes.
102
+ on faith: **`fux savings`** measures the multiplier from your own file sizes and
103
+ prices the win in **real dollars** (configurable `usd_per_mtok`, default = Claude
104
+ Opus 4.8's $5/M input rate), per lookup and as a cumulative ledger.
82
105
 
83
106
  ## Install
84
107
 
@@ -102,10 +125,13 @@ fux why day-pnl [--history] # explain a rule (+ how its *why* evolved, via gi
102
125
  fux refs src/aggregator.py # which rules govern this file
103
126
  fux recall "how is day P&L computed" --hybrid # BM25F; RRF-fuse lexical+semantic+graph
104
127
  fux seal --all # bind rules to an AST fingerprint of their code
128
+ fux debate "<rule>" (skill) # two-agent free debate → you ratify the result
129
+ fux ratify <id> --by Arpit # ratify a constitutional rule (tamper-evident; the only path)
130
+ fux critic "<change>" # critique a change vs principles before it lands (deterministic pass; $0)
105
131
  fux coverage # % of important files with a governing rule
106
132
  fux verify --fuzz # run invariant `check:`; boundary-fuzz for div-by-zero
107
133
  fux mine # surface candidate rules latent in the code (drafts)
108
- fux savings "how is day P&L computed" # measured token-cost win (+ cumulative ledger)
134
+ fux savings "how is day P&L computed" # measured token + dollar cost win (+ cumulative ledger)
109
135
  fux lint # rule *quality*: missing why / code_refs / edges
110
136
  fux stats # knowledge-health dashboard + score
111
137
  fux gate --install # wire a git pre-commit enforcement hook
@@ -170,6 +196,31 @@ Beyond authoring, Fux **enforces and reports**: `fux lint` grades rule quality,
170
196
  `fux stats` scores knowledge health, `fux gate` blocks drift at commit/CI time,
171
197
  and `fux mcp` exposes the whole substrate to agents over MCP.
172
198
 
199
+ **Constitutional tier (opt-in):** a rule's `tier` (`constitutional` · `standard` ·
200
+ `advisory`) sets how hard it bites — constitutional rules block in any mode. A principle
201
+ *becomes* law through `/fux debate "<rule>"` — a skill that spawns **two sub-agents** (no
202
+ assigned sides, blind first passes, anti-sycophancy gates) and escalates to **you** as
203
+ tie-breaker — then `fux ratify` makes it tamper-evident: it stamps a `content_seal` (and the
204
+ debate's `debate_hash`) and records the rule in a committed `.fux/constitution.lock`, so any
205
+ in-place edit, add, or delete becomes an always-blocking `tampered` finding. The debate is
206
+ the *host session's* tokens; Fux's own code never calls an LLM (a guard test proves the
207
+ maintenance path is model-free). All deterministic and `$0`. Default behaviour is unchanged
208
+ until you ratify (`tier` defaults to `standard`).
209
+
210
+ Each principle is tagged `enforcement: deterministic` (money/PII/numbers — decided by a
211
+ `check:`/seal, **never** sent to the AI critic) or `judgment` (tone/completeness — decided
212
+ by AI self-critique, **never** faked as a machine check); the split is enforced structurally
213
+ by a `$0` router, and `fux check` flags untagged rules that look like principles so backfill
214
+ is guided, not guessed.
215
+
216
+ At the action boundary (PreToolUse / pre-commit), `fux critic "<change>"` runs the
217
+ **deterministic pass first** (hard-invariant fails block, no LLM), then the host agent
218
+ self-critiques the `judgment` principles with its own tokens (the `critic` skill drives the
219
+ bounded revise / escalate / `/fux debate` loop); verdicts land in `.fux/out/critic.jsonl`,
220
+ and `fux gate` reports any ungoverned `important_globs` path (report-first, never blocks).
221
+ A headless AI critic for no-session/runtime use ships behind an opt-in `[critic]` extra
222
+ (mirroring `[embeddings]`) — the default install stays model-free.
223
+
173
224
  For cross-session memory it stays **authored, not captured**: an opt-in `capture`
174
225
  hook queues *which* files changed for `fux distill` (human-confirmed) rather than
175
226
  auto-summarising, and `type: memory` entries **decay** after a TTL so stale notes
@@ -2,15 +2,34 @@
2
2
 
3
3
  # Fux
4
4
 
5
- > A portable, agent-aware **knowledge engine**. One frontmatter substrate
6
- > derived **index**, **graph**, and **memory** views. `$0` deterministic
7
- > maintenance no mandatory LLM calls. Continuously referenced, cheaply
8
- > maintained.
5
+ > **Fux records *why* your code is the way it is — and checks it's still true.**
6
+ > A portable, agent-aware knowledge engine: one frontmatter substrate → derived
7
+ > **index**, **graph**, and **memory** views. `$0` and deterministic — zero
8
+ > third-party deps, no mandatory LLM calls. Author each rule once; your agent
9
+ > reads a cheap one-line index first and opens the full rule only when it's
10
+ > relevant — and `fux seal` lets `fux check` tell you when the governed code
11
+ > drifted out from under it.
12
+
13
+ > **New in v0.4.0 — the constitutional-app engine.** Govern where trust lives, opt-in and
14
+ > `$0`: give a rule a `tier` (`constitutional` · `standard` · `advisory`); author principles
15
+ > by two-agent **`/fux debate`**; **`fux ratify`** a rule into a tamper-evident constitution
16
+ > (sealed in **`.fux/constitution.lock`** — any later edit/add/delete is an always-blocking
17
+ > `tampered` finding); critique changes against the constitution with **`fux critic`** before
18
+ > they land (deterministic pass first, no LLM); and **`fux gate`** reports every ungoverned
19
+ > path (report-first, never blocks). The AI self-critique ships behind an opt-in **`[critic]`**
20
+ > extra — the default install stays model-free. Adopting it changes nothing until you opt in.
21
+
22
+ <!-- launch: replace the line below with the demo GIF — `fux why day-pnl` → rule + why +
23
+ governed code, then the Solar Terminal graph igniting the `governs` links.
24
+ Storyboard + capture script: docs/launch/gif-storyboard.md -->
25
+ <p align="center"><em>▶ demo GIF goes here — see <a href="docs/launch/gif-storyboard.md">docs/launch/gif-storyboard.md</a></em></p>
26
+
27
+ **Pronounced "fox."** (Say it like the animal — *fux* → *fox*.)
9
28
 
10
29
  Named after *Johann Joseph Fux*, author of *Gradus ad Parnassum* (1725) — the
11
30
  counterpoint treatise every composer learned the rules from. A tool that codifies
12
- and enforces rules, named after the man who wrote *the* rulebook. Sits beside
13
- `wagner`, `bach`, `orff`.
31
+ and enforces rules, named after the man who wrote *the* rulebook. (The name is
32
+ deliberate.) Sits beside `wagner`, `bach`, `orff`.
14
33
 
15
34
  Fux **unifies and replaces** three things a project usually runs separately — the
16
35
  structural graph (graphify), cross-session memory, and the narrative docs — and
@@ -34,12 +53,14 @@ So: write the *why* down once → it's found fast, stays correct, and never gets
34
53
  ## Why
35
54
 
36
55
  The *why* behind a formula — why current value not invested cost, why
37
- INR-normalize first, which cost-basis method — usually lives only as an inline
56
+ dollar-normalize first, which cost-basis method — usually lives only as an inline
38
57
  comment, invisible until someone greps for it. Fux makes that knowledge
39
58
  **first-class**: one entry, authored once, served back through a tiny index (read
40
59
  first) plus lazily-opened rules (read only when relevant). Lookups run ~5–10×
41
60
  cheaper and more correct on every later session — and you don't have to take that
42
- on faith: **`fux savings`** measures the multiplier from your own file sizes.
61
+ on faith: **`fux savings`** measures the multiplier from your own file sizes and
62
+ prices the win in **real dollars** (configurable `usd_per_mtok`, default = Claude
63
+ Opus 4.8's $5/M input rate), per lookup and as a cumulative ledger.
43
64
 
44
65
  ## Install
45
66
 
@@ -63,10 +84,13 @@ fux why day-pnl [--history] # explain a rule (+ how its *why* evolved, via gi
63
84
  fux refs src/aggregator.py # which rules govern this file
64
85
  fux recall "how is day P&L computed" --hybrid # BM25F; RRF-fuse lexical+semantic+graph
65
86
  fux seal --all # bind rules to an AST fingerprint of their code
87
+ fux debate "<rule>" (skill) # two-agent free debate → you ratify the result
88
+ fux ratify <id> --by Arpit # ratify a constitutional rule (tamper-evident; the only path)
89
+ fux critic "<change>" # critique a change vs principles before it lands (deterministic pass; $0)
66
90
  fux coverage # % of important files with a governing rule
67
91
  fux verify --fuzz # run invariant `check:`; boundary-fuzz for div-by-zero
68
92
  fux mine # surface candidate rules latent in the code (drafts)
69
- fux savings "how is day P&L computed" # measured token-cost win (+ cumulative ledger)
93
+ fux savings "how is day P&L computed" # measured token + dollar cost win (+ cumulative ledger)
70
94
  fux lint # rule *quality*: missing why / code_refs / edges
71
95
  fux stats # knowledge-health dashboard + score
72
96
  fux gate --install # wire a git pre-commit enforcement hook
@@ -131,6 +155,31 @@ Beyond authoring, Fux **enforces and reports**: `fux lint` grades rule quality,
131
155
  `fux stats` scores knowledge health, `fux gate` blocks drift at commit/CI time,
132
156
  and `fux mcp` exposes the whole substrate to agents over MCP.
133
157
 
158
+ **Constitutional tier (opt-in):** a rule's `tier` (`constitutional` · `standard` ·
159
+ `advisory`) sets how hard it bites — constitutional rules block in any mode. A principle
160
+ *becomes* law through `/fux debate "<rule>"` — a skill that spawns **two sub-agents** (no
161
+ assigned sides, blind first passes, anti-sycophancy gates) and escalates to **you** as
162
+ tie-breaker — then `fux ratify` makes it tamper-evident: it stamps a `content_seal` (and the
163
+ debate's `debate_hash`) and records the rule in a committed `.fux/constitution.lock`, so any
164
+ in-place edit, add, or delete becomes an always-blocking `tampered` finding. The debate is
165
+ the *host session's* tokens; Fux's own code never calls an LLM (a guard test proves the
166
+ maintenance path is model-free). All deterministic and `$0`. Default behaviour is unchanged
167
+ until you ratify (`tier` defaults to `standard`).
168
+
169
+ Each principle is tagged `enforcement: deterministic` (money/PII/numbers — decided by a
170
+ `check:`/seal, **never** sent to the AI critic) or `judgment` (tone/completeness — decided
171
+ by AI self-critique, **never** faked as a machine check); the split is enforced structurally
172
+ by a `$0` router, and `fux check` flags untagged rules that look like principles so backfill
173
+ is guided, not guessed.
174
+
175
+ At the action boundary (PreToolUse / pre-commit), `fux critic "<change>"` runs the
176
+ **deterministic pass first** (hard-invariant fails block, no LLM), then the host agent
177
+ self-critiques the `judgment` principles with its own tokens (the `critic` skill drives the
178
+ bounded revise / escalate / `/fux debate` loop); verdicts land in `.fux/out/critic.jsonl`,
179
+ and `fux gate` reports any ungoverned `important_globs` path (report-first, never blocks).
180
+ A headless AI critic for no-session/runtime use ships behind an opt-in `[critic]` extra
181
+ (mirroring `[embeddings]`) — the default install stays model-free.
182
+
134
183
  For cross-session memory it stays **authored, not captured**: an opt-in `capture`
135
184
  hook queues *which* files changed for `fux distill` (human-confirmed) rather than
136
185
  auto-summarising, and `type: memory` entries **decay** after a TTL so stale notes
@@ -3,4 +3,4 @@
3
3
  One frontmatter substrate → derived index, graph, and memory views.
4
4
  $0 deterministic maintenance; no mandatory LLM calls. See docs/fux-plan.md.
5
5
  """
6
- __version__ = "0.3.2"
6
+ __version__ = "0.4.0"
@@ -0,0 +1,35 @@
1
+ """§5b migration guard — snapshot findings, surface only the NEW ones ($0).
2
+
3
+ A *transient* upgrade guard, not a regression subsystem: `fux check --baseline-write`
4
+ snapshots current findings (canonical: kind, rule_id, message) pre-upgrade; `fux gate
5
+ --baseline` re-runs and fails only on findings absent from the snapshot, so a backward-
6
+ compatible upgrade is provably a no-op. Reuses the `Finding` serialization; stdlib only.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from fux.findings import Finding
13
+
14
+
15
+ def _sig(f: Finding) -> str:
16
+ return f"{f.kind}\t{f.rule_id}\t{f.message}"
17
+
18
+
19
+ def write(path: Path, findings: list[Finding]) -> int:
20
+ """Snapshot findings canonically sorted. Returns the count written."""
21
+ sigs = sorted(_sig(f) for f in findings)
22
+ path.write_text("\n".join(sigs) + ("\n" if sigs else ""), encoding="utf-8")
23
+ return len(sigs)
24
+
25
+
26
+ def _read(path: Path) -> set[str]:
27
+ if not path.exists():
28
+ return set()
29
+ return {ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()}
30
+
31
+
32
+ def new_findings(path: Path, findings: list[Finding]) -> list[Finding]:
33
+ """Findings absent from the baseline snapshot — the upgrade's new ones."""
34
+ base = _read(path)
35
+ return [f for f in findings if _sig(f) not in base]
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
  import re
5
5
  from pathlib import Path
6
6
 
7
- from fux import config, gitutil, governance, loader, paths, schema, seal
7
+ from fux import (config, constitution, critic, gitutil, governance, loader, paths,
8
+ schema, seal)
8
9
  from fux.findings import Finding
9
10
  from fux.model import Rule
10
11
 
@@ -25,6 +26,13 @@ def run(root: Path) -> list[Finding]:
25
26
  findings += _seal(r, root)
26
27
  findings += _conflicts(layered)
27
28
  findings += _extractor_drift(fp)
29
+ findings += constitution.check_tamper(rs.rules) # constitution layer (plan §6, §7a)
30
+ findings += constitution.check_lock(root, rs.rules)
31
+ findings += critic.untagged_candidates(rs.rules) # advisory backfill guide (plan §3, §5b)
32
+ tier_of = {r.id: str(r.fm.get("tier", "standard")) for r in rs.rules}
33
+ for f in findings:
34
+ f.tier = tier_of.get(f.rule_id, "standard") # constitution layer (plan §6)
35
+ findings.sort(key=lambda f: (f.kind, f.rule_id, f.message)) # canonical → baseline diff
28
36
  _write_drift(fp, findings)
29
37
  return findings
30
38
 
@@ -133,8 +141,9 @@ def _conflicts(layered: list[Rule]) -> list[Finding]:
133
141
  def _write_drift(fp: paths.Footprint, findings: list[Finding]) -> None:
134
142
  lines = ["# Fux DRIFT report", "",
135
143
  f"_{len(findings)} finding(s)._" if findings else "_No drift — all rules current._", ""]
136
- for kind in ("schema", "dead-ref", "conflict", "stale", "plan-drift", "invariant",
137
- "memory-stale", "unsealed", "extractor-drift"):
144
+ for kind in ("tampered", "schema", "dead-ref", "conflict", "stale", "plan-drift",
145
+ "invariant", "memory-stale", "unsealed", "untagged-candidate",
146
+ "extractor-drift"):
138
147
  group = [f for f in findings if f.kind == kind]
139
148
  if group:
140
149
  lines.append(f"## {kind} ({len(group)})")
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import argparse
5
5
 
6
- from fux import __version__, clicmds, cligraph, cliquery, hooks
6
+ from fux import __version__, clicmds, cliconstitution, cligraph, cliquery, hooks
7
7
 
8
8
 
9
9
  def build_parser() -> argparse.ArgumentParser:
@@ -20,6 +20,8 @@ def build_parser() -> argparse.ArgumentParser:
20
20
  bld.set_defaults(fn=clicmds.cmd_build)
21
21
  chk = sub.add_parser("check", help="validate schema/refs/staleness/conflicts")
22
22
  chk.add_argument("--fix", action="store_true", help="apply mechanical $0 repairs")
23
+ chk.add_argument("--baseline-write", metavar="FILE",
24
+ help="snapshot current findings for the §5b migration gate (then exit)")
23
25
  chk.set_defaults(fn=clicmds.cmd_check)
24
26
 
25
27
  sub.add_parser("context", help="emit the compact INDEX (SessionStart hook)").set_defaults(fn=clicmds.cmd_context)
@@ -42,6 +44,18 @@ def build_parser() -> argparse.ArgumentParser:
42
44
  sl.add_argument("--all", action="store_true", help="seal every rule with resolvable code")
43
45
  sl.set_defaults(fn=cliquery.cmd_seal)
44
46
 
47
+ rt = sub.add_parser("ratify", help="ratify a constitutional rule (stamp + seal + lock; no LLM)")
48
+ rt.add_argument("id")
49
+ rt.add_argument("--by", help="named human ratifier (default: git user.name)")
50
+ rt.add_argument("--date", help="ISO ratification date (default: today)")
51
+ rt.add_argument("--debate", metavar="FILE",
52
+ help="debate transcript (from /fux debate) to hash into ratification.debate_hash")
53
+ rt.set_defaults(fn=cliconstitution.cmd_ratify)
54
+
55
+ cr = sub.add_parser("critic", help="critique a proposed change against principles (deterministic pass first; $0)")
56
+ cr.add_argument("proposal", help="the proposed change / commit message / diff summary to critique")
57
+ cr.set_defaults(fn=cliconstitution.cmd_critic)
58
+
45
59
  refs = sub.add_parser("refs", help="reverse lookup: which rules govern this file")
46
60
  refs.add_argument("file")
47
61
  refs.set_defaults(fn=cliquery.cmd_refs)
@@ -58,7 +72,7 @@ def build_parser() -> argparse.ArgumentParser:
58
72
  vf.set_defaults(fn=cliquery.cmd_verify)
59
73
  sub.add_parser("tour", help="emit an ordered ONBOARDING.md").set_defaults(fn=cliquery.cmd_tour)
60
74
 
61
- sv = sub.add_parser("savings", help="estimate the token-cost win of Fux ($0)")
75
+ sv = sub.add_parser("savings", help="estimate the token + dollar cost win of Fux ($0)")
62
76
  sv.add_argument("query", nargs="?", help="optional: cost a specific lookup")
63
77
  sv.add_argument("--top", type=int, default=3)
64
78
  sv.add_argument("--reset", action="store_true", help="clear the cumulative cost ledger")
@@ -82,6 +96,8 @@ def build_parser() -> argparse.ArgumentParser:
82
96
  gt = sub.add_parser("gate", help="CI / pre-commit enforcement (exit 2 on blocking)")
83
97
  gt.add_argument("--install", action="store_true", help="install a git pre-commit hook")
84
98
  gt.add_argument("--strict-lint", action="store_true", help="treat lint findings as blocking")
99
+ gt.add_argument("--baseline", metavar="FILE",
100
+ help="§5b migration gate: fail only on findings new since this snapshot")
85
101
  gt.set_defaults(fn=clicmds.cmd_gate)
86
102
 
87
103
  sub.add_parser("mcp", help="serve the substrate over MCP (stdio JSON-RPC)").set_defaults(fn=clicmds.cmd_mcp)
@@ -5,8 +5,8 @@ import shutil
5
5
  import subprocess
6
6
  from pathlib import Path
7
7
 
8
- from fux import (build, check, config, context, fix, gate, hookinstall, importer,
9
- initcmd, mcpserver, paths, serve)
8
+ from fux import (baseline, build, check, config, context, fix, gate, hookinstall,
9
+ importer, initcmd, mcpserver, paths, serve)
10
10
  from fux.cliutil import root
11
11
  from fux.findings import blocking
12
12
 
@@ -51,6 +51,10 @@ def cmd_build(args) -> int:
51
51
  def cmd_check(args) -> int:
52
52
  here = root()
53
53
  findings = check.run(here)
54
+ if getattr(args, "baseline_write", None):
55
+ n = baseline.write(Path(args.baseline_write), findings)
56
+ print(f"✔ baseline written: {n} finding(s) → {args.baseline_write}")
57
+ return 0
54
58
  if args.fix:
55
59
  for n in fix.apply(here, findings):
56
60
  print(f"✔ fixed: {n}")
@@ -77,7 +81,8 @@ def cmd_gate(args) -> int:
77
81
  print(f"✔ pre-commit gate installed → {hook}")
78
82
  print(" it runs `fux gate` on every commit; bypass once with `git commit --no-verify`.")
79
83
  return 0
80
- code, report = gate.run(here, strict_lint=args.strict_lint)
84
+ base = Path(args.baseline) if getattr(args, "baseline", None) else None
85
+ code, report = gate.run(here, strict_lint=args.strict_lint, baseline=base)
81
86
  print(report)
82
87
  return code
83
88
 
@@ -166,15 +171,15 @@ def cmd_setup(_args) -> int:
166
171
  skills_src = data / "skills"
167
172
  _copy_skill(skills_src / "fux", skills_dir / "fux")
168
173
  print(f"✔ /fux skill → {skills_dir / 'fux'}/")
169
- for name in ("plan", "adr", "trace", "savings", "distill", "fetch-rules"):
174
+ for name in ("plan", "adr", "debate", "critic", "trace", "savings", "distill", "fetch-rules"):
170
175
  src = skills_src / name
171
176
  if src.exists():
172
177
  _copy_skill(src, skills_dir / f"fux-{name}")
173
- print(f"✔ sub-skills → {skills_dir}/fux-{{plan,adr,trace,savings,distill,fetch-rules}}/")
178
+ print(f"✔ sub-skills → {skills_dir}/fux-{{plan,adr,debate,critic,trace,savings,distill,fetch-rules}}/")
174
179
 
175
180
  codex_skills_dir.mkdir(parents=True, exist_ok=True)
176
181
  _copy_skill(skills_src / "fux", codex_skills_dir / "fux")
177
- for name in ("plan", "adr", "trace", "savings", "distill", "fetch-rules"):
182
+ for name in ("plan", "adr", "debate", "critic", "trace", "savings", "distill", "fetch-rules"):
178
183
  src = skills_src / name
179
184
  if src.exists():
180
185
  _copy_skill(src, codex_skills_dir / f"fux-{name}")
@@ -0,0 +1,61 @@
1
+ """CLI handlers for the constitution layer — `ratify` + `critic` (deterministic, no LLM)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ from datetime import date as _date
6
+ from pathlib import Path
7
+
8
+ from fux import config, constitution, criticloop, gitutil, loader, paths
9
+ from fux.cliutil import root
10
+
11
+
12
+ def cmd_ratify(args) -> int:
13
+ """Stamp ratification + freeze the seal + update the lock — the only path to the apex."""
14
+ here = root()
15
+ cfg = config.load(paths.Footprint(here).config)
16
+ rules = loader.resolve(here, cfg).rules
17
+ by = args.by or gitutil.user_name(here) or ""
18
+ if not by:
19
+ print("fux: no ratifier — pass --by <name> (or set git user.name)")
20
+ return 1
21
+ when = args.date or _date.today().isoformat()
22
+ dhash = None
23
+ if args.debate:
24
+ dpath = Path(args.debate)
25
+ if not dpath.is_file():
26
+ print(f"fux: debate transcript not found: {args.debate}")
27
+ return 1
28
+ dhash = hashlib.sha256(dpath.read_bytes()).hexdigest()[:16]
29
+ try:
30
+ r = constitution.ratify(here, rules, args.id, by=by, date=when, debate_hash=dhash)
31
+ except KeyError:
32
+ print(f"fux: no rule with id '{args.id}'")
33
+ return 1
34
+ except ValueError as e:
35
+ print(f"fux: {e}")
36
+ return 1
37
+ print(f"✔ ratified {r.id} → constitutional (by {by}, {when})")
38
+ print(f" content_seal frozen{f' + debate_hash {dhash}' if dhash else ''} + "
39
+ ".fux/constitution.lock updated — the only path into the apex.")
40
+ return 0
41
+
42
+
43
+ def cmd_critic(args) -> int:
44
+ """Critique a proposed change: deterministic pass first, then list judgment principles the
45
+ host agent must self-critique. $0, no LLM — the agent (or `[critic]` extra) is the judge."""
46
+ here = root()
47
+ result = criticloop.critique(here, args.proposal)
48
+ criticloop.record(here, result)
49
+ for v in result.verdicts:
50
+ mark = {"pass": "✔", "fail": "✗", "needs-judgment": "?"}.get(v.status, "·")
51
+ print(f" {mark} [{v.status}] {v.principle}: {v.rationale}".rstrip())
52
+ pend = result.pending
53
+ if pend:
54
+ print(f"\n{len(pend)} judgment principle(s) need self-critique — review each against the "
55
+ "proposal, revise, escalate if borderline (skills/critic/SKILL.md).")
56
+ if result.blocked:
57
+ print("\n✗ critic: a deterministic principle is violated — fix before proceeding.")
58
+ return 2
59
+ if not pend:
60
+ print("\n✔ critic: deterministic pass clean.")
61
+ return 0
@@ -106,7 +106,7 @@ def cmd_savings(args) -> int:
106
106
  return 0
107
107
  rep = savings.build(here, query=getattr(args, "query", None), top=args.top)
108
108
  print(savings.render(rep))
109
- print(costledger.render_summary(costledger.load(here)), end="")
109
+ print(costledger.render_summary(costledger.load(here), per_mtok=rep.usd_per_mtok), end="")
110
110
  return 0
111
111
 
112
112
 
@@ -30,6 +30,8 @@ DEFAULTS = {
30
30
  "memory_ttl_days": 180, # type: memory decays after this many untouched days (§17.3)
31
31
  "usage_tracking": False, # opt-in: record served rules → usage-weighted decay (§17.20c)
32
32
  "cost_tracking": False, # opt-in: record each lookup's savings → cumulative cost.json (§12)
33
+ "usd_per_mtok": 5.0, # $/million input tokens for `fux savings` dollar figures (§12);
34
+ # default = Claude Opus 4.8 input price. Model-agnostic — override per project.
33
35
  "parity_stay": [], # docs that stay/are out-of-scope for `fux parity` (§17.17)
34
36
  "context_budget_tokens": 0, # >0 ⇒ knapsack-pack the SessionStart INDEX (§17.25)
35
37
  "graph_editor": "vscode", # editor URI scheme for clickable graph.html node links:
@@ -78,6 +80,9 @@ def default_toml() -> str:
78
80
  "usage_tracking = false\n\n"
79
81
  "# Opt-in: accumulate each lookup's token savings into .fux/cost.json ($0).\n"
80
82
  "cost_tracking = false\n\n"
83
+ "# $/million input tokens used to price `fux savings` in dollars (model-agnostic).\n"
84
+ "# Default = Claude Opus 4.8 input price; set to your model's rate.\n"
85
+ "usd_per_mtok = 5.0\n\n"
81
86
  "# Docs that stay / are out-of-scope for `fux parity` (beyond conventions,\n"
82
87
  "# guardrails) — e.g. process docs that get deleted, not migrated to narrative.\n"
83
88
  "parity_stay = []\n\n"
@@ -0,0 +1,100 @@
1
+ """Constitutional integrity — tamper-evidence, ratification & the lock ($0, deterministic).
2
+ A ratified constitutional rule (§6) carries `ratification.content_seal`, recorded in
3
+ `.fux/constitution.lock`; `fux check` recomputes both → always-blocking `tampered`; `fux
4
+ ratify` is the only path that stamps them. No LLM, no new deps."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from fux import seal
11
+ from fux.findings import Finding
12
+ from fux.model import Rule
13
+
14
+ LOCK = "constitution.lock" # under .fux/
15
+ _VOLATILE = {"ratification", "seal", "updated"} # change without changing meaning
16
+
17
+
18
+ def content_seal(rule: Rule) -> str:
19
+ """Tamper fingerprint: hash of the rule's normalized body + governing frontmatter
20
+ (volatile seal/ratification/updated excluded). Reuses seal.py's hash + whitespace fold."""
21
+ gov = {k: v for k, v in rule.fm.items() if k not in _VOLATILE}
22
+ blob = json.dumps(gov, sort_keys=True, ensure_ascii=False, default=str)
23
+ return seal._hash(blob + "\n" + " ".join(rule.body.split()))
24
+
25
+
26
+ def _constitutional(rules: list[Rule]) -> list[Rule]:
27
+ return [r for r in rules if str(r.fm.get("tier")) == "constitutional"]
28
+
29
+
30
+ def check_tamper(rules: list[Rule]) -> list[Finding]:
31
+ """`tampered` for any ratified constitutional rule whose recomputed content_seal no
32
+ longer matches its stamped `ratification.content_seal` (a body/meaning edit)."""
33
+ out: list[Finding] = []
34
+ for r in _constitutional(rules):
35
+ stored = (r.fm.get("ratification") or {}).get("content_seal")
36
+ if stored and content_seal(r) != stored:
37
+ out.append(Finding("tampered", r.id,
38
+ "body/frontmatter changed since ratification — constitutional "
39
+ "rules never change in place; supersede + re-ratify"))
40
+ return out
41
+
42
+
43
+ def lock_manifest(rules: list[Rule]) -> dict[str, str]:
44
+ """The expected lock — {id: stamped content_seal} for every ratified constitutional rule."""
45
+ out = {r.id: (r.fm.get("ratification") or {}).get("content_seal")
46
+ for r in _constitutional(rules)}
47
+ return {k: v for k, v in sorted(out.items()) if v}
48
+
49
+
50
+ def _lock_path(root: Path) -> Path:
51
+ return root / ".fux" / LOCK
52
+
53
+
54
+ def _read_lock(root: Path) -> dict[str, str]:
55
+ p = _lock_path(root)
56
+ if not p.exists():
57
+ return {}
58
+ try:
59
+ return json.loads(p.read_text(encoding="utf-8"))
60
+ except (OSError, ValueError):
61
+ return {}
62
+
63
+
64
+ def check_lock(root: Path, rules: list[Rule]) -> list[Finding]:
65
+ """`tampered` wherever `.fux/constitution.lock` and the live ratified set diverge —
66
+ a constitutional rule added, deleted, or re-stamped outside `fux ratify`."""
67
+ locked, current = _read_lock(root), lock_manifest(rules)
68
+ out: list[Finding] = []
69
+ for rid in sorted(set(locked) | set(current)):
70
+ lo, cu = locked.get(rid), current.get(rid)
71
+ if lo == cu:
72
+ continue
73
+ why = ("missing/un-ratified on disk — restore + `fux ratify`" if cu is None
74
+ else "absent from the lock — `fux ratify` to record" if lo is None
75
+ else "content_seal differs from the lock — changed outside `fux ratify`")
76
+ out.append(Finding("tampered", rid, "constitution.lock mismatch: " + why))
77
+ return out
78
+
79
+
80
+ def ratify(root: Path, rules: list[Rule], rule_id: str, by: str, date: str,
81
+ debate_hash: str | None = None) -> Rule:
82
+ """The only path into the constitutional tier: stamp ratification.{by,date,content_seal,
83
+ debate_hash?}, freeze the code seal, rewrite `.fux/constitution.lock`. Raises if id absent
84
+ or not constitutional. Deterministic — no LLM, no clock (caller supplies date + hash)."""
85
+ from fux import fmwrite
86
+ rule = next((r for r in rules if r.id == rule_id), None)
87
+ if rule is None:
88
+ raise KeyError(rule_id)
89
+ if str(rule.fm.get("tier")) != "constitutional":
90
+ raise ValueError(f"{rule_id} is tier={rule.fm.get('tier', 'standard')}, not constitutional")
91
+ rat = {"by": by, "date": date, "content_seal": content_seal(rule)}
92
+ rule.fm["ratification"] = {**rat, "debate_hash": debate_hash} if debate_hash else rat
93
+ if (code_seal := seal.current(root, rule)):
94
+ rule.fm["seal"] = code_seal
95
+ rule.path.write_text(fmwrite.dump(rule.fm, rule.body), encoding="utf-8")
96
+ lock = _lock_path(root)
97
+ lock.parent.mkdir(parents=True, exist_ok=True)
98
+ lock.write_text(json.dumps(lock_manifest(rules), indent=2, sort_keys=True) + "\n",
99
+ encoding="utf-8")
100
+ return rule