bmad-module-skill-forge 1.6.0 → 1.7.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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +1 -1
  4. package/package.json +2 -2
  5. package/src/knowledge/version-paths.md +4 -3
  6. package/src/shared/health-check.md +1 -1
  7. package/src/shared/scripts/schemas/skill-brief.v1.json +5 -0
  8. package/src/shared/scripts/skf-atomic-write.py +4 -1
  9. package/src/shared/scripts/skf-detect-language.py +34 -0
  10. package/src/shared/scripts/skf-detect-workspaces.py +54 -0
  11. package/src/shared/scripts/skf-enumerate-stack-skills.py +107 -16
  12. package/src/shared/scripts/skf-forge-tier-rw.py +4 -1
  13. package/src/shared/scripts/skf-manifest-ops.py +8 -4
  14. package/src/shared/scripts/skf-merge-ccc-exclusions.py +4 -1
  15. package/src/shared/scripts/skf-qmd-classify-collections.py +29 -3
  16. package/src/shared/scripts/skf-rebuild-managed-sections.py +72 -7
  17. package/src/shared/scripts/skf-scan-skill-md-structure.py +13 -3
  18. package/src/shared/scripts/skf-validate-brief-inputs.py +79 -18
  19. package/src/shared/scripts/skf-validate-brief-schema.py +47 -1
  20. package/src/shared/scripts/skf-validate-frontmatter.py +60 -2
  21. package/src/shared/scripts/skf-write-skill-brief.py +97 -17
  22. package/src/skf-analyze-source/assets/skill-brief-schema.md +10 -2
  23. package/src/skf-analyze-source/references/generate-briefs.md +30 -9
  24. package/src/skf-analyze-source/references/recommend.md +1 -1
  25. package/src/skf-brief-skill/SKILL.md +3 -3
  26. package/src/skf-brief-skill/assets/skill-brief-schema.md +6 -1
  27. package/src/skf-brief-skill/references/analyze-target.md +22 -6
  28. package/src/skf-brief-skill/references/confirm-brief.md +4 -0
  29. package/src/skf-brief-skill/references/gather-intent.md +14 -4
  30. package/src/skf-brief-skill/references/headless-args.md +3 -2
  31. package/src/skf-brief-skill/references/portfolio-similarity-check.md +16 -8
  32. package/src/skf-brief-skill/references/scope-definition.md +20 -2
  33. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  34. package/src/skf-brief-skill/references/write-brief.md +8 -2
  35. package/src/skf-create-skill/assets/compile-assembly-rules.md +19 -0
  36. package/src/skf-create-skill/assets/skill-sections.md +1 -0
  37. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +18 -5
  38. package/src/skf-create-skill/references/compile.md +5 -2
  39. package/src/skf-create-skill/references/extract.md +8 -3
  40. package/src/skf-create-skill/references/extraction-patterns.md +14 -5
  41. package/src/skf-create-skill/references/generate-artifacts.md +5 -1
  42. package/src/skf-create-skill/references/source-resolution-protocols.md +5 -2
  43. package/src/skf-create-skill/references/validate.md +4 -3
  44. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +3 -3
  45. package/src/skf-create-stack-skill/assets/stack-skill-template.md +56 -0
  46. package/src/skf-create-stack-skill/references/compile-stack.md +13 -2
  47. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -0
  48. package/src/skf-create-stack-skill/references/detect-integrations.md +5 -2
  49. package/src/skf-create-stack-skill/references/generate-output.md +49 -9
  50. package/src/skf-create-stack-skill/references/validate.md +8 -5
  51. package/src/skf-export-skill/references/load-skill.md +5 -4
  52. package/src/skf-export-skill/references/manifest-rebuild.md +1 -1
  53. package/src/skf-export-skill/references/token-report.md +2 -2
  54. package/src/skf-export-skill/references/update-context.md +25 -11
  55. package/src/skf-quick-skill/assets/skill-template.md +2 -2
  56. package/src/skf-quick-skill/references/compile.md +4 -2
  57. package/src/skf-quick-skill/references/write-and-validate.md +2 -2
  58. package/src/skf-refine-architecture/SKILL.md +1 -1
  59. package/src/skf-refine-architecture/references/gap-analysis.md +30 -4
  60. package/src/skf-refine-architecture/references/init.md +2 -0
  61. package/src/skf-refine-architecture/references/issue-detection.md +8 -3
  62. package/src/skf-test-skill/references/coherence-check.md +4 -4
  63. package/src/skf-test-skill/references/coverage-check.md +58 -16
  64. package/src/skf-test-skill/references/external-validators.md +7 -7
  65. package/src/skf-test-skill/references/init.md +5 -4
  66. package/src/skf-test-skill/references/score.md +6 -3
  67. package/src/skf-test-skill/references/scoring-rules.md +12 -0
  68. package/src/skf-test-skill/references/source-access-protocol.md +19 -3
  69. package/src/skf-test-skill/scripts/compute-score.py +4 -1
  70. package/src/skf-update-skill/references/detect-changes.md +15 -2
  71. package/src/skf-update-skill/references/merge.md +22 -0
  72. package/src/skf-update-skill/references/re-extract.md +19 -7
  73. package/src/skf-update-skill/references/write.md +3 -0
  74. package/src/skf-verify-stack/SKILL.md +2 -2
  75. package/src/skf-verify-stack/assets/feasibility-report-template.md +1 -1
  76. package/src/skf-verify-stack/references/coverage.md +35 -6
  77. package/src/skf-verify-stack/references/init.md +6 -2
  78. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  79. package/src/skf-verify-stack/references/integrations.md +7 -3
  80. package/src/skf-verify-stack/references/report.md +5 -1
  81. package/src/skf-verify-stack/references/requirements.md +3 -1
  82. package/src/skf-verify-stack/references/synthesize.md +11 -3
  83. package/tools/cli/lib/installer.js +1 -0
