fux-engine 0.3.0__tar.gz → 0.3.2__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 (146) hide show
  1. {fux_engine-0.3.0/fux_engine.egg-info → fux_engine-0.3.2}/PKG-INFO +14 -1
  2. {fux_engine-0.3.0 → fux_engine-0.3.2}/README.md +13 -0
  3. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/__init__.py +1 -1
  4. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/assets/graph_boot.js +62 -22
  5. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/assets/graph_template.html +1 -1
  6. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/costledger.py +33 -0
  7. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/hookinstall.py +22 -16
  8. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/paths.py +6 -0
  9. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/settings.py +21 -28
  10. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/touch.py +4 -3
  11. {fux_engine-0.3.0 → fux_engine-0.3.2/fux_engine.egg-info}/PKG-INFO +14 -1
  12. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux_engine.egg-info/SOURCES.txt +0 -1
  13. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_costledger.py +30 -0
  14. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_hookinstall.py +20 -4
  15. fux_engine-0.3.0/fux/data/hooks/pre_commit.sh +0 -27
  16. {fux_engine-0.3.0 → fux_engine-0.3.2}/LICENSE +0 -0
  17. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/__main__.py +0 -0
  18. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/assets/fux-icon.svg +0 -0
  19. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/assets/fux-lockup.svg +0 -0
  20. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/assets/fux-mark.svg +0 -0
  21. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/astextract.py +0 -0
  22. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/bench.py +0 -0
  23. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/build.py +0 -0
  24. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/capture.py +0 -0
  25. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/check.py +0 -0
  26. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/cli.py +0 -0
  27. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/clicmds.py +0 -0
  28. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/cligraph.py +0 -0
  29. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/cliquery.py +0 -0
  30. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/cliutil.py +0 -0
  31. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/community.py +0 -0
  32. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/components.py +0 -0
  33. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/config.py +0 -0
  34. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/context.py +0 -0
  35. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/coverage.py +0 -0
  36. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/copilot/prompts/fux-plan.prompt.md +0 -0
  37. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/copilot/prompts/fux.prompt.md +0 -0
  38. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/global/README.md +0 -0
  39. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/global/rules/async-everywhere.md +0 -0
  40. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/global/rules/doc-per-code-change.md +0 -0
  41. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/global/rules/files-max-100-lines.md +0 -0
  42. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/global/rules/no-secrets-in-vcs.md +0 -0
  43. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/hooks/_common.sh +0 -0
  44. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/hooks/post_tool_use.sh +0 -0
  45. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/hooks/session_start.sh +0 -0
  46. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/hooks/stop.sh +0 -0
  47. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/hooks/user_prompt_submit.sh +0 -0
  48. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/packs/indian-markets-tax/pack.toml +0 -0
  49. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/packs/indian-markets-tax/rules/capital-gains-equity.md +0 -0
  50. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/packs/indian-markets-tax/rules/market-hours-nse.md +0 -0
  51. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/schema.json +0 -0
  52. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/adr/SKILL.md +0 -0
  53. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/distill/SKILL.md +0 -0
  54. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/fetch-rules/SKILL.md +0 -0
  55. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/fux/SKILL.md +0 -0
  56. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/plan/SKILL.md +0 -0
  57. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/savings/SKILL.md +0 -0
  58. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/data/skills/trace/SKILL.md +0 -0
  59. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/drift.py +0 -0
  60. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/embed.py +0 -0
  61. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/explain.py +0 -0
  62. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/feedback.py +0 -0
  63. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/fetchrules.py +0 -0
  64. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/findings.py +0 -0
  65. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/fix.py +0 -0
  66. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/fmwrite.py +0 -0
  67. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/frontmatter.py +0 -0
  68. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/gate.py +0 -0
  69. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/gitutil.py +0 -0
  70. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/globs.py +0 -0
  71. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/governance.py +0 -0
  72. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/graph.py +0 -0
  73. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/graphhtml.py +0 -0
  74. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/graphquery.py +0 -0
  75. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/hookio.py +0 -0
  76. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/hooks.py +0 -0
  77. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/hybrid.py +0 -0
  78. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/impact.py +0 -0
  79. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/importer.py +0 -0
  80. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/index.py +0 -0
  81. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/initcmd.py +0 -0
  82. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/lint.py +0 -0
  83. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/loader.py +0 -0
  84. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/mcpserver.py +0 -0
  85. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/mine.py +0 -0
  86. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/model.py +0 -0
  87. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/narrative.py +0 -0
  88. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/pack.py +0 -0
  89. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/parity.py +0 -0
  90. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/recall.py +0 -0
  91. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/report.py +0 -0
  92. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/savings.py +0 -0
  93. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/scaffold.py +0 -0
  94. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/scalars.py +0 -0
  95. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/schema.py +0 -0
  96. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/seal.py +0 -0
  97. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/serve.py +0 -0
  98. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/stats.py +0 -0
  99. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/templates/formula.md +0 -0
  100. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/templates/spec.md +0 -0
  101. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/tour.py +0 -0
  102. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/uispec.py +0 -0
  103. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/usage.py +0 -0
  104. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/verify.py +0 -0
  105. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux/vexamples.py +0 -0
  106. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux_engine.egg-info/dependency_links.txt +0 -0
  107. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux_engine.egg-info/entry_points.txt +0 -0
  108. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux_engine.egg-info/requires.txt +0 -0
  109. {fux_engine-0.3.0 → fux_engine-0.3.2}/fux_engine.egg-info/top_level.txt +0 -0
  110. {fux_engine-0.3.0 → fux_engine-0.3.2}/pyproject.toml +0 -0
  111. {fux_engine-0.3.0 → fux_engine-0.3.2}/setup.cfg +0 -0
  112. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_ast_backend.py +0 -0
  113. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_astextract.py +0 -0
  114. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_bm25f_expand.py +0 -0
  115. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_capture_governance.py +0 -0
  116. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_centrality.py +0 -0
  117. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_check_fix.py +0 -0
  118. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_components.py +0 -0
  119. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_crossfile_calls.py +0 -0
  120. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_edge_confidence.py +0 -0
  121. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_embed_rerank.py +0 -0
  122. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_examples.py +0 -0
  123. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_feedback.py +0 -0
  124. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_fetch_rules.py +0 -0
  125. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_frontmatter.py +0 -0
  126. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_fuzz_mine.py +0 -0
  127. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_globs.py +0 -0
  128. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_graph_determinism.py +0 -0
  129. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_graphhtml.py +0 -0
  130. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_graphhtml_links.py +0 -0
  131. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_hybrid.py +0 -0
  132. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_impact.py +0 -0
  133. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_lint_stats_gate.py +0 -0
  134. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_mcp.py +0 -0
  135. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_mcp_extra.py +0 -0
  136. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_pack.py +0 -0
  137. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_parity_import.py +0 -0
  138. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_recall_build_verify.py +0 -0
  139. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_recall_eval.py +0 -0
  140. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_resolution.py +0 -0
  141. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_savings.py +0 -0
  142. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_schema_scaffold_init.py +0 -0
  143. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_seal.py +0 -0
  144. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_serve_sanitize.py +0 -0
  145. {fux_engine-0.3.0 → fux_engine-0.3.2}/tests/test_uispec.py +0 -0
  146. {fux_engine-0.3.0 → fux_engine-0.3.2}/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.0
