@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.
- package/CHANGELOG.md +43 -0
- package/README.md +21 -12
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -3
- package/app/hooks/_hook-io.sh +18 -3
- package/app/hooks/ai-toolkit-statusline.sh +30 -5
- package/app/hooks/filter-tool-output.sh +76 -0
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/guard-path.sh +2 -2
- package/app/hooks/post-tool-use.sh +5 -3
- package/app/hooks/pre-compact-save.sh +4 -3
- package/app/hooks/quality-gate.sh +12 -1
- package/app/hooks/revert-guard.sh +5 -2
- package/app/hooks/save-session.sh +4 -2
- package/app/hooks/session-end.sh +36 -4
- package/app/hooks/session-start.sh +11 -5
- package/app/hooks.json +10 -0
- package/app/output-filter-policy.json +15 -0
- package/app/skills/brand-voice/scripts/measure.py +7 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
- package/benchmarks/output-filter/README.md +11 -0
- package/benchmarks/output-filter/scenarios.json +25 -0
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
- package/kb/procedures/release-preparation-sop.md +6 -5
- package/kb/reference/architecture-overview.md +6 -5
- package/kb/reference/cli-reference.md +19 -2
- package/kb/reference/codex-cli-compatibility.md +1 -0
- package/kb/reference/copilot-compatibility.md +173 -0
- package/kb/reference/enterprise-config-guide.md +28 -2
- package/kb/reference/global-install-model.md +1 -0
- package/kb/reference/hooks-catalog.md +105 -16
- package/kb/reference/opencode-compatibility.md +1 -0
- package/kb/reference/supported-tools-registry.md +10 -5
- package/kb/reference/tool-output-filter.md +288 -0
- package/llms-full.txt +1173 -35
- package/llms.txt +3 -0
- package/manifest.json +9 -6
- package/package.json +3 -2
- package/scripts/benchmark_output_filter.py +343 -0
- package/scripts/check_deps.py +16 -0
- package/scripts/claude_app.py +30 -2
- package/scripts/config_cli.py +4 -4
- package/scripts/config_lock.py +120 -14
- package/scripts/config_merger.py +103 -20
- package/scripts/config_resolver.py +22 -2
- package/scripts/config_validator.py +268 -16
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_copilot.py +35 -4
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install.py +5 -1
- package/scripts/install_steps/ai_tools.py +101 -2
- package/scripts/install_steps/hooks.py +25 -1
- package/scripts/output_filter_cli.py +347 -0
- package/scripts/output_filter_hook.py +23 -0
- package/scripts/plugin_schema.py +27 -1
- package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
- package/scripts/session_state.py +156 -42
- package/scripts/tool_output_filter/__init__.py +33 -0
- package/scripts/tool_output_filter/contracts.py +173 -0
- package/scripts/tool_output_filter/engine.py +260 -0
- package/scripts/tool_output_filter/hook_runtime.py +369 -0
- package/scripts/tool_output_filter/input.py +56 -0
- package/scripts/tool_output_filter/invariants.py +40 -0
- package/scripts/tool_output_filter/policy.py +153 -0
- package/scripts/tool_output_filter/profiles/__init__.py +68 -0
- package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
- package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
- package/scripts/tool_output_filter/recovery.py +846 -0
- package/scripts/tool_output_filter/telemetry.py +13 -0
- 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(
|
|
257
|
-
|
|
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
|
-
|
|
320
|
-
required_plugins = enforce.get("requiredPlugins", [])
|
|
543
|
+
required_plugins = set(enforce.get("requiredPlugins", []))
|
|
321
544
|
if required_plugins:
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
@@ -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]
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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,
|
package/scripts/install.py
CHANGED