fux-engine 0.2.0__tar.gz → 0.3.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 (148) hide show
  1. {fux_engine-0.2.0/fux_engine.egg-info → fux_engine-0.3.0}/PKG-INFO +1 -1
  2. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/__init__.py +1 -1
  3. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/assets/graph_boot.js +9 -1
  4. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/assets/graph_template.html +2 -0
  5. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/build.py +3 -1
  6. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/cli.py +9 -0
  7. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/clicmds.py +19 -2
  8. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/config.py +6 -1
  9. fux_engine-0.3.0/fux/data/hooks/pre_commit.sh +27 -0
  10. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/graph.py +3 -1
  11. fux_engine-0.3.0/fux/graphhtml.py +20 -0
  12. fux_engine-0.3.0/fux/hookinstall.py +88 -0
  13. fux_engine-0.3.0/fux/settings.py +95 -0
  14. {fux_engine-0.2.0 → fux_engine-0.3.0/fux_engine.egg-info}/PKG-INFO +1 -1
  15. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux_engine.egg-info/SOURCES.txt +5 -0
  16. fux_engine-0.3.0/tests/test_graph_determinism.py +30 -0
  17. fux_engine-0.3.0/tests/test_graphhtml_links.py +31 -0
  18. fux_engine-0.3.0/tests/test_hookinstall.py +73 -0
  19. fux_engine-0.2.0/fux/graphhtml.py +0 -14
  20. fux_engine-0.2.0/fux/settings.py +0 -52
  21. {fux_engine-0.2.0 → fux_engine-0.3.0}/LICENSE +0 -0
  22. {fux_engine-0.2.0 → fux_engine-0.3.0}/README.md +0 -0
  23. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/__main__.py +0 -0
  24. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/assets/fux-icon.svg +0 -0
  25. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/assets/fux-lockup.svg +0 -0
  26. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/assets/fux-mark.svg +0 -0
  27. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/astextract.py +0 -0
  28. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/bench.py +0 -0
  29. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/capture.py +0 -0
  30. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/check.py +0 -0
  31. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/cligraph.py +0 -0
  32. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/cliquery.py +0 -0
  33. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/cliutil.py +0 -0
  34. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/community.py +0 -0
  35. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/components.py +0 -0
  36. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/context.py +0 -0
  37. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/costledger.py +0 -0
  38. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/coverage.py +0 -0
  39. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/copilot/prompts/fux-plan.prompt.md +0 -0
  40. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/copilot/prompts/fux.prompt.md +0 -0
  41. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/global/README.md +0 -0
  42. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/global/rules/async-everywhere.md +0 -0
  43. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/global/rules/doc-per-code-change.md +0 -0
  44. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/global/rules/files-max-100-lines.md +0 -0
  45. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/global/rules/no-secrets-in-vcs.md +0 -0
  46. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/hooks/_common.sh +0 -0
  47. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/hooks/post_tool_use.sh +0 -0
  48. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/hooks/session_start.sh +0 -0
  49. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/hooks/stop.sh +0 -0
  50. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/hooks/user_prompt_submit.sh +0 -0
  51. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/packs/indian-markets-tax/pack.toml +0 -0
  52. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/packs/indian-markets-tax/rules/capital-gains-equity.md +0 -0
  53. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/packs/indian-markets-tax/rules/market-hours-nse.md +0 -0
  54. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/schema.json +0 -0
  55. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/adr/SKILL.md +0 -0
  56. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/distill/SKILL.md +0 -0
  57. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/fetch-rules/SKILL.md +0 -0
  58. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/fux/SKILL.md +0 -0
  59. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/plan/SKILL.md +0 -0
  60. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/savings/SKILL.md +0 -0
  61. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/data/skills/trace/SKILL.md +0 -0
  62. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/drift.py +0 -0
  63. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/embed.py +0 -0
  64. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/explain.py +0 -0
  65. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/feedback.py +0 -0
  66. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/fetchrules.py +0 -0
  67. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/findings.py +0 -0
  68. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/fix.py +0 -0
  69. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/fmwrite.py +0 -0
  70. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/frontmatter.py +0 -0
  71. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/gate.py +0 -0
  72. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/gitutil.py +0 -0
  73. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/globs.py +0 -0
  74. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/governance.py +0 -0
  75. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/graphquery.py +0 -0
  76. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/hookio.py +0 -0
  77. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/hooks.py +0 -0
  78. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/hybrid.py +0 -0
  79. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/impact.py +0 -0
  80. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/importer.py +0 -0
  81. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/index.py +0 -0
  82. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/initcmd.py +0 -0
  83. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/lint.py +0 -0
  84. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/loader.py +0 -0
  85. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/mcpserver.py +0 -0
  86. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/mine.py +0 -0
  87. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/model.py +0 -0
  88. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/narrative.py +0 -0
  89. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/pack.py +0 -0
  90. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/parity.py +0 -0
  91. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/paths.py +0 -0
  92. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/recall.py +0 -0
  93. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/report.py +0 -0
  94. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/savings.py +0 -0
  95. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/scaffold.py +0 -0
  96. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/scalars.py +0 -0
  97. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/schema.py +0 -0
  98. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/seal.py +0 -0
  99. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/serve.py +0 -0
  100. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/stats.py +0 -0
  101. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/templates/formula.md +0 -0
  102. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/templates/spec.md +0 -0
  103. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/touch.py +0 -0
  104. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/tour.py +0 -0
  105. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/uispec.py +0 -0
  106. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/usage.py +0 -0
  107. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/verify.py +0 -0
  108. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux/vexamples.py +0 -0
  109. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux_engine.egg-info/dependency_links.txt +0 -0
  110. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux_engine.egg-info/entry_points.txt +0 -0
  111. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux_engine.egg-info/requires.txt +0 -0
  112. {fux_engine-0.2.0 → fux_engine-0.3.0}/fux_engine.egg-info/top_level.txt +0 -0
  113. {fux_engine-0.2.0 → fux_engine-0.3.0}/pyproject.toml +0 -0
  114. {fux_engine-0.2.0 → fux_engine-0.3.0}/setup.cfg +0 -0
  115. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_ast_backend.py +0 -0
  116. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_astextract.py +0 -0
  117. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_bm25f_expand.py +0 -0
  118. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_capture_governance.py +0 -0
  119. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_centrality.py +0 -0
  120. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_check_fix.py +0 -0
  121. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_components.py +0 -0
  122. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_costledger.py +0 -0
  123. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_crossfile_calls.py +0 -0
  124. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_edge_confidence.py +0 -0
  125. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_embed_rerank.py +0 -0
  126. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_examples.py +0 -0
  127. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_feedback.py +0 -0
  128. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_fetch_rules.py +0 -0
  129. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_frontmatter.py +0 -0
  130. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_fuzz_mine.py +0 -0
  131. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_globs.py +0 -0
  132. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_graphhtml.py +0 -0
  133. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_hybrid.py +0 -0
  134. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_impact.py +0 -0
  135. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_lint_stats_gate.py +0 -0
  136. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_mcp.py +0 -0
  137. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_mcp_extra.py +0 -0
  138. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_pack.py +0 -0
  139. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_parity_import.py +0 -0
  140. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_recall_build_verify.py +0 -0
  141. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_recall_eval.py +0 -0
  142. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_resolution.py +0 -0
  143. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_savings.py +0 -0
  144. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_schema_scaffold_init.py +0 -0
  145. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_seal.py +0 -0
  146. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_serve_sanitize.py +0 -0
  147. {fux_engine-0.2.0 → fux_engine-0.3.0}/tests/test_uispec.py +0 -0
  148. {fux_engine-0.2.0 → fux_engine-0.3.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.2.0
3
+ Version: 0.3.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
@@ -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.2.0"
6
+ __version__ = "0.3.0"
@@ -459,7 +459,7 @@ function showDetail(n){ $("agentrow").style.display="flex";
459
459
  `<span class="lab" style="${hue!=null?"":"color:var(--muted)"}">${n.type}${isKnow(n)?" · knowledge layer":""}</span></div>`+
460
460
  `<div class="ins-title">${esc(n.label)}</div>`+
461
461
  `<div class="ins-sub">community ${n.community} · degree ${deg[n.id]||0} · centrality ${(n.centrality||0).toFixed(3)}`+
462
- (n.file?` · ${esc(n.file)}${n.line?":"+n.line:""}`:"")+`</div>`;
462
+ (n.file?` · ${fileLink(n)}`:"")+`</div>`;
463
463
  const pills=[]; if(n.layer)pills.push(["layer: "+n.layer,1]); if(n.domain)pills.push(["domain: "+n.domain,0]);
464
464
  if(n.status)pills.push(["status: "+n.status,0]); if(isGod(n))pills.push(["⭐ hub",1]);
465
465
  if(pills.length) s += `<div class="pills">`+pills.map(([p,a])=>`<span class="pill${a?" amb":""}">${esc(p)}</span>`).join("")+`</div>`;
@@ -472,6 +472,14 @@ function showDetail(n){ $("agentrow").style.display="flex";
472
472
  $("detail").innerHTML = s; wireGo(); }
473
473
  function clearDetail(){ $("agentrow").style.display="none";
474
474
  $("detail").innerHTML = `<div class="ins-sub" style="margin:0">Click a node. Double-click to focus its neighbourhood.</div>`; }
475
+ // file:line → an <editor>://file/<abs>:<line>:<col> deep link (opens VSCode/Cursor on
476
+ // the exact line). Falls back to plain text when the build embedded no project ROOT.
477
+ function fileLink(n){
478
+ const label = esc(n.file) + (n.line ? ":"+n.line : "");
479
+ if(!ROOT) return label;
480
+ const href = encodeURI(EDITOR + "://file" + ROOT + "/" + n.file + ":" + (n.line||1) + ":1");
481
+ return `<a href="${href}" title="open in ${esc(EDITOR)} at line ${n.line||1}"`+
482
+ ` style="color:#ffb877;text-decoration:none">${label}<span style="opacity:.6"> ↗</span></a>`; }
475
483
 
476
484
  // ---- markdown export ----------------------------------------------------
477
485
  function nodeMarkdown(n){ let s=`### ${n.label} (${n.type})\n`;
@@ -301,6 +301,8 @@
301
301
  </div>
302
302
  <script>
303
303
  const DATA = __GRAPH_DATA__;
304
+ const ROOT = __ROOT__; // absolute project dir (for editor deep links)
305
+ const EDITOR = __EDITOR__; // vscode | vscode-insiders | cursor | windsurf
304
306
  // node-type accent (used by community/heat lenses + meters); knowledge ignites amber.
305
307
  const COLORS = { "code-file":"#3e4750","function":"#4a5560","class":"#5a4f63",
306
308
  "rule":"#ffa44f","formula":"#ffd27f","glossary":"#ffce8a","invariant":"#ff7a5c",
@@ -17,7 +17,9 @@ def run(root: Path, full: bool = False) -> dict:
17
17
 
18
18
  g = graph.build(root, rs, cfg, full=full)
19
19
  fp.out_file("graph.json").write_text(graph.to_json(g), encoding="utf-8")
20
- fp.out_file("graph.html").write_text(graphhtml.render(g), encoding="utf-8")
20
+ fp.out_file("graph.html").write_text(
21
+ graphhtml.render(g, root=root, editor=cfg.get("graph_editor", "vscode")),
22
+ encoding="utf-8")
21
23
  fp.out_file("GRAPH_REPORT.md").write_text(report.render(g), encoding="utf-8")
22
24
 
23
25
  narr = narrative.render(rs)
@@ -141,6 +141,15 @@ def build_parser() -> argparse.ArgumentParser:
141
141
 
142
142
  sub.add_parser("report", help="write GRAPH_REPORT.md (god nodes + communities)").set_defaults(fn=cligraph.cmd_report)
143
143
 
144
+ hk = sub.add_parser("hooks", help="install/uninstall/status Fux hooks across git + agents")
145
+ hk.add_argument("action", choices=["install", "uninstall", "status"], nargs="?",
146
+ default="install", help="default: install")
147
+ hk.add_argument("--all", action="store_true", help="all surfaces (the default)")
148
+ for _s in ("git", "claude", "codex", "copilot"):
149
+ hk.add_argument(f"--{_s}", action="store_true", help=f"only the {_s} surface")
150
+ hk.add_argument("--recall", action="store_true", help="also wire the UserPromptSubmit recall hook")
151
+ hk.set_defaults(fn=clicmds.cmd_hooks)
152
+
144
153
  sub.add_parser("setup", help="copy bundled assets (schema, hooks, skills) to ~/.claude/fux/").set_defaults(fn=clicmds.cmd_setup)
145
154
 
146
155
  fr = sub.add_parser("fetch-rules", help="fetch plain text from a URL / file / PDF for rule extraction")
@@ -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, importer, initcmd,
9
- mcpserver, paths, serve)
8
+ from fux import (build, check, config, context, fix, gate, hookinstall, importer,
9
+ initcmd, mcpserver, paths, serve)
10
10
  from fux.cliutil import root
11
11
  from fux.findings import blocking
12
12
 
@@ -24,6 +24,23 @@ def cmd_init(args) -> int:
24
24
  return 0
25
25
 
26
26
 
27
+ def cmd_hooks(args) -> int:
28
+ """install | uninstall | status across git + claude + codex + copilot surfaces."""
29
+ picked = [s for s in hookinstall.SURFACES if getattr(args, s, False)]
30
+ surfaces = None if (getattr(args, "all", False) or not picked) else picked
31
+ if args.action == "status":
32
+ for surface, on in hookinstall.status(root()).items():
33
+ print(f" {'✔' if on else '·'} {surface:<8} {'wired' if on else 'not wired'}")
34
+ return 0
35
+ fn = hookinstall.uninstall if args.action == "uninstall" else hookinstall.install
36
+ kw = {} if args.action == "uninstall" else {"recall": getattr(args, "recall", False)}
37
+ verb = "removed from" if args.action == "uninstall" else "wired into"
38
+ print(f"✔ Fux hooks {verb}:")
39
+ for surface, where in fn(root(), surfaces, **kw).items():
40
+ print(f" {surface:<8} → {where}")
41
+ return 0
42
+
43
+
27
44
  def cmd_build(args) -> int:
28
45
  s = build.run(root(), full=getattr(args, "full", False))
29
46
  print(f"✔ Built: {s['active']} active rules · {s['code_files']} code files · "
@@ -32,6 +32,8 @@ DEFAULTS = {
32
32
  "cost_tracking": False, # opt-in: record each lookup's savings → cumulative cost.json (§12)
33
33
  "parity_stay": [], # docs that stay/are out-of-scope for `fux parity` (§17.17)
34
34
  "context_budget_tokens": 0, # >0 ⇒ knapsack-pack the SessionStart INDEX (§17.25)
35
+ "graph_editor": "vscode", # editor URI scheme for clickable graph.html node links:
36
+ # vscode | vscode-insiders | cursor | windsurf (§7)
35
37
  }
36
38
 
37
39
 
@@ -81,5 +83,8 @@ def default_toml() -> str:
81
83
  "parity_stay = []\n\n"
82
84
  "# Token budget for the SessionStart INDEX. 0 = inject everything; >0 picks\n"
83
85
  "# the optimal (knapsack) rule subset that fits — for very large corpora.\n"
84
- "context_budget_tokens = 0\n"
86
+ "context_budget_tokens = 0\n\n"
87
+ "# Editor for clickable file:line node links in graph.html.\n"
88
+ "# vscode | vscode-insiders | cursor | windsurf\n"
89
+ 'graph_editor = "vscode"\n'
85
90
  )
@@ -0,0 +1,27 @@
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
@@ -126,7 +126,9 @@ def _xref(nodes: dict, texts: dict[str, str], covered: set[tuple[str, str]]) ->
126
126
  index = _symbol_index(nodes)
127
127
  seen, out = set(), []
128
128
  for rel, text in texts.items():
129
- for name in astextract.call_names(text) - astextract.CALL_KEYWORDS:
129
+ # `call_names` returns a set — sort so reference-edge order (and thus
130
+ # graph.json) is reproducible across builds (no PYTHONHASHSEED churn).
131
+ for name in sorted(astextract.call_names(text) - astextract.CALL_KEYWORDS):
130
132
  for tid in index.get(name, []):
131
133
  if tid.startswith(rel + "::") or (rel, tid) in covered or (rel, tid) in seen:
132
134
  continue
@@ -0,0 +1,20 @@
1
+ """Render the merged graph as a self-contained interactive HTML file (plan §7)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ _ASSETS = Path(__file__).parent / "assets"
8
+
9
+
10
+ def render(graph: dict, root: Path | None = None, editor: str = "vscode") -> str:
11
+ """Build the offline viewer. ``root`` (absolute project dir) + ``editor`` make
12
+ file:line node labels clickable as ``<editor>://file/<abs>:<line>`` deep links."""
13
+ template = (_ASSETS / "graph_template.html").read_text(encoding="utf-8")
14
+ boot = (_ASSETS / "graph_boot.js").read_text(encoding="utf-8")
15
+ data = json.dumps(graph, ensure_ascii=False)
16
+ root_str = str(root.resolve()) if root is not None else ""
17
+ return (template.replace("__GRAPH_DATA__", data)
18
+ .replace("__BOOT__", boot)
19
+ .replace("__ROOT__", json.dumps(root_str))
20
+ .replace("__EDITOR__", json.dumps(editor or "vscode")))
@@ -0,0 +1,88 @@
1
+ """`fux hooks install` — wire Fux across every agent surface from one command.
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)
6
+ claude → .claude/settings.json (SessionStart/PostToolUse/Stop hooks)
7
+ codex → .codex/hooks.json
8
+ copilot → .copilot/settings.json
9
+ Idempotent; `uninstall` / `status` mirror it. Git is non-blocking by design.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import stat
14
+ from pathlib import Path
15
+
16
+ from fux import gitutil, paths, settings
17
+
18
+ SURFACES = ["git", "claude", "codex", "copilot"]
19
+ _MARK = "fux-hook"
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')
32
+
33
+
34
+ def _install_git(root: Path) -> str:
35
+ hooks = gitutil.hooks_dir(root)
36
+ if hooks is None:
37
+ return "skipped (not a git repo)"
38
+ hooks.mkdir(parents=True, exist_ok=True)
39
+ hook = hooks / "pre-commit"
40
+ if hook.exists() and _MARK not in hook.read_text(encoding="utf-8", errors="ignore"):
41
+ backup = hook.with_suffix(".pre-fux")
42
+ hook.rename(backup)
43
+ note = f" (existing hook backed up → {backup.name})"
44
+ else:
45
+ note = ""
46
+ hook.write_text(_shim(_packaged_precommit()), encoding="utf-8")
47
+ hook.chmod(hook.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
48
+ return f"{hook}{note}"
49
+
50
+
51
+ def install(root: Path, surfaces: list[str] | None = None, recall: bool = False) -> dict:
52
+ todo = surfaces or SURFACES
53
+ out: dict[str, str] = {}
54
+ if "git" in todo:
55
+ out["git"] = _install_git(root)
56
+ for agent in ("claude", "codex", "copilot"):
57
+ if agent in todo:
58
+ out[agent] = str(settings.wire_file(root / settings.AGENT_FILES[agent], recall=recall))
59
+ return out
60
+
61
+
62
+ def uninstall(root: Path, surfaces: list[str] | None = None) -> dict:
63
+ todo = surfaces or SURFACES
64
+ out: dict[str, str] = {}
65
+ if "git" in todo:
66
+ hooks = gitutil.hooks_dir(root)
67
+ hook = hooks / "pre-commit" if hooks else None
68
+ if hook and hook.exists() and _MARK in hook.read_text(encoding="utf-8", errors="ignore"):
69
+ hook.unlink()
70
+ out["git"] = "removed"
71
+ else:
72
+ out["git"] = "not installed"
73
+ for agent in ("claude", "codex", "copilot"):
74
+ if agent in todo:
75
+ removed = settings.unwire_file(root / settings.AGENT_FILES[agent])
76
+ out[agent] = "removed" if removed else "not installed"
77
+ return out
78
+
79
+
80
+ def status(root: Path) -> dict:
81
+ out: dict[str, bool] = {}
82
+ hooks = gitutil.hooks_dir(root)
83
+ hook = hooks / "pre-commit" if hooks else None
84
+ out["git"] = bool(hook and hook.exists()
85
+ and _MARK in hook.read_text(encoding="utf-8", errors="ignore"))
86
+ for agent in ("claude", "codex", "copilot"):
87
+ out[agent] = settings.is_wired(root / settings.AGENT_FILES[agent])
88
+ return out
@@ -0,0 +1,95 @@
1
+ """Wire Fux hooks into an agent's JSON settings (plan §8). Idempotent.
2
+
3
+ Claude (`.claude/settings.json`), Codex (`.codex/hooks.json`), and Copilot
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.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from fux import paths
15
+
16
+ # event → (wrapper script name, bare-command fallback, optional matcher)
17
+ _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),
21
+ }
22
+ _RECALL = {"UserPromptSubmit": ("user_prompt_submit.sh", "fux hook-recall", None)}
23
+
24
+ # agent → settings file (relative to project root)
25
+ AGENT_FILES = {
26
+ "claude": Path(".claude") / "settings.json",
27
+ "codex": Path(".codex") / "hooks.json",
28
+ "copilot": Path(".copilot") / "settings.json",
29
+ }
30
+
31
+
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)}]}
39
+ if matcher:
40
+ hook["matcher"] = matcher
41
+ return hook
42
+
43
+
44
+ def wire_file(path: Path, recall: bool = False) -> Path:
45
+ """Wire the Fux hook spec into one agent settings file. Idempotent per event."""
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ data = json.loads(path.read_text()) if path.exists() else {}
48
+ hooks = data.setdefault("hooks", {})
49
+ 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))
54
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
55
+ return path
56
+
57
+
58
+ def wire(root: Path, recall: bool = False) -> Path:
59
+ """Wire Claude's `.claude/settings.json` (the original entry point)."""
60
+ return wire_file(root / AGENT_FILES["claude"], recall=recall)
61
+
62
+
63
+ def unwire_file(path: Path) -> bool:
64
+ """Remove every Fux hook entry from an agent settings file. True if changed."""
65
+ if not path.exists():
66
+ return False
67
+ data = json.loads(path.read_text())
68
+ hooks = data.get("hooks", {})
69
+ changed = False
70
+ for event in list(hooks):
71
+ kept = [e for e in hooks[event]
72
+ if not any("fux" in h.get("command", "") for h in e.get("hooks", []))]
73
+ if len(kept) != len(hooks[event]):
74
+ changed = True
75
+ hooks[event] = kept
76
+ if not hooks[event]:
77
+ del hooks[event]
78
+ if changed:
79
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
80
+ return changed
81
+
82
+
83
+ def is_wired(path: Path) -> bool:
84
+ """True if any Fux hook is present in this agent settings file."""
85
+ if not path.exists():
86
+ return False
87
+ hooks = json.loads(path.read_text()).get("hooks", {})
88
+ return any("fux" in h.get("command", "")
89
+ 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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fux-engine
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -35,6 +35,7 @@ fux/governance.py
35
35
  fux/graph.py
36
36
  fux/graphhtml.py
37
37
  fux/graphquery.py
38
+ fux/hookinstall.py
38
39
  fux/hookio.py
39
40
  fux/hooks.py
40
41
  fux/hybrid.py
@@ -82,6 +83,7 @@ fux/data/global/rules/files-max-100-lines.md
82
83
  fux/data/global/rules/no-secrets-in-vcs.md
83
84
  fux/data/hooks/_common.sh
84
85
  fux/data/hooks/post_tool_use.sh
86
+ fux/data/hooks/pre_commit.sh
85
87
  fux/data/hooks/session_start.sh
86
88
  fux/data/hooks/stop.sh
87
89
  fux/data/hooks/user_prompt_submit.sh
@@ -120,7 +122,10 @@ tests/test_fetch_rules.py
120
122
  tests/test_frontmatter.py
121
123
  tests/test_fuzz_mine.py
122
124
  tests/test_globs.py
125
+ tests/test_graph_determinism.py
123
126
  tests/test_graphhtml.py
127
+ tests/test_graphhtml_links.py
128
+ tests/test_hookinstall.py
124
129
  tests/test_hybrid.py
125
130
  tests/test_impact.py
126
131
  tests/test_lint_stats_gate.py
@@ -0,0 +1,30 @@
1
+ """Reproducible builds: `_xref` iterates a set (`call_names`), so without an
2
+ explicit sort the `references` edge order — and thus graph.json — churns across
3
+ builds under hash randomization, making committed views noisy. The fix sorts the
4
+ iteration; this guards it by asserting the emitted order is canonical (sorted)."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+
9
+ from fux import build
10
+
11
+
12
+ def _refs_from(project, src_file):
13
+ g = json.loads((project / ".fux" / "out" / "graph.json").read_text())
14
+ return [e for e in g["edges"]
15
+ if e["type"] == "references" and e["source"] == src_file]
16
+
17
+
18
+ def test_reference_edges_emitted_in_sorted_target_order(project):
19
+ # caller.py references three symbols defined elsewhere, at module scope (so they
20
+ # are loose file→symbol `references`, the edges built by `_xref`).
21
+ (project / "src" / "defs.py").write_text(
22
+ "def zeta():\n return 1\n\ndef alpha():\n return 2\n\ndef mu():\n return 3\n")
23
+ (project / "src" / "caller.py").write_text("zeta()\nalpha()\nmu()\n")
24
+ build.run(project)
25
+
26
+ refs = _refs_from(project, "src/caller.py")
27
+ names = [e["target"].split("::", 1)[1] for e in refs]
28
+ assert set(names) >= {"alpha", "mu", "zeta"}, names
29
+ # canonical order — independent of set-iteration / PYTHONHASHSEED.
30
+ assert names == sorted(names), f"reference edges not in sorted order: {names}"
@@ -0,0 +1,31 @@
1
+ """graph.html embeds the project root + editor scheme so node file:line labels
2
+ become clickable <editor>://file/<abs>:<line> deep links."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fux import graphhtml
8
+
9
+
10
+ def _graph():
11
+ return {"nodes": [{"id": "src/a.py::f", "label": "f", "type": "function",
12
+ "file": "src/a.py", "line": 12}],
13
+ "edges": [], "meta": {}}
14
+
15
+
16
+ def test_render_embeds_resolved_root_and_editor(tmp_path):
17
+ html = graphhtml.render(_graph(), root=tmp_path, editor="cursor")
18
+ assert f'const ROOT = "{tmp_path.resolve()}"' in html
19
+ assert 'const EDITOR = "cursor"' in html
20
+ assert "function fileLink" in html # the deep-link helper is shipped
21
+
22
+
23
+ def test_render_defaults_editor_to_vscode(tmp_path):
24
+ html = graphhtml.render(_graph(), root=tmp_path)
25
+ assert 'const EDITOR = "vscode"' in html
26
+
27
+
28
+ def test_render_without_root_is_inert():
29
+ # No root → empty ROOT string → fileLink falls back to plain text (no crash).
30
+ html = graphhtml.render(_graph())
31
+ assert 'const ROOT = ""' in html
@@ -0,0 +1,73 @@
1
+ """`fux hooks install` wires git + claude + codex + copilot from one command,
2
+ always pointing at packaged scripts, idempotent, with status/uninstall mirrors."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+
8
+ from fux import hookinstall, settings
9
+
10
+
11
+ def _git_init(root):
12
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
13
+
14
+
15
+ def test_install_wires_all_three_agents(project):
16
+ out = hookinstall.install(project, ["claude", "codex", "copilot"])
17
+ for agent, rel in settings.AGENT_FILES.items():
18
+ path = project / rel
19
+ assert path.exists(), f"{agent} settings not written"
20
+ hooks = json.loads(path.read_text())["hooks"]
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"
23
+ assert set(out) == {"claude", "codex", "copilot"}
24
+
25
+
26
+ def test_install_is_idempotent(project):
27
+ hookinstall.install(project, ["claude"])
28
+ hookinstall.install(project, ["claude"])
29
+ hooks = json.loads((project / ".claude/settings.json").read_text())["hooks"]
30
+ # SessionStart wired exactly once, not duplicated on a second install.
31
+ assert len(hooks["SessionStart"]) == 1
32
+
33
+
34
+ def test_git_install_writes_executable_precommit_shim(project):
35
+ _git_init(project)
36
+ out = hookinstall.install(project, ["git"])
37
+ hook = project / ".git" / "hooks" / "pre-commit"
38
+ assert hook.exists() and hook.stat().st_mode & 0o111, "pre-commit not executable"
39
+ body = hook.read_text()
40
+ assert "fux-hook" in body # our marker
41
+ assert "pre_commit.sh" in body # delegates to the packaged script
42
+ assert str(hook) in out["git"]
43
+
44
+
45
+ def test_git_install_backs_up_foreign_precommit(project):
46
+ _git_init(project)
47
+ hook = project / ".git" / "hooks" / "pre-commit"
48
+ hook.write_text("#!/bin/sh\necho mine\n")
49
+ hookinstall.install(project, ["git"])
50
+ assert (project / ".git" / "hooks" / "pre-commit.pre-fux").exists()
51
+ assert "fux-hook" in hook.read_text()
52
+
53
+
54
+ def test_status_and_uninstall_roundtrip(project):
55
+ _git_init(project)
56
+ hookinstall.install(project)
57
+ st = hookinstall.status(project)
58
+ assert all(st.values()), st
59
+ hookinstall.uninstall(project)
60
+ st2 = hookinstall.status(project)
61
+ assert not any(st2.values()), st2
62
+
63
+
64
+ def test_uninstall_leaves_foreign_hooks(project):
65
+ settings.wire_file(project / ".claude/settings.json")
66
+ data = json.loads((project / ".claude/settings.json").read_text())
67
+ data["hooks"].setdefault("SessionStart", [])[0] # fux entry exists
68
+ data["hooks"]["Stop"].append({"hooks": [{"type": "command", "command": "echo other"}]})
69
+ (project / ".claude/settings.json").write_text(json.dumps(data))
70
+ hookinstall.uninstall(project, ["claude"])
71
+ hooks = json.loads((project / ".claude/settings.json").read_text())["hooks"]
72
+ cmds = [h["command"] for evs in hooks.values() for e in evs for h in e["hooks"]]
73
+ assert "echo other" in cmds and not any("fux" in c for c in cmds)
@@ -1,14 +0,0 @@
1
- """Render the merged graph as a self-contained interactive HTML file (plan §7)."""
2
- from __future__ import annotations
3
-
4
- import json
5
- from pathlib import Path
6
-
7
- _ASSETS = Path(__file__).parent / "assets"
8
-
9
-
10
- def render(graph: dict) -> str:
11
- template = (_ASSETS / "graph_template.html").read_text(encoding="utf-8")
12
- boot = (_ASSETS / "graph_boot.js").read_text(encoding="utf-8")
13
- data = json.dumps(graph, ensure_ascii=False)
14
- return template.replace("__GRAPH_DATA__", data).replace("__BOOT__", boot)
@@ -1,52 +0,0 @@
1
- """Wire Fux hooks into a project's .claude/settings.json (plan §8). Idempotent.
2
-
3
- Prefers the installed wrapper scripts (~/.claude/fux/hooks/*.sh, which carry a
4
- `python -m fux` fallback) so hooks work even when the `fux` console script is not
5
- on PATH; falls back to the bare `fux <subcommand>` form otherwise.
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- from pathlib import Path
11
-
12
- from fux import paths
13
-
14
- # event → (wrapper script name, bare-command fallback, optional matcher)
15
- _SPEC = {
16
- "SessionStart": ("session_start.sh", "fux context", None),
17
- "PostToolUse": ("post_tool_use.sh", "fux hook-touch", "Edit|Write"),
18
- "Stop": ("stop.sh", "fux hook-check", None),
19
- }
20
- _RECALL = {"UserPromptSubmit": ("user_prompt_submit.sh", "fux hook-recall", None)}
21
-
22
-
23
- def _command(script: str, fallback: str) -> str:
24
- wrapper = paths.claude_home() / "fux" / "hooks" / script
25
- return str(wrapper) if wrapper.exists() else fallback
26
-
27
-
28
- def _entry(script: str, fallback: str, matcher: str | None) -> dict:
29
- hook = {"hooks": [{"type": "command", "command": _command(script, fallback)}]}
30
- if matcher:
31
- hook["matcher"] = matcher
32
- return hook
33
-
34
-
35
- def wire(root: Path, recall: bool = False) -> Path:
36
- path = root / ".claude" / "settings.json"
37
- path.parent.mkdir(parents=True, exist_ok=True)
38
- data = json.loads(path.read_text()) if path.exists() else {}
39
- hooks = data.setdefault("hooks", {})
40
- spec = {**_SPEC, **(_RECALL if recall else {})}
41
- for event, (script, fallback, matcher) in spec.items():
42
- existing = hooks.setdefault(event, [])
43
- if not _already(existing):
44
- existing.append(_entry(script, fallback, matcher))
45
- path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
46
- return path
47
-
48
-
49
- def _already(existing: list) -> bool:
50
- """True if any Fux hook (either wiring form) is already present for this event."""
51
- cmds = [h.get("command", "") for e in existing for h in e.get("hooks", [])]
52
- return any("fux" in c for c in cmds)
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