MertCapkin-GraphStack 4.5.1__py3-none-any.whl

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 (57) hide show
  1. graphstack/__init__.py +12 -0
  2. graphstack/__main__.py +10 -0
  3. graphstack/assets/docs/CURSOR_PROMPTS.md +215 -0
  4. graphstack/assets/handoff/BOOTSTRAP.md +73 -0
  5. graphstack/assets/handoff/BRIEF.md +66 -0
  6. graphstack/assets/handoff/REVIEW.md +7 -0
  7. graphstack/assets/handoff/board/README.md +60 -0
  8. graphstack/assets/orchestrator/ORCHESTRATOR.md +416 -0
  9. graphstack/assets/orchestrator/TOKEN_OPTIMIZER.md +319 -0
  10. graphstack/assets/scripts/board.ps1 +37 -0
  11. graphstack/assets/scripts/board.sh +22 -0
  12. graphstack/assets/scripts/gate-hook.ps1 +41 -0
  13. graphstack/assets/scripts/gate-hook.sh +26 -0
  14. graphstack/assets/scripts/post-commit +20 -0
  15. graphstack/assets/scripts/post-commit.ps1 +44 -0
  16. graphstack/board.py +361 -0
  17. graphstack/bootstrap.py +50 -0
  18. graphstack/cli.py +99 -0
  19. graphstack/compact/__init__.py +9 -0
  20. graphstack/compact/__pycache__/__init__.cpython-311.pyc +0 -0
  21. graphstack/compact/__pycache__/base.cpython-311.pyc +0 -0
  22. graphstack/compact/__pycache__/generic.cpython-311.pyc +0 -0
  23. graphstack/compact/__pycache__/git.cpython-311.pyc +0 -0
  24. graphstack/compact/__pycache__/registry.cpython-311.pyc +0 -0
  25. graphstack/compact/base.py +115 -0
  26. graphstack/compact/generic.py +90 -0
  27. graphstack/compact/git.py +167 -0
  28. graphstack/compact/registry.py +47 -0
  29. graphstack/constants.py +38 -0
  30. graphstack/gate.py +429 -0
  31. graphstack/graph.py +143 -0
  32. graphstack/hook.py +144 -0
  33. graphstack/init_cmd.py +113 -0
  34. graphstack/installer.py +366 -0
  35. graphstack/platform_utils.py +127 -0
  36. graphstack/run.py +103 -0
  37. graphstack/state.py +117 -0
  38. graphstack/tests/__init__.py +0 -0
  39. graphstack/tests/conftest.py +30 -0
  40. graphstack/tests/test_assets.py +35 -0
  41. graphstack/tests/test_board.py +166 -0
  42. graphstack/tests/test_compact.py +93 -0
  43. graphstack/tests/test_gate.py +406 -0
  44. graphstack/tests/test_graph.py +60 -0
  45. graphstack/tests/test_hook.py +57 -0
  46. graphstack/tests/test_init.py +58 -0
  47. graphstack/tests/test_installer.py +73 -0
  48. graphstack/tests/test_platform_utils.py +69 -0
  49. graphstack/tests/test_state.py +56 -0
  50. graphstack/tests/test_validate.py +204 -0
  51. graphstack/validate.py +469 -0
  52. mertcapkin_graphstack-4.5.1.dist-info/METADATA +720 -0
  53. mertcapkin_graphstack-4.5.1.dist-info/RECORD +57 -0
  54. mertcapkin_graphstack-4.5.1.dist-info/WHEEL +5 -0
  55. mertcapkin_graphstack-4.5.1.dist-info/entry_points.txt +2 -0
  56. mertcapkin_graphstack-4.5.1.dist-info/licenses/LICENSE +21 -0
  57. mertcapkin_graphstack-4.5.1.dist-info/top_level.txt +1 -0
