@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
@@ -20,9 +20,18 @@ from typing import Any
20
20
 
21
21
  VALID_PROFILES = {"minimal", "standard", "strict", "full", "offline-slm"}
22
22
  VALID_HOOK_PROFILES = {"minimal", "standard", "strict"}
23
+ VALID_OUTPUT_FILTER_MODES = {"off", "observe", "safe"}
24
+ VALID_OUTPUT_FILTER_PROFILES = {"repeat-lines", "tap-success"}
25
+ MAX_OUTPUT_FILTER_INPUT_BYTES = 8_388_608
26
+ DEFAULT_OUTPUT_FILTER_MIN_SAVINGS_BYTES = 1_024
23
27
  HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
24
- IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6})
28
+ IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6, 7})
25
29
  MIN_JUSTIFICATION_LEN = 20
30
+ VALID_CONFIG_FIELDS = frozenset({
31
+ "$schema", "extends", "name", "version", "description", "profile",
32
+ "agents", "plugins", "rules", "constitution", "enforce", "overrides",
33
+ "toolOutputFilter",
34
+ })
26
35
 
27
36
 
28
37
  # ---------------------------------------------------------------------------
@@ -116,6 +125,12 @@ def validate_merged_config(
116
125
  def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
117
126
  """Validate structural correctness of config."""
118
127
 
128
+ unknown = set(config) - VALID_CONFIG_FIELDS
129
+ if unknown:
130
+ errors.append(
131
+ f"Unknown top-level config keys: {', '.join(sorted(unknown))}."
132
+ )
133
+
119
134
  # extends: must be string if present
120
135
  extends = config.get("extends")
121
136
  if extends is not None and not isinstance(extends, str):
@@ -135,6 +150,10 @@ def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
135
150
  if agents is not None:
136
151
  _validate_agents_block(agents, errors)
137
152
 
153
+ plugins = config.get("plugins")
154
+ if plugins is not None:
155
+ _validate_plugins_block(plugins, errors)
156
+
138
157
  # rules: structural check
139
158
  rules = config.get("rules")
140
159
  if rules is not None:
@@ -155,6 +174,10 @@ def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
155
174
  if overrides is not None:
156
175
  _validate_overrides_block(overrides, errors)
157
176
 
177
+ output_filter = config.get("toolOutputFilter")
178
+ if output_filter is not None:
179
+ _validate_output_filter_block(output_filter, errors)
180
+
158
181
 
159
182
  def _validate_agents_block(agents: Any, errors: list[str]) -> None:
160
183
  """Validate agents section structure."""
@@ -176,6 +199,47 @@ def _validate_agents_block(agents: Any, errors: list[str]) -> None:
176
199
  errors.append(f"'agents.{key}' items must be strings.")
177
200
 
178
201
 
202
+ def _validate_plugins_block(plugins: Any, errors: list[str]) -> None:
203
+ """Validate effective plugin enable/disable intent."""
204
+ if not isinstance(plugins, dict):
205
+ errors.append("'plugins' must be an object.")
206
+ return
207
+
208
+ valid_keys = {"enabled", "disabled"}
209
+ unknown = set(plugins) - valid_keys
210
+ if unknown:
211
+ errors.append(f"Unknown keys in 'plugins': {', '.join(sorted(unknown))}.")
212
+
213
+ for key in ("enabled", "disabled"):
214
+ value = plugins.get(key)
215
+ if value is not None:
216
+ if not isinstance(value, list):
217
+ errors.append(f"'plugins.{key}' must be an array.")
218
+ elif not all(
219
+ isinstance(item, str) and item.strip()
220
+ for item in value
221
+ ):
222
+ errors.append(
223
+ f"'plugins.{key}' items must be non-empty strings."
224
+ )
225
+ elif len(value) != len(set(value)):
226
+ errors.append(f"'plugins.{key}' must not contain duplicates.")
227
+
228
+ enabled = plugins.get("enabled")
229
+ disabled = plugins.get("disabled")
230
+ if (
231
+ isinstance(enabled, list)
232
+ and isinstance(disabled, list)
233
+ and all(isinstance(item, str) for item in enabled + disabled)
234
+ ):
235
+ overlap = set(enabled) & set(disabled)
236
+ if overlap:
237
+ errors.append(
238
+ "Plugins cannot be both enabled and disabled: "
239
+ f"{', '.join(sorted(overlap))}."
240
+ )
241
+
242
+
179
243
  def _validate_rules_block(rules: Any, errors: list[str]) -> None:
180
244
  """Validate rules section structure."""
181
245
  if not isinstance(rules, dict):
@@ -220,6 +284,12 @@ def _validate_constitution_block(constitution: Any, errors: list[str]) -> None:
220
284
  errors.append(f"'constitution.amendments[{i}].article' must be a positive integer.")
221
285
  continue
222
286
 
287
+ if article in IMMUTABLE_ARTICLES:
288
+ errors.append(
289
+ f"Constitution Article {article} is reserved by ai-toolkit "
290
+ "(Articles I-VII); custom amendments must use article 8+."
291
+ )
292
+
223
293
  if article in seen_articles:
224
294
  errors.append(f"Duplicate constitution article number: {article}.")
225
295
  seen_articles.add(article)
@@ -253,8 +323,15 @@ def _validate_enforce_block(enforce: Any, errors: list[str]) -> None:
253
323
  if val is not None:
254
324
  if not isinstance(val, list):
255
325
  errors.append(f"'enforce.{key}' must be an array.")
256
- elif not all(isinstance(item, str) for item in val):
257
- errors.append(f"'enforce.{key}' items must be strings.")
326
+ elif not all(
327
+ isinstance(item, str) and item.strip()
328
+ for item in val
329
+ ):
330
+ errors.append(
331
+ f"'enforce.{key}' items must be non-empty strings."
332
+ )
333
+ elif len(val) != len(set(val)):
334
+ errors.append(f"'enforce.{key}' must not contain duplicates.")
258
335
 
259
336
 
260
337
  def _validate_overrides_block(overrides: Any, errors: list[str]) -> None:
@@ -279,6 +356,153 @@ def _validate_overrides_block(overrides: Any, errors: list[str]) -> None:
279
356
  )
