@softspark/ai-toolkit 4.20.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
  })
@@ -95,11 +96,31 @@ HOOK_REQUIRED_FIELDS = {
95
96
  "mcp_tool": ("server", "tool", "arguments"),
96
97
  }
97
98
 
99
+ # The taxonomy. `app/skills/documentation-standards/SKILL.md` documents the same
100
+ # eight and is what authors read; this set is what rejects a typo. They are one
101
+ # list in two places, so a change belongs in both.
102
+ #
103
+ # `decisions` and `runbooks` were missing until v4.21.0 while the kb-migration
104
+ # SOP had been telling people to create those directories for months, so a
105
+ # correctly-filed ADR failed validation.
98
106
  VALID_KB_CATEGORIES = frozenset({
99
107
  "reference", "howto", "procedures", "troubleshooting", "best-practices",
100
- "planning",
108
+ "decisions", "runbooks", "planning",
101
109
  })
102
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
+
103
124
  VALID_RULE_CATEGORIES = frozenset({
104
125
  "coding-style",
105
126
  "testing",
@@ -219,6 +240,22 @@ def _body_line_count(filepath: Path) -> int:
219
240
  return count
220
241
 
221
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
+
222
259
  def _body_nonblank_count(filepath: Path) -> int:
223
260
  """Count non-blank lines after the second --- delimiter."""
224
261
  count = 0
@@ -325,6 +362,18 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
325
362
  if body_lines > 500:
326
363
  vr.warn(f"skills/{name}/SKILL.md: body is {body_lines} lines (recommended < 500)")
327
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
+
328
377
  desc_value = _fm_field(fm_lines, "description")
329
378
  if len(desc_value) > 1024:
330
379
  vr.warn(f"{name} - Description exceeds 1024 characters")
@@ -377,6 +426,7 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
377
426
  print("## Skills")
378
427
  skills_dir = tk_dir / "app" / "skills"
379
428
  skill_count = 0
429
+ largest_body = (0, "")
380
430
 
381
431
  if not skills_dir.is_dir():
382
432
  vr.error("app/skills directory not found")
@@ -401,8 +451,15 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
401
451
  fm_lines = _parse_frontmatter_lines(skill_file)
402
452
  _validate_skill_frontmatter(tk_dir, skill_path, fm_lines, vr)
403
453
  _validate_skill_references(tk_dir, skill_path, fm_lines, vr)
454
+ largest_body = max(largest_body, (_body_bytes(skill_file), name))
404
455
 
405
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
+ )
406
463
  print()
407
464
  return skill_count
408
465
 
@@ -730,6 +787,31 @@ def validate_kb_documents(tk_dir: Path, vr: ValidationResult) -> None:
730
787
  vr.error(f"{kb_name} - Invalid category '{kb_category}' (valid: {', '.join(sorted(VALID_KB_CATEGORIES))})")
731
788
  kb_errors += 1
732
789
 
790
+ # `section` is a legacy alias for `category`, not a second axis. A
791
+ # document carrying both with different values is indexed twice and
792
+ # found once, and nothing about that is visible to its author.
793
+ kb_section = _fm_field(fm_lines, "section").strip()
794
+ if kb_section and kb_category and kb_section != kb_category:
795
+ vr.error(
796
+ f"{kb_name} - section '{kb_section}' disagrees with category "
797
+ f"'{kb_category}'; section is an alias and must match"
798
+ )
799
+ kb_errors += 1
800
+
801
+ # A document filed under a taxonomy directory must declare that
802
+ # category, or a browsing reader and a search hit disagree about what it
803
+ # is. The rule is scoped to directories that ARE category names on
804
+ # purpose: `kb/history/completed/` is a lifecycle location, not a type,
805
+ # and a finished plan is still a `planning` document.
806
+ relative = kb_file.relative_to(kb_dir).parts
807
+ top = relative[0] if len(relative) > 1 else ""
808
+ if kb_category and top in VALID_KB_CATEGORIES and top != kb_category:
809
+ vr.error(
810
+ f"{kb_name} - is in {top}/ but declares category "
811
+ f"'{kb_category}'; they must agree"
812
+ )
813
+ kb_errors += 1
814
+
733
815
  # Validate tags is not empty
734
816
  tags_val = _fm_field(fm_lines, "tags")
735
817
  if tags_val: