@softspark/ai-toolkit 2.1.3 → 2.2.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/AGENTS.md CHANGED
@@ -578,28 +578,98 @@ Derived from the immutable safety constitution (5 articles):
578
578
  - **No Secrets in Code**: Never commit credentials, API keys, or sensitive configuration values
579
579
  <!-- TOOLKIT:ai-toolkit END -->
580
580
 
581
- <!-- TOOLKIT:jira-rules START -->
581
+ <!-- TOOLKIT:jira-mcp START -->
582
582
  <!-- Auto-injected by ai-toolkit. Re-run to update. -->
583
583
 
584
584
  # Jira MCP Server
585
585
 
586
- Tools: `sync_tasks`, `read_cached_tasks`, `update_task_status`, `add_task_comment`, `reassign_task`, `get_task_statuses`, `get_task_details`, `log_task_time`, `get_task_time_tracking`
586
+ Tools: `sync_tasks`, `read_cached_tasks`, `update_task_status`, `update_task`, `add_task_comment`, `reassign_task`, `get_task_statuses`, `get_task_details`, `get_project_language`, `log_task_time`, `get_task_time_tracking`, `list_comment_templates`, `add_templated_comment`, `create_task`, `search_tasks`
587
587
 
588
588
  ## Key Rules
589
589
 
590
- - **Always `sync_tasks` first** before reading cache may be stale.
591
- - **Time format:** `"2h 30m"` hours and minutes only, never days.
590
+ - **Always `sync_tasks` first** before reading -- cache may be stale.
591
+ - **Language first:** before writing ANY comment, description, or task content, call `get_project_language(project_key)` or check the `language` field in `get_task_details` response. Write ALL content in the project's configured language. Never assume Polish or English — always check first.
592
+ - **Time format:** `"2h 30m"` -- hours and minutes only, never days.
592
593
  - **Status changes:** call `get_task_statuses` first to check valid transitions.
593
594
  - **Multi-instance:** project key determines which Jira instance is used (mapped in config.json).
595
+ - **Comments are ADF:** `add_task_comment` converts markdown to ADF (Atlassian Document Format) automatically.
596
+ - **Templates:** use `list_comment_templates` to discover available templates, then `add_templated_comment` with `template_id` + `variables`.
594
597
 
595
598
  ## Workflow
596
599
 
597
- 1. `sync_tasks(jql="assignee=currentUser() AND status!=Done")` fetch fresh
598
- 2. `read_cached_tasks()` work offline
599
- 3. `get_task_details(task_key="PROJ-123")` deep dive
600
- 4. `update_task_status(...)` / `add_task_comment(...)` / `log_task_time(...)` mutate
601
-
602
- <!-- TOOLKIT:jira-rules END -->
600
+ 1. `sync_tasks(jql="assignee=currentUser() AND status!=Done")` -- fetch fresh
601
+ 2. `read_cached_tasks()` -- work offline
602
+ 3. `get_task_details(task_key="PROJ-123")` -- deep dive (description + comments as markdown)
603
+ 4. `update_task_status(...)` / `add_task_comment(...)` / `log_task_time(...)` -- mutate
604
+
605
+ ## Comment Templates (8 built-in)
606
+
607
+ | ID | Use for |
608
+ |----|---------|
609
+ | `status-update` | Progress report with completed/next/blockers |
610
+ | `blocker-notification` | Escalate blocking issue |
611
+ | `handoff-transition` | Task handoff between people |
612
+ | `review-request` | Request code review |
613
+ | `sprint-update` | Sprint progress report |
614
+ | `bug-report` | Structured bug report |
615
+ | `deployment-note` | Deployment documentation |
616
+ | `time-log-summary` | Time logging with description |
617
+
618
+ ## CLI Commands
619
+
620
+ | Command | Description |
621
+ |---------|-------------|
622
+ | `jira-mcp config init` | Initialize global config (~/.softspark/jira-mcp/) |
623
+ | `jira-mcp config add-project <key> <url>` | Add Jira project mapping |
624
+ | `jira-mcp config remove-project <key>` | Remove a project |
625
+ | `jira-mcp config list-projects` | Show configured projects with language |
626
+ | `jira-mcp config set-default <key>` | Set default project |
627
+ | `jira-mcp config set-credentials` | Set API credentials |
628
+ | `jira-mcp config set-language <lang>` | Set global default language |
629
+ | `jira-mcp config set-project-language <key> <lang>` | Set language for a specific project |
630
+ | `jira-mcp create <path>` | Create tasks from template (dry-run default) |
631
+ | `jira-mcp create-monthly` | Create monthly admin tasks |
632
+ | `jira-mcp cache sync-users` | Cache user list for reassignment |
633
+ | `jira-mcp cache sync-workflows` | Cache status transitions |
634
+ | `jira-mcp cache list-users` | Show cached users |
635
+ | `jira-mcp cache list-workflows` | Show cached workflows |
636
+
637
+ ## Architecture
638
+
639
+ Four layers -- each depends only on layers below:
640
+
641
+ 1. **Types & Config** (`config/`, `errors/`, `*/types.ts`) -- pure data, zero runtime deps
642
+ 2. **Infrastructure** (`connector/`, `cache/`, `adf/`, `templates/`) -- I/O and external APIs
643
+ 3. **Business Logic** (`operations/`, `bulk/`) -- orchestrates infrastructure
644
+ 4. **Entry Points** (`tools/`, `cli/`, `server.ts`) -- thin dispatchers
645
+
646
+ ## Coding Conventions
647
+
648
+ - **Strict TypeScript**: `strict: true`, NO `any`, `readonly` interfaces, `import type`, `.js` imports
649
+ - **Zod schemas** for all external data: `type Foo = z.infer<typeof FooSchema>`
650
+ - **Error classes**: extend `JiraMcpError` with `code` property
651
+ - **ADF round-trip**: `markdownToAdf()` for writes, `adfToMarkdown()` for reads -- NEVER throw
652
+ - **InstancePool**: singleton, lazy connectors, dedup by URL
653
+ - **Dual-write**: after Jira mutation, update local cache, return API result
654
+ - **Dry-run default**: `--execute` required for destructive operations
655
+ - **DI pattern**: handlers accept `deps?` parameter for testing
656
+ - **Config path**: ALWAYS `~/.softspark/jira-mcp/` via `GLOBAL_CONFIG_DIR` -- no manual config, no env vars in MCP client setup
657
+ - **SoftSpark standard**: all open-source tools use `~/.softspark/<tool-name>/` -- see SOP in rag-mcp `kb/procedures/softspark-config-standard.md`
658
+
659
+ ## Testing
660
+
661
+ - **Vitest**: 70% coverage threshold, `vi.fn()` for mocks
662
+ - **No real Jira API calls** in tests, use `tests/fixtures/mocks.ts`
663
+ - **Filesystem tests**: `os.tmpdir()` + `mkdtemp()`, NEVER write to `~/.softspark/`
664
+ - Quick pre-commit: `npm run typecheck && npm run lint && npm test && npm run build`
665
+
666
+ ## KB & SOPs
667
+
668
+ - `kb/reference/` -- architecture, api, configuration, adf, caching, templates
669
+ - `kb/howto/` -- setup, multi-instance, cli-usage
670
+ - `kb/procedures/` -- sop-pre-commit, sop-release, sop-post-release-testing
671
+
672
+ <!-- TOOLKIT:jira-mcp END -->
603
673
 
