feed-the-machine 1.0.0 → 1.2.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 (136) hide show
  1. package/bin/generate-manifest.mjs +253 -0
  2. package/bin/install.mjs +134 -4
  3. package/docs/HOOKS.md +243 -0
  4. package/docs/INBOX.md +233 -0
  5. package/ftm/SKILL.md +34 -0
  6. package/ftm-audit/SKILL.md +69 -0
  7. package/ftm-brainstorm/SKILL.md +51 -0
  8. package/ftm-browse/SKILL.md +39 -0
  9. package/ftm-capture/SKILL.md +370 -0
  10. package/ftm-capture.yml +4 -0
  11. package/ftm-codex-gate/SKILL.md +59 -0
  12. package/ftm-config/SKILL.md +35 -0
  13. package/ftm-council/SKILL.md +56 -0
  14. package/ftm-dashboard/SKILL.md +163 -0
  15. package/ftm-debug/SKILL.md +84 -0
  16. package/ftm-diagram/SKILL.md +44 -0
  17. package/ftm-executor/SKILL.md +97 -0
  18. package/ftm-git/SKILL.md +60 -0
  19. package/ftm-inbox/backend/__init__.py +0 -0
  20. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  21. package/ftm-inbox/backend/adapters/__init__.py +0 -0
  22. package/ftm-inbox/backend/adapters/_retry.py +64 -0
  23. package/ftm-inbox/backend/adapters/base.py +230 -0
  24. package/ftm-inbox/backend/adapters/freshservice.py +104 -0
  25. package/ftm-inbox/backend/adapters/gmail.py +125 -0
  26. package/ftm-inbox/backend/adapters/jira.py +136 -0
  27. package/ftm-inbox/backend/adapters/registry.py +192 -0
  28. package/ftm-inbox/backend/adapters/slack.py +110 -0
  29. package/ftm-inbox/backend/db/__init__.py +0 -0
  30. package/ftm-inbox/backend/db/connection.py +54 -0
  31. package/ftm-inbox/backend/db/schema.py +78 -0
  32. package/ftm-inbox/backend/executor/__init__.py +7 -0
  33. package/ftm-inbox/backend/executor/engine.py +149 -0
  34. package/ftm-inbox/backend/executor/step_runner.py +98 -0
  35. package/ftm-inbox/backend/main.py +103 -0
  36. package/ftm-inbox/backend/models/__init__.py +1 -0
  37. package/ftm-inbox/backend/models/unified_task.py +36 -0
  38. package/ftm-inbox/backend/planner/__init__.py +6 -0
  39. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  41. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  42. package/ftm-inbox/backend/planner/generator.py +127 -0
  43. package/ftm-inbox/backend/planner/schema.py +34 -0
  44. package/ftm-inbox/backend/requirements.txt +5 -0
  45. package/ftm-inbox/backend/routes/__init__.py +0 -0
  46. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  47. package/ftm-inbox/backend/routes/execute.py +186 -0
  48. package/ftm-inbox/backend/routes/health.py +52 -0
  49. package/ftm-inbox/backend/routes/inbox.py +68 -0
  50. package/ftm-inbox/backend/routes/plan.py +271 -0
  51. package/ftm-inbox/bin/launchagent.mjs +91 -0
  52. package/ftm-inbox/bin/setup.mjs +188 -0
  53. package/ftm-inbox/bin/start.sh +10 -0
  54. package/ftm-inbox/bin/status.sh +17 -0
  55. package/ftm-inbox/bin/stop.sh +8 -0
  56. package/ftm-inbox/config.example.yml +55 -0
  57. package/ftm-inbox/package-lock.json +2898 -0
  58. package/ftm-inbox/package.json +26 -0
  59. package/ftm-inbox/postcss.config.js +6 -0
  60. package/ftm-inbox/src/app.css +199 -0
  61. package/ftm-inbox/src/app.html +18 -0
  62. package/ftm-inbox/src/lib/api.ts +166 -0
  63. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
  64. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
  65. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
  66. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
  67. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
  68. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
  69. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
  70. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
  71. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
  72. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
  73. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
  74. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
  75. package/ftm-inbox/src/lib/theme.ts +47 -0
  76. package/ftm-inbox/src/routes/+layout.svelte +76 -0
  77. package/ftm-inbox/src/routes/+page.svelte +401 -0
  78. package/ftm-inbox/static/favicon.png +0 -0
  79. package/ftm-inbox/svelte.config.js +12 -0
  80. package/ftm-inbox/tailwind.config.ts +63 -0
  81. package/ftm-inbox/tsconfig.json +13 -0
  82. package/ftm-inbox/vite.config.ts +6 -0
  83. package/ftm-intent/SKILL.md +44 -0
  84. package/ftm-manifest.json +3794 -0
  85. package/ftm-map/SKILL.md +259 -0
  86. package/ftm-map/scripts/db.py +391 -0
  87. package/ftm-map/scripts/index.py +341 -0
  88. package/ftm-map/scripts/parser.py +455 -0
  89. package/ftm-map/scripts/queries/.gitkeep +0 -0
  90. package/ftm-map/scripts/queries/javascript-tags.scm +23 -0
  91. package/ftm-map/scripts/queries/python-tags.scm +17 -0
  92. package/ftm-map/scripts/queries/typescript-tags.scm +29 -0
  93. package/ftm-map/scripts/query.py +149 -0
  94. package/ftm-map/scripts/requirements.txt +2 -0
  95. package/ftm-map/scripts/setup-hooks.sh +27 -0
  96. package/ftm-map/scripts/setup.sh +45 -0
  97. package/ftm-map/scripts/test_db.py +124 -0
  98. package/ftm-map/scripts/test_parser.py +106 -0
  99. package/ftm-map/scripts/test_query.py +66 -0
  100. package/ftm-map/scripts/tests/fixtures/__init__.py +0 -0
  101. package/ftm-map/scripts/tests/fixtures/sample_project/api.ts +16 -0
  102. package/ftm-map/scripts/tests/fixtures/sample_project/auth.py +15 -0
  103. package/ftm-map/scripts/tests/fixtures/sample_project/utils.js +16 -0
  104. package/ftm-map/scripts/views.py +545 -0
  105. package/ftm-mind/SKILL.md +173 -66
  106. package/ftm-pause/SKILL.md +43 -0
  107. package/ftm-researcher/SKILL.md +275 -0
  108. package/ftm-researcher/evals/agent-diversity.yaml +17 -0
  109. package/ftm-researcher/evals/synthesis-quality.yaml +12 -0
  110. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -0
  111. package/ftm-researcher/references/adaptive-search.md +116 -0
  112. package/ftm-researcher/references/agent-prompts.md +193 -0
  113. package/ftm-researcher/references/council-integration.md +193 -0
  114. package/ftm-researcher/references/output-format.md +203 -0
  115. package/ftm-researcher/references/synthesis-pipeline.md +165 -0
  116. package/ftm-researcher/scripts/score_credibility.py +234 -0
  117. package/ftm-researcher/scripts/validate_research.py +92 -0
  118. package/ftm-resume/SKILL.md +47 -0
  119. package/ftm-retro/SKILL.md +54 -0
  120. package/ftm-routine/SKILL.md +170 -0
  121. package/ftm-state/blackboard/capabilities.json +5 -0
  122. package/ftm-state/blackboard/capabilities.schema.json +27 -0
  123. package/ftm-upgrade/SKILL.md +41 -0
  124. package/ftm-upgrade/scripts/check-version.sh +1 -1
  125. package/ftm-upgrade/scripts/upgrade.sh +1 -1
  126. package/hooks/ftm-blackboard-enforcer.sh +94 -0
  127. package/hooks/ftm-discovery-reminder.sh +90 -0
  128. package/hooks/ftm-drafts-gate.sh +61 -0
  129. package/hooks/ftm-event-logger.mjs +107 -0
  130. package/hooks/ftm-map-autodetect.sh +79 -0
  131. package/hooks/ftm-pending-sync-check.sh +22 -0
  132. package/hooks/ftm-plan-gate.sh +96 -0
  133. package/hooks/ftm-post-commit-trigger.sh +57 -0
  134. package/hooks/settings-template.json +81 -0
  135. package/install.sh +140 -11
  136. package/package.json +12 -2
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env python3
2
+ """View generators: produce INTENT.md and ARCHITECTURE.mmd from the code graph."""
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, os.path.dirname(__file__))
11
+ from db import get_connection
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Module grouping helpers
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ def _get_module_for_path(file_path: str) -> str:
19
+ """Return the top-level directory component of a relative file path.
20
+
21
+ Files at the project root (no directory component) are grouped under '.'.
22
+ """
23
+ parts = Path(file_path).parts
24
+ if len(parts) > 1:
25
+ return parts[0]
26
+ return "."
27
+
28
+
29
+ def get_modules(conn) -> dict:
30
+ """Group symbols by directory to identify modules.
31
+
32
+ Returns a dict mapping module name -> set of file paths.
33
+ """
34
+ rows = conn.execute(
35
+ "SELECT DISTINCT file_path FROM symbols ORDER BY file_path"
36
+ ).fetchall()
37
+
38
+ modules: dict = defaultdict(set)
39
+ for row in rows:
40
+ fp = row["file_path"]
41
+ module = _get_module_for_path(fp)
42
+ modules[module].add(fp)
43
+
44
+ return dict(modules)
45
+
46
+
47
+ def _get_symbols_for_module(conn, module: str, files: set) -> list:
48
+ """Return all symbol rows for a module (identified by its set of files)."""
49
+ placeholders = ",".join("?" * len(files))
50
+ rows = conn.execute(
51
+ f"SELECT * FROM symbols WHERE file_path IN ({placeholders}) ORDER BY file_path, start_line",
52
+ list(files),
53
+ ).fetchall()
54
+ return [dict(r) for r in rows]
55
+
56
+
57
+ def _get_callers(conn, symbol_id: int) -> list:
58
+ """Return direct callers (symbols that call this one)."""
59
+ rows = conn.execute(
60
+ """
61
+ SELECT s.name, s.file_path
62
+ FROM edges e JOIN symbols s ON s.id = e.source_id
63
+ WHERE e.target_id = ?
64
+ LIMIT 10
65
+ """,
66
+ (symbol_id,),
67
+ ).fetchall()
68
+ return [dict(r) for r in rows]
69
+
70
+
71
+ def _get_callees(conn, symbol_id: int) -> list:
72
+ """Return direct callees (symbols this one calls)."""
73
+ rows = conn.execute(
74
+ """
75
+ SELECT s.name, s.file_path
76
+ FROM edges e JOIN symbols s ON s.id = e.target_id
77
+ WHERE e.source_id = ?
78
+ LIMIT 10
79
+ """,
80
+ (symbol_id,),
81
+ ).fetchall()
82
+ return [dict(r) for r in rows]
83
+
84
+
85
+ def _top_symbols(symbols: list, n: int = 5) -> list:
86
+ """Return top n function/method symbols from a list, falling back to any kind."""
87
+ funcs = [s for s in symbols if s["kind"] in ("function", "method")]
88
+ selection = funcs if funcs else symbols
89
+ return selection[:n]
90
+
91
+
92
+ def _infer_purpose(module: str, symbols: list) -> str:
93
+ """Infer a one-line purpose description from module name and symbol kinds."""
94
+ if not symbols:
95
+ return "Empty module — no symbols indexed yet."
96
+
97
+ kinds = [s["kind"] for s in symbols]
98
+ kind_counts: dict = defaultdict(int)
99
+ for k in kinds:
100
+ kind_counts[k] += 1
101
+
102
+ dominant = sorted(kind_counts.items(), key=lambda x: x[1], reverse=True)[0][0]
103
+
104
+ name_lower = module.lower()
105
+ if any(kw in name_lower for kw in ("test", "spec", "__tests__")):
106
+ return "Test suite."
107
+ if any(kw in name_lower for kw in ("util", "helper", "common", "shared")):
108
+ return "Shared utilities and helpers."
109
+ if any(kw in name_lower for kw in ("model", "schema", "entity", "type")):
110
+ return "Data models and type definitions."
111
+ if any(kw in name_lower for kw in ("route", "api", "handler", "endpoint")):
112
+ return "API routes and request handlers."
113
+ if any(kw in name_lower for kw in ("db", "database", "repo", "store")):
114
+ return "Data access and persistence layer."
115
+ if any(kw in name_lower for kw in ("config", "setting", "env")):
116
+ return "Configuration and environment settings."
117
+ if any(kw in name_lower for kw in ("service", "manager", "controller")):
118
+ return "Business logic and service layer."
119
+ if any(kw in name_lower for kw in ("component", "view", "page", "ui")):
120
+ return "UI components and views."
121
+
122
+ if dominant == "class":
123
+ return f"Module defining {kind_counts['class']} class(es)."
124
+ if dominant == "function":
125
+ return f"Module with {kind_counts['function']} function(s)."
126
+ return f"Module containing {len(symbols)} symbols."
127
+
128
+
129
+ def _infer_function_does(sym: dict) -> str:
130
+ """Infer what a function does from its name and signature."""
131
+ doc = (sym.get("doc_comment") or "").strip()
132
+ if doc:
133
+ first_sentence = doc.split(".")[0].strip()
134
+ if first_sentence:
135
+ return first_sentence + "."
136
+
137
+ sig = (sym.get("signature") or "").strip()
138
+ name = sym.get("name", "")
139
+
140
+ name_lower = name.lower()
141
+ if name_lower.startswith("get_") or name_lower.startswith("fetch_"):
142
+ subject = name_lower[4:].replace("_", " ")
143
+ return f"Retrieves {subject}."
144
+ if name_lower.startswith("set_") or name_lower.startswith("update_"):
145
+ subject = name_lower[4:].replace("_", " ")
146
+ return f"Updates {subject}."
147
+ if name_lower.startswith("create_") or name_lower.startswith("add_"):
148
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
149
+ return f"Creates or adds {subject}."
150
+ if name_lower.startswith("delete_") or name_lower.startswith("remove_"):
151
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
152
+ return f"Removes {subject}."
153
+ if name_lower.startswith("is_") or name_lower.startswith("has_") or name_lower.startswith("check_"):
154
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
155
+ return f"Checks whether {subject}."
156
+ if name_lower.startswith("parse_") or name_lower.startswith("decode_"):
157
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
158
+ return f"Parses {subject}."
159
+ if name_lower.startswith("render_") or name_lower.startswith("format_"):
160
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
161
+ return f"Formats or renders {subject}."
162
+ if name_lower.startswith("handle_") or name_lower.startswith("on_"):
163
+ subject = name_lower.split("_", 1)[1].replace("_", " ") if "_" in name_lower else name_lower
164
+ return f"Handles {subject} event."
165
+ if name_lower.startswith("init") or name_lower.startswith("setup") or name_lower.startswith("bootstrap"):
166
+ return "Initializes and configures the component."
167
+ if name_lower in ("main", "__main__"):
168
+ return "Entry point for the module."
169
+ if name_lower.startswith("test_"):
170
+ subject = name_lower[5:].replace("_", " ")
171
+ return f"Tests {subject}."
172
+
173
+ if sig:
174
+ return f"Executes `{sig[:80]}`."
175
+
176
+ return f"Implements `{name}` logic."
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # INTENT.md generation
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ def generate_intent(project_root: str, only_modules: set | None = None) -> None:
185
+ """Generate root INTENT.md and per-module INTENT.md files.
186
+
187
+ If *only_modules* is provided, only regenerate views for those modules
188
+ (incremental mode). The root INTENT.md is always regenerated when any
189
+ module is affected.
190
+ """
191
+ abs_root = os.path.abspath(project_root)
192
+ conn = get_connection(abs_root)
193
+ try:
194
+ modules = get_modules(conn)
195
+ if not modules:
196
+ print("No symbols found in database. Run the indexer first.", file=sys.stderr)
197
+ conn.close()
198
+ return
199
+
200
+ project_name = Path(abs_root).name
201
+
202
+ # Determine which modules to regenerate
203
+ target_modules = set(modules.keys())
204
+ if only_modules:
205
+ target_modules = {m for m in modules if m in only_modules}
206
+
207
+ # Always regenerate root INTENT.md when any module is touched
208
+ if target_modules or not only_modules:
209
+ _write_root_intent(conn, abs_root, project_name, modules)
210
+
211
+ for module in target_modules:
212
+ # Root-level files (module=".") are covered by the root INTENT.md
213
+ # written above — skip to avoid overwriting it.
214
+ if module == ".":
215
+ continue
216
+ files = modules[module]
217
+ symbols = _get_symbols_for_module(conn, module, files)
218
+ _write_module_intent(conn, abs_root, module, symbols)
219
+
220
+ print(
221
+ f"Generated INTENT.md for {len(target_modules)} module(s) + root.",
222
+ file=sys.stderr,
223
+ )
224
+ finally:
225
+ conn.close()
226
+
227
+
228
+ def _write_root_intent(conn, project_root: str, project_name: str, modules: dict) -> None:
229
+ """Write the root-level INTENT.md."""
230
+ rows = []
231
+ for module, files in sorted(modules.items()):
232
+ symbols = _get_symbols_for_module(conn, module, files)
233
+ purpose = _infer_purpose(module, symbols)
234
+ top = _top_symbols(symbols)
235
+ key_fns = ", ".join(s["name"] for s in top) if top else "—"
236
+ display = module if module != "." else "(root)"
237
+ rows.append(f"| `{display}` | {purpose} | {key_fns} |")
238
+
239
+ module_table = "\n".join(rows) if rows else "| — | No modules found | — |"
240
+
241
+ content = f"""# {project_name} — Intent
242
+
243
+ ## Vision
244
+
245
+ {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.
246
+
247
+ ## Architecture Decisions
248
+
249
+ | Decision | Choice | Reasoning |
250
+ |---|---|---|
251
+ | Code indexing | SQLite + FTS5 | Persistent, queryable graph without external dependencies |
252
+ | Symbol extraction | tree-sitter | Language-agnostic AST parsing with multi-language support |
253
+ | View generation | Markdown + Mermaid | Human-readable output compatible with most documentation tools |
254
+
255
+ ## Module Map
256
+
257
+ | Module | Purpose | Key Functions |
258
+ |---|---|---|
259
+ {module_table}
260
+ """
261
+
262
+ out_path = os.path.join(project_root, "INTENT.md")
263
+ _write_file(out_path, content)
264
+
265
+
266
+ def _write_module_intent(conn, project_root: str, module: str, symbols: list) -> None:
267
+ """Write a per-module INTENT.md inside the module directory."""
268
+ if not symbols:
269
+ return
270
+
271
+ module_name = module if module != "." else Path(project_root).name
272
+
273
+ # Build function entries
274
+ entries = []
275
+ for sym in symbols:
276
+ if sym["kind"] not in ("function", "method", "class"):
277
+ continue
278
+
279
+ does = _infer_function_does(sym)
280
+ callers = _get_callers(conn, sym["id"])
281
+ callees = _get_callees(conn, sym["id"])
282
+
283
+ called_by_str = ", ".join(c["name"] for c in callers) if callers else "none found"
284
+ calls_str = ", ".join(c["name"] for c in callees) if callees else "none found"
285
+
286
+ entry = f"""## {sym["name"]}
287
+ - **Does**: {does}
288
+ - **Why**: Supports the `{module_name}` module's responsibilities.
289
+ - **Relationships**: calls [{calls_str}], called by [{called_by_str}]
290
+ - **Decisions**: `{sym.get("signature", "") or sym["name"]}` (line {sym.get("start_line", "?")} – {sym.get("end_line", "?")})
291
+ """
292
+ entries.append(entry)
293
+
294
+ if not entries:
295
+ return
296
+
297
+ content = f"# {module_name} — Intent\n\n" + "\n".join(entries)
298
+
299
+ if module == ".":
300
+ out_path = os.path.join(project_root, "INTENT.md")
301
+ else:
302
+ module_dir = os.path.join(project_root, module)
303
+ os.makedirs(module_dir, exist_ok=True)
304
+ out_path = os.path.join(module_dir, "INTENT.md")
305
+
306
+ _write_file(out_path, content)
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # ARCHITECTURE.mmd / DIAGRAM.mmd generation
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ def generate_diagrams(project_root: str, only_modules: set | None = None) -> None:
315
+ """Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.
316
+
317
+ If *only_modules* is provided, only regenerate views for those modules.
318
+ The root diagram is always regenerated when any module is affected.
319
+ """
320
+ abs_root = os.path.abspath(project_root)
321
+ conn = get_connection(abs_root)
322
+ try:
323
+ modules = get_modules(conn)
324
+ if not modules:
325
+ print("No symbols found in database. Run the indexer first.", file=sys.stderr)
326
+ conn.close()
327
+ return
328
+
329
+ target_modules = set(modules.keys())
330
+ if only_modules:
331
+ target_modules = {m for m in modules if m in only_modules}
332
+
333
+ if target_modules or not only_modules:
334
+ _write_root_diagram(conn, abs_root, modules)
335
+
336
+ for module in target_modules:
337
+ # Root-level files (module=".") are covered by ARCHITECTURE.mmd
338
+ # written above — skip to avoid overwriting it.
339
+ if module == ".":
340
+ continue
341
+ files = modules[module]
342
+ symbols = _get_symbols_for_module(conn, module, files)
343
+ _write_module_diagram(conn, abs_root, module, symbols)
344
+
345
+ print(
346
+ f"Generated diagrams for {len(target_modules)} module(s) + root.",
347
+ file=sys.stderr,
348
+ )
349
+ finally:
350
+ conn.close()
351
+
352
+
353
+ def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
354
+ """Write root ARCHITECTURE.mmd showing module-level dependencies."""
355
+ module_list = sorted(modules.keys())
356
+
357
+ # Build module -> set of modules it imports from
358
+ module_deps: dict = defaultdict(set)
359
+
360
+ for module, files in modules.items():
361
+ symbols = _get_symbols_for_module(conn, module, files)
362
+ for sym in symbols:
363
+ callees = _get_callees(conn, sym["id"])
364
+ for callee in callees:
365
+ target_module = _get_module_for_path(callee["file_path"])
366
+ if target_module != module:
367
+ module_deps[module].add(target_module)
368
+
369
+ # Build mermaid lines
370
+ lines = ["graph LR"]
371
+
372
+ # Node declarations
373
+ for m in module_list:
374
+ safe_id = _mermaid_id(m)
375
+ label = m if m != "." else "(root)"
376
+ lines.append(f" {safe_id}[{label}]")
377
+
378
+ # Edge declarations
379
+ edge_added = False
380
+ for src_module in sorted(module_deps.keys()):
381
+ for tgt_module in sorted(module_deps[src_module]):
382
+ if tgt_module in modules:
383
+ src_id = _mermaid_id(src_module)
384
+ tgt_id = _mermaid_id(tgt_module)
385
+ lines.append(f" {src_id} --> {tgt_id}")
386
+ edge_added = True
387
+
388
+ if not edge_added and len(module_list) > 1:
389
+ # No edges detected — add a comment so the diagram is still valid
390
+ lines.append(" %% No inter-module dependencies detected in index")
391
+
392
+ content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
393
+ out_path = os.path.join(project_root, "ARCHITECTURE.mmd")
394
+ _write_file(out_path, content)
395
+
396
+
397
+ def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -> None:
398
+ """Write per-module DIAGRAM.mmd showing function-level call graph."""
399
+ if not symbols:
400
+ return
401
+
402
+ # Collect symbol IDs and names in this module
403
+ sym_ids = {s["id"] for s in symbols}
404
+ sym_names = {s["id"]: s["name"] for s in symbols}
405
+
406
+ lines = ["graph TD"]
407
+
408
+ # Node declarations for all symbols with interesting kinds
409
+ interesting = [s for s in symbols if s["kind"] in ("function", "method", "class")]
410
+ if not interesting:
411
+ interesting = symbols
412
+
413
+ for sym in interesting:
414
+ safe_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
415
+ lines.append(f" {safe_id}[{sym['name']}]")
416
+
417
+ # Edge declarations — only intra-module edges
418
+ edges_added = False
419
+ for sym in interesting:
420
+ callees = _get_callees(conn, sym["id"])
421
+ src_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
422
+ for callee_row in callees:
423
+ # Find callee in this module's symbol set
424
+ matching = [s for s in interesting if s["name"] == callee_row["name"]]
425
+ for tgt_sym in matching:
426
+ tgt_id = _mermaid_id(f"{tgt_sym['name']}_{tgt_sym['id']}")
427
+ lines.append(f" {src_id} --> {tgt_id}")
428
+ edges_added = True
429
+
430
+ if not edges_added and len(interesting) > 1:
431
+ lines.append(" %% No intra-module call edges detected in index")
432
+
433
+ content = "```mermaid\n" + "\n".join(lines) + "\n```\n"
434
+
435
+ if module == ".":
436
+ out_path = os.path.join(project_root, "DIAGRAM.mmd")
437
+ else:
438
+ module_dir = os.path.join(project_root, module)
439
+ os.makedirs(module_dir, exist_ok=True)
440
+ out_path = os.path.join(module_dir, "DIAGRAM.mmd")
441
+
442
+ _write_file(out_path, content)
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Shared helpers
447
+ # ---------------------------------------------------------------------------
448
+
449
+
450
+ def _mermaid_id(text: str) -> str:
451
+ """Convert arbitrary text to a safe Mermaid node ID."""
452
+ safe = ""
453
+ for ch in text:
454
+ if ch.isalnum() or ch == "_":
455
+ safe += ch
456
+ else:
457
+ safe += "_"
458
+ # Mermaid IDs cannot start with a digit
459
+ if safe and safe[0].isdigit():
460
+ safe = "_" + safe
461
+ return safe or "_unknown"
462
+
463
+
464
+ def _write_file(path: str, content: str) -> None:
465
+ """Write content to path, creating parent directories as needed."""
466
+ os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
467
+ with open(path, "w", encoding="utf-8") as fh:
468
+ fh.write(content)
469
+
470
+
471
+ def _files_to_modules(files_str: str) -> set:
472
+ """Convert a comma-separated file list to a set of affected module names."""
473
+ raw = [f.strip() for f in files_str.split(",") if f.strip()]
474
+ return {_get_module_for_path(f) for f in raw}
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # CLI entry point
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ def main() -> None:
483
+ parser = argparse.ArgumentParser(
484
+ description="ftm-map view generators — produce INTENT.md and ARCHITECTURE.mmd from the code graph.",
485
+ formatter_class=argparse.RawDescriptionHelpFormatter,
486
+ epilog=(
487
+ "Examples:\n"
488
+ " python3 views.py generate-intent /path/to/project\n"
489
+ " python3 views.py generate-diagrams /path/to/project\n"
490
+ " python3 views.py generate-intent /path/to/project --files src/foo.ts,src/bar.py\n"
491
+ " python3 views.py generate-diagrams /path/to/project --files src/foo.ts\n"
492
+ ),
493
+ )
494
+
495
+ subparsers = parser.add_subparsers(dest="command", required=True)
496
+
497
+ # generate-intent subcommand
498
+ intent_parser = subparsers.add_parser(
499
+ "generate-intent",
500
+ help="Generate root INTENT.md and per-module INTENT.md files.",
501
+ )
502
+ intent_parser.add_argument(
503
+ "project_root",
504
+ help="Path to the project root directory.",
505
+ )
506
+ intent_parser.add_argument(
507
+ "--files",
508
+ metavar="FILE_LIST",
509
+ default=None,
510
+ help="Comma-separated list of changed files (incremental mode — only regenerate affected modules).",
511
+ )
512
+
513
+ # generate-diagrams subcommand
514
+ diag_parser = subparsers.add_parser(
515
+ "generate-diagrams",
516
+ help="Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.",
517
+ )
518
+ diag_parser.add_argument(
519
+ "project_root",
520
+ help="Path to the project root directory.",
521
+ )
522
+ diag_parser.add_argument(
523
+ "--files",
524
+ metavar="FILE_LIST",
525
+ default=None,
526
+ help="Comma-separated list of changed files (incremental mode — only regenerate affected modules).",
527
+ )
528
+
529
+ args = parser.parse_args()
530
+
531
+ only_modules: set | None = None
532
+ if args.files:
533
+ only_modules = _files_to_modules(args.files)
534
+
535
+ if args.command == "generate-intent":
536
+ generate_intent(args.project_root, only_modules)
537
+ elif args.command == "generate-diagrams":
538
+ generate_diagrams(args.project_root, only_modules)
539
+ else:
540
+ parser.print_help()
541
+ sys.exit(1)
542
+
543
+
544
+ if __name__ == "__main__":
545
+ main()