280
357
 
281
358
 
359
+ def _validate_output_filter_block(output_filter: Any, errors: list[str]) -> None:
360
+ """Validate native tool-output filter configuration."""
361
+ if not isinstance(output_filter, dict):
362
+ errors.append("'toolOutputFilter' must be an object.")
363
+ return
364
+
365
+ valid_keys = {
366
+ "mode", "profiles", "maxInputBytes", "minSavingsBytes",
367
+ "minSavingsRatio", "recovery",
368
+ }
369
+ unknown = set(output_filter) - valid_keys
370
+ if unknown:
371
+ errors.append(
372
+ f"Unknown keys in 'toolOutputFilter': {', '.join(sorted(unknown))}."
373
+ )
374
+
375
+ mode = output_filter.get("mode")
376
+ if mode is not None and mode not in VALID_OUTPUT_FILTER_MODES:
377
+ errors.append(
378
+ f"Invalid 'toolOutputFilter.mode' '{mode}'. "
379
+ f"Valid: {', '.join(sorted(VALID_OUTPUT_FILTER_MODES))}."
380
+ )
381
+
382
+ profiles = output_filter.get("profiles")
383
+ if profiles is not None:
384
+ if not isinstance(profiles, list) or not all(
385
+ isinstance(profile, str) for profile in profiles
386
+ ):
387
+ errors.append("'toolOutputFilter.profiles' must be an array of strings.")
388
+ else:
389
+ invalid = set(profiles) - VALID_OUTPUT_FILTER_PROFILES
390
+ if invalid:
391
+ errors.append(
392
+ "Invalid 'toolOutputFilter.profiles': "
393
+ f"{', '.join(sorted(invalid))}."
394
+ )
395
+ if len(profiles) != len(set(profiles)):
396
+ errors.append(
397
+ "'toolOutputFilter.profiles' must not contain duplicate items."
398
+ )
399
+
400
+ _validate_output_filter_limits(output_filter, errors)
401
+ recovery = output_filter.get("recovery")
402
+ if recovery is not None:
403
+ _validate_output_filter_recovery(recovery, errors)
404
+
405
+
406
+ def _validate_output_filter_limits(
407
+ output_filter: dict[str, Any],
408
+ errors: list[str],
409
+ ) -> None:
410
+ """Validate output-filter byte and ratio bounds."""
411
+ _validate_bounded_integer(
412
+ output_filter.get("maxInputBytes"),
413
+ "toolOutputFilter.maxInputBytes",
414
+ errors,
415
+ minimum=1,
416
+ maximum=MAX_OUTPUT_FILTER_INPUT_BYTES,
417
+ )
418
+ _validate_bounded_integer(
419
+ output_filter.get("minSavingsBytes"),
420
+ "toolOutputFilter.minSavingsBytes",
421
+ errors,
422
+ minimum=0,
423
+ maximum=MAX_OUTPUT_FILTER_INPUT_BYTES,
424
+ )
425
+ _validate_output_filter_savings_relation(output_filter, errors)
426
+
427
+ ratio = output_filter.get("minSavingsRatio")
428
+ if ratio is not None and (
429
+ isinstance(ratio, bool)
430
+ or not isinstance(ratio, (int, float))
431
+ or not 0 <= ratio <= 1
432
+ ):
433
+ errors.append("'toolOutputFilter.minSavingsRatio' must be between 0 and 1.")
434
+
435
+
436
+ def _validate_output_filter_savings_relation(
437
+ output_filter: dict[str, Any],
438
+ errors: list[str],
439
+ ) -> None:
440
+ """Ensure the savings threshold can be reached within the input limit."""
441
+ max_input = output_filter.get(
442
+ "maxInputBytes",
443
+ MAX_OUTPUT_FILTER_INPUT_BYTES,
444
+ )
445
+ min_savings = output_filter.get(
446
+ "minSavingsBytes",
447
+ DEFAULT_OUTPUT_FILTER_MIN_SAVINGS_BYTES,
448
+ )
449
+ values = (max_input, min_savings)
450
+ if any(
451
+ not isinstance(value, int) or isinstance(value, bool)
452
+ for value in values
453
+ ):
454
+ return
455
+ if min_savings > max_input:
456
+ errors.append(
457
+ "'toolOutputFilter.minSavingsBytes' must not exceed "
458
+ "'toolOutputFilter.maxInputBytes'."
459
+ )
460
+
461
+
462
+ def _validate_output_filter_recovery(recovery: Any, errors: list[str]) -> None:
463
+ """Validate secure ephemeral recovery settings."""
464
+ if not isinstance(recovery, dict):
465
+ errors.append("'toolOutputFilter.recovery' must be an object.")
466
+ return
467
+
468
+ valid_keys = {"mode", "ttlMinutes", "maxSessionBytes"}
469
+ unknown = set(recovery) - valid_keys
470
+ if unknown:
471
+ errors.append(
472
+ "Unknown keys in 'toolOutputFilter.recovery': "
473
+ f"{', '.join(sorted(unknown))}."
474
+ )
475
+
476
+ if recovery.get("mode") not in (None, "ephemeral"):
477
+ errors.append("'toolOutputFilter.recovery.mode' must be 'ephemeral'.")
478
+ for key in ("ttlMinutes", "maxSessionBytes"):
479
+ _validate_bounded_integer(
480
+ recovery.get(key),
481
+ f"toolOutputFilter.recovery.{key}",
482
+ errors,
483
+ minimum=1,
484
+ )
485
+
486
+
487
+ def _validate_bounded_integer(
488
+ value: Any,
489
+ field: str,
490
+ errors: list[str],
491
+ *,
492
+ minimum: int,
493
+ maximum: int | None = None,
494
+ ) -> None:
495
+ """Validate an optional integer bound."""
496
+ if value is None:
497
+ return
498
+ is_valid = isinstance(value, int) and not isinstance(value, bool) and value >= minimum
499
+ if maximum is not None:
500
+ is_valid = is_valid and value <= maximum
501
+ if not is_valid:
502
+ suffix = f" and at most {maximum}" if maximum is not None else ""
503
+ errors.append(f"'{field}' must be an integer of at least {minimum}{suffix}.")
504
+
505
+
282
506
  # ---------------------------------------------------------------------------