604
674
  <!-- TOOLKIT:rag-mcp-rules START -->
605
675
  <!-- Auto-injected by ai-toolkit. Re-run to update. -->
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v2.2.0 — URL Rules & Registry Safety (2026-04-14)
11
+
12
+ ### Added
13
+ - **URL rule registration** — `ai-toolkit add-rule https://...` registers rules from HTTPS URLs. URL-sourced rules are tracked in `rules/sources.json` and auto-refreshed on every `ai-toolkit update`. Falls back to cached local copy on network failure.
14
+ - **Version consistency validation** — `validate.py --strict` now cross-checks `package.json`, `manifest.json`, and `plugin.json` versions match
15
+
16
+ ### Fixed
17
+ - **Project registry race condition** — parallel `install --local` during `ai-toolkit update` could silently drop registry entries. Fixed with `fcntl.flock` exclusive lock, atomic writes (tempfile + rename), and deferred sequential registration after parallel phase.
18
+ - **Version drift** — `manifest.json` and `plugin.json` were stuck at 1.9.0 since v2.0.0, now synced
19
+ - **Language rules count** — ARCHITECTURE.md claimed 70 files, actual is 68
20
+ - **Skills catalog tiers** — added missing Tier 1.5 (planning pipeline + design/architecture)
21
+
22
+ ---
23
+
10
24
  ## v2.1.3 — Idempotent Update Fix (2026-04-13)
11
25
 
12
26
  ### Fixed
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-92-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-598%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-604%20passing-success)](tests/)
10
10
 
11
11
  ---
12
12
 
@@ -142,7 +142,7 @@ ai-toolkit/
142
142
  │ └── ARCHITECTURE.md # Full system design
143
143
  ├── kb/ # Reference docs, procedures, plans
144
144
  ├── scripts/ # Validation, install, evaluation scripts
145
- ├── tests/ # Bats test suite (598 tests)
145
+ ├── tests/ # Bats test suite (604 tests)
146
146
  └── CHANGELOG.md
147
147
  ```
148
148
 
@@ -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": "1.9.0",
4
+ "version": "2.2.0",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
@@ -304,7 +304,7 @@ Lead Session (You)
304
304
  ### MCP Templates (25)
305
305
  `app/plugins/mcp-templates/` ships 25 ready-to-use MCP server config templates (filesystems, databases, GitHub, Slack, etc.). Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
306
306
 
307
- ### Language Rules (70 files, 13 languages)
307
+ ### Language Rules (68 files, 13 languages)
308
308
  `app/rules/` contains per-language coding rules. Supported languages: TypeScript, Python, Go, Rust, Java, Kotlin, Swift, Dart, C#, PHP, C++, Ruby, and common (shared). Auto-detected from project files via `--auto-detect` or selected with `--modules rules-<lang>`.
309
309
 
310
310
  Language rules are propagated to **all configured editors** — not just Claude. `dir_rules_shared.build_language_rules()` reads `app/rules/<lang>/*.md`, strips frontmatter, and returns combined content per language. Each directory-based generator (Cursor, Windsurf, Cline, Roo, Augment, Antigravity, Codex) emits `ai-toolkit-lang-<lang>` files in its native format. Registered custom rules (`~/.softspark/ai-toolkit/rules/`) are similarly propagated as `ai-toolkit-custom-<name>` files via `build_registered_rules()`.
package/bin/ai-toolkit.js CHANGED
@@ -63,7 +63,7 @@ const COMMANDS = {
63
63
  status: 'Show installed modules, version, and profile from state.json',
64
64
  reset: 'Wipe and recreate project-local configs from scratch (requires --local)',
65
65
  uninstall: 'Remove ai-toolkit from ~/.claude/',
66
- 'add-rule': 'Register a rule file in ~/.softspark/ai-toolkit/rules/ (applied on every install/update)',
66
+ 'add-rule': 'Register a rule file or URL in ~/.softspark/ai-toolkit/rules/ (URL rules auto-refresh on update)',
67
67
  'remove-rule': 'Unregister a rule from ~/.softspark/ai-toolkit/rules/ and remove its block from CLAUDE.md',
68
68
  'inject-hook': 'Inject external hooks into ~/.claude/settings.json (tagged with _source for idempotent updates)',
69
69
  'remove-hook': 'Remove injected hooks by source name from ~/.claude/settings.json',
@@ -232,8 +232,8 @@ function showHelp() {
232
232
  console.log(' <source-name> Source tag to remove (derived from hooks filename stem)');
233
233
  console.log(' [target-dir] Target dir containing .claude/settings.json (default: $HOME)');
234
234
  console.log('\nOptions for add-rule:');
235
- console.log(' <rule-file> Path to .md rule file to register globally');
236
- console.log(' [rule-name] Override rule name (default: filename without .md)');
235
+ console.log(' <rule-file> Path to .md rule file or HTTPS URL to register globally');
236
+ console.log(' [rule-name] Override rule name (default: filename/URL stem without .md)');
237
237
  console.log('\nOptions for plugin:');
238
238
  console.log(' install <name> Install a plugin pack (--editor claude|codex|all)');
239
239
  console.log(' install --all Install all available plugin packs for selected editor(s)');
@@ -333,16 +333,18 @@ function handleRemoveRule(args) {
333
333
  }
334
334
 
335
335
  /**
336
- * Handle `ai-toolkit add-rule` -- validates rule file, resolves absolute path.
336
+ * Handle `ai-toolkit add-rule` -- validates rule file/URL, resolves absolute path.
337
337
  * @param {string[]} args
338
338
  */
339
339
  function handleAddRule(args) {
340
340
  const ruleFile = args[0];
341
341
  if (!ruleFile) {
342
- console.error('Usage: ai-toolkit add-rule <rule-file> [rule-name]');
342
+ console.error('Usage: ai-toolkit add-rule <rule-file-or-url> [rule-name]');
343
343
  process.exit(1);
344
344
  }
345
- const absRuleFile = path.resolve(CWD, ruleFile);
345
+ // Pass URLs through directly (don't resolve as filesystem path)
346
+ const isUrl = ruleFile.startsWith('https://') || ruleFile.startsWith('http://');
347
+ const absRuleFile = isUrl ? ruleFile : path.resolve(CWD, ruleFile);
346
348
  const ruleName = args[1];
347
349
  run(scriptPath('add_rule.py'), ruleName ? [absRuleFile, ruleName] : [absRuleFile]);
348
350
  }
@@ -319,6 +319,7 @@ Follow this sequence before every `npm publish` / `git tag`:
319
319
 
320
320
  ```bash
321
321
  # Edit package.json version field (semver: X.Y.Z)
322
+ # Sync package-lock.json: npm install --package-lock-only
322
323
  # Add entry to CHANGELOG.md
323
324
  ```
324
325
 
@@ -108,8 +108,9 @@ The canonical version lives in `package.json`. These files **must** match:
108
108
  | `README.md` | Badge counts, "What You Get" table |
109
109
  | `app/ARCHITECTURE.md` | Section headings with counts |
110
110
 
111
- > **Tip:** `validate.py --strict` and `npm test` (metadata contract tests) catch
112
- > count drift automatically. If tests pass, counts are correct.
111
+ > **Tip:** `validate.py --strict` catches count drift AND version mismatches
112
+ > (package.json vs manifest.json vs plugin.json) automatically.
113
+ > If validation passes, counts and versions are correct.
113
114
 
114
115
  ### Verification command
115
116
 
@@ -17,7 +17,9 @@ All functionality is unified under skills. Task and hybrid skills are user-invoc
17
17
 
18
18
  | Tier | Skills | When |
19
19
  |------|--------|------|
20
- | **1 — Quick single-agent** | `/debug`, `/review`, `/refactor`, `/analyze`, `/docs`, `/plan`, `/explain` | One concern, fast |
20
+ | **1 — Quick single-agent** | `/debug`, `/review`, `/refactor`, `/analyze`, `/docs`, `/plan`, `/explain`, `/tdd`, `/grill-me`, `/triage-issue` | One concern, fast |
21
+ | **1.5 — Product planning** | `/write-a-prd` → `/prd-to-plan` → `/prd-to-issues` | Interview-driven PRD → vertical-slice plan → GitHub issues |
22
+ | **1.5 — Design & architecture** | `/design-an-interface`, `/architecture-audit`, `/refactor-plan`, `/ubiquitous-language`, `/qa-session` | Parallel sub-agent exploration |
21
23
  | **2 — Multi-agent workflow** | `/workflow <type>` | Cross-cutting task with known pattern |
22
24
  | **3 — Custom parallelism** | `/orchestrate`, `/swarm` | No predefined workflow matches |
23
25
 
package/llms-full.txt CHANGED
@@ -3441,6 +3441,7 @@ Follow this sequence before every `npm publish` / `git tag`:
3441
3441
 
3442
3442
  ```bash
3443
3443
  # Edit package.json version field (semver: X.Y.Z)
3444
+ # Sync package-lock.json: npm install --package-lock-only
3444
3445
  # Add entry to CHANGELOG.md
3445
3446
  ```
