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.
- graphstack/__init__.py +12 -0
- graphstack/__main__.py +10 -0
- graphstack/assets/docs/CURSOR_PROMPTS.md +215 -0
- graphstack/assets/handoff/BOOTSTRAP.md +73 -0
- graphstack/assets/handoff/BRIEF.md +66 -0
- graphstack/assets/handoff/REVIEW.md +7 -0
- graphstack/assets/handoff/board/README.md +60 -0
- graphstack/assets/orchestrator/ORCHESTRATOR.md +416 -0
- graphstack/assets/orchestrator/TOKEN_OPTIMIZER.md +319 -0
- graphstack/assets/scripts/board.ps1 +37 -0
- graphstack/assets/scripts/board.sh +22 -0
- graphstack/assets/scripts/gate-hook.ps1 +41 -0
- graphstack/assets/scripts/gate-hook.sh +26 -0
- graphstack/assets/scripts/post-commit +20 -0
- graphstack/assets/scripts/post-commit.ps1 +44 -0
- graphstack/board.py +361 -0
- graphstack/bootstrap.py +50 -0
- graphstack/cli.py +99 -0
- graphstack/compact/__init__.py +9 -0
- graphstack/compact/__pycache__/__init__.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/base.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/generic.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/git.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/registry.cpython-311.pyc +0 -0
- graphstack/compact/base.py +115 -0
- graphstack/compact/generic.py +90 -0
- graphstack/compact/git.py +167 -0
- graphstack/compact/registry.py +47 -0
- graphstack/constants.py +38 -0
- graphstack/gate.py +429 -0
- graphstack/graph.py +143 -0
- graphstack/hook.py +144 -0
- graphstack/init_cmd.py +113 -0
- graphstack/installer.py +366 -0
- graphstack/platform_utils.py +127 -0
- graphstack/run.py +103 -0
- graphstack/state.py +117 -0
- graphstack/tests/__init__.py +0 -0
- graphstack/tests/conftest.py +30 -0
- graphstack/tests/test_assets.py +35 -0
- graphstack/tests/test_board.py +166 -0
- graphstack/tests/test_compact.py +93 -0
- graphstack/tests/test_gate.py +406 -0
- graphstack/tests/test_graph.py +60 -0
- graphstack/tests/test_hook.py +57 -0
- graphstack/tests/test_init.py +58 -0
- graphstack/tests/test_installer.py +73 -0
- graphstack/tests/test_platform_utils.py +69 -0
- graphstack/tests/test_state.py +56 -0
- graphstack/tests/test_validate.py +204 -0
- graphstack/validate.py +469 -0
- mertcapkin_graphstack-4.5.1.dist-info/METADATA +720 -0
- mertcapkin_graphstack-4.5.1.dist-info/RECORD +57 -0
- mertcapkin_graphstack-4.5.1.dist-info/WHEEL +5 -0
- mertcapkin_graphstack-4.5.1.dist-info/entry_points.txt +2 -0
- mertcapkin_graphstack-4.5.1.dist-info/licenses/LICENSE +21 -0
- 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:]))
|