feed-the-machine 1.6.0 → 1.7.0

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 (269) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +170 -170
  3. package/bin/brain.py +1340 -0
  4. package/bin/convert_claude_skills_to_codex.py +490 -0
  5. package/bin/generate-manifest.mjs +463 -463
  6. package/bin/harden_codex_skills.py +141 -0
  7. package/bin/install.mjs +491 -491
  8. package/bin/migrate-eng-buddy-data.py +875 -0
  9. package/bin/playbook_engine/__init__.py +1 -0
  10. package/bin/playbook_engine/conftest.py +8 -0
  11. package/bin/playbook_engine/extractor.py +33 -0
  12. package/bin/playbook_engine/manager.py +102 -0
  13. package/bin/playbook_engine/models.py +84 -0
  14. package/bin/playbook_engine/registry.py +35 -0
  15. package/bin/playbook_engine/test_extractor.py +72 -0
  16. package/bin/playbook_engine/test_integration.py +129 -0
  17. package/bin/playbook_engine/test_manager.py +85 -0
  18. package/bin/playbook_engine/test_models.py +166 -0
  19. package/bin/playbook_engine/test_registry.py +67 -0
  20. package/bin/playbook_engine/test_tracer.py +86 -0
  21. package/bin/playbook_engine/tracer.py +93 -0
  22. package/bin/tasks_db.py +456 -0
  23. package/docs/HOOKS.md +243 -243
  24. package/docs/INBOX.md +233 -233
  25. package/ftm/SKILL.md +125 -122
  26. package/ftm-audit/SKILL.md +623 -623
  27. package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
  28. package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
  29. package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
  30. package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
  31. package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
  32. package/ftm-audit/scripts/run-knip.sh +23 -23
  33. package/ftm-audit.yml +2 -2
  34. package/ftm-brainstorm/SKILL.md +1003 -498
  35. package/ftm-brainstorm/evals/evals.json +180 -100
  36. package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
  37. package/ftm-brainstorm/references/agent-prompts.md +552 -224
  38. package/ftm-brainstorm/references/plan-template.md +209 -121
  39. package/ftm-brainstorm.yml +2 -2
  40. package/ftm-browse/SKILL.md +454 -454
  41. package/ftm-browse/daemon/browser-manager.ts +206 -206
  42. package/ftm-browse/daemon/bun.lock +30 -30
  43. package/ftm-browse/daemon/cli.ts +347 -347
  44. package/ftm-browse/daemon/commands.ts +410 -410
  45. package/ftm-browse/daemon/main.ts +357 -357
  46. package/ftm-browse/daemon/package.json +17 -17
  47. package/ftm-browse/daemon/server.ts +189 -189
  48. package/ftm-browse/daemon/snapshot.ts +519 -519
  49. package/ftm-browse/daemon/tsconfig.json +22 -22
  50. package/ftm-browse.yml +4 -4
  51. package/ftm-capture/SKILL.md +370 -370
  52. package/ftm-capture.yml +4 -4
  53. package/ftm-codex-gate/SKILL.md +361 -361
  54. package/ftm-codex-gate.yml +2 -2
  55. package/ftm-config/SKILL.md +422 -345
  56. package/ftm-config.default.yml +125 -82
  57. package/ftm-config.yml +44 -2
  58. package/ftm-council/SKILL.md +416 -416
  59. package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
  60. package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
  61. package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
  62. package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
  63. package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
  64. package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
  65. package/ftm-council.yml +2 -2
  66. package/ftm-dashboard/SKILL.md +163 -163
  67. package/ftm-dashboard.yml +4 -4
  68. package/ftm-debug/SKILL.md +1037 -1037
  69. package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
  70. package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
  71. package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
  72. package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
  73. package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
  74. package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
  75. package/ftm-debug.yml +2 -2
  76. package/ftm-diagram/SKILL.md +277 -277
  77. package/ftm-diagram.yml +2 -2
  78. package/ftm-executor/SKILL.md +777 -777
  79. package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
  80. package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
  81. package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
  82. package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
  83. package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
  84. package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
  85. package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
  86. package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
  87. package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
  88. package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
  89. package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
  90. package/ftm-executor/runtime/package.json +8 -8
  91. package/ftm-executor.yml +2 -2
  92. package/ftm-git/SKILL.md +441 -441
  93. package/ftm-git/evals/evals.json +26 -26
  94. package/ftm-git/evals/promptfoo.yaml +75 -75
  95. package/ftm-git/hooks/post-commit-experience.sh +92 -92
  96. package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
  97. package/ftm-git/references/protocols/REMEDIATION.md +139 -139
  98. package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
  99. package/ftm-git.yml +2 -2
  100. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  101. package/ftm-inbox/backend/adapters/_retry.py +64 -64
  102. package/ftm-inbox/backend/adapters/base.py +230 -230
  103. package/ftm-inbox/backend/adapters/freshservice.py +104 -104
  104. package/ftm-inbox/backend/adapters/gmail.py +125 -125
  105. package/ftm-inbox/backend/adapters/jira.py +136 -136
  106. package/ftm-inbox/backend/adapters/registry.py +192 -192
  107. package/ftm-inbox/backend/adapters/slack.py +110 -110
  108. package/ftm-inbox/backend/db/connection.py +54 -54
  109. package/ftm-inbox/backend/db/schema.py +78 -78
  110. package/ftm-inbox/backend/executor/__init__.py +7 -7
  111. package/ftm-inbox/backend/executor/engine.py +149 -149
  112. package/ftm-inbox/backend/executor/step_runner.py +98 -98
  113. package/ftm-inbox/backend/main.py +103 -103
  114. package/ftm-inbox/backend/models/__init__.py +1 -1
  115. package/ftm-inbox/backend/models/unified_task.py +36 -36
  116. package/ftm-inbox/backend/planner/__init__.py +6 -6
  117. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  119. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  120. package/ftm-inbox/backend/planner/generator.py +127 -127
  121. package/ftm-inbox/backend/planner/schema.py +34 -34
  122. package/ftm-inbox/backend/requirements.txt +5 -5
  123. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  124. package/ftm-inbox/backend/routes/execute.py +186 -186
  125. package/ftm-inbox/backend/routes/health.py +52 -52
  126. package/ftm-inbox/backend/routes/inbox.py +68 -68
  127. package/ftm-inbox/backend/routes/plan.py +271 -271
  128. package/ftm-inbox/bin/launchagent.mjs +91 -91
  129. package/ftm-inbox/bin/setup.mjs +188 -188
  130. package/ftm-inbox/bin/start.sh +10 -10
  131. package/ftm-inbox/bin/status.sh +17 -17
  132. package/ftm-inbox/bin/stop.sh +8 -8
  133. package/ftm-inbox/config.example.yml +55 -55
  134. package/ftm-inbox/package-lock.json +2898 -2898
  135. package/ftm-inbox/package.json +26 -26
  136. package/ftm-inbox/postcss.config.js +6 -6
  137. package/ftm-inbox/src/app.css +199 -199
  138. package/ftm-inbox/src/app.html +18 -18
  139. package/ftm-inbox/src/lib/api.ts +166 -166
  140. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
  141. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
  142. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
  143. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
  144. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
  145. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
  146. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
  147. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
  148. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
  149. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
  150. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
  151. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
  152. package/ftm-inbox/src/lib/theme.ts +47 -47
  153. package/ftm-inbox/src/routes/+layout.svelte +76 -76
  154. package/ftm-inbox/src/routes/+page.svelte +401 -401
  155. package/ftm-inbox/svelte.config.js +12 -12
  156. package/ftm-inbox/tailwind.config.ts +63 -63
  157. package/ftm-inbox/tsconfig.json +13 -13
  158. package/ftm-inbox/vite.config.ts +6 -6
  159. package/ftm-intent/SKILL.md +241 -241
  160. package/ftm-intent.yml +2 -2
  161. package/ftm-manifest.json +3794 -3794
  162. package/ftm-map/SKILL.md +291 -291
  163. package/ftm-map/scripts/db.py +712 -712
  164. package/ftm-map/scripts/index.py +415 -415
  165. package/ftm-map/scripts/parser.py +224 -224
  166. package/ftm-map/scripts/queries/go-tags.scm +20 -20
  167. package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
  168. package/ftm-map/scripts/queries/python-tags.scm +31 -31
  169. package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
  170. package/ftm-map/scripts/queries/rust-tags.scm +37 -37
  171. package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
  172. package/ftm-map/scripts/query.py +301 -301
  173. package/ftm-map/scripts/ranker.py +377 -377
  174. package/ftm-map/scripts/requirements.txt +5 -5
  175. package/ftm-map/scripts/setup-hooks.sh +27 -27
  176. package/ftm-map/scripts/setup.sh +56 -56
  177. package/ftm-map/scripts/test_db.py +364 -364
  178. package/ftm-map/scripts/test_parser.py +174 -174
  179. package/ftm-map/scripts/test_query.py +183 -183
  180. package/ftm-map/scripts/test_ranker.py +199 -199
  181. package/ftm-map/scripts/views.py +591 -591
  182. package/ftm-map.yml +2 -2
  183. package/ftm-mind/SKILL.md +201 -1943
  184. package/ftm-mind/evals/promptfoo.yaml +142 -142
  185. package/ftm-mind/references/blackboard-protocol.md +110 -0
  186. package/ftm-mind/references/blackboard-schema.md +328 -328
  187. package/ftm-mind/references/complexity-guide.md +110 -110
  188. package/ftm-mind/references/complexity-sizing.md +138 -0
  189. package/ftm-mind/references/decide-act-protocol.md +172 -0
  190. package/ftm-mind/references/direct-execution.md +51 -0
  191. package/ftm-mind/references/environment-discovery.md +77 -0
  192. package/ftm-mind/references/event-registry.md +319 -319
  193. package/ftm-mind/references/mcp-inventory.md +300 -296
  194. package/ftm-mind/references/ops-routing.md +47 -0
  195. package/ftm-mind/references/orient-protocol.md +234 -0
  196. package/ftm-mind/references/personality.md +40 -0
  197. package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
  198. package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
  199. package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
  200. package/ftm-mind/references/reflexion-protocol.md +249 -249
  201. package/ftm-mind/references/routing/SCENARIOS.md +22 -22
  202. package/ftm-mind/references/routing-scenarios.md +35 -35
  203. package/ftm-mind.yml +2 -2
  204. package/ftm-ops.yml +4 -0
  205. package/ftm-pause/SKILL.md +395 -395
  206. package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
  207. package/ftm-pause/references/protocols/VALIDATION.md +80 -80
  208. package/ftm-pause.yml +2 -2
  209. package/ftm-researcher/SKILL.md +275 -275
  210. package/ftm-researcher/evals/agent-diversity.yaml +17 -17
  211. package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
  212. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
  213. package/ftm-researcher/references/adaptive-search.md +116 -116
  214. package/ftm-researcher/references/agent-prompts.md +193 -193
  215. package/ftm-researcher/references/council-integration.md +193 -193
  216. package/ftm-researcher/references/output-format.md +203 -203
  217. package/ftm-researcher/references/synthesis-pipeline.md +165 -165
  218. package/ftm-researcher/scripts/score_credibility.py +234 -234
  219. package/ftm-researcher/scripts/validate_research.py +92 -92
  220. package/ftm-researcher.yml +2 -2
  221. package/ftm-resume/SKILL.md +518 -518
  222. package/ftm-resume/references/protocols/VALIDATION.md +172 -172
  223. package/ftm-resume.yml +2 -2
  224. package/ftm-retro/SKILL.md +380 -380
  225. package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
  226. package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
  227. package/ftm-retro.yml +2 -2
  228. package/ftm-routine/SKILL.md +170 -170
  229. package/ftm-routine.yml +4 -4
  230. package/ftm-state/blackboard/capabilities.json +5 -5
  231. package/ftm-state/blackboard/capabilities.schema.json +27 -27
  232. package/ftm-state/blackboard/context.json +37 -23
  233. package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
  234. package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
  235. package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
  236. package/ftm-state/blackboard/experiences/index.json +58 -9
  237. package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
  238. package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
  239. package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
  240. package/ftm-state/blackboard/patterns.json +6 -6
  241. package/ftm-state/schemas/context.schema.json +130 -130
  242. package/ftm-state/schemas/experience-index.schema.json +77 -77
  243. package/ftm-state/schemas/experience.schema.json +78 -78
  244. package/ftm-state/schemas/patterns.schema.json +44 -44
  245. package/ftm-upgrade/SKILL.md +194 -194
  246. package/ftm-upgrade/scripts/check-version.sh +76 -76
  247. package/ftm-upgrade/scripts/upgrade.sh +143 -143
  248. package/ftm-upgrade.yml +2 -2
  249. package/ftm-verify.yml +2 -2
  250. package/ftm.yml +2 -2
  251. package/hooks/ftm-auto-log.sh +137 -0
  252. package/hooks/ftm-blackboard-enforcer.sh +93 -93
  253. package/hooks/ftm-discovery-reminder.sh +90 -90
  254. package/hooks/ftm-drafts-gate.sh +61 -61
  255. package/hooks/ftm-event-logger.mjs +107 -107
  256. package/hooks/ftm-install-hooks.sh +240 -0
  257. package/hooks/ftm-learning-capture.sh +117 -0
  258. package/hooks/ftm-map-autodetect.sh +79 -79
  259. package/hooks/ftm-pending-sync-check.sh +22 -22
  260. package/hooks/ftm-plan-gate.sh +92 -92
  261. package/hooks/ftm-post-commit-trigger.sh +57 -57
  262. package/hooks/ftm-post-compaction.sh +138 -0
  263. package/hooks/ftm-pre-compaction.sh +147 -0
  264. package/hooks/ftm-session-end.sh +52 -0
  265. package/hooks/ftm-session-snapshot.sh +213 -0
  266. package/hooks/settings-template.json +81 -81
  267. package/install.sh +363 -363
  268. package/package.json +84 -84
  269. 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()