@softspark/ai-toolkit 4.15.1 → 4.16.1

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 (73) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +21 -12
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +4 -3
  5. package/app/hooks/_hook-io.sh +18 -3
  6. package/app/hooks/ai-toolkit-statusline.sh +30 -5
  7. package/app/hooks/filter-tool-output.sh +76 -0
  8. package/app/hooks/governance-capture.sh +1 -1
  9. package/app/hooks/guard-path.sh +2 -2
  10. package/app/hooks/post-tool-use.sh +5 -3
  11. package/app/hooks/pre-compact-save.sh +4 -3
  12. package/app/hooks/quality-gate.sh +12 -1
  13. package/app/hooks/revert-guard.sh +5 -2
  14. package/app/hooks/save-session.sh +4 -2
  15. package/app/hooks/session-end.sh +36 -4
  16. package/app/hooks/session-start.sh +11 -5
  17. package/app/hooks.json +10 -0
  18. package/app/output-filter-policy.json +15 -0
  19. package/app/skills/brand-voice/scripts/measure.py +7 -5
  20. package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
  21. package/benchmarks/output-filter/README.md +11 -0
  22. package/benchmarks/output-filter/scenarios.json +25 -0
  23. package/bin/ai-toolkit.js +2 -0
  24. package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
  25. package/kb/procedures/release-preparation-sop.md +6 -5
  26. package/kb/reference/architecture-overview.md +6 -5
  27. package/kb/reference/cli-reference.md +19 -2
  28. package/kb/reference/codex-cli-compatibility.md +1 -0
  29. package/kb/reference/copilot-compatibility.md +173 -0
  30. package/kb/reference/enterprise-config-guide.md +28 -2
  31. package/kb/reference/global-install-model.md +1 -0
  32. package/kb/reference/hooks-catalog.md +105 -16
  33. package/kb/reference/opencode-compatibility.md +1 -0
  34. package/kb/reference/supported-tools-registry.md +10 -5
  35. package/kb/reference/tool-output-filter.md +288 -0
  36. package/llms-full.txt +1173 -35
  37. package/llms.txt +3 -0
  38. package/manifest.json +9 -6
  39. package/package.json +3 -2
  40. package/scripts/benchmark_output_filter.py +343 -0
  41. package/scripts/check_deps.py +16 -0
  42. package/scripts/claude_app.py +30 -2
  43. package/scripts/config_cli.py +4 -4
  44. package/scripts/config_lock.py +120 -14
  45. package/scripts/config_merger.py +103 -20
  46. package/scripts/config_resolver.py +22 -2
  47. package/scripts/config_validator.py +268 -16
  48. package/scripts/doctor.py +1 -0
  49. package/scripts/generate_codex_hooks.py +2 -0
  50. package/scripts/generate_copilot.py +35 -4
  51. package/scripts/generate_gemini_hooks.py +33 -10
  52. package/scripts/generate_opencode_plugin.py +28 -12
  53. package/scripts/install.py +5 -1
  54. package/scripts/install_steps/ai_tools.py +101 -2
  55. package/scripts/install_steps/hooks.py +25 -1
  56. package/scripts/output_filter_cli.py +347 -0
  57. package/scripts/output_filter_hook.py +23 -0
  58. package/scripts/plugin_schema.py +27 -1
  59. package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
  60. package/scripts/session_state.py +156 -42
  61. package/scripts/tool_output_filter/__init__.py +33 -0
  62. package/scripts/tool_output_filter/contracts.py +173 -0
  63. package/scripts/tool_output_filter/engine.py +260 -0
  64. package/scripts/tool_output_filter/hook_runtime.py +369 -0
  65. package/scripts/tool_output_filter/input.py +56 -0
  66. package/scripts/tool_output_filter/invariants.py +40 -0
  67. package/scripts/tool_output_filter/policy.py +153 -0
  68. package/scripts/tool_output_filter/profiles/__init__.py +68 -0
  69. package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
  70. package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
  71. package/scripts/tool_output_filter/recovery.py +846 -0
  72. package/scripts/tool_output_filter/telemetry.py +13 -0
  73. 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
package/scripts/doctor.py CHANGED
@@ -75,6 +75,7 @@ EXPECTED_HOOKS = [
75
75
  "subagent-start.sh",
76
76
  "subagent-stop.sh",
77
77
  "session-end.sh",
78
+ "filter-tool-output.sh",
78
79
  ]