@@ -30,7 +30,7 @@
30
30
  "name": "skill-forge",
31
31
  "source": "./",
32
32
  "description": "Evidence-based agent skills compiler with progressive capability tiers (Quick/Forge/Forge+/Deep).",
33
- "version": "1.6.0",
33
+ "version": "1.7.0",
34
34
  "author": {
35
35
  "name": "Armel"
36
36
  },
@@ -26,7 +26,7 @@
26
26
  # Enforced two ways: (1) release.yaml bumps this line in the same commit
27
27
  # as package.json + marketplace.json on every release; (2) validate-docs-drift.js
28
28
  # cross-checks this value against package.json.version and fails on mismatch.
29
- skf_version: "1.6.0"
29
+ skf_version: "1.7.0"
30
30
 
31
31
  # Path to the oh-my-skills repo, resolved relative to this repo's root.
32
32
  # Override at runtime with the OMS environment variable if oh-my-skills
package/docs/workflows.md CHANGED
@@ -426,7 +426,7 @@ If the answer to all of these is "no", the health check exits in one line (`Clea
426
426
 
427
427
  ### How findings are routed
428
428
 
429
- - **Severity gate.** Only `bug` findings submit live as GitHub issues by default. `friction` and `gap` findings — the most subjective categories — go to a **local queue** at `{output_folder}/improvement-queue/` unless you explicitly opt in to submit them live during the review gate. This keeps the high-signal reports (real defects) flowing to maintainers while the softer observations sit safely on your disk for you to batch or revisit.
429
+ - **Severity gate.** Only `bug` findings submit live as GitHub issues by default. `friction` and `gap` findings — the most subjective categories — go to a **local queue** at `{forge_data_folder}/improvement-queue/` unless you explicitly opt in to submit them live during the review gate. This keeps the high-signal reports (real defects) flowing to maintainers while the softer observations sit safely on your disk for you to batch or revisit.
430
430
  - **Fingerprint dedup.** Every finding gets a deterministic 7-hex fingerprint computed from `sha1(severity|workflow|step_file|section)` — no LLM similarity judgment, just a tuple hash. Before Ferris opens a new issue, it searches the repo for an existing open issue with the same `fp-*` label. If one exists, you're offered a choice: add a 👍 reaction (silent upvote), react + post a one-sentence environment delta, open a new issue anyway (if you're certain it's distinct), or skip. Re-reporting the same fingerprint is safe — it just adds to the signal-count on the canonical issue.
431
431
  - **Global seen-cache.** Once you've submitted or reacted for a given fingerprint, it's recorded at `~/.skf/health-check-seen.json` so the same user never re-reports the same defect across sessions or across different projects on the same machine.
432
432
  - **Server-side safety net.** If two users race past the client-side search and both open issues with the same fingerprint, a GitHub Action on this repo catches it: the later issue is auto-closed as a duplicate, linked to the canonical (lowest-numbered) issue, and a 👍 is added there to preserve the signal-count. Manual filers using the [issue template](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) feed the same pipeline.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-module-skill-forge",
4
- "version": "1.6.0",
4
+ "version": "1.7.0",
5
5
  "description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
6
6
  "keywords": [
7
7
  "bmad",
@@ -53,7 +53,7 @@
53
53
  "test:cli": "node test/test-cli-integration.js",
54
54
  "test:install": "node test/test-installation-components.js",
55
55
  "test:knowledge": "node test/test-knowledge-base.js",
56
- "test:python": "uv run --with pytest --with pyyaml --with jsonschema pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py test/test-skf-validate-brief-inputs.py test/test-skf-emit-brief-result-envelope.py test/test-skf-write-skill-brief.py test/test-skf-detect-workspaces.py test/test-skf-recommend-scope-type.py test/test-skf-detect-language.py test/test-skf-description-guard.py test/test-skf-detect-scripts-assets.py test/test-skf-hash-content.py test/test-skf-validate-brief-schema.py test/test-skf-check-workspace-drift.py test/test-skf-update-active-symlink.py test/test-skf-build-change-manifest.py test/test-skf-provenance-gap-dispatch.py test/test-skf-resolve-authoritative-files.py test/test-skf-scan-manifests.py test/test-skf-pair-intersect.py test/test-skf-enumerate-stack-skills.py test/test-skf-compare-file-hashes.py test/test-skf-load-provenance.py test/test-skf-scan-skill-md-structure.py test/test-skf-disqualify-candidates.py test/test-skf-chain-reachability.py -v",
56
+ "test:python": "uv run --with pytest --with pyyaml --with jsonschema pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-atomic-write.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py test/test-skf-validate-brief-inputs.py test/test-skf-emit-brief-result-envelope.py test/test-skf-write-skill-brief.py test/test-skf-detect-workspaces.py test/test-skf-recommend-scope-type.py test/test-skf-detect-language.py test/test-skf-description-guard.py test/test-skf-detect-scripts-assets.py test/test-skf-hash-content.py test/test-skf-validate-brief-schema.py test/test-skf-check-workspace-drift.py test/test-skf-update-active-symlink.py test/test-skf-build-change-manifest.py test/test-skf-provenance-gap-dispatch.py test/test-skf-resolve-authoritative-files.py test/test-skf-scan-manifests.py test/test-skf-pair-intersect.py test/test-skf-enumerate-stack-skills.py test/test-skf-compare-file-hashes.py test/test-skf-load-provenance.py test/test-skf-scan-skill-md-structure.py test/test-skf-disqualify-candidates.py test/test-skf-chain-reachability.py -v",
57
57
  "test:schemas": "node test/test-agent-schema.js",
58
58
  "test:workflow": "node test/test-workflow-state.js",
59
59
  "validate:refs": "node tools/validate-file-refs.js --strict",
@@ -119,9 +119,10 @@ When reading artifacts, resolve the skill path using the export manifest:
119
119
  1. Read `{skills_output_folder}/.export-manifest.json`
120
120
  2. Look up the skill name in `exports`
121
121
  3. Read `active_version` to get the target version
122
- 4. Resolve to `{skill_package}` using the active version
123
- 5. If manifest does not contain the skill: check for `active` symlink at `{skill_group}/active`
124
- 6. If neither manifest nor symlink: fall back to flat-path resolution (migration see below)
122
+ 4. **Manifest-lag guard.** If the `active` symlink at `{skill_group}/active` resolves to a *different* version than the manifest's `active_version`, prefer the **symlink target** and emit an Info note. The manifest `active_version` only advances when `export-skill` runs, but the `active` symlink is flipped forward by every writing workflow (CS/QS/SS/US) the instant it commits a new version. So in the canonical SS→TS→EX order the manifest lags the just-forged version in the window between forge and export — a manifest-first read would otherwise resolve the *previously exported* version. Preferring the symlink target closes this gap (and, for `export-skill`, makes that export publish the forged version and reconcile the manifest). This guard never overrides legitimate state: `drop-skill` — the only workflow that switches the active version — repoints the symlink to the manifest's `active_version` (it never leaves them diverged), and no workflow flips the symlink *backward*, so the only divergence that can occur is exactly this forge→export lag.
123
+ 5. Resolve to `{skill_package}` using the chosen version the symlink target when it diverges per step 4, otherwise `active_version`
124
+ 6. If manifest does not contain the skill: check for `active` symlink at `{skill_group}/active`
125
+ 7. If neither manifest nor symlink: fall back to flat-path resolution (migration — see below)
125
126
 
126
127
  ### Manifest-Driven Snippet Scanning (EX Step-04)
127
128
 
@@ -3,7 +3,7 @@ name: 'health-check'
3
3
  description: 'Workflow self-improvement health check — captures real friction as GitHub issues'
4
4
  # No nextStepFile — this is always the terminal step
5
5
  healthCheckRepo: '{health_check_repo}'
6
- localFallbackFolder: '{output_folder}/improvement-queue'
6
+ localFallbackFolder: '{forge_data_folder}/improvement-queue' # forge workspace artifact — use forge_data_folder (single, unambiguous SKF var), NOT output_folder (a Core Config var that resolves differently across co-installed BMad modules)
7
7
  seenCachePath: '$HOME/.skf/health-check-seen.json'
8
8
  liveSubmitSeverities: ['bug'] # friction/gap go local-queue-default with explicit opt-in
9
9
  ---
@@ -21,6 +21,11 @@
21
21
  "pattern": "^v?\\d+\\.\\d+\\.\\d+([.\\-+][0-9A-Za-z][0-9A-Za-z.\\-+]*)?$",
22
22
  "description": "User-specified target. When present, MUST equal version."
23
23
  },
24
+ "target_ref": {
25
+ "type": "string",
26
+ "minLength": 1,
27
+ "description": "Optional explicit git ref (tag or branch) used verbatim as source_ref, bypassing version-to-tag matching. Escape hatch for monorepo crate tags whose prefix differs from the skill name (e.g. tag livekit/v0.7.42 for skill livekit-rust). Remote sources only."
28
+ },
24
29
  "source_type": {
25
30
  "type": "string",
26
31
  "enum": ["source", "docs-only"]
@@ -144,8 +144,11 @@ def cmd_write(target: Path) -> None:
144
144
  data = sys.stdin.buffer.read()
145
145
  target.parent.mkdir(parents=True, exist_ok=True)
146
146
  tmp = target.with_name(target.name + ".skf-tmp")
147
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
148
+ # translation that would otherwise corrupt verbatim writes on Windows.
149
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
147
150
  try:
148
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
151
+ fd = os.open(tmp, flags, 0o644)
149
152
  try:
150
153
  os.write(fd, data)
151
154
  os.fsync(fd)
@@ -14,6 +14,13 @@ co-located with a deterministic rule rather than restated in prose.
14
14
 
15
15
  Detection rules (apply in order, first match wins):
16
16
 
17
+ 0. workspace_signal (optional, from skf-detect-workspaces.manifest_kind):
18
+ "cargo-workspace" → rust (high)
19
+ "python-multi-package" → python (high)
20
+ A non-JS workspace root's language wins over a nested package.json +
21
+ tsconfig.json (e.g. a docs/ or website/ site that is not a workspace
22
+ member). JS-family workspaces (npm/pnpm/lerna) carry no entry here and
23
+ fall through to rule 1, whose root package.json correctly resolves js/ts.
17
24
  1. package.json (with optional tsconfig.json companion):
18
25
  tsconfig.json present → typescript (high)
19
26
  tsconfig.json absent → javascript (high)
@@ -37,6 +44,10 @@ CLI:
37
44
 
38
45
  Input (JSON object on stdin or via --json):
39
46
  tree — list of repo-relative file paths (required, non-empty)
47
+ workspace_signal — optional manifest_kind from skf-detect-workspaces. When it
48
+ names a non-JS workspace ("cargo-workspace",
49
+ "python-multi-package"), rule 0 returns the root language
50
+ and ignores nested package.json/tsconfig matches.
40
51
 
41
52
  Output (JSON on stdout):
42
53
  language — javascript | typescript | rust | python | go
@@ -60,6 +71,17 @@ import sys
60
71
  from collections import Counter
61
72
  from typing import Any
62
73
 
74
+ # Workspace manifest_kind → root language (rule 0). Only non-JS workspace kinds
75
+ # appear here: a Cargo/Python workspace root is unambiguously rust/python, and a
76
+ # nested package.json+tsconfig (a docs or website subproject) must not win. JS
77
+ # workspace kinds (npm-workspaces/pnpm-workspaces/lerna) are intentionally
78
+ # absent — their root manifest IS package.json, so rule 1 resolves js/ts
79
+ # correctly. generic-folders / null carry no language signal.
80
+ _WORKSPACE_SIGNAL_LANGUAGE: dict[str, str] = {
81
+ "cargo-workspace": "rust",
82
+ "python-multi-package": "python",
83
+ }
84
+
63
85
  # Manifest basenames the rule table checks. Kept tight — tree-wide pattern
64
86
  # matches (e.g. *.csproj) are handled separately to avoid false positives
65
87
  # from generated artifacts under build/ or dist/.
@@ -185,6 +207,18 @@ def detect(payload: dict[str, Any]) -> dict[str, Any]:
185
207
  if len(tree) == 0:
186
208
  _die("payload.tree must be non-empty")
187
209
 
210
+ # Rule 0 — workspace precedence. A non-JS workspace root (from
211
+ # skf-detect-workspaces.manifest_kind) wins over any nested package.json +
212
+ # tsconfig.json, which would otherwise be misread as a typescript root.
213
+ workspace_signal = payload.get("workspace_signal")
214
+ if isinstance(workspace_signal, str) and workspace_signal in _WORKSPACE_SIGNAL_LANGUAGE:
215
+ return {
216
+ "language": _WORKSPACE_SIGNAL_LANGUAGE[workspace_signal],
217
+ "confidence": "high",
218
+ "detection_source": f"workspace manifest_kind={workspace_signal} (root manifest wins over nested package.json)",
219
+ "fallback_to_extension_frequency": False,
220
+ }
221
+
188
222
  # Rule 1 — package.json (with tsconfig.json disambiguation)
189
223
  if _has_basename(tree, "package.json"):
190
224
  if _has_basename(tree, "tsconfig.json"):
@@ -361,6 +361,59 @@ DETECTORS: list[tuple[str, callable]] = [
361
361
  ("generic-folders", detect_generic_folders),
362
362
  ]
363
363
 
364
+ # Language ecosystem each manifest kind belongs to. Drives the cross-ecosystem
365
+ # secondary-manifest warning below. `generic-folders` is intentionally absent —
366
+ # it is a tree-shape heuristic, not a distinct root workspace manifest, so it
367
+ # never participates in cross-ecosystem warnings.
368
+ ECOSYSTEM_OF_KIND = {
369
+ "npm-workspaces": "js",
370
+ "pnpm-workspaces": "js",
371
+ "lerna": "js",
372
+ "cargo-workspace": "rust",
373
+ "python-multi-package": "python",
374
+ }
375
+
376
+ ROOT_MANIFEST_OF_KIND = {
377
+ "npm-workspaces": "package.json",
378
+ "pnpm-workspaces": "pnpm-workspace.yaml",
379
+ "lerna": "lerna.json",
380
+ "cargo-workspace": "Cargo.toml",
381
+ "python-multi-package": None, # discovered from the tree; no single root manifest
382
+ }
383
+
384
+
385
+ def _cross_ecosystem_warnings(
386
+ primary_kind: str, tree: set[str], manifests: dict[str, str]
387
+ ) -> list[str]:
388
+ """Warn when a root workspace manifest from a *different* language ecosystem
389
+ than the surfaced one also resolves members.
390
+
391
+ First-match-wins detection silently drops a co-located workspace from another
392
+ ecosystem (e.g. a root `Cargo.toml [workspace]` when a pnpm workspace wins),
393
+ which then poisons downstream language detection and the §1b workspace menu.
394
+ This re-runs only the manifest-backed detectors from a different ecosystem,
395
+ against a throwaway warnings sink so their parse errors are not propagated,
396
+ and emits one warning per ignored cross-ecosystem kind that resolves >=1
397
+ member. The surfaced `manifest_kind` / `workspaces` are left unchanged.
398
+ """
399
+ primary_eco = ECOSYSTEM_OF_KIND.get(primary_kind)
400
+ out: list[str] = []
401
+ for kind, detector in DETECTORS:
402
+ eco = ECOSYSTEM_OF_KIND.get(kind)
403
+ if kind == primary_kind or eco is None or eco == primary_eco:
404
+ continue # self, generic-folders, or same ecosystem as the winner
405
+ result = detector(tree, manifests, [])
406
+ if result and len(result) >= 1:
407
+ manifest = ROOT_MANIFEST_OF_KIND.get(kind)
408
+ where = f" (root {manifest})" if manifest else ""
409
+ out.append(
410
+ f"cross-ecosystem workspace ignored: {kind}{where} resolves "
411
+ f"{len(result)} member(s) but only {primary_kind} was surfaced. "
412
+ f"If the skill targets the {kind} ecosystem, re-scope to it — "
413
+ f"language detection keys off the surfaced manifest_kind."
414
+ )
415
+ return out
416
+
364
417
 
365
418
  # --------------------------------------------------------------------------
366
419
  # Entry point
@@ -381,6 +434,7 @@ def detect(payload: dict) -> dict:
381
434
  for kind, detector in DETECTORS:
382
435
  result = detector(tree, manifests, warnings)
383
436
  if result and len(result) >= 1:
437
+ warnings.extend(_cross_ecosystem_warnings(kind, tree, manifests))
384
438
  return {
385
439
  "is_monorepo": True,
386
440
  "manifest_kind": kind,
@@ -24,11 +24,16 @@ benefit from LLM judgment).
24
24
  Subcommand:
25
25
  enumerate <skills-root>
26
26
  Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]}
27
- describing every subdirectory of <skills-root> that contains a
28
- SKILL.md. Each entry carries:
29
-
30
- name skill name (subdirectory name)
31
- path — relative path under skills-root, forward-slash
27
+ describing every subdirectory of <skills-root> that resolves to a
28
+ skill package. Both layouts from knowledge/version-paths.md are
29
+ supported: the flat layout (<child>/SKILL.md) and the version-nested
30
+ layout (<child>/active/<name>/SKILL.md via the `active` symlink, or
31
+ <child>/<version>/<name>/SKILL.md by highest version). Each entry
32
+ carries:
33
+
34
+ name — skill name (top-level subdirectory name)
35
+ path — relative path to the resolved package under
36
+ skills-root, forward-slash
32
37
  exports — exports list resolved via cascade
33
38
  exports_source — "metadata|references|skill-md|unknown"
34
39
  confidence — "T1|T2|T1-low" (mapped from exports_source)
@@ -393,6 +398,80 @@ def detect_cycles(graph: dict[str, list[str]]) -> list[str]:
393
398
  return sorted(cycle_nodes)
394
399
 
395
400
 
401
+ # --------------------------------------------------------------------------
402
+ # Version-nested layout resolution
403
+ # --------------------------------------------------------------------------
404
+
405
+
406
+ def _version_sort_key(name: str) -> tuple:
407
+ """Sort key for version directory names — higher sorts as newer.
408
+
409
+ Parses the leading dotted numeric core (`N.N.N`); a pre-release suffix
410
+ (`-rc1`, `-beta.2`, ...) ranks below the same core release. Names without
411
+ a numeric core rank lowest. Deterministic tie-break on the raw name.
412
+ """
413
+ core, _, pre = name.partition("-")
414
+ nums: list[int] = []
415
+ for part in core.split("."):
416
+ if part.isdigit():
417
+ nums.append(int(part))
418
+ else:
419
+ break
420
+ return (tuple(nums), 0 if pre == "" else -1, name)
421
+
422
+
423
+ def _inner_package(version_dir: Path) -> Path | None:
424
+ """Within a version directory, return the inner package dir holding SKILL.md.
425
+
426
+ Matches the `{version}/{skill-name}/SKILL.md` shape from
427
+ knowledge/version-paths.md. Returns None if no inner dir has a SKILL.md.
428
+ """
429
+ try:
430
+ for inner in sorted(version_dir.iterdir()):
431
+ if inner.is_dir() and (inner / "SKILL.md").is_file():
432
+ return inner
433
+ except OSError:
434
+ return None
435
+ return None
436
+
437
+
438
+ def _resolve_package_dir(child: Path) -> Path | None:
439
+ """Resolve the agentskills package dir (the one containing SKILL.md).
440
+
441
+ Supports both layouts defined in knowledge/version-paths.md:
442
+ - flat: {child}/SKILL.md
443
+ - version-nested: {child}/active/{skill-name}/SKILL.md (via the `active`
444
+ symlink) or {child}/{version}/{skill-name}/SKILL.md.
445
+
446
+ For the nested layout the `active` symlink wins; otherwise the highest
447
+ version directory by semver precedence is used. Returns None when no
448
+ SKILL.md is reachable (caller skips the entry as a non-package).
449
+ """
450
+ # Flat layout — direct SKILL.md.
451
+ if (child / "SKILL.md").is_file():
452
+ return child
453
+
454
+ # Nested layout — prefer the stable `active` pointer.
455
+ active = child / "active"
456
+ if active.is_dir(): # is_dir() follows the symlink; False if broken
457
+ pkg = _inner_package(active)
458
+ if pkg is not None:
459
+ return pkg
460
+
461
+ # Fallback — highest version directory.
462
+ try:
463
+ version_dirs = [
464
+ d for d in child.iterdir() if d.is_dir() and d.name != "active"
465
+ ]
466
+ except OSError:
467
+ return None
468
+ for vdir in sorted(version_dirs, key=lambda d: _version_sort_key(d.name), reverse=True):
469
+ pkg = _inner_package(vdir)
470
+ if pkg is not None:
471
+ return pkg
472
+ return None
473
+
474
+
396
475
  # --------------------------------------------------------------------------
397
476
  # Enumeration entry point
398
477
  # --------------------------------------------------------------------------
@@ -416,8 +495,10 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
416
495
  if name.startswith("."):
417
496
  continue
418
497
 
419
- # Symlink fallback — resolve and verify target is a directory we
420
- # can read. If anything fails, warn and skip the entry.
498
+ # Symlink fallback — a child-level symlink (e.g. a top-level `active`
499
+ # pointer) must resolve to a readable directory. If it's broken, warn
500
+ # and skip; otherwise keep the original path so the reported `path`
501
+ # stays relative to skills_root.
421
502
  if child.is_symlink():
422
503
  try:
423
504
  target = child.resolve(strict=True)
@@ -431,16 +512,18 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
431
512
  f"{name}: symlink target is not a directory"
432
513
  )
433
514
  continue
434
- skill_dir = target
435
- else:
436
- if not child.is_dir():
437
- continue
438
- skill_dir = child
515
+ elif not child.is_dir():
516
+ continue
439
517
 
440
- # Require SKILL.md to consider this a skill package. Subdirs
441
- # without SKILL.md (e.g. `shared/`, `knowledge/`) are skipped
442
- # silently — they're not skill packages by definition.
443
- if not (skill_dir / "SKILL.md").is_file():
518
+ # Resolve the package dir across flat and version-nested layouts
519
+ # (knowledge/version-paths.md). Subdirs with no reachable SKILL.md
520
+ # (e.g. `shared/`, `knowledge/`) are skipped silently — not packages.
521
+ try:
522
+ skill_dir = _resolve_package_dir(child)
523
+ except OSError as exc:
524
+ result["warnings"].append(f"{name}: failed to resolve package dir ({exc})")
525
+ continue
526
+ if skill_dir is None:
444
527
  continue
445
528
 
446
529
  try:
@@ -449,6 +532,14 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
449
532
  result["warnings"].append(f"{name}: enumeration failed: {exc}")
450
533
  continue
451
534
 
535
+ # Reflect the resolved package location (forward-slash, relative to
536
+ # skills_root) — for the nested layout this is the `{active_skill}`
537
+ # path, for the flat layout just the skill name.
538
+ try:
539
+ entry["path"] = skill_dir.relative_to(skills_root).as_posix()
540
+ except ValueError:
541
+ entry["path"] = name
542
+
452
543
  result["skills"].append(entry)
453
544
  result["warnings"].extend(skill_warnings)
454
545
  compose_graph[name] = composes
@@ -146,8 +146,11 @@ def _atomic_write(target: Path, content: str) -> None:
146
146
  """Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
147
147
  target.parent.mkdir(parents=True, exist_ok=True)
148
148
  tmp = target.with_name(target.name + ".skf-tmp")
149
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
150
+ # translation that would otherwise corrupt verbatim writes on Windows.
151
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
149
152
  try:
150
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
153
+ fd = os.open(tmp, flags, 0o644)
151
154
  try:
152
155
  os.write(fd, content.encode("utf-8"))
153
156
  os.fsync(fd)
@@ -135,13 +135,17 @@ def cmd_set(manifest_path, skill_name, version, ides=None):
135
135
  existing = exports.get(skill_name, {})
136
136
  today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
137
137
 
138
- # Preserve existing versions dict, archive the previously-active version
138
+ # Preserve existing versions dict, archive every *other* still-active version.
139
+ # Archiving by status (rather than only the entry matching the prior
140
+ # active_version) keeps a single active version even when active_version was
141
+ # advanced to this version before set() ran — otherwise the genuine prior
142
+ # version would stay active, breaking the single-active invariant. Idempotent.
139
143
  versions = existing.get("versions", {})
140
144
  if isinstance(versions, list):
141
145
  versions = {} # Safety: handle any residual v1 data
142
- old_active = existing.get("active_version")
143
- if old_active and old_active in versions and old_active != version:
144
- versions[old_active]["status"] = "archived"
146
+ for other_version, other_entry in versions.items():
147
+ if other_version != version and other_entry.get("status") == "active":
148
+ other_entry["status"] = "archived"
145
149
 
146
150
  # Add or update the new active version. Absent --ides preserves the
147
151
  # existing ides/platforms list verbatim (backward-compatible). When
@@ -119,8 +119,11 @@ def _atomic_write(target: Path, content: str) -> None:
119
119
  """Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
120
120
  target.parent.mkdir(parents=True, exist_ok=True)
121
121
  tmp = target.with_name(target.name + ".skf-tmp")
122
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
123
+ # translation that would otherwise corrupt verbatim writes on Windows.
124
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
122
125
  try:
123
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
126
+ fd = os.open(tmp, flags, 0o644)
124
127
  try:
125
128
  os.write(fd, content.encode("utf-8"))
126
129
  os.fsync(fd)
@@ -89,6 +89,9 @@ import yaml
89
89
  FORGE_SUFFIXES = ("-brief", "-temporal", "-docs", "-extraction")
90
90
  FOREIGN_SAMPLE_CAP = 5
91
91
 
92
+ # Header line emitted by newer qmd builds, e.g. "Collections (56):".
93
+ _QMD_HEADER_RE = re.compile(r"^Collections \(\d+\):\s*$")
94
+
92
95
 
93
96
  def _die(code: int, message: str) -> None:
94
97
  print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
@@ -184,6 +187,31 @@ def classify(live: list[str], registry: list[str]) -> dict:
184
187
  }
185
188
 
186
189
 
190
+ def parse_collection_list_output(raw: str) -> list[str]:
191
+ """Extract collection names from `qmd collection list` stdout.
192
+
193
+ qmd's output format varies by version. Newer builds print a
194
+ `Collections (N):` header, a blank line between entries, each entry as
195
+ `<name> (qmd://<name>/)`, and indented metadata lines (` Pattern:`,
196
+ ` Files:`). Older builds print one bare name per line. Both layouts are
197
+ handled: skip blank lines, the header, and indented metadata, then take
198
+ the first whitespace-delimited token of each remaining line — that strips
199
+ the trailing ` (qmd://name/)` URI and is a no-op for the bare-name form.
200
+ Without this, suffixed entries fail the `is_forge_owned` suffix check and
201
+ every forge collection is mis-classified as foreign.
202
+ """
203
+ names: list[str] = []
204
+ for line in raw.splitlines():
205
+ if not line.strip():
206
+ continue
207
+ if line[0].isspace(): # indented per-collection metadata
208
+ continue
209
+ if _QMD_HEADER_RE.match(line):
210
+ continue
211
+ names.append(line.split()[0])
212
+ return names
213
+
214
+
187
215
  def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
188
216
  """Invoke `qmd collection list` and return (names, error).
189
217
 
@@ -205,9 +233,7 @@ def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
205
233
  return [], f"qmd collection list failed: {e}"
206
234
  if result.returncode != 0:
207
235
  return [], f"qmd collection list exited {result.returncode}: {result.stderr.strip() or '<no stderr>'}"
208
- # Each non-empty line is a collection name (qmd's stable contract).
209
- names = [line.strip() for line in result.stdout.splitlines() if line.strip()]
210
- return names, None
236
+ return parse_collection_list_output(result.stdout), None
211
237
 
212
238
 
213
239
  def main() -> None:
@@ -21,6 +21,7 @@ Actions:
21
21
  from __future__ import annotations
22
22
 
23
23
  import json
24
+ import os
24
25
  import re
25
26
  import sys
26
27
  from datetime import datetime, timezone
@@ -47,6 +48,64 @@ def find_managed_section(content):
47
48
  return MARKER_PATTERN.search(content)
48
49
 
49
50
 
51
+ def _atomic_write(file_path, text):
52
+ """Stage `text` into <file>.skf-tmp, fsync, then os.replace into place.
53
+
54
+ Mirrors skf-atomic-write.py's `write` so the marker-surgery actions deliver
55
+ the crash-safety the step file documents: a mid-write process kill leaves
56
+ the original file intact rather than truncated. Cleans up the temp file on
57
+ failure. Raises OSError on any I/O failure.
58
+ """
59
+ path = Path(file_path)
60
+ tmp = path.with_name(path.name + ".skf-tmp")
61
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
62
+ # translation that would otherwise diverge the on-disk bytes from the staged
63
+ # string and trip the byte-identity verify below.
64
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
65
+ try:
66
+ fd = os.open(tmp, flags, 0o644)
67
+ try:
68
+ os.write(fd, text.encode("utf-8"))
69
+ os.fsync(fd)
70
+ finally:
71
+ os.close(fd)
72
+ os.replace(tmp, path)
73
+ except OSError:
74
+ if tmp.exists():
75
+ try:
76
+ tmp.unlink()
77
+ except OSError:
78
+ pass
79
+ raise
80
+
81
+
82
+ def _write_and_verify(file_path, updated, *, expect_section):
83
+ """Atomically write `updated`, then re-read to confirm integrity.
84
+
85
+ Returns None on success or an error string. The re-read asserts the on-disk
86
+ bytes match what was staged (so content outside the markers is byte-identical)
87
+ and that managed-section marker presence matches `expect_section`.
88
+ """
89
+ try:
90
+ _atomic_write(file_path, updated)
91
+ except OSError as e:
92
+ return f"atomic write failed: {e}"
93
+
94
+ # Re-read raw bytes (no newline translation) for a true byte-identity check.
95
+ try:
96
+ on_disk = Path(file_path).read_bytes()
97
+ except OSError as e:
98
+ return f"post-write verification failed: {e}"
99
+ if on_disk != updated.encode("utf-8"):
100
+ return "post-write verification failed: on-disk bytes do not match staged content"
101
+ if (find_managed_section(on_disk.decode("utf-8")) is not None) != expect_section:
102
+ return (
103
+ "post-write verification failed: managed section "
104
+ + ("missing after write" if expect_section else "still present after clear")
105
+ )
106
+ return None
107
+
108
+
50
109
  def cmd_check(file_path):
51
110
  """Check if managed section exists."""
52
111
  content, err = read_context_file(file_path)
@@ -112,7 +171,9 @@ def cmd_replace(file_path, new_content):
112
171
  new_section = f"<!-- SKF:BEGIN updated:{today} -->\n{new_content}\n{END_MARKER}"
113
172
  updated = content[: match.start()] + new_section + content[match.end() :]
114
173
 
115
- Path(file_path).write_text(updated, encoding="utf-8")
174
+ verify_err = _write_and_verify(file_path, updated, expect_section=True)
175
+ if verify_err:
176
+ return {"status": "error", "error": verify_err}
116
177
  return {"status": "ok", "action": "replaced", "bytes_written": len(updated)}
117
178
 
118
179
 
@@ -131,7 +192,9 @@ def cmd_clear(file_path):
131
192
  after = content[match.end() :].lstrip("\n")
132
193
  updated = before + ("\n\n" if before and after else "") + after
133
194
 
134
- Path(file_path).write_text(updated, encoding="utf-8")
195
+ verify_err = _write_and_verify(file_path, updated, expect_section=False)
196
+ if verify_err:
197
+ return {"status": "error", "error": verify_err}
135
198
  return {"status": "ok", "action": "cleared", "bytes_written": len(updated)}
136
199
 
137
200
 
@@ -153,7 +216,9 @@ def cmd_insert(file_path, new_content):
153
216
  section = f"\n<!-- SKF:BEGIN updated:{today} -->\n{new_content}\n{END_MARKER}\n"
154
217
  updated = content.rstrip("\n") + "\n" + section if content.strip() else section.lstrip("\n")
155
218
 
156
- Path(file_path).write_text(updated, encoding="utf-8")
219
+ verify_err = _write_and_verify(file_path, updated, expect_section=True)
220
+ if verify_err:
221
+ return {"status": "error", "error": verify_err}
157
222
  return {"status": "ok", "action": "inserted", "bytes_written": len(updated)}
158
223
 
159
224
 
@@ -179,15 +244,15 @@ def main():
179
244
  elif action == "read":
180
245
  result = cmd_read(file_path)
181
246
  elif action == "replace":
182
- if content_arg is None:
183
- result = {"status": "error", "error": "replace requires --content or stdin"}
247
+ if content_arg is None or not content_arg.strip():
248
+ result = {"status": "error", "error": "replace requires non-empty --content or stdin"}
184
249
  else:
185
250
  result = cmd_replace(file_path, content_arg)
186
251
  elif action == "clear":
187
252
  result = cmd_clear(file_path)
188
253
  elif action == "insert":
189
- if content_arg is None:
190
- result = {"status": "error", "error": "insert requires --content or stdin"}
254
+ if content_arg is None or not content_arg.strip():
255
+ result = {"status": "error", "error": "insert requires non-empty --content or stdin"}
191
256
  else:
192
257
  result = cmd_insert(file_path, content_arg)
193
258
  else: