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