feed-the-machine 1.3.0 → 1.4.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 (45) hide show
  1. package/ftm-audit/SKILL.md +383 -57
  2. package/ftm-brainstorm/SKILL.md +119 -51
  3. package/ftm-config/SKILL.md +1 -1
  4. package/ftm-council/SKILL.md +259 -31
  5. package/ftm-dashboard/SKILL.md +10 -10
  6. package/ftm-debug/SKILL.md +861 -54
  7. package/ftm-diagram/SKILL.md +1 -1
  8. package/ftm-executor/SKILL.md +6 -6
  9. package/ftm-git/SKILL.md +208 -22
  10. package/ftm-inbox/bin/start.sh +1 -1
  11. package/ftm-inbox/bin/status.sh +1 -1
  12. package/ftm-inbox/bin/stop.sh +1 -1
  13. package/ftm-intent/SKILL.md +0 -1
  14. package/ftm-map/SKILL.md +46 -14
  15. package/ftm-map/scripts/db.py +439 -118
  16. package/ftm-map/scripts/index.py +128 -54
  17. package/ftm-map/scripts/parser.py +89 -320
  18. package/ftm-map/scripts/queries/go-tags.scm +20 -0
  19. package/ftm-map/scripts/queries/javascript-tags.scm +19 -7
  20. package/ftm-map/scripts/queries/python-tags.scm +22 -8
  21. package/ftm-map/scripts/queries/ruby-tags.scm +19 -0
  22. package/ftm-map/scripts/queries/rust-tags.scm +37 -0
  23. package/ftm-map/scripts/queries/typescript-tags.scm +20 -8
  24. package/ftm-map/scripts/query.py +176 -24
  25. package/ftm-map/scripts/ranker.py +377 -0
  26. package/ftm-map/scripts/requirements.txt +3 -0
  27. package/ftm-map/scripts/setup.sh +11 -0
  28. package/ftm-map/scripts/test_db.py +355 -115
  29. package/ftm-map/scripts/test_parser.py +169 -101
  30. package/ftm-map/scripts/test_query.py +178 -61
  31. package/ftm-map/scripts/test_ranker.py +199 -0
  32. package/ftm-map/scripts/views.py +107 -61
  33. package/ftm-mind/SKILL.md +861 -11
  34. package/ftm-mind/references/event-registry.md +20 -0
  35. package/ftm-pause/SKILL.md +256 -37
  36. package/ftm-resume/SKILL.md +380 -75
  37. package/ftm-retro/SKILL.md +164 -27
  38. package/ftm-upgrade/SKILL.md +4 -4
  39. package/hooks/ftm-blackboard-enforcer.sh +2 -4
  40. package/install.sh +6 -1
  41. package/package.json +1 -1
  42. package/ftm-map/scripts/tests/fixtures/__init__.py +0 -0
  43. package/ftm-map/scripts/tests/fixtures/sample_project/api.ts +0 -16
  44. package/ftm-map/scripts/tests/fixtures/sample_project/auth.py +0 -15
  45. package/ftm-map/scripts/tests/fixtures/sample_project/utils.js +0 -16
