@softspark/ai-toolkit 4.2.3 → 4.2.5

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 (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +10 -9
  3. package/action.yml +1 -1
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/hooks/_hook-io.sh +64 -0
  6. package/app/hooks/_locate-toolkit.sh +39 -0
  7. package/app/hooks/_search-capability.sh +46 -0
  8. package/app/hooks/commit-quality.sh +3 -1
  9. package/app/hooks/config-desync-guard.sh +95 -0
  10. package/app/hooks/governance-capture.sh +5 -3
  11. package/app/hooks/guard-config.sh +5 -3
  12. package/app/hooks/guard-destructive.sh +3 -1
  13. package/app/hooks/guard-path.sh +8 -1
  14. package/app/hooks/instructions-audit.sh +43 -0
  15. package/app/hooks/post-tool-use.sh +20 -8
  16. package/app/hooks/quality-gate.sh +47 -0
  17. package/app/hooks/revert-guard.sh +84 -0
  18. package/app/hooks/search-tracker.sh +18 -0
  19. package/app/hooks/session-start.sh +14 -2
  20. package/app/hooks/stop-search-check.sh +37 -0
  21. package/app/hooks/test-cohesion-map.json +77 -0
  22. package/app/hooks/test-cohesion.sh +93 -0
  23. package/app/hooks/track-usage.sh +3 -1
  24. package/app/hooks/user-prompt-submit.sh +33 -6
  25. package/app/hooks.json +65 -1
  26. package/app/skills/ci/SKILL.md +1 -1
  27. package/app/skills/ci/templates/github-actions-node.yml +2 -2
  28. package/app/skills/ci/templates/github-actions-python.yml +1 -1
  29. package/app/skills/ci-cd-patterns/SKILL.md +8 -8
  30. package/app/skills/csharp-patterns/SKILL.md +1 -1
  31. package/app/skills/docker-devops/SKILL.md +4 -4
  32. package/benchmarks/ecosystem-doctor-snapshot.json +8 -8
  33. package/kb/procedures/ecosystem-sync-sop.md +1 -1
  34. package/kb/procedures/release-verification-sop.md +2 -2
  35. package/kb/reference/architecture-overview.md +1 -1
  36. package/kb/reference/ci-integration.md +2 -2
  37. package/kb/reference/hooks-catalog.md +149 -24
  38. package/kb/reference/unique-features.md +11 -5
  39. package/llms-full.txt +166 -35
  40. package/manifest.json +1 -1
  41. package/package.json +1 -1
  42. package/scripts/doctor.py +13 -0
  43. package/scripts/generate_augment_hooks.py +5 -1
  44. package/scripts/generate_codex_hooks.py +2 -0
  45. package/scripts/generate_cursor_hooks.py +6 -0
  46. package/scripts/generate_gemini_hooks.py +5 -1
  47. package/scripts/generate_windsurf_hooks.py +6 -0
  48. package/scripts/install_steps/hooks.py +5 -0
  49. package/scripts/merge-hooks.py +10 -1
  50. package/scripts/plugin_schema.py +4 -0
  51. package/scripts/session_state.py +150 -0
  52. package/scripts/test_cohesion.py +133 -0
@@ -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())