@softspark/ai-toolkit 4.2.4 → 4.3.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 (51) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +10 -9
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/hooks/_hook-io.sh +64 -0
  5. package/app/hooks/_locate-toolkit.sh +39 -0
  6. package/app/hooks/_search-capability.sh +46 -0
  7. package/app/hooks/commit-quality.sh +3 -1
  8. package/app/hooks/config-desync-guard.sh +95 -0
  9. package/app/hooks/governance-capture.sh +5 -3
  10. package/app/hooks/guard-config.sh +5 -3
  11. package/app/hooks/guard-destructive.sh +3 -1
  12. package/app/hooks/guard-path.sh +8 -1
  13. package/app/hooks/instructions-audit.sh +43 -0
  14. package/app/hooks/post-tool-use.sh +20 -8
  15. package/app/hooks/quality-gate.sh +47 -0
  16. package/app/hooks/revert-guard.sh +84 -0
  17. package/app/hooks/search-tracker.sh +18 -0
  18. package/app/hooks/session-start.sh +14 -2
  19. package/app/hooks/stop-search-check.sh +37 -0
  20. package/app/hooks/test-cohesion-map.json +77 -0
  21. package/app/hooks/test-cohesion.sh +93 -0
  22. package/app/hooks/track-usage.sh +3 -1
  23. package/app/hooks/user-prompt-submit.sh +33 -6
  24. package/app/hooks.json +65 -1
  25. package/benchmarks/ecosystem-doctor-snapshot.json +8 -8
  26. package/bin/ai-toolkit.js +43 -0
  27. package/kb/procedures/release-verification-sop.md +2 -2
  28. package/kb/reference/architecture-overview.md +1 -1
  29. package/kb/reference/extension-api.md +77 -11
  30. package/kb/reference/hooks-catalog.md +149 -24
  31. package/kb/reference/mcp-templates.md +6 -4
  32. package/kb/reference/unique-features.md +11 -5
  33. package/llms-full.txt +163 -32
  34. package/manifest.json +1 -1
  35. package/package.json +1 -1
  36. package/scripts/doctor.py +13 -0
  37. package/scripts/generate_augment_hooks.py +5 -1
  38. package/scripts/generate_codex_hooks.py +2 -0
  39. package/scripts/generate_cursor_hooks.py +6 -0
  40. package/scripts/generate_gemini_hooks.py +5 -1
  41. package/scripts/generate_windsurf_hooks.py +6 -0
  42. package/scripts/inject_mcp_cli.py +514 -0
  43. package/scripts/install.py +2 -1
  44. package/scripts/install_steps/hooks.py +5 -0
  45. package/scripts/install_steps/markers.py +40 -0
  46. package/scripts/mcp_sources.py +162 -0
  47. package/scripts/merge-hooks.py +10 -1
  48. package/scripts/paths.py +2 -0
  49. package/scripts/plugin_schema.py +4 -0
  50. package/scripts/session_state.py +150 -0
  51. package/scripts/test_cohesion.py +133 -0
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for externally-injected MCP templates.
3
+
4
+ Tracks which MCP templates were registered from a URL or local file so that
5
+ `ai-toolkit update` can re-fetch the latest version and re-inject. Mirrors
6
+ the design of hook_sources.py for parity between inject-hook and inject-mcp.
7
+
8
+ Metadata stored in ~/.softspark/ai-toolkit/mcp-templates/external/sources.json.
9
+
10
+ Stdlib-only -- no external dependencies.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from paths import EXTERNAL_MCP_DIR
26
+
27
+ _SOURCES_FILENAME = "sources.json"
28
+
29
+
30
+ def _sources_path(mcp_dir: Path | None = None) -> Path:
31
+ return (mcp_dir or EXTERNAL_MCP_DIR) / _SOURCES_FILENAME
32
+
33
+
34
+ def load_sources(mcp_dir: Path | None = None) -> dict[str, dict[str, Any]]:
35
+ """Load sources.json. Returns {} if missing or corrupt."""
36
+ path = _sources_path(mcp_dir)
37
+ if not path.is_file():
38
+ return {}
39
+ try:
40
+ with open(path, encoding="utf-8") as f:
41
+ data = json.load(f)
42
+ if isinstance(data, dict):
43
+ return data.get("templates", {})
44
+ return {}
45
+ except (json.JSONDecodeError, OSError):
46
+ return {}
47
+
48
+
49
+ def save_sources(mcp_dir: Path | None = None,
50
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
51
+ """Write sources.json atomically."""
52
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
53
+ path = _sources_path(mcp_dir)
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ payload = json.dumps(
57
+ {"schema_version": 1, "templates": sources or {}}, indent=2
58
+ )
59
+
60
+ fd, tmp_path = tempfile.mkstemp(
61
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
62
+ )
63
+ try:
64
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
65
+ f.write(payload)
66
+ f.write("\n")
67
+ f.flush()
68
+ os.fsync(f.fileno())
69
+ os.rename(tmp_path, str(path))
70
+ except BaseException:
71
+ try:
72
+ os.unlink(tmp_path)
73
+ except OSError:
74
+ pass
75
+ raise
76
+
77
+
78
+ def register_url_source(
79
+ mcp_dir: Path | None,
80
+ template_name: str,
81
+ url: str,
82
+ content: bytes | None = None,
83
+ ) -> None:
84
+ """Add or update a URL source entry for an MCP template.
85
+
86
+ When ``content`` is supplied, its sha256 is persisted. If a previous
87
+ sha256 exists and differs from the new one, a warning is printed
88
+ (and the process fails with exit 2 when ``AI_TOOLKIT_STRICT_PIN=1``).
89
+ """
90
+ if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
91
+ raise ValueError(f"Invalid MCP template name: {template_name!r}")
92
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
93
+ sources = load_sources(mcp_dir)
94
+ entry: dict[str, Any] = {
95
+ "url": url,
96
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
97
+ }
98
+ if content is not None:
99
+ new_hash = hashlib.sha256(content).hexdigest()
100
+ prev = sources.get(template_name) or {}
101
+ prev_hash = prev.get("sha256")
102
+ if prev_hash and prev_hash != new_hash:
103
+ msg = (
104
+ f" CHECKSUM CHANGED: mcp '{template_name}' sha256 "
105
+ f"{prev_hash[:12]}... -> {new_hash[:12]}..."
106
+ )
107
+ print(msg)
108
+ if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
109
+ raise SystemExit(
110
+ f"Refusing to update '{template_name}' under AI_TOOLKIT_STRICT_PIN=1."
111
+ )
112
+ entry["sha256"] = new_hash
113
+ sources[template_name] = entry
114
+ save_sources(mcp_dir, sources)
115
+
116
+
117
+ def register_path_source(
118
+ mcp_dir: Path | None,
119
+ template_name: str,
120
+ path: Path,
121
+ content: bytes | None = None,
122
+ ) -> None:
123
+ """Add or update a local-file source entry for an MCP template.
124
+
125
+ Stores the absolute origin path so subsequent ``ai-toolkit update`` runs
126
+ can detect drift, plus a sha256 of the injected content.
127
+ """
128
+ if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
129
+ raise ValueError(f"Invalid MCP template name: {template_name!r}")
130
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
131
+ sources = load_sources(mcp_dir)
132
+ existing = sources.get(template_name) or {}
133
+ # Never demote a URL-tracked entry to a local-path entry. update() flows
134
+ # call inject() with the cached file path after URL fetch, which would
135
+ # otherwise overwrite the URL.
136
+ if "url" in existing:
137
+ return
138
+ entry: dict[str, Any] = {
139
+ "path": str(Path(path).resolve()),
140
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
141
+ }
142
+ if content is not None:
143
+ entry["sha256"] = hashlib.sha256(content).hexdigest()
144
+ sources[template_name] = entry
145
+ save_sources(mcp_dir, sources)
146
+
147
+
148
+ def unregister_source(mcp_dir: Path | None, template_name: str) -> bool:
149
+ """Remove a source entry. Returns True if found and removed."""
150
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
151
+ sources = load_sources(mcp_dir)
152
+ if template_name in sources:
153
+ del sources[template_name]
154
+ save_sources(mcp_dir, sources)
155
+ return True
156
+ return False
157
+
158
+
159
+ def get_url_templates(mcp_dir: Path | None = None) -> dict[str, str]:
160
+ """Return {template_name: url} for all URL-sourced MCP templates."""
161
+ sources = load_sources(mcp_dir)
162
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
@@ -41,7 +41,16 @@ LEGACY_TOOLKIT_HOOKS = {
41
41
  ),
42
42
  }