@@ -0,0 +1,199 @@
1
+ """Tests for ranker.py -- PageRank context selection."""
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ import pytest
6
+
7
+ sys.path.insert(0, os.path.dirname(__file__))
8
+ from db import get_connection, add_file, add_symbol, add_reference, rebuild_file_edges, rebuild_symbol_edges
9
+ from ranker import rank_files, fit_to_budget, build_adjacency_matrix, build_personalization
10
+
11
+
12
+ @pytest.fixture
13
+ def graph_conn():
14
+ """Connection with a graph that has meaningful PageRank differences.
15
+
16
+ Hub-and-spoke pattern: utils.py is referenced by 5 module files.
17
+ One isolated file with no connections.
18
+ """
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ conn = get_connection(tmp)
21
+
22
+ # Hub file: utils.py with symbols that many files reference
23
+ f_hub = add_file(conn, "src/utils.py", "python", 1.0, line_count=200)
24
+ add_symbol(conn, f_hub, "format_date", "function", 1, 10)
25
+ add_symbol(conn, f_hub, "validate", "function", 15, 30)
26
+
27
+ spokes = []
28
+ for i in range(5):
29
+ f = add_file(conn, f"src/module_{i}.py", "python", 1.0, line_count=50)
30
+ add_symbol(conn, f, f"handler_{i}", "function", 1, 20)
31
+ add_reference(conn, f, "format_date", 10)
32
+ add_reference(conn, f, "validate", 15)
33
+ spokes.append(f)
34
+
35
+ # One isolated file with no connections
36
+ f_iso = add_file(conn, "src/isolated.py", "python", 1.0, line_count=10)
37
+ add_symbol(conn, f_iso, "lonely_func", "function", 1, 5)
38
+
39
+ rebuild_file_edges(conn)
40
+ rebuild_symbol_edges(conn)
41
+ conn.commit()
42
+
43
+ yield conn
44
+ conn.close()
45
+
46
+
47
+ class TestPageRank:
48
+ def test_hub_ranked_higher(self, graph_conn):
49
+ results = rank_files(graph_conn)
50
+ assert len(results) > 0
51
+ # utils.py (hub) should be ranked higher than isolated.py
52
+ path_scores = {p: s for p, s in results}
53
+ assert path_scores.get("src/utils.py", 0) > path_scores.get("src/isolated.py", 0)
54
+
55
+ def test_all_files_ranked(self, graph_conn):
56
+ results = rank_files(graph_conn)
57
+ assert len(results) == 7 # 1 hub + 5 spokes + 1 isolated
58
+
59
+ def test_scores_sum_to_one(self, graph_conn):
60
+ results = rank_files(graph_conn)
61
+ total = sum(s for _, s in results)
62
+ assert abs(total - 1.0) < 0.01
63
+
64
+ def test_returns_sorted_descending(self, graph_conn):
65
+ results = rank_files(graph_conn)
66
+ scores = [s for _, s in results]
67
+ for i in range(len(scores) - 1):
68
+ assert scores[i] >= scores[i + 1]
69
+
70
+ def test_all_scores_positive(self, graph_conn):
71
+ results = rank_files(graph_conn)
72
+ for _, score in results:
73
+ assert score > 0
74
+
75
+
76
+ class TestPersonalization:
77
+ def test_seed_file_boosts(self, graph_conn):
78
+ uniform = rank_files(graph_conn)
79
+ personalized = rank_files(graph_conn, seed_files=["src/isolated.py"])
80
+
81
+ u_score = dict(uniform).get("src/isolated.py", 0)
82
+ p_score = dict(personalized).get("src/isolated.py", 0)
83
+ assert p_score > u_score # Seeded file should get boosted
84
+
85
+ def test_seed_symbol_boosts(self, graph_conn):
86
+ uniform = rank_files(graph_conn)
87
+ personalized = rank_files(graph_conn, seed_symbols=["format_date"])
88
+
89
+ u_score = dict(uniform).get("src/utils.py", 0)
90
+ p_score = dict(personalized).get("src/utils.py", 0)
91
+ # utils.py defines format_date, should stay high or increase
92
+ assert p_score >= u_score * 0.9 # Allow small variance
93
+
94
+ def test_seed_keyword_boosts(self, graph_conn):
95
+ uniform = rank_files(graph_conn)
96
+ personalized = rank_files(graph_conn, seed_keywords=["format_date"])
97
+
98
+ p_scores = dict(personalized)
99
+ u_scores = dict(uniform)
100
+ # utils.py has format_date symbol, should be boosted
101
+ assert p_scores.get("src/utils.py", 0) >= u_scores.get("src/utils.py", 0) * 0.9
102
+
103
+
104
+ class TestBudgetFitting:
105
+ def test_respects_budget(self, graph_conn):
106
+ ranked = rank_files(graph_conn)
107
+ result, tokens = fit_to_budget(ranked, graph_conn, 100)
108
+ assert tokens <= 100 * 1.15 # 15% tolerance
109
+
110
+ def test_returns_file_entries(self, graph_conn):
111
+ ranked = rank_files(graph_conn)
112
+ result, _ = fit_to_budget(ranked, graph_conn, 500)
113
+ assert len(result) > 0
114
+ assert "symbols" in result[0]
115
+ assert "path" in result[0]
116
+ assert "score" in result[0]
117
+ assert "tokens" in result[0]
118
+
119
+ def test_zero_budget(self, graph_conn):
120
+ ranked = rank_files(graph_conn)
121
+ result, tokens = fit_to_budget(ranked, graph_conn, 0)
122
+ assert result == []
123
+ assert tokens == 0
124
+
125
+ def test_large_budget_includes_all(self, graph_conn):
126
+ ranked = rank_files(graph_conn)
127
+ result, _ = fit_to_budget(ranked, graph_conn, 100000)
128
+ assert len(result) == len(ranked)
129
+
130
+ def test_symbols_populated(self, graph_conn):
131
+ ranked = rank_files(graph_conn)
132
+ result, _ = fit_to_budget(ranked, graph_conn, 500)
133
+ # At least some entries should have symbols
134
+ has_symbols = any(len(entry["symbols"]) > 0 for entry in result)
135
+ assert has_symbols
136
+
137
+ def test_empty_ranked_list(self, graph_conn):
138
+ result, tokens = fit_to_budget([], graph_conn, 500)
139
+ assert result == []
140
+ assert tokens == 0
141
+
142
+
143
+ class TestAdjacencyMatrix:
144
+ def test_builds_sparse_matrix(self, graph_conn):
145
+ adj, fid_to_idx, idx_to_fid = build_adjacency_matrix(graph_conn)
146
+ assert adj is not None
147
+ assert adj.shape[0] == 7 # 7 files
148
+ assert adj.nnz > 0 # Has edges
149
+
150
+ def test_index_mappings_consistent(self, graph_conn):
151
+ adj, fid_to_idx, idx_to_fid = build_adjacency_matrix(graph_conn)
152
+ assert len(fid_to_idx) == 7
153
+ assert len(idx_to_fid) == 7
154
+ # Forward and reverse should be inverses
155
+ for fid, idx in fid_to_idx.items():
156
+ assert idx_to_fid[idx] == fid
157
+
158
+ def test_matrix_is_symmetric(self, graph_conn):
159
+ """Adjacency matrix should be symmetrized (undirected)."""
160
+ adj, _, _ = build_adjacency_matrix(graph_conn)
161
+ diff = abs(adj - adj.T)
162
+ assert diff.nnz == 0 or diff.max() < 1e-10
163
+
164
+
165
+ class TestBuildPersonalization:
166
+ def test_uniform_baseline(self, graph_conn):
167
+ _, fid_to_idx, _ = build_adjacency_matrix(graph_conn)
168
+ pers = build_personalization(graph_conn, file_id_to_idx=fid_to_idx)
169
+ assert abs(pers.sum() - 1.0) < 1e-6 # Normalized
170
+
171
+ def test_seed_file_increases_weight(self, graph_conn):
172
+ _, fid_to_idx, _ = build_adjacency_matrix(graph_conn)
173
+ uniform = build_personalization(graph_conn, file_id_to_idx=fid_to_idx)
174
+ seeded = build_personalization(
175
+ graph_conn, seed_files=["src/isolated.py"], file_id_to_idx=fid_to_idx
176
+ )
177
+ # Find the isolated file's index
178
+ iso_row = graph_conn.execute(
179
+ "SELECT id FROM files WHERE path='src/isolated.py'"
180
+ ).fetchone()
181
+ idx = fid_to_idx[iso_row["id"]]
182
+ assert seeded[idx] > uniform[idx]
183
+
184
+
185
+ class TestEmptyDatabase:
186
+ def test_rank_files_empty(self):
187
+ with tempfile.TemporaryDirectory() as tmp:
188
+ conn = get_connection(tmp)
189
+ results = rank_files(conn)
190
+ assert results == []
191
+ conn.close()
192
+
193
+ def test_fit_to_budget_empty(self):
194
+ with tempfile.TemporaryDirectory() as tmp:
195
+ conn = get_connection(tmp)
196
+ result, tokens = fit_to_budget([], conn, 500)
197
+ assert result == []
198
+ assert tokens == 0
199
+ conn.close()
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """View generators: produce INTENT.md and ARCHITECTURE.mmd from the code graph."""
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
+ """
3
7
 
4
8
  import argparse
5
9
  import os
@@ -27,17 +31,18 @@ def _get_module_for_path(file_path: str) -> str:
27
31
 
28
32
 
29
33
  def get_modules(conn) -> dict:
30
- """Group symbols by directory to identify modules.
34
+ """Group files by directory to identify modules.
31
35
 
36
+ Queries the files table directly (symbols no longer carry file_path).
32
37
  Returns a dict mapping module name -> set of file paths.
33
38
  """
