@softspark/ai-toolkit 4.21.0 → 4.22.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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
4
+ # Source: https://github.com/softspark/ai-toolkit
5
+
6
+ """Public surface manifest — the enforceable half of BACKWARD_COMPATIBILITY.md.
7
+
8
+ `BACKWARD_COMPATIBILITY.md` names the surfaces users depend on. Prose stops
9
+ nothing: rename a skill and every gate stays green. This turns the list into a
10
+ committed snapshot and a test.
11
+
12
+ The check is deliberately one-directional:
13
+
14
+ entry in the manifest, missing from the tree -> FAIL (a removal)
15
+ entry in the tree, missing from the manifest -> pass (an addition)
16
+
17
+ Additions are free because a surface nobody has installed yet has no users to
18
+ break. Removals fail because someone out there pinned the version that had it.
19
+
20
+ The manifest is NOT regenerated automatically. If it were, deleting a skill would
21
+ delete its manifest entry in the same breath and the check would detect nothing.
22
+ `--update` is a deliberate act: run it, read the diff, and any line that
23
+ disappeared is a breaking change owing an entry in DECISIONS.md.
24
+
25
+ Stdlib-only.
26
+
27
+ Usage:
28
+ python3 scripts/surface_manifest.py # check, human-readable
29
+ python3 scripts/surface_manifest.py --json # check, machine-readable
30
+ python3 scripts/surface_manifest.py --update # rewrite app/surface.json
31
+ python3 scripts/surface_manifest.py --toolkit-dir /path
32
+
33
+ Exit codes:
34
+ 0 no protected surface was removed
35
+ 1 a removal detected (or the manifest is missing on a check run)
36
+ 2 usage error
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import re
42
+ import sys
43
+ from pathlib import Path
44
+
45
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
46
+ from _common import toolkit_dir as default_toolkit_dir
47
+
48
+ MANIFEST_RELPATH = Path("app") / "surface.json"
49
+
50
+ FM_FIELD_RE = re.compile(r"^([a-z][a-z0-9-]*):")
51
+ CLI_COMMAND_RE = re.compile(r"^\s+'?([a-z][a-z0-9-]*)'?\s*:")
52
+
53
+
54
+ def _frontmatter_fields(path: Path) -> set[str]:
55
+ parts = path.read_text(encoding="utf-8").split("---")
56
+ if len(parts) < 3:
57
+ return set()
58
+ return {
59
+ m.group(1)
60
+ for line in parts[1].splitlines()
61
+ if (m := FM_FIELD_RE.match(line))
62
+ }
63
+
64
+
65
+ def collect_surface(tk_dir: Path) -> dict:
66
+ """Read the current public surface out of the tree."""
67
+ skills_dir = tk_dir / "app" / "skills"
68
+ agents_dir = tk_dir / "app" / "agents"
69
+
70
+ skills: list[str] = []
71
+ skill_fields: set[str] = set()
72
+ for d in sorted(skills_dir.iterdir()) if skills_dir.is_dir() else []:
73
+ if not d.is_dir() or d.name.startswith("_") or not (d / "SKILL.md").is_file():
74
+ continue
75
+ skills.append(d.name)
76
+ skill_fields |= _frontmatter_fields(d / "SKILL.md")
77
+
78
+ agents: list[str] = []
79
+ agent_fields: set[str] = set()
80
+ for f in sorted(agents_dir.glob("*.md")) if agents_dir.is_dir() else []:
81
+ agents.append(f.stem)
82
+ agent_fields |= _frontmatter_fields(f)
83
+
84
+ hooks_dir = tk_dir / "app" / "hooks"
85
+ hook_scripts = sorted(
86
+ f.name for f in hooks_dir.glob("*.sh") if not f.name.startswith("_")
87
+ ) if hooks_dir.is_dir() else []
88
+
89
+ hook_events: list[str] = []
90
+ hooks_json = tk_dir / "app" / "hooks.json"
91
+ if hooks_json.is_file():
92
+ try:
93
+ hook_events = sorted(json.loads(hooks_json.read_text(encoding="utf-8")).get("hooks", {}))
94
+ except (json.JSONDecodeError, OSError):
95
+ hook_events = []
96
+
97
+ plugins_dir = tk_dir / "app" / "plugins"
98
+ packs = sorted(
99
+ d.name for d in plugins_dir.iterdir()
100
+ if d.is_dir() and not d.name.startswith(".")
101
+ ) if plugins_dir.is_dir() else []
102
+
103
+ kb_categories: list[str] = []
104
+ validate_py = tk_dir / "scripts" / "validate.py"
105
+ if validate_py.is_file():
106
+ text = validate_py.read_text(encoding="utf-8")
107
+ block = re.search(r"VALID_KB_CATEGORIES\s*=\s*frozenset\(\{(.*?)\}\)", text, re.S)
108
+ if block:
109
+ kb_categories = sorted(re.findall(r'"([a-z-]+)"', block.group(1)))
110
+
111
+ cli_commands: list[str] = []
112
+ cli = tk_dir / "bin" / "ai-toolkit.js"
113
+ if not cli.is_file():
114
+ candidates = sorted((tk_dir / "bin").glob("*.js")) if (tk_dir / "bin").is_dir() else []
115
+ cli = candidates[0] if candidates else cli
116
+ if cli.is_file():
117
+ # The COMMANDS map is what `--help` prints, so it is the published surface.
118
+ # An undocumented internal branch is not a promise and is not captured here.
119
+ block = re.search(r"const COMMANDS\s*=\s*\{(.*?)\n\};", cli.read_text(encoding="utf-8"), re.S)
120
+ if block:
121
+ cli_commands = sorted({
122
+ m.group(1) for line in block.group(1).splitlines()
123
+ if (m := CLI_COMMAND_RE.match(line))
124
+ })
125
+
126
+ return {
127
+ "skills": skills,
128
+ "agents": agents,
129
+ "skill_frontmatter_fields": sorted(skill_fields),
130
+ "agent_frontmatter_fields": sorted(agent_fields),
131
+ "hook_scripts": hook_scripts,
132
+ "hook_events": hook_events,
133
+ "plugin_packs": packs,
134
+ "kb_categories": kb_categories,
135
+ "cli_commands": cli_commands,
136
+ }
137
+
138
+
139
+ def compare(manifest: dict, current: dict) -> dict:
140
+ """Removals fail, additions pass. See the module docstring for why."""
141
+ removed: dict[str, list[str]] = {}
142
+ added: dict[str, list[str]] = {}
143
+ for key, entries in manifest.items():
144
+ if key.startswith("_"):
145
+ continue
146
+ have = set(current.get(key, []))
147
+ gone = [e for e in entries if e not in have]
148
+ if gone:
149
+ removed[key] = gone
150
+ new = [e for e in current.get(key, []) if e not in set(entries)]
151
+ if new:
152
+ added[key] = new
153
+ return {"removed": removed, "added": added, "ok": not removed}
154
+
155
+
156
+ def _write_manifest(path: Path, current: dict) -> None:
157
+ payload = {
158
+ "_comment": (
159
+ "Public surface snapshot. Removing an entry is a breaking change: see "
160
+ "BACKWARD_COMPATIBILITY.md, and record it in DECISIONS.md. Regenerate "
161
+ "deliberately with `python3 scripts/surface_manifest.py --update` and "
162
+ "read the diff — never as a reflex to green a red build."
163
+ ),
164
+ **current,
165
+ }
166
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
167
+
168
+
169
+ def main(argv: list[str]) -> int:
170
+ update = False
171
+ as_json = False
172
+ tk_dir = default_toolkit_dir
173
+
174
+ idx = 0
175
+ while idx < len(argv):
176
+ arg = argv[idx]
177
+ if arg == "--update":
178
+ update = True
179
+ elif arg == "--json":
180
+ as_json = True
181
+ elif arg in ("-h", "--help"):
182
+ print(__doc__)
183
+ return 0
184
+ elif arg == "--toolkit-dir":
185
+ idx += 1
186
+ if idx >= len(argv):
187
+ print("ERROR: --toolkit-dir requires a value", file=sys.stderr)
188
+ return 2
189
+ tk_dir = Path(argv[idx])
190
+ elif arg.startswith("-"):
191
+ print(f"ERROR: unknown option: {arg}", file=sys.stderr)
192
+ return 2
193
+ else:
194
+ tk_dir = Path(arg)
195
+ idx += 1
196
+
197
+ tk_dir = tk_dir.resolve()
198
+ manifest_path = tk_dir / MANIFEST_RELPATH
199
+ current = collect_surface(tk_dir)
200
+
201
+ if update:
202
+ _write_manifest(manifest_path, current)
203
+ total = sum(len(v) for v in current.values())
204
+ print(f"Wrote {manifest_path.relative_to(tk_dir)} — {total} protected entries")
205
+ print("Read the diff. Every line removed is a breaking change.")
206
+ return 0
207
+
208
+ if not manifest_path.is_file():
209
+ print(f"ERROR: {MANIFEST_RELPATH} not found — run with --update to create it",
210
+ file=sys.stderr)
211
+ return 1
212
+
213
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
214
+ result = compare(manifest, current)
215
+
216
+ if as_json:
217
+ print(json.dumps(result, indent=2, ensure_ascii=False))
218
+ return 0 if result["ok"] else 1
219
+
220
+ print("## Public Surface")
221
+ if result["removed"]:
222
+ for key, entries in sorted(result["removed"].items()):
223
+ for entry in entries:
224
+ print(f" ERROR: {key}: '{entry}' was removed from the public surface")
225
+ print()
226
+ print("A removal is a breaking change. Either restore it, or take the")
227
+ print("deprecation path in BACKWARD_COMPATIBILITY.md, record it in")
228
+ print("DECISIONS.md, and re-run with --update.")
229
+ print()
230
+ print("SURFACE CHECK FAILED")
231
+ return 1
232
+
233
+ protected = sum(len(v) for k, v in manifest.items() if not k.startswith("_"))
234
+ print(f" OK: {protected} protected entries intact")
235
+ if result["added"]:
236
+ new_total = sum(len(v) for v in result["added"].values())
237
+ detail = ", ".join(f"{k} +{len(v)}" for k, v in sorted(result["added"].items()))
238
+ print(f" Note: {new_total} new entries not yet protected ({detail})")
239
+ print(" Run --update before the next release to adopt them.")
240
+ print()
241
+ print("SURFACE CHECK PASSED")
242
+ return 0
243
+
244
+
245
+ if __name__ == "__main__":
246
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
4
+ # Source: https://github.com/softspark/ai-toolkit
5
+
6
+ """Sync the README count badges to ground truth.
7
+
8
+ `validate.py` errors when a README badge disagrees with the tree. The badges were
9
+ maintained by hand, so every change that added a test or a skill turned the build
10
+ red until someone edited a number — a tax paid per change, with a diagnosis cycle
11
+ each time it was forgotten.
12
+
13
+ This derives the three counts the same way `validate.py` does and rewrites them.
14
+ It runs inside `npm run generate:all`, which `prepublishOnly` executes before
15
+ `validate.py --strict`, so the badges are correct by the time anything checks them.
16
+
17
+ Counting rules, matched to validate.py so the two cannot disagree:
18
+ agents — *.md under app/agents/
19
+ skills — directories under app/skills/ with a SKILL.md, excluding _-prefixed
20
+ tests — `@test ` declarations across tests/*.bats
21
+
22
+ Stdlib-only.
23
+
24
+ Usage:
25
+ python3 scripts/sync_badges.py # rewrite README.md badges
26
+ python3 scripts/sync_badges.py --check # report drift, change nothing
27
+ python3 scripts/sync_badges.py --toolkit-dir /path
28
+
29
+ Exit codes:
30
+ 0 badges are correct (or were rewritten)
31
+ 1 --check found drift
32
+ 2 usage error
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
41
+ from _common import toolkit_dir as default_toolkit_dir
42
+
43
+ TEST_RE = re.compile(r"^@test ", re.MULTILINE)
44
+
45
+
46
+ def counts(tk_dir: Path) -> dict[str, int]:
47
+ agents_dir = tk_dir / "app" / "agents"
48
+ skills_dir = tk_dir / "app" / "skills"
49
+ tests_dir = tk_dir / "tests"
50
+
51
+ agents = len(list(agents_dir.glob("*.md"))) if agents_dir.is_dir() else 0
52
+ skills = sum(
53
+ 1 for d in skills_dir.iterdir()
54
+ if d.is_dir() and not d.name.startswith("_") and (d / "SKILL.md").is_file()
55
+ ) if skills_dir.is_dir() else 0
56
+ tests = sum(
57
+ len(TEST_RE.findall(f.read_text(encoding="utf-8")))
58
+ for f in tests_dir.glob("*.bats")
59
+ ) if tests_dir.is_dir() else 0
60
+
61
+ return {"agents": agents, "skills": skills, "tests": tests}
62
+
63
+
64
+ def sync(tk_dir: Path, check_only: bool) -> int:
65
+ readme = tk_dir / "README.md"
66
+ if not readme.is_file():
67
+ print("ERROR: README.md not found", file=sys.stderr)
68
+ return 2
69
+
70
+ text = readme.read_text(encoding="utf-8")
71
+ actual = counts(tk_dir)
72
+ drift: list[str] = []
73
+ updated = text
74
+
75
+ for label, value in actual.items():
76
+ pattern = re.compile(rf"({label}-)(\d+)")
77
+ match = pattern.search(updated)
78
+ if not match:
79
+ continue
80
+ if match.group(2) != str(value):
81
+ drift.append(f"{label}: badge {match.group(2)} != actual {value}")
82
+ updated = pattern.sub(rf"\g<1>{value}", updated, count=1)
83
+
84
+ if check_only:
85
+ for line in drift:
86
+ print(f" DRIFT: {line}")
87
+ if drift:
88
+ print("BADGE CHECK FAILED — run `python3 scripts/sync_badges.py`")
89
+ return 1
90
+ print(f" OK: badges match ({actual['agents']} agents, "
91
+ f"{actual['skills']} skills, {actual['tests']} tests)")
92
+ return 0
93
+
94
+ if drift:
95
+ readme.write_text(updated, encoding="utf-8")
96
+ for line in drift:
97
+ print(f" synced {line}")
98
+ else:
99
+ print(f" OK: badges already match ({actual['agents']} agents, "
100
+ f"{actual['skills']} skills, {actual['tests']} tests)")
101
+ return 0
102
+
103
+
104
+ def main(argv: list[str]) -> int:
105
+ check_only = False
106
+ tk_dir = default_toolkit_dir
107
+
108
+ idx = 0
109
+ while idx < len(argv):
110
+ arg = argv[idx]
111
+ if arg == "--check":
112
+ check_only = True
113
+ elif arg in ("-h", "--help"):
114
+ print(__doc__)
115
+ return 0
116
+ elif arg == "--toolkit-dir":
117
+ idx += 1
118
+ if idx >= len(argv):
119
+ print("ERROR: --toolkit-dir requires a value", file=sys.stderr)
120
+ return 2
121
+ tk_dir = Path(argv[idx])
122
+ elif arg.startswith("-"):
123
+ print(f"ERROR: unknown option: {arg}", file=sys.stderr)
124
+ return 2
125
+ else:
126
+ tk_dir = Path(arg)
127
+ idx += 1
128
+
129
+ return sync(tk_dir.resolve(), check_only)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main(sys.argv[1:]))
@@ -45,6 +45,7 @@ VALID_TOOLS = frozenset({
45
45
  VALID_HOOK_EVENTS = frozenset({
46
46
  # Core lifecycle
47
47
  "SessionStart", "SessionEnd", "UserPromptSubmit", "Notification",
48
+ "MessageDisplay",
48
49
  # Tool lifecycle
49
50
  "PreToolUse", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
50
51
  # Turn lifecycle
@@ -60,7 +61,7 @@ VALID_HOOK_EVENTS = frozenset({
60
61
  "TaskCreated", "TaskCompleted", "TeammateIdle",
61
62
  # Worktrees & environment
62
63
  "WorktreeCreate", "WorktreeRemove",
63
- "CwdChanged", "FileChanged", "ConfigChange",
64
+ "CwdChanged", "FileChanged", "ConfigChange", "DirectoryAdded",
64
65
  # Setup / bootstrap
65
66
  "Setup", "InstructionsLoaded",
66
67
  })
@@ -107,6 +108,19 @@ VALID_KB_CATEGORIES = frozenset({
107
108
  "decisions", "runbooks", "planning",
108
109
  })
109
110
 
111
+ # Skill body budget, in bytes after the frontmatter block.
112
+ #
113
+ # The body loads in full every time a trigger matches, including when it matches
114
+ # by accident. Detail that only some runs need belongs in the skill's reference/
115
+ # directory, reached from a pointer in the body.
116
+ #
117
+ # Ratchet: lower WARN by 2_000 each release until it reaches 12_000. Never lower a
118
+ # threshold in the same change that something violates it — split the skill first,
119
+ # then tighten. The step lives in kb/procedures/release-preparation-sop.md so it
120
+ # does not rot as a comment nobody reads.
121
+ SKILL_BODY_BUDGET_ERROR = 20_000
122
+ SKILL_BODY_BUDGET_WARN = 18_000
123
+
110
124
  VALID_RULE_CATEGORIES = frozenset({
111
125
  "coding-style",
112
126
  "testing",
@@ -226,6 +240,22 @@ def _body_line_count(filepath: Path) -> int:
226
240
  return count
227
241
 
228
242
 
243
+ def _body_bytes(filepath: Path) -> int:
244
+ """Byte size of the body — everything after the frontmatter block.
245
+
246
+ Unlike _body_line_count this anchors on the frontmatter delimiters at the top
247
+ of the file, so a `---` horizontal rule inside the body does not shift the
248
+ measurement.
249
+ """
250
+ text = filepath.read_text(encoding="utf-8")
251
+ lines = text.splitlines(keepends=True)
252
+ if lines and lines[0].strip() == "---":
253
+ for idx in range(1, len(lines)):
254
+ if lines[idx].strip() == "---":
255
+ return len("".join(lines[idx + 1:]).encode("utf-8"))
256
+ return len(text.encode("utf-8"))
257
+
258
+
229
259
  def _body_nonblank_count(filepath: Path) -> int:
230
260
  """Count non-blank lines after the second --- delimiter."""
231
261
  count = 0
@@ -332,6 +362,18 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
332
362
  if body_lines > 500:
333
363
  vr.warn(f"skills/{name}/SKILL.md: body is {body_lines} lines (recommended < 500)")
334
364
 
365
+ body_bytes = _body_bytes(skill_file)
366
+ if body_bytes > SKILL_BODY_BUDGET_ERROR:
367
+ vr.error(
368
+ f"skills/{name}/SKILL.md: body is {body_bytes} bytes "
369
+ f"(limit {SKILL_BODY_BUDGET_ERROR}) - move detail into reference/"
370
+ )
371
+ elif body_bytes > SKILL_BODY_BUDGET_WARN:
372
+ vr.warn(
373
+ f"skills/{name}/SKILL.md: body is {body_bytes} bytes "
374
+ f"(budget {SKILL_BODY_BUDGET_WARN}) - move detail into reference/"
375
+ )
376
+
335
377
  desc_value = _fm_field(fm_lines, "description")
336
378
  if len(desc_value) > 1024:
337
379
  vr.warn(f"{name} - Description exceeds 1024 characters")
@@ -384,6 +426,7 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
384
426
  print("## Skills")
385
427
  skills_dir = tk_dir / "app" / "skills"
386
428
  skill_count = 0
429
+ largest_body = (0, "")
387
430
 
388
431
  if not skills_dir.is_dir():
389
432
  vr.error("app/skills directory not found")
@@ -408,8 +451,15 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
408
451
  fm_lines = _parse_frontmatter_lines(skill_file)
409
452
  _validate_skill_frontmatter(tk_dir, skill_path, fm_lines, vr)
410
453
  _validate_skill_references(tk_dir, skill_path, fm_lines, vr)
454
+ largest_body = max(largest_body, (_body_bytes(skill_file), name))
411
455
 
412
456
  print(f" Found: {skill_count} skills")
457
+ if skill_count:
458
+ size, worst = largest_body
459
+ print(
460
+ f" Body budget: largest is {worst} at {size} bytes "
461
+ f"(warn {SKILL_BODY_BUDGET_WARN}, error {SKILL_BODY_BUDGET_ERROR})"
462
+ )
413
463
  print()
414
464
  return skill_count
415
465