43
43
  ],
44
- }
44
+ },
45
+ {
46
+ "matcher": "",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "bash ~/.softspark/ai-toolkit/hooks/notify-waiting.sh",
51
+ }
52
+ ],
53
+ },
45
54
  ],
46
55
  }
47
56
 
package/scripts/paths.py CHANGED
@@ -26,6 +26,8 @@ LEGACY_DATA_DIR = Path.home() / ".ai-toolkit"
26
26
  # Sub-directories under TOOLKIT_DATA_DIR
27
27
  HOOKS_DIR = TOOLKIT_DATA_DIR / "hooks"
28
28
  EXTERNAL_HOOKS_DIR = HOOKS_DIR / "external"
29
+ MCP_TEMPLATES_DIR = TOOLKIT_DATA_DIR / "mcp-templates"
30
+ EXTERNAL_MCP_DIR = MCP_TEMPLATES_DIR / "external"
29
31
  RULES_DIR = TOOLKIT_DATA_DIR / "rules"
30
32
  SESSIONS_DIR = TOOLKIT_DATA_DIR / "sessions"
31
33
  COMPACTIONS_DIR = TOOLKIT_DATA_DIR / "compactions"
@@ -32,6 +32,10 @@ VALID_HOOK_EVENTS = frozenset({
32
32
  "SessionStart", "Notification", "PreToolUse", "PostToolUse", "Stop",
33
33
  "PreCompact", "SubagentStop", "UserPromptSubmit", "TaskCompleted",
34
34
  "TeammateIdle", "SubagentStart", "SessionEnd", "PermissionRequest", "Setup",
35
+ "InstructionsLoaded", "ConfigChange", "PostToolUseFailure", "PostToolBatch",
36
+ "UserPromptExpansion", "PostCompact", "StopFailure", "CwdChanged",
37
+ "FileChanged", "PermissionDenied", "Elicitation", "ElicitationResult",
38
+ "WorktreeCreate", "WorktreeRemove", "TaskCreated",
35
39
  })