79
80
 
80
81
  PLANNED_ASSETS = [
@@ -84,6 +84,7 @@ CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
84
84
  ],
85
85
  "PreToolUse": [
86
86
  ("Bash", "guard-destructive.sh"),
87
+ ("Bash", "guard-path.sh"),
87
88
  ("Bash", "commit-quality.sh"),
88
89
  ("Bash", "revert-guard.sh"),
89
90
  ],
@@ -93,6 +94,7 @@ CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
93
94
  ],
94
95
  "PermissionRequest": [
95
96
  ("Bash", "guard-destructive.sh"),
97
+ ("Bash", "guard-path.sh"),
96
98
  ],
97
99
  "UserPromptSubmit": [
98
100
  ("", "user-prompt-submit.sh"),
@@ -974,6 +974,23 @@ def _is_managed_skill_dir(path: Path) -> bool:
974
974
  return path.is_dir() and not path.is_symlink() and _is_managed(skill_file)
975
975
 
976
976
 
977
+ def _is_skill_remnant(path: Path) -> bool:
978
+ """Asset-only leftover of a legacy managed skill.
979
+
980
+ Pre-manifest toolkit versions tracked only SKILL.md as managed, so their
981
+ cleanup removed SKILL.md and left reference/ and scripts/ assets behind.
982
+ Without SKILL.md the directory is not a functional Copilot skill, so it is
983
+ safe to rebuild in place; a directory that still has any SKILL.md (managed
984
+ or not) is never classified as a remnant.
985
+ """
986
+ return (
987
+ path.is_dir()
988
+ and not path.is_symlink()
989
+ and not (path / "SKILL.md").exists()
990
+ and not (path / "SKILL.md").is_symlink()
991
+ )
992
+
993
+
977
994
  def _managed_skill_paths(path: Path) -> set[Path]:
978
995
  manifest = path / SKILL_MANIFEST
979
996
  if not manifest.is_file() or manifest.is_symlink():
@@ -999,7 +1016,8 @@ def _has_user_skill_extras(path: Path) -> bool:
999
1016
 
1000
1017
 
1001
1018
  def _copy_user_skill_extras(existing: Path, staging: Path,
1002
- generated_paths: set[Path]) -> None:
1019
+ generated_paths: set[Path],
1020
+ *, skip_stale_assets: bool = False) -> None:
1003
1021
  """Preserve files a user added inside a previously managed skill."""
1004
1022
  old_managed = _managed_skill_paths(existing)
1005
1023
  for source in sorted(existing.rglob("*")):
