@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.
- package/AGENTS.md +117 -0
- package/CHANGELOG.md +43 -0
- package/README.md +19 -13
- 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 +6 -2
- 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/kb/reference/windows-support.md +4 -3
- package/llms-full.txt +1182 -40
- 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/copilot_legacy_hashes.json +338 -0
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_copilot.py +464 -71
- package/scripts/generate_copilot_hooks.py +124 -7
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install_steps/ai_tools.py +115 -3
- 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
|
@@ -18,6 +18,9 @@ import tempfile
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
|
+
import secure_fs
|
|
22
|
+
from secure_fs import SecureDestination, run_secure_transaction
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
OWNER_KEY = "AI_TOOLKIT_HOOK_OWNER"
|
|
23
26
|
OWNER_VALUE = "ai-toolkit"
|
|
@@ -433,13 +436,11 @@ def _validate_entry(event: str, entry: Any) -> None:
|
|
|
433
436
|
raise ValueError(f"Copilot {event} {key} must be positive")
|
|
434
437
|
|
|
435
438
|
|
|
436
|
-
def
|
|
437
|
-
if path.is_symlink() or not path.is_file():
|
|
438
|
-
return False
|
|
439
|
+
def _is_managed_config_content(content: bytes) -> bool:
|
|
439
440
|
try:
|
|
440
|
-
data = json.loads(
|
|
441
|
+
data = json.loads(content.decode("utf-8"))
|
|
441
442
|
_validate_document(data)
|
|
442
|
-
except (
|
|
443
|
+
except (UnicodeError, json.JSONDecodeError, ValueError):
|
|
443
444
|
return False
|
|
444
445
|
entries = [entry for values in data["hooks"].values() for entry in values]
|
|
445
446
|
return bool(entries) and all(
|
|
@@ -448,12 +449,28 @@ def _is_managed_config(path: Path) -> bool:
|
|
|
448
449
|
)
|
|
449
450
|
|
|
450
451
|
|
|
452
|
+
def _is_managed_config(path: Path) -> bool:
|
|
453
|
+
if path.is_symlink() or not path.is_file():
|
|
454
|
+
return False
|
|
455
|
+
try:
|
|
456
|
+
return _is_managed_config_content(path.read_bytes())
|
|
457
|
+
except OSError:
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _is_managed_script_content(content: bytes) -> bool:
|
|
462
|
+
try:
|
|
463
|
+
return SCRIPT_MARKER in content[:256].decode("utf-8")
|
|
464
|
+
except UnicodeError:
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
|
|
451
468
|
def _is_managed_script(path: Path) -> bool:
|
|
452
469
|
if path.is_symlink() or not path.is_file():
|
|
453
470
|
return False
|
|
454
471
|
try:
|
|
455
|
-
return
|
|
456
|
-
except
|
|
472
|
+
return _is_managed_script_content(path.read_bytes())
|
|
473
|
+
except OSError:
|
|
457
474
|
return False
|
|
458
475
|
|
|
459
476
|
|
|
@@ -553,6 +570,106 @@ def copilot_home(home: Path | None = None) -> Path:
|
|
|
553
570
|
return base / ".copilot"
|
|
554
571
|
|
|
555
572
|
|
|
573
|
+
def _cleanup_targets(
|
|
574
|
+
target_dir: Path,
|
|
575
|
+
config_root: Path | None,
|
|
576
|
+
) -> tuple[Path, Path, Path, list[SecureDestination]]:
|
|
577
|
+
"""Resolve and validate every hook-cleanup destination without mutation."""
|
|
578
|
+
target_dir = Path(target_dir).expanduser().absolute()
|
|
579
|
+
project_install = config_root is None
|
|
580
|
+
customization_root = (
|
|
581
|
+
target_dir / ".github"
|
|
582
|
+
if project_install
|
|
583
|
+
else Path(config_root).expanduser().absolute()
|
|
584
|
+
)
|
|
585
|
+
hooks_dir = customization_root / "hooks"
|
|
586
|
+
assets_dir = hooks_dir / "ai-toolkit"
|
|
587
|
+
config_path = hooks_dir / CONFIG_NAME
|
|
588
|
+
script_path = assets_dir / SCRIPT_NAME
|
|
589
|
+
_assert_safe_paths([
|
|
590
|
+
(customization_root, "customization root"),
|
|
591
|
+
(hooks_dir, "hooks directory"),
|
|
592
|
+
(assets_dir, "hook assets directory"),
|
|
593
|
+
(config_path, "hook config"),
|
|
594
|
+
(script_path, "hook runtime"),
|
|
595
|
+
])
|
|
596
|
+
existing_paths: list[tuple[Path, str]] = []
|
|
597
|
+
if config_path.exists():
|
|
598
|
+
existing_paths.append((config_path, "hook config"))
|
|
599
|
+
if script_path.exists():
|
|
600
|
+
existing_paths.append((script_path, "hook runtime"))
|
|
601
|
+
trusted_root = target_dir if project_install else customization_root
|
|
602
|
+
destinations = [
|
|
603
|
+
SecureDestination(path, trusted_root, f"Copilot {label}")
|
|
604
|
+
for path, label in existing_paths
|
|
605
|
+
]
|
|
606
|
+
return hooks_dir, config_path, script_path, destinations
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _require_secure_cleanup() -> None:
|
|
610
|
+
if secure_fs.SECURE_DIR_FD:
|
|
611
|
+
return
|
|
612
|
+
raise RuntimeError(
|
|
613
|
+
"Copilot hook cleanup requires POSIX dir_fd and O_NOFOLLOW; "
|
|
614
|
+
"No files were changed"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def preflight_cleanup(
|
|
619
|
+
target_dir: Path,
|
|
620
|
+
*,
|
|
621
|
+
config_root: Path | None = None,
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Fail before installer mutations when hook cleanup cannot run safely."""
|
|
624
|
+
_, _, _, destinations = _cleanup_targets(target_dir, config_root)
|
|
625
|
+
if not destinations:
|
|
626
|
+
return
|
|
627
|
+
_require_secure_cleanup()
|
|
628
|
+
run_secure_transaction(destinations, lambda _transaction: None)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def cleanup(target_dir: Path, *, config_root: Path | None = None) -> None:
|
|
632
|
+
"""Remove only the managed Copilot hook bundle for a profile downgrade."""
|
|
633
|
+
hooks_dir, config_path, script_path, destinations = _cleanup_targets(
|
|
634
|
+
target_dir,
|
|
635
|
+
config_root,
|
|
636
|
+
)
|
|
637
|
+
if not destinations:
|
|
638
|
+
return
|
|
639
|
+
_require_secure_cleanup()
|
|
640
|
+
|
|
641
|
+
def remove_managed(transaction) -> bool:
|
|
642
|
+
contents = {
|
|
643
|
+
destination.path: transaction.initial_content(destination)
|
|
644
|
+
for destination in destinations
|
|
645
|
+
}
|
|
646
|
+
config_content = contents.get(config_path)
|
|
647
|
+
script_content = contents.get(script_path)
|
|
648
|
+
config_owned = (
|
|
649
|
+
config_content is None or _is_managed_config_content(config_content)
|
|
650
|
+
)
|
|
651
|
+
script_owned = (
|
|
652
|
+
script_content is None or _is_managed_script_content(script_content)
|
|
653
|
+
)
|
|
654
|
+
if not config_owned or not script_owned:
|
|
655
|
+
print(
|
|
656
|
+
f"Warning: preserving user-owned Copilot hook bundle at '{hooks_dir}'",
|
|
657
|
+
file=sys.stderr,
|
|
658
|
+
)
|
|
659
|
+
return False
|
|
660
|
+
for destination in destinations:
|
|
661
|
+
transaction.unlink(destination)
|
|
662
|
+
return True
|
|
663
|
+
|
|
664
|
+
if run_secure_transaction(destinations, remove_managed):
|
|
665
|
+
label = (
|
|
666
|
+
".github/hooks"
|
|
667
|
+
if config_root is None
|
|
668
|
+
else str(hooks_dir)
|
|
669
|
+
)
|
|
670
|
+
print(f" Removed managed: {label}/{CONFIG_NAME}")
|
|
671
|
+
|
|
672
|
+
|
|
556
673
|
def generate(target_dir: Path, *, config_root: Path | None = None) -> None:
|
|
557
674
|
"""Generate repository hooks, or user hooks when ``config_root`` is set."""
|
|
558
675
|
target_dir = Path(target_dir).expanduser().absolute()
|
|
@@ -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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Install global and project-local AI tool configs."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
6
7
|
import subprocess
|
|
@@ -14,6 +15,7 @@ from codex_skill_adapter import (
|
|
|
14
15
|
unmanaged_codex_skill_names,
|
|
15
16
|
)
|
|
16
17
|
from mcp_editors import sync_project_mcp_to_editors
|
|
18
|
+
from secure_fs import SecureDestination, run_secure_transaction
|
|
17
19
|
from injection import (
|
|
18
20
|
collapse_blank_runs as _collapse_blank_runs,
|
|
19
21
|
strip_all_sections as _strip_all_sections,
|
|
@@ -21,6 +23,11 @@ from injection import (
|
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
OUTPUT_FILTER_POLICY_NAME = "ai-toolkit-output-filter.json"
|
|
27
|
+
OUTPUT_FILTER_OWNER_NAME = ".ai-toolkit-output-filter.owner"
|
|
28
|
+
OUTPUT_FILTER_OWNER_MARKER = b"ai-toolkit-output-filter-policy-v1\n"
|
|
29
|
+
|
|
30
|
+
|
|
24
31
|
def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
25
32
|
dry_run: bool,
|
|
26
33
|
editors: list[str] | None = None,
|
|
@@ -750,6 +757,10 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
750
757
|
print(f" Would inject language rules: {', '.join(language_modules)}")
|
|
751
758
|
if merged_config:
|
|
752
759
|
print(" Would apply merged config from extends")
|
|
760
|
+
if merged_config and merged_config.get("toolOutputFilter") is not None:
|
|
761
|
+
print(" Would write: .claude/ai-toolkit-output-filter.json")
|
|
762
|
+
else:
|
|
763
|
+
print(" Would remove: managed project output-filter policy (if present)")
|
|
753
764
|
return
|
|
754
765
|
|
|
755
766
|
(cwd / ".claude").mkdir(parents=True, exist_ok=True)
|
|
@@ -759,6 +770,12 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
759
770
|
|
|
760
771
|
_create_local_claude_md(cwd, reset)
|
|
761
772
|
_create_local_settings(cwd, reset)
|
|
773
|
+
configured_policy = (
|
|
774
|
+
merged_config.get("toolOutputFilter")
|
|
775
|
+
if merged_config is not None
|
|
776
|
+
else None
|
|
777
|
+
)
|
|
778
|
+
_sync_local_output_filter_policy(cwd, configured_policy)
|
|
762
779
|
|
|
763
780
|
legacy_local_hooks = cwd / ".claude" / "hooks.json"
|
|
764
781
|
if legacy_local_hooks.is_file():
|
|
@@ -832,8 +849,8 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
832
849
|
|
|
833
850
|
# Inject constitution amendments
|
|
834
851
|
amendments = merged.get("constitution", {}).get("amendments", [])
|
|
835
|
-
#
|
|
836
|
-
custom_amendments = [a for a in amendments if a.get("article", 0) >=
|
|
852
|
+
# Articles I-VII are toolkit-owned; inherited custom amendments start at VIII.
|
|
853
|
+
custom_amendments = [a for a in amendments if a.get("article", 0) >= 8]
|
|
837
854
|
if custom_amendments:
|
|
838
855
|
constitution_file = cwd / ".claude" / "constitution.md"
|
|
839
856
|
if constitution_file.is_file():
|
|
@@ -876,6 +893,88 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
876
893
|
print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
|
|
877
894
|
|
|
878
895
|
|
|
896
|
+
def _sync_local_output_filter_policy(
|
|
897
|
+
cwd: Path,
|
|
898
|
+
configured: dict | None,
|
|
899
|
+
) -> None:
|
|
900
|
+
"""Atomically synchronize the toolkit-owned per-project output policy."""
|
|
901
|
+
policy_path = cwd / ".claude" / OUTPUT_FILTER_POLICY_NAME
|
|
902
|
+
owner_path = cwd / ".claude" / OUTPUT_FILTER_OWNER_NAME
|
|
903
|
+
policy_destination = SecureDestination(
|
|
904
|
+
policy_path, cwd, "project output-filter policy",
|
|
905
|
+
)
|
|
906
|
+
owner_destination = SecureDestination(
|
|
907
|
+
owner_path, cwd, "project output-filter owner marker",
|
|
908
|
+
)
|
|
909
|
+
destinations = [policy_destination, owner_destination]
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
policy_content = (
|
|
913
|
+
_materialize_output_filter_policy(configured)
|
|
914
|
+
if configured is not None
|
|
915
|
+
else None
|
|
916
|
+
)
|
|
917
|
+
except (
|
|
918
|
+
AttributeError,
|
|
919
|
+
json.JSONDecodeError,
|
|
920
|
+
OSError,
|
|
921
|
+
RuntimeError,
|
|
922
|
+
TypeError,
|
|
923
|
+
ValueError,
|
|
924
|
+
) as error:
|
|
925
|
+
print(f" Warning: project output-filter policy not changed: {error}")
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
def mutate(transaction) -> None:
|
|
929
|
+
owner = transaction.initial_content(owner_destination)
|
|
930
|
+
existing_policy = transaction.initial_content(policy_destination)
|
|
931
|
+
if owner is None and existing_policy is not None:
|
|
932
|
+
print(" Kept: user-owned .claude/ai-toolkit-output-filter.json")
|
|
933
|
+
return
|
|
934
|
+
if owner not in (None, OUTPUT_FILTER_OWNER_MARKER):
|
|
935
|
+
print(" Kept: untrusted .claude output-filter ownership marker")
|
|
936
|
+
return
|
|
937
|
+
|
|
938
|
+
if policy_content is None:
|
|
939
|
+
if owner == OUTPUT_FILTER_OWNER_MARKER:
|
|
940
|
+
transaction.unlink(policy_destination)
|
|
941
|
+
transaction.unlink(owner_destination)
|
|
942
|
+
print(" Removed: managed project output-filter policy")
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
transaction.atomic_write(policy_destination, policy_content, 0o600)
|
|
946
|
+
if owner is None:
|
|
947
|
+
transaction.atomic_write(
|
|
948
|
+
owner_destination,
|
|
949
|
+
OUTPUT_FILTER_OWNER_MARKER,
|
|
950
|
+
0o600,
|
|
951
|
+
)
|
|
952
|
+
print(" Wrote: .claude/ai-toolkit-output-filter.json")
|
|
953
|
+
|
|
954
|
+
try:
|
|
955
|
+
run_secure_transaction(destinations, mutate)
|
|
956
|
+
except RuntimeError as error:
|
|
957
|
+
print(f" Warning: project output-filter policy not changed: {error}")
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _materialize_output_filter_policy(configured: dict) -> bytes:
|
|
961
|
+
"""Merge a partial project policy over canonical safe defaults."""
|
|
962
|
+
default_path = app_dir / "output-filter-policy.json"
|
|
963
|
+
with open(default_path, encoding="utf-8") as handle:
|
|
964
|
+
defaults = json.load(handle)
|
|
965
|
+
policy = dict(defaults)
|
|
966
|
+
policy.update(configured)
|
|
967
|
+
recovery = dict(defaults.get("recovery", {}))
|
|
968
|
+
recovery.update(configured.get("recovery", {}))
|
|
969
|
+
policy["recovery"] = recovery
|
|
970
|
+
|
|
971
|
+
from config_validator import validate_project_config
|
|
972
|
+
errors = validate_project_config({"toolOutputFilter": policy})
|
|
973
|
+
if errors:
|
|
974
|
+
raise RuntimeError("invalid output-filter policy: " + "; ".join(errors))
|
|
975
|
+
return (json.dumps(policy, indent=2, sort_keys=True) + "\n").encode()
|
|
976
|
+
|
|
977
|
+
|
|
879
978
|
def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
|
|
880
979
|
"""Install Claude language-rule entrypoints for a project.
|
|
881
980
|
|
|
@@ -1267,6 +1366,16 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
|
|
|
1267
1366
|
emit_pointer = not _claude_skills_discoverable(cwd)
|
|
1268
1367
|
|
|
1269
1368
|
if "copilot" in eds:
|
|
1369
|
+
from generate_copilot import (
|
|
1370
|
+
generate as gen_copilot_dir,
|
|
1371
|
+
preflight_cleanup as preflight_copilot_cleanup,
|
|
1372
|
+
)
|
|
1373
|
+
if not add_copilot_dir:
|
|
1374
|
+
from generate_copilot_hooks import (
|
|
1375
|
+
preflight_cleanup as preflight_copilot_hook_cleanup,
|
|
1376
|
+
)
|
|
1377
|
+
preflight_copilot_cleanup(cwd)
|
|
1378
|
+
preflight_copilot_hook_cleanup(cwd)
|
|
1270
1379
|
inject_with_rules(
|
|
1271
1380
|
"generate-copilot.sh",
|
|
1272
1381
|
cwd / ".github" / "copilot-instructions.md",
|
|
@@ -1275,16 +1384,19 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
|
|
|
1275
1384
|
_install_copilot_agents_md(cwd, rules_dir)
|
|
1276
1385
|
# Agents and skills are the minimal Copilot surface. Standard and above
|
|
1277
1386
|
# add path instructions, prompts, and native lifecycle hooks.
|
|
1278
|
-
from generate_copilot import generate as gen_copilot_dir
|
|
1279
1387
|
gen_copilot_dir(
|
|
1280
1388
|
cwd,
|
|
1281
1389
|
language_modules=language_modules,
|
|
1282
1390
|
rules_dir=rules_dir,
|
|
1283
1391
|
emit_prompts=add_copilot_dir,
|
|
1284
1392
|
emit_instructions=add_copilot_dir,
|
|
1393
|
+
cleanup_disabled=not add_copilot_dir,
|
|
1285
1394
|
)
|
|
1286
1395
|
if add_copilot_dir:
|
|
1287
1396
|
_try_generator("generate_copilot_hooks", cwd)
|
|
1397
|
+
else:
|
|
1398
|
+
from generate_copilot_hooks import cleanup as cleanup_copilot_hooks
|
|
1399
|
+
cleanup_copilot_hooks(cwd)
|
|
1288
1400
|
|
|
1289
1401
|
if "cursor" in eds:
|
|
1290
1402
|
inject_with_rules(
|
|
@@ -61,6 +61,13 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
61
61
|
for runtime_file in sorted(hooks_src.glob("*.json")):
|
|
62
62
|
shutil.copy2(runtime_file, hooks_scripts_dir / runtime_file.name)
|
|
63
63
|
copied += 1
|
|
64
|
+
output_filter_policy = app_dir / "output-filter-policy.json"
|
|
65
|
+
policy_destination = hooks_scripts_dir / output_filter_policy.name
|
|
66
|
+
# The global policy is user configuration: seed it once, never overwrite,
|
|
67
|
+
# so `ai-toolkit update` cannot silently reset an enabled mode to off.
|
|
68
|
+
if output_filter_policy.is_file() and not policy_destination.exists():
|
|
69
|
+
shutil.copy2(output_filter_policy, policy_destination)
|
|
70
|
+
copied += 1
|
|
64
71
|
print(f" Copied: {copied} hook scripts to ~/.softspark/ai-toolkit/hooks/")
|
|
65
72
|
legacy_hooks = claude_dir / "hooks"
|
|
66
73
|
if legacy_hooks.is_symlink():
|
|
@@ -71,6 +78,8 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
71
78
|
# Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
|
|
72
79
|
# scripts that a deployed hook actually executes belong here.
|
|
73
80
|
HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
|
|
81
|
+
"output_filter_cli.py",
|
|
82
|
+
"output_filter_hook.py",
|
|
74
83
|
"session_state.py",
|
|
75
84
|
"session_token_stats.py",
|
|
76
85
|
"test_cohesion.py",
|
|
@@ -98,8 +107,23 @@ def _copy_hook_runtime_scripts(scripts_dst: Path) -> None:
|
|
|
98
107
|
shutil.copy2(src, dst)
|
|
99
108
|
dst.chmod(dst.stat().st_mode | 0o111)
|
|
100
109
|
copied += 1
|
|
110
|
+
output_filter_src = scripts_src / "tool_output_filter"
|
|
111
|
+
if output_filter_src.is_dir():
|
|
112
|
+
output_filter_dst = scripts_dst / output_filter_src.name
|
|
113
|
+
# Prune first: dirs_exist_ok alone never removes modules deleted in a
|
|
114
|
+
# newer release, and a stale .py at sys.path[0] would shadow the
|
|
115
|
+
# shipped implementation under `python3 -S`.
|
|
116
|
+
if output_filter_dst.is_dir() and not output_filter_dst.is_symlink():
|
|
117
|
+
shutil.rmtree(output_filter_dst)
|
|
118
|
+
shutil.copytree(
|
|
119
|
+
output_filter_src,
|
|
120
|
+
output_filter_dst,
|
|
121
|
+
dirs_exist_ok=True,
|
|
122
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
|
123
|
+
)
|
|
124
|
+
copied += 1
|
|
101
125
|
if copied:
|
|
102
|
-
print(f" Copied: {copied} hook runtime
|
|
126
|
+
print(f" Copied: {copied} hook runtime assets to ~/.softspark/ai-toolkit/scripts/")
|
|
103
127
|
|
|
104
128
|
|
|
105
129
|
def _run_merge_hooks(action: str, *args: str) -> None:
|