3
+ Version: 0.3.2
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
@@ -115,6 +115,13 @@ fux serve # local dashboard over the generated views
115
115
  fux import docs/ # migrate existing markdown → narrative entries
116
116
  fux parity # is it safe to retire the old graph/docs/memory?
117
117
  fux tour # ordered ONBOARDING.md
118
+
119
+ # Runtime consumers (agents + apps, e.g. Anton's Orff concierge)
120
+ fux components [--scope dir] # component/hook registry for on-the-fly UI generation
121
+ fux validate-spec # validate a declarative UISpec against the registry
122
+ fux feedback # record rejected specs as candidate vocabulary gaps
123
+ fux hook-recall # stdin-JSON recall for agent prompt hooks
124
+ fux query / path / explain # graph traversal: cross-module "how does X relate to Y"
118
125
  ```
119
126
 
120
127
  **Complete, example-driven guide to everything Fux does:
@@ -180,6 +187,12 @@ effective ruleset = ~/.claude/fux/global/ (cross-project best practices)
180
187
  `project` overrides `pack` overrides `global`. `fux check` flags conflicts
181
188
  instead of silently shadowing.
182
189
 
190
+ > Packs are optional. A single-project setup can keep `packs = []` and hold all
191
+ > authored knowledge in the repo's own `.fux/` — version-controlled with the code
192
+ > it governs (see Anton's `knowledge-location` rule for the reasoning). Global
193
+ > rules are seeded from this repo's `fux/data/global/`, so they stay versioned
194
+ > tool code, not loose documents.
195
+
183
196
  ## Guarantee
184
197
 
185
198
  Every maintenance command is shell/AST/parse — **no LLM calls**. The only paths
@@ -76,6 +76,13 @@ fux serve # local dashboard over the generated views
76
76
  fux import docs/ # migrate existing markdown → narrative entries
77
77
  fux parity # is it safe to retire the old graph/docs/memory?
78
78
  fux tour # ordered ONBOARDING.md
79
+
80
+ # Runtime consumers (agents + apps, e.g. Anton's Orff concierge)
81
+ fux components [--scope dir] # component/hook registry for on-the-fly UI generation
82
+ fux validate-spec # validate a declarative UISpec against the registry
83
+ fux feedback # record rejected specs as candidate vocabulary gaps
84
+ fux hook-recall # stdin-JSON recall for agent prompt hooks
85
+ fux query / path / explain # graph traversal: cross-module "how does X relate to Y"
79
86
  ```
80
87
 
81
88
  **Complete, example-driven guide to everything Fux does:
@@ -141,6 +148,12 @@ effective ruleset = ~/.claude/fux/global/ (cross-project best practices)
141
148
  `project` overrides `pack` overrides `global`. `fux check` flags conflicts
142
149
  instead of silently shadowing.
143
150
 
151
+ > Packs are optional. A single-project setup can keep `packs = []` and hold all
152
+ > authored knowledge in the repo's own `.fux/` — version-controlled with the code
153
+ > it governs (see Anton's `knowledge-location` rule for the reasoning). Global
154
+ > rules are seeded from this repo's `fux/data/global/`, so they stay versioned
155
+ > tool code, not loose documents.
156
+
144
157
  ## Guarantee
145
158
 
146
159
  Every maintenance command is shell/AST/parse — **no LLM calls**. The only paths
@@ -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.0"
6
+ __version__ = "0.3.2"
@@ -42,6 +42,8 @@ for (const e of edges){ const a=byId[e.source], b=byId[e.target];
42
42
  (govTargets[kn.id]=govTargets[kn.id]||[]).push({id:other.id, type:e.type}); }
43
43
 
44
44
  let view = { x: 0, y: 0, k: 1 }, hidden = new Set(), hiddenE = new Set();
45
+ let vTarget = null; // when set, the camera eases toward it (smooth zoom/fly)
46
+ const VIEW_EASE = 0.2; // per-frame approach fraction toward vTarget
45
47
  let selected = null, hover = null, query = "", lens = "know";
46
48
  let running = true, showLabels = true, focusSet = null;
47
49
  let pathMode = false, pathA = null, pathSet = null, pathEdge = new Set(), pathMD = null;
@@ -135,7 +137,7 @@ function updateHits(){ if(!query){ $("qhits").innerHTML=""; return; }
135
137
  + (m.length>14 ? `<div class="ct" style="padding:5px 7px">+${m.length-14} more</div>` : "");
136
138
  $("qhits").querySelectorAll("[data-jump]").forEach(el => el.onclick = () => jumpTo(el.dataset.jump)); }
137
139
  function jumpTo(id){ const n=byId[id]; if(!n) return; selected=id; clearPath(); showDetail(n);
138
- if(view.k<1) view.k=1; view.x=-n.x*view.k; view.y=-n.y*view.k; }
140
+ const k=Math.max(view.k,1); flyTo(-n.x*k, -n.y*k, k); }
139
141
 
140
142
  // ---- copy / governance footer buttons ----
141
143
  $("bcopy").onclick = () => { if(pathSet && pathMD) copy(pathMD(), "path copied");
@@ -151,10 +153,10 @@ $("railtab").onclick = () => { $("right").classList.toggle("collapsed"); applyRi
151
153
  applyRightState(); // honour the default-collapsed markup on load
152
154
  // Micro / Macro both show the real nodes — they just change zoom. Micro zooms in
153
155
  // (centred on the selection if any); Macro fits the whole graph as an overview.
154
- $("bmicro").onclick = () => { focusSet=null; userMoved=true; const z=Math.max(view.k,1.3); const p=selected&&byId[selected];
155
- view.k=z; if(p){ view.x=-p.x*z; view.y=-p.y*z; } };
156
+ $("bmicro").onclick = () => { focusSet=null; const z=Math.max(view.k,1.3); const p=selected&&byId[selected];
157
+ if(p) flyTo(-p.x*z, -p.y*z, z); else zoomToCenter(z); };
156
158
  // Macro = the auto-framed overview; re-enable auto-fit so it stays framed on resize.
157
- $("bmacro").onclick = () => { focusSet=null; userMoved=false; query=""; $("q").value=""; updateHits(); clearPath(); fit(); };
159
+ $("bmacro").onclick = () => { focusSet=null; userMoved=false; query=""; $("q").value=""; updateHits(); clearPath(); fitAnimated(); };
158
160
 
159
161
  // ---- layout & geometry --------------------------------------------------
160
162
  let W=0, H=0;
@@ -201,7 +203,7 @@ function step(){
201
203
  if(c){ n.vx+=(c.x-n.x)*COMM_PULL*alpha; n.vy+=(c.y-n.y)*COMM_PULL*alpha; }
202
204
  n.vx*=.85; n.vy*=.85; n.x+=n.vx*0.5; n.y+=n.vy*0.5;
203
205
  n.vx-=n.x*GRAVITY*alpha; n.vy-=n.y*GRAVITY*alpha; }
204
- alpha *= ALPHA_DECAY; if(alpha < ALPHA_MIN){ alpha = 0; running = false; if(!userMoved) fit(); }
206
+ alpha *= ALPHA_DECAY; if(alpha < ALPHA_MIN){ alpha = 0; running = false; if(!userMoved && !vTarget) fit(); }
205
207
  }
206
208
  const TC = (wx,wy) => ({ x: wx*view.k+W/2+view.x, y: wy*view.k+H/2+view.y });
207
209
  const T = n => TC(n.x, n.y);
@@ -213,13 +215,16 @@ const baseR = n => isKnow(n) ? (3 + (n.centrality||0)*3.4 + Math.min((deg[n.id]|
213
215
  const radius = n => baseR(n) * Math.min(2.4, Math.max(0.7, view.k));
214
216
  const neighbors = id => new Set(adj[id].map(([t])=>t));
215
217
 
216
- function fit(){ const vis = nodes.filter(visible); if(!vis.length) return;
218
+ function fitView(){ const vis = nodes.filter(visible); if(!vis.length) return null;
217
219
  const xs=vis.map(n=>n.x).sort((a,b)=>a-b), ys=vis.map(n=>n.y).sort((a,b)=>a-b);
218
220
  const lo=i=>i[Math.floor(i.length*0.02)], hi=i=>i[Math.floor(i.length*0.98)];
219
221
  const minX=lo(xs),maxX=hi(xs),minY=lo(ys),maxY=hi(ys);
220
222
  const w=maxX-minX||1, h=maxY-minY||1;
221
- view.k = Math.max(0.2, Math.min(2.5, 0.85*Math.min(W/w, H/h)));
222
- view.x = -(minX+maxX)/2*view.k; view.y = -(minY+maxY)/2*view.k; }
223
+ const k = Math.max(0.2, Math.min(2.5, 0.85*Math.min(W/w, H/h)));
224
+ return { k, x:-(minX+maxX)/2*k, y:-(minY+maxY)/2*k }; }
225
+ // fit() snaps (used by the settling physics loop); fitAnimated() glides (user-driven).
226
+ function fit(){ const t=fitView(); if(t){ view.k=t.k; view.x=t.x; view.y=t.y; vTarget=null; } }
227
+ function fitAnimated(){ const t=fitView(); if(t) vTarget={...t}; }
223
228
  function setFocus(id){ focusSet = new Set([id, ...neighbors(id)]); reheat(0.45); }
224
229
  function clearFocus(){ focusSet = null; reheat(0.3); }
225
230
  function toggleLens(){ const knowOn = focusSet && focusSet._lens;
@@ -249,11 +254,13 @@ function clearPath(){ pathSet=null; pathEdge=new Set(); pathA=null; pathMD=null;
249
254
  // ---- render : the Solar pipeline ----------------------------------------
250
255
  function draw(){
251
256
  _f++;
257
+ easeView();
252
258
  if(running && _f % PHYS_STRIDE === 0) step();
253
259
  // The layout grows as it settles, so keep re-framing to fit (driven by the draw
254
260
  // loop, not events) until the user takes control — this is the real fix for the
255
- // "graph is cut off until I switch tabs" glitch.
256
- if(running && !userMoved && _f % 16 === 0) fit();
261
+ // "graph is cut off until I switch tabs" glitch. Suppressed while a camera tween
262
+ // is in flight, so the auto-fit and the glide never fight.
263
+ if(running && !userMoved && !vTarget && _f % 16 === 0) fit();
257
264
  ctx.clearRect(0,0,W,H);
258
265
  const anchor = selected || hover, near = anchor ? neighbors(anchor) : null;
259
266
  const dimCode = lens==="know";
@@ -370,19 +377,46 @@ function bindMM(mm){ if(!mm) return;
370
377
  const panTo = ev => { if(!_mmBounds) return; const r=mm.getBoundingClientRect();
371
378
  const wx=((ev.clientX-r.left)-_mmBounds.ox)/_mmBounds.s, wy=((ev.clientY-r.top)-_mmBounds.oy)/_mmBounds.s;
372
379
  view.x=-wx*view.k; view.y=-wy*view.k; };
373
- mm.onmousedown = e => { panTo(e); const mv=ev=>panTo(ev);
380
+ mm.onmousedown = e => { vTarget=null; panTo(e); const mv=ev=>panTo(ev);
374
381
  const up=()=>{ window.removeEventListener("mousemove",mv); window.removeEventListener("mouseup",up); };
375
382
  window.addEventListener("mousemove",mv); window.addEventListener("mouseup",up); }; }
376
383
  bindMM($("mm")); bindMM($("fmm"));
377
384
 
378
385
  // ---- zoom well + mode pill ----------------------------------------------
379
386
  const K_MIN=0.15, K_MAX=5, LK=Math.log(K_MIN), LKR=Math.log(K_MAX)-LK;
387
+ const clampK = k => Math.max(K_MIN, Math.min(K_MAX, k));
388
+ // Smooth camera. Every interactive zoom/recenter sets `vTarget`; easeView() (run
389
+ // once per frame in draw) glides the live `view` toward it, so nothing snaps.
390
+ // Direct manipulation — panning, node-drag, the zoom slider — cancels the tween so
391
+ // it stays 1:1 with the cursor; the physics auto-fit is suppressed while one runs.
392
+ function easeView(){ if(!vTarget) return;
393
+ view.k += (vTarget.k-view.k)*VIEW_EASE;
394
+ view.x += (vTarget.x-view.x)*VIEW_EASE;
395
+ view.y += (vTarget.y-view.y)*VIEW_EASE;
396
+ if(Math.abs(vTarget.k-view.k)<1e-3 && Math.hypot(vTarget.x-view.x, vTarget.y-view.y)<0.4){
397
+ view.k=vTarget.k; view.x=vTarget.x; view.y=vTarget.y; vTarget=null; } }
398
+ // Zoom keeping the world point under (ex,ey) pinned — compounded in target space so
399
+ // rapid wheel ticks anchor consistently while the previous glide is still in flight.
400
+ function zoomBy(ex, ey, factor){ userMoved=true; const b=vTarget||view; const k=clampK(b.k*factor);
401
+ const wx=(ex-W/2-b.x)/b.k, wy=(ey-H/2-b.y)/b.k;
402
+ vTarget={ k, x: ex-W/2-wx*k, y: ey-H/2-wy*k }; }
403
+ function zoomToCenter(k){ userMoved=true; const b=vTarget||view; k=clampK(k);
404
+ const wx=-b.x/b.k, wy=-b.y/b.k; vTarget={ k, x:-wx*k, y:-wy*k }; }
405
+ function flyTo(x, y, k){ userMoved=true; vTarget={ k:clampK(k), x, y }; }
380
406
  function updatePill(){ const macro = view.k < 0.55; // zoomed-out overview = macro
381
407
  $("bmacro").classList.toggle("on",macro); $("bmicro").classList.toggle("on",!macro);
382
- $("zthumb").style.left=((Math.log(Math.max(K_MIN,Math.min(K_MAX,view.k)))-LK)/LKR*100)+"%";
408
+ $("zthumb").style.left=((Math.log(clampK(view.k))-LK)/LKR*100)+"%";
383
409
  $("zlabel").textContent = macro ? "macro" : (view.k>1.4 ? "detail" : "micro"); }
384
- $("ztrack").onclick = e => { userMoved=true; const r=$("ztrack").getBoundingClientRect();
385
- view.k = Math.exp(LK + Math.max(0,Math.min(1,(e.clientX-r.left)/r.width))*LKR); };
410
+ // Zoom slider: drag (not just click) the thumb. Writes the camera directly so the
411
+ // thumb tracks the cursor 1:1, recentred on the viewport so the graph scales in place.
412
+ const kFromTrack = clientX => { const r=$("ztrack").getBoundingClientRect();
413
+ return Math.exp(LK + Math.max(0,Math.min(1,(clientX-r.left)/r.width))*LKR); };
414
+ function zoomCenterNow(k){ userMoved=true; vTarget=null; k=clampK(k);
415
+ const wx=-view.x/view.k, wy=-view.y/view.k; view.k=k; view.x=-wx*k; view.y=-wy*k; }
416
+ $("ztrack").onmousedown = e => { e.preventDefault(); zoomCenterNow(kFromTrack(e.clientX));
417
+ const mv=ev=>zoomCenterNow(kFromTrack(ev.clientX));
418
+ const up=()=>{ window.removeEventListener("mousemove",mv); window.removeEventListener("mouseup",up); };
419
+ window.addEventListener("mousemove",mv); window.addEventListener("mouseup",up); };
386
420
 
387
421
  // ---- governance ledger --------------------------------------------------
388
422
  (function buildLedger(){
@@ -404,17 +438,17 @@ $("ztrack").onclick = e => { userMoved=true; const r=$("ztrack").getBoundingClie
404
438
  if(e.target.closest(".tgt")) return;
405
439
  body.querySelectorAll(".lrow").forEach(x=>x.classList.remove("open")); row.classList.add("open");
406
440
  const n=byId[row.dataset.rule]; selected=n.id; clearPath(); showDetail(n);
407
- if(view.k<1) view.k=1.1; view.x=-n.x*view.k; view.y=-n.y*view.k; }; });
441
+ const k=Math.max(view.k,1.1); flyTo(-n.x*k, -n.y*k, k); }; });
408
442
  body.querySelectorAll(".tgt").forEach(t => { t.onclick = () => { const n=byId[t.dataset.go];
409
- if(!n) return; selected=n.id; clearPath(); showDetail(n); if(view.k<1) view.k=1.1;
410
- view.x=-n.x*view.k; view.y=-n.y*view.k; }; });
443
+ if(!n) return; selected=n.id; clearPath(); showDetail(n);
444
+ const k=Math.max(view.k,1.1); flyTo(-n.x*k, -n.y*k, k); }; });
411
445
  })();