34
39
  rows = conn.execute(
35
- "SELECT DISTINCT file_path FROM symbols ORDER BY file_path"
40
+ "SELECT DISTINCT path FROM files ORDER BY path"
36
41
  ).fetchall()
37
42
 
38
43
  modules: dict = defaultdict(set)
39
44
  for row in rows:
40
- fp = row["file_path"]
45
+ fp = row["path"]
41
46
  module = _get_module_for_path(fp)
42
47
  modules[module].add(fp)
43
48
 
@@ -45,22 +50,37 @@ def get_modules(conn) -> dict:
45
50
 
46
51
 
47
52
  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)."""
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
+ """
49
58
  placeholders = ",".join("?" * len(files))
50
59
  rows = conn.execute(
51
- f"SELECT * FROM symbols WHERE file_path IN ({placeholders}) ORDER BY file_path, start_line",
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
+ """,
52
70
  list(files),
53
71
  ).fetchall()
54
72
  return [dict(r) for r in rows]
55
73
 
56
74
 
57
75
  def _get_callers(conn, symbol_id: int) -> list:
58
- """Return direct callers (symbols that call this one)."""
76
+ """Return direct callers (symbols that call this one) via symbol_edges."""
59
77
  rows = conn.execute(
60
78
  """
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 = ?
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 = ?
64
84
  LIMIT 10
65
85
  """,
66
86
  (symbol_id,),
@@ -69,12 +89,14 @@ def _get_callers(conn, symbol_id: int) -> list:
69
89
 
70
90
 
71
91
  def _get_callees(conn, symbol_id: int) -> list:
72
- """Return direct callees (symbols this one calls)."""
92
+ """Return direct callees (symbols this one calls) via symbol_edges."""
73
93
  rows = conn.execute(
74
94
  """
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 = ?
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 = ?
78
100
  LIMIT 10
79
101
  """,
80
102
  (symbol_id,),
@@ -82,9 +104,18 @@ def _get_callees(conn, symbol_id: int) -> list:
82
104
  return [dict(r) for r in rows]
83
105
 
84
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
+
85
116
  def _top_symbols(symbols: list, n: int = 5) -> list:
86
117
  """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")]
118
+ funcs = [s for s in symbols if s["kind"] in ("function", "method", "definition")]
88
119
  selection = funcs if funcs else symbols
89
120
  return selection[:n]
90
121
 
@@ -123,6 +154,8 @@ def _infer_purpose(module: str, symbols: list) -> str:
123
154
  return f"Module defining {kind_counts['class']} class(es)."
124
155
  if dominant == "function":
125
156
  return f"Module with {kind_counts['function']} function(s)."
157
+ if dominant == "definition":
158
+ return f"Module with {kind_counts['definition']} definition(s)."
126
159
  return f"Module containing {len(symbols)} symbols."
127
160
 
128
161
 
@@ -250,6 +283,9 @@ def _write_root_intent(conn, project_root: str, project_name: str, modules: dict
250
283
  |---|---|---|
251
284
  | Code indexing | SQLite + FTS5 | Persistent, queryable graph without external dependencies |
252
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 |
253
289
  | View generation | Markdown + Mermaid | Human-readable output compatible with most documentation tools |
254
290
 
255
291
  ## Module Map
@@ -273,12 +309,13 @@ def _write_module_intent(conn, project_root: str, module: str, symbols: list) ->
273
309
  # Build function entries
274
310
  entries = []
275
311
  for sym in symbols:
276
- if sym["kind"] not in ("function", "method", "class"):
312
+ if sym["kind"] not in ("function", "method", "class", "definition"):
277
313
  continue
278
314
 
279
315
  does = _infer_function_does(sym)
280
316
  callers = _get_callers(conn, sym["id"])
281
317
  callees = _get_callees(conn, sym["id"])
318
+ ref_count = _get_ref_count(conn, sym["name"])
282
319
 
283
320
  called_by_str = ", ".join(c["name"] for c in callers) if callers else "none found"
284
321
  calls_str = ", ".join(c["name"] for c in callees) if callees else "none found"
@@ -287,6 +324,7 @@ def _write_module_intent(conn, project_root: str, module: str, symbols: list) ->
287
324
  - **Does**: {does}
288
325
  - **Why**: Supports the `{module_name}` module's responsibilities.
289
326
  - **Relationships**: calls [{calls_str}], called by [{called_by_str}]
327
+ - **References**: {ref_count} reference(s) across codebase
290
328
  - **Decisions**: `{sym.get("signature", "") or sym["name"]}` (line {sym.get("start_line", "?")} – {sym.get("end_line", "?")})
291
329
  """
292
330
  entries.append(entry)
@@ -351,20 +389,35 @@ def generate_diagrams(project_root: str, only_modules: set | None = None) -> Non
351
389
 
352
390
 
353
391
  def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
354
- """Write root ARCHITECTURE.mmd showing module-level dependencies."""
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
+ """
355
397
  module_list = sorted(modules.keys())
356
398
 
357
- # Build module -> set of modules it imports from
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
358
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()
359
415
 
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)
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)
368
421
 
369
422
  # Build mermaid lines
370
423
  lines = ["graph LR"]
@@ -395,7 +448,10 @@ def _write_root_diagram(conn, project_root: str, modules: dict) -> None:
395
448
 
396
449
 
397
450
  def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -> None:
398
- """Write per-module DIAGRAM.mmd showing function-level call graph."""
451
+ """Write per-module DIAGRAM.mmd showing function-level call graph.
452
+
453
+ Uses symbol_edges for intra-module edges.
454
+ """
399
455
  if not symbols:
400
456
  return
401
457
 
@@ -406,7 +462,7 @@ def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -
406
462
  lines = ["graph TD"]
407
463
 
408
464
  # Node declarations for all symbols with interesting kinds
409
- interesting = [s for s in symbols if s["kind"] in ("function", "method", "class")]
465
+ interesting = [s for s in symbols if s["kind"] in ("function", "method", "class", "definition")]
410
466
  if not interesting:
411
467
  interesting = symbols
412
468
 
@@ -414,7 +470,7 @@ def _write_module_diagram(conn, project_root: str, module: str, symbols: list) -
414
470
  safe_id = _mermaid_id(f"{sym['name']}_{sym['id']}")
415
471
  lines.append(f" {safe_id}[{sym['name']}]")
416
472
 
417
- # Edge declarations — only intra-module edges
473
+ # Edge declarations — only intra-module edges via symbol_edges
418
474
  edges_added = False
419
475
  for sym in interesting:
420
476
  callees = _get_callees(conn, sym["id"])
@@ -485,41 +541,30 @@ def main() -> None:
485
541
  formatter_class=argparse.RawDescriptionHelpFormatter,
486
542
  epilog=(
487
543
  "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"
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"
492
548
  ),
493
549
  )
494
550
 
495
- subparsers = parser.add_subparsers(dest="command", required=True)
496
-
497
- # generate-intent subcommand
498
- intent_parser = subparsers.add_parser(
499
- "generate-intent",
551
+ parser.add_argument(
552
+ "--intent",
553
+ action="store_true",
500
554
  help="Generate root INTENT.md and per-module INTENT.md files.",
501
555
  )
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",
556
+ parser.add_argument(
557
+ "--diagram",
558
+ action="store_true",
516
559
  help="Generate root ARCHITECTURE.mmd and per-module DIAGRAM.mmd files.",
517
560
  )
518
- diag_parser.add_argument(
519
- "project_root",
520
- help="Path to the project root directory.",
561
+ parser.add_argument(
562
+ "--project-root",
563
+ metavar="PATH",
564
+ default=os.getcwd(),
565
+ help="Path to the project root directory (default: cwd).",
521
566
  )
522
- diag_parser.add_argument(
567
+ parser.add_argument(
523
568
  "--files",
524
569
  metavar="FILE_LIST",
525
570
  default=None,
@@ -528,17 +573,18 @@ def main() -> None:
528
573
 
529
574
  args = parser.parse_args()
530
575
 
576
+ if not args.intent and not args.diagram:
577
+ parser.print_help()
578
+ sys.exit(1)
579
+
531
580
  only_modules: set | None = None
532
581
  if args.files:
533
582
  only_modules = _files_to_modules(args.files)
534
583
 
535
- if args.command == "generate-intent":
584
+ if args.intent:
536
585
  generate_intent(args.project_root, only_modules)
537
- elif args.command == "generate-diagrams":
586
+ if args.diagram:
538
587
  generate_diagrams(args.project_root, only_modules)
539
- else:
540
- parser.print_help()
541
- sys.exit(1)
542
588
 
543
589
 
544
590
  if __name__ == "__main__":