@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
@@ -8,6 +8,7 @@ Stdlib-only — no external dependencies.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import hashlib
11
12
  import json
12
13
  import sys
13
14
  from datetime import datetime, timezone
@@ -41,9 +42,10 @@ def load_lock_file(project_dir: Path) -> dict[str, Any] | None:
41
42
  return None
42
43
  try:
43
44
  with open(lock_path, encoding="utf-8") as f:
44
- return json.load(f)
45
- except (json.JSONDecodeError, OSError):
45
+ lock = json.load(f)
46
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
46
47
  return None
48
+ return lock if isinstance(lock, dict) else None
47
49
 
48
50
 
49
51
  def save_lock_file(
@@ -66,7 +68,7 @@ def save_lock_file(
66
68
  "lockfileVersion": LOCK_VERSION,
67
69
  "resolved": {},
68
70
  "generated_at": _now_iso(),
69
- "ai_toolkit_version": ai_toolkit_version,
71
+ "ai_toolkit_version": ai_toolkit_version or _current_toolkit_version(),
70
72
  }
71
73
 
72
74
  for config in resolved_configs:
@@ -105,16 +107,11 @@ def check_lock_staleness(project_dir: Path) -> str:
105
107
  if lock is None:
106
108
  return "missing"
107
109
 
108
- # Check lock version
109
- if lock.get("lockfileVersion") != LOCK_VERSION:
110
- return f"stale: lock version {lock.get('lockfileVersion')} != {LOCK_VERSION}"
111
-
112
- # Check if resolved entries exist
113
- resolved = lock.get("resolved", {})
114
- if not resolved:
115
- return "stale: no resolved entries"
116
-
117
- return "ok"
110
+ header_error = _check_lock_header(lock)
111
+ if header_error:
112
+ return header_error
113
+ resolved = lock["resolved"]
114
+ return _check_resolved_chain(config["extends"], resolved)
118
115
 
119
116
 
120
117
  def get_locked_version(project_dir: Path, config_name: str) -> str | None:
@@ -126,8 +123,13 @@ def get_locked_version(project_dir: Path, config_name: str) -> str | None:
126
123
  if lock is None:
127
124
  return None
128
125
  resolved = lock.get("resolved", {})
126
+ if not isinstance(resolved, dict):
127
+ return None
129
128
  entry = resolved.get(config_name, {})
130
- return entry.get("version") or None
129
+ if not isinstance(entry, dict):
130
+ return None
131
+ version = entry.get("version")
132
+ return version if isinstance(version, str) and version else None
131
133
 
132
134
 
133
135
  # ---------------------------------------------------------------------------
@@ -139,6 +141,110 @@ def _now_iso() -> str:
139
141
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
140
142
 
141
143
 
144
+ def _check_lock_header(lock: dict[str, Any]) -> str:
145
+ """Validate lock format and toolkit version."""
146
+ if lock.get("lockfileVersion") != LOCK_VERSION:
147
+ return f"stale: lock version {lock.get('lockfileVersion')} != {LOCK_VERSION}"
148
+ locked_version = lock.get("ai_toolkit_version")
149
+ current_version = _current_toolkit_version()
150
+ if current_version and (
151
+ not isinstance(locked_version, str)
152
+ or not locked_version
153
+ ):
154
+ return "stale: ai-toolkit version metadata is missing or invalid"
155
+ if current_version and locked_version != current_version:
156
+ return (
157
+ f"stale: ai-toolkit version '{locked_version}' "
158
+ f"!= current '{current_version}'"
159
+ )
160
+ resolved = lock.get("resolved")
161
+ if not isinstance(resolved, dict) or not resolved:
162
+ return "stale: no resolved entries"
163
+ if not all(isinstance(entry, dict) for entry in resolved.values()):
164
+ return "stale: invalid resolved entries"
165
+ return ""
166
+
167
+
168
+ def _check_resolved_chain(
169
+ current_source: str,
170
+ resolved: dict[str, dict[str, Any]],
171
+ ) -> str:
172
+ """Compare every locked config against its current cached snapshot."""
173
+ if not isinstance(current_source, str) or not current_source:
174
+ return "stale: invalid project extends source"
175
+ for name, entry in reversed(list(resolved.items())):
176
+ locked_source = entry.get("source", "")
177
+ if current_source != locked_source:
178
+ return (
179
+ f"stale: extends source '{current_source}' "
180
+ f"!= locked '{locked_source}'"
181
+ )
182
+ error, current_config = _check_resolved_entry(name, entry)
183
+ if error:
184
+ return error
185
+ assert current_config is not None
186
+ current_source = current_config.get("extends", "")
187
+ if not isinstance(current_source, str):
188
+ return f"stale: invalid extends source in resolved config {name}"
189
+ if current_source:
190
+ return f"stale: unresolved extends source '{current_source}'"
191
+ return "ok"
192
+
193
+
194
+ def _check_resolved_entry(
195
+ name: str,
196
+ entry: dict[str, Any],
197
+ ) -> tuple[str, dict[str, Any] | None]:
198
+ """Compare version and integrity for one locked config."""
199
+ cached = entry.get("cached")
200
+ locked_source = entry.get("source")
201
+ locked_version = entry.get("version")
202
+ locked_integrity = entry.get("integrity")
203
+ if (
204
+ not isinstance(cached, str)
205
+ or not cached
206
+ or not isinstance(locked_source, str)
207
+ or not locked_source
208
+ or not isinstance(locked_version, str)
209
+ or not isinstance(locked_integrity, str)
210
+ or not locked_integrity.startswith("sha256:")
211
+ ):
212
+ return f"stale: invalid resolved config metadata for {name}", None
213
+ config_path = Path(cached).expanduser() / "ai-toolkit.config.json"
214
+ try:
215
+ raw_config = config_path.read_bytes()
216
+ current_config = json.loads(raw_config.decode("utf-8"))
217
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
218
+ return f"stale: cannot read resolved config for {name}", None
219
+ if not isinstance(current_config, dict):
220
+ return f"stale: invalid resolved config content for {name}", None
221
+ current_version = current_config.get("version", "")
222
+ if current_version != locked_version:
223
+ return (
224
+ f"stale: {name} version '{current_version}' "
225
+ f"!= locked '{locked_version}'"
226
+ ), None
227
+ current_integrity = f"sha256:{hashlib.sha256(raw_config).hexdigest()}"
228
+ if current_integrity != locked_integrity:
229
+ return (
230
+ f"stale: {name} integrity '{current_integrity}' "
231
+ f"!= locked '{locked_integrity}'"
232
+ ), None
233
+ return "", current_config
234
+
235
+
236
+ def _current_toolkit_version() -> str:
237
+ """Read the version of the toolkit owning this lock implementation."""
238
+ package_path = Path(__file__).resolve().parent.parent / "package.json"
239
+ try:
240
+ with open(package_path, encoding="utf-8") as handle:
241
+ package = json.load(handle)
242
+ except (json.JSONDecodeError, OSError):
243
+ return ""
244
+ version = package.get("version", "")
245
+ return version if isinstance(version, str) else ""
246
+
247
+
142
248
  # ---------------------------------------------------------------------------
143
249
  # CLI entry point (for testing)
144
250
  # ---------------------------------------------------------------------------
@@ -2,7 +2,7 @@
2
2
  """Config merger for ai-toolkit extends system.
3
3
 
4
4
  Implements layered deep merge with:
5
- - Constitution immutability (Articles I-VI absolute, base articles immutable)
5
+ - Constitution immutability (Articles I-VII absolute, base articles immutable)
6
6
  - Agent merge with requiredAgents enforcement
7
7
  - Override validation (override:true + justification required)
8
8
  - enforce block constraints (minHookProfile, requiredPlugins, forbidOverride, requiredAgents)
@@ -22,17 +22,10 @@ from typing import Any
22
22
  # Constants
23
23
  # ---------------------------------------------------------------------------
24
24
 
25
- IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6})
25
+ IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6, 7})
26
26
 
27
27
  HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
28
28
 
29
- # v1 schema fields that participate in merge
30
- V1_FIELDS = frozenset({
31
- "$schema", "extends", "name", "version", "description",
32
- "profile", "agents", "rules", "constitution", "enforce", "overrides",
33
- })
34
-
35
-
36
29
  # ---------------------------------------------------------------------------
37
30
  # Exceptions
38
31
  # ---------------------------------------------------------------------------
@@ -84,6 +77,13 @@ def merge_config_chain(
84
77
  # Merge project over accumulated base
85
78
  result.merged = _merge_project_over_base(accumulated_base, project_config, result)
86
79
 
80
+ if "toolOutputFilter" in accumulated_base or "toolOutputFilter" in project_config:
81
+ configured = result.merged.get("toolOutputFilter", {})
82
+ result.merged["toolOutputFilter"] = _deep_merge(
83
+ _load_default_output_filter_policy(),
84
+ configured,
85
+ )
86
+
87
87
  # Validate enforce constraints
88
88
  _validate_enforce(accumulated_base, result.merged)
89
89
 
@@ -95,6 +95,16 @@ def merge_two(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
95
95
  return _deep_merge(base, overlay)
96
96
 
97
97
 
98
+ def _load_default_output_filter_policy() -> dict[str, Any]:
99
+ """Load the canonical disabled output-filter policy."""
100
+ policy_path = Path(__file__).resolve().parent.parent / "app" / "output-filter-policy.json"
101
+ with open(policy_path, encoding="utf-8") as handle:
102
+ policy = json.load(handle)
103
+ if not isinstance(policy, dict):
104
+ raise ConfigMergeError(f"Invalid output-filter policy: {policy_path}")
105
+ return policy
106
+
107
+
98
108
  # ---------------------------------------------------------------------------
99
109
  # Internal: project-over-base merge (with enforcement)
100
110
  # ---------------------------------------------------------------------------
@@ -108,6 +118,8 @@ def _merge_project_over_base(
108
118
  merged: dict[str, Any] = {}
109
119
 
110
120
  all_keys = set(base.keys()) | set(project.keys())
121
+ if base.get("enforce", {}).get("requiredPlugins"):
122
+ all_keys.add("plugins")
111
123
 
112
124
  for key in all_keys:
113
125
  # Skip meta fields that don't participate in merge output
@@ -119,23 +131,21 @@ def _merge_project_over_base(
119
131
  base_val = base.get(key)
120
132
  proj_val = project.get(key)
121
133
 
122
- if proj_val is None:
134
+ if key == "plugins":
135
+ merged[key] = _merge_plugins(base_val or {}, proj_val or {}, base)
136
+ elif proj_val is None:
123
137
  merged[key] = base_val
124
138
  elif key == "overrides":
125
139
  # Always validate overrides against base enforce, even if base has no overrides
126
140
  merged[key] = _validate_overrides(base, proj_val, result)
127
- elif key == "constitution" and base_val is not None:
128
- merged[key] = _merge_constitution(base_val, proj_val, base)
141
+ elif key == "constitution":
142
+ merged[key] = _merge_constitution(base_val or {}, proj_val, base)
129
143
  elif base_val is None:
130
144
  merged[key] = proj_val
131
- elif key == "constitution":
132
- merged[key] = _merge_constitution(base_val, proj_val, base)
133
145
  elif key == "agents":
134
146
  merged[key] = _merge_agents(base_val, proj_val, base)
135
147
  elif key == "rules":
136
148
  merged[key] = _merge_rules(base_val, proj_val)
137
- elif key == "overrides":
138
- merged[key] = _validate_overrides(base, proj_val, result)
139
149
  elif key == "enforce":
140
150
  # enforce blocks merge: base wins (projects cannot weaken enforcement)
141
151
  merged[key] = _merge_enforce(base_val, proj_val)
@@ -163,7 +173,7 @@ def _merge_constitution(
163
173
  """Merge constitution — additions only, no modifications.
164
174
 
165
175
  Rules:
166
- 1. Articles I-VI (1-6) are ABSOLUTELY immutable — toolkit core.
176
+ 1. Articles I-VII (1-7) are ABSOLUTELY immutable — toolkit core.
167
177
  2. Articles defined by base configs are immutable — projects cannot modify.
168
178
  3. Projects can ADD new articles with article numbers not in base.
169
179
  """
@@ -177,8 +187,8 @@ def _merge_constitution(
177
187
  if article_num in IMMUTABLE_ARTICLES:
178
188
  raise ConfigMergeError(
179
189
  f"Cannot modify Constitution Article {article_num} — immutable.\n"
180
- f"Articles I-VI are defined by ai-toolkit and cannot be overridden.\n"
181
- f"You can ADD new articles (article 7+)."
190
+ f"Articles I-VII are defined by ai-toolkit and cannot be overridden.\n"
191
+ f"You can ADD new articles (article 8+)."
182
192
  )
183
193
  if article_num in base_amendments:
184
194
  raise ConfigMergeError(
@@ -226,6 +236,71 @@ def _merge_agents(
226
236
  }
227
237
 
228
238
 
239
+ def _merge_plugins(
240
+ base: dict[str, Any],
241
+ project: dict[str, Any],
242
+ full_base: dict[str, Any],
243
+ ) -> dict[str, list[str]]:
244
+ """Merge plugin intent while preserving inherited requirements."""
245
+ base_enabled = _plugin_names(base, "enabled", "base plugins")
246
+ base_disabled = _plugin_names(base, "disabled", "base plugins")
247
+ project_enabled = _plugin_names(project, "enabled", "project plugins")
248
+ project_disabled = _plugin_names(project, "disabled", "project plugins")
249
+ required = _plugin_names(
250
+ full_base.get("enforce", {}),
251
+ "requiredPlugins",
252
+ "base enforce",
253
+ )
254
+ for label, enabled, disabled in (
255
+ ("base", base_enabled, base_disabled),
256
+ ("project", project_enabled, project_disabled),
257
+ ):
258
+ overlap = enabled & disabled
259
+ if overlap:
260
+ raise ConfigMergeError(
261
+ f"{label.capitalize()} plugins cannot be both enabled and "
262
+ f"disabled: {', '.join(sorted(overlap))}."
263
+ )
264
+ blocked = required & project_disabled
265
+ if blocked:
266
+ plugin = sorted(blocked)[0]
267
+ raise ConfigMergeError(
268
+ f"Cannot disable plugin '{plugin}' — required by base config "
269
+ f"'{full_base.get('name', 'unknown')}'."
270
+ )
271
+
272
+ enabled = set(base_enabled)
273
+ enabled.update(project_enabled)
274
+ enabled.difference_update(project_disabled)
275
+ enabled.update(required)
276
+ disabled = (base_disabled | project_disabled) - enabled
277
+ return {
278
+ "enabled": sorted(enabled),
279
+ "disabled": sorted(disabled),
280
+ }
281
+
282
+
283
+ def _plugin_names(
284
+ block: Any,
285
+ key: str,
286
+ label: str,
287
+ ) -> set[str]:
288
+ """Return validated plugin names for a merge boundary."""
289
+ if not isinstance(block, dict):
290
+ raise ConfigMergeError(f"{label} must be an object.")
291
+ value = block.get(key, [])
292
+ if not isinstance(value, list) or not all(
293
+ isinstance(item, str) and item.strip()
294
+ for item in value
295
+ ):
296
+ raise ConfigMergeError(
297
+ f"{label}.{key} must be an array of non-empty strings."
298
+ )
299
+ if len(value) != len(set(value)):
300
+ raise ConfigMergeError(f"{label}.{key} must not contain duplicates.")
301
+ return set(value)
302
+
303
+
229
304
  # ---------------------------------------------------------------------------
230
305
  # Merge: rules
231
306
  # ---------------------------------------------------------------------------
@@ -363,7 +438,15 @@ def _validate_enforce(
363
438
  f"is below minimum '{min_profile}' required by base config."
364
439
  )
365
440
 
366
- # requiredPlugins — deferred to v2 (plugins field not in v1)
441
+ required_plugins = set(enforce.get("requiredPlugins", []))
442
+ if required_plugins:
443
+ enabled_plugins = set(merged.get("plugins", {}).get("enabled", []))
444
+ missing_plugins = required_plugins - enabled_plugins
445
+ if missing_plugins:
446
+ errors.append(
447
+ "Required plugins missing from enabled intent: "
448
+ f"{', '.join(sorted(missing_plugins))}."
449
+ )
367
450
 
368
451
  # requiredAgents
369
452
  required_agents = set(enforce.get("requiredAgents", []))
@@ -10,7 +10,6 @@ from __future__ import annotations
10
10
 
11
11
  import hashlib
12
12
  import json
13
- import os
14
13
  import shutil
15
14
  import subprocess
16
15
  import sys
@@ -167,6 +166,7 @@ def _resolve_chain(
167
166
 
168
167
  # Resolve this source
169
168
  base_config = _resolve_source(extends_value, project_root, result, refresh=refresh)
169
+ _validate_resolved_base(base_config)
170
170
 
171
171
  # Recurse if this base also extends something
172
172
  if base_config.extends:
@@ -182,6 +182,19 @@ def _resolve_chain(
182
182
  result.configs.append(base_config)
183
183
 
184
184
 
185
+ def _validate_resolved_base(base_config: BaseConfig) -> None:
186
+ """Reject an invalid base before consumers merge or inspect it."""
187
+ from config_validator import validate_base_config
188
+
189
+ errors = validate_base_config(base_config.data, base_config.root)
190
+ if not errors:
191
+ return
192
+ details = "\n".join(f" - {error}" for error in errors)
193
+ raise ConfigResolverError(
194
+ f"Invalid base config '{base_config.source}':\n{details}"
195
+ )
196
+
197
+
185
198
  def _resolve_source(
186
199
  source: str,
187
200
  project_root: Path,
@@ -470,11 +483,18 @@ def _load_json(path: Path) -> dict[str, Any]:
470
483
  """Load and parse a JSON file."""
471
484
  try:
472
485
  with open(path, encoding="utf-8") as f:
473
- return json.load(f)
486
+ config = json.load(f)
474
487
  except json.JSONDecodeError as e:
475
488
  raise ConfigResolverError(f"Invalid JSON in {path}: {e}") from e
489
+ except UnicodeDecodeError as e:
490
+ raise ConfigResolverError(
491
+ f"Cannot decode {path} as UTF-8: {e}"
492
+ ) from e
476
493
  except OSError as e:
477
494
  raise ConfigResolverError(f"Cannot read {path}: {e}") from e
495
+ if not isinstance(config, dict):
496
+ raise ConfigResolverError(f"{path} must contain a JSON object.")
497
+ return config
478
498
 
479
499
 
480
500
  def _file_hash(path: Path) -> str: