feed-the-machine 1.6.1 → 1.7.1

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 (272) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +262 -170
  3. package/bin/__pycache__/tasks_db.cpython-314.pyc +0 -0
  4. package/bin/brain.py +1340 -0
  5. package/bin/convert_claude_skills_to_codex.py +490 -0
  6. package/bin/generate-manifest.mjs +463 -463
  7. package/bin/harden_codex_skills.py +141 -0
  8. package/bin/install.mjs +491 -491
  9. package/bin/migrate-eng-buddy-data.py +875 -0
  10. package/bin/playbook_engine/__init__.py +1 -0
  11. package/bin/playbook_engine/conftest.py +8 -0
  12. package/bin/playbook_engine/extractor.py +33 -0
  13. package/bin/playbook_engine/manager.py +102 -0
  14. package/bin/playbook_engine/models.py +84 -0
  15. package/bin/playbook_engine/registry.py +35 -0
  16. package/bin/playbook_engine/test_extractor.py +72 -0
  17. package/bin/playbook_engine/test_integration.py +129 -0
  18. package/bin/playbook_engine/test_manager.py +85 -0
  19. package/bin/playbook_engine/test_models.py +166 -0
  20. package/bin/playbook_engine/test_registry.py +67 -0
  21. package/bin/playbook_engine/test_tracer.py +86 -0
  22. package/bin/playbook_engine/tracer.py +93 -0
  23. package/bin/tasks_db.py +456 -0
  24. package/docs/HOOKS.md +243 -243
  25. package/docs/INBOX.md +233 -233
  26. package/ftm/SKILL.md +125 -122
  27. package/ftm-audit/SKILL.md +673 -623
  28. package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
  29. package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
  30. package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
  31. package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
  32. package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
  33. package/ftm-audit/scripts/run-knip.sh +23 -23
  34. package/ftm-audit.yml +2 -2
  35. package/ftm-brainstorm/SKILL.md +1003 -498
  36. package/ftm-brainstorm/evals/evals.json +180 -100
  37. package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
  38. package/ftm-brainstorm/references/agent-prompts.md +552 -224
  39. package/ftm-brainstorm/references/plan-template.md +209 -121
  40. package/ftm-brainstorm.yml +2 -2
  41. package/ftm-browse/SKILL.md +454 -454
  42. package/ftm-browse/daemon/browser-manager.ts +206 -206
  43. package/ftm-browse/daemon/bun.lock +30 -30
  44. package/ftm-browse/daemon/cli.ts +347 -347
  45. package/ftm-browse/daemon/commands.ts +410 -410
  46. package/ftm-browse/daemon/main.ts +357 -357
  47. package/ftm-browse/daemon/package.json +17 -17
  48. package/ftm-browse/daemon/server.ts +189 -189
  49. package/ftm-browse/daemon/snapshot.ts +519 -519
  50. package/ftm-browse/daemon/tsconfig.json +22 -22
  51. package/ftm-browse.yml +4 -4
  52. package/ftm-capture/SKILL.md +370 -370
  53. package/ftm-capture.yml +4 -4
  54. package/ftm-codex-gate/SKILL.md +361 -361
  55. package/ftm-codex-gate.yml +2 -2
  56. package/ftm-config/SKILL.md +422 -345
  57. package/ftm-config.default.yml +125 -82
  58. package/ftm-config.yml +44 -2
  59. package/ftm-council/SKILL.md +416 -416
  60. package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
  61. package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
  62. package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
  63. package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
  64. package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
  65. package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
  66. package/ftm-council-chat.yml +2 -0
  67. package/ftm-council.yml +2 -2
  68. package/ftm-dashboard/SKILL.md +163 -163
  69. package/ftm-dashboard.yml +4 -4
  70. package/ftm-debug/SKILL.md +1037 -1037
  71. package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
  72. package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
  73. package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
  74. package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
  75. package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
  76. package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
  77. package/ftm-debug.yml +2 -2
  78. package/ftm-diagram/SKILL.md +277 -277
  79. package/ftm-diagram.yml +2 -2
  80. package/ftm-executor/SKILL.md +777 -777
  81. package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
  82. package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
  83. package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
  84. package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
  85. package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +81 -72
  86. package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
  87. package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
  88. package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
  89. package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
  90. package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
  91. package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
  92. package/ftm-executor/runtime/package.json +8 -8
  93. package/ftm-executor.yml +2 -2
  94. package/ftm-git/SKILL.md +441 -441
  95. package/ftm-git/evals/evals.json +26 -26
  96. package/ftm-git/evals/promptfoo.yaml +75 -75
  97. package/ftm-git/hooks/post-commit-experience.sh +92 -92
  98. package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
  99. package/ftm-git/references/protocols/REMEDIATION.md +139 -139
  100. package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
  101. package/ftm-git.yml +2 -2
  102. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  103. package/ftm-inbox/backend/adapters/_retry.py +64 -64
  104. package/ftm-inbox/backend/adapters/base.py +230 -230
  105. package/ftm-inbox/backend/adapters/freshservice.py +104 -104
  106. package/ftm-inbox/backend/adapters/gmail.py +125 -125
  107. package/ftm-inbox/backend/adapters/jira.py +136 -136
  108. package/ftm-inbox/backend/adapters/registry.py +192 -192
  109. package/ftm-inbox/backend/adapters/slack.py +110 -110
  110. package/ftm-inbox/backend/db/connection.py +54 -54
  111. package/ftm-inbox/backend/db/schema.py +78 -78
  112. package/ftm-inbox/backend/executor/__init__.py +7 -7
  113. package/ftm-inbox/backend/executor/engine.py +149 -149
  114. package/ftm-inbox/backend/executor/step_runner.py +98 -98
  115. package/ftm-inbox/backend/main.py +103 -103
  116. package/ftm-inbox/backend/models/__init__.py +1 -1
  117. package/ftm-inbox/backend/models/unified_task.py +36 -36
  118. package/ftm-inbox/backend/planner/__init__.py +6 -6
  119. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  120. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  121. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  122. package/ftm-inbox/backend/planner/generator.py +127 -127
  123. package/ftm-inbox/backend/planner/schema.py +34 -34
  124. package/ftm-inbox/backend/requirements.txt +5 -5
  125. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  126. package/ftm-inbox/backend/routes/execute.py +186 -186
  127. package/ftm-inbox/backend/routes/health.py +52 -52
  128. package/ftm-inbox/backend/routes/inbox.py +68 -68
  129. package/ftm-inbox/backend/routes/plan.py +271 -271
  130. package/ftm-inbox/bin/launchagent.mjs +91 -91
  131. package/ftm-inbox/bin/setup.mjs +188 -188
  132. package/ftm-inbox/bin/start.sh +10 -10
  133. package/ftm-inbox/bin/status.sh +17 -17
  134. package/ftm-inbox/bin/stop.sh +8 -8
  135. package/ftm-inbox/config.example.yml +55 -55
  136. package/ftm-inbox/package-lock.json +2898 -2898
  137. package/ftm-inbox/package.json +26 -26
  138. package/ftm-inbox/postcss.config.js +6 -6
  139. package/ftm-inbox/src/app.css +199 -199
  140. package/ftm-inbox/src/app.html +18 -18
  141. package/ftm-inbox/src/lib/api.ts +166 -166
  142. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
  143. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
  144. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
  145. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
  146. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
  147. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
  148. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
  149. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
  150. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
  151. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
  152. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
  153. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
  154. package/ftm-inbox/src/lib/theme.ts +47 -47
  155. package/ftm-inbox/src/routes/+layout.svelte +76 -76
  156. package/ftm-inbox/src/routes/+page.svelte +401 -401
  157. package/ftm-inbox/svelte.config.js +12 -12
  158. package/ftm-inbox/tailwind.config.ts +63 -63
  159. package/ftm-inbox/tsconfig.json +13 -13
  160. package/ftm-inbox/vite.config.ts +6 -6
  161. package/ftm-intent/SKILL.md +241 -241
  162. package/ftm-intent.yml +2 -2
  163. package/ftm-manifest.json +3794 -3794
  164. package/ftm-map/SKILL.md +291 -291
  165. package/ftm-map/scripts/db.py +712 -712
  166. package/ftm-map/scripts/index.py +415 -415
  167. package/ftm-map/scripts/parser.py +224 -224
  168. package/ftm-map/scripts/queries/go-tags.scm +20 -20
  169. package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
  170. package/ftm-map/scripts/queries/python-tags.scm +31 -31
  171. package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
  172. package/ftm-map/scripts/queries/rust-tags.scm +37 -37
  173. package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
  174. package/ftm-map/scripts/query.py +301 -301
  175. package/ftm-map/scripts/ranker.py +377 -377
  176. package/ftm-map/scripts/requirements.txt +5 -5
  177. package/ftm-map/scripts/setup-hooks.sh +27 -27
  178. package/ftm-map/scripts/setup.sh +56 -56
  179. package/ftm-map/scripts/test_db.py +364 -364
  180. package/ftm-map/scripts/test_parser.py +174 -174
  181. package/ftm-map/scripts/test_query.py +183 -183
  182. package/ftm-map/scripts/test_ranker.py +199 -199
  183. package/ftm-map/scripts/views.py +591 -591
  184. package/ftm-map.yml +2 -2
  185. package/ftm-mind/SKILL.md +201 -1943
  186. package/ftm-mind/evals/promptfoo.yaml +142 -142
  187. package/ftm-mind/references/blackboard-protocol.md +110 -0
  188. package/ftm-mind/references/blackboard-schema.md +328 -328
  189. package/ftm-mind/references/complexity-guide.md +110 -110
  190. package/ftm-mind/references/complexity-sizing.md +138 -0
  191. package/ftm-mind/references/decide-act-protocol.md +172 -0
  192. package/ftm-mind/references/direct-execution.md +51 -0
  193. package/ftm-mind/references/environment-discovery.md +77 -0
  194. package/ftm-mind/references/event-registry.md +319 -319
  195. package/ftm-mind/references/mcp-inventory.md +300 -296
  196. package/ftm-mind/references/ops-routing.md +47 -0
  197. package/ftm-mind/references/orient-protocol.md +234 -0
  198. package/ftm-mind/references/personality.md +40 -0
  199. package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
  200. package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
  201. package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
  202. package/ftm-mind/references/reflexion-protocol.md +249 -249
  203. package/ftm-mind/references/routing/SCENARIOS.md +22 -22
  204. package/ftm-mind/references/routing-scenarios.md +35 -35
  205. package/ftm-mind.yml +2 -2
  206. package/ftm-ops.yml +4 -0
  207. package/ftm-pause/SKILL.md +395 -395
  208. package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
  209. package/ftm-pause/references/protocols/VALIDATION.md +80 -80
  210. package/ftm-pause.yml +2 -2
  211. package/ftm-researcher/SKILL.md +275 -275
  212. package/ftm-researcher/evals/agent-diversity.yaml +17 -17
  213. package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
  214. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
  215. package/ftm-researcher/references/adaptive-search.md +116 -116
  216. package/ftm-researcher/references/agent-prompts.md +193 -193
  217. package/ftm-researcher/references/council-integration.md +193 -193
  218. package/ftm-researcher/references/output-format.md +203 -203
  219. package/ftm-researcher/references/synthesis-pipeline.md +165 -165
  220. package/ftm-researcher/scripts/score_credibility.py +234 -234
  221. package/ftm-researcher/scripts/validate_research.py +92 -92
  222. package/ftm-researcher.yml +2 -2
  223. package/ftm-resume/SKILL.md +518 -518
  224. package/ftm-resume/references/protocols/VALIDATION.md +172 -172
  225. package/ftm-resume.yml +2 -2
  226. package/ftm-retro/SKILL.md +380 -380
  227. package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
  228. package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
  229. package/ftm-retro.yml +2 -2
  230. package/ftm-routine/SKILL.md +170 -170
  231. package/ftm-routine.yml +4 -4
  232. package/ftm-state/blackboard/capabilities.json +5 -5
  233. package/ftm-state/blackboard/capabilities.schema.json +27 -27
  234. package/ftm-state/blackboard/context.json +37 -23
  235. package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
  236. package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
  237. package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
  238. package/ftm-state/blackboard/experiences/index.json +58 -9
  239. package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
  240. package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
  241. package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
  242. package/ftm-state/blackboard/patterns.json +6 -6
  243. package/ftm-state/schemas/context.schema.json +130 -130
  244. package/ftm-state/schemas/experience-index.schema.json +77 -77
  245. package/ftm-state/schemas/experience.schema.json +78 -78
  246. package/ftm-state/schemas/patterns.schema.json +44 -44
  247. package/ftm-upgrade/SKILL.md +194 -194
  248. package/ftm-upgrade/scripts/check-version.sh +76 -76
  249. package/ftm-upgrade/scripts/upgrade.sh +143 -143
  250. package/ftm-upgrade.yml +2 -2
  251. package/ftm-verify.yml +2 -2
  252. package/ftm.yml +2 -2
  253. package/hooks/ftm-auto-log.sh +137 -0
  254. package/hooks/ftm-blackboard-enforcer.sh +93 -93
  255. package/hooks/ftm-discovery-reminder.sh +90 -90
  256. package/hooks/ftm-drafts-gate.sh +61 -61
  257. package/hooks/ftm-event-logger.mjs +107 -107
  258. package/hooks/ftm-install-hooks.sh +240 -0
  259. package/hooks/ftm-learning-capture.sh +117 -0
  260. package/hooks/ftm-map-autodetect.sh +79 -79
  261. package/hooks/ftm-pending-sync-check.sh +22 -22
  262. package/hooks/ftm-plan-gate.sh +92 -92
  263. package/hooks/ftm-post-commit-trigger.sh +57 -57
  264. package/hooks/ftm-post-compaction.sh +138 -0
  265. package/hooks/ftm-pre-compaction.sh +147 -0
  266. package/hooks/ftm-session-end.sh +52 -0
  267. package/hooks/ftm-session-snapshot.sh +213 -0
  268. package/hooks/ftm-task-loader.sh +100 -0
  269. package/hooks/settings-template.json +91 -81
  270. package/install.sh +363 -363
  271. package/package.json +84 -84
  272. package/uninstall.sh +25 -25
