@softspark/ai-toolkit 4.15.0 → 4.16.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 (76) hide show
  1. package/AGENTS.md +117 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +19 -13
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/ARCHITECTURE.md +4 -3
  6. package/app/hooks/_hook-io.sh +18 -3
  7. package/app/hooks/ai-toolkit-statusline.sh +30 -5
  8. package/app/hooks/filter-tool-output.sh +76 -0
  9. package/app/hooks/governance-capture.sh +1 -1
  10. package/app/hooks/guard-path.sh +2 -2
  11. package/app/hooks/post-tool-use.sh +5 -3
  12. package/app/hooks/pre-compact-save.sh +4 -3
  13. package/app/hooks/quality-gate.sh +12 -1
  14. package/app/hooks/revert-guard.sh +5 -2
  15. package/app/hooks/save-session.sh +4 -2
  16. package/app/hooks/session-end.sh +36 -4
  17. package/app/hooks/session-start.sh +11 -5
  18. package/app/hooks.json +10 -0
  19. package/app/output-filter-policy.json +15 -0
  20. package/app/skills/brand-voice/scripts/measure.py +7 -5
  21. package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
  22. package/benchmarks/output-filter/README.md +11 -0
  23. package/benchmarks/output-filter/scenarios.json +25 -0
  24. package/bin/ai-toolkit.js +2 -0
  25. package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
  26. package/kb/procedures/release-preparation-sop.md +6 -5
  27. package/kb/reference/architecture-overview.md +6 -5
  28. package/kb/reference/cli-reference.md +19 -2
  29. package/kb/reference/codex-cli-compatibility.md +1 -0
  30. package/kb/reference/copilot-compatibility.md +173 -0
  31. package/kb/reference/enterprise-config-guide.md +28 -2
  32. package/kb/reference/global-install-model.md +6 -2
  33. package/kb/reference/hooks-catalog.md +105 -16
  34. package/kb/reference/opencode-compatibility.md +1 -0
  35. package/kb/reference/supported-tools-registry.md +10 -5
  36. package/kb/reference/tool-output-filter.md +288 -0
  37. package/kb/reference/windows-support.md +4 -3
  38. package/llms-full.txt +1182 -40
  39. package/llms.txt +3 -0
  40. package/manifest.json +9 -6
  41. package/package.json +3 -2
  42. package/scripts/benchmark_output_filter.py +343 -0
  43. package/scripts/check_deps.py +16 -0
  44. package/scripts/claude_app.py +30 -2
  45. package/scripts/config_cli.py +4 -4
  46. package/scripts/config_lock.py +120 -14
  47. package/scripts/config_merger.py +103 -20
  48. package/scripts/config_resolver.py +22 -2
  49. package/scripts/config_validator.py +268 -16
  50. package/scripts/copilot_legacy_hashes.json +338 -0
  51. package/scripts/doctor.py +1 -0
  52. package/scripts/generate_codex_hooks.py +2 -0
  53. package/scripts/generate_copilot.py +464 -71
  54. package/scripts/generate_copilot_hooks.py +124 -7
  55. package/scripts/generate_gemini_hooks.py +33 -10
  56. package/scripts/generate_opencode_plugin.py +28 -12
  57. package/scripts/install_steps/ai_tools.py +115 -3
  58. package/scripts/install_steps/hooks.py +25 -1
  59. package/scripts/output_filter_cli.py +347 -0
  60. package/scripts/output_filter_hook.py +23 -0
  61. package/scripts/plugin_schema.py +27 -1
  62. package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
  63. package/scripts/session_state.py +156 -42
  64. package/scripts/tool_output_filter/__init__.py +33 -0
  65. package/scripts/tool_output_filter/contracts.py +173 -0
  66. package/scripts/tool_output_filter/engine.py +260 -0
  67. package/scripts/tool_output_filter/hook_runtime.py +369 -0
  68. package/scripts/tool_output_filter/input.py +56 -0
  69. package/scripts/tool_output_filter/invariants.py +40 -0
  70. package/scripts/tool_output_filter/policy.py +153 -0
  71. package/scripts/tool_output_filter/profiles/__init__.py +68 -0
  72. package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
  73. package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
  74. package/scripts/tool_output_filter/recovery.py +846 -0
  75. package/scripts/tool_output_filter/telemetry.py +13 -0
  76. package/scripts/uninstall.py +96 -3
@@ -5,14 +5,16 @@ Stores per-session file edits so hooks can:
5
5
  - Know which paths were touched (revert-guard, test-cohesion).