@@ -1011,6 +1029,8 @@ def _copy_user_skill_extras(existing: Path, staging: Path,
1011
1029
  if not source.is_file():
1012
1030
  continue
1013
1031
  if relative in generated_paths:
1032
+ if skip_stale_assets:
1033
+ continue
1014
1034
  raise RuntimeError(
1015
1035
  f"Copilot skill update would overwrite a user asset: {source}"
1016
1036
  )
@@ -1046,7 +1066,10 @@ def _stage_skill(skills_root: Path, source_dir: Path,
1046
1066
  encoding="utf-8",
1047
1067
  )
1048
1068
  if existing is not None:
1049
- _copy_user_skill_extras(existing, staging, generated_paths)
1069
+ _copy_user_skill_extras(
1070
+ existing, staging, generated_paths,
1071
+ skip_stale_assets=_is_skill_remnant(existing),
1072
+ )
1050
1073
  return staging, name
1051
1074
  except Exception:
1052
1075
  shutil.rmtree(staging, ignore_errors=True)
@@ -1058,7 +1081,10 @@ def _replace_skill_dir(staging: Path, destination: Path) -> None:
1058
1081
  backup: Path | None = None
1059
1082
  try:
1060
1083
  if destination.exists():
1061
- if destination.is_symlink() or not _is_managed_skill_dir(destination):
1084
+ if destination.is_symlink() or not (
1085
+ _is_managed_skill_dir(destination)
1086
+ or _is_skill_remnant(destination)
1087
+ ):
1062
1088
  raise RuntimeError(
1063
1089
  f"Refusing user-owned Copilot skill collision: {destination}"
1064
1090
  )
@@ -1125,7 +1151,12 @@ def _sync_copilot_skills(customization_root: Path, *, label: str) -> None:
1125
1151
  expected_dirs.add(destination_name)
1126
1152
  existing = destination if destination.exists() else None
1127
1153
  if existing is not None and not _is_managed_skill_dir(existing):
1128
- raise RuntimeError(f"Refusing user-owned Copilot skill collision: {destination}")
1154
+ if not _is_skill_remnant(existing):
1155
+ raise RuntimeError(f"Refusing user-owned Copilot skill collision: {destination}")
1156
+ print(
1157
+ f"Note: rebuilding asset-only Copilot skill remnant '{destination}'",
1158
+ file=sys.stderr,
1159
+ )
1129
1160
  staging, rendered_name = _stage_skill(skill_root, source_dir, existing)
1130
1161
  if rendered_name != logical_name:
1131
1162
  shutil.rmtree(staging, ignore_errors=True)
@@ -26,7 +26,9 @@ Writes `<target-dir>/.gemini/settings.json` (merge-safe, idempotent).
26
26
  from __future__ import annotations
27
27
 
28
28
  import json
29
+ import os
29
30
  import sys
31
+ import tempfile
30
32
  from pathlib import Path
31
33
 
32
34
  HOOKS_PREFIX = 'AI_TOOLKIT_HOOK_FORMAT=json "$HOME/.softspark/ai-toolkit/hooks/'
@@ -121,28 +123,49 @@ def merge_hooks(existing_hooks: dict, toolkit_hooks: dict) -> dict:
121
123
  return merged
122
124
 
123
125
 
126
+ def _write_settings_atomic(settings_path: Path, settings: dict) -> None:
127
+ temp_path: Path | None = None
128
+ try:
129
+ with tempfile.NamedTemporaryFile(
130
+ mode="w",
131
+ dir=settings_path.parent,
132
+ prefix=f".{settings_path.name}.",
133
+ encoding="utf-8",
134
+ delete=False,
135
+ ) as temp_file:
136
+ json.dump(settings, temp_file, indent=4, ensure_ascii=False, sort_keys=True)
137
+ temp_file.write("\n")
138
+ temp_file.flush()
139
+ os.fsync(temp_file.fileno())
140
+ temp_path = Path(temp_file.name)
141
+ os.replace(temp_path, settings_path)
142
+ temp_path = None
143
+ finally:
144
+ if temp_path is not None:
145
+ temp_path.unlink(missing_ok=True)
146
+
147
+
124
148
  def generate(target_dir: Path) -> Path:
125
149
  """Write `<target_dir>/.gemini/settings.json` and return its path."""
126
150
  gemini_dir = target_dir / ".gemini"
151
+ if gemini_dir.is_symlink():
152
+ raise RuntimeError(f"Refusing symlinked Gemini directory: {gemini_dir}")
127
153
  gemini_dir.mkdir(parents=True, exist_ok=True)
128
154
  settings_path = gemini_dir / "settings.json"
155
+ if settings_path.is_symlink():
156
+ raise RuntimeError(f"Refusing symlinked Gemini settings: {settings_path}")
129
157
 
130
158
  settings: dict = {}
131
159
  if settings_path.is_file():
132
- try:
133
- with open(settings_path, encoding="utf-8") as f:
134
- settings = json.load(f)
135
- if not isinstance(settings, dict):
136
- settings = {}
137
- except (json.JSONDecodeError, OSError):
138
- settings = {}
160
+ with open(settings_path, encoding="utf-8") as f:
161
+ settings = json.load(f)
162
+ if not isinstance(settings, dict):
163
+ raise ValueError(f"{settings_path} must contain a JSON object")
139
164
 
140
165
  existing_hooks = settings.get("hooks") if isinstance(settings.get("hooks"), dict) else {}
141
166
  settings["hooks"] = merge_hooks(existing_hooks or {}, build_toolkit_hooks())
142
167
 
143
- with open(settings_path, "w", encoding="utf-8") as f:
144
- json.dump(settings, f, indent=4, ensure_ascii=False, sort_keys=True)
145
- f.write("\n")
168
+ _write_settings_atomic(settings_path, settings)
146
169
  return settings_path
147
170
 
148
171
 
@@ -32,7 +32,6 @@ Usage:
32
32
  from __future__ import annotations
33
33
 
34
34
  import argparse
35
- import sys
36
35
  from pathlib import Path
37
36
 
38
37
  PLUGIN_BODY = r"""// ai-toolkit opencode plugin — bridges shared Bash hooks to opencode events.
@@ -49,8 +48,9 @@ PLUGIN_BODY = r"""// ai-toolkit opencode plugin — bridges shared Bash hooks to
49
48
  const HOOKS_DIR = `${process.env.HOME}/.softspark/ai-toolkit/hooks`;
50
49
 
51
50
  /** Invoke a Bash hook with a JSON payload on stdin. */
52
- async function runHook($, script, payload) {
51
+ async function runHook($, script, payload, blockOnExit2 = false) {
53
52
  const scriptPath = `${HOOKS_DIR}/${script}`;
53
+ let result;
54
54
  try {
55
55
  const input = JSON.stringify(payload ?? {});
56
56
  const proc = $`bash ${scriptPath}`.env({
@@ -59,23 +59,34 @@ async function runHook($, script, payload) {
59
59
  });
60
60
  proc.stdin.write(input);
61
61
  proc.stdin.end();
62
- const result = await proc.quiet().nothrow();
63
- if (result.exitCode !== 0 && result.exitCode !== 2) {
64
- // Exit 2 is the toolkit's "block" signal — pass through to opencode as a guard.
65
- process.stderr.write(
66
- `[ai-toolkit] ${script} exited ${result.exitCode}\n${result.stderr.toString()}`
67
- );
68
- }
69
- return result.exitCode;
62
+ result = await proc.quiet().nothrow();
70
63
  } catch (err) {
71
64
  process.stderr.write(`[ai-toolkit] failed to run ${script}: ${err.message}\n`);
72
65
  return 0;
73
66
  }
67
+ if (result.exitCode === 2 && blockOnExit2) {
68
+ const detail = result.stderr.toString().trim();
69
+ throw new Error(
70
+ `[ai-toolkit] ${script} blocked tool execution${detail ? `: ${detail}` : ""}`
71
+ );
72
+ }
73
+ if (result.exitCode !== 0 && result.exitCode !== 2) {
74
+ process.stderr.write(
75
+ `[ai-toolkit] ${script} exited ${result.exitCode}\n${result.stderr.toString()}`
76
+ );
77
+ }
78
+ return result.exitCode;
74
79
  }
75
80
 
76
81
  export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
77
82
  event: async ({ event }) => {
78
- const payload = { event: event.type, project, directory, worktree };
83
+ const payload = {
84
+ event: event.type,
85
+ session_id: event?.properties?.sessionID,
86
+ project,
87
+ directory,
88
+ worktree,
89
+ };
79
90
  switch (event.type) {
80
91
  case "session.created":
81
92
  await runHook($, "session-start.sh", payload);
@@ -106,12 +117,15 @@ export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
106
117
  "tool.execute.before": async (input, output) => {
107
118
  const payload = {
108
119
  event: "tool.execute.before",
120
+ session_id: input?.sessionID,
121
+ tool_name: input?.tool,
122
+ tool_input: output?.args,
109
123
  tool: input?.tool,
110
124
  args: output?.args,
111
125
  project,
112
126
  };
113
127
  if (input?.tool === "bash") {
114
- await runHook($, "guard-destructive.sh", payload);
128
+ await runHook($, "guard-destructive.sh", payload, true);
115
129
  await runHook($, "commit-quality.sh", payload);
116
130
  }
117
131
  },
@@ -119,6 +133,8 @@ export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
119
133
  "tool.execute.after": async (input, output) => {
120
134
  const payload = {
121
135
  event: "tool.execute.after",
136
+ session_id: input?.sessionID,
137
+ tool_name: input?.tool,
122
138
  tool: input?.tool,
123
139
  result: output,
124
140
  project,
@@ -841,4 +841,8 @@ def _infer_modules_from_legacy(profile: str, only: str) -> list[str]:
841
841
 
842
842
 
843
843
  if __name__ == "__main__":
844
- main()
844
+ try:
845
+ main()
846
+ except RuntimeError as error:
847
+ print(f"ERROR: {error}", file=sys.stderr)
848
+ sys.exit(1)