3446
3447
 
@@ -3619,8 +3620,9 @@ The canonical version lives in `package.json`. These files **must** match:
3619
3620
  | `README.md` | Badge counts, "What You Get" table |
3620
3621
  | `app/ARCHITECTURE.md` | Section headings with counts |
3621
3622
 
3622
- > **Tip:** `validate.py --strict` and `npm test` (metadata contract tests) catch
3623
- > count drift automatically. If tests pass, counts are correct.
3623
+ > **Tip:** `validate.py --strict` catches count drift AND version mismatches
3624
+ > (package.json vs manifest.json vs plugin.json) automatically.
3625
+ > If validation passes, counts and versions are correct.
3624
3626
 
3625
3627
  ### Verification command
3626
3628
 
@@ -8518,7 +8520,9 @@ All functionality is unified under skills. Task and hybrid skills are user-invoc
8518
8520
 
8519
8521
  | Tier | Skills | When |
8520
8522
  |------|--------|------|
8521
- | **1 — Quick single-agent** | `/debug`, `/review`, `/refactor`, `/analyze`, `/docs`, `/plan`, `/explain` | One concern, fast |
8523
+ | **1 — Quick single-agent** | `/debug`, `/review`, `/refactor`, `/analyze`, `/docs`, `/plan`, `/explain`, `/tdd`, `/grill-me`, `/triage-issue` | One concern, fast |
8524
+ | **1.5 — Product planning** | `/write-a-prd` → `/prd-to-plan` → `/prd-to-issues` | Interview-driven PRD → vertical-slice plan → GitHub issues |
8525
+ | **1.5 — Design & architecture** | `/design-an-interface`, `/architecture-audit`, `/refactor-plan`, `/ubiquitous-language`, `/qa-session` | Parallel sub-agent exploration |
8522
8526
  | **2 — Multi-agent workflow** | `/workflow <type>` | Cross-cutting task with known pattern |
8523
8527
  | **3 — Custom parallelism** | `/orchestrate`, `/swarm` | No predefined workflow matches |
8524
8528
 
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.9.0",
2
+ "version": "2.2.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": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Professional-grade AI coding toolkit: 92 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity, Codex CLI), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,52 +1,94 @@
1
1
  #!/usr/bin/env python3
2
- """add-rule -- Register a rule file in ~/.softspark/ai-toolkit/rules/.
2
+ """add-rule -- Register a rule file or URL in ~/.softspark/ai-toolkit/rules/.
3
3
 
4
4
  Registered rules are automatically injected into all AI tool configs
5
- on next 'ai-toolkit install' or 'ai-toolkit update':
5
+ on next 'ai-toolkit install' or 'ai-toolkit update'.
6
+ URL-sourced rules are auto-refreshed on every update.
7
+
6
8
  Global: Claude, Cursor, Windsurf, Gemini, Augment
7
9
  Local (--local): all of the above + Copilot, Cline, Roo, Aider, Antigravity
8
10
 
9
11
  Usage:
10
- add_rule.py <rule-file> [rule-name]
12
+ add_rule.py <rule-file-or-url> [rule-name]
11
13
 
12
14
  Arguments:
13
- rule-file Path to .md file with the rule content
14
- rule-name Override the rule name (default: filename without .md)
15
+ rule-file-or-url Path to .md file or HTTPS URL to register globally
16
+ rule-name Override the rule name (default: filename without .md)
15
17
  """
16
18
  from __future__ import annotations
17
19
 
18
20
  import re
19
21
  import shutil
20
22
  import sys
23
+ import urllib.parse
21
24
  from pathlib import Path
22
25
 
23
26
  sys.path.insert(0, str(Path(__file__).resolve().parent))
24
27
 
25
28
 
29
+ def _name_from_url(url: str) -> str:
30
+ """Derive a rule name from a URL's last path segment."""
31
+ parsed = urllib.parse.urlparse(url)
32
+ filename = parsed.path.rstrip("/").split("/")[-1]
33
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
34
+ return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
35
+
36
+
26
37
  def main() -> None:
27
- """Register a rule file in the global rules directory."""
38
+ """Register a rule file or URL in the global rules directory."""
28
39
  if len(sys.argv) < 2:
29
- print("Usage: add_rule.py <rule-file> [rule-name]", file=sys.stderr)
40
+ print("Usage: add_rule.py <rule-file-or-url> [rule-name]", file=sys.stderr)
30
41
  sys.exit(1)
31
42
 
32
- rule_file = Path(sys.argv[1])
33
- if not rule_file.is_file():
34
- print(f"Rule file not found: {rule_file}", file=sys.stderr)
35
- sys.exit(1)
43
+ source = sys.argv[1]
44
+ is_url = source.startswith("https://") or source.startswith("http://")
36
45
 
37
- rule_name = sys.argv[2] if len(sys.argv) > 2 else rule_file.stem
38
- rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
39
- if not rule_name:
40
- print("Error: rule name is empty after sanitization", file=sys.stderr)
46
+ if is_url and source.startswith("http://"):
47
+ print("Error: only HTTPS URLs are supported. Use https:// for security.", file=sys.stderr)
41
48
  sys.exit(1)
49
+
42
50
  from paths import RULES_DIR
43
51
  rules_dir = RULES_DIR
44
52
  rules_dir.mkdir(parents=True, exist_ok=True)
45
53
 
46
- dest = rules_dir / f"{rule_name}.md"
47
- shutil.copy2(rule_file, dest)
54
+ if is_url:
55
+ from rule_sources import fetch_url, register_url_source
56
+
57
+ rule_name = sys.argv[2] if len(sys.argv) > 2 else _name_from_url(source)
58
+ rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
59
+ if not rule_name:
60
+ print("Error: could not derive rule name from URL. Provide one explicitly.", file=sys.stderr)
61
+ sys.exit(1)
62
+
63
+ try:
64
+ data = fetch_url(source)
65
+ except Exception as exc:
66
+ print(f"Error fetching URL: {exc}", file=sys.stderr)
67
+ sys.exit(1)
68
+
69
+ dest = rules_dir / f"{rule_name}.md"
70
+ dest.write_bytes(data)
71
+ register_url_source(rules_dir, rule_name, source)
72
+
73
+ print(f"Registered: '{rule_name}' -> {dest}")
74
+ print(f"Source URL: {source} (auto-refreshed on update)")
75
+ else:
76
+ rule_file = Path(source)
77
+ if not rule_file.is_file():
78
+ print(f"Rule file not found: {rule_file}", file=sys.stderr)
79
+ sys.exit(1)
80
+
81
+ rule_name = sys.argv[2] if len(sys.argv) > 2 else rule_file.stem
82
+ rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
83
+ if not rule_name:
84
+ print("Error: rule name is empty after sanitization", file=sys.stderr)
85
+ sys.exit(1)
86
+
87
+ dest = rules_dir / f"{rule_name}.md"
88
+ shutil.copy2(rule_file, dest)
89
+
90
+ print(f"Registered: '{rule_name}' -> {dest}")
48
91
 
49
- print(f"Registered: '{rule_name}' -> {dest}")
50
92
  print()
51
93
  print("Apply now:")
52
94
  print(" ai-toolkit update # global (Claude, Cursor, Windsurf, Gemini, Augment)")
@@ -212,6 +212,7 @@ def parse_args(argv: list[str]) -> dict:
212
212
  "editors": "",
213
213
  "config": "",
214
214
  "refresh_base": False,
215
+ "skip_register": False,
215
216
  }
216
217
  i = 0
217
218
  while i < len(argv):
@@ -268,6 +269,8 @@ def parse_args(argv: list[str]) -> dict:
268
269
  cfg["config"] = argv[i] if i < len(argv) else ""
269
270
  elif arg == "--refresh-base":
270
271
  cfg["refresh_base"] = True
272
+ elif arg == "--skip-register":
273
+ cfg["skip_register"] = True
271
274
  elif arg.startswith("-"):
272
275
  print(f"Unknown option: {arg}")
273
276
  sys.exit(1)
@@ -426,7 +429,8 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
426
429
  print()
427
430
  print(f" Available: {count_agents()} agents, {count_skills()} skills")
428
431
 
429
- inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run)
432
+ inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run,
433
+ refresh_urls=True)
430
434
 
431
435
 
432
436
  VALID_PERSONAS = ("backend-lead", "frontend-lead", "devops-eng", "junior-dev")
@@ -711,7 +715,9 @@ def main() -> None:
711
715
  )
712
716
 
713
717
  # Register project in global registry (for `ai-toolkit update` propagation)
714
- if local:
718
+ # Skipped when called from update_projects.py (--skip-register) to avoid
719
+ # concurrent writes to projects.json during parallel updates.
720
+ if local and not cfg.get("skip_register"):
715
721
  extends_source = ""
716
722
  if extends_info:
717
723
  extends_source = extends_info.get("source", "")
@@ -32,14 +32,23 @@ def install_marker_files(claude_dir: Path, only: str, skip: str,
32
32
 
33
33
 
34
34
  def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
35
- only: str, skip: str, dry_run: bool) -> None:
36
- """Inject rules into CLAUDE.md."""
35
+ only: str, skip: str, dry_run: bool,
36
+ refresh_urls: bool = False) -> None:
37
+ """Inject rules into CLAUDE.md.
38
+
39
+ When refresh_urls is True, re-fetches URL-sourced rules before injection.
40
+ Only the global install path should set this to True (once per update).
41
+ """
37
42
  claude_md = claude_dir / "CLAUDE.md"
38
43
 
39
44
  if dry_run:
40
45
  _inject_rules_dry_run(rules_dir)
41
46
  return
42
47
 
48
+ # Refresh URL-sourced rules before injection (global update only)
49
+ if refresh_urls:
50
+ _refresh_url_rules(rules_dir)
51
+
43
52
  if not claude_md.is_file():
44
53
  claude_md.touch()
45
54
  print(" Created: ~/.claude/CLAUDE.md")
@@ -66,6 +75,30 @@ def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
66
75
  print(f" Rules injected: {' '.join(rules_injected)}")
67
76
 
68
77
 
78
+ def _refresh_url_rules(rules_dir: Path) -> None:
79
+ """Re-fetch all URL-sourced rules. Warn on failure, use cached copy."""
80
+ from rule_sources import get_url_rules, fetch_url, register_url_source
81
+
82
+ url_rules = get_url_rules(rules_dir)
83
+ if not url_rules:
84
+ return
85
+
86
+ for rule_name, url in url_rules.items():
87
+ rule_file = rules_dir / f"{rule_name}.md"
88
+ try:
89
+ data = fetch_url(url)
90
+ rule_file.write_bytes(data)
91
+ register_url_source(rules_dir, rule_name, url)
92
+ print(f" Refreshed: {rule_name} (from {url})")
93
+ except Exception as exc:
94
+ if rule_file.is_file():
95
+ print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
96
+ print(f" Using cached version.")
97
+ else:
98
+ print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
99
+ print(f" No cached version — rule will be skipped.")
100
+
101
+
69
102
  def _inject_rules_dry_run(rules_dir: Path) -> None:
