@ictechgy/context-guard 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +4 -0
  4. package/README.ko.md +353 -0
  5. package/README.md +353 -0
  6. package/context-guard-kit/README.md +76 -0
  7. package/context-guard-kit/benchmark_runner.py +1898 -0
  8. package/context-guard-kit/claude_transcript_cost_audit.py +1591 -0
  9. package/context-guard-kit/context_compress.py +543 -0
  10. package/context-guard-kit/context_escrow.py +919 -0
  11. package/context-guard-kit/context_guard_cli.py +149 -0
  12. package/context-guard-kit/context_guard_diet.py +1036 -0
  13. package/context-guard-kit/context_pack.py +929 -0
  14. package/context-guard-kit/failed_attempt_nudge.py +567 -0
  15. package/context-guard-kit/guard_large_read.py +690 -0
  16. package/context-guard-kit/hook_secret_patterns.py +43 -0
  17. package/context-guard-kit/read_symbol.py +483 -0
  18. package/context-guard-kit/rewrite_bash_for_token_budget.py +501 -0
  19. package/context-guard-kit/sanitize_output.py +725 -0
  20. package/context-guard-kit/settings.example.json +67 -0
  21. package/context-guard-kit/setup_wizard.py +1724 -0
  22. package/context-guard-kit/statusline.sh +362 -0
  23. package/context-guard-kit/statusline_merged.sh +157 -0
  24. package/context-guard-kit/tool_schema_pruner.py +837 -0
  25. package/context-guard-kit/trim_command_output.py +1098 -0
  26. package/docs/distribution.md +55 -0
  27. package/package.json +70 -0
  28. package/packaging/homebrew/context-guard.rb.template +34 -0
  29. package/plugins/context-guard/.claude-plugin/plugin.json +41 -0
  30. package/plugins/context-guard/LICENSE +201 -0
  31. package/plugins/context-guard/NOTICE +4 -0
  32. package/plugins/context-guard/README.ko.md +135 -0
  33. package/plugins/context-guard/README.md +135 -0
  34. package/plugins/context-guard/bin/claude-read-symbol +6 -0
  35. package/plugins/context-guard/bin/claude-sanitize-output +6 -0
  36. package/plugins/context-guard/bin/claude-token-artifact +6 -0
  37. package/plugins/context-guard/bin/claude-token-audit +6 -0
  38. package/plugins/context-guard/bin/claude-token-bench +6 -0
  39. package/plugins/context-guard/bin/claude-token-diet +6 -0
  40. package/plugins/context-guard/bin/claude-token-failed-nudge +6 -0
  41. package/plugins/context-guard/bin/claude-token-guard-read +6 -0
  42. package/plugins/context-guard/bin/claude-token-rewrite-bash +6 -0
  43. package/plugins/context-guard/bin/claude-token-setup +6 -0
  44. package/plugins/context-guard/bin/claude-token-statusline +6 -0
  45. package/plugins/context-guard/bin/claude-token-statusline-merged +6 -0
  46. package/plugins/context-guard/bin/claude-trim-output +6 -0
  47. package/plugins/context-guard/bin/context-guard +149 -0
  48. package/plugins/context-guard/bin/context-guard-artifact +919 -0
  49. package/plugins/context-guard/bin/context-guard-audit +1591 -0
  50. package/plugins/context-guard/bin/context-guard-bench +1898 -0
  51. package/plugins/context-guard/bin/context-guard-compress +543 -0
  52. package/plugins/context-guard/bin/context-guard-diet +1036 -0
  53. package/plugins/context-guard/bin/context-guard-failed-nudge +567 -0
  54. package/plugins/context-guard/bin/context-guard-guard-read +690 -0
  55. package/plugins/context-guard/bin/context-guard-pack +929 -0
  56. package/plugins/context-guard/bin/context-guard-read-symbol +483 -0
  57. package/plugins/context-guard/bin/context-guard-rewrite-bash +501 -0
  58. package/plugins/context-guard/bin/context-guard-sanitize-output +725 -0
  59. package/plugins/context-guard/bin/context-guard-setup +1724 -0
  60. package/plugins/context-guard/bin/context-guard-statusline +362 -0
  61. package/plugins/context-guard/bin/context-guard-statusline-merged +157 -0
  62. package/plugins/context-guard/bin/context-guard-tool-prune +837 -0
  63. package/plugins/context-guard/bin/context-guard-trim-output +1098 -0
  64. package/plugins/context-guard/brief/README.md +65 -0
  65. package/plugins/context-guard/brief/brief-mode.lite.md +29 -0
  66. package/plugins/context-guard/brief/brief-mode.standard.md +31 -0
  67. package/plugins/context-guard/brief/brief-mode.ultra.md +32 -0
  68. package/plugins/context-guard/lib/hook_secret_patterns.py +43 -0
  69. package/plugins/context-guard/skills/audit/SKILL.md +39 -0
  70. package/plugins/context-guard/skills/optimize/SKILL.md +48 -0
  71. package/plugins/context-guard/skills/setup/SKILL.md +40 -0
