@softspark/ai-toolkit 4.22.0 → 4.23.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/CHANGELOG.md CHANGED
@@ -7,6 +7,72 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v4.23.0 — a compliance scanner that says nothing must say why (2026-08-06)
11
+
12
+ ### Fixed
13
+
14
+ - **`hipaa-validate` scanned a manifest-less project silently clean.** Category
15
+ 1, 3, 4 and 7 patterns are language-tagged and fire only for a detected
16
+ language, and detection read manifest files only. A directory of Python with
17
+ PHI in its loggers therefore reported `HIGH: 0` while every Python rule sat
18
+ unused — the worst possible output for a compliance tool, because zero reads
19
+ as compliant. Found by the post-release SOP on v4.22.1.
20
+
21
+ Detection now falls back to the extensions of the files being scanned when no
22
+ manifest is present. **Manifests still win**, so a project that already
23
+ declared itself keeps exactly the behaviour it had.
24
+
25
+ ### Changed
26
+
27
+ - **The scan summary reports how the language was decided.** A new
28
+ `language_detection` field says `manifest`, `file extensions (no manifest
29
+ found)`, or `none`. When it is `none` the text report writes to stderr that
30
+ language-tagged patterns did not run and that a zero-finding result means
31
+ unscanned rather than compliant.
32
+
33
+ **Upgrade note:** a project scanned without a manifest may now report HIGH
34
+ findings where v4.22.x reported none. Those findings were always there. A CI
35
+ job gating on the exit code can start failing on real PHI exposure.
36
+
37
+ ---
38
+
39
+ ## v4.22.1 — nine skills, not four, could not find their own scripts (2026-08-06)
40
+
41
+ v4.22.0 claimed to fix four skills whose documented script path never resolved.
42
+ The first real run of the post-release SOP found five more, plus a wrong
43
+ interpreter and a script that crashed on `--help`. The v4.22.0 fix had grepped
44
+ for one spelling of the bug and missed the others.
45
+
46
+ ### Fixed
47
+
48
+ - **Five more skills ran their own script through a path that cannot resolve
49
+ after install**: `brand-voice` (repo-relative `app/skills/.../measure.py`),
50
+ `ci`, `deploy`, `migrate` and `rollback` (cwd-relative `scripts/*.py`). Four
51
+ further example lines in `debug`, `fix`, `pr` and `review` had the same defect
52
+ in a secondary snippet while the primary invocation was already correct.
53
+ - **`rollback` ran a Python file through `bash`.** `bash scripts/rollback_info.py`
54
+ was wrong twice over.
55
+ - **`explore` used `python` rather than `python3`**, which is missing or Python 2
56
+ on many systems.
57
+ - **`explore`'s `visualize.py` crashed with a traceback on `--help`**, treating
58
+ the flag as a directory to scan. It now prints usage, and reports a JSON error
59
+ with exit 1 for a path that does not exist.
60
+
61
+ ### Added
62
+
63
+ - **`validate.py` fails the build on the whole class.**
64
+ `_validate_skill_script_invocations` checks every documented command that runs
65
+ a skill-owned script: the path must go through `${CLAUDE_SKILL_DIR}`, and the
66
+ interpreter must match the file. `python` instead of `python3` is a warning.
67
+ Repo-level scripts such as `scripts/validate.py` are correctly ignored, and the
68
+ frontmatter `scripts:` list stays relative because it declares rather than runs.
69
+ - **Post-release SOP phases 4b and 4c** (`post-release-testing-sop.md` v1.1.0):
70
+ run every skill's documented invocation from the installed copy, and prove
71
+ scanner-wrapping skills actually scan a fixture with known defects. Phase 4b
72
+ found every defect in this release on its first run.
73
+
74
+ ---
75
+
10
76
  ## v4.22.0 — the public surface is a test, not a promise (2026-08-06)
11
77
 
12
78
  ### Added
package/README.md CHANGED
@@ -6,7 +6,24 @@
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-109-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-1508%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-1516%20passing-success)](tests/)
10
+
11
+ ## What's New in v4.23.0
12
+
13
+ **v4.23.0** — `hipaa-validate` scanned a project without a manifest silently
14
+ clean: its language-tagged patterns never ran, and the report said `HIGH: 0`.
15
+ Zero reads as compliant. Detection now falls back to file extensions, the summary
16
+ says how the language was decided, and an undecidable run warns that a zero means
17
+ unscanned. Manifest-declaring projects are unaffected. Found by the post-release
18
+ SOP, not by a user.
19
+
20
+ ## What's New in v4.22.1
21
+
22
+ **v4.22.1** — nine skills, not four, could not find their own scripts. v4.22.0
23
+ grepped for one spelling of the bug and missed five more, a `.py` file run
24
+ through `bash`, and a script that crashed on `--help`. `validate.py` now fails
25
+ the build on the entire class, and the post-release SOP runs every skill's
26
+ documented command from the installed copy — which is how these were found.
10
27
 
11
28
  ## What's New in v4.22.0
12
29
 
@@ -3,7 +3,7 @@
3
3
  "name": "ai-toolkit",
4
4
  "displayName": "AI Toolkit",
5
5
  "description": "Professional-grade engineering skills, agents, rules, and lifecycle guardrails for Claude Code, Claude Chat, and Cowork.",
6
- "version": "4.22.0",
6
+ "version": "4.23.0",
7
7
  "author": {
8
8
  "name": "SoftSpark",
9
9
  "url": "https://github.com/softspark"
@@ -162,7 +162,7 @@ Modes change *prose*, not *data*. If you cut a fact to fit a length budget, you
162
162
 
163
163
  ### Measurement
164
164
 
165
- Run `python3 app/skills/brand-voice/scripts/measure.py --fixtures tests/fixtures/output-modes/` to compare baseline vs mode tokens on the fixture set. Report shows per-fixture deltas and an aggregate ratio.
165
+ Run `python3 ${CLAUDE_SKILL_DIR}/scripts/measure.py --fixtures tests/fixtures/output-modes/` to compare baseline vs mode tokens on the fixture set. Report shows per-fixture deltas and an aggregate ratio.
166
166
 
167
167
  ## When NOT to Load
168
168
 
@@ -27,7 +27,7 @@ Generate, update, or troubleshoot CI/CD pipeline configuration based on project
27
27
 
28
28
  Detect CI platform and analyze configuration:
29
29
  ```bash
30
- python3 scripts/ci-detect.py [directory]
30
+ python3 ${CLAUDE_SKILL_DIR}/scripts/ci-detect.py [directory]
31
31
  ```
32
32
 
33
33
  Returns JSON with:
@@ -28,7 +28,7 @@ Pipe error output through the error parser for structured diagnosis:
28
28
  your_command 2>&1 | python3 ${CLAUDE_SKILL_DIR}/scripts/error-parser.py
29
29
 
30
30
  # Or from a log file
31
- cat /var/log/app/error.log | python3 scripts/error-parser.py
31
+ cat /var/log/app/error.log | python3 ${CLAUDE_SKILL_DIR}/scripts/error-parser.py
32
32
  ```
33
33
 
34
34
  The script outputs JSON with:
@@ -50,7 +50,7 @@ Deploy application to target environment.
50
50
 
51
51
  Run automated pre-deployment checks:
52
52
  ```bash
53
- python3 scripts/pre_deploy_check.py [environment]
53
+ python3 ${CLAUDE_SKILL_DIR}/scripts/pre_deploy_check.py [environment]
54
54
  ```
55
55
 
56
56
  Returns JSON with pass/fail for each check:
@@ -99,7 +99,7 @@ It does NOT write or modify any files.
99
99
  For an interactive HTML tree visualization of the codebase:
100
100
 
101
101
  ```bash
102
- python ${CLAUDE_SKILL_DIR}/scripts/visualize.py .
102
+ python3 ${CLAUDE_SKILL_DIR}/scripts/visualize.py .
103
103
  ```
104
104
 
105
105
  This generates `codebase-map.html` with collapsible directories, file sizes, and type-colored indicators.
@@ -108,8 +108,22 @@ data.children.forEach(c=>render(c,document.getElementById('root')));
108
108
  </script></body></html>'''
109
109
  output.write_text(html)
110
110
 
111
+ USAGE = """usage: visualize.py [PATH]
112
+
113
+ Scan PATH (default: current directory) and write an interactive codebase map to
114
+ codebase-map.html, then open it in the default browser.
115
+ """
116
+
111
117
  if __name__ == '__main__':
112
- target = Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
118
+ if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help'):
119
+ print(USAGE)
120
+ raise SystemExit(0)
121
+
122
+ target = Path(sys.argv[1] if len(sys.argv) > 1 else '.')
123
+ if not target.is_dir():
124
+ print(f'{{"error": "Not a directory: {target}"}}')
125
+ raise SystemExit(1)
126
+ target = target.resolve()
113
127
  stats = {"files": 0, "dirs": 0, "extensions": Counter(), "ext_sizes": Counter()}
114
128
  data = scan(target, stats)
115
129
  out = Path('codebase-map.html')
@@ -27,8 +27,8 @@ Before entering the fix loop, classify errors to prioritize auto-fixable ones:
27
27
  ```bash
28
28
  # Pipe lint or test output
29
29
  ruff check . 2>&1 | python3 ${CLAUDE_SKILL_DIR}/scripts/error-classifier.py
30
- mypy src/ 2>&1 | python3 scripts/error-classifier.py
31
- npx eslint . 2>&1 | python3 scripts/error-classifier.py
30
+ mypy src/ 2>&1 | python3 ${CLAUDE_SKILL_DIR}/scripts/error-classifier.py
31
+ npx eslint . 2>&1 | python3 ${CLAUDE_SKILL_DIR}/scripts/error-classifier.py
32
32
  ```
33
33
 
34
34
  The script outputs JSON with:
@@ -161,6 +161,9 @@ This distinction helps compliance officers prioritize immediate remediation (def
161
161
 
162
162
  ## Gotchas
163
163
 
164
+ - **Check `language_detection` in the summary before trusting a zero.** Category 1, 3, 4 and 7 patterns are language-tagged and only fire for a detected language. Manifests decide first (`pyproject.toml`, `package.json`, `go.mod`, …); without one the scanner falls back to file extensions. If it reports `languages: ["any"]` with `language_detection: "none"`, the language rules never ran and `HIGH: 0` means *unscanned*, not *compliant* — the scanner prints that warning to stderr, so a run whose stderr is discarded loses it.
165
+ - Scanning a monorepo package or a subdirectory can put you below the manifest. The extension fallback covers the common case, but a directory of `.sql`, `.yaml` or templates resolves to no language at all — scan from the level that holds the manifest.
166
+
164
167
  - Test fixtures and seed data often contain **synthetic** PHI that looks real (SSN-shaped IDs, formatted phone numbers, sample email addresses). Flag them but lower severity — production code handling the same patterns is the actual risk.
165
168
  - HIPAA §164.312(b) requires audit logging but does not specify a format. "Logs exist" is not evidence of compliance — the logs must capture WHO (authenticated user), WHAT (action), WHEN (timestamp), WHERE (resource), and they must be immutable (append-only or write-once storage).
166
169
  - Encryption-at-rest varies silently by storage layer. RDS auto-encrypts new volumes since 2017, but older DB snapshots may not be; S3 bucket policies can override instance-level encryption. Treat "encryption enabled" as a claim to verify with the cloud provider, not a state to trust.
@@ -302,8 +302,24 @@ def context_gate(files: list[Path], keywords: list[str],
302
302
  # Language detection (Step 1)
303
303
  # ---------------------------------------------------------------------------
304
304
 
305
- def detect_languages(root: Path) -> set[str]:
306
- """Detect project languages from manifest files."""
305
+ def detect_languages(root: Path, files: list[Path] | None = None) -> tuple[set[str], str]:
306
+ """Detect project languages. Returns (languages, how_they_were_detected).
307
+
308
+ Manifest files decide when present. When none is found, fall back to the
309
+ extensions of the files actually being scanned.
310
+
311
+ The fallback exists because language-tagged patterns fire only when the
312
+ project language includes the tag, so a manifest-less directory of Python
313
+ scanned clean while every Python rule sat unused — and the report said
314
+ `HIGH: 0`, which reads as compliant rather than unscanned. Silence is the
315
+ worst possible output for a compliance tool. A monorepo package, a scan
316
+ pointed at a subdirectory, or a service whose manifest lives one level up
317
+ all hit this.
318
+
319
+ Manifests still win: a project that declares itself keeps exactly the
320
+ behaviour it had, so upgrading changes nothing for anyone who was already
321
+ being scanned properly.
322
+ """
307
323
  langs = set()
308
324
  for indicator, lang in LANGUAGE_INDICATORS.items():
309
325
  if "*" in indicator:
@@ -314,7 +330,20 @@ def detect_languages(root: Path) -> set[str]:
314
330
  else:
315
331
  if (root / indicator).exists():
316
332
  langs.add(lang)
317
- return langs or {"any"}
333
+ if langs:
334
+ return langs, "manifest"
335
+
336
+ if files:
337
+ ext_to_lang: dict[str, set[str]] = {}
338
+ for lang, exts in LANG_EXTENSIONS.items():
339
+ for ext in exts:
340
+ ext_to_lang.setdefault(ext, set()).add(lang)
341
+ for f in files:
342
+ langs |= ext_to_lang.get(f.suffix, set())
343
+ if langs:
344
+ return langs, "file extensions (no manifest found)"
345
+
346
+ return {"any"}, "none — language-tagged patterns were skipped"
318
347
 
319
348
 
320
349
  # ---------------------------------------------------------------------------
@@ -616,7 +645,8 @@ def format_text_report(findings: list[dict], mode: str,
616
645
 
617
646
  def format_json_report(findings: list[dict], mode: str,
618
647
  phi_count: int, scanned_count: int,
619
- categories_run: list[int], languages: list[str]) -> str:
648
+ categories_run: list[int], languages: list[str],
649
+ language_source: str = "manifest") -> str:
620
650
  """Format findings as structured JSON for CI integration."""
621
651
  high_count = sum(1 for f in findings if f["severity"] == "HIGH")
622
652
  warn_count = sum(1 for f in findings if f["severity"] == "WARN")
@@ -628,6 +658,7 @@ def format_json_report(findings: list[dict], mode: str,
628
658
  "files_scanned": scanned_count,
629
659
  "categories_run": sorted(categories_run),
630
660
  "languages": sorted(languages),
661
+ "language_detection": language_source,
631
662
  "high": high_count,
632
663
  "warn": warn_count,
633
664
  "total": len(findings),
@@ -723,8 +754,9 @@ def main() -> None:
723
754
  file=sys.stderr)
724
755
  print("Consider narrowing the scan path for targeted results.", file=sys.stderr)
725
756
 
726
- # Step 1: Detect languages
727
- languages = detect_languages(root)
757
+ # Step 1: Detect languages. Categories 1-2 scan everything, so the extension
758
+ # fallback looks at all collected files, not just the PHI-adjacent subset.
759
+ languages, lang_source = detect_languages(root, all_files)
728
760
 
729
761
  # Step 2: Run categories
730
762
  findings: list[dict] = []
@@ -789,10 +821,18 @@ def main() -> None:
789
821
  if args.output == "json":
790
822
  print(format_json_report(findings, args.mode, phi_count,
791
823
  scanned_count, categories_run,
792
- sorted(languages)))
824
+ sorted(languages), lang_source))
793
825
  else:
794
826
  print(format_text_report(findings, args.mode, phi_count,
795
827
  scanned_count, categories_run))
828
+ if languages == {"any"}:
829
+ # Never let an unscanned run read as a clean one.
830
+ print("\nWARNING: no project language identified "
831
+ f"({lang_source}).", file=sys.stderr)
832
+ print("Language-tagged patterns (Python, JS/TS, Go, Java, Ruby, C#) "
833
+ "did NOT run.", file=sys.stderr)
834
+ print("A zero-finding result here means unscanned, not compliant. "
835
+ "Scan from the directory holding the manifest.", file=sys.stderr)
796
836
 
797
837
  # Exit code: non-zero if HIGH findings
798
838
  high_count = sum(1 for f in findings if f["severity"] == "HIGH")
@@ -31,7 +31,7 @@ Create, run, or manage database migrations with auto-detection of the migration
31
31
 
32
32
  Detect migration tool and report status:
33
33
  ```bash
34
- python3 scripts/migration-status.py [directory]
34
+ python3 ${CLAUDE_SKILL_DIR}/scripts/migration-status.py [directory]
35
35
  ```
36
36
 
37
37
  Returns JSON with: `tool`, `config_file`, `migrations_dir`, `total_migrations`, `latest`, `commands{}` (status/create/upgrade/downgrade).
@@ -30,7 +30,7 @@ Generate a structured PR summary from the commit history before writing the PR d
30
30
  ```bash
31
31
  python3 ${CLAUDE_SKILL_DIR}/scripts/pr-summary.py [base_branch]
32
32
  # Default base branch: main
33
- # Example: python3 scripts/pr-summary.py develop
33
+ # Example: python3 ${CLAUDE_SKILL_DIR}/scripts/pr-summary.py develop
34
34
  ```
35
35
 
36
36
  The script outputs JSON with:
@@ -43,7 +43,7 @@ Before starting manual review, run the diff analyzer script to get a structured
43
43
  ```bash
44
44
  python3 ${CLAUDE_SKILL_DIR}/scripts/diff-analyzer.py [base_branch]
45
45
  # Default base branch: main
46
- # Example: python3 scripts/diff-analyzer.py develop
46
+ # Example: python3 ${CLAUDE_SKILL_DIR}/scripts/diff-analyzer.py develop
47
47
  ```
48
48
 
49
49
  The script outputs JSON with:
@@ -59,7 +59,7 @@ git reset --soft <commit-sha> # Keep changes staged
59
59
 
60
60
  Run the rollback info script to assess current state before rolling back:
61
61
  ```bash
62
- bash scripts/rollback_info.py
62
+ python3 ${CLAUDE_SKILL_DIR}/scripts/rollback_info.py
63
63
  ```
64
64
 
65
65
  Returns JSON with:
@@ -3,10 +3,10 @@ title: "SOP: Post-Release Testing"
3
3
  category: procedures
4
4
  service: ai-toolkit
5
5
  tags: [sop, post-release, smoke-test, npm, sandbox, plugin-pack, provenance, isolation]
6
- version: "1.0.0"
6
+ version: "1.1.0"
7
7
  created: "2026-07-26"
8
- last_updated: "2026-07-26"
9
- description: "Smoke-test a published @softspark/ai-toolkit release from npm in an isolated HOME and npm prefix, without touching the maintainer's real install. Covers provenance, CLI, doctor, and the full plugin-pack lifecycle including the degraded-install path. Written for v4.18.0 and not run; v4.18.0 shipped a pack that broke every command it touched, and every step here would have caught it."
8
+ last_updated: "2026-08-06"
9
+ description: "Smoke-test a published @softspark/ai-toolkit release from npm in an isolated HOME and npm prefix, without touching the maintainer's real install. Covers provenance, CLI, doctor, per-skill script resolution, scanner wiring, and the full plugin-pack lifecycle including the degraded-install path. Written for v4.18.0 and not run; v4.18.0 shipped a pack that broke every command it touched. First actually run on v4.22.0, which added Phases 4b and 4c after that release fixed four skills whose documented script path had never resolved and two that shipped a scanner nothing invoked."
10
10
  ---
11
11
 
12
12
  # SOP: Post-Release Testing
@@ -92,6 +92,95 @@ npm install -g --prefix "$SB/npm" "@softspark/ai-toolkit@${VERSION}"
92
92
  `skills directory missing`.** That is the sandbox being empty, not a defect.
93
93
  Install first, then judge doctor.
94
94
 
95
+ ## Phase 4b: Skill scripts resolve and run from the installed copy
96
+
97
+ 23 skills ship an executable script under `scripts/`. A skill body invokes it
98
+ through `${CLAUDE_SKILL_DIR}`, which only resolves once the skill is installed —
99
+ so a wrong path is invisible in the working tree and invisible to `validate.py`,
100
+ which checks that the file exists on disk, never that the documented command
101
+ finds it.
102
+
103
+ This is exactly how four skills shipped with `$(dirname "$0")`, which expands to
104
+ the shell's directory rather than the skill's. Every one of them had been broken
105
+ for as long as the line existed.
106
+
107
+ Iterate over every skill that ships a script. Do not hand-pick the ones the
108
+ release touched — the point of this phase is to catch the ones nobody remembered.
109
+
110
+ ```bash
111
+ for D in "$HOME"/.claude/skills/*/; do
112
+ s=$(basename "$D")
113
+ [ -d "$D/scripts" ] || continue
114
+ # Match ANY interpreter and ANY extension. Narrowing this to `python3` and
115
+ # `.py` is how the first version of this phase reported "no invocation" for a
116
+ # skill that used `python`, and missed one that ran a .py file through bash.
117
+ ref=$(grep -ohE '(python3?|bash|sh|node) +\$\{CLAUDE_SKILL_DIR\}/scripts/[A-Za-z0-9_.-]+' "$D/SKILL.md" | head -1)
118
+ [ -n "$ref" ] || { printf '%-22s NO ${CLAUDE_SKILL_DIR} INVOCATION\n' "$s"; continue; }
119
+ interp=${ref%% *}
120
+ rel=${ref##*\$\{CLAUDE_SKILL_DIR\}/}
121
+ printf '%-22s %-10s %-26s ' "$s" "$interp" "$rel"
122
+ [ -f "$D/$rel" ] || { echo 'PATH DOES NOT RESOLVE'; continue; }
123
+ out=$(CLAUDE_SKILL_DIR="$D" timeout 20 "$interp" "$D/$rel" --help </dev/null 2>&1 | head -1)
124
+ printf 'rc=%s %s\n' "$?" "$(echo "$out" | cut -c1-40)"
125
+ done
126
+ ```
127
+
128
+ **Verify:**
129
+ - [ ] Every skill with a `scripts/` directory has a documented invocation
130
+ - [ ] Every documented path resolves to a file that exists
131
+ - [ ] Every script exits without a traceback and without hanging
132
+ - [ ] The interpreter matches the file: no `.py` through `bash`, no bare `python`
133
+
134
+ A skill reported as `NO ${CLAUDE_SKILL_DIR} INVOCATION` is not automatically a
135
+ defect — some ship assets rather than executables (`write-a-prd` ships `.html`,
136
+ `.js` and `.cjs`). Read the skill before filing it. What *is* always a defect is
137
+ an invocation that names a skill-owned script through any other path.
138
+
139
+ `validate.py` now fails the build on that class (`_validate_skill_script_invocations`),
140
+ so this phase is the second line rather than the first. Keep it: the validator
141
+ reasons about the source, this runs the real thing.
142
+
143
+ **`</dev/null` and `timeout` are not defensive padding.** Several of these
144
+ scripts are stdin filters (`error-parser.py`, `error-classifier.py`) documented
145
+ as `command 2>&1 | python3 …`. Probing one with `--help` and an open stdin
146
+ blocks forever, and the run looks like a slow test rather than a hung one. A
147
+ correct stdin filter answers an empty stdin with a JSON error and `rc=0`; a
148
+ traceback there is a real finding.
149
+
150
+ ## Phase 4c: Skills that wrap a scanner actually scan
151
+
152
+ For any skill whose body tells the model to run a scanner, presence of the script
153
+ is not evidence the wiring works. Build a fixture with known defects and confirm
154
+ the scanner reports them.
155
+
156
+ ```bash
157
+ FX="$SB/fixture"; mkdir -p "$FX"
158
+ cat > "$FX/index.html" <<'EOF'
159
+ <!DOCTYPE html><html><head><title>t</title></head>
160
+ <body><h1>A</h1><h3>skipped h2</h3><img src="x.png"><input type="text"><div onclick="go()">click</div></body></html>
161
+ EOF
162
+ printf '{"name":"fx","dependencies":{"react":"18"}}\n' > "$FX/package.json"
163
+
164
+ for s in a11y-validate seo-validate hipaa-validate cve-scan; do
165
+ D="$HOME/.claude/skills/$s"
166
+ script=$(ls "$D"/scripts/*.py 2>/dev/null | head -1)
167
+ [ -n "$script" ] || continue
168
+ echo "=== $s ==="
169
+ CLAUDE_SKILL_DIR="$D" timeout 60 python3 "$script" "$FX" --output json </dev/null 2>&1 | head -3
170
+ done
171
+ ```
172
+
173
+ **Verify:**
174
+ - [ ] The scanner returns findings, not an empty set — the fixture has real defects
175
+ - [ ] Findings span more than one category, proving the whole check set ran
176
+ - [ ] A scanner that exits non-zero on findings is doing its job, not failing
177
+
178
+ `a11y-validate` and `seo-validate` shipped working scanners that **no step in
179
+ either skill invoked** for their entire life before v4.22.0. The model was told to
180
+ grep the pattern tables by hand instead. Nothing in the test suite noticed,
181
+ because a script nobody calls still passes every check that asks whether it
182
+ exists.
183
+
95
184
  ## Phase 5: Plugin-pack lifecycle
96
185
 
97
186
  Run this for any pack the release touched. For a pack that downloads a binary,
package/llms-full.txt CHANGED
@@ -7019,10 +7019,10 @@ title: "SOP: Post-Release Testing"
7019
7019
  category: procedures
7020
7020
  service: ai-toolkit
7021
7021
  tags: [sop, post-release, smoke-test, npm, sandbox, plugin-pack, provenance, isolation]
7022
- version: "1.0.0"
7022
+ version: "1.1.0"
7023
7023
  created: "2026-07-26"
7024
- last_updated: "2026-07-26"
7025
- description: "Smoke-test a published @softspark/ai-toolkit release from npm in an isolated HOME and npm prefix, without touching the maintainer's real install. Covers provenance, CLI, doctor, and the full plugin-pack lifecycle including the degraded-install path. Written for v4.18.0 and not run; v4.18.0 shipped a pack that broke every command it touched, and every step here would have caught it."
7024
+ last_updated: "2026-08-06"
7025
+ description: "Smoke-test a published @softspark/ai-toolkit release from npm in an isolated HOME and npm prefix, without touching the maintainer's real install. Covers provenance, CLI, doctor, per-skill script resolution, scanner wiring, and the full plugin-pack lifecycle including the degraded-install path. Written for v4.18.0 and not run; v4.18.0 shipped a pack that broke every command it touched. First actually run on v4.22.0, which added Phases 4b and 4c after that release fixed four skills whose documented script path had never resolved and two that shipped a scanner nothing invoked."
7026
7026
  ---
7027
7027
 
7028
7028
  # SOP: Post-Release Testing
@@ -7108,6 +7108,95 @@ npm install -g --prefix "$SB/npm" "@softspark/ai-toolkit@${VERSION}"
7108
7108
  `skills directory missing`.** That is the sandbox being empty, not a defect.
7109
7109
  Install first, then judge doctor.
7110
7110
 
7111
+ ## Phase 4b: Skill scripts resolve and run from the installed copy
7112
+
7113
+ 23 skills ship an executable script under `scripts/`. A skill body invokes it
7114
+ through `${CLAUDE_SKILL_DIR}`, which only resolves once the skill is installed —
7115
+ so a wrong path is invisible in the working tree and invisible to `validate.py`,
7116
+ which checks that the file exists on disk, never that the documented command
7117
+ finds it.
7118
+
7119
+ This is exactly how four skills shipped with `$(dirname "$0")`, which expands to
7120
+ the shell's directory rather than the skill's. Every one of them had been broken
7121
+ for as long as the line existed.
7122
+
7123
+ Iterate over every skill that ships a script. Do not hand-pick the ones the
7124
+ release touched — the point of this phase is to catch the ones nobody remembered.
7125
+
7126
+ ```bash
7127
+ for D in "$HOME"/.claude/skills/*/; do
7128
+ s=$(basename "$D")
7129
+ [ -d "$D/scripts" ] || continue
7130
+ # Match ANY interpreter and ANY extension. Narrowing this to `python3` and
7131
+ # `.py` is how the first version of this phase reported "no invocation" for a
7132
+ # skill that used `python`, and missed one that ran a .py file through bash.
7133
+ ref=$(grep -ohE '(python3?|bash|sh|node) +\$\{CLAUDE_SKILL_DIR\}/scripts/[A-Za-z0-9_.-]+' "$D/SKILL.md" | head -1)
7134
+ [ -n "$ref" ] || { printf '%-22s NO ${CLAUDE_SKILL_DIR} INVOCATION\n' "$s"; continue; }
7135
+ interp=${ref%% *}
7136
+ rel=${ref##*\$\{CLAUDE_SKILL_DIR\}/}
7137
+ printf '%-22s %-10s %-26s ' "$s" "$interp" "$rel"
7138
+ [ -f "$D/$rel" ] || { echo 'PATH DOES NOT RESOLVE'; continue; }
7139
+ out=$(CLAUDE_SKILL_DIR="$D" timeout 20 "$interp" "$D/$rel" --help </dev/null 2>&1 | head -1)
7140
+ printf 'rc=%s %s\n' "$?" "$(echo "$out" | cut -c1-40)"
7141
+ done
7142
+ ```
7143
+
7144
+ **Verify:**
7145
+ - [ ] Every skill with a `scripts/` directory has a documented invocation
7146
+ - [ ] Every documented path resolves to a file that exists
7147
+ - [ ] Every script exits without a traceback and without hanging
7148
+ - [ ] The interpreter matches the file: no `.py` through `bash`, no bare `python`
7149
+
7150
+ A skill reported as `NO ${CLAUDE_SKILL_DIR} INVOCATION` is not automatically a
7151
+ defect — some ship assets rather than executables (`write-a-prd` ships `.html`,
7152
+ `.js` and `.cjs`). Read the skill before filing it. What *is* always a defect is
7153
+ an invocation that names a skill-owned script through any other path.
7154
+
7155
+ `validate.py` now fails the build on that class (`_validate_skill_script_invocations`),
7156
+ so this phase is the second line rather than the first. Keep it: the validator
7157
+ reasons about the source, this runs the real thing.
7158
+
7159
+ **`</dev/null` and `timeout` are not defensive padding.** Several of these
7160
+ scripts are stdin filters (`error-parser.py`, `error-classifier.py`) documented
7161
+ as `command 2>&1 | python3 …`. Probing one with `--help` and an open stdin
7162
+ blocks forever, and the run looks like a slow test rather than a hung one. A
7163
+ correct stdin filter answers an empty stdin with a JSON error and `rc=0`; a
7164
+ traceback there is a real finding.
7165
+
7166
+ ## Phase 4c: Skills that wrap a scanner actually scan
7167
+
7168
+ For any skill whose body tells the model to run a scanner, presence of the script
7169
+ is not evidence the wiring works. Build a fixture with known defects and confirm
7170
+ the scanner reports them.
7171
+
7172
+ ```bash
7173
+ FX="$SB/fixture"; mkdir -p "$FX"
7174
+ cat > "$FX/index.html" <<'EOF'
7175
+ <!DOCTYPE html><html><head><title>t</title></head>
7176
+ <body><h1>A</h1><h3>skipped h2</h3><img src="x.png"><input type="text"><div onclick="go()">click</div></body></html>
7177
+ EOF
7178
+ printf '{"name":"fx","dependencies":{"react":"18"}}\n' > "$FX/package.json"
7179
+
7180
+ for s in a11y-validate seo-validate hipaa-validate cve-scan; do
7181
+ D="$HOME/.claude/skills/$s"
7182
+ script=$(ls "$D"/scripts/*.py 2>/dev/null | head -1)
7183
+ [ -n "$script" ] || continue
7184
+ echo "=== $s ==="
7185
+ CLAUDE_SKILL_DIR="$D" timeout 60 python3 "$script" "$FX" --output json </dev/null 2>&1 | head -3
7186
+ done
7187
+ ```
7188
+
7189
+ **Verify:**
7190
+ - [ ] The scanner returns findings, not an empty set — the fixture has real defects
7191
+ - [ ] Findings span more than one category, proving the whole check set ran
7192
+ - [ ] A scanner that exits non-zero on findings is doing its job, not failing
7193
+
7194
+ `a11y-validate` and `seo-validate` shipped working scanners that **no step in
7195
+ either skill invoked** for their entire life before v4.22.0. The model was told to
7196
+ grep the pattern tables by hand instead. Nothing in the test suite noticed,
7197
+ because a script nobody calls still passes every check that asks whether it
7198
+ exists.
7199
+
7111
7200
  ## Phase 5: Plugin-pack lifecycle
7112
7201
 
7113
7202
  Run this for any pack the release touched. For a pack that downloads a binary,
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.22.0",
2
+ "version": "4.23.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.22.0",
3
+ "version": "4.23.0",
4
4
  "description": "AI coding toolkit: 109 skills, 44 agents, 12 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -379,6 +379,71 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
379
379
  vr.warn(f"{name} - Description exceeds 1024 characters")
380
380
 
381
381
 
382
+ def _validate_skill_script_invocations(skill_path: Path, vr: ValidationResult) -> None:
383
+ """Every documented run of a skill-owned script must resolve after install.
384
+
385
+ A skill's own scripts live beside it and are reachable only through
386
+ `${CLAUDE_SKILL_DIR}`. A relative path such as `python3 scripts/foo.py`
387
+ resolves against the user's working directory, and a repo-relative one such
388
+ as `app/skills/<name>/scripts/foo.py` against a tree the user does not have.
389
+ Both look correct in the source and fail for every installed user.
390
+
391
+ Nine skills shipped with one of those forms before this check existed.
392
+ `_validate_skill_references` never caught them because it asks whether the
393
+ file is on disk, not whether the documented command can find it.
394
+
395
+ Only executable invocations are checked. The frontmatter `scripts:` list
396
+ declares ownership and is correctly relative; prose that names a script in
397
+ backticks runs nothing.
398
+ """
399
+ name = skill_path.name
400
+ scripts_dir = skill_path / "scripts"
401
+ if not scripts_dir.is_dir():
402
+ return
403
+ owned = {p.name for p in scripts_dir.iterdir() if p.is_file()}
404
+ if not owned:
405
+ return
406
+
407
+ skill_file = skill_path / "SKILL.md"
408
+ text = skill_file.read_text(encoding="utf-8")
409
+
410
+ # Skip the frontmatter block: `scripts:` entries there are declarations.
411
+ lines = text.splitlines()
412
+ start = 0
413
+ if lines and lines[0].strip() == "---":
414
+ for idx in range(1, len(lines)):
415
+ if lines[idx].strip() == "---":
416
+ start = idx + 1
417
+ break
418
+
419
+ interpreters = {"python3": {".py"}, "python": {".py"}, "bash": {".sh"}, "sh": {".sh"}}
420
+ invocation = re.compile(r"\b(python3|python|bash|sh)\s+(\S+)")
421
+
422
+ for offset, line in enumerate(lines[start:], start=start + 1):
423
+ for interp, target in invocation.findall(line):
424
+ base = target.rsplit("/", 1)[-1].strip("\"'`")
425
+ if base not in owned:
426
+ continue # a repo-level script such as scripts/validate.py
427
+ if "${CLAUDE_SKILL_DIR}" not in target:
428
+ vr.error(
429
+ f"skills/{name}/SKILL.md:{offset}: runs its own '{base}' via "
430
+ f"'{target}' - use ${{CLAUDE_SKILL_DIR}}/scripts/{base}, which "
431
+ f"is the only path that resolves after install"
432
+ )
433
+ suffix = base[base.rfind("."):] if "." in base else ""
434
+ allowed = interpreters.get(interp, set())
435
+ if suffix and allowed and suffix not in allowed:
436
+ vr.error(
437
+ f"skills/{name}/SKILL.md:{offset}: runs '{base}' with "
438
+ f"'{interp}' - wrong interpreter for a {suffix} file"
439
+ )
440
+ elif interp == "python":
441
+ vr.warn(
442
+ f"skills/{name}/SKILL.md:{offset}: uses 'python' for '{base}' "
443
+ f"- prefer 'python3', 'python' is absent or Python 2 on many systems"
444
+ )
445
+
446
+
382
447
  def _validate_skill_references(tk_dir: Path, skill_path: Path,
383
448
  fm_lines: list[str], vr: ValidationResult) -> None:
384
449
  """Validate agent refs, depends-on, context/agent co-occurrence, and reference links."""
@@ -412,6 +477,8 @@ def _validate_skill_references(tk_dir: Path, skill_path: Path,
412
477
  if _fm_has(fm_lines, "run-mode"):
413
478
  vr.warn(f"{name} - Uses deprecated 'run-mode' field (rename to 'context:')")
414
479
 
480
+ _validate_skill_script_invocations(skill_path, vr)
481
+
415
482
  ref_dir = skill_path / "reference"
416
483
  if ref_dir.is_dir():
417
484
  content = skill_file.read_text(encoding="utf-8")