70
103
  rules_src = app_dir / "rules"
71
104
  rule_names = " ".join(
@@ -8,15 +8,43 @@ Stdlib-only — no external dependencies.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import contextlib
12
+ import fcntl
11
13
  import json
14
+ import os
12
15
  import sys
16
+ import tempfile
17
+ import time
13
18
  from datetime import datetime, timezone
14
19
  from pathlib import Path
15
- from typing import Any
20
+ from typing import Any, Generator
16
21
 
17
22
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
23
  from paths import PROJECTS_FILE
19
24
 
25
+ # Max retries when reading a partially-written file
26
+ _LOAD_RETRIES = 3
27
+ _LOAD_RETRY_DELAY = 0.05 # 50ms
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def _registry_lock() -> Generator[None, None, None]:
32
+ """Exclusive file lock for read-modify-write on projects.json.
33
+
34
+ Prevents concurrent processes from interleaving loads and saves,
35
+ which can silently drop entries.
36
+ """
37
+ path = _registry_path()
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ lock_path = path.with_suffix(".lock")
40
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
41
+ try:
42
+ fcntl.flock(fd, fcntl.LOCK_EX)
43
+ yield
44
+ finally:
45
+ fcntl.flock(fd, fcntl.LOCK_UN)
46
+ os.close(fd)
47
+
20
48
 
21
49
  def _registry_path() -> Path:
22
50
  """Return the canonical path to projects.json."""
@@ -32,28 +60,66 @@ def _now_iso() -> str:
32
60
  # ---------------------------------------------------------------------------
33
61
 
34
62
  def load_registry() -> list[dict[str, Any]]:
35
- """Load project registry. Returns empty list if missing/corrupt."""
63
+ """Load project registry with retry for partially-written files.
64
+
65
+ Retries on JSONDecodeError (another process mid-write).
66
+ Returns empty list only if the file genuinely doesn't exist.
67
+ """
36
68
  path = _registry_path()
37
69
  if not path.is_file():
38
70
  return []
39
- try:
40
- with open(path, encoding="utf-8") as f:
41
- data = json.load(f)
42
- if isinstance(data, dict):
43
- projects = data.get("projects", [])
44
- return projects if isinstance(projects, list) else []
45
- return []
46
- except (json.JSONDecodeError, OSError):
47
- return []
71
+
72
+ last_err: Exception | None = None
73
+ for attempt in range(_LOAD_RETRIES):
74
+ try:
75
+ with open(path, encoding="utf-8") as f:
76
+ data = json.load(f)
77
+ if isinstance(data, dict):
78
+ projects = data.get("projects", [])
79
+ return projects if isinstance(projects, list) else []
80
+ return []
81
+ except json.JSONDecodeError as exc:
82
+ last_err = exc
83
+ if attempt < _LOAD_RETRIES - 1:
84
+ time.sleep(_LOAD_RETRY_DELAY)
85
+ except OSError:
86
+ return []
87
+
88
+ # All retries exhausted — file is genuinely corrupt, not mid-write
89
+ import sys as _sys
90
+ print(
91
+ f"Warning: {path} is corrupt after {_LOAD_RETRIES} retries: {last_err}",
92
+ file=_sys.stderr,
93
+ )
94
+ return []
48
95
 
49
96
 
50
97
  def save_registry(projects: list[dict[str, Any]]) -> None:
51
- """Save project registry."""
98
+ """Save project registry atomically (write-to-temp + rename).
99
+
100
+ Uses os.rename which is atomic on POSIX, preventing other processes
101
+ from reading a partially-written file.
102
+ """
52
103
  path = _registry_path()
53
104
  path.parent.mkdir(parents=True, exist_ok=True)
54
- with open(path, "w", encoding="utf-8") as f:
55
- json.dump({"projects": projects}, f, indent=2)
56
- f.write("\n")
105
+
106
+ fd, tmp_path = tempfile.mkstemp(
107
+ dir=str(path.parent), prefix=".projects_", suffix=".tmp"
108
+ )
109
+ try:
110
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
111
+ json.dump({"projects": projects}, f, indent=2)
112
+ f.write("\n")
113
+ f.flush()
114
+ os.fsync(f.fileno())
115
+ os.rename(tmp_path, str(path))
116
+ except BaseException:
117
+ # Clean up temp file on failure
118
+ try:
119
+ os.unlink(tmp_path)
120
+ except OSError:
121
+ pass
122
+ raise
57
123
 
58
124
 
59
125
  # ---------------------------------------------------------------------------
@@ -68,47 +134,52 @@ def register_project(
68
134
  """Register a project directory. Returns True if newly added, False if updated.
69
135
 
70
136
  Idempotent — updates existing entry if path already registered.
137
+ Uses file lock to prevent concurrent read-modify-write races.
71
138
  """
72
139
  project_path = str(Path(project_path).resolve())
73
- projects = load_registry()
74
- now = _now_iso()
75
-
76
- for p in projects:
77
- if p.get("path") == project_path:
78
- # Update existing
79
- p["last_updated"] = now
80
- if profile:
81
- p["profile"] = profile
82
- if extends:
83
- p["extends"] = extends
84
- elif "extends" in p and not extends:
85
- # Clear extends if project no longer uses it
86
- pass
87
- save_registry(projects)
88
- return False
89
140
 
90
- # New registration
91
- projects.append({
92
- "path": project_path,
93
- "registered_at": now,
94
- "last_updated": now,
95
- "profile": profile or "standard",
96
- "extends": extends or "",
97
- })
98
- save_registry(projects)
99
- return True
141
+ with _registry_lock():
142
+ projects = load_registry()
143
+ now = _now_iso()
144
+
145
+ for p in projects:
146
+ if p.get("path") == project_path:
147
+ # Update existing
148
+ p["last_updated"] = now
149
+ if profile:
150
+ p["profile"] = profile
151
+ if extends:
152
+ p["extends"] = extends
153
+ elif "extends" in p and not extends:
154
+ # Clear extends if project no longer uses it
155
+ pass
156
+ save_registry(projects)
157
+ return False
158
+
159
+ # New registration
160
+ projects.append({
161
+ "path": project_path,
162
+ "registered_at": now,
163
+ "last_updated": now,
164
+ "profile": profile or "standard",
165
+ "extends": extends or "",
166
+ })
167
+ save_registry(projects)
168
+ return True
100
169
 
101
170
 
102
171
  def unregister_project(project_path: str | Path) -> bool:
103
172
  """Unregister a project. Returns True if found and removed."""
104
173
  project_path = str(Path(project_path).resolve())
105
- projects = load_registry()
106
- original_len = len(projects)
107
- projects = [p for p in projects if p.get("path") != project_path]
108
- if len(projects) < original_len:
109
- save_registry(projects)
110
- return True
111
- return False
174
+
175
+ with _registry_lock():
176
+ projects = load_registry()
177
+ original_len = len(projects)
178
+ projects = [p for p in projects if p.get("path") != project_path]
179
+ if len(projects) < original_len:
180
+ save_registry(projects)
181
+ return True
182
+ return False
112
183
 
113
184
 
114
185
  def list_projects() -> list[dict[str, Any]]:
@@ -121,18 +192,19 @@ def list_projects() -> list[dict[str, Any]]:
121
192
 
122
193
  def prune_stale() -> list[str]:
123
194
  """Remove projects whose directories no longer exist. Returns pruned paths."""
124
- projects = load_registry()
125
- pruned: list[str] = []
126
- kept: list[dict[str, Any]] = []
127
-
128
- for p in projects:
129
- if Path(p["path"]).is_dir():
130
- kept.append(p)
131
- else:
132
- pruned.append(p["path"])
133
-
134
- if pruned:
135
- save_registry(kept)
195
+ with _registry_lock():
196
+ projects = load_registry()
197
+ pruned: list[str] = []
198
+ kept: list[dict[str, Any]] = []
199
+
200
+ for p in projects:
201
+ if Path(p["path"]).is_dir():
202
+ kept.append(p)
203
+ else:
204
+ pruned.append(p["path"])
205
+
206
+ if pruned:
207
+ save_registry(kept)
136
208
 
137
209
  return pruned
138
210
 
@@ -43,6 +43,11 @@ def main() -> None:
43
43
  else:
44
44
  print(f"Not registered: '{rule_name}' not found in {rules_dir}")
45
45
 
46
+ # 1b. Clean up URL source metadata (if any)
47
+ from rule_sources import unregister_source
48
+ if unregister_source(rules_dir, rule_name):
49
+ print(f"Removed URL source for '{rule_name}'")
50
+
46
51
  # 2. Strip injected block from .claude/CLAUDE.md
47
52
  found = remove_rule_section(rule_name, target_dir)
48
53
  if found:
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for remotely-sourced rules.
3
+
4
+ Tracks which rules were registered from a URL so that `ai-toolkit update`
5
+ can re-fetch the latest version before injection.
6
+
7
+ Metadata stored in ~/.softspark/ai-toolkit/rules/sources.json.
8
+
9
+ Stdlib-only — no external dependencies.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import ssl
16
+ import sys
17
+ import tempfile
18
+ import urllib.request
19
+ import urllib.error
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from paths import RULES_DIR
26
+
27
+ _SOURCES_FILENAME = "sources.json"
28
+ _FETCH_TIMEOUT = 30 # seconds
29
+ _FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Load / Save
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _sources_path(rules_dir: Path | None = None) -> Path:
37
+ return (rules_dir or RULES_DIR) / _SOURCES_FILENAME
38
+
39
+
40
+ def load_sources(rules_dir: Path | None = None) -> dict[str, dict[str, Any]]:
41
+ """Load sources.json. Returns {} if missing or corrupt."""
42
+ path = _sources_path(rules_dir)
43
+ if not path.is_file():
44
+ return {}
45
+ try:
46
+ with open(path, encoding="utf-8") as f:
47
+ data = json.load(f)
48
+ if isinstance(data, dict):
49
+ return data.get("rules", {})
50
+ return {}
51
+ except (json.JSONDecodeError, OSError):
52
+ return {}
53
+
54
+
55
+ def save_sources(rules_dir: Path | None = None,
56
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
57
+ """Write sources.json atomically."""
58
+ rules_dir = rules_dir or RULES_DIR
59
+ path = _sources_path(rules_dir)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+
62
+ payload = json.dumps({"schema_version": 1, "rules": sources or {}}, indent=2)
63
+
64
+ fd, tmp_path = tempfile.mkstemp(
65
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
66
+ )
67
+ try:
68
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
69
+ f.write(payload)
70
+ f.write("\n")
71
+ f.flush()
72
+ os.fsync(f.fileno())
73
+ os.rename(tmp_path, str(path))
74
+ except BaseException:
75
+ try:
76
+ os.unlink(tmp_path)
77
+ except OSError:
78
+ pass
79
+ raise
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # CRUD
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def register_url_source(rules_dir: Path | None, rule_name: str, url: str) -> None:
87
+ """Add or update a URL source entry."""
88
+ rules_dir = rules_dir or RULES_DIR
89
+ sources = load_sources(rules_dir)
90
+ sources[rule_name] = {
91
+ "url": url,
92
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
93
+ }
94
+ save_sources(rules_dir, sources)
95
+
96
+
97
+ def unregister_source(rules_dir: Path | None, rule_name: str) -> bool:
98
+ """Remove a source entry. Returns True if found and removed."""
99
+ rules_dir = rules_dir or RULES_DIR
100
+ sources = load_sources(rules_dir)
101
+ if rule_name in sources:
102
+ del sources[rule_name]
103
+ save_sources(rules_dir, sources)
104
+ return True
105
+ return False
106
+
107
+
108
+ def get_url_rules(rules_dir: Path | None = None) -> dict[str, str]:
109
+ """Return {rule_name: url} for all URL-sourced rules."""
110
+ sources = load_sources(rules_dir)
111
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Fetch
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def fetch_url(url: str) -> bytes:
119
+ """Fetch URL content. HTTPS only, 30s timeout, 10MB cap.
120
+
121
+ Raises:
122
+ ValueError: if URL is not HTTPS
123
+ urllib.error.URLError: on network failure
124
+ """
125
+ if not url.startswith("https://"):
126
+ raise ValueError(
127
+ f"Only HTTPS URLs are supported (got: {url.split('://')[0]}://)"
128
+ )
129
+
130
+ ctx = ssl.create_default_context()
131
+ with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
132
+ data = resp.read(_FETCH_MAX_BYTES)
133
+
134
+ # Basic binary detection — reject if null bytes present
135
+ if b"\x00" in data:
136
+ raise ValueError(f"URL returned binary content, expected markdown: {url}")
137
+
138
+ return data
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
  from typing import Any
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
- from install_steps.project_registry import get_active_projects, prune_stale
20
+ from install_steps.project_registry import get_active_projects, prune_stale, register_project
21
21
 
22
22
 
23
23
  def _update_project(project: dict[str, Any], install_script: str, extra_args: list[str]) -> dict:
@@ -91,13 +91,17 @@ def main() -> None:
91
91
  print(f" Updating {len(projects)} registered project(s)...")
92
92
  print()
93
93
 
94
+ # --skip-register: parallel installs must NOT write to projects.json
95
+ # concurrently. We re-register sequentially after all installs complete.
96
+ parallel_args = extra_args + ["--skip-register"]
97
+
94
98
  # Run in parallel (max 8 workers — don't overwhelm the system)
95
99
  max_workers = min(len(projects), 8)
96
100
  results: list[dict] = []
97
101
 
98
102
  with ThreadPoolExecutor(max_workers=max_workers) as pool:
99
103
  futures = {
100
- pool.submit(_update_project, p, install_script, extra_args): p
104
+ pool.submit(_update_project, p, install_script, parallel_args): p
101
105
  for p in projects
102
106
  }
103
107
  for future in as_completed(futures):
@@ -118,6 +122,15 @@ def main() -> None:
118
122
  for line in result["error"].strip().split("\n"):
119
123
  print(f" ERROR: {line}")
120
124
 
125
+ # Re-register projects sequentially (safe — no concurrent writes)
126
+ for result in results:
127
+ if result["success"]:
128
+ register_project(
129
+ result["path"],
130
+ profile=result.get("profile", "standard"),
131
+ extends=result.get("extends", ""),
132
+ )
133
+
121
134
  # Summary
122
135
  passed = sum(1 for r in results if r["success"])
123
136
  failed = len(results) - passed
@@ -610,10 +610,43 @@ def validate_metadata_contracts(
610
610
  else:
611
611
  print(f" OK: tests ({actual_tests})")
612
612
 
613
+ # Cross-validate versions: package.json vs manifest.json vs plugin.json
614
+ _validate_version_sync(tk_dir, vr)
615
+
613
616
  print()
614
617
  return actual_tests
615
618
 
616
619
 
620
+ def _validate_version_sync(tk_dir: Path, vr: ValidationResult) -> None:
621
+ """Ensure package.json, manifest.json, and plugin.json versions match."""
622
+ import json as _json
623
+
624
+ version_files = {
625
+ "package.json": tk_dir / "package.json",
626
+ "manifest.json": tk_dir / "manifest.json",
627
+ "plugin.json": tk_dir / "app" / ".claude-plugin" / "plugin.json",
628
+ }
629
+
630
+ versions: dict[str, str] = {}
631
+ for name, path in version_files.items():
632
+ if path.is_file():
633
+ try:
634
+ data = _json.loads(path.read_text(encoding="utf-8"))
635
+ versions[name] = data.get("version", "")
636
+ except Exception:
637
+ pass
638
+
639
+ if len(versions) < 2:
640
+ return # Not enough files to compare (e.g., installed copy without source)
641
+
642
+ unique = set(versions.values())
643
+ if len(unique) == 1:
644
+ print(f" OK: version sync ({unique.pop()})")
645
+ else:
646
+ detail = ", ".join(f"{k}={v}" for k, v in versions.items())
647
+ vr.error(f"Version mismatch across files: {detail}")
648
+
649
+
617
650
  def validate_content_quality(tk_dir: Path, vr: ValidationResult) -> None:
618
651
  """Check content quality: name matches directory, non-empty body."""
619
652
  print()