@@ -0,0 +1,1098 @@
1
+ #!/usr/bin/env python3
2
+ """Run a command, preserve exit code, and print a token-budgeted output summary.
3
+
4
+ Designed for Claude Code Bash tool output. It avoids dumping thousands of log
5
+ lines into the conversation while preserving the lines most likely to be useful.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import collections
11
+ import hashlib
12
+ import importlib.machinery
13
+ import importlib.util
14
+ import json
15
+ import os
16
+ from pathlib import PurePosixPath
17
+ import queue
18
+ import re
19
+ import shlex
20
+ import signal
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ from typing import Iterable, Iterator
26
+
27
+ MAX_SUMMARY_ITEM_CHARS = 500
28
+ MAX_LINES_LIMIT = 5_000
29
+ MAX_CHARS_LIMIT = 1_000_000
30
+ MAX_LINE_CHARS_LIMIT = 100_000
31
+ MAX_SECTION_LINES_LIMIT = 2_000
32
+ MAX_RUNNER_SUMMARY_ITEMS_LIMIT = 100
33
+ DEFAULT_TIMEOUT_SECONDS = 600
34
+ MAX_TIMEOUT_SECONDS = 86_400
35
+ TIMEOUT_EXIT_CODE = 124
36
+
37
+
38
+ def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
39
+ try:
40
+ number = int(value)
41
+ except (TypeError, ValueError, OverflowError):
42
+ return default
43
+ return min(max(number, minimum), maximum)
44
+
45
+
46
+ def normalize_budgets(args: argparse.Namespace) -> None:
47
+ args.max_lines = bounded_int(args.max_lines, 220, 1, MAX_LINES_LIMIT)
48
+ args.max_chars = bounded_int(args.max_chars, 20000, 1, MAX_CHARS_LIMIT)
49
+ args.max_line_chars = bounded_int(args.max_line_chars, 4000, 1, MAX_LINE_CHARS_LIMIT)
50
+ args.head_lines = bounded_int(args.head_lines, 40, 0, MAX_SECTION_LINES_LIMIT)
51
+ args.tail_lines = bounded_int(args.tail_lines, 80, 0, MAX_SECTION_LINES_LIMIT)
52
+ args.error_lines = bounded_int(args.error_lines, 120, 0, MAX_SECTION_LINES_LIMIT)
53
+ args.runner_summary_items = bounded_int(args.runner_summary_items, 12, 0, MAX_RUNNER_SUMMARY_ITEMS_LIMIT)
54
+ args.timeout_seconds = bounded_int(
55
+ args.timeout_seconds,
56
+ DEFAULT_TIMEOUT_SECONDS,
57
+ 1,
58
+ MAX_TIMEOUT_SECONDS,
59
+ )
60
+
61
+ TERMINAL_CONTROL_RE = re.compile(
62
+ r"(?:"
63
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|" # OSC title/clipboard controls
64
+ r"\x1b[@-_][0-?]*[ -/]*[@-~]|" # CSI and other ESC sequences
65
+ r"[\x00-\x08\x0b\x0c\x0d\x0e-\x1f\x7f-\x9f]"
66
+ r")"
67
+ )
68
+ ABSOLUTE_PATH_RE = re.compile(r"(?P<prefix>^|[\s('\"=])(?P<path>/(?:[^\s:(),]+/)*[^\s:(),]+)")
69
+ SECRET_KEY = (
70
+ r"[A-Za-z0-9_.-]*(?:api[_-]?key|apikey|token|secret|password|passwd|pwd|"
71
+ r"private[_-]?key|access[_-]?key|client[_-]?secret)[A-Za-z0-9_.-]*"
72
+ )
73
+ FALLBACK_INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
74
+ (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
75
+ (re.compile(r"(?i)\bBasic\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
76
+ (re.compile(r"(?i)\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), "[REDACTED]"),
77
+ (re.compile(r"(?i)\bgithub_pat_[A-Za-z0-9_]{20,}\b"), "[REDACTED]"),
78
+ (re.compile(r"(?i)\bglpat-[A-Za-z0-9_-]{12,}\b"), "[REDACTED]"),
79
+ (re.compile(r"(?i)\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED]"),
80
+ (re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "[REDACTED]"),
81
+ (re.compile(r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b"), "[REDACTED]"),
82
+ (re.compile(r"\bsk-(?:ant|proj)-[A-Za-z0-9_-]{12,}\b"), "[REDACTED]"),
83
+ (re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b"), "[REDACTED]"),
84
+ (re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b"), "[REDACTED]"),
85
+ (re.compile(r"(?i)\bAIza[0-9A-Za-z_\-]{20,}\b"), "[REDACTED]"),
86
+ (re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b"), "[REDACTED]"),
87
+ (re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[REDACTED]"),
88
+ (re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@", re.IGNORECASE), r"\1[REDACTED]@"),
89
+ (re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s&#;]+"), r"\1[REDACTED]"),
90
+ (re.compile(rf"(?i)(\b(?:{SECRET_KEY})\s*[:=]\s*)[^\s]+"), r"\1[REDACTED]"),
91
+ )
92
+ FALLBACK_AUTH_HEADER_RE = re.compile(
93
+ r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Proxy-)?Authorization\s*:\s*).+$"
94
+ )
95
+ ERROR_RE = re.compile(
96
+ r"(FAIL|FAILED|ERROR|Error:|Exception|Traceback|AssertionError|panic:|fatal:|"
97
+ r"segmentation fault|not ok|\bE\s+assert|\[ERROR\]|✗|✖)",
98
+ re.IGNORECASE,
99
+ )
100
+ PYTEST_RESULT_RE = re.compile(r"^(?P<kind>FAILED|ERROR)\s+(?P<node>\S+)(?:\s+-\s+(?P<reason>.*))?$")
101
+ PYTEST_LOCATION_RE = re.compile(r"^(?P<file>[^:\s][^:\n]*\.py):(?P<line>\d+):(?P<message>.*)$")
102
+ JEST_FILE_RE = re.compile(
103
+ r"^\s*FAIL\s+(?P<file>\S+(?:\.(?:test|spec)\.[cm]?[jt]sx?|__tests__/\S+\.[cm]?[jt]sx?))"
104
+ r"(?:\s+>\s+(?P<name>.+))?\s*$"
105
+ )
106
+ JEST_TEST_RE = re.compile(r"^\s*[●✕×]\s+(?P<name>.+?)\s*$")
107
+ JEST_AT_RE = re.compile(
108
+ r"^\s*at\s+(?:.+?\s+\()?(?P<file>[^()\s]+?\.[cm]?[jt]sx?):(?P<line>\d+):(?P<col>\d+)\)?\s*$"
109
+ )
110
+ VITEST_LOCATION_RE = re.compile(r"^\s*❯\s+(?P<file>[^()\s]+?\.[cm]?[jt]sx?):(?P<line>\d+):(?P<col>\d+)\s*$")
111
+ GO_FAIL_RE = re.compile(r"^--- FAIL: (?P<name>\S+)(?:\s+\([^)]+\))?")
112
+ GO_LOCATION_RE = re.compile(r"^\s*(?P<file>[^:\s]+_test\.go):(?P<line>\d+):\s*(?P<message>.*)$")
113
+ RUST_THREAD_RE = re.compile(
114
+ r"^thread '(?P<name>[^']+)' panicked at (?:.*,\s+)?(?P<file>[^,\n]+?\.rs):(?P<line>\d+):(?P<col>\d+):?"
115
+ )
116
+
117
+
118
+ def strip_ansi(text: str) -> str:
119
+ return TERMINAL_CONTROL_RE.sub("", text)
120
+
121
+
122
+ def anonymize_absolute_paths(text: str) -> str:
123
+ def repl(match: re.Match[str]) -> str:
124
+ prefix = match.group("prefix")
125
+ path = match.group("path")
126
+ name = PurePosixPath(path).name or "path"
127
+ digest = hashlib.sha256(path.encode("utf-8", "replace")).hexdigest()[:12]
128
+ return f"{prefix}{name}#path:{digest}"
129
+
130
+ return ABSOLUTE_PATH_RE.sub(repl, text)
131
+
132
+
133
+ class FallbackLineSanitizer:
134
+ def __init__(self, *, show_paths: bool = False, diagnostic: str | None = None) -> None:
135
+ self.show_paths = show_paths
136
+ self.diagnostic = diagnostic
137
+ self.diagnostic_emitted = False
138
+ self.redactions = 0
139
+
140
+ def sanitize(self, raw_line: str) -> tuple[str, bool]:
141
+ if self.diagnostic and not self.diagnostic_emitted:
142
+ print(f"context-guard-kit: sanitizer fallback active: {self.diagnostic}", file=sys.stderr)
143
+ self.diagnostic_emitted = True
144
+ line = strip_ansi(raw_line)
145
+ if not self.show_paths:
146
+ line = anonymize_absolute_paths(line)
147
+ original = line
148
+ auth_match = FALLBACK_AUTH_HEADER_RE.match(line)
149
+ if auth_match:
150
+ line = auth_match.group("prefix") + "[REDACTED]\n"
151
+ else:
152
+ for pattern, repl in FALLBACK_INLINE_PATTERNS:
153
+ line = pattern.sub(repl, line)
154
+ redacted = line != original
155
+ if redacted:
156
+ self.redactions += 1
157
+ return line, redacted
158
+
159
+
160
+ def load_line_sanitizer(show_paths: bool) -> object:
161
+ """Reuse the stronger sanitizer when it is shipped next to this wrapper."""
162
+ script_dir = os.path.dirname(os.path.abspath(__file__))
163
+ load_errors: list[str] = []
164
+ for name in ("sanitize_output.py", "context-guard-sanitize-output"):
165
+ candidate = os.path.join(script_dir, name)
166
+ if not os.path.exists(candidate):
167
+ continue
168
+ try:
169
+ loader = importlib.machinery.SourceFileLoader(f"_claude_token_sanitize_{os.getpid()}", candidate)
170
+ spec = importlib.util.spec_from_loader(loader.name, loader)
171
+ if spec is None:
172
+ continue
173
+ module = importlib.util.module_from_spec(spec)
174
+ loader.exec_module(module)
175
+ return module.LineSanitizer(show_paths=show_paths)
176
+ except Exception as exc:
177
+ load_errors.append(f"{os.path.basename(candidate)} failed to load: {exc.__class__.__name__}: {exc}")
178
+ continue
179
+ diagnostic = "; ".join(load_errors) if load_errors else "strong sanitizer not found next to trim wrapper"
180
+ return FallbackLineSanitizer(show_paths=show_paths, diagnostic=diagnostic)
181
+
182
+
183
+ def unique_keep_order(lines: Iterable[str]) -> list[str]:
184
+ seen: set[str] = set()
185
+ out: list[str] = []
186
+ for line in lines:
187
+ key = line.rstrip()
188
+ if key not in seen:
189
+ out.append(line)
190
+ seen.add(key)
191
+ return out
192
+
193
+
194
+ def cap_line(line: str, max_line_chars: int) -> tuple[str, bool]:
195
+ if max_line_chars <= 0 or len(line) <= max_line_chars:
196
+ return line, False
197
+ newline = "\n" if line.endswith("\n") else ""
198
+ body = line[:-1] if newline else line
199
+ marker = f"...[line trimmed: {len(body)} chars]"
200
+ keep = max(0, max_line_chars - len(marker) - len(newline))
201
+ return body[:keep] + marker + newline, True
202
+
203
+
204
+ def cap_text(text: str, max_chars: int) -> tuple[str, bool]:
205
+ if max_chars <= 0 or len(text) <= max_chars:
206
+ return text, False
207
+ marker = f"\n[context-guard-kit] text capped: {len(text)} chars total\n"
208
+ keep = max(0, max_chars - len(marker))
209
+ return text[:keep].rstrip() + marker, True
210
+
211
+
212
+ def compact_item(
213
+ text: str,
214
+ limit: int = MAX_SUMMARY_ITEM_CHARS,
215
+ *,
216
+ show_paths: bool = False,
217
+ sanitizer: object | None = None,
218
+ ) -> str:
219
+ """Normalize a failure-summary item without letting one log line dominate memory/output."""
220
+ if sanitizer is None:
221
+ sanitizer = load_line_sanitizer(show_paths)
222
+ sanitized, _ = sanitizer.sanitize(text) # type: ignore[attr-defined]
223
+ item = re.sub(r"\s+", " ", strip_ansi(sanitized).strip())
224
+ if len(item) <= limit:
225
+ return item
226
+ marker = f"...[item trimmed: {len(item)} chars]"
227
+ keep = max(0, limit - len(marker))
228
+ return item[:keep] + marker
229
+
230
+
231
+ class RunnerFailureSummary:
232
+ """Bounded, runner-aware extraction of the most actionable failure lines.
233
+
234
+ The extractor is intentionally online and stores only a small de-duplicated
235
+ set of findings. That keeps the wrapper useful for huge logs without
236
+ retaining the whole command output in memory.
237
+ """
238
+
239
+ def __init__(self, max_items_per_runner: int, *, show_paths: bool = False) -> None:
240
+ self.max_items_per_runner = max(0, max_items_per_runner)
241
+ self.show_paths = show_paths
242
+ self.sanitizer = load_line_sanitizer(show_paths)
243
+ self.items: dict[str, list[str]] = collections.defaultdict(list)
244
+ self.seen: dict[str, set[str]] = collections.defaultdict(set)
245
+ self.jest_active = False
246
+ self.go_failed_seen = False
247
+
248
+ def add(self, runner: str, item: str) -> None:
249
+ if self.max_items_per_runner <= 0:
250
+ return
251
+ compact = compact_item(item, show_paths=self.show_paths, sanitizer=self.sanitizer)
252
+ if not compact or compact in self.seen[runner]:
253
+ return
254
+ if len(self.items[runner]) >= self.max_items_per_runner:
255
+ return
256
+ self.items[runner].append(compact)
257
+ self.seen[runner].add(compact)
258
+
259
+ def feed(self, line: str) -> None:
260
+ if self.max_items_per_runner <= 0:
261
+ return
262
+
263
+ stripped = strip_ansi(line.rstrip("\n"))
264
+
265
+ match = PYTEST_RESULT_RE.match(stripped)
266
+ if match and (".py" in match.group("node") or "::" in match.group("node")):
267
+ reason = compact_item(match.group("reason") or "", show_paths=self.show_paths, sanitizer=self.sanitizer)
268
+ if reason:
269
+ self.add("pytest", f"{match.group('kind')} {match.group('node')} - {reason}")
270
+ else:
271
+ self.add("pytest", f"{match.group('kind')} {match.group('node')}")
272
+
273
+ match = PYTEST_LOCATION_RE.match(stripped)
274
+ if match and ERROR_RE.search(stripped):
275
+ self.add("pytest", f"{match.group('file')}:{match.group('line')}: {match.group('message').strip()}")
276
+
277
+ match = JEST_FILE_RE.match(stripped)
278
+ if match:
279
+ self.jest_active = True
280
+ self.add("jest/vitest", f"FAIL {match.group('file')}")
281
+ if match.group("name"):
282
+ self.add("jest/vitest", f"test {match.group('name')}")
283
+
284
+ if self.jest_active:
285
+ match = JEST_TEST_RE.match(stripped)
286
+ if match:
287
+ self.add("jest/vitest", f"test {match.group('name')}")
288
+
289
+ match = JEST_AT_RE.match(stripped)
290
+ if match:
291
+ self.add("jest/vitest", f"{match.group('file')}:{match.group('line')}:{match.group('col')}")
292
+
293
+ match = VITEST_LOCATION_RE.match(stripped)
294
+ if match:
295
+ self.add("jest/vitest", f"{match.group('file')}:{match.group('line')}:{match.group('col')}")
296
+
297
+ match = GO_FAIL_RE.match(stripped)
298
+ if match:
299
+ self.go_failed_seen = True
300
+ self.add("go test", f"FAIL {match.group('name')}")
301
+
302
+ match = GO_LOCATION_RE.match(stripped)
303
+ if self.go_failed_seen and match:
304
+ message = match.group("message").strip()
305
+ suffix = f": {message}" if message else ""
306
+ self.add("go test", f"{match.group('file')}:{match.group('line')}{suffix}")
307
+
308
+ match = RUST_THREAD_RE.match(stripped)
309
+ if match:
310
+ self.add(
311
+ "cargo test",
312
+ f"{match.group('name')} at {match.group('file')}:{match.group('line')}:{match.group('col')}",
313
+ )
314
+
315
+ def as_lines(self, max_line_chars: int, max_lines: int) -> list[str]:
316
+ if not self.items:
317
+ return []
318
+ if max_lines <= 0:
319
+ return []
320
+ out = ["\n--- runner failure summary ---\n"]
321
+ used_lines = len(out[0].splitlines())
322
+ for runner in sorted(self.items):
323
+ runner_line = f"runner={runner}\n"
324
+ if used_lines + 1 > max_lines:
325
+ break
326
+ out.append(runner_line)
327
+ used_lines += 1
328
+ for item in self.items[runner]:
329
+ if used_lines + 1 > max_lines:
330
+ break
331
+ line, _ = cap_line(f"- {item}\n", max_line_chars)
332
+ out.append(line)
333
+ used_lines += 1
334
+ return out
335
+
336
+ def as_dict(self) -> dict[str, list[str]]:
337
+ return {runner: list(items) for runner, items in sorted(self.items.items()) if items}
338
+
339
+
340
+ def digest_line_items(lines: Iterable[str], *, limit: int, max_line_chars: int) -> list[str]:
341
+ out: list[str] = []
342
+ seen: set[str] = set()
343
+ for line in lines:
344
+ item = strip_ansi(line).strip()
345
+ if not item or item in seen:
346
+ continue
347
+ capped, _ = cap_line(item, max_line_chars)
348
+ out.append(capped.strip())
349
+ seen.add(item)
350
+ if len(out) >= limit:
351
+ break
352
+ return out
353
+
354
+
355
+ class DuplicateLineTracker:
356
+ """Track repeated sanitized lines without retaining unbounded unique output."""
357
+
358
+ def __init__(self, *, max_groups: int = 12, max_unique: int = 2048) -> None:
359
+ self.max_groups = max(0, max_groups)
360
+ self.max_unique = max(1, max_unique)
361
+ self.counts: dict[str, int] = {}
362
+ self.first_line: dict[str, int] = {}
363
+ self.overflow_unique_lines = 0
364
+
365
+ def feed(self, line_number: int, line: str) -> None:
366
+ text = strip_ansi(line).strip()
367
+ if not text:
368
+ return
369
+ if text not in self.counts:
370
+ if len(self.counts) >= self.max_unique:
371
+ self.overflow_unique_lines += 1
372
+ return
373
+ self.counts[text] = 0
374
+ self.first_line[text] = line_number
375
+ self.counts[text] += 1
376
+
377
+ def as_list(self) -> list[dict[str, object]]:
378
+ groups: list[dict[str, object]] = []
379
+ repeated = [
380
+ (text, count)
381
+ for text, count in self.counts.items()
382
+ if count > 1
383
+ ]
384
+ for text, count in sorted(repeated, key=lambda item: (-item[1], self.first_line[item[0]], item[0]))[
385
+ : self.max_groups
386
+ ]:
387
+ groups.append(
388
+ {
389
+ "count": count,
390
+ "first_line": self.first_line[text],
391
+ "text": text,
392
+ }
393
+ )
394
+ if groups and self.overflow_unique_lines:
395
+ groups.append(
396
+ {
397
+ "count": self.overflow_unique_lines,
398
+ "first_line": None,
399
+ "text": "[context-guard-kit] additional unique lines omitted from duplicate tracking",
400
+ }
401
+ )
402
+ return groups
403
+
404
+
405
+ def command_preview(command: list[str], sanitizer: object, max_line_chars: int) -> str:
406
+ try:
407
+ raw = shlex.join(command)
408
+ except Exception:
409
+ raw = " ".join(command)
410
+ sanitized, _ = sanitizer.sanitize(raw + "\n") # type: ignore[attr-defined]
411
+ capped, _ = cap_line(sanitized.strip(), max_line_chars)
412
+ return capped.strip()
413
+
414
+
415
+ def digest_next_queries(
416
+ *,
417
+ rc: int,
418
+ timed_out: bool,
419
+ raw_output_truncated: bool,
420
+ runner_items: dict[str, list[str]],
421
+ top_error_lines: list[str],
422
+ ) -> list[str]:
423
+ if timed_out:
424
+ return [
425
+ "Inspect timeout cause first; rerun with a narrower command or higher --timeout-seconds only if needed.",
426
+ "If the process spawned children, check whether the wrapped command handles termination cleanly.",
427
+ ]
428
+ if rc == 0:
429
+ if raw_output_truncated:
430
+ return [
431
+ "Treat this as success unless a specific assertion needs raw logs.",
432
+ "Query exact raw output only for the component named in the next task.",
433
+ ]
434
+ return ["No raw output follow-up needed; command completed successfully."]
435
+ queries: list[str] = []
436
+ if runner_items:
437
+ queries.append("Run the failing test/node from runner_failure_summary directly with minimal verbosity.")
438
+ if top_error_lines:
439
+ queries.append("Inspect top_error_lines before rerunning the full command.")
440
+ if raw_output_truncated:
441
+ queries.append("Rerun without trim only if these failure facts are insufficient.")
442
+ if not queries:
443
+ queries.append("Rerun with a narrower command or grep for the first error before requesting raw output.")
444
+ return queries
445
+
446
+
447
+ def build_failure_signature(
448
+ *,
449
+ status: str,
450
+ rc: int,
451
+ timed_out: bool,
452
+ runner_items: dict[str, list[str]],
453
+ top_error_lines: list[str],
454
+ ) -> dict[str, object]:
455
+ basis: list[str] = []
456
+ source = "status"
457
+ if runner_items:
458
+ source = "runner_failure_summary"
459
+ for runner in sorted(runner_items):
460
+ for item in runner_items[runner]:
461
+ basis.append(f"{runner}: {item}")
462
+ if len(basis) >= 8:
463
+ break
464
+ if len(basis) >= 8:
465
+ break
466
+ elif top_error_lines:
467
+ source = "top_error_lines"
468
+ basis = top_error_lines[:8]
469
+ if not basis:
470
+ basis = [f"status={status}", f"exit_code={rc}", f"timed_out={str(timed_out).lower()}"]
471
+ digest = hashlib.sha256(
472
+ json.dumps(
473
+ {"status": status, "exit_code": rc, "timed_out": timed_out, "basis": basis},
474
+ ensure_ascii=False,
475
+ sort_keys=True,
476
+ ).encode("utf-8", errors="replace")
477
+ ).hexdigest()[:16]
478
+ return {
479
+ "hash": digest,
480
+ "source": source,
481
+ "basis": basis,
482
+ "exit_code": rc,
483
+ "timed_out": timed_out,
484
+ }
485
+
486
+
487
+ def build_digest_payload(
488
+ *,
489
+ args: argparse.Namespace,
490
+ command: list[str],
491
+ rc: int,
492
+ timed_out: bool,
493
+ total: int,
494
+ raw_chars: int,
495
+ visible_chars: int,
496
+ any_line_capped: bool,
497
+ redacted_lines: int,
498
+ head: list[str],
499
+ tail: Iterable[str],
500
+ error_lines: list[str],
501
+ runner_summary: RunnerFailureSummary,
502
+ line_sanitizer: object,
503
+ duplicate_line_groups: list[dict[str, object]] | None = None,
504
+ ) -> dict[str, object]:
505
+ raw_output_truncated = total > args.max_lines or visible_chars > args.max_chars or any_line_capped
506
+ status = "timeout" if timed_out else ("success" if rc == 0 else "failure")
507
+ runner_items = runner_summary.as_dict() if rc != 0 else {}
508
+ top_error_lines = digest_line_items(error_lines, limit=12, max_line_chars=args.max_line_chars)
509
+ sample_limit = 8 if status == "success" else 10
510
+ tail_list = list(tail)
511
+ payload: dict[str, object] = {
512
+ "tool": "context-guard-kit.trim_command_output",
513
+ "digest_version": 1,
514
+ "status": status,
515
+ "exit_code": rc,
516
+ "timed_out": timed_out,
517
+ "raw_output": {
518
+ "lines": total,
519
+ "chars": raw_chars,
520
+ "visible_chars": visible_chars,
521
+ "truncated": raw_output_truncated,
522
+ "line_capped": any_line_capped,
523
+ "redacted_lines": redacted_lines,
524
+ },
525
+ "budget": {
526
+ "max_lines": args.max_lines,
527
+ "max_chars": args.max_chars,
528
+ "max_line_chars": args.max_line_chars,
529
+ },
530
+ "command_preview": command_preview(command, line_sanitizer, args.max_line_chars),
531
+ "runner_failure_summary": runner_items,
532
+ "top_error_lines": top_error_lines,
533
+ "representative_head": digest_line_items(head, limit=sample_limit, max_line_chars=args.max_line_chars),
534
+ "representative_tail": digest_line_items(
535
+ tail_list[-sample_limit:],
536
+ limit=sample_limit,
537
+ max_line_chars=args.max_line_chars,
538
+ ),
539
+ }
540
+ if duplicate_line_groups:
541
+ payload["duplicate_line_groups"] = duplicate_line_groups
542
+ if status != "success":
543
+ payload["failure_signature"] = build_failure_signature(
544
+ status=status,
545
+ rc=rc,
546
+ timed_out=timed_out,
547
+ runner_items=runner_items,
548
+ top_error_lines=top_error_lines,
549
+ )
550
+ payload["next_queries"] = digest_next_queries(
551
+ rc=rc,
552
+ timed_out=timed_out,
553
+ raw_output_truncated=raw_output_truncated,
554
+ runner_items=runner_items,
555
+ top_error_lines=top_error_lines,
556
+ )
557
+ return payload
558
+
559
+
560
+ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
561
+ raw_output = payload.get("raw_output", {})
562
+ budget = payload.get("budget", {})
563
+ lines: list[str] = []
564
+ lines.append("[context-guard-kit] semantic digest\n")
565
+ lines.append(f"- status: {payload.get('status')}\n")
566
+ lines.append(f"- exit_code: {payload.get('exit_code')}\n")
567
+ lines.append(f"- timed_out: {str(payload.get('timed_out')).lower()}\n")
568
+ if isinstance(raw_output, dict):
569
+ lines.append(
570
+ "- raw_output: "
571
+ f"{raw_output.get('lines')} lines/{raw_output.get('chars')} chars"
572
+ f" (visible={raw_output.get('visible_chars')}, truncated={str(raw_output.get('truncated')).lower()})\n"
573
+ )
574
+ if raw_output.get("line_capped"):
575
+ lines.append(f"- line_capped: true\n")
576
+ if raw_output.get("redacted_lines"):
577
+ lines.append(f"- redacted_lines: {raw_output.get('redacted_lines')}\n")
578
+ if isinstance(budget, dict):
579
+ lines.append(
580
+ "- budget: "
581
+ f"{budget.get('max_lines')} lines/{budget.get('max_chars')} chars/"
582
+ f"line={budget.get('max_line_chars')} chars\n"
583
+ )
584
+ if payload.get("command_preview"):
585
+ lines.append(f"- command: `{payload.get('command_preview')}`\n")
586
+ failure_signature = payload.get("failure_signature")
587
+ if isinstance(failure_signature, dict):
588
+ lines.append(
589
+ "- failure_signature: "
590
+ f"{failure_signature.get('hash')} ({failure_signature.get('source')})\n"
591
+ )
592
+
593
+ runner_summary = payload.get("runner_failure_summary")
594
+ if isinstance(runner_summary, dict) and runner_summary:
595
+ lines.append("\n## runner_failure_summary\n")
596
+ for runner, items in sorted(runner_summary.items()):
597
+ lines.append(f"- runner={runner}\n")
598
+ if isinstance(items, list):
599
+ for item in items:
600
+ lines.append(f" - {item}\n")
601
+
602
+ duplicate_line_groups = payload.get("duplicate_line_groups")
603
+ if isinstance(duplicate_line_groups, list) and duplicate_line_groups:
604
+ lines.append("\n## duplicate_line_groups\n")
605
+ for group in duplicate_line_groups:
606
+ if not isinstance(group, dict):
607
+ continue
608
+ lines.append(
609
+ "- "
610
+ f"count={group.get('count')} "
611
+ f"first_line={group.get('first_line')} "
612
+ f"text={group.get('text')}\n"
613
+ )
614
+
615
+ for title, key in [
616
+ ("top_error_lines", "top_error_lines"),
617
+ ("representative_head", "representative_head"),
618
+ ("representative_tail", "representative_tail"),
619
+ ("next_queries", "next_queries"),
620
+ ]:
621
+ values = payload.get(key)
622
+ if isinstance(values, list) and values:
623
+ lines.append(f"\n## {title}\n")
624
+ for value in values:
625
+ lines.append(f"- {value}\n")
626
+
627
+ text = "".join(lines)
628
+ output, capped = cap_text(text, max_chars)
629
+ if not capped:
630
+ return output
631
+ marker = "[context-guard-kit] digest capped by --max-chars.\n"
632
+ if max_chars <= len(marker):
633
+ return marker[:max_chars]
634
+ output, _ = cap_text(text, max_chars - len(marker))
635
+ return output + marker
636
+
637
+
638
+ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
639
+ def dumps(data: dict[str, object]) -> str:
640
+ return json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
641
+
642
+ def shrink_list_to_fit(data: dict[str, object], values: list[object]) -> None:
643
+ if len(dumps(data)) <= max_chars:
644
+ return
645
+ lo, hi = 0, len(values)
646
+ best = 0
647
+ original = list(values)
648
+ while lo <= hi:
649
+ mid = (lo + hi) // 2
650
+ values[:] = original[:mid]
651
+ if len(dumps(data)) <= max_chars:
652
+ best = mid
653
+ lo = mid + 1
654
+ else:
655
+ hi = mid - 1
656
+ values[:] = original[:best]
657
+
658
+ def first_fitting(candidates: list[dict[str, object]]) -> str:
659
+ for candidate in candidates:
660
+ output = dumps(candidate)
661
+ if len(output) <= max_chars:
662
+ return output
663
+ return dumps(candidates[-1])
664
+
665
+ output = dumps(payload)
666
+ if len(output) <= max_chars:
667
+ return output
668
+
669
+ capped = json.loads(json.dumps(payload))
670
+ capped["digest_capped"] = True
671
+ for key in ("duplicate_line_groups", "representative_tail", "representative_head", "top_error_lines", "next_queries"):
672
+ values = capped.get(key)
673
+ if isinstance(values, list):
674
+ shrink_list_to_fit(capped, values)
675
+ failure_signature = capped.get("failure_signature")
676
+ if isinstance(failure_signature, dict):
677
+ basis = failure_signature.get("basis")
678
+ if isinstance(basis, list):
679
+ shrink_list_to_fit(capped, basis)
680
+ runner_summary = capped.get("runner_failure_summary")
681
+ if isinstance(runner_summary, dict):
682
+ for runner in sorted(runner_summary):
683
+ values = runner_summary.get(runner)
684
+ if isinstance(values, list):
685
+ shrink_list_to_fit(capped, values)
686
+ output = dumps(capped)
687
+ if len(output) <= max_chars:
688
+ return output
689
+
690
+ compact_signature: object | None = None
691
+ failure_signature = payload.get("failure_signature")
692
+ if isinstance(failure_signature, dict):
693
+ compact_signature = {
694
+ "hash": failure_signature.get("hash"),
695
+ "source": failure_signature.get("source"),
696
+ "exit_code": failure_signature.get("exit_code"),
697
+ "timed_out": failure_signature.get("timed_out"),
698
+ }
699
+
700
+ return first_fitting(
701
+ [
702
+ {
703
+ "tool": payload.get("tool"),
704
+ "digest_version": payload.get("digest_version"),
705
+ "digest_capped": True,
706
+ "status": payload.get("status"),
707
+ "exit_code": payload.get("exit_code"),
708
+ "timed_out": payload.get("timed_out"),
709
+ "failure_signature": compact_signature,
710
+ "raw_output": payload.get("raw_output"),
711
+ "budget": payload.get("budget"),
712
+ "next_queries": ["Raise --max-chars or inspect a narrower command for details."],
713
+ },
714
+ {
715
+ "digest_capped": True,
716
+ "status": payload.get("status"),
717
+ "exit_code": payload.get("exit_code"),
718
+ "timed_out": payload.get("timed_out"),
719
+ "failure_signature": compact_signature,
720
+ "raw_output": payload.get("raw_output"),
721
+ "next_queries": ["Raise --max-chars or inspect a narrower command for details."],
722
+ },
723
+ {
724
+ "digest_capped": True,
725
+ "status": payload.get("status"),
726
+ "exit_code": payload.get("exit_code"),
727
+ "timed_out": payload.get("timed_out"),
728
+ "failure_signature": compact_signature,
729
+ },
730
+ {"digest_capped": True},
731
+ ]
732
+ )
733
+
734
+
735
+ _STREAM_END = object()
736
+
737
+
738
+ def process_group_exists(pgid: int) -> bool:
739
+ try:
740
+ os.killpg(pgid, 0)
741
+ except ProcessLookupError:
742
+ return False
743
+ except PermissionError:
744
+ return True
745
+ except OSError:
746
+ return False
747
+ return True
748
+
749
+
750
+ def terminate_process_tree(
751
+ proc: subprocess.Popen[str],
752
+ *,
753
+ process_group_id: int | None = None,
754
+ include_exited_group: bool = False,
755
+ ) -> None:
756
+ if os.name != "nt":
757
+ pgid = process_group_id if process_group_id is not None else proc.pid
758
+ if proc.poll() is not None and not include_exited_group:
759
+ return
760
+ try:
761
+ os.killpg(pgid, signal.SIGTERM)
762
+ except ProcessLookupError:
763
+ return
764
+ deadline = time.monotonic() + 2
765
+ while time.monotonic() < deadline:
766
+ if proc.poll() is None:
767
+ try:
768
+ proc.wait(timeout=0.05)
769
+ except subprocess.TimeoutExpired:
770
+ pass
771
+ if not process_group_exists(pgid):
772
+ return
773
+ time.sleep(0.05)
774
+ try:
775
+ os.killpg(pgid, signal.SIGKILL)
776
+ except ProcessLookupError:
777
+ return
778
+ return
779
+
780
+ if proc.poll() is not None:
781
+ return
782
+ try:
783
+ proc.terminate()
784
+ except ProcessLookupError:
785
+ return
786
+ except OSError:
787
+ try:
788
+ proc.kill()
789
+ except OSError:
790
+ return
791
+ try:
792
+ proc.wait(timeout=2)
793
+ return
794
+ except subprocess.TimeoutExpired:
795
+ pass
796
+ try:
797
+ proc.kill()
798
+ except ProcessLookupError:
799
+ return
800
+ except OSError:
801
+ return
802
+
803
+
804
+ class TimedCommandStream:
805
+ def __init__(
806
+ self,
807
+ proc: subprocess.Popen[str],
808
+ stdout: Iterable[str],
809
+ *,
810
+ timeout_seconds: int,
811
+ process_group_id: int | None = None,
812
+ ) -> None:
813
+ self.proc = proc
814
+ self.timeout_seconds = timeout_seconds
815
+ self.process_group_id = process_group_id
816
+ self.deadline = time.monotonic() + timeout_seconds
817
+ self.timed_out = False
818
+ self.timeout_reported = False
819
+ self._stream_closed = False
820
+ self._queue: queue.Queue[str | object] = queue.Queue(maxsize=1024)
821
+ self._thread = threading.Thread(target=self._read_stdout, args=(stdout,), daemon=True)
822
+ self._thread.start()
823
+
824
+ def _read_stdout(self, stdout: Iterable[str]) -> None:
825
+ try:
826
+ for line in stdout:
827
+ self._queue.put(line)
828
+ finally:
829
+ self._stream_closed = True
830
+ self._queue.put(_STREAM_END)
831
+
832
+ def timeout_message(self) -> str:
833
+ return (
834
+ f"[context-guard-kit] command timed out after {self.timeout_seconds}s; "
835
+ "terminated wrapped process\n"
836
+ )
837
+
838
+ def _mark_timed_out(self) -> None:
839
+ if not self.timed_out:
840
+ self.timed_out = True
841
+ terminate_process_tree(
842
+ self.proc,
843
+ process_group_id=self.process_group_id,
844
+ include_exited_group=True,
845
+ )
846
+
847
+ def _timeout_line(self) -> str:
848
+ self._mark_timed_out()
849
+ self.timeout_reported = True
850
+ return self.timeout_message()
851
+
852
+ def __iter__(self) -> Iterator[str]:
853
+ while True:
854
+ remaining = self.deadline - time.monotonic()
855
+ wait_time = 0.05 if self.proc.poll() is not None or self.timed_out else min(0.05, max(0.0, remaining))
856
+ try:
857
+ item = self._queue.get(timeout=wait_time)
858
+ except queue.Empty:
859
+ if remaining <= 0 and not self._stream_closed:
860
+ if not self.timeout_reported:
861
+ yield self._timeout_line()
862
+ break
863
+ continue
864
+ if item is _STREAM_END:
865
+ break
866
+ if not isinstance(item, str):
867
+ continue
868
+ yield item
869
+ if not self._stream_closed and time.monotonic() >= self.deadline:
870
+ if not self.timeout_reported:
871
+ yield self._timeout_line()
872
+ break
873
+
874
+ def returncode(self) -> int:
875
+ if self.timed_out:
876
+ return TIMEOUT_EXIT_CODE
877
+ remaining = self.deadline - time.monotonic()
878
+ try:
879
+ return self.proc.wait(timeout=max(0.0, remaining))
880
+ except subprocess.TimeoutExpired:
881
+ self._mark_timed_out()
882
+ return TIMEOUT_EXIT_CODE
883
+
884
+
885
+ def process_group_id_for(proc: subprocess.Popen[str]) -> int | None:
886
+ if os.name == "nt":
887
+ return None
888
+ try:
889
+ return os.getpgid(proc.pid)
890
+ except ProcessLookupError:
891
+ # start_new_session=True makes the child the group leader; if it exits
892
+ # before getpgid(), the group id is still the leader pid while inherited
893
+ # stdout descendants remain alive.
894
+ return proc.pid
895
+
896
+
897
+ def main() -> int:
898
+ parser = argparse.ArgumentParser()
899
+ parser.add_argument("--max-lines", type=int, default=220)
900
+ parser.add_argument("--max-chars", type=int, default=20000)
901
+ parser.add_argument("--max-line-chars", type=int, default=4000)
902
+ parser.add_argument("--head-lines", type=int, default=40)
903
+ parser.add_argument("--tail-lines", type=int, default=80)
904
+ parser.add_argument("--error-lines", type=int, default=120)
905
+ parser.add_argument(
906
+ "--runner-summary-items",
907
+ type=int,
908
+ default=12,
909
+ help="maximum runner-specific failure facts to keep per detected runner (0 disables)",
910
+ )
911
+ parser.add_argument(
912
+ "--show-paths",
913
+ action="store_true",
914
+ help="show raw absolute paths in output instead of basename#path:<hash>; local debugging only because private paths may be exposed",
915
+ )
916
+ parser.add_argument(
917
+ "--timeout-seconds",
918
+ type=int,
919
+ default=DEFAULT_TIMEOUT_SECONDS,
920
+ help=(
921
+ "maximum runtime for wrapped commands before terminating the process group "
922
+ f"(default: {DEFAULT_TIMEOUT_SECONDS}, max: {MAX_TIMEOUT_SECONDS})"
923
+ ),
924
+ )
925
+ parser.add_argument(
926
+ "--digest",
927
+ choices=("off", "markdown", "json"),
928
+ default="off",
929
+ help=(
930
+ "emit an opt-in semantic digest instead of raw/trimmed logs "
931
+ "(default: off; formats: markdown, json)"
932
+ ),
933
+ )
934
+ parser.add_argument("command", nargs=argparse.REMAINDER)
935
+ args = parser.parse_args()
936
+ normalize_budgets(args)
937
+
938
+ command = args.command
939
+ if command and command[0] == "--":
940
+ command = command[1:]
941
+ if not command:
942
+ print("trim_command_output.py: missing command", file=sys.stderr)
943
+ return 2
944
+
945
+ popen_kwargs: dict[str, object] = {}
946
+ if os.name != "nt":
947
+ popen_kwargs["start_new_session"] = True
948
+ try:
949
+ proc = subprocess.Popen(
950
+ command,
951
+ stdout=subprocess.PIPE,
952
+ stderr=subprocess.STDOUT,
953
+ text=True,
954
+ bufsize=1,
955
+ errors="replace",
956
+ **popen_kwargs,
957
+ )
958
+ except OSError as exc:
959
+ print(f"context-guard-kit: command failed to start: {exc}", file=sys.stderr)
960
+ return 127
961
+
962
+ all_lines: list[str] = []
963
+ head: list[str] = []
964
+ tail: collections.deque[str] = collections.deque(maxlen=args.tail_lines)
965
+ error_lines: list[str] = []
966
+ total = 0
967
+ raw_chars = 0
968
+ visible_chars = 0
969
+ any_line_capped = False
970
+ runner_summary = RunnerFailureSummary(args.runner_summary_items, show_paths=args.show_paths)
971
+ line_sanitizer = load_line_sanitizer(args.show_paths)
972
+ duplicate_tracker = DuplicateLineTracker()
973
+ redacted_lines = 0
974
+
975
+ if proc.stdout is None:
976
+ print("trim_command_output.py: subprocess produced no stdout pipe", file=sys.stderr)
977
+ return 1
978
+ command_stream = TimedCommandStream(
979
+ proc,
980
+ proc.stdout,
981
+ timeout_seconds=args.timeout_seconds,
982
+ process_group_id=process_group_id_for(proc),
983
+ )
984
+ for line in command_stream:
985
+ total += 1
986
+ raw_chars += len(line)
987
+ visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
988
+ if redacted:
989
+ redacted_lines += 1
990
+ visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
991
+ any_line_capped = any_line_capped or line_capped
992
+ visible_chars += len(visible_line)
993
+ duplicate_tracker.feed(total, visible_line)
994
+ if total <= args.head_lines:
995
+ head.append(visible_line)
996
+ tail.append(visible_line)
997
+ if ERROR_RE.search(visible_line) and len(error_lines) < args.error_lines:
998
+ error_lines.append(visible_line)
999
+ runner_summary.feed(line)
1000
+ if total <= args.max_lines:
1001
+ all_lines.append(visible_line)
1002
+
1003
+ rc = command_stream.returncode()
1004
+ if command_stream.timed_out and not command_stream.timeout_reported:
1005
+ line = command_stream.timeout_message()
1006
+ command_stream.timeout_reported = True
1007
+ total += 1
1008
+ raw_chars += len(line)
1009
+ visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
1010
+ if redacted:
1011
+ redacted_lines += 1
1012
+ visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
1013
+ any_line_capped = any_line_capped or line_capped
1014
+ visible_chars += len(visible_line)
1015
+ duplicate_tracker.feed(total, visible_line)
1016
+ if total <= args.head_lines:
1017
+ head.append(visible_line)
1018
+ tail.append(visible_line)
1019
+ if ERROR_RE.search(visible_line) and len(error_lines) < args.error_lines:
1020
+ error_lines.append(visible_line)
1021
+ runner_summary.feed(line)
1022
+ if total <= args.max_lines:
1023
+ all_lines.append(visible_line)
1024
+
1025
+ if args.digest != "off":
1026
+ payload = build_digest_payload(
1027
+ args=args,
1028
+ command=command,
1029
+ rc=rc,
1030
+ timed_out=command_stream.timed_out,
1031
+ total=total,
1032
+ raw_chars=raw_chars,
1033
+ visible_chars=visible_chars,
1034
+ any_line_capped=any_line_capped,
1035
+ redacted_lines=redacted_lines,
1036
+ head=head,
1037
+ tail=list(tail),
1038
+ error_lines=error_lines,
1039
+ runner_summary=runner_summary,
1040
+ line_sanitizer=line_sanitizer,
1041
+ duplicate_line_groups=duplicate_tracker.as_list(),
1042
+ )
1043
+ if args.digest == "json":
1044
+ sys.stdout.write(render_digest_json(payload, args.max_chars))
1045
+ else:
1046
+ sys.stdout.write(render_digest_markdown(payload, args.max_chars))
1047
+ return rc
1048
+
1049
+ if total <= args.max_lines and visible_chars <= args.max_chars and not any_line_capped:
1050
+ sys.stdout.writelines(all_lines)
1051
+ else:
1052
+ head_budget = min(args.head_lines, max(1, args.max_lines // 4))
1053
+ tail_budget = min(args.tail_lines, max(1, args.max_lines // 3))
1054
+ head_out = head[:head_budget]
1055
+ tail_out = [line for line in list(tail)[-tail_budget:] if line not in set(head_out)]
1056
+ remaining = max(0, args.max_lines - len(head_out) - len(tail_out))
1057
+ error_out = unique_keep_order(error_lines)[:remaining]
1058
+
1059
+ parts: list[str] = []
1060
+ parts.append(
1061
+ f"[context-guard-kit] output trimmed: {total} lines/{raw_chars} chars "
1062
+ f"-> budget about {args.max_lines} log lines/{args.max_chars} chars\n"
1063
+ )
1064
+ parts.append(f"[context-guard-kit] command exit_code={rc}\n")
1065
+ if any_line_capped:
1066
+ parts.append(f"[context-guard-kit] one or more lines were capped at {args.max_line_chars} chars\n")
1067
+ if redacted_lines:
1068
+ parts.append(f"[context-guard-kit] redacted_lines={redacted_lines}\n")
1069
+ summary_budget = max(0, min(args.max_lines, max(4, args.max_lines // 3))) if args.max_lines > 0 else 0
1070
+ runner_lines = runner_summary.as_lines(args.max_line_chars, summary_budget) if rc != 0 else []
1071
+ summary_line_count = len("".join(runner_lines).splitlines())
1072
+ remaining_log_budget = max(0, args.max_lines - summary_line_count)
1073
+
1074
+ parts.extend(runner_lines)
1075
+ parts.append("\n--- head ---\n")
1076
+ if remaining_log_budget > 0:
1077
+ head_out = head_out[:remaining_log_budget]
1078
+ parts.extend(head_out)
1079
+ remaining_log_budget -= len(head_out)
1080
+ if error_out:
1081
+ parts.append("\n--- matched error/failure lines ---\n")
1082
+ error_out = error_out[:remaining_log_budget]
1083
+ parts.extend(error_out)
1084
+ remaining_log_budget -= len(error_out)
1085
+ parts.append("\n--- tail ---\n")
1086
+ if remaining_log_budget > 0:
1087
+ parts.extend(tail_out[-remaining_log_budget:])
1088
+ parts.append("\n[context-guard-kit] rerun the command without trim only if more context is essential.\n")
1089
+ output, capped = cap_text("".join(parts), args.max_chars)
1090
+ if capped:
1091
+ output += "[context-guard-kit] final summary was capped by --max-chars.\n"
1092
+ sys.stdout.write(output)
1093
+
1094
+ return rc
1095
+
1096
+
1097
+ if __name__ == "__main__":
1098
+ raise SystemExit(main())