36
40
 
37
41
 
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """Session edit state tracker for ai-toolkit hooks.
3
+
4
+ Stores per-session file edits so hooks can:
5
+ - Know which paths were touched (revert-guard, test-cohesion).
6
+ - Run only related tests (quality-gate ai-toolkit branch).
7
+
8
+ State file: ``~/.softspark/ai-toolkit/state/session-edits.json``
9
+
10
+ Usage:
11
+ session_state.py reset [--session-id ID]
12
+ session_state.py append --tool Edit --path /abs/path [--session-id ID]
13
+ session_state.py was-edited /abs/path
14
+ session_state.py list
15
+ session_state.py session-id
16
+
17
+ Exit codes:
18
+ 0 success / true
19
+ 1 false / not found (for boolean queries)
20
+ 2 usage error
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import datetime as _dt
26
+ import json
27
+ import os
28
+ import sys
29
+ import uuid
30
+ from pathlib import Path
31
+
32
+ STATE_DIR = Path(os.path.expanduser("~/.softspark/ai-toolkit/state"))
33
+ STATE_FILE = STATE_DIR / "session-edits.json"
34
+ MAX_EDITS = 5000 # cap to avoid unbounded growth
35
+
36
+
37
+ def _now() -> str:
38
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
39
+
40
+
41
+ def _ensure_dir() -> None:
42
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
43
+
44
+
45
+ def _load() -> dict:
46
+ if not STATE_FILE.is_file():
47
+ return {"session_id": "", "started_at": "", "edits": []}
48
+ try:
49
+ with STATE_FILE.open() as f:
50
+ data = json.load(f)
51
+ except (json.JSONDecodeError, OSError):
52
+ return {"session_id": "", "started_at": "", "edits": []}
53
+ data.setdefault("session_id", "")
54
+ data.setdefault("started_at", "")
55
+ data.setdefault("edits", [])
56
+ return data
57
+
58
+
59
+ def _save(data: dict) -> None:
60
+ _ensure_dir()
61
+ tmp = STATE_FILE.with_suffix(".json.tmp")
62
+ with tmp.open("w") as f:
63
+ json.dump(data, f, indent=2)
64
+ f.write("\n")
65
+ os.replace(tmp, STATE_FILE)
66
+
67
+
68
+ def cmd_reset(session_id: str | None) -> int:
69
+ sid = session_id or str(uuid.uuid4())
70
+ _save({"session_id": sid, "started_at": _now(), "edits": []})
71
+ return 0
72
+
73
+
74
+ def cmd_append(tool: str, path: str, session_id: str | None) -> int:
75
+ if not path:
76
+ return 0 # silently ignore tools without file_path (e.g., bash)
77
+ data = _load()
78
+ if session_id and data["session_id"] != session_id:
79
+ # New session detected mid-stream — auto-reset.
80
+ cmd_reset(session_id)
81
+ data = _load()
82
+ abs_path = os.path.abspath(path)
83
+ data["edits"].append({"ts": _now(), "tool": tool, "path": abs_path})
84
+ if len(data["edits"]) > MAX_EDITS:
85
+ data["edits"] = data["edits"][-MAX_EDITS:]
86
+ _save(data)
87
+ return 0
88
+
89
+
90
+ def cmd_was_edited(path: str) -> int:
91
+ abs_path = os.path.abspath(path)
92
+ data = _load()
93
+ for edit in data["edits"]:
94
+ if edit.get("path") == abs_path:
95
+ return 0
96
+ return 1
97
+
98
+
99
+ def cmd_list() -> int:
100
+ data = _load()
101
+ seen: set[str] = set()
102
+ for edit in data["edits"]:
103
+ p = edit.get("path", "")
104
+ if p and p not in seen:
105
+ seen.add(p)
106
+ print(p)
107
+ return 0
108
+
109
+
110
+ def cmd_session_id() -> int:
111
+ data = _load()
112
+ print(data.get("session_id", ""))
113
+ return 0
114
+
115
+
116
+ def main(argv: list[str] | None = None) -> int:
117
+ parser = argparse.ArgumentParser(description="Session edit state tracker")
118
+ sub = parser.add_subparsers(dest="cmd", required=True)
119
+
120
+ reset = sub.add_parser("reset", help="Clear state, start fresh session")
121
+ reset.add_argument("--session-id", default=None)
122
+
123
+ append = sub.add_parser("append", help="Record an edit")
124
+ append.add_argument("--tool", required=True)
125
+ append.add_argument("--path", required=True)
126
+ append.add_argument("--session-id", default=None)
127
+
128
+ was = sub.add_parser("was-edited", help="Exit 0 if path was edited this session")
129
+ was.add_argument("path")
130
+
131
+ sub.add_parser("list", help="Print unique edited paths, one per line")
132
+ sub.add_parser("session-id", help="Print current session id")
133
+
134
+ args = parser.parse_args(argv)
135
+
136
+ if args.cmd == "reset":
137
+ return cmd_reset(args.session_id)
138
+ if args.cmd == "append":
139
+ return cmd_append(args.tool, args.path, args.session_id)
140
+ if args.cmd == "was-edited":
141
+ return cmd_was_edited(args.path)
142
+ if args.cmd == "list":
143
+ return cmd_list()
144
+ if args.cmd == "session-id":
145
+ return cmd_session_id()
146
+ return 2
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(main())
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Test cohesion resolver for ai-toolkit hooks.
3
+
4
+ Reads a project-local or toolkit-default test-cohesion-map and answers:
5
+ "given these changed paths, what tests should run?"
6
+
7
+ Map format (JSON array of rules):
8
+ [
9
+ {
10
+ "match": "app/hooks/*.sh", # glob, repo-rooted
11
+ "tests": ["tests/test_hooks.bats"],
12
+ "runner": "bats", # bats|pytest|vitest|jest|custom
13
+ "command": null # optional: full command override
14
+ }
15
+ ]
16
+
17
+ Lookup order:
18
+ 1. $PWD/.claude/test-cohesion-map.json (project override)
19
+ 2. $TOOLKIT_DIR/app/hooks/test-cohesion-map.json (toolkit default)
20
+
21
+ Usage:
22
+ test_cohesion.py resolve --changed-paths PATH [PATH ...] [--repo-root DIR]
23
+ Prints one shell command per line. Exit 0 if any test commands resolved,
24
+ 1 if no rules matched (so hook can skip silently).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import fnmatch
30
+ import json
31
+ import os
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ DEFAULT_RUNNERS: dict[str, str] = {
36
+ "bats": "bats --no-parallelize-within-files",
37
+ "pytest": "pytest -x",
38
+ "vitest": "npx vitest run",
39
+ "jest": "npx jest --bail",
40
+ }
41
+
42
+
43
+ def _toolkit_dir() -> Path | None:
44
+ env = os.environ.get("AI_TOOLKIT_DIR")
45
+ if env and Path(env).is_dir():
46
+ return Path(env)
47
+ # Walk up from this script: scripts/ -> repo root
48
+ here = Path(__file__).resolve()
49
+ candidate = here.parent.parent
50
+ if (candidate / "app" / "hooks.json").is_file():
51
+ return candidate
52
+ return None
53
+
54
+
55
+ def _load_map(repo_root: Path) -> list[dict]:
56
+ candidates = [repo_root / ".claude" / "test-cohesion-map.json"]
57
+ tk = _toolkit_dir()
58
+ if tk:
59
+ candidates.append(tk / "hooks" / "test-cohesion-map.json")
60
+ candidates.append(tk / "app" / "hooks" / "test-cohesion-map.json")
61
+ for path in candidates:
62
+ if not path.is_file():
63
+ continue
64
+ try:
65
+ with path.open() as f:
66
+ data = json.load(f)
67
+ except (json.JSONDecodeError, OSError):
68
+ continue
69
+ if isinstance(data, list):
70
+ return data
71
+ return []
72
+
73
+
74
+ def _matches(path: str, pattern: str) -> bool:
75
+ return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(
76
+ os.path.basename(path), pattern
77
+ )
78
+
79
+
80
+ def _relpath(repo_root: Path, abs_path: str) -> str:
81
+ try:
82
+ return str(Path(abs_path).resolve().relative_to(repo_root.resolve()))
83
+ except (ValueError, OSError):
84
+ return abs_path
85
+
86
+
87
+ def resolve(repo_root: Path, changed: list[str]) -> list[str]:
88
+ rules = _load_map(repo_root)
89
+ if not rules:
90
+ return []
91
+ # First-match-wins per changed path. List specific rules before broad
92
+ # globs in the map to control scope.
93
+ buckets: dict[tuple[str, str | None], set[str]] = {}
94
+ for raw in changed:
95
+ rel = _relpath(repo_root, raw)
96
+ for rule in rules:
97
+ pattern = rule.get("match")
98
+ if not pattern or not _matches(rel, pattern):
99
+ continue
100
+ runner = rule.get("runner", "bats")
101
+ command_override = rule.get("command")
102
+ key = (runner, command_override)
103
+ buckets.setdefault(key, set()).update(rule.get("tests", []))
104
+ break # first match wins for this path
105
+ if not buckets:
106
+ return []
107
+ commands: list[str] = []
108
+ for (runner, override), tests in buckets.items():
109
+ if override:
110
+ commands.append(override)
111
+ continue
112
+ base = DEFAULT_RUNNERS.get(runner, runner)
113
+ commands.append(f"{base} {' '.join(sorted(tests))}".strip())
114
+ return commands
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int:
118
+ parser = argparse.ArgumentParser(description="Resolve cohesion test commands")
119
+ sub = parser.add_subparsers(dest="cmd", required=True)
120
+ r = sub.add_parser("resolve")
121
+ r.add_argument("--changed-paths", nargs="+", required=True)
122
+ r.add_argument("--repo-root", default=os.getcwd())
123
+ args = parser.parse_args(argv)
124
+
125
+ repo_root = Path(args.repo_root)
126
+ commands = resolve(repo_root, args.changed_paths)
127
+ for c in commands:
128
+ print(c)
129
+ return 0 if commands else 1
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())