bmad-module-skill-forge 1.5.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +1 -1
- package/package.json +2 -2
- package/src/knowledge/version-paths.md +4 -3
- package/src/shared/health-check.md +1 -1
- package/src/shared/scripts/schemas/skill-brief.v1.json +5 -0
- package/src/shared/scripts/skf-atomic-write.py +4 -1
- package/src/shared/scripts/skf-description-guard.py +33 -80
- package/src/shared/scripts/skf-detect-language.py +34 -0
- package/src/shared/scripts/skf-detect-workspaces.py +54 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +107 -16
- package/src/shared/scripts/skf-forge-tier-rw.py +4 -1
- package/src/shared/scripts/skf-manifest-ops.py +8 -4
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +4 -1
- package/src/shared/scripts/skf-qmd-classify-collections.py +29 -3
- package/src/shared/scripts/skf-rebuild-managed-sections.py +72 -7
- package/src/shared/scripts/skf-scan-skill-md-structure.py +13 -3
- package/src/shared/scripts/skf-validate-brief-inputs.py +79 -18
- package/src/shared/scripts/skf-validate-brief-schema.py +47 -1
- package/src/shared/scripts/skf-validate-frontmatter.py +60 -2
- package/src/shared/scripts/skf-write-skill-brief.py +97 -17
- package/src/skf-analyze-source/assets/skill-brief-schema.md +10 -2
- package/src/skf-analyze-source/references/continue.md +3 -1
- package/src/skf-analyze-source/references/generate-briefs.md +30 -9
- package/src/skf-analyze-source/references/recommend.md +1 -1
- package/src/skf-brief-skill/SKILL.md +4 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +6 -1
- package/src/skf-brief-skill/references/analyze-target.md +22 -6
- package/src/skf-brief-skill/references/confirm-brief.md +4 -0
- package/src/skf-brief-skill/references/gather-intent.md +72 -3
- package/src/skf-brief-skill/references/headless-args.md +3 -2
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +16 -8
- package/src/skf-brief-skill/references/scope-definition.md +20 -2
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +12 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +20 -1
- package/src/skf-create-skill/assets/skill-sections.md +2 -1
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +18 -5
- package/src/skf-create-skill/references/compile.md +5 -2
- package/src/skf-create-skill/references/extract.md +8 -3
- package/src/skf-create-skill/references/extraction-patterns.md +14 -5
- package/src/skf-create-skill/references/generate-artifacts.md +5 -1
- package/src/skf-create-skill/references/source-resolution-protocols.md +5 -2
- package/src/skf-create-skill/references/validate.md +4 -3
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +3 -3
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +56 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +13 -2
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -0
- package/src/skf-create-stack-skill/references/detect-integrations.md +5 -2
- package/src/skf-create-stack-skill/references/generate-output.md +49 -9
- package/src/skf-create-stack-skill/references/validate.md +8 -5
- package/src/skf-export-skill/references/load-skill.md +5 -4
- package/src/skf-export-skill/references/manifest-rebuild.md +1 -1
- package/src/skf-export-skill/references/token-report.md +2 -2
- package/src/skf-export-skill/references/update-context.md +25 -11
- package/src/skf-quick-skill/assets/skill-template.md +2 -2
- package/src/skf-quick-skill/references/compile.md +4 -2
- package/src/skf-quick-skill/references/write-and-validate.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +1 -1
- package/src/skf-refine-architecture/references/gap-analysis.md +30 -4
- package/src/skf-refine-architecture/references/init.md +2 -0
- package/src/skf-refine-architecture/references/issue-detection.md +8 -3
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/references/coherence-check.md +4 -4
- package/src/skf-test-skill/references/coverage-check.md +58 -16
- package/src/skf-test-skill/references/external-validators.md +7 -7
- package/src/skf-test-skill/references/init.md +5 -4
- package/src/skf-test-skill/references/score.md +6 -3
- package/src/skf-test-skill/references/scoring-rules.md +12 -0
- package/src/skf-test-skill/references/source-access-protocol.md +19 -3
- package/src/skf-test-skill/scripts/compute-score.py +4 -1
- package/src/skf-update-skill/references/detect-changes.md +15 -2
- package/src/skf-update-skill/references/init.md +8 -1
- package/src/skf-update-skill/references/merge.md +22 -0
- package/src/skf-update-skill/references/re-extract.md +19 -7
- package/src/skf-update-skill/references/write.md +3 -0
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/src/skf-verify-stack/assets/feasibility-report-template.md +1 -1
- package/src/skf-verify-stack/references/coverage.md +35 -6
- package/src/skf-verify-stack/references/init.md +6 -2
- package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
- package/src/skf-verify-stack/references/integrations.md +7 -3
- package/src/skf-verify-stack/references/report.md +5 -1
- package/src/skf-verify-stack/references/requirements.md +3 -1
- package/src/skf-verify-stack/references/synthesize.md +11 -3
- package/tools/cli/lib/installer.js +1 -0
package/docs/_data/pinned.yaml
CHANGED
|
@@ -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.
|
|
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 `{
|
|
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.
|
|
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.
|
|
123
|
-
5.
|
|
124
|
-
6. If
|
|
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: '{
|
|
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,
|
|
151
|
+
fd = os.open(tmp, flags, 0o644)
|
|
149
152
|
try:
|
|
150
153
|
os.write(fd, data)
|
|
151
154
|
os.fsync(fd)
|
|
@@ -160,17 +160,45 @@ def is_diverged(diff_kind: str) -> bool:
|
|
|
160
160
|
def restore_description(skill_md: Path, captured: str) -> None:
|
|
161
161
|
"""Atomically rewrite SKILL.md so its frontmatter `description` equals captured.
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
Parses the entire frontmatter via PyYAML, replaces the top-level
|
|
164
|
+
`description` value, and re-emits the frontmatter via `yaml.safe_dump`.
|
|
165
|
+
This guarantees valid YAML output regardless of the source representation
|
|
166
|
+
(inline, double-quoted, single-quoted, folded `>`, literal `|`), and
|
|
167
|
+
avoids the line-level pitfalls a previous implementation had with folded
|
|
168
|
+
block scalars and nested `description:` keys in sibling mappings.
|
|
169
|
+
|
|
170
|
+
Key order is preserved (PyYAML's `safe_dump` honours dict insertion order;
|
|
171
|
+
this module's `requires-python = ">=3.10"` guarantees ordered dicts).
|
|
172
|
+
Quoting style of *other* fields may change to whatever `safe_dump`
|
|
173
|
+
chooses for each scalar — downstream readers parse YAML, so any valid
|
|
174
|
+
YAML emission is acceptable.
|
|
167
175
|
"""
|
|
168
176
|
text = skill_md.read_text(encoding="utf-8")
|
|
169
177
|
leading, fm_yaml, body = _split_frontmatter(text)
|
|
170
178
|
if not fm_yaml:
|
|
171
179
|
raise ValueError(f"cannot restore: no frontmatter in {skill_md}")
|
|
172
180
|
|
|
173
|
-
|
|
181
|
+
try:
|
|
182
|
+
fm = yaml.safe_load(fm_yaml)
|
|
183
|
+
except yaml.YAMLError as exc:
|
|
184
|
+
raise ValueError(f"frontmatter in {skill_md} is not valid YAML: {exc}") from exc
|
|
185
|
+
if not isinstance(fm, dict):
|
|
186
|
+
raise ValueError(f"frontmatter in {skill_md} is not a mapping")
|
|
187
|
+
if "description" not in fm:
|
|
188
|
+
raise ValueError(f"frontmatter in {skill_md} has no `description` field")
|
|
189
|
+
|
|
190
|
+
fm["description"] = captured
|
|
191
|
+
|
|
192
|
+
# `width=10**9` keeps the description on one line regardless of length;
|
|
193
|
+
# PyYAML otherwise inserts line breaks at ~80 chars which would re-introduce
|
|
194
|
+
# folded-scalar continuation lines — the exact failure mode this rewrite fixes.
|
|
195
|
+
new_fm = yaml.safe_dump(
|
|
196
|
+
fm,
|
|
197
|
+
sort_keys=False,
|
|
198
|
+
default_flow_style=False,
|
|
199
|
+
allow_unicode=True,
|
|
200
|
+
width=10**9,
|
|
201
|
+
).rstrip("\n")
|
|
174
202
|
new_text = f"{leading}{new_fm}\n---\n{body}"
|
|
175
203
|
|
|
176
204
|
# atomic: write to a sibling temp file, fsync, rename
|
|
@@ -191,81 +219,6 @@ def restore_description(skill_md: Path, captured: str) -> None:
|
|
|
191
219
|
raise
|
|
192
220
|
|
|
193
221
|
|
|
194
|
-
def _rewrite_description_line(fm_yaml: str, new_description: str) -> str:
|
|
195
|
-
"""Replace the `description:` value in a YAML frontmatter block.
|
|
196
|
-
|
|
197
|
-
Preserves other keys verbatim. Handles three common shapes:
|
|
198
|
-
description: single-line value
|
|
199
|
-
description: "double-quoted value"
|
|
200
|
-
description: | # block scalar (folded variant: >)
|
|
201
|
-
multi-line
|
|
202
|
-
value here
|
|
203
|
-
"""
|
|
204
|
-
lines = fm_yaml.split("\n")
|
|
205
|
-
out: list[str] = []
|
|
206
|
-
i = 0
|
|
207
|
-
quoted = _yaml_quote_inline(new_description)
|
|
208
|
-
replaced = False
|
|
209
|
-
while i < len(lines):
|
|
210
|
-
line = lines[i]
|
|
211
|
-
if not replaced and _is_description_key_line(line):
|
|
212
|
-
stripped = line.lstrip()
|
|
213
|
-
indent = line[: len(line) - len(stripped)]
|
|
214
|
-
# detect block-scalar indicator (| or >)
|
|
215
|
-
after_key = stripped[len("description:") :].lstrip()
|
|
216
|
-
if after_key.startswith("|") or after_key.startswith(">"):
|
|
217
|
-
# skip block-scalar continuation lines (deeper indent than the key line)
|
|
218
|
-
key_indent = len(indent)
|
|
219
|
-
i += 1
|
|
220
|
-
while i < len(lines):
|
|
221
|
-
nxt = lines[i]
|
|
222
|
-
if nxt.strip() == "":
|
|
223
|
-
# blank lines belong to the block scalar
|
|
224
|
-
i += 1
|
|
225
|
-
continue
|
|
226
|
-
nxt_indent = len(nxt) - len(nxt.lstrip())
|
|
227
|
-
if nxt_indent <= key_indent:
|
|
228
|
-
break
|
|
229
|
-
i += 1
|
|
230
|
-
out.append(f"{indent}description: {quoted}")
|
|
231
|
-
replaced = True
|
|
232
|
-
continue
|
|
233
|
-
out.append(f"{indent}description: {quoted}")
|
|
234
|
-
replaced = True
|
|
235
|
-
i += 1
|
|
236
|
-
continue
|
|
237
|
-
out.append(line)
|
|
238
|
-
i += 1
|
|
239
|
-
if not replaced:
|
|
240
|
-
raise ValueError("description key not found while rewriting frontmatter")
|
|
241
|
-
return "\n".join(out)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _is_description_key_line(line: str) -> bool:
|
|
245
|
-
stripped = line.lstrip()
|
|
246
|
-
return stripped.startswith("description:") and (
|
|
247
|
-
len(stripped) == len("description:") or stripped[len("description:")] in (" ", "\t", "")
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _yaml_quote_inline(value: str) -> str:
|
|
252
|
-
"""Emit a YAML scalar suitable as the inline value of `description: `.
|
|
253
|
-
|
|
254
|
-
Uses double-quoted form so control characters and embedded quotes are
|
|
255
|
-
safe. Block scalars (| / >) are not used — the description field is a
|
|
256
|
-
single semantic string and downstream readers (skill-check, agentskills)
|
|
257
|
-
expect inline form.
|
|
258
|
-
"""
|
|
259
|
-
escaped = (
|
|
260
|
-
value.replace("\\", "\\\\")
|
|
261
|
-
.replace("\"", "\\\"")
|
|
262
|
-
.replace("\n", "\\n")
|
|
263
|
-
.replace("\r", "\\r")
|
|
264
|
-
.replace("\t", "\\t")
|
|
265
|
-
)
|
|
266
|
-
return f'"{escaped}"'
|
|
267
|
-
|
|
268
|
-
|
|
269
222
|
# --------------------------------------------------------------------------
|
|
270
223
|
# CLI
|
|
271
224
|
# --------------------------------------------------------------------------
|
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 —
|
|
420
|
-
#
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
if not child.is_dir():
|
|
437
|
-
continue
|
|
438
|
-
skill_dir = child
|
|
515
|
+
elif not child.is_dir():
|
|
516
|
+
continue
|
|
439
517
|
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
# silently —
|
|
443
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|