graphstack/gate.py ADDED
@@ -0,0 +1,429 @@
1
+ """Deterministic process gate — enforces GraphStack handoff discipline.
2
+
3
+ Three entry points:
4
+
5
+ - ``gate check [--json]`` — CI / manual rule evaluation (exit 0 pass, 1 fail)
6
+ - ``gate hook cursor`` — Cursor hooks adapter (stdin payload → stdout JSON)
7
+ - ``gate hook claude`` — Claude Code hooks adapter (stdin payload → stdout JSON)
8
+
9
+ Rules:
10
+ R1 ``git commit`` touching code paths while board doing/ is empty → DENY
11
+ R2 Edit/Write on a code path (Claude Code PreToolUse) while doing/ empty → DENY
12
+ R3 doing/ has a task but BRIEF.md is still the template → DENY commit
13
+ R4 (stop events) doing/ task exists but STATE.json is older than the task
14
+ claim → advisory warning, never blocks
15
+
16
+ Design constraints (do not weaken):
17
+ - FAIL OPEN: any internal error → allow + warning on stderr. A crashing gate
18
+ that blocks all work is worse than no gate.
19
+ - Cursor honors only ``deny`` reliably → rules are deny-or-silent.
20
+ - Claude Code deny requires exit code 0 + ``hookSpecificOutput`` wrapper.
21
+ - Bypass: env ``GRAPHSTACK_GATE=off`` or ``handoff/.gate-off`` file.
22
+ - Strict: env ``GRAPHSTACK_GATE=strict`` — internal hook errors deny instead of fail-open.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ from .constants import DOING_DIR, GATE_OFF_FILE, HANDOFF_DIR, NON_CODE_PREFIXES
35
+ from .platform_utils import echo, git_available, run_git
36
+ from .state import load_state
37
+ from .validate import _brief_is_template
38
+
39
+ GIT_COMMIT_RE = re.compile(r"\bgit\b[^|&;]*\bcommit\b")
40
+
41
+ MSG_NO_TASK = (
42
+ "GraphStack gate: no task in handoff/board/doing/. "
43
+ "Process requires: Architect writes handoff/BRIEF.md, then "
44
+ "'python -m graphstack board new <id> <title>' and "
45
+ "'board claim <id> builder' BEFORE changing code. "
46
+ "(Bypass: GRAPHSTACK_GATE=off)"
47
+ )
48
+ MSG_TEMPLATE_BRIEF = (
49
+ "GraphStack gate: a task is in doing/ but handoff/BRIEF.md is still the "
50
+ "template. Architect must write the brief before code is committed. "
51
+ "(Bypass: GRAPHSTACK_GATE=off)"
52
+ )
53
+ MSG_STALE_STATE = (
54
+ "GraphStack gate (advisory): task in doing/ but handoff/STATE.json was not "
55
+ "updated this cycle. Run: python -m graphstack state set --role <role> "
56
+ "--task <id>"
57
+ )
58
+
59
+
60
+ def gate_disabled() -> bool:
61
+ if os.environ.get("GRAPHSTACK_GATE", "").lower() in ("off", "0", "false"):
62
+ return True
63
+ return GATE_OFF_FILE.exists()
64
+
65
+
66
+ def gate_strict() -> bool:
67
+ """When True, hook internal errors deny instead of fail-open."""
68
+ return os.environ.get("GRAPHSTACK_GATE", "").lower() in (
69
+ "strict", "fail-closed", "failclosed",
70
+ )
71
+
72
+
73
+ def _extract_file_path(tool_input: dict) -> str:
74
+ for key in ("file_path", "path", "target_file"):
75
+ val = tool_input.get(key)
76
+ if val:
77
+ return str(val)
78
+ return ""
79
+
80
+
81
+ def is_code_path(path: str) -> bool:
82
+ """Anything outside handoff/graph/IDE config and root-level *.md is code."""
83
+ p = path.replace("\\", "/")
84
+ while p.startswith("./"):
85
+ p = p[2:]
86
+ if not p:
87
+ return False
88
+ if any(p.startswith(prefix) for prefix in NON_CODE_PREFIXES):
89
+ return False
90
+ if "/" not in p and p.endswith(".md"):
91
+ return False
92
+ return True
93
+
94
+
95
+ def _doing_tasks() -> list[Path]:
96
+ if not DOING_DIR.is_dir():
97
+ return []
98
+ return sorted(DOING_DIR.glob("*.json"))
99
+
100
+
101
+ def _brief_is_unwritten() -> bool:
102
+ brief = HANDOFF_DIR / "BRIEF.md"
103
+ try:
104
+ return _brief_is_template(brief.read_text(encoding="utf-8"))
105
+ except OSError:
106
+ return True
107
+
108
+
109
+ def _changed_files(*git_args: str) -> list[str]:
110
+ if not git_available():
111
+ return []
112
+ proc = run_git(*git_args)
113
+ if proc.returncode != 0 or not proc.stdout:
114
+ return []
115
+ return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
116
+
117
+
118
+ def _commit_candidate_files(command: str) -> list[str]:
119
+ """Files a ``git commit`` command would plausibly commit.
120
+
121
+ Staged files, plus modified tracked files for ``-a`` commits, plus any
122
+ command token that exists on disk (covers ``git add X && git commit``
123
+ where X is not staged yet when the hook fires).
124
+ """
125
+ files = _changed_files("diff", "--cached", "--name-only")
126
+ if re.search(r"\s(-a\b|--all\b|-am\b)", command):
127
+ files += _changed_files("diff", "--name-only")
128
+ for token in re.split(r"[\s'\"]+", command):
129
+ cleaned = token.strip("&|;")
130
+ if not cleaned or cleaned.startswith("-"):
131
+ continue
132
+ try:
133
+ if Path(cleaned).is_file():
134
+ files.append(cleaned)
135
+ except OSError:
136
+ continue
137
+ return sorted(set(files))
138
+
139
+
140
+ # ---------------------------------------------------------------- rule logic
141
+
142
+ def evaluate_command(command: str) -> tuple[bool, str | None]:
143
+ """R1 + R3 for shell commands. Returns (allow, deny_reason)."""
144
+ if gate_disabled():
145
+ return True, None
146
+ if not GIT_COMMIT_RE.search(command):
147
+ return True, None
148
+
149
+ doing = _doing_tasks()
150
+ candidates = _commit_candidate_files(command)
151
+ touches_code = any(is_code_path(f) for f in candidates)
152
+
153
+ if not doing and touches_code:
154
+ return False, MSG_NO_TASK # R1
155
+ if doing and touches_code and _brief_is_unwritten():
156
+ return False, MSG_TEMPLATE_BRIEF # R3
157
+ return True, None
158
+
159
+
160
+ def evaluate_file_edit(file_path: str) -> tuple[bool, str | None]:
161
+ """R2 for Edit/Write tool calls. Returns (allow, deny_reason)."""
162
+ if gate_disabled():
163
+ return True, None
164
+ try:
165
+ rel = os.path.relpath(file_path, Path.cwd())
166
+ except ValueError: # different drive on Windows
167
+ return True, None
168
+ if rel.startswith(".."):
169
+ return True, None # outside this project — not ours to gate
170
+ if is_code_path(rel) and not _doing_tasks():
171
+ return False, MSG_NO_TASK
172
+ return True, None
173
+
174
+
175
+ def evaluate_pretooluse(tool_name: str, tool_input: dict) -> tuple[bool, str | None]:
176
+ """R1 + R2 for generic PreToolUse / preToolUse events."""
177
+ if gate_disabled():
178
+ return True, None
179
+ tool = tool_name.strip()
180
+ write_tools = {"Write", "Edit", "Delete", "TabWrite", "MultiEdit", "NotebookEdit"}
181
+ if tool in write_tools:
182
+ path = _extract_file_path(tool_input)
183
+ if path:
184
+ return evaluate_file_edit(path)
185
+ if tool in ("Shell", "Bash"):
186
+ return evaluate_command(str(tool_input.get("command", "")))
187
+ return True, None
188
+
189
+
190
+ def evaluate_stop() -> str | None:
191
+ """R4 — advisory only. Returns a warning message or None."""
192
+ if gate_disabled():
193
+ return None
194
+ doing = _doing_tasks()
195
+ if not doing:
196
+ return None
197
+ state = load_state()
198
+ if state is None:
199
+ return MSG_STALE_STATE
200
+ try:
201
+ task = json.loads(doing[0].read_text(encoding="utf-8"))
202
+ except (OSError, json.JSONDecodeError):
203
+ return None
204
+ started = task.get("started_at") or ""
205
+ updated = state.get("updated_at") or ""
206
+ if started and updated < started: # ISO-8601 strings compare lexically
207
+ return MSG_STALE_STATE
208
+ return None
209
+
210
+
211
+ # ---------------------------------------------------------------- gate check
212
+
213
+ def run_check(argv: list[str]) -> int:
214
+ parser = argparse.ArgumentParser(
215
+ prog="graphstack gate check",
216
+ description="Evaluate GraphStack process-gate rules (CI / manual).",
217
+ )
218
+ parser.add_argument("--json", action="store_true")
219
+ args = parser.parse_args(argv)
220
+
221
+ failures: list[str] = []
222
+ warnings: list[str] = []
223
+
224
+ if gate_disabled():
225
+ warnings.append("gate bypassed (GRAPHSTACK_GATE=off or handoff/.gate-off)")
226
+ else:
227
+ doing = _doing_tasks()
228
+ dirty = _changed_files("status", "--porcelain")
229
+ dirty_code = [
230
+ line.split(maxsplit=1)[1] if " " in line else line
231
+ for line in dirty
232
+ if line and is_code_path(line.split(maxsplit=1)[-1])
233
+ ]
234
+ if not doing and dirty_code:
235
+ failures.append(
236
+ f"{len(dirty_code)} uncommitted code change(s) but doing/ is "
237
+ f"empty — claim a board task first (e.g. {dirty_code[0]})"
238
+ )
239
+ if doing and _brief_is_unwritten():
240
+ failures.append("task in doing/ but handoff/BRIEF.md is still the template")
241
+ stale = evaluate_stop()
242
+ if stale:
243
+ warnings.append(stale)
244
+
245
+ if args.json:
246
+ echo(json.dumps({"ok": not failures, "failures": failures,
247
+ "warnings": warnings}, ensure_ascii=False))
248
+ else:
249
+ for msg in failures:
250
+ echo(f" [FAIL] {msg}")
251
+ for msg in warnings:
252
+ echo(f" [WARN] {msg}")
253
+ echo(f" Gate: {'FAIL' if failures else 'PASS'} "
254
+ f"({len(failures)} failure(s), {len(warnings)} warning(s))")
255
+ return 1 if failures else 0
256
+
257
+
258
+ # ------------------------------------------------------------- hook adapters
259
+
260
+ def _read_stdin_json() -> dict:
261
+ raw = sys.stdin.read()
262
+ if not raw.strip():
263
+ return {}
264
+ data = json.loads(raw)
265
+ return data if isinstance(data, dict) else {}
266
+
267
+
268
+ def _emit(payload: dict) -> None:
269
+ print(json.dumps(payload, ensure_ascii=False), flush=True)
270
+
271
+
272
+ def _cursor_deny(reason: str) -> None:
273
+ _emit({
274
+ "continue": False,
275
+ "permission": "deny",
276
+ "user_message": reason,
277
+ "agent_message": reason,
278
+ })
279
+
280
+
281
+ def _cursor_allow() -> None:
282
+ _emit({"continue": True, "permission": "allow"})
283
+
284
+
285
+ def _cursor_pretool_deny(reason: str) -> None:
286
+ _emit({"permission": "deny", "user_message": reason, "agent_message": reason})
287
+
288
+
289
+ def _cursor_pretool_allow() -> None:
290
+ _emit({"permission": "allow"})
291
+
292
+
293
+ def _handle_gate_error(cursor: bool, *, pretool: bool, exc: Exception) -> int:
294
+ print(f"graphstack gate: internal error: {exc}", file=sys.stderr)
295
+ if gate_strict():
296
+ msg = (
297
+ "GraphStack gate (strict): internal error — action denied. "
298
+ "Fix the gate or set GRAPHSTACK_GATE=off temporarily."
299
+ )
300
+ if pretool:
301
+ _cursor_pretool_deny(msg)
302
+ elif cursor:
303
+ _cursor_deny(msg)
304
+ else:
305
+ _emit({"hookSpecificOutput": {
306
+ "hookEventName": "PreToolUse",
307
+ "permissionDecision": "deny",
308
+ "permissionDecisionReason": msg,
309
+ }})
310
+ return 0
311
+ print("graphstack gate: failing open (default). Use GRAPHSTACK_GATE=strict to deny.",
312
+ file=sys.stderr)
313
+ if pretool:
314
+ _cursor_pretool_allow()
315
+ elif cursor:
316
+ _cursor_allow()
317
+ else:
318
+ _emit({})
319
+ return 0
320
+
321
+
322
+ def hook_cursor() -> int:
323
+ """Cursor adapter. Responses use snake_case; only deny is load-bearing."""
324
+ try:
325
+ data = _read_stdin_json()
326
+ event = data.get("hook_event_name", "")
327
+
328
+ if event == "beforeShellExecution":
329
+ allow, reason = evaluate_command(str(data.get("command", "")))
330
+ if not allow:
331
+ _cursor_deny(reason or MSG_NO_TASK)
332
+ return 0
333
+ _cursor_allow()
334
+ return 0
335
+
336
+ if event == "preToolUse":
337
+ allow, reason = evaluate_pretooluse(
338
+ str(data.get("tool_name", "")),
339
+ data.get("tool_input") or {},
340
+ )
341
+ if not allow:
342
+ _cursor_pretool_deny(reason or MSG_NO_TASK)
343
+ return 0
344
+ _cursor_pretool_allow()
345
+ return 0
346
+
347
+ if event == "afterFileEdit":
348
+ # Cursor has no before-edit blocking event — advisory only.
349
+ edited = str(data.get("file_path", ""))
350
+ if edited and not gate_disabled() and not _doing_tasks():
351
+ try:
352
+ rel = os.path.relpath(edited, Path.cwd())
353
+ except ValueError:
354
+ rel = edited
355
+ if not rel.startswith("..") and is_code_path(rel):
356
+ _emit({"agent_message": MSG_NO_TASK})
357
+ return 0
358
+ _emit({})
359
+ return 0
360
+
361
+ if event == "stop":
362
+ warning = evaluate_stop()
363
+ _emit({"agent_message": warning} if warning else {})
364
+ return 0
365
+
366
+ _cursor_allow()
367
+ return 0
368
+ except Exception as exc: # noqa: BLE001
369
+ return _handle_gate_error(True, pretool=False, exc=exc)
370
+
371
+
372
+ def hook_claude() -> int:
373
+ """Claude Code adapter. Deny = exit 0 + hookSpecificOutput wrapper."""
374
+ try:
375
+ data = _read_stdin_json()
376
+ event = data.get("hook_event_name", "")
377
+ tool = data.get("tool_name", "")
378
+ tool_input = data.get("tool_input") or {}
379
+
380
+ if event == "PreToolUse":
381
+ allow, reason = evaluate_pretooluse(tool, tool_input)
382
+ if not allow:
383
+ _emit({"hookSpecificOutput": {
384
+ "hookEventName": "PreToolUse",
385
+ "permissionDecision": "deny",
386
+ "permissionDecisionReason": reason,
387
+ }})
388
+ return 0
389
+ _emit({})
390
+ return 0
391
+
392
+ if event == "Stop":
393
+ warning = evaluate_stop()
394
+ _emit({"systemMessage": warning} if warning else {})
395
+ return 0
396
+
397
+ _emit({})
398
+ return 0
399
+ except Exception as exc: # noqa: BLE001
400
+ return _handle_gate_error(False, pretool=False, exc=exc)
401
+
402
+
403
+ # ------------------------------------------------------------------ dispatch
404
+
405
+ def run(argv: list[str]) -> int:
406
+ if not argv or argv[0] in ("-h", "--help", "help"):
407
+ echo("GraphStack Gate — commands:")
408
+ echo(" check [--json] evaluate gate rules (exit 1 on failure)")
409
+ echo(" hook cursor Cursor hooks adapter (stdin → stdout)")
410
+ echo(" hook claude Claude Code hooks adapter (stdin → stdout)")
411
+ echo("Bypass: GRAPHSTACK_GATE=off or create handoff/.gate-off")
412
+ echo("Strict: GRAPHSTACK_GATE=strict (deny on internal hook errors)")
413
+ return 0
414
+ if argv[0] == "check":
415
+ return run_check(argv[1:])
416
+ if argv[0] == "hook":
417
+ platform = argv[1] if len(argv) > 1 else ""
418
+ if platform == "cursor":
419
+ return hook_cursor()
420
+ if platform == "claude":
421
+ return hook_claude()
422
+ echo(f"Unknown hook platform: '{platform}' (expected cursor|claude)")
423
+ return 2
424
+ echo(f"Unknown gate command: '{argv[0]}'")
425
+ return 2
426
+
427
+
428
+ if __name__ == "__main__":
429
+ sys.exit(run(sys.argv[1:]))
graphstack/graph.py ADDED
@@ -0,0 +1,143 @@
1
+ """Graphify query wrapper — graph-first reads without raw file grepping.
2
+
3
+ Delegates to the ``graphify`` CLI (``graphify query``, ``path``, ``explain``,
4
+ ``update``). Prefer this over reading ``graph.json`` manually or loading the
5
+ full ``GRAPH_REPORT.md`` for targeted questions.
6
+
7
+ Usage::
8
+
9
+ python -m graphstack graph query "who calls login"
10
+ python -m graphstack graph path src/auth/login.ts src/utils/crypto.ts
11
+ python -m graphstack graph explain "login()"
12
+ python -m graphstack graph update .
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .constants import GRAPH_JSON, GRAPHIFY_OUT
24
+ from .platform_utils import echo, find_python, graphify_available
25
+
26
+
27
+ def graphify_argv(*args: str) -> list[str]:
28
+ """Return argv prefix to invoke graphify (PATH binary or ``python -m graphify``)."""
29
+ if graphify_available():
30
+ return ["graphify", *args]
31
+ return [*find_python(), "-m", "graphify", *args]
32
+
33
+
34
+ def _default_graph() -> str:
35
+ return str(GRAPH_JSON)
36
+
37
+
38
+ def _run_graphify(sub_args: list[str]) -> int:
39
+ if not graphify_available() and not shutil.which(find_python()[0]):
40
+ echo("graphify not found. Install with: pip install \"graphifyy>=0.7,<0.9\"")
41
+ return 1
42
+ proc = subprocess.run(
43
+ graphify_argv(*sub_args),
44
+ check=False,
45
+ encoding="utf-8",
46
+ errors="replace",
47
+ )
48
+ return proc.returncode
49
+
50
+
51
+ def _add_graph_arg(parser: argparse.ArgumentParser) -> None:
52
+ parser.add_argument(
53
+ "--graph",
54
+ default=_default_graph(),
55
+ help=f"path to graph.json (default: {GRAPH_JSON})",
56
+ )
57
+
58
+
59
+ def run_query(argv: list[str]) -> int:
60
+ parser = argparse.ArgumentParser(prog="graphstack graph query")
61
+ parser.add_argument("question", help="natural-language graph question")
62
+ _add_graph_arg(parser)
63
+ parser.add_argument("--budget", type=int, default=None, help="token budget cap")
64
+ parser.add_argument("--dfs", action="store_true", help="depth-first instead of BFS")
65
+ args = parser.parse_args(argv)
66
+
67
+ sub = ["query", args.question, "--graph", args.graph]
68
+ if args.budget is not None:
69
+ sub.extend(["--budget", str(args.budget)])
70
+ if args.dfs:
71
+ sub.append("--dfs")
72
+ return _run_graphify(sub)
73
+
74
+
75
+ def run_path(argv: list[str]) -> int:
76
+ parser = argparse.ArgumentParser(prog="graphstack graph path")
77
+ parser.add_argument("start", help="start node label or file path")
78
+ parser.add_argument("end", help="end node label or file path")
79
+ _add_graph_arg(parser)
80
+ args = parser.parse_args(argv)
81
+ return _run_graphify(["path", args.start, args.end, "--graph", args.graph])
82
+
83
+
84
+ def run_explain(argv: list[str]) -> int:
85
+ parser = argparse.ArgumentParser(prog="graphstack graph explain")
86
+ parser.add_argument("node", help="node label to explain")
87
+ _add_graph_arg(parser)
88
+ args = parser.parse_args(argv)
89
+ return _run_graphify(["explain", args.node, "--graph", args.graph])
90
+
91
+
92
+ def run_update(argv: list[str]) -> int:
93
+ parser = argparse.ArgumentParser(
94
+ prog="graphstack graph update",
95
+ description="Re-extract code files into the graph (no LLM, local AST only).",
96
+ )
97
+ parser.add_argument(
98
+ "path",
99
+ nargs="?",
100
+ default=".",
101
+ help="project root to update (default: .)",
102
+ )
103
+ parser.add_argument(
104
+ "--force",
105
+ action="store_true",
106
+ help="overwrite graph even if node count drops (refactors)",
107
+ )
108
+ args = parser.parse_args(argv)
109
+ sub = ["update", args.path]
110
+ if args.force:
111
+ sub.append("--force")
112
+ return _run_graphify(sub)
113
+
114
+
115
+ def run(argv: list[str]) -> int:
116
+ if not argv or argv[0] in ("-h", "--help", "help"):
117
+ echo("GraphStack Graph — graphify wrappers (graph-first reads):")
118
+ echo(" query <question> [--graph PATH] [--budget N] [--dfs]")
119
+ echo(" path <start> <end> [--graph PATH]")
120
+ echo(" explain <node> [--graph PATH]")
121
+ echo(" update [path] [--force] AST-only graph refresh")
122
+ echo("")
123
+ echo("Requires graphify on PATH or: pip install \"graphifyy>=0.7,<0.9\"")
124
+ if GRAPHIFY_OUT.is_dir():
125
+ echo(f"Graph dir: {GRAPHIFY_OUT}/")
126
+ return 0
127
+
128
+ cmd, rest = argv[0], argv[1:]
129
+ if cmd == "query":
130
+ return run_query(rest)
131
+ if cmd == "path":
132
+ return run_path(rest)
133
+ if cmd == "explain":
134
+ return run_explain(rest)
135
+ if cmd == "update":
136
+ return run_update(rest)
137
+
138
+ echo(f"Unknown graph command: '{cmd}'")
139
+ return 2
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(run(sys.argv[1:]))