6
6
  - Run only related tests (quality-gate ai-toolkit branch).
7
7
 
8
- State file: ``~/.softspark/ai-toolkit/state/session-edits.json``
8
+ State files: ``~/.softspark/ai-toolkit/state/session-edits-<hash>.json``.
9
+ The legacy ``session-edits.json`` alias tracks the most recently active session.
9
10
 
10
11
  Usage:
11
12
  session_state.py reset [--session-id ID]
12
13
  session_state.py append --tool Edit --path /abs/path [--session-id ID]
13
- session_state.py was-edited /abs/path
14
- session_state.py list
15
- session_state.py session-id
14
+ session_state.py was-edited /abs/path [--session-id ID]
15
+ session_state.py list [--session-id ID]
16
+ session_state.py session-id [--session-id ID]
17
+ session_state.py clean --session-id ID
16
18
 
17
19
  Exit codes:
18
20
  0 success / true
@@ -23,9 +25,12 @@ from __future__ import annotations
23
25
 
24
26
  import argparse
25
27
  import datetime as _dt
28
+ import hashlib
26
29
  import json
27
30
  import os
31
+ import stat
28
32
  import sys
33
+ import tempfile
29
34
  import uuid
30
35
  from pathlib import Path
31
36
 
@@ -42,77 +47,176 @@ def _ensure_dir() -> None:
42
47
  STATE_DIR.mkdir(parents=True, exist_ok=True)
43
48
 
44
49
 
45
- def _load() -> dict:
46
- if not STATE_FILE.is_file():
47
- return {"session_id": "", "started_at": "", "edits": []}
50
+ def _state_file(session_id: str | None) -> Path:
51
+ if not session_id:
52
+ return STATE_FILE
53
+ digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
54
+ return STATE_DIR / f"session-edits-{digest}.json"
55
+
56
+
57
+ def _empty_state(session_id: str = "") -> dict:
58
+ return {"session_id": session_id, "started_at": "", "edits": []}
59
+
60
+
61
+ def _validated_state(data: object, session_id: str | None) -> dict:
62
+ fallback = _empty_state(session_id or "")
63
+ if not isinstance(data, dict):
64
+ return fallback
65
+ stored_session = data.get("session_id", session_id or "")
66
+ started_at = data.get("started_at", "")
67
+ edits = data.get("edits", [])
68
+ if not isinstance(stored_session, str) or not isinstance(started_at, str):
69
+ return fallback
70
+ if not isinstance(edits, list) or any(
71
+ not isinstance(edit, dict)
72
+ or not isinstance(edit.get("path", ""), str)
73
+ for edit in edits
74
+ ):
75
+ return fallback
76
+ return {
77
+ "session_id": stored_session,
78
+ "started_at": started_at,
79
+ "edits": edits,
80
+ }
81
+
82
+
83
+ def _load(session_id: str | None = None) -> dict:
84
+ state_file = _state_file(session_id)
85
+ if not state_file.is_file():
86
+ return _empty_state(session_id or "")
48
87
  try:
49
- with STATE_FILE.open() as f:
88
+ with state_file.open(encoding="utf-8") as f:
50
89
  data = json.load(f)
51
- except (json.JSONDecodeError, OSError):
52
- return {"session_id": "", "started_at": "", "edits": []}
53
- data.setdefault("session_id", "")
54
- data.setdefault("started_at", "")
55
- data.setdefault("edits", [])
56
- return data
90
+ except (json.JSONDecodeError, OSError, UnicodeError):
91
+ return _empty_state(session_id or "")
92
+ return _validated_state(data, session_id)
57
93
 
58
94
 
59
- def _save(data: dict) -> None:
95
+ def _atomic_save(state_file: Path, data: dict) -> None:
60
96
  _ensure_dir()
61
- tmp = STATE_FILE.with_suffix(".json.tmp")
62
- with tmp.open("w") as f:
63
- json.dump(data, f, indent=2)
64
- f.write("\n")
65
- os.replace(tmp, STATE_FILE)
97
+ temp_path: Path | None = None
98
+ try:
99
+ with tempfile.NamedTemporaryFile(
100
+ mode="w",
101
+ dir=STATE_DIR,
102
+ prefix=f".{state_file.name}.",
103
+ encoding="utf-8",
104
+ delete=False,
105
+ ) as temp_file:
106
+ json.dump(data, temp_file, indent=2)
107
+ temp_file.write("\n")
108
+ temp_path = Path(temp_file.name)
109
+ os.replace(temp_path, state_file)
110
+ temp_path = None
111
+ finally:
112
+ if temp_path is not None:
113
+ temp_path.unlink(missing_ok=True)
114
+
115
+
116
+ def _save(data: dict, session_id: str | None = None) -> None:
117
+ _atomic_save(_state_file(session_id), data)
118
+ if session_id:
119
+ # Preserve no-ID CLI behavior without making it the source of truth.
120
+ _atomic_save(STATE_FILE, data)
66
121
 