412
446
 
413
447
  // ---- interaction --------------------------------------------------------
414
448
  function hit(mx,my){ let best=null,bd=1e9; for(const n of nodes){ if(!visible(n)) continue; const p=T(n);
415
449
  const d=Math.hypot(mx-p.x,my-p.y); if(d < radius(n)+4 && d<bd){ bd=d; best=n; } } return best; }
416
450
  let drag=null, pan=false, last=null, downAt=null;
417
- cv.onmousedown = e => { last={x:e.offsetX,y:e.offsetY}; downAt={x:e.offsetX,y:e.offsetY};
451
+ cv.onmousedown = e => { vTarget=null; last={x:e.offsetX,y:e.offsetY}; downAt={x:e.offsetX,y:e.offsetY};
418
452
  const n=hit(e.offsetX,e.offsetY);
419
453
  if(pathMode){ if(n){ if(!pathA){ pathA=n.id; selected=n.id; showDetail(n); toast("now click the target node"); }
420
454
  else { setPath(shortestPath(pathA,n.id)); pathA=null; togglePath(); } } return; }
@@ -433,13 +467,19 @@ cv.onmousemove = e => {
433
467
  last={x:e.offsetX,y:e.offsetY}; };
434
468
  window.addEventListener("mouseup", () => { drag=null; pan=false; });
435
469
  cv.ondblclick = e => { const n=hit(e.offsetX,e.offsetY); if(n){ selected=n.id; setFocus(n.id); showDetail(n); } };
436
- cv.onwheel = e => { e.preventDefault(); userMoved=true; const f=e.deltaY<0?1.1:0.9;
437
- const mx=e.offsetX-W/2-view.x, my=e.offsetY-H/2-view.y;
438
- view.k=Math.max(K_MIN,Math.min(K_MAX,view.k*f)); view.x-=mx*(f-1); view.y-=my*(f-1); };
470
+ // Wheel/trackpad zoom: scale by the *magnitude* of the delta (normalised across
471
+ // pixel/line/page deltaModes) so one mouse notch and a trackpad swipe both feel
472
+ // natural, then glide there. macOS pinch arrives as ctrl+wheel with coarser deltas.
473
+ cv.onwheel = e => { e.preventDefault();
474
+ let d = e.deltaY; if(e.deltaMode===1) d*=16; else if(e.deltaMode===2) d*=(H||500);
475
+ const factor = Math.max(0.4, Math.min(2.5, Math.exp(-d * (e.ctrlKey ? 0.010 : 0.0018))));
476
+ zoomBy(e.offsetX, e.offsetY, factor); };
439
477
  window.addEventListener("keydown", e => { if(e.target.tagName==="INPUT"){ if(e.key==="Escape")e.target.blur(); return; }
440
478
  const k=e.key.toLowerCase();
441
479
  if(k==="/"){ e.preventDefault(); $("q").focus(); }
442
- else if(k==="f"){ userMoved=false; fit(); } else if(k==="r"){ userMoved=true; view={x:0,y:0,k:1}; }
480
+ else if(k==="f"){ userMoved=false; fitAnimated(); } else if(k==="r"){ flyTo(0,0,1); }
481
+ else if(k==="+"||k==="="){ zoomToCenter((vTarget?vTarget.k:view.k)*1.3); }
482
+ else if(k==="-"||k==="_"){ zoomToCenter((vTarget?vTarget.k:view.k)/1.3); }
443
483
  else if(k===" "){ e.preventDefault(); running ? (running=false) : reheat(0.3); }
444
484
  else if(k==="e"){ if(selected) setFocus(selected); }
445
485
  else if(k==="l"){ showLabels=!showLabels; }
@@ -262,7 +262,7 @@
262
262
  <div class="lab">Edge language</div>
263
263
  <div id="efilters"></div>
264
264
  </div>
265
- <div class="keyhint"><span><b>/</b>search</span><span><b>e</b>focus</span><span><b>c</b>macro</span><span><b>p</b>path</span><span><b>l</b>labels</span><span><b>Esc</b>clear</span></div>
265
+ <div class="keyhint"><span><b>/</b>search</span><span><b>±</b>zoom</span><span><b>f</b>fit</span><span><b>e</b>focus</span><span><b>c</b>macro</span><span><b>p</b>path</span><span><b>l</b>labels</span><span><b>Esc</b>clear</span></div>
266
266
  </div>
267
267
  <div class="zoomwell">
268
268
  <span class="lab" style="font-size:10px;letter-spacing:.12em;text-transform:uppercase">Zoom</span>
@@ -9,6 +9,10 @@ not just a per-call estimate. Opt-in via `cost_tracking`; recorded on each
9
9
  `tokens_without` = reading the governed source file(s) for the matched rules;
10
10
  `tokens_with` = the matched Tier-2 rule(s) only (the realistic later-lookup cost,
11
11
  since the Tier-1 INDEX is injected once per session). `saved = without − with`.
12
+
13
+ The summary reports the lifetime total *and* a per-day / per-week / per-month rate
14
+ — `tokens_saved` amortised across the observed span (`first`→`last`, floored at one
15
+ day), then scaled — so the win reads as an ongoing throughput, not just a total.
12
16
  """
13
17
  from __future__ import annotations
14
18
 
@@ -104,16 +108,45 @@ def overall_ratio(led: dict) -> float:
104
108
  return (led.get("tokens_without", 0) / w) if w else 0.0
105
109
 
106
110
 
111
+ _AVG_MONTH = 365.25 / 12 # mean calendar month, in days
112
+
113
+
114
+ def _date(s: str | None) -> _dt.date | None:
115
+ try:
116
+ return _dt.date.fromisoformat(s) if s else None
117
+ except ValueError:
118
+ return None
119
+
120
+
121
+ def span_days(led: dict) -> int:
122
+ """Calendar days spanned by the recorded lookups (floored at 1, so a same-day
123
+ ledger still yields a finite per-day rate rather than dividing by zero)."""
124
+ a, b = _date(led.get("first")), _date(led.get("last"))
125
+ return max(1, (b - a).days) if a and b else 1
126
+
127
+
128
+ def rates(led: dict) -> dict:
129
+ """Average tokens saved per day / week / month over the observed span — the
130
+ lifetime `tokens_saved` amortised across `span_days`, then scaled up."""
131
+ days = span_days(led)
132
+ per_day = led.get("tokens_saved", 0) / days
133
+ return {"days": days, "day": per_day, "week": per_day * 7, "month": per_day * _AVG_MONTH}
134
+
135
+
107
136
  def render_summary(led: dict) -> str:
108
137
  if not led.get("lookups"):
109
138
  return ""
110
139
  span = led.get("first") or "?"
111
140
  ratio = overall_ratio(led)
112
141
  x = f"{ratio:.1f}×" if ratio else "—"
142
+ r = rates(led)
113
143
  return "\n".join([
114
144
  "",
115
145
  f"Cumulative (tracked across {led['lookups']} lookup(s) since {span})",
116
146
  f" tokens without Fux: {led['tokens_without']:>10,} tok",
117
147
  f" tokens with Fux: {led['tokens_with']:>10,} tok",
118
148
  f" tokens saved: {led['tokens_saved']:>10,} tok → {x} overall",
149
+ f" {'≈ saved per day:':<21}{round(r['day']):>10,} tok (avg over {r['days']} day(s))",
150
+ f" {'≈ saved per week:':<21}{round(r['week']):>10,} tok",
151
+ f" {'≈ saved per month:':<21}{round(r['month']):>10,} tok",
119
152
  ])
@@ -1,8 +1,8 @@
1
1
  """`fux hooks install` — wire Fux across every agent surface from one command.
2
2
 
3
- Four surfaces, all pointing at the *installed package* scripts (~/.claude/fux/hooks),
4
- never a sibling dev checkout:
5
- git → .git/hooks/pre-commit shim packaged pre_commit.sh (build + stage views)
3
+ Four surfaces, all invoking the *installed* `fux` console script — the package is
4
+ the single source of truth, no copied wrapper scripts or dev-checkout paths:
5
+ git → .git/hooks/pre-commit (self-contained: `fux build` + stage views)
6
6
  claude → .claude/settings.json (SessionStart/PostToolUse/Stop hooks)
7
7
  codex → .codex/hooks.json
8
8
  copilot → .copilot/settings.json
@@ -13,22 +13,28 @@ from __future__ import annotations
13
13
  import stat
14
14
  from pathlib import Path
15
15
 
16
- from fux import gitutil, paths, settings
16
+ from fux import gitutil, settings
17
17
 
18
18
  SURFACES = ["git", "claude", "codex", "copilot"]
19
19
  _MARK = "fux-hook"
20
20
 
21
-
22
- def _packaged_precommit() -> Path:
23
- return paths.claude_home() / "fux" / "hooks" / "pre_commit.sh"
24
-
25
-
26
- def _shim(target: Path) -> str:
27
- return (f"#!/bin/sh\n# {_MARK} installed by `fux hooks install`. Delegates to the\n"
28
- f"# packaged Fux pre-commit (build derived views + stage them).\n"
29
- f'HOOK="{target}"\n'
30
- '[ -x "$HOOK" ] && exec "$HOOK" "$@"\n'
31
- 'exit 0\n')
21
+ # Self-contained pre-commit: resolve `fux` (or `python -m fux`), then rebuild the
22
+ # derived views and stage them so .fux/out/ matches the committed code in the same
23
+ # commit. Non-blocking; skips during rebase/merge. No external script reference.
24
+ _SHIM = f"""#!/bin/sh
25
+ # {_MARK} — installed by `fux hooks install`. Rebuild + stage .fux/out/ ($0).
26
+ if command -v fux >/dev/null 2>&1; then FUX="fux"; else
27
+ PY="${{FUX_PYTHON:-$(command -v python3.14 || command -v python3 || command -v python)}}"
28
+ [ -n "$PY" ] && "$PY" -c "import fux" 2>/dev/null && FUX="$PY -m fux" || exit 0
29
+ fi
30
+ G="$(git rev-parse --git-dir 2>/dev/null || echo .git)"
31
+ [ -d "$G/rebase-merge" ] || [ -d "$G/rebase-apply" ] && exit 0
32
+ [ -f "$G/MERGE_HEAD" ] || [ -f "$G/CHERRY_PICK_HEAD" ] && exit 0
33
+ $FUX context >/dev/null 2>&1 || exit 0
34
+ echo "[fux hook] rebuilding derived views..."
35
+ $FUX build >/dev/null 2>&1 && git add .fux/out 2>/dev/null
36
+ exit 0
37
+ """
32
38
 
33
39
 
34
40
  def _install_git(root: Path) -> str:
@@ -43,7 +49,7 @@ def _install_git(root: Path) -> str:
43
49
  note = f" (existing hook backed up → {backup.name})"
44
50
  else:
45
51
  note = ""
46
- hook.write_text(_shim(_packaged_precommit()), encoding="utf-8")
52
+ hook.write_text(_SHIM, encoding="utf-8")
47
53
  hook.chmod(hook.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
48
54
  return f"{hook}{note}"
49
55
 
@@ -80,6 +80,12 @@ class Footprint:
80
80
  def out(self) -> Path:
81
81
  return self.base / "out"
82
82
 
83
+ @property
84
+ def sessions(self) -> Path:
85
+ """Per-session runtime state (gitignored), kept out of ``out/``'s top level
86
+ so the derived html/reports there stay easy to browse."""
87
+ return self.base / "out" / "sessions"
88
+
83
89
  @property
84
90
  def config(self) -> Path:
85
91
  return self.base / "config.toml"
@@ -2,24 +2,25 @@
2
2
 
3
3
  Claude (`.claude/settings.json`), Codex (`.codex/hooks.json`), and Copilot
4
4
  (`.copilot/settings.json`) share one event→hook shape, so one writer serves all
5
- three. Prefers the installed wrapper scripts (~/.claude/fux/hooks/*.sh, which carry
6
- a `python -m fux` fallback) so hooks fire even when the `fux` console script is not
7
- on PATH; falls back to the bare `fux <subcommand>` form otherwise.
5
+ three. Hooks invoke the installed `fux` **console script** directly (`fux context`,
6
+ `fux hook-touch`, ) the package is the single source of truth, no copied wrapper
7
+ scripts and no dev-checkout paths baked into a committed settings file. Set
8
+ `FUX_PYTHON`/PATH so `fux` resolves; for a missing-PATH safety net the bundled
9
+ `~/.claude/fux/hooks/*.sh` wrappers (with a `python -m fux` fallback) still ship and
10
+ can be referenced by hand.
8
11
  """
9
12
  from __future__ import annotations
10
13
 
11
14
  import json
12
15
  from pathlib import Path
13
16
 
14
- from fux import paths
15
-
16
- # event → (wrapper script name, bare-command fallback, optional matcher)
17
+ # event → (fux subcommand, optional matcher)
17
18
  _SPEC = {
18
- "SessionStart": ("session_start.sh", "fux context", None),
19
- "PostToolUse": ("post_tool_use.sh", "fux hook-touch", "Edit|Write"),
20
- "Stop": ("stop.sh", "fux hook-check", None),
19
+ "SessionStart": ("fux context", None),
20
+ "PostToolUse": ("fux hook-touch", "Edit|Write"),
21
+ "Stop": ("fux hook-check", None),
21
22
  }
22
- _RECALL = {"UserPromptSubmit": ("user_prompt_submit.sh", "fux hook-recall", None)}
23
+ _RECALL = {"UserPromptSubmit": ("fux hook-recall", None)}
23
24
 
24
25
  # agent → settings file (relative to project root)
25
26
  AGENT_FILES = {
@@ -29,28 +30,26 @@ AGENT_FILES = {
29
30
  }
30
31
 
31
32
 
32
- def _command(script: str, fallback: str) -> str:
33
- wrapper = paths.claude_home() / "fux" / "hooks" / script
34
- return str(wrapper) if wrapper.exists() else fallback
35
-
36
-
37
- def _entry(script: str, fallback: str, matcher: str | None) -> dict:
38
- hook = {"hooks": [{"type": "command", "command": _command(script, fallback)}]}
33
+ def _entry(command: str, matcher: str | None) -> dict:
34
+ hook = {"hooks": [{"type": "command", "command": command}]}
39
35
  if matcher:
40
36
  hook["matcher"] = matcher
41
37
  return hook
42
38
 
43
39
 
44
40
  def wire_file(path: Path, recall: bool = False) -> Path:
45
- """Wire the Fux hook spec into one agent settings file. Idempotent per event."""
41
+ """Wire the Fux hook spec into one agent settings file. Idempotent, and
42
+ *migrating*: a re-install rewrites any stale Fux entry (e.g. an old wrapper-script
43
+ path) to the current `fux <subcommand>` form, leaving foreign hooks untouched."""
46
44
  path.parent.mkdir(parents=True, exist_ok=True)
47
45
  data = json.loads(path.read_text()) if path.exists() else {}
48
46
  hooks = data.setdefault("hooks", {})
49
47
  spec = {**_SPEC, **(_RECALL if recall else {})}
50
- for event, (script, fallback, matcher) in spec.items():
51
- existing = hooks.setdefault(event, [])
52
- if not _already(existing):
53
- existing.append(_entry(script, fallback, matcher))
48
+ for event, (command, matcher) in spec.items():
49
+ kept = [e for e in hooks.get(event, [])
50
+ if not any("fux" in h.get("command", "") for h in e.get("hooks", []))]
51
+ kept.append(_entry(command, matcher))
52
+ hooks[event] = kept
54
53
  path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
55
54
  return path
56
55
 
@@ -87,9 +86,3 @@ def is_wired(path: Path) -> bool:
87
86
  hooks = json.loads(path.read_text()).get("hooks", {})
88
87
  return any("fux" in h.get("command", "")
89
88
  for evs in hooks.values() for e in evs for h in e.get("hooks", []))
90
-
91
-
92
- def _already(existing: list) -> bool:
93
- """True if any Fux hook (either wiring form) is already present for this event."""
94
- cmds = [h.get("command", "") for e in existing for h in e.get("hooks", [])]
95
- return any("fux" in c for c in cmds)
@@ -2,7 +2,8 @@
2
2
 
3
3
  Session-aware: rules whose own source was edited this session are skipped, so the
4
4
  PostToolUse hook only nags about rules that drifted, not ones you just updated.
5
- Session state lives in ``.fux/out/.session-<id>.json`` (gitignored).
5
+ Session state lives in ``.fux/out/sessions/<id>.json`` (gitignored) — tucked in its
6
+ own subdir so it never clutters the derived html/reports at the top of ``out/``.
6
7
  """
7
8
  from __future__ import annotations
8
9
 
@@ -15,8 +16,8 @@ from fux.model import Rule
15
16
 
16
17
  def _state_path(root: Path, session: str) -> Path:
17
18
  fp = paths.Footprint(root)
18
- fp.out.mkdir(parents=True, exist_ok=True)
19
- return fp.out / f".session-{session or 'default'}.json"
19
+ fp.sessions.mkdir(parents=True, exist_ok=True)
20
+ return fp.sessions / f"{session or 'default'}.json"
20
21
 
21
22
 
22
23
  def mark_rule_edited(root: Path, session: str, rule_id: str) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fux-engine
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -115,6 +115,13 @@ fux serve # local dashboard over the generated views
115
115
  fux import docs/ # migrate existing markdown → narrative entries
116
116
  fux parity # is it safe to retire the old graph/docs/memory?
117
117
  fux tour # ordered ONBOARDING.md
118
+
119
+ # Runtime consumers (agents + apps, e.g. Anton's Orff concierge)
120
+ fux components [--scope dir] # component/hook registry for on-the-fly UI generation
121
+ fux validate-spec # validate a declarative UISpec against the registry
122
+ fux feedback # record rejected specs as candidate vocabulary gaps
123
+ fux hook-recall # stdin-JSON recall for agent prompt hooks
124
+ fux query / path / explain # graph traversal: cross-module "how does X relate to Y"
118
125
  ```
119
126
 
120
127
  **Complete, example-driven guide to everything Fux does:
@@ -180,6 +187,12 @@ effective ruleset = ~/.claude/fux/global/ (cross-project best practices)
180
187
  `project` overrides `pack` overrides `global`. `fux check` flags conflicts
181
188
  instead of silently shadowing.
182
189
 
190
+ > Packs are optional. A single-project setup can keep `packs = []` and hold all
191
+ > authored knowledge in the repo's own `.fux/` — version-controlled with the code
192
+ > it governs (see Anton's `knowledge-location` rule for the reasoning). Global
193
+ > rules are seeded from this repo's `fux/data/global/`, so they stay versioned
194
+ > tool code, not loose documents.
195
+
183
196
  ## Guarantee
184
197
 
185
198
  Every maintenance command is shell/AST/parse — **no LLM calls**. The only paths
@@ -83,7 +83,6 @@ fux/data/global/rules/files-max-100-lines.md
83
83
  fux/data/global/rules/no-secrets-in-vcs.md
84
84
  fux/data/hooks/_common.sh
85
85
  fux/data/hooks/post_tool_use.sh
86
- fux/data/hooks/pre_commit.sh
87
86
  fux/data/hooks/session_start.sh
88
87
  fux/data/hooks/stop.sh
89
88
  fux/data/hooks/user_prompt_submit.sh
@@ -49,3 +49,33 @@ def test_reset_and_summary(project):
49
49
  costledger.reset(project)
50
50
  assert costledger.load(project)["lookups"] == 0
51
51
  assert costledger.render_summary(costledger.load(project)) == ""
52
+
53
+
54
+ def test_rates_amortise_over_span(project):
55
+ _seed(project)
56
+ from fux import loader, config, paths
57
+ rules = loader.resolve(project, config.load(paths.Footprint(project).config)).active()
58
+ # span 2026-06-05 → 2026-06-13 is 8 days
59
+ costledger.record(project, "a", rules, today=_dt.date(2026, 6, 5))
60
+ costledger.record(project, "b", rules, today=_dt.date(2026, 6, 13))
61
+ led = costledger.load(project)
62
+ assert costledger.span_days(led) == 8
63
+ r = costledger.rates(led)
64
+ assert r["day"] == led["tokens_saved"] / 8
65
+ assert r["week"] == r["day"] * 7
66
+ assert r["month"] > r["week"] > r["day"] > 0 # ascending, all positive
67
+ out = costledger.render_summary(led)
68
+ for label in ("saved per day", "saved per week", "saved per month", "avg over 8 day(s)"):
69
+ assert label in out
70
+
71
+
72
+ def test_span_floored_to_one_day(project):
73
+ """A same-day ledger (first == last) yields span 1, not a divide-by-zero."""
74
+ _seed(project)
75
+ from fux import loader, config, paths
76
+ rules = loader.resolve(project, config.load(paths.Footprint(project).config)).active()
77
+ costledger.record(project, "a", rules, today=_dt.date(2026, 6, 5))
78
+ led = costledger.load(project)
79
+ assert costledger.span_days(led) == 1
80
+ r = costledger.rates(led)
81
+ assert r["day"] == led["tokens_saved"] and r["week"] == led["tokens_saved"] * 7
@@ -12,14 +12,16 @@ def _git_init(root):
12
12
  subprocess.run(["git", "init", "-q"], cwd=root, check=True)
13
13
 
14
14
 
15
- def test_install_wires_all_three_agents(project):
15
+ def test_install_wires_all_three_agents_via_console_script(project):
16
16
  out = hookinstall.install(project, ["claude", "codex", "copilot"])
17
17
  for agent, rel in settings.AGENT_FILES.items():
18
18
  path = project / rel
19
19
  assert path.exists(), f"{agent} settings not written"
20
20
  hooks = json.loads(path.read_text())["hooks"]
21
21
  cmds = [h["command"] for evs in hooks.values() for e in evs for h in e["hooks"]]
22
- assert any("fux" in c for c in cmds), f"{agent} has no fux hook"
22
+ # invokes the installed console script not a copied wrapper / dev-checkout path.
23
+ assert "fux context" in cmds, f"{agent} not wired to the fux CLI"
24
+ assert not any("/.claude/fux/hooks/" in c for c in cmds), f"{agent} points at a copied script"
23
25
  assert set(out) == {"claude", "codex", "copilot"}
24
26
 
25
27
 
@@ -31,14 +33,28 @@ def test_install_is_idempotent(project):
31
33
  assert len(hooks["SessionStart"]) == 1
32
34
 
33
35
 
36
+ def test_rewire_migrates_stale_wrapper_path_to_console_script(project):
37
+ # Simulate a settings file written by an older Fux that pointed at a copied script.
38
+ path = project / ".claude/settings.json"
39
+ stale = {"hooks": {"SessionStart": [{"hooks": [{"type": "command",
40
+ "command": "/Users/x/.claude/fux/hooks/session_start.sh"}]}]}}
41
+ path.write_text(json.dumps(stale))
42
+ settings.wire_file(path)
43
+ hooks = json.loads(path.read_text())["hooks"]
44
+ cmds = [h["command"] for e in hooks["SessionStart"] for h in e["hooks"]]
45
+ assert cmds == ["fux context"] # migrated, not duplicated
46
+ assert not any(".sh" in c for c in cmds)
47
+
48
+
34
49
  def test_git_install_writes_executable_precommit_shim(project):
35
50
  _git_init(project)
36
51
  out = hookinstall.install(project, ["git"])
37
52
  hook = project / ".git" / "hooks" / "pre-commit"
38
53
  assert hook.exists() and hook.stat().st_mode & 0o111, "pre-commit not executable"
39
54
  body = hook.read_text()
40
- assert "fux-hook" in body # our marker
41
- assert "pre_commit.sh" in body # delegates to the packaged script
55
+ assert "fux-hook" in body # our marker
56
+ assert "$FUX build" in body and ".fux/out" in body # self-contained, calls the CLI
57
+ assert "/.claude/fux/hooks/" not in body # no copied-script / dev-checkout ref
42
58
  assert str(hook) in out["git"]
43
59
 
44
60
 
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Fux pre-commit — rebuild the derived views ($0, AST-only) and stage them so
3
- # .fux/out/ always matches the committed code IN THE SAME COMMIT. Non-blocking:
4
- # a build failure warns but never aborts the commit (use `fux gate` to *block* on
5
- # drift). git invokes this with cwd at the repo root. Installed by `fux hooks install`.
6
- set -uo pipefail
7
- DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
- # shellcheck source=_common.sh
9
- . "$DIR/_common.sh"
10
-
11
- # Skip during rebase/merge/cherry-pick — don't fight --continue with new changes.
12
- GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || echo .git)"
13
- [ -d "$GIT_DIR/rebase-merge" ] && exit 0
14
- [ -d "$GIT_DIR/rebase-apply" ] && exit 0
15
- [ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
16
- [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
17
-
18
- # Only act inside a project that has a .fux/ footprint.
19
- fux_run context >/dev/null 2>&1 || exit 0
20
-
21
- echo "[fux hook] rebuilding derived views..."
22
- if fux_run build >/dev/null 2>&1; then
23
- git add .fux/out 2>/dev/null || true # .session-*.json is gitignored, so skipped
24
- else
25
- echo "[fux hook] build failed — committing without refreshed views (run \`fux build\`)."
26
- fi
27
- exit 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes