@softspark/ai-toolkit 3.3.0 → 3.4.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,25 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v3.4.0 — Local-path source tracking & orphan detection (2026-05-06)
11
+
12
+ Minor release. Closes the visibility gap for rules and hooks installed from local files: `add-rule` and `inject-hook` now record the origin path in `sources.json`, and `status` flags any rule files on disk that have no source recorded.
13
+
14
+ ### Added
15
+
16
+ - **`register_path_source()` in `scripts/rule_sources.py` and `scripts/hook_sources.py`** — records `{"path": <abs_path>, "fetched_at": ..., "sha256": ...}` for local-file injections. Schema-compatible with existing URL entries (mutually exclusive `url` vs `path` field, `schema_version` unchanged at 1).
17
+ - **`add-rule` registers local paths** — `scripts/add_rule.py` now calls `register_path_source` after copying a local file, and prints the origin path with a hint to re-run `add-rule` to refresh.
18
+ - **`inject-hook` registers local paths** — `scripts/inject_hook_cli.py` writes a `path`-tracked entry into `~/.softspark/ai-toolkit/hooks/external/sources.json` for file-sourced hooks. URL behavior unchanged. Registration failures are non-fatal — injection always proceeds.
19
+ - **`ai-toolkit status` distinguishes URL vs local sources** — local-path entries display the absolute path with a `[local]` tag; URL entries display the URL. Format: `rule <name> <- <origin>[ [local]] (<fetched_at>)`.
20
+ - **Orphan rule detection in `status`** — files in `~/.softspark/ai-toolkit/rules/` that have no entry in `sources.json` are listed as `(orphan, no source recorded — re-run add-rule)`. Surfaces rules registered before the registry existed (pre-v3.3.0).
21
+
22
+ ### Migration
23
+
24
+ - Existing URL-tracked sources continue to work unchanged.
25
+ - To upgrade an orphan local rule into the registry, re-run `ai-toolkit add-rule <path>` from the rule's source directory. The new run records the path.
26
+
27
+ ---
28
+
10
29
  ## v3.3.0 — External-source visibility & skill-listing budget default (2026-05-06)
11
30
 
12
31
  Minor release. Two coordinated install-time additions: `ai-toolkit status` now surfaces every registered external rule and hook, and fresh installs / updates set a sane `skillListingBudgetFraction` so all 112 skill descriptions stay loadable in Claude Code.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-112-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-1042%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-1046%20passing-success)](tests/)
10
10
 
11
11
  <p align="center">
12
12
  <img src="assets/ai-toolkit-readme-hero.png" alt="ai-toolkit 3 README hero showing one toolkit for 12 AI editors" width="900">
@@ -14,13 +14,13 @@
14
14
 
15
15
  ---
16
16
 
17
- ## What's New in v3.3.0
17
+ ## What's New in v3.4.0
18
18
 
19
- Minor release. Two install-time additions: visibility for external rules/hooks and a sane default for Claude Code's skill listing budget.
19
+ Minor release. Closes the visibility gap for rules and hooks installed from local files.
20
20
 
21
- - **`ai-toolkit status` lists external sources** — every rule and hook registered via `add-rule` / `inject-hook` is now visible in `status` output with its source URL and last-fetched timestamp. No more grepping `~/.softspark/ai-toolkit/*/sources.json` by hand.
22
- - **`skillListingBudgetFraction = 0.02` set by default** — install/update writes the key into `~/.claude/settings.json` only when missing, so all 112 skill descriptions stay loadable in Claude Code without truncation. User-set values are preserved.
23
- - **Updated `status` help text**`bin/ai-toolkit.js` now mentions external rules/hooks alongside modules, version, and profile.
21
+ - **`add-rule` and `inject-hook` now track local paths** — file-sourced rules and hooks record `{"path": ..., "sha256": ...}` in `sources.json`, alongside the existing URL entries. URL flow unchanged.
22
+ - **`status` distinguishes URL vs local sources** — local entries show the absolute origin path with a `[local]` tag; URL entries show the URL.
23
+ - **Orphan rule detection** — rule files on disk that have no source recorded (e.g., installed before v3.3.0's registry existed) are listed in `status` with `(orphan, no source recorded re-run add-rule)`. Run `add-rule <path>` again to register them.
24
24
 
25
25
  See [CHANGELOG.md](CHANGELOG.md) for full history.
26
26
 
@@ -153,7 +153,7 @@ ai-toolkit/
153
153
  │ └── ARCHITECTURE.md # Full system design
154
154
  ├── kb/ # Reference docs, procedures, plans
155
155
  ├── scripts/ # Validation, install, evaluation scripts
156
- ├── tests/ # Bats test suite (1042 tests)
156
+ ├── tests/ # Bats test suite (1046 tests)
157
157
  └── CHANGELOG.md
158
158
  ```
159
159
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-toolkit",
3
3
  "description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
4
- "version": "3.3.0",
4
+ "version": "3.4.0",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.3.0",
2
+ "version": "3.4.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": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "AI coding toolkit: 112 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -87,7 +87,11 @@ def main() -> None:
87
87
  dest = rules_dir / f"{rule_name}.md"
88
88
  shutil.copy2(rule_file, dest)
89
89
 
90
+ from rule_sources import register_path_source
91
+ register_path_source(rules_dir, rule_name, rule_file, content=dest.read_bytes())
92
+
90
93
  print(f"Registered: '{rule_name}' -> {dest}")
94
+ print(f"Source path: {rule_file.resolve()} (re-run add-rule to refresh)")
91
95
 
92
96
  print()
93
97
  print("Apply now:")
@@ -119,6 +119,32 @@ def register_url_source(
119
119
  save_sources(hooks_dir, sources)
120
120
 
121
121
 
122
+ def register_path_source(
123
+ hooks_dir: Path | None,
124
+ hook_name: str,
125
+ path: Path,
126
+ content: bytes | None = None,
127
+ ) -> None:
128
+ """Add or update a local-file source entry for a hook.
129
+
130
+ Stores the absolute origin path so subsequent ``ai-toolkit update`` runs
131
+ can detect drift, plus a sha256 of the injected content.
132
+ """
133
+ import re
134
+ if not hook_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", hook_name):
135
+ raise ValueError(f"Invalid hook name: {hook_name!r}")
136
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
137
+ sources = load_sources(hooks_dir)
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[hook_name] = entry
145
+ save_sources(hooks_dir, sources)
146
+
147
+
122
148
  def unregister_source(hooks_dir: Path | None, hook_name: str) -> bool:
123
149
  """Remove a source entry. Returns True if found and removed."""
124
150
  hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
@@ -377,6 +377,18 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
377
377
  save_json(str(settings_path), settings)
378
378
  print(f"Injected hooks from '{source}' into {settings_path}")
379
379
 
380
+ # Register local-file source so `status` and future updates can see it.
381
+ # URL sources are already registered earlier inside _fetch_and_cache().
382
+ if not is_url:
383
+ try:
384
+ from hook_sources import register_path_source
385
+ register_path_source(
386
+ None, source, Path(hooks_file),
387
+ content=Path(hooks_file).read_bytes(),
388
+ )
389
+ except Exception as exc: # registration failure must not block injection
390
+ print(f"Warning: could not register local source: {exc}", file=sys.stderr)
391
+
380
392
  # Propagate Codex-compatible events to ~/.codex/hooks.json
381
393
  _inject_codex(tagged, source, target_dir)
382
394
 
@@ -10,8 +10,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
10
10
  from paths import TOOLKIT_DATA_DIR, STATE_FILE, RULES_DIR, EXTERNAL_HOOKS_DIR
11
11
 
12
12
 
13
- def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str]]:
14
- """Read a sources.json registry and return (name, url, fetched_at) tuples."""
13
+ def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str, str]]:
14
+ """Read a sources.json registry.
15
+
16
+ Returns a list of ``(name, origin, fetched_at, kind)`` tuples where
17
+ ``kind`` is ``"url"`` for remote sources, ``"local"`` for path-tracked
18
+ sources, and ``"unknown"`` if neither field is present.
19
+ """
15
20
  if not sources_file.is_file():
16
21
  return []
17
22
  try:
@@ -22,14 +27,30 @@ def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str]]:
22
27
  entries = data.get(key, {}) if isinstance(data, dict) else {}
23
28
  if not isinstance(entries, dict):
24
29
  return []
25
- out: list[tuple[str, str, str]] = []
30
+ out: list[tuple[str, str, str, str]] = []
26
31
  for name, meta in sorted(entries.items()):
27
32
  if not isinstance(meta, dict):
28
33
  continue
29
- out.append((name, str(meta.get("url", "")), str(meta.get("fetched_at", ""))))
34
+ if "url" in meta:
35
+ origin, kind = str(meta["url"]), "url"
36
+ elif "path" in meta:
37
+ origin, kind = str(meta["path"]), "local"
38
+ else:
39
+ origin, kind = "", "unknown"
40
+ out.append((name, origin, str(meta.get("fetched_at", "")), kind))
30
41
  return out
31
42
 
32
43
 
44
+ def _orphan_rule_files(rules_dir: Path, registered: set[str]) -> list[str]:
45
+ """Return rule names present on disk but missing from sources.json."""
46
+ if not rules_dir.is_dir():
47
+ return []
48
+ return sorted(
49
+ f.stem for f in rules_dir.glob("*.md")
50
+ if f.stem not in registered
51
+ )
52
+
53
+
33
54
  def _state_path() -> Path:
34
55
  """Return the canonical path to state.json."""
35
56
  return STATE_FILE
@@ -226,15 +247,20 @@ def print_status() -> None:
226
247
 
227
248
  ext_rules = _load_sources(RULES_DIR / "sources.json", "rules")
228
249
  ext_hooks = _load_sources(EXTERNAL_HOOKS_DIR / "sources.json", "hooks")
229
- if ext_rules or ext_hooks:
250
+ rule_orphans = _orphan_rule_files(RULES_DIR, {n for n, _, _, _ in ext_rules})
251
+ if ext_rules or ext_hooks or rule_orphans:
230
252
  print()
231
253
  print(" External sources:")
232
- for name, url, fetched_at in ext_rules:
254
+ for name, origin, fetched_at, kind in ext_rules:
255
+ tag = " [local]" if kind == "local" else ""
233
256
  stamp = f" ({fetched_at})" if fetched_at else ""
234
- print(f" rule {name} <- {url}{stamp}")
235
- for name, url, fetched_at in ext_hooks:
257
+ print(f" rule {name} <- {origin}{tag}{stamp}")
258
+ for name in rule_orphans:
259
+ print(f" rule {name} <- (orphan, no source recorded — re-run add-rule)")
260
+ for name, origin, fetched_at, kind in ext_hooks:
261
+ tag = " [local]" if kind == "local" else ""
236
262
  stamp = f" ({fetched_at})" if fetched_at else ""
237
- print(f" hook {name} <- {url}{stamp}")
263
+ print(f" hook {name} <- {origin}{tag}{stamp}")
238
264
 
239
265
  extends = state.get("extends")
240
266
  if extends:
@@ -120,6 +120,32 @@ def register_url_source(
120
120
  save_sources(rules_dir, sources)
121
121
 
122
122
 
123
+ def register_path_source(
124
+ rules_dir: Path | None,
125
+ rule_name: str,
126
+ path: Path,
127
+ content: bytes | None = None,
128
+ ) -> None:
129
+ """Add or update a local-file source entry.
130
+
131
+ Stores the absolute origin path (so future ``add-rule`` re-runs from the
132
+ same project register cleanly) plus a sha256 of the current content.
133
+ """
134
+ import re
135
+ if not rule_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", rule_name):
136
+ raise ValueError(f"Invalid rule name: {rule_name!r}")
137
+ rules_dir = rules_dir or RULES_DIR
138
+ sources = load_sources(rules_dir)
139
+ entry: dict[str, Any] = {
140
+ "path": str(Path(path).resolve()),
141
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
142
+ }
143
+ if content is not None:
144
+ entry["sha256"] = hashlib.sha256(content).hexdigest()
145
+ sources[rule_name] = entry
146
+ save_sources(rules_dir, sources)
147
+
148
+
123
149
  def unregister_source(rules_dir: Path | None, rule_name: str) -> bool:
124
150
  """Remove a source entry. Returns True if found and removed."""
125
151
  rules_dir = rules_dir or RULES_DIR