67
122
 
68
123
  def cmd_reset(session_id: str | None) -> int:
69
124
  sid = session_id or str(uuid.uuid4())
70
- _save({"session_id": sid, "started_at": _now(), "edits": []})
125
+ _save({"session_id": sid, "started_at": _now(), "edits": []}, session_id)
71
126
  return 0
72
127
 
73
128
 
74
129
  def cmd_append(tool: str, path: str, session_id: str | None) -> int:
75
130
  if not path:
76
131
  return 0 # silently ignore tools without file_path (e.g., bash)
77
- data = _load()
132
+ data = _load(session_id)
78
133
  if session_id and data["session_id"] != session_id:
79
- # New session detected mid-stream — auto-reset.
80
- cmd_reset(session_id)
81
- data = _load()
134
+ data = _empty_state(session_id)
135
+ if not data["started_at"]:
136
+ data["started_at"] = _now()
82
137
  abs_path = os.path.abspath(path)
83
138
  data["edits"].append({"ts": _now(), "tool": tool, "path": abs_path})
84
139
  if len(data["edits"]) > MAX_EDITS:
85
140
  data["edits"] = data["edits"][-MAX_EDITS:]
86
- _save(data)
141
+ _save(data, session_id)
87
142
  return 0
88
143
 
89
144
 
90
- def cmd_was_edited(path: str) -> int:
145
+ def cmd_was_edited(path: str, session_id: str | None) -> int:
91
146
  abs_path = os.path.abspath(path)
92
- data = _load()
147
+ data = _load(session_id)
93
148
  for edit in data["edits"]:
94
149
  if edit.get("path") == abs_path:
95
150
  return 0
96
151
  return 1
97
152
 
98
153
 
99
- def cmd_list() -> int:
100
- data = _load()
154
+ def cmd_list(session_id: str | None) -> int:
155
+ data = _load(session_id)
101
156
  seen: set[str] = set()
102
157
  for edit in data["edits"]:
103
- p = edit.get("path", "")
104
- if p and p not in seen:
105
- seen.add(p)
106
- print(p)
158
+ path = edit.get("path", "")
159
+ if path and path not in seen:
160
+ seen.add(path)
161
+ print(path)
107
162
  return 0
108
163
 
109
164
 
110
- def cmd_session_id() -> int:
111
- data = _load()
165
+ def cmd_session_id(session_id: str | None) -> int:
166
+ data = _load(session_id)
112
167
  print(data.get("session_id", ""))
113
168
  return 0
114
169
 
115
170
 
171
+ def cmd_clean(session_id: str) -> int:
172
+ """Remove the selected hashed state and its matching legacy alias."""
173
+ state_file = _state_file(session_id)
174
+ try:
175
+ metadata = state_file.lstat()
176
+ except FileNotFoundError:
177
+ _clean_matching_legacy_alias(session_id)
178
+ return 0
179
+ if not stat.S_ISREG(metadata.st_mode):
180
+ return 2
181
+ try:
182
+ state_file.unlink()
183
+ except FileNotFoundError:
184
+ pass
185
+ _clean_matching_legacy_alias(session_id)
186
+ return 0
187
+
188
+
189
+ def _clean_matching_legacy_alias(session_id: str) -> None:
190
+ """Remove the compatibility alias only when it names this session."""
191
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(
192
+ os,
193
+ "O_NOFOLLOW",
194
+ 0,
195
+ )
196
+ try:
197
+ descriptor = os.open(STATE_FILE, flags)
198
+ except OSError:
199
+ return
200
+ opened_metadata = os.fstat(descriptor)
201
+ try:
202
+ with os.fdopen(descriptor, encoding="utf-8") as state_handle:
203
+ data = json.load(state_handle)
204
+ except (json.JSONDecodeError, OSError, UnicodeError):
205
+ return
206
+ if isinstance(data, dict) and data.get("session_id") == session_id:
207
+ try:
208
+ current_metadata = STATE_FILE.lstat()
209
+ if (
210
+ not stat.S_ISREG(current_metadata.st_mode)
211
+ or current_metadata.st_dev != opened_metadata.st_dev
212
+ or current_metadata.st_ino != opened_metadata.st_ino
213
+ ):
214
+ return
215
+ STATE_FILE.unlink()
216
+ except FileNotFoundError:
217
+ pass
218
+
219
+
116
220
  def main(argv: list[str] | None = None) -> int:
117
221
  parser = argparse.ArgumentParser(description="Session edit state tracker")
118
222
  sub = parser.add_subparsers(dest="cmd", required=True)
@@ -127,9 +231,17 @@ def main(argv: list[str] | None = None) -> int:
127
231
 
128
232
  was = sub.add_parser("was-edited", help="Exit 0 if path was edited this session")
129
233
  was.add_argument("path")
130
-
131
- sub.add_parser("list", help="Print unique edited paths, one per line")
132
- sub.add_parser("session-id", help="Print current session id")
234
+ was.add_argument("--session-id", default=None)
235
+
236
+ list_parser = sub.add_parser("list", help="Print unique edited paths, one per line")
237
+ list_parser.add_argument("--session-id", default=None)
238
+ session_parser = sub.add_parser("session-id", help="Print current session id")
239
+ session_parser.add_argument("--session-id", default=None)
240
+ clean_parser = sub.add_parser(
241
+ "clean",
242
+ help="Remove one session-scoped edit state file",
243
+ )
244
+ clean_parser.add_argument("--session-id", required=True)
133
245
 
134
246
  args = parser.parse_args(argv)
135
247
 
@@ -138,11 +250,13 @@ def main(argv: list[str] | None = None) -> int:
138
250
  if args.cmd == "append":
139
251
  return cmd_append(args.tool, args.path, args.session_id)
140
252
  if args.cmd == "was-edited":
141
- return cmd_was_edited(args.path)
253
+ return cmd_was_edited(args.path, args.session_id)
142
254
  if args.cmd == "list":
143
- return cmd_list()
255
+ return cmd_list(args.session_id)
144
256
  if args.cmd == "session-id":
145
- return cmd_session_id()
257
+ return cmd_session_id(args.session_id)
258
+ if args.cmd == "clean":
259
+ return cmd_clean(args.session_id)
146
260
  return 2
147
261
 
148
262
 
