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/board.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""GNAP board manager — pure Python port of ``scripts/board.sh``.
|
|
2
|
+
|
|
3
|
+
JSON schema is preserved verbatim so existing ``handoff/board/*.json`` files
|
|
4
|
+
created under v3.0.0 continue to work without migration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .constants import BOARD_DIR, DOING_DIR, DONE_DIR, TODO_DIR
|
|
15
|
+
from .platform_utils import echo, run_git, utc_now_iso
|
|
16
|
+
|
|
17
|
+
VALID_ROLES = ("architect", "builder", "reviewer", "qa", "ship", "bootstrapper")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_task(path: Path) -> dict:
|
|
21
|
+
with path.open(encoding="utf-8") as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _save_task(path: Path, data: dict) -> None:
|
|
26
|
+
with path.open("w", encoding="utf-8") as f:
|
|
27
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
28
|
+
f.write("\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get(data: dict, key: str) -> str:
|
|
32
|
+
value = data.get(key)
|
|
33
|
+
if value in (None, ""):
|
|
34
|
+
return "-"
|
|
35
|
+
return str(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _print_task(path: Path) -> None:
|
|
39
|
+
try:
|
|
40
|
+
data = _load_task(path)
|
|
41
|
+
except (OSError, json.JSONDecodeError):
|
|
42
|
+
echo(f" ! could not read {path.name}")
|
|
43
|
+
return
|
|
44
|
+
echo(
|
|
45
|
+
f" {_get(data, 'id'):<32} {_get(data, 'status'):<10} "
|
|
46
|
+
f"{_get(data, 'assigned_to'):<12} {_get(data, 'title')}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _iter_tasks(directory: Path) -> list[Path]:
|
|
51
|
+
if not directory.is_dir():
|
|
52
|
+
return []
|
|
53
|
+
return sorted(directory.glob("*.json"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git_commit_board(message: str) -> None:
|
|
57
|
+
"""Stage the board directory and commit silently — never fails the command."""
|
|
58
|
+
run_git("add", str(BOARD_DIR))
|
|
59
|
+
run_git("commit", "-m", message)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_status(_args: argparse.Namespace) -> int:
|
|
63
|
+
echo("")
|
|
64
|
+
echo("📋 GraphStack GNAP Board")
|
|
65
|
+
echo("=" * 56)
|
|
66
|
+
echo(f" {'TASK ID':<32} {'STATUS':<10} {'ASSIGNED':<12} TITLE")
|
|
67
|
+
echo(" " + "-" * 54)
|
|
68
|
+
|
|
69
|
+
todo = _iter_tasks(TODO_DIR)
|
|
70
|
+
doing = _iter_tasks(DOING_DIR)
|
|
71
|
+
done = _iter_tasks(DONE_DIR)
|
|
72
|
+
|
|
73
|
+
for f in todo + doing + done:
|
|
74
|
+
_print_task(f)
|
|
75
|
+
|
|
76
|
+
if not (todo or doing or done):
|
|
77
|
+
echo(" (no tasks yet)")
|
|
78
|
+
|
|
79
|
+
echo("")
|
|
80
|
+
echo(f" Todo: {len(todo)} | In Progress: {len(doing)} | Done: {len(done)}")
|
|
81
|
+
echo("")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_new(args: argparse.Namespace) -> int:
|
|
86
|
+
task_id: str = args.task_id
|
|
87
|
+
title = " ".join(args.title) if args.title else "New task"
|
|
88
|
+
|
|
89
|
+
TODO_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
dst = TODO_DIR / f"{task_id}.json"
|
|
91
|
+
if dst.exists():
|
|
92
|
+
echo(f"❌ Task '{task_id}' already exists in todo/")
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
task = {
|
|
96
|
+
"id": task_id,
|
|
97
|
+
"title": title,
|
|
98
|
+
"created_at": utc_now_iso(),
|
|
99
|
+
"created_by": "architect",
|
|
100
|
+
"brief": "handoff/BRIEF.md",
|
|
101
|
+
"graph_nodes": [],
|
|
102
|
+
"criteria_count": 0,
|
|
103
|
+
"priority": "normal",
|
|
104
|
+
"status": "todo",
|
|
105
|
+
"assigned_to": None,
|
|
106
|
+
"started_at": None,
|
|
107
|
+
"completed_at": None,
|
|
108
|
+
"notes": "",
|
|
109
|
+
}
|
|
110
|
+
_save_task(dst, task)
|
|
111
|
+
_git_commit_board(f"board: new task {task_id} — {title}")
|
|
112
|
+
|
|
113
|
+
echo(f"✅ Task '{task_id}' created in todo/")
|
|
114
|
+
echo(f" Title: {title}")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_claim(args: argparse.Namespace) -> int:
|
|
119
|
+
task_id: str = args.task_id
|
|
120
|
+
role: str = args.role.lower()
|
|
121
|
+
|
|
122
|
+
if role not in VALID_ROLES:
|
|
123
|
+
echo(f"⚠️ Unknown role '{role}'. Continuing anyway "
|
|
124
|
+
f"(expected one of: {', '.join(VALID_ROLES)})")
|
|
125
|
+
|
|
126
|
+
src = TODO_DIR / f"{task_id}.json"
|
|
127
|
+
dst = DOING_DIR / f"{task_id}.json"
|
|
128
|
+
|
|
129
|
+
if not src.exists():
|
|
130
|
+
existing = DOING_DIR / f"{task_id}.json"
|
|
131
|
+
if existing.exists():
|
|
132
|
+
current_role = _load_task(existing).get("assigned_to") or "?"
|
|
133
|
+
echo(f"⚠️ Task '{task_id}' is already in doing/ "
|
|
134
|
+
f"(claimed by {current_role})")
|
|
135
|
+
return 0
|
|
136
|
+
if (DONE_DIR / f"{task_id}.json").exists():
|
|
137
|
+
echo(f"⚠️ Task '{task_id}' is already done.")
|
|
138
|
+
return 0
|
|
139
|
+
echo(f"❌ Task '{task_id}' not found in todo/")
|
|
140
|
+
echo(" Run: python -m graphstack board status")
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
data = _load_task(src)
|
|
144
|
+
data["status"] = "doing"
|
|
145
|
+
data["assigned_to"] = role
|
|
146
|
+
data["started_at"] = utc_now_iso()
|
|
147
|
+
_save_task(src, data)
|
|
148
|
+
|
|
149
|
+
DOING_DIR.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
src.replace(dst)
|
|
151
|
+
|
|
152
|
+
_git_commit_board(f"board: {role} claims {task_id}")
|
|
153
|
+
echo(f"✅ Task '{task_id}' claimed by {role}")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_complete(args: argparse.Namespace) -> int:
|
|
158
|
+
task_id: str = args.task_id
|
|
159
|
+
|
|
160
|
+
src = DOING_DIR / f"{task_id}.json"
|
|
161
|
+
dst = DONE_DIR / f"{task_id}.json"
|
|
162
|
+
|
|
163
|
+
if not src.exists():
|
|
164
|
+
if (DONE_DIR / f"{task_id}.json").exists():
|
|
165
|
+
echo(f"⚠️ Task '{task_id}' is already done.")
|
|
166
|
+
return 0
|
|
167
|
+
echo(f"❌ Task '{task_id}' not found in doing/")
|
|
168
|
+
echo(" Run: python -m graphstack board status")
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
data = _load_task(src)
|
|
172
|
+
data["status"] = "done"
|
|
173
|
+
data["completed_at"] = utc_now_iso()
|
|
174
|
+
_save_task(src, data)
|
|
175
|
+
|
|
176
|
+
DONE_DIR.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
src.replace(dst)
|
|
178
|
+
|
|
179
|
+
_git_commit_board(f"board: complete {task_id}")
|
|
180
|
+
echo(f"✅ Task '{task_id}' marked complete")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_list_done(args: argparse.Namespace) -> int:
|
|
185
|
+
limit = args.limit
|
|
186
|
+
done = _iter_tasks(DONE_DIR)
|
|
187
|
+
if not done:
|
|
188
|
+
echo("")
|
|
189
|
+
echo("📋 Done tasks: (none)")
|
|
190
|
+
echo("")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
if limit is not None and limit > 0:
|
|
194
|
+
done = done[-limit:]
|
|
195
|
+
|
|
196
|
+
echo("")
|
|
197
|
+
echo("📋 Done tasks")
|
|
198
|
+
echo("=" * 56)
|
|
199
|
+
echo(f" {'TASK ID':<32} {'COMPLETED':<22} TITLE")
|
|
200
|
+
echo(" " + "-" * 54)
|
|
201
|
+
for path in done:
|
|
202
|
+
try:
|
|
203
|
+
data = _load_task(path)
|
|
204
|
+
except (OSError, json.JSONDecodeError):
|
|
205
|
+
echo(f" ! could not read {path.name}")
|
|
206
|
+
continue
|
|
207
|
+
completed = _get(data, "completed_at")
|
|
208
|
+
echo(f" {_get(data, 'id'):<32} {completed:<22} {_get(data, 'title')}")
|
|
209
|
+
echo("")
|
|
210
|
+
echo(f" Showing {len(done)} task(s)")
|
|
211
|
+
echo("")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_reopen(args: argparse.Namespace) -> int:
|
|
216
|
+
task_id: str = args.task_id
|
|
217
|
+
dest = args.to.lower()
|
|
218
|
+
|
|
219
|
+
if dest not in ("todo", "doing"):
|
|
220
|
+
echo(f"❌ Invalid destination '{dest}'. Use: todo or doing")
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
src_done = DONE_DIR / f"{task_id}.json"
|
|
224
|
+
src_doing = DOING_DIR / f"{task_id}.json"
|
|
225
|
+
src_todo = TODO_DIR / f"{task_id}.json"
|
|
226
|
+
|
|
227
|
+
if src_todo.exists():
|
|
228
|
+
echo(f"⚠️ Task '{task_id}' is already in todo/")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
if src_doing.exists() and dest == "doing":
|
|
232
|
+
echo(f"⚠️ Task '{task_id}' is already in doing/")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
src: Path | None = None
|
|
236
|
+
if src_done.exists():
|
|
237
|
+
src = src_done
|
|
238
|
+
elif src_doing.exists() and dest == "todo":
|
|
239
|
+
src = src_doing
|
|
240
|
+
else:
|
|
241
|
+
echo(f"❌ Task '{task_id}' not found in done/ (or doing/ for todo reopen)")
|
|
242
|
+
echo(" Run: python -m graphstack board list-done")
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
data = _load_task(src)
|
|
246
|
+
data["status"] = dest
|
|
247
|
+
data["completed_at"] = None
|
|
248
|
+
if dest == "todo":
|
|
249
|
+
data["assigned_to"] = None
|
|
250
|
+
data["started_at"] = None
|
|
251
|
+
elif data.get("started_at") is None:
|
|
252
|
+
data["started_at"] = utc_now_iso()
|
|
253
|
+
|
|
254
|
+
dst_dir = TODO_DIR if dest == "todo" else DOING_DIR
|
|
255
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
dst = dst_dir / f"{task_id}.json"
|
|
257
|
+
_save_task(dst, data)
|
|
258
|
+
src.unlink()
|
|
259
|
+
|
|
260
|
+
_git_commit_board(f"board: reopen {task_id} -> {dest}")
|
|
261
|
+
echo(f"✅ Task '{task_id}' reopened to {dest}/")
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_log(_args: argparse.Namespace) -> int:
|
|
266
|
+
echo("")
|
|
267
|
+
echo("📜 Board History")
|
|
268
|
+
result = run_git("log", "--oneline", "--", str(BOARD_DIR))
|
|
269
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
270
|
+
echo(result.stdout.rstrip())
|
|
271
|
+
else:
|
|
272
|
+
echo("(no git history yet — initialize with: git init)")
|
|
273
|
+
echo("")
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
278
|
+
p = argparse.ArgumentParser(
|
|
279
|
+
prog="graphstack board",
|
|
280
|
+
description="GNAP board: todo → doing → done lifecycle.",
|
|
281
|
+
)
|
|
282
|
+
sub = p.add_subparsers(dest="action", required=True)
|
|
283
|
+
|
|
284
|
+
sub.add_parser("status", help="show full board status")
|
|
285
|
+
|
|
286
|
+
p_new = sub.add_parser("new", help="create a new task in todo/")
|
|
287
|
+
p_new.add_argument("task_id")
|
|
288
|
+
p_new.add_argument("title", nargs="*", help="task title (no quotes needed)")
|
|
289
|
+
|
|
290
|
+
p_claim = sub.add_parser("claim", help="move task from todo → doing")
|
|
291
|
+
p_claim.add_argument("task_id")
|
|
292
|
+
p_claim.add_argument("role")
|
|
293
|
+
|
|
294
|
+
p_complete = sub.add_parser("complete", help="move task from doing → done")
|
|
295
|
+
p_complete.add_argument("task_id")
|
|
296
|
+
|
|
297
|
+
p_reopen = sub.add_parser("reopen", help="move task from done/ back to todo/ or doing/")
|
|
298
|
+
p_reopen.add_argument("task_id")
|
|
299
|
+
p_reopen.add_argument(
|
|
300
|
+
"--to",
|
|
301
|
+
default="todo",
|
|
302
|
+
choices=("todo", "doing"),
|
|
303
|
+
help="destination column (default: todo)",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
p_list_done = sub.add_parser("list-done", help="list completed tasks only")
|
|
307
|
+
p_list_done.add_argument(
|
|
308
|
+
"--limit",
|
|
309
|
+
type=int,
|
|
310
|
+
default=None,
|
|
311
|
+
help="show only the last N completed tasks",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
sub.add_parser("log", help="show git history of board changes")
|
|
315
|
+
|
|
316
|
+
return p
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _print_help() -> None:
|
|
320
|
+
echo("")
|
|
321
|
+
echo("GraphStack Board — Commands:")
|
|
322
|
+
echo(" status show full board")
|
|
323
|
+
echo(" new <id> <title words...> create task (no quotes needed)")
|
|
324
|
+
echo(" claim <id> <role> claim task (builder/reviewer/qa)")
|
|
325
|
+
echo(" complete <id> mark done")
|
|
326
|
+
echo(" reopen <id> [--to todo|doing] move done task back to todo/doing")
|
|
327
|
+
echo(" list-done [--limit N] list completed tasks only")
|
|
328
|
+
echo(" log git history of board")
|
|
329
|
+
echo("")
|
|
330
|
+
echo("Examples:")
|
|
331
|
+
echo(" python -m graphstack board new add-rate-limit Add rate limiting to login")
|
|
332
|
+
echo(" python -m graphstack board claim add-rate-limit builder")
|
|
333
|
+
echo(" python -m graphstack board complete add-rate-limit")
|
|
334
|
+
echo("")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
_DISPATCH = {
|
|
338
|
+
"status": cmd_status,
|
|
339
|
+
"new": cmd_new,
|
|
340
|
+
"claim": cmd_claim,
|
|
341
|
+
"complete": cmd_complete,
|
|
342
|
+
"reopen": cmd_reopen,
|
|
343
|
+
"list-done": cmd_list_done,
|
|
344
|
+
"log": cmd_log,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def run(argv: list[str]) -> int:
|
|
349
|
+
if not argv or argv[0] in ("help", "-h", "--help"):
|
|
350
|
+
_print_help()
|
|
351
|
+
return 0
|
|
352
|
+
parser = _build_parser()
|
|
353
|
+
try:
|
|
354
|
+
args = parser.parse_args(argv)
|
|
355
|
+
except SystemExit as e:
|
|
356
|
+
return int(e.code) if isinstance(e.code, int) else 2
|
|
357
|
+
return _DISPATCH[args.action](args)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
sys.exit(run(sys.argv[1:]))
|
graphstack/bootstrap.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Dependency bootstrap helpers for one-shot ``graphstack init``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from .platform_utils import echo, find_python, graphify_available
|
|
8
|
+
|
|
9
|
+
# PyPI distribution name (``graphstack`` was taken). CLI command remains ``graphstack``.
|
|
10
|
+
PIP_SPEC = "MertCapkin_GraphStack[graphify]"
|
|
11
|
+
PIP_SPEC_GIT = (
|
|
12
|
+
"MertCapkin_GraphStack[graphify] @ git+https://github.com/MertCapkin/GraphStack.git"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pip_install(*specs: str, quiet: bool = True) -> int:
|
|
17
|
+
"""Install packages with the same Python running graphstack."""
|
|
18
|
+
if not specs:
|
|
19
|
+
return 0
|
|
20
|
+
cmd = [*find_python(), "-m", "pip", "install", "--upgrade"]
|
|
21
|
+
if quiet:
|
|
22
|
+
cmd.append("--quiet")
|
|
23
|
+
cmd.extend(specs)
|
|
24
|
+
echo(f" pip install {' '.join(specs)}")
|
|
25
|
+
return subprocess.run(cmd, check=False).returncode
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_graphify(*, install: bool = True) -> bool:
|
|
29
|
+
if graphify_available():
|
|
30
|
+
return True
|
|
31
|
+
if not install:
|
|
32
|
+
return False
|
|
33
|
+
echo("")
|
|
34
|
+
echo("Installing Graphify (graphifyy)...")
|
|
35
|
+
rc = pip_install("graphifyy>=0.7,<0.9")
|
|
36
|
+
return rc == 0 and graphify_available()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_graphstack_from_git() -> int:
|
|
40
|
+
"""Fallback when PyPI package is not published yet."""
|
|
41
|
+
echo("Trying GitHub install (PyPI fallback)...")
|
|
42
|
+
return pip_install(PIP_SPEC_GIT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_graphify_cursor_install() -> int:
|
|
46
|
+
if not graphify_available():
|
|
47
|
+
return 1
|
|
48
|
+
cmd = [*find_python(), "-m", "graphify", "cursor", "install"]
|
|
49
|
+
proc = subprocess.run(cmd, check=False)
|
|
50
|
+
return proc.returncode
|
graphstack/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Top-level CLI dispatcher.
|
|
2
|
+
|
|
3
|
+
Ten sub-commands:
|
|
4
|
+
- ``board`` — GNAP task board manager (replaces ``scripts/board.sh``)
|
|
5
|
+
- ``install`` — install GraphStack into a target project (replaces ``install.sh``)
|
|
6
|
+
- ``init`` — one-shot install + graph refresh + doctor
|
|
7
|
+
- ``hook`` — post-commit graph-update logic (replaces ``scripts/post-commit``)
|
|
8
|
+
- ``validate`` — check handoff layout, brief, board tasks, graph freshness
|
|
9
|
+
- ``doctor`` — human-friendly health report (same checks as validate)
|
|
10
|
+
- ``run`` — execute shell commands with token-safe output compaction
|
|
11
|
+
- ``gate`` — deterministic process gate (check / cursor hook / claude hook)
|
|
12
|
+
- ``state`` — machine-readable session state (handoff/STATE.json)
|
|
13
|
+
- ``graph`` — graphify query wrappers (query / path / explain / update)
|
|
14
|
+
|
|
15
|
+
Each sub-command parses its own arguments to keep the dispatcher minimal.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="graphstack",
|
|
29
|
+
description="GraphStack cross-platform helper (board / install / hook / validate / doctor / run).",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--version", action="version", version=f"graphstack {__version__}"
|
|
33
|
+
)
|
|
34
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
|
|
36
|
+
sub.add_parser("board", help="GNAP board commands", add_help=False)
|
|
37
|
+
sub.add_parser("install", help="Install GraphStack into a project", add_help=False)
|
|
38
|
+
sub.add_parser("init", help="Install + graph refresh + doctor", add_help=False)
|
|
39
|
+
sub.add_parser("hook", help="Run the post-commit hook logic", add_help=False)
|
|
40
|
+
sub.add_parser("validate", help="Validate handoff and graph layout", add_help=False)
|
|
41
|
+
sub.add_parser("doctor", help="Project health report", add_help=False)
|
|
42
|
+
sub.add_parser("run", help="Run shell command with compact output", add_help=False)
|
|
43
|
+
sub.add_parser("gate", help="Process gate (check / hook adapters)", add_help=False)
|
|
44
|
+
sub.add_parser("state", help="Session state (handoff/STATE.json)", add_help=False)
|
|
45
|
+
sub.add_parser("graph", help="Graphify query wrappers", add_help=False)
|
|
46
|
+
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str] | None = None) -> int:
|
|
51
|
+
"""Entry point for both ``python -m graphstack`` and unit tests."""
|
|
52
|
+
args = sys.argv[1:] if argv is None else argv
|
|
53
|
+
if not args or args[0] in ("-h", "--help"):
|
|
54
|
+
_build_parser().print_help()
|
|
55
|
+
return 0
|
|
56
|
+
if args[0] == "--version":
|
|
57
|
+
print(f"graphstack {__version__}")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
cmd, rest = args[0], args[1:]
|
|
61
|
+
|
|
62
|
+
if cmd == "board":
|
|
63
|
+
from .board import run as board_run
|
|
64
|
+
return board_run(rest)
|
|
65
|
+
if cmd == "install":
|
|
66
|
+
from .installer import run as install_run
|
|
67
|
+
return install_run(rest)
|
|
68
|
+
if cmd == "init":
|
|
69
|
+
from .init_cmd import run as init_run
|
|
70
|
+
return init_run(rest)
|
|
71
|
+
if cmd == "hook":
|
|
72
|
+
from .hook import run as hook_run
|
|
73
|
+
return hook_run(rest)
|
|
74
|
+
if cmd == "validate":
|
|
75
|
+
from .validate import run_validate
|
|
76
|
+
return run_validate(rest)
|
|
77
|
+
if cmd == "doctor":
|
|
78
|
+
from .validate import run_doctor
|
|
79
|
+
return run_doctor(rest)
|
|
80
|
+
if cmd == "run":
|
|
81
|
+
from .run import run as run_cmd
|
|
82
|
+
return run_cmd(rest)
|
|
83
|
+
if cmd == "gate":
|
|
84
|
+
from .gate import run as gate_run
|
|
85
|
+
return gate_run(rest)
|
|
86
|
+
if cmd == "state":
|
|
87
|
+
from .state import run as state_run
|
|
88
|
+
return state_run(rest)
|
|
89
|
+
if cmd == "graph":
|
|
90
|
+
from .graph import run as graph_run
|
|
91
|
+
return graph_run(rest)
|
|
92
|
+
|
|
93
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
94
|
+
_build_parser().print_help()
|
|
95
|
+
return 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Token-safe shell output compaction (independent implementation).
|
|
2
|
+
|
|
3
|
+
Preserves actionable detail (paths, errors, hunks). Falls back to raw output
|
|
4
|
+
when compaction would drop too much signal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .registry import compact_command_output
|
|
8
|
+
|
|
9
|
+
__all__ = ["compact_command_output"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Shared helpers for safe output compaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Lines matching these patterns are never dropped during truncation.
|
|
9
|
+
_CRITICAL_RE = re.compile(
|
|
10
|
+
r"(?i)(error|failed|failure|exception|traceback|fatal|panic|assertion|"
|
|
11
|
+
r"not found|cannot |can't |conflict|denied|fatal:|FAILED|ERROR\b|"
|
|
12
|
+
r"^\+{3}|^-{3}|^@@\s|^\?\?\s|^[MADRCU!]{1,2}\s)",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_DEFAULT_MAX_LINES = 120
|
|
16
|
+
_MIN_RETAINED_RATIO = 0.05 # if output shrinks below 5% of input, prefer raw
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CompactResult:
|
|
21
|
+
text: str
|
|
22
|
+
used_compactor: str
|
|
23
|
+
fell_back_to_raw: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_critical_line(line: str) -> bool:
|
|
27
|
+
stripped = line.strip()
|
|
28
|
+
if not stripped:
|
|
29
|
+
return False
|
|
30
|
+
return bool(_CRITICAL_RE.search(stripped))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dedupe_consecutive(lines: list[str]) -> list[str]:
|
|
34
|
+
if not lines:
|
|
35
|
+
return []
|
|
36
|
+
out: list[str] = []
|
|
37
|
+
prev = lines[0]
|
|
38
|
+
count = 1
|
|
39
|
+
for line in lines[1:]:
|
|
40
|
+
if line == prev:
|
|
41
|
+
count += 1
|
|
42
|
+
continue
|
|
43
|
+
if count > 1:
|
|
44
|
+
out.append(f"{prev} (×{count})")
|
|
45
|
+
else:
|
|
46
|
+
out.append(prev)
|
|
47
|
+
prev = line
|
|
48
|
+
count = 1
|
|
49
|
+
if count > 1:
|
|
50
|
+
out.append(f"{prev} (×{count})")
|
|
51
|
+
else:
|
|
52
|
+
out.append(prev)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def truncate_preserving_critical(
|
|
57
|
+
lines: list[str],
|
|
58
|
+
*,
|
|
59
|
+
max_lines: int = _DEFAULT_MAX_LINES,
|
|
60
|
+
) -> tuple[list[str], int]:
|
|
61
|
+
"""Keep critical lines and a head/tail window; return (lines, omitted_count)."""
|
|
62
|
+
if len(lines) <= max_lines:
|
|
63
|
+
return lines, 0
|
|
64
|
+
|
|
65
|
+
critical_idx = [i for i, line in enumerate(lines) if is_critical_line(line)]
|
|
66
|
+
keep: set[int] = set()
|
|
67
|
+
head = max_lines // 3
|
|
68
|
+
tail = max_lines // 3
|
|
69
|
+
for i in range(min(head, len(lines))):
|
|
70
|
+
keep.add(i)
|
|
71
|
+
for i in range(max(0, len(lines) - tail), len(lines)):
|
|
72
|
+
keep.add(i)
|
|
73
|
+
keep.update(critical_idx)
|
|
74
|
+
|
|
75
|
+
if len(keep) > max_lines:
|
|
76
|
+
# Too many critical lines — keep all critical + fill with head/tail budget
|
|
77
|
+
ordered = sorted(keep)
|
|
78
|
+
keep = set(ordered[: max_lines])
|
|
79
|
+
else:
|
|
80
|
+
# Fill remaining budget with lines near critical regions
|
|
81
|
+
for idx in critical_idx:
|
|
82
|
+
for j in range(max(0, idx - 2), min(len(lines), idx + 3)):
|
|
83
|
+
if len(keep) >= max_lines:
|
|
84
|
+
break
|
|
85
|
+
keep.add(j)
|
|
86
|
+
|
|
87
|
+
selected = [lines[i] for i in sorted(keep)]
|
|
88
|
+
omitted = len(lines) - len(selected)
|
|
89
|
+
return selected, omitted
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def safe_compact(
|
|
93
|
+
raw: str,
|
|
94
|
+
compactor_name: str,
|
|
95
|
+
compacted: str,
|
|
96
|
+
) -> CompactResult:
|
|
97
|
+
"""Return compacted text unless it lost too much signal vs raw."""
|
|
98
|
+
raw_stripped = raw.strip()
|
|
99
|
+
compact_stripped = compacted.strip()
|
|
100
|
+
|
|
101
|
+
if not raw_stripped:
|
|
102
|
+
return CompactResult("", compactor_name, fell_back_to_raw=False)
|
|
103
|
+
|
|
104
|
+
if not compact_stripped:
|
|
105
|
+
return CompactResult(raw.rstrip("\n"), compactor_name, fell_back_to_raw=True)
|
|
106
|
+
|
|
107
|
+
raw_lines = raw.splitlines()
|
|
108
|
+
compact_lines = compacted.splitlines()
|
|
109
|
+
if len(compact_lines) < max(1, int(len(raw_lines) * _MIN_RETAINED_RATIO)):
|
|
110
|
+
# Extreme shrink — only accept if raw was huge noise (progress bars only)
|
|
111
|
+
if not any(is_critical_line(line) for line in raw_lines):
|
|
112
|
+
return CompactResult(compacted.rstrip("\n"), compactor_name, fell_back_to_raw=False)
|
|
113
|
+
return CompactResult(raw.rstrip("\n"), compactor_name, fell_back_to_raw=True)
|
|
114
|
+
|
|
115
|
+
return CompactResult(compacted.rstrip("\n"), compactor_name, fell_back_to_raw=False)
|