@@ -1,591 +1,591 @@
1
- #!/usr/bin/env python3
2
- """View generators: produce INTENT.md and ARCHITECTURE.mmd from the code graph.
3
-
4
- Updated for v2 hybrid architecture with 5-table schema:
5
- files, symbols, refs, file_edges, symbol_edges
6
- """
7
-
8
- import argparse
9
- import os
10
- import sys
11
- from collections import defaultdict
12
- from pathlib import Path
13
-
14
- sys.path.insert(0, os.path.dirname(__file__))
15
- from db import get_connection
16
-
17
- # ---------------------------------------------------------------------------
18
- # Module grouping helpers
19
- # ---------------------------------------------------------------------------
20
-
21
-
22
- def _get_module_for_path(file_path: str) -> str:
23
- """Return the top-level directory component of a relative file path.
24
-
25
- Files at the project root (no directory component) are grouped under '.'.
26
- """
27
- parts = Path(file_path).parts
28
- if len(parts) > 1:
29
- return parts[0]
30
- return "."
31
-
32
-
33
- def get_modules(conn) -> dict:
34
- """Group files by directory to identify modules.
35
-
36
- Queries the files table directly (symbols no longer carry file_path).
37
- Returns a dict mapping module name -> set of file paths.
38
- """
39
- rows = conn.execute(
40
- "SELECT DISTINCT path FROM files ORDER BY path"
41
- ).fetchall()
42
-
43
- modules: dict = defaultdict(set)
44
- for row in rows:
45
- fp = row["path"]
46
- module = _get_module_for_path(fp)
47
- modules[module].add(fp)
48
-
49
- return dict(modules)
50
-
51
-
52
- def _get_symbols_for_module(conn, module: str, files: set) -> list:
53
- """Return all symbol rows for a module (identified by its set of files).
54
-
55
- Joins symbols with files to resolve file_path and maps column names
56
- to the view-layer conventions (file_path, start_line, end_line).
57
- """
58
- placeholders = ",".join("?" * len(files))
59
- rows = conn.execute(
60
- f"""
61
- SELECT s.id, s.name, s.qualified_name, s.kind,
62
- s.line_start AS start_line, s.line_end AS end_line,
63
- s.signature, s.parent_id,
64
- f.path AS file_path
65
- FROM symbols s
66
- JOIN files f ON f.id = s.file_id
67
- WHERE f.path IN ({placeholders})
68
- ORDER BY f.path, s.line_start
69
- """,
70
- list(files),
71
- ).fetchall()
72
- return [dict(r) for r in rows]
73
-
74
-
75
- def _get_callers(conn, symbol_id: int) -> list:
76
- """Return direct callers (symbols that call this one) via symbol_edges."""
77
- rows = conn.execute(
78
- """
79
- SELECT s.name, f.path AS file_path
80
- FROM symbol_edges se
81
- JOIN symbols s ON s.id = se.source_symbol_id
82
- JOIN files f ON f.id = s.file_id
83
- WHERE se.target_symbol_id = ?
84
- LIMIT 10
85
- """,
86
- (symbol_id,),
87
- ).fetchall()
88
- return [dict(r) for r in rows]
89
-
90
-
91
- def _get_callees(conn, symbol_id: int) -> list:
92
- """Return direct callees (symbols this one calls) via symbol_edges."""
93
- rows = conn.execute(
94
- """
95
- SELECT s.name, f.path AS file_path
96
- FROM symbol_edges se
97
- JOIN symbols s ON s.id = se.target_symbol_id
98
- JOIN files f ON f.id = s.file_id
99
- WHERE se.source_symbol_id = ?
100
- LIMIT 10
101
- """,
102
- (symbol_id,),
103
- ).fetchall()
104
- return [dict(r) for r in rows]
105
-
106
-
107
- def _get_ref_count(conn, symbol_name: str) -> int:
108
- """Return the number of references to a symbol from the refs table."""
109
- row = conn.execute(
110
- "SELECT COUNT(*) AS cnt FROM refs WHERE symbol_name = ?",
111
- (symbol_name,),
112
- ).fetchone()
113
- return row["cnt"] if row else 0
114
-
115
-
116
- def _top_symbols(symbols: list, n: int = 5) -> list:
117
- """Return top n function/method symbols from a list, falling back to any kind."""
118
- funcs = [s for s in symbols if s["kind"] in ("function", "method", "definition")]
119
- selection = funcs if funcs else symbols
120
- return selection[:n]
121
-
122
-
123
- def _infer_purpose(module: str, symbols: list) -> str:
124
- """Infer a one-line purpose description from module name and symbol kinds."""
125
- if not symbols:
126
- return "Empty module — no symbols indexed yet."
127
-
128
- kinds = [s["kind"] for s in symbols]
129
- kind_counts: dict = defaultdict(int)
130
- for k in kinds:
131
- kind_counts[k] += 1
132
-
133
- dominant = sorted(kind_counts.items(), key=lambda x: x[1], reverse=True)[0][0]
134
-
135
- name_lower = module.lower()
136
- if any(kw in name_lower for kw in ("test", "spec", "__tests__")):
137
- return "Test suite."
138
- if any(kw in name_lower for kw in ("util", "helper", "common", "shared")):
139
- return "Shared utilities and helpers."
140
- if any(kw in name_lower for kw in ("model", "schema", "entity", "type")):
141
- return "Data models and type definitions."
142
- if any(kw in name_lower for kw in ("route", "api", "handler", "endpoint")):
143
- return "API routes and request handlers."
144
- if any(kw in name_lower for kw in ("db", "database", "repo", "store")):
145
- return "Data access and persistence layer."
146
- if any(kw in name_lower for kw in ("config", "setting", "env")):
147
- return "Configuration and environment settings."
148
- if any(kw in name_lower for kw in ("service", "manager", "controller")):
149
- return "Business logic and service layer."
150
- if any(kw in name_lower for kw in ("component", "view", "page", "ui")):
151
- return "UI components and views."
152
-
153
- if dominant == "class":
154
- return f"Module defining {kind_counts['class']} class(es)."
155
- if dominant == "function":
156
- return f"Module with {kind_counts['function']} function(s)."
157
- if dominant == "definition":
158
- return f"Module with {kind_counts['definition']} definition(s)."
159
- return f"Module containing {len(symbols)} symbols."
160
-
161
-
162
- def _infer_function_does(sym: dict) -> str:
163
- """Infer what a function does from its name and signature."""
164
- doc = (sym.get("doc_comment") or "").strip()
165
- if doc:
166
- first_sentence = doc.split(".")[0].strip()
167
- if first_sentence:
168
- return first_sentence + "."
169
-
170
- sig = (sym.get("signature") or "").strip()
171
- name = sym.get("name", "")
172
-
173
- name_lower = name.lower()
174
- if name_lower.startswith("get_") or name_lower.startswith("fetch_"):
175
- subject = name_lower[4:].replace("_", " ")
176
- return f"Retrieves {subject}."
177
- if name_lower.startswith("set_") or name_lower.startswith("update_"):
178
- subject = name_lower[4:].replace("_", " ")
179
- return f"Updates {subject}."
180
- if name_lower.startswith("create_") or name_lower.startswith("add_"):
181
- subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
182
- return f"Creates or adds {subject}."
183
- if name_lower.startswith("delete_") or name_lower.startswith("remove_"):
184
- subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
185
- return f"Removes {subject}."
186
- if name_lower.startswith("is_") or name_lower.startswith("has_") or name_lower.startswith("check_"):
187
- subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
188
- return f"Checks whether {subject}."
189
- if name_lower.startswith("parse_") or name_lower.startswith("decode_"):
190
- subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
191
- return f"Parses {subject}."
192
- if name_lower.startswith("render_") or name_lower.startswith("format_"):
193
- subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
194
- return f"Formats or renders {subject}."
195
- if name_lower.startswith("handle_") or name_lower.startswith("on_"):
196
- subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
197
- return f"Handles {subject} event."
198
- if name_lower.startswith("init") or name_lower.startswith("setup") or name_lower.startswith("bootstrap"):
199
- return "Initializes and configures the component."
200
- if name_lower in ("main", "__main__"):
201
- return "Entry point for the module."
202
- if name_lower.startswith("test_"):
203
- subject = name_lower[5:].replace("_", " ")
204
- return f"Tests {subject}."
205
-
206
- if sig:
207
- return f"Executes `{sig[:80]}`."
208
-
209
- return f"Implements `{name}` logic."
210
-
211
-
212
- # ---------------------------------------------------------------------------
213
- # INTENT.md generation
214
- # ---------------------------------------------------------------------------
215
-
216
-
217
- def generate_intent(project_root: str, only_modules: set | None = None) -> None:
218
- """Generate root INTENT.md and per-module INTENT.md files.
219
-
220
- If *only_modules* is provided, only regenerate views for those modules
221
- (incremental mode). The root INTENT.md is always regenerated when any
222
- module is affected.
223
- """
224
- abs_root = os.path.abspath(project_root)
225
- conn = get_connection(abs_root)
226
- try:
227
- modules = get_modules(conn)
228
- if not modules:
229
- print("No symbols found in database. Run the indexer first.", file=sys.stderr)
230
- conn.close()
231
- return
232
-
233
- project_name = Path(abs_root).name
234
-
235
- # Determine which modules to regenerate
236
- target_modules = set(modules.keys())
237
- if only_modules:
238
- target_modules = {m for m in modules if m in only_modules}
239
-
240
- # Always regenerate root INTENT.md when any module is touched
241
- if target_modules or not only_modules:
242
- _write_root_intent(conn, abs_root, project_name, modules)
243
-
244
- for module in target_modules:
245
- # Root-level files (module=".") are covered by the root INTENT.md
246
- # written above — skip to avoid overwriting it.
247
- if module == ".":
248
- continue
249
- files = modules[module]
250
- symbols = _get_symbols_for_module(conn, module, files)
251
- _write_module_intent(conn, abs_root, module, symbols)
252
-
253
- print(
254
- f"Generated INTENT.md for {len(target_modules)} module(s) + root.",
255
- file=sys.stderr,
256
- )
257
- finally:
258
- conn.close()
259
-
260
-
261
- def _write_root_intent(conn, project_root: str, project_name: str, modules: dict) -> None:
262
- """Write the root-level INTENT.md."""
263
- rows = []
264
- for module, files in sorted(modules.items()):
265
- symbols = _get_symbols_for_module(conn, module, files)
266
- purpose = _infer_purpose(module, symbols)
267
- top = _top_symbols(symbols)
268
- key_fns = ", ".join(s["name"] for s in top) if top else "—"
269
- display = module if module != "." else "(root)"
270
- rows.append(f"| `{display}` | {purpose} | {key_fns} |")
271
-
272
- module_table = "\n".join(rows) if rows else "| — | No modules found | — |"
273
-
274
- content = f"""# {project_name} — Intent
275
-
276
- ## Vision
277
-
278
- {project_name} is a codebase with {len(modules)} module(s). The structure below summarises each module's purpose and key entry points as derived from the code graph.
279
-
280
- ## Architecture Decisions
281
-
282
- | Decision | Choice | Reasoning |
283
- |---|---|---|
284
- | Code indexing | SQLite + FTS5 | Persistent, queryable graph without external dependencies |
285
- | Symbol extraction | tree-sitter | Language-agnostic AST parsing with multi-language support |
286
- | Edge extraction | Aider-style def/ref with tags.scm | Reliable cross-language reference detection |
287
- | Ranking | fast-pagerank with scipy sparse matrices | Hybrid file-level PageRank + symbol-level blast radius |
288
- | Schema | 5-table (files, symbols, refs, file_edges, symbol_edges) | Separated concerns for file-level and symbol-level analysis |
289
- | View generation | Markdown + Mermaid | Human-readable output compatible with most documentation tools |
290
-
291
- ## Module Map
292
-
293
- | Module | Purpose | Key Functions |
294
- |---|---|---|
295
- {module_table}
296
- """
297
-
298
- out_path = os.path.join(project_root, "INTENT.md")
299
- _write_file(out_path, content)
300
-
301
-
302
- def _write_module_intent(conn, project_root: str, module: str, symbols: list) -> None:
303
- """Write a per-module INTENT.md inside the module directory."""
304
- if not symbols:
305
- return
306
-
307
- module_name = module if module != "." else Path(project_root).name
308
-
309
- # Build function entries
310
- entries = []
311
- for sym in symbols:
312
- if sym["kind"] not in ("function", "method", "class", "definition"):
313
- continue
314
-
315
- does = _infer_function_does(sym)
316
- callers = _get_callers(conn, sym["id"])
317
- callees = _get_callees(conn, sym["id"])
318
- ref_count = _get_ref_count(conn, sym["name"])
319
-
320
- called_by_str = ", ".join(c["name"] for c in callers) if callers else "none found"
321
- calls_str = ", ".join(c["name"] for c in callees) if callees else "none found"
322
-
323
- entry = f"""## {sym["name"]}
324
- - **Does**: {does}
325
- - **Why**: Supports the `{module_name}` module's responsibilities.
326
- - **Relationships**: calls [{calls_str}], called by [{called_by_str}]
327
- - **References**: {ref_count} reference(s) across codebase
328
- - **Decisions**: `{sym.get("signature", "") or sym["name"]}` (line {sym.get("start_line", "?")} – {sym.get("end_line", "?")})
329
- """
330
- entries.append(entry)
331
-
332
- if not entries:
333
- return
334
-
335
- content = f"# {module_name} — Intent\n\n" + "\n".join(entries)
336
-
337
- if module == ".":
338
- out_path = os.path.join(project_root, "INTENT.md")
339
- else:
340
- module_dir = os.path.join(project_root, module)
341
- os.makedirs(module_dir, exist_ok=True)
342
- out_path = os.path.join(module_dir, "INTENT.md")
343
-
344
- _write_file(out_path, content)
345
-
346
-
347
- # ---------------------------------------------------------------------------
348
- # ARCHITECTURE.mmd / DIAGRAM.mmd generation
349
- # ---------------------------------------------------------------------------
350
-
351
-
352
- def generate_diagrams(project_root: str, only_modules: set | None = None) -> None:
353
- """Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.
354
-
355
- If *only_modules* is provided, only regenerate views for those modules.
356
- The root diagram is always regenerated when any module is affected.
357
- """
358
- abs_root = os.path.abspath(project_root)
359
- conn = get_connection(abs_root)
360
- try:
361
- modules = get_modules(conn)
362
- if not modules:
363
- print("No symbols found in database. Run the indexer first.", file=sys.stderr)
364
- conn.close()
365
- return
366
-
367
- target_modules = set(modules.keys())
368
- if only_modules:
369
- target_modules = {m for m in modules if m in only_modules}
370
-
371
- if target_modules or not only_modules:
372
- _write_root_diagram(conn, abs_root, modules)
373
-
374
- for module in target_modules:
375
- # Root-level files (module=".") are covered by ARCHITECTURE.mmd
376
- # written above — skip to avoid overwriting it.
377
- if module == ".":
378
- continue
379
- files = modules[module]
380
- symbols = _get_symbols_for_module(conn, module, files)
381
- _write_module_diagram(conn, abs_root, module, symbols)
382
-
383
- print(
384
- f"Generated diagrams for {len(target_modules)} module(s) + root.",
385
- file=sys.stderr,
386
- )
387
- finally:
388
- conn.close()
389
-
390
-
391
- def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
392
- """Write root ARCHITECTURE.mmd showing module-level dependencies.
393
-
394
- Uses the file_edges table for module-level dependency information,
395
- which is more efficient than walking symbol-level edges.
396
- """
397
- module_list = sorted(modules.keys())
398
-
399
- # Build a file_path -> module lookup
400
- file_to_module = {}
401
- for module, files in modules.items():
402
- for fp in files:
403
- file_to_module[fp] = module
404
-
405
- # Query file_edges and aggregate into module-level dependencies
406
- module_deps: dict = defaultdict(set)
407
- rows = conn.execute(
408
- """
409
- SELECT sf.path AS source_path, tf.path AS target_path, fe.weight
410
- FROM file_edges fe
411
- JOIN files sf ON sf.id = fe.source_file_id
412
- JOIN files tf ON tf.id = fe.target_file_id
413
- """
414
- ).fetchall()
415
-
416
- for row in rows:
417
- src_module = _get_module_for_path(row["source_path"])
418
- tgt_module = _get_module_for_path(row["target_path"])
419
- if src_module != tgt_module:
420
- module_deps[src_module].add(tgt_module)
421
-
422
- # Build mermaid lines
423
- lines = ["graph LR"]
424
-
425
- # Node declarations
426
- for m in module_list:
427
- safe_id = _mermaid_id(m)
428
- label = m if m != "." else "(root)"
429
- lines.append(f" {safe_id}[{label}]")
430
-
431
- # Edge declarations
432
- edge_added = False
433
- for src_module in sorted(module_deps.keys()):
434
- for tgt_module in sorted(module_deps[src_module]):
435
- if tgt_module in modules:
436
- src_id = _mermaid_id(src_module)
437
- tgt_id = _mermaid_id(tgt_module)
438
- lines.append(f" {src_id} --> {tgt_id}")
439
- edge_added = True
440
-
441
- if not edge_added and len(module_list) > 1:
442
- # No edges detected — add a comment so the diagram is still valid
443
- lines.append(" %% No inter-module dependencies detected in index")
444
-
445
- content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
446
- out_path = os.path.join(project_root, "ARCHITECTURE.mmd")
447
- _write_file(out_path, content)
448
-
449
-
450
- def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -> None:
451
- """Write per-module DIAGRAM.mmd showing function-level call graph.
452
-
453
- Uses symbol_edges for intra-module edges.
454
- """
455
- if not symbols:
456
- return
457
-
458
- # Collect symbol IDs and names in this module
459
- sym_ids = {s["id"] for s in symbols}
460
- sym_names = {s["id"]: s["name"] for s in symbols}
461
-
462
- lines = ["graph TD"]
463
-
464
- # Node declarations for all symbols with interesting kinds
465
- interesting = [s for s in symbols if s["kind"] in ("function", "method", "class", "definition")]
466
- if not interesting:
467
- interesting = symbols
468
-
469
- for sym in interesting:
470
- safe_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
471
- lines.append(f" {safe_id}[{sym['name']}]")
472
-
473
- # Edge declarations — only intra-module edges via symbol_edges
474
- edges_added = False
475
- for sym in interesting:
476
- callees = _get_callees(conn, sym["id"])
477
- src_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
478
- for callee_row in callees:
479
- # Find callee in this module's symbol set
480
- matching = [s for s in interesting if s["name"] == callee_row["name"]]
481
- for tgt_sym in matching:
482
- tgt_id = _mermaid_id(f"{tgt_sym['name']}_{tgt_sym['id']}")
483
- lines.append(f" {src_id} --> {tgt_id}")
484
- edges_added = True
485
-
486
- if not edges_added and len(interesting) > 1:
487
- lines.append(" %% No intra-module call edges detected in index")
488
-
489
- content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
490
-
491
- if module == ".":
492
- out_path = os.path.join(project_root, "DIAGRAM.mmd")
493
- else:
494
- module_dir = os.path.join(project_root, module)
495
- os.makedirs(module_dir, exist_ok=True)
496
- out_path = os.path.join(module_dir, "DIAGRAM.mmd")
497
-
498
- _write_file(out_path, content)
499
-
500
-
501
- # ---------------------------------------------------------------------------
502
- # Shared helpers
503
- # ---------------------------------------------------------------------------
504
-
505
-
506
- def _mermaid_id(text: str) -> str:
507
- """Convert arbitrary text to a safe Mermaid node ID."""
508
- safe = ""
509
- for ch in text:
510
- if ch.isalnum() or ch == "_":
511
- safe += ch
512
- else:
513
- safe += "_"
514
- # Mermaid IDs cannot start with a digit
515
- if safe and safe[0].isdigit():
516
- safe = "_" + safe
517
- return safe or "_unknown"
518
-
519
-
520
- def _write_file(path: str, content: str) -> None:
521
- """Write content to path, creating parent directories as needed."""
522
- os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
523
- with open(path, "w", encoding="utf-8") as fh:
524
- fh.write(content)
525
-
526
-
527
- def _files_to_modules(files_str: str) -> set:
528
- """Convert a comma-separated file list to a set of affected module names."""
529
- raw = [f.strip() for f in files_str.split(",") if f.strip()]
530
- return {_get_module_for_path(f) for f in raw}
531
-
532
-
533
- # ---------------------------------------------------------------------------
534
- # CLI entry point
535
- # ---------------------------------------------------------------------------
536
-
537
-
538
- def main() -> None:
539
- parser = argparse.ArgumentParser(
540
- description="ftm-map view generators — produce INTENT.md and ARCHITECTURE.mmd from the code graph.",
541
- formatter_class=argparse.RawDescriptionHelpFormatter,
542
- epilog=(
543
- "Examples:\n"
544
- " python3 views.py --intent --project-root /path/to/project\n"
545
- " python3 views.py --diagram --project-root /path/to/project\n"
546
- " python3 views.py --intent --files src/foo.ts,src/bar.py --project-root /path/to/project\n"
547
- " python3 views.py --diagram --files src/foo.ts --project-root /path/to/project\n"
548
- ),
549
- )
550
-
551
- parser.add_argument(
552
- "--intent",
553
- action="store_true",
554
- help="Generate root INTENT.md and per-module INTENT.md files.",
555
- )
556
- parser.add_argument(
557
- "--diagram",
558
- action="store_true",
559
- help="Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.",
560
- )
561
- parser.add_argument(
562
- "--project-root",
563
- metavar="PATH",
564
- default=os.getcwd(),
565
- help="Path to the project root directory (default: cwd).",
566
- )
567
- parser.add_argument(
568
- "--files",
569
- metavar="FILE_LIST",
570
- default=None,
571
- help="Comma-separated list of changed files (incremental mode — only regenerate affected modules).",
572
- )
573
-
574
- args = parser.parse_args()
575
-
576
- if not args.intent and not args.diagram:
577
- parser.print_help()
578
- sys.exit(1)
579
-
580
- only_modules: set | None = None
581
- if args.files:
582
- only_modules = _files_to_modules(args.files)
583
-
584
- if args.intent:
585
- generate_intent(args.project_root, only_modules)
586
- if args.diagram:
587
- generate_diagrams(args.project_root, only_modules)
588
-
589
-
590
- if __name__ == "__main__":
591
- main()
1
+ #!/usr/bin/env python3
2
+ """View generators: produce INTENT.md and ARCHITECTURE.mmd from the code graph.
3
+
4
+ Updated for v2 hybrid architecture with 5-table schema:
5
+ files, symbols, refs, file_edges, symbol_edges
6
+ """
7
+
8
+ import argparse
9
+ import os
10
+ import sys
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, os.path.dirname(__file__))
15
+ from db import get_connection
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Module grouping helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def _get_module_for_path(file_path: str) -> str:
23
+ """Return the top-level directory component of a relative file path.
24
+
25
+ Files at the project root (no directory component) are grouped under '.'.
26
+ """
27
+ parts = Path(file_path).parts
28
+ if len(parts) > 1:
29
+ return parts[0]
30
+ return "."
31
+
32
+
33
+ def get_modules(conn) -> dict:
34
+ """Group files by directory to identify modules.
35
+
36
+ Queries the files table directly (symbols no longer carry file_path).
37
+ Returns a dict mapping module name -> set of file paths.
38
+ """
39
+ rows = conn.execute(
40
+ "SELECT DISTINCT path FROM files ORDER BY path"
41
+ ).fetchall()
42
+
43
+ modules: dict = defaultdict(set)
44
+ for row in rows:
45
+ fp = row["path"]
46
+ module = _get_module_for_path(fp)
47
+ modules[module].add(fp)
48
+
49
+ return dict(modules)
50
+
51
+
52
+ def _get_symbols_for_module(conn, module: str, files: set) -> list:
53
+ """Return all symbol rows for a module (identified by its set of files).
54
+
55
+ Joins symbols with files to resolve file_path and maps column names
56
+ to the view-layer conventions (file_path, start_line, end_line).
57
+ """
58
+ placeholders = ",".join("?" * len(files))
59
+ rows = conn.execute(
60
+ f"""
61
+ SELECT s.id, s.name, s.qualified_name, s.kind,
62
+ s.line_start AS start_line, s.line_end AS end_line,
63
+ s.signature, s.parent_id,
64
+ f.path AS file_path
65
+ FROM symbols s
66
+ JOIN files f ON f.id = s.file_id
67
+ WHERE f.path IN ({placeholders})
68
+ ORDER BY f.path, s.line_start
69
+ """,
70
+ list(files),
71
+ ).fetchall()
72
+ return [dict(r) for r in rows]
73
+
74
+
75
+ def _get_callers(conn, symbol_id: int) -> list:
76
+ """Return direct callers (symbols that call this one) via symbol_edges."""
77
+ rows = conn.execute(
78
+ """
79
+ SELECT s.name, f.path AS file_path
80
+ FROM symbol_edges se
81
+ JOIN symbols s ON s.id = se.source_symbol_id
82
+ JOIN files f ON f.id = s.file_id
83
+ WHERE se.target_symbol_id = ?
84
+ LIMIT 10
85
+ """,
86
+ (symbol_id,),
87
+ ).fetchall()
88
+ return [dict(r) for r in rows]
89
+
90
+
91
+ def _get_callees(conn, symbol_id: int) -> list:
92
+ """Return direct callees (symbols this one calls) via symbol_edges."""
93
+ rows = conn.execute(
94
+ """
95
+ SELECT s.name, f.path AS file_path
96
+ FROM symbol_edges se
97
+ JOIN symbols s ON s.id = se.target_symbol_id
98
+ JOIN files f ON f.id = s.file_id
99
+ WHERE se.source_symbol_id = ?
100
+ LIMIT 10
101
+ """,
102
+ (symbol_id,),
103
+ ).fetchall()
104
+ return [dict(r) for r in rows]
105
+
106
+
107
+ def _get_ref_count(conn, symbol_name: str) -> int:
108
+ """Return the number of references to a symbol from the refs table."""
109
+ row = conn.execute(
110
+ "SELECT COUNT(*) AS cnt FROM refs WHERE symbol_name = ?",
111
+ (symbol_name,),
112
+ ).fetchone()
113
+ return row["cnt"] if row else 0
114
+
115
+
116
+ def _top_symbols(symbols: list, n: int = 5) -> list:
117
+ """Return top n function/method symbols from a list, falling back to any kind."""
118
+ funcs = [s for s in symbols if s["kind"] in ("function", "method", "definition")]
119
+ selection = funcs if funcs else symbols
120
+ return selection[:n]
121
+
122
+
123
+ def _infer_purpose(module: str, symbols: list) -> str:
124
+ """Infer a one-line purpose description from module name and symbol kinds."""
125
+ if not symbols:
126
+ return "Empty module — no symbols indexed yet."
127
+
128
+ kinds = [s["kind"] for s in symbols]
129
+ kind_counts: dict = defaultdict(int)
130
+ for k in kinds:
131
+ kind_counts[k] += 1
132
+
133
+ dominant = sorted(kind_counts.items(), key=lambda x: x[1], reverse=True)[0][0]
134
+
135
+ name_lower = module.lower()
136
+ if any(kw in name_lower for kw in ("test", "spec", "__tests__")):
137
+ return "Test suite."
138
+ if any(kw in name_lower for kw in ("util", "helper", "common", "shared")):
139
+ return "Shared utilities and helpers."
140
+ if any(kw in name_lower for kw in ("model", "schema", "entity", "type")):
141
+ return "Data models and type definitions."
142
+ if any(kw in name_lower for kw in ("route", "api", "handler", "endpoint")):
143
+ return "API routes and request handlers."
144
+ if any(kw in name_lower for kw in ("db", "database", "repo", "store")):
145
+ return "Data access and persistence layer."
146
+ if any(kw in name_lower for kw in ("config", "setting", "env")):
147
+ return "Configuration and environment settings."
148
+ if any(kw in name_lower for kw in ("service", "manager", "controller")):
149
+ return "Business logic and service layer."
150
+ if any(kw in name_lower for kw in ("component", "view", "page", "ui")):
151
+ return "UI components and views."
152
+
153
+ if dominant == "class":
154
+ return f"Module defining {kind_counts['class']} class(es)."
155
+ if dominant == "function":
156
+ return f"Module with {kind_counts['function']} function(s)."
157
+ if dominant == "definition":
158
+ return f"Module with {kind_counts['definition']} definition(s)."
159
+ return f"Module containing {len(symbols)} symbols."
160
+
161
+
162
+ def _infer_function_does(sym: dict) -> str:
163
+ """Infer what a function does from its name and signature."""
164
+ doc = (sym.get("doc_comment") or "").strip()
165
+ if doc:
166
+ first_sentence = doc.split(".")[0].strip()
167
+ if first_sentence:
168
+ return first_sentence + "."
169
+
170
+ sig = (sym.get("signature") or "").strip()
171
+ name = sym.get("name", "")
172
+
173
+ name_lower = name.lower()
174
+ if name_lower.startswith("get_") or name_lower.startswith("fetch_"):
175
+ subject = name_lower[4:].replace("_", " ")
176
+ return f"Retrieves {subject}."
177
+ if name_lower.startswith("set_") or name_lower.startswith("update_"):
178
+ subject = name_lower[4:].replace("_", " ")
179
+ return f"Updates {subject}."
180
+ if name_lower.startswith("create_") or name_lower.startswith("add_"):
181
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
182
+ return f"Creates or adds {subject}."
183
+ if name_lower.startswith("delete_") or name_lower.startswith("remove_"):
184
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
185
+ return f"Removes {subject}."
186
+ if name_lower.startswith("is_") or name_lower.startswith("has_") or name_lower.startswith("check_"):
187
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
188
+ return f"Checks whether {subject}."
189
+ if name_lower.startswith("parse_") or name_lower.startswith("decode_"):
190
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
191
+ return f"Parses {subject}."
192
+ if name_lower.startswith("render_") or name_lower.startswith("format_"):
193
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
194
+ return f"Formats or renders {subject}."
195
+ if name_lower.startswith("handle_") or name_lower.startswith("on_"):
196
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
197
+ return f"Handles {subject} event."
198
+ if name_lower.startswith("init") or name_lower.startswith("setup") or name_lower.startswith("bootstrap"):
199
+ return "Initializes and configures the component."
200
+ if name_lower in ("main", "__main__"):
201
+ return "Entry point for the module."
202
+ if name_lower.startswith("test_"):
203
+ subject = name_lower[5:].replace("_", " ")
204
+ return f"Tests {subject}."
205
+
206
+ if sig:
207
+ return f"Executes `{sig[:80]}`."
208
+
209
+ return f"Implements `{name}` logic."
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # INTENT.md generation
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def generate_intent(project_root: str, only_modules: set | None = None) -> None:
218
+ """Generate root INTENT.md and per-module INTENT.md files.
219
+
220
+ If *only_modules* is provided, only regenerate views for those modules
221
+ (incremental mode). The root INTENT.md is always regenerated when any
222
+ module is affected.
223
+ """
224
+ abs_root = os.path.abspath(project_root)
225
+ conn = get_connection(abs_root)
226
+ try:
227
+ modules = get_modules(conn)
228
+ if not modules:
229
+ print("No symbols found in database. Run the indexer first.", file=sys.stderr)
230
+ conn.close()
231
+ return
232
+
233
+ project_name = Path(abs_root).name
234
+
235
+ # Determine which modules to regenerate
236
+ target_modules = set(modules.keys())
237
+ if only_modules:
238
+ target_modules = {m for m in modules if m in only_modules}
239
+
240
+ # Always regenerate root INTENT.md when any module is touched
241
+ if target_modules or not only_modules:
242
+ _write_root_intent(conn, abs_root, project_name, modules)
243
+
244
+ for module in target_modules:
245
+ # Root-level files (module=".") are covered by the root INTENT.md
246
+ # written above — skip to avoid overwriting it.
247
+ if module == ".":
248
+ continue
249
+ files = modules[module]
250
+ symbols = _get_symbols_for_module(conn, module, files)
251
+ _write_module_intent(conn, abs_root, module, symbols)
252
+
253
+ print(
254
+ f"Generated INTENT.md for {len(target_modules)} module(s) + root.",
255
+ file=sys.stderr,
256
+ )
257
+ finally:
258
+ conn.close()
259
+
260
+
261
+ def _write_root_intent(conn, project_root: str, project_name: str, modules: dict) -> None:
262
+ """Write the root-level INTENT.md."""
263
+ rows = []
264
+ for module, files in sorted(modules.items()):
265
+ symbols = _get_symbols_for_module(conn, module, files)
266
+ purpose = _infer_purpose(module, symbols)
267
+ top = _top_symbols(symbols)
268
+ key_fns = ", ".join(s["name"] for s in top) if top else "—"
269
+ display = module if module != "." else "(root)"
270
+ rows.append(f"| `{display}` | {purpose} | {key_fns} |")
271
+
272
+ module_table = "\n".join(rows) if rows else "| — | No modules found | — |"
273
+
274
+ content = f"""# {project_name} — Intent
275
+
276
+ ## Vision
277
+
278
+ {project_name} is a codebase with {len(modules)} module(s). The structure below summarises each module's purpose and key entry points as derived from the code graph.
279
+
280
+ ## Architecture Decisions
281
+
282
+ | Decision | Choice | Reasoning |
283
+ |---|---|---|
284
+ | Code indexing | SQLite + FTS5 | Persistent, queryable graph without external dependencies |
285
+ | Symbol extraction | tree-sitter | Language-agnostic AST parsing with multi-language support |
286
+ | Edge extraction | Aider-style def/ref with tags.scm | Reliable cross-language reference detection |
287
+ | Ranking | fast-pagerank with scipy sparse matrices | Hybrid file-level PageRank + symbol-level blast radius |
288
+ | Schema | 5-table (files, symbols, refs, file_edges, symbol_edges) | Separated concerns for file-level and symbol-level analysis |
289
+ | View generation | Markdown + Mermaid | Human-readable output compatible with most documentation tools |
290
+
291
+ ## Module Map
292
+
293
+ | Module | Purpose | Key Functions |
294
+ |---|---|---|
295
+ {module_table}
296
+ """
297
+
298
+ out_path = os.path.join(project_root, "INTENT.md")
299
+ _write_file(out_path, content)
300
+
301
+
302
+ def _write_module_intent(conn, project_root: str, module: str, symbols: list) -> None:
303
+ """Write a per-module INTENT.md inside the module directory."""
304
+ if not symbols:
305
+ return
306
+
307
+ module_name = module if module != "." else Path(project_root).name
308
+
309
+ # Build function entries
310
+ entries = []
311
+ for sym in symbols:
312
+ if sym["kind"] not in ("function", "method", "class", "definition"):
313
+ continue
314
+
315
+ does = _infer_function_does(sym)
316
+ callers = _get_callers(conn, sym["id"])
317
+ callees = _get_callees(conn, sym["id"])
318
+ ref_count = _get_ref_count(conn, sym["name"])
319
+
320
+ called_by_str = ", ".join(c["name"] for c in callers) if callers else "none found"
321
+ calls_str = ", ".join(c["name"] for c in callees) if callees else "none found"
322
+
323
+ entry = f"""## {sym["name"]}
324
+ - **Does**: {does}
325
+ - **Why**: Supports the `{module_name}` module's responsibilities.
326
+ - **Relationships**: calls [{calls_str}], called by [{called_by_str}]
327
+ - **References**: {ref_count} reference(s) across codebase
328
+ - **Decisions**: `{sym.get("signature", "") or sym["name"]}` (line {sym.get("start_line", "?")} – {sym.get("end_line", "?")})
329
+ """
330
+ entries.append(entry)
331
+
332
+ if not entries:
333
+ return
334
+
335
+ content = f"# {module_name} — Intent\n\n" + "\n".join(entries)
336
+
337
+ if module == ".":
338
+ out_path = os.path.join(project_root, "INTENT.md")
339
+ else:
340
+ module_dir = os.path.join(project_root, module)
341
+ os.makedirs(module_dir, exist_ok=True)
342
+ out_path = os.path.join(module_dir, "INTENT.md")
343
+
344
+ _write_file(out_path, content)
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # ARCHITECTURE.mmd / DIAGRAM.mmd generation
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ def generate_diagrams(project_root: str, only_modules: set | None = None) -> None:
353
+ """Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.
354
+
355
+ If *only_modules* is provided, only regenerate views for those modules.
356
+ The root diagram is always regenerated when any module is affected.
357
+ """
358
+ abs_root = os.path.abspath(project_root)
359
+ conn = get_connection(abs_root)
360
+ try:
361
+ modules = get_modules(conn)
362
+ if not modules:
363
+ print("No symbols found in database. Run the indexer first.", file=sys.stderr)
364
+ conn.close()
365
+ return
366
+
367
+ target_modules = set(modules.keys())
368
+ if only_modules:
369
+ target_modules = {m for m in modules if m in only_modules}
370
+
371
+ if target_modules or not only_modules:
372
+ _write_root_diagram(conn, abs_root, modules)
373
+
374
+ for module in target_modules:
375
+ # Root-level files (module=".") are covered by ARCHITECTURE.mmd
376
+ # written above — skip to avoid overwriting it.
377
+ if module == ".":
378
+ continue
379
+ files = modules[module]
380
+ symbols = _get_symbols_for_module(conn, module, files)
381
+ _write_module_diagram(conn, abs_root, module, symbols)
382
+
383
+ print(
384
+ f"Generated diagrams for {len(target_modules)} module(s) + root.",
385
+ file=sys.stderr,
386
+ )
387
+ finally:
388
+ conn.close()
389
+
390
+
391
+ def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
392
+ """Write root ARCHITECTURE.mmd showing module-level dependencies.
393
+
394
+ Uses the file_edges table for module-level dependency information,
395
+ which is more efficient than walking symbol-level edges.
396
+ """
397
+ module_list = sorted(modules.keys())
398
+
399
+ # Build a file_path -> module lookup
400
+ file_to_module = {}
401
+ for module, files in modules.items():
402
+ for fp in files:
403
+ file_to_module[fp] = module
404
+
405
+ # Query file_edges and aggregate into module-level dependencies
406
+ module_deps: dict = defaultdict(set)
407
+ rows = conn.execute(
408
+ """
409
+ SELECT sf.path AS source_path, tf.path AS target_path, fe.weight
410
+ FROM file_edges fe
411
+ JOIN files sf ON sf.id = fe.source_file_id
412
+ JOIN files tf ON tf.id = fe.target_file_id
413
+ """
414
+ ).fetchall()
415
+
416
+ for row in rows:
417
+ src_module = _get_module_for_path(row["source_path"])
418
+ tgt_module = _get_module_for_path(row["target_path"])
419
+ if src_module != tgt_module:
420
+ module_deps[src_module].add(tgt_module)
421
+
422
+ # Build mermaid lines
423
+ lines = ["graph LR"]
424
+
425
+ # Node declarations
426
+ for m in module_list:
427
+ safe_id = _mermaid_id(m)
428
+ label = m if m != "." else "(root)"
429
+ lines.append(f" {safe_id}[{label}]")
430
+
431
+ # Edge declarations
432
+ edge_added = False
433
+ for src_module in sorted(module_deps.keys()):
434
+ for tgt_module in sorted(module_deps[src_module]):
435
+ if tgt_module in modules:
436
+ src_id = _mermaid_id(src_module)
437
+ tgt_id = _mermaid_id(tgt_module)
438
+ lines.append(f" {src_id} --> {tgt_id}")
439
+ edge_added = True
440
+
441
+ if not edge_added and len(module_list) > 1:
442
+ # No edges detected — add a comment so the diagram is still valid
443
+ lines.append(" %% No inter-module dependencies detected in index")
444
+
445
+ content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
446
+ out_path = os.path.join(project_root, "ARCHITECTURE.mmd")
447
+ _write_file(out_path, content)
448
+
449
+
450
+ def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -> None:
451
+ """Write per-module DIAGRAM.mmd showing function-level call graph.
452
+
453
+ Uses symbol_edges for intra-module edges.
454
+ """
455
+ if not symbols:
456
+ return
457
+
458
+ # Collect symbol IDs and names in this module
459
+ sym_ids = {s["id"] for s in symbols}
460
+ sym_names = {s["id"]: s["name"] for s in symbols}
461
+
462
+ lines = ["graph TD"]
463
+
464
+ # Node declarations for all symbols with interesting kinds
465
+ interesting = [s for s in symbols if s["kind"] in ("function", "method", "class", "definition")]
466
+ if not interesting:
467
+ interesting = symbols
468
+
469
+ for sym in interesting:
470
+ safe_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
471
+ lines.append(f" {safe_id}[{sym['name']}]")
472
+
473
+ # Edge declarations — only intra-module edges via symbol_edges
474
+ edges_added = False
475
+ for sym in interesting:
476
+ callees = _get_callees(conn, sym["id"])
477
+ src_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
478
+ for callee_row in callees:
479
+ # Find callee in this module's symbol set
480
+ matching = [s for s in interesting if s["name"] == callee_row["name"]]
481
+ for tgt_sym in matching:
482
+ tgt_id = _mermaid_id(f"{tgt_sym['name']}_{tgt_sym['id']}")
483
+ lines.append(f" {src_id} --> {tgt_id}")
484
+ edges_added = True
485
+
486
+ if not edges_added and len(interesting) > 1:
487
+ lines.append(" %% No intra-module call edges detected in index")
488
+
489
+ content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
490
+
491
+ if module == ".":
492
+ out_path = os.path.join(project_root, "DIAGRAM.mmd")
493
+ else:
494
+ module_dir = os.path.join(project_root, module)
495
+ os.makedirs(module_dir, exist_ok=True)
496
+ out_path = os.path.join(module_dir, "DIAGRAM.mmd")
497
+
498
+ _write_file(out_path, content)
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # Shared helpers
503
+ # ---------------------------------------------------------------------------
504
+
505
+
506
+ def _mermaid_id(text: str) -> str:
507
+ """Convert arbitrary text to a safe Mermaid node ID."""
508
+ safe = ""
509
+ for ch in text:
510
+ if ch.isalnum() or ch == "_":
511
+ safe += ch
512
+ else:
513
+ safe += "_"
514
+ # Mermaid IDs cannot start with a digit
515
+ if safe and safe[0].isdigit():
516
+ safe = "_" + safe
517
+ return safe or "_unknown"
518
+
519
+
520
+ def _write_file(path: str, content: str) -> None:
521
+ """Write content to path, creating parent directories as needed."""
522
+ os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
523
+ with open(path, "w", encoding="utf-8") as fh:
524
+ fh.write(content)
525
+
526
+
527
+ def _files_to_modules(files_str: str) -> set:
528
+ """Convert a comma-separated file list to a set of affected module names."""
529
+ raw = [f.strip() for f in files_str.split(",") if f.strip()]
530
+ return {_get_module_for_path(f) for f in raw}
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # CLI entry point
535
+ # ---------------------------------------------------------------------------
536
+
537
+
538
+ def main() -> None:
539
+ parser = argparse.ArgumentParser(
540
+ description="ftm-map view generators — produce INTENT.md and ARCHITECTURE.mmd from the code graph.",
541
+ formatter_class=argparse.RawDescriptionHelpFormatter,
542
+ epilog=(
543
+ "Examples:\n"
544
+ " python3 views.py --intent --project-root /path/to/project\n"
545
+ " python3 views.py --diagram --project-root /path/to/project\n"
546
+ " python3 views.py --intent --files src/foo.ts,src/bar.py --project-root /path/to/project\n"
547
+ " python3 views.py --diagram --files src/foo.ts --project-root /path/to/project\n"
548
+ ),
549
+ )
550
+
551
+ parser.add_argument(
552
+ "--intent",
553
+ action="store_true",
554
+ help="Generate root INTENT.md and per-module INTENT.md files.",
555
+ )
556
+ parser.add_argument(
557
+ "--diagram",
558
+ action="store_true",
559
+ help="Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.",
560
+ )
561
+ parser.add_argument(
562
+ "--project-root",
563
+ metavar="PATH",
564
+ default=os.getcwd(),
565
+ help="Path to the project root directory (default: cwd).",
566
+ )
567
+ parser.add_argument(
568
+ "--files",
569
+ metavar="FILE_LIST",
570
+ default=None,
571
+ help="Comma-separated list of changed files (incremental mode — only regenerate affected modules).",
572
+ )
573
+
574
+ args = parser.parse_args()
575
+
576
+ if not args.intent and not args.diagram:
577
+ parser.print_help()
578
+ sys.exit(1)
579
+
580
+ only_modules: set | None = None
581
+ if args.files:
582
+ only_modules = _files_to_modules(args.files)
583
+
584
+ if args.intent:
585
+ generate_intent(args.project_root, only_modules)
586
+ if args.diagram:
587
+ generate_diagrams(args.project_root, only_modules)
588
+
589
+
590
+ if __name__ == "__main__":
591
+ main()