@@ -0,0 +1,33 @@
1
+ """Dependency-free post-execution tool-output filtering."""
2
+
3
+ from .contracts import FilterMode, FilterRequest, FilterResult
4
+ from .engine import filter_output
5
+ from .invariants import SessionCircuitBreaker
6
+ from .recovery import (
7
+ EphemeralRecoveryStore,
8
+ RecoveryStore,
9
+ RecoveryUnavailableError,
10
+ clean_owned_recovery_tree,
11
+ clean_owned_repo_recovery,
12
+ clean_session,
13
+ count_owned_recovery_artifacts,
14
+ recover_by_handle,
15
+ )
16
+ from .telemetry import TelemetrySink
17
+
18
+ __all__ = [
19
+ "EphemeralRecoveryStore",
20
+ "FilterMode",
21
+ "FilterRequest",
22
+ "FilterResult",
23
+ "RecoveryStore",
24
+ "RecoveryUnavailableError",
25
+ "SessionCircuitBreaker",
26
+ "TelemetrySink",
27
+ "clean_owned_recovery_tree",
28
+ "clean_owned_repo_recovery",
29
+ "clean_session",
30
+ "count_owned_recovery_artifacts",
31
+ "filter_output",
32
+ "recover_by_handle",
33
+ ]
@@ -0,0 +1,173 @@
1
+ """Public data contracts for the tool-output filter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ DEFAULT_MAX_INPUT_BYTES = 8 * 1024 * 1024
8
+ DEFAULT_MIN_SAVINGS_BYTES = 1024
9
+ DEFAULT_MIN_SAVINGS_RATIO = 0.15
10
+
11
+
12
+ class _ImmutableSlots:
13
+ """Allow one assignment per declared slot."""
14
+
15
+ __slots__ = ()
16
+
17
+ def __setattr__(self, name: str, value: object) -> None:
18
+ if hasattr(self, name):
19
+ raise AttributeError(f"{type(self).__name__} is immutable")
20
+ object.__setattr__(self, name, value)
21
+
22
+
23
+ class FilterMode(str, Enum):
24
+ """Supported activation modes."""
25
+
26
+ OFF = "off"
27
+ OBSERVE = "observe"
28
+ SAFE = "safe"
29
+
30
+
31
+ class FilterRequest(_ImmutableSlots):
32
+ """Normalized successful textual tool output."""
33
+
34
+ __slots__ = (
35
+ "interrupted",
36
+ "is_image",
37
+ "is_streaming",
38
+ "max_input_bytes",
39
+ "min_savings_bytes",
40
+ "min_savings_ratio",
41
+ "mode",
42
+ "output",
43
+ "profile_id",
44
+ "raw_response",
45
+ "stderr",
46
+ "successful",
47
+ )
48
+
49
+ def __init__(
50
+ self,
51
+ output: str,
52
+ mode: FilterMode,
53
+ profile_id: str,
54
+ raw_response: object | None = None,
55
+ successful: bool = True,
56
+ stderr: str = "",
57
+ interrupted: bool = False,
58
+ is_image: bool = False,
59
+ is_streaming: bool = False,
60
+ max_input_bytes: int = DEFAULT_MAX_INPUT_BYTES,
61
+ min_savings_bytes: int = DEFAULT_MIN_SAVINGS_BYTES,
62
+ min_savings_ratio: float = DEFAULT_MIN_SAVINGS_RATIO,
63
+ ) -> None:
64
+ self.output = output
65
+ self.mode = mode
66
+ self.profile_id = profile_id
67
+ self.raw_response = raw_response
68
+ self.successful = successful
69
+ self.stderr = stderr
70
+ self.interrupted = interrupted
71
+ self.is_image = is_image
72
+ self.is_streaming = is_streaming
73
+ self.max_input_bytes = max_input_bytes
74
+ self.min_savings_bytes = min_savings_bytes
75
+ self.min_savings_ratio = min_savings_ratio
76
+
77
+
78
+ class FilterTelemetry(_ImmutableSlots):
79
+ """Content-free measurements for one filter decision."""
80
+
81
+ __slots__ = (
82
+ "duration_ms",
83
+ "fallback_reason",
84
+ "input_bytes",
85
+ "input_lines",
86
+ "outcome",
87
+ "output_bytes",
88
+ "output_lines",
89
+ "profile_id",
90
+ "profile_version",
91
+ )
92
+
93
+ def __init__(
94
+ self,
95
+ profile_id: str,
96
+ profile_version: int,
97
+ input_bytes: int,
98
+ output_bytes: int,
99
+ input_lines: int,
100
+ output_lines: int,
101
+ duration_ms: float = 0.0,
102
+ outcome: str = "",
103
+ fallback_reason: str | None = None,
104
+ ) -> None:
105
+ self.profile_id = profile_id
106
+ self.profile_version = profile_version
107
+ self.input_bytes = input_bytes
108
+ self.output_bytes = output_bytes
109
+ self.input_lines = input_lines
110
+ self.output_lines = output_lines
111
+ self.duration_ms = duration_ms
112
+ self.outcome = outcome
113
+ self.fallback_reason = fallback_reason
114
+
115
+ def with_runtime(
116
+ self,
117
+ *,
118
+ duration_ms: float,
119
+ outcome: str,
120
+ fallback_reason: str | None,
121
+ ) -> FilterTelemetry:
122
+ return FilterTelemetry(
123
+ self.profile_id,
124
+ self.profile_version,
125
+ self.input_bytes,
126
+ self.output_bytes,
127
+ self.input_lines,
128
+ self.output_lines,
129
+ duration_ms,
130
+ outcome,
131
+ fallback_reason,
132
+ )
133
+
134
+ def as_dict(self) -> dict[str, object]:
135
+ return {
136
+ slot: getattr(self, slot)
137
+ for slot in self.__slots__
138
+ }
139
+
140
+
141
+ class FilterResult(_ImmutableSlots):
142
+ """Observable result returned by the filtering engine."""
143
+
144
+ __slots__ = (
145
+ "changed",
146
+ "fallback_reason",
147
+ "outcome",
148
+ "output",
149
+ "telemetry",
150
+ )
151
+
152
+ def __init__(
153
+ self,
154
+ output: str,
155
+ changed: bool,
156
+ outcome: str,
157
+ telemetry: FilterTelemetry | None = None,
158
+ fallback_reason: str | None = None,
159
+ ) -> None:
160
+ self.output = output
161
+ self.changed = changed
162
+ self.outcome = outcome
163
+ self.telemetry = telemetry
164
+ self.fallback_reason = fallback_reason
165
+
166
+ def with_telemetry(self, telemetry: FilterTelemetry) -> FilterResult:
167
+ return FilterResult(
168
+ self.output,
169
+ self.changed,
170
+ self.outcome,
171
+ telemetry,
172
+ self.fallback_reason,
173
+ )