283
507
  # Enforce constraint validation (post-merge)
284
508
  # ---------------------------------------------------------------------------
@@ -316,11 +540,15 @@ def _validate_enforce_constraints(
316
540
  if missing:
317
541
  errors.append(f"Required agents missing: {', '.join(sorted(missing))}.")
318
542
 
319
- # requiredPlugins — informational in v1 (plugins field deferred)
320
- required_plugins = enforce.get("requiredPlugins", [])
543
+ required_plugins = set(enforce.get("requiredPlugins", []))
321
544
  if required_plugins:
322
- # v1: just warn, no plugins field to check against
323
- pass
545
+ enabled_plugins = set(merged.get("plugins", {}).get("enabled", []))
546
+ missing_plugins = required_plugins - enabled_plugins
547
+ if missing_plugins:
548
+ errors.append(
549
+ "Required plugins missing from enabled intent: "
550
+ f"{', '.join(sorted(missing_plugins))}."
551
+ )
324
552
 
325
553
 
326
554
  # ---------------------------------------------------------------------------
@@ -335,16 +563,31 @@ def _validate_file_references(
335
563
  """Check that referenced files actually exist."""
336
564
 
337
565
  # Custom agents
338
- for agent_path in config.get("agents", {}).get("custom", []):
339
- full = root / agent_path
340
- if not full.is_file():
341
- errors.append(f"Custom agent file not found: {agent_path} (resolved: {full})")
566
+ agents = config.get("agents")
567
+ custom_agents = agents.get("custom") if isinstance(agents, dict) else None
568
+ if isinstance(custom_agents, list):
569
+ for agent_path in custom_agents:
570
+ if not isinstance(agent_path, str):
571
+ continue
572
+ full = root / agent_path
573
+ if not full.is_file():
574
+ errors.append(
575
+ f"Custom agent file not found: {agent_path} "
576
+ f"(resolved: {full})"
577
+ )
342
578
 
343
579
  # Rule files
344
- for rule_path in config.get("rules", {}).get("inject", []):
345
- full = root / rule_path
346
- if not full.is_file():
347
- errors.append(f"Rule file not found: {rule_path} (resolved: {full})")
580
+ rules = config.get("rules")
581
+ injected_rules = rules.get("inject") if isinstance(rules, dict) else None
582
+ if isinstance(injected_rules, list):
583
+ for rule_path in injected_rules:
584
+ if not isinstance(rule_path, str):
585
+ continue
586
+ full = root / rule_path
587
+ if not full.is_file():
588
+ errors.append(
589
+ f"Rule file not found: {rule_path} (resolved: {full})"
590
+ )
348
591
 
349
592
 
350
593
  # ---------------------------------------------------------------------------
@@ -361,11 +604,20 @@ def main() -> None:
361
604
  strict = "--strict" in sys.argv
362
605
 
363
606
  try:
364
- with open(config_path) as f:
607
+ with open(config_path, encoding="utf-8") as f:
365
608
  config = json.load(f)
366
609
  except (json.JSONDecodeError, OSError) as e:
367
610
  print(json.dumps({"valid": False, "errors": [str(e)]}))
368
611
  sys.exit(1)
612
+ except UnicodeDecodeError as e:
613
+ error = f"Cannot decode {config_path} as UTF-8: {e}"
614
+ print(json.dumps({"valid": False, "errors": [error]}))
615
+ sys.exit(1)
616
+
617
+ if not isinstance(config, dict):
618
+ error = f"{config_path} must contain a JSON object."
619
+ print(json.dumps({"valid": False, "errors": [error]}))
620
+ sys.exit(1)
369
621
 
370
622
  project_root = config_path.parent
371
623
  is_base = "name" in config and "version" in config