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/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:]))
@@ -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"]
@@ -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)