bmad-module-skill-forge 1.7.0 → 1.8.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/package.json +1 -1
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +56 -5
- package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
- package/src/skf-analyze-source/references/identify-units.md +41 -4
- package/src/skf-analyze-source/references/init.md +6 -0
- package/src/skf-analyze-source/references/scan-project.md +5 -2
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +16 -0
- package/src/skf-create-skill/references/generate-artifacts.md +5 -10
- package/src/skf-create-stack-skill/references/generate-output.md +5 -5
- package/src/skf-test-skill/references/source-access-protocol.md +14 -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.8.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/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.8.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",
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
"target_ref": {
|
|
25
25
|
"type": "string",
|
|
26
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."
|
|
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. Mutually exclusive with constituent_refs."
|
|
28
|
+
},
|
|
29
|
+
"constituent_refs": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": { "type": "string", "minLength": 1 },
|
|
32
|
+
"description": "Per-constituent git ref overrides for composite (multi-repo or multi-ref) sources. Keys must match entries in the analyze-source project_paths array. When absent, a single target_ref or auto-detected ref applies to all paths. Mutually exclusive with target_ref."
|
|
28
33
|
},
|
|
29
34
|
"source_type": {
|
|
30
35
|
"type": "string",
|
|
@@ -42,6 +42,17 @@ Subcommands:
|
|
|
42
42
|
collections without re-rendering the whole file in
|
|
43
43
|
prose.
|
|
44
44
|
|
|
45
|
+
register-ccc-index
|
|
46
|
+
Append-or-replace a single entry in the
|
|
47
|
+
`ccc_index_registry` array. Reads the entry as JSON on
|
|
48
|
+
stdin (must include `source_repo` and `skill_name`;
|
|
49
|
+
composite key `source_repo`+`skill_name` is the upsert
|
|
50
|
+
key — existing entry with the same composite key is
|
|
51
|
+
replaced, otherwise appended). All other forge-tier
|
|
52
|
+
state is preserved verbatim. Used by skf-create-skill
|
|
53
|
+
§6b to register CCC-indexed source paths without
|
|
54
|
+
re-rendering the whole file in prose.
|
|
55
|
+
|
|
45
56
|
clean-stale Two cleanup operations gated by flags:
|
|
46
57
|
--qmd-live-names a,b,c — remove qmd_collections
|
|
47
58
|
entries whose `name` is not in the comma-separated
|
|
@@ -74,6 +85,7 @@ system-wide:
|
|
|
74
85
|
uv run skf-forge-tier-rw.py read --target /path/forge-tier.yaml
|
|
75
86
|
echo '{...}' | uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml
|
|
76
87
|
uv run skf-forge-tier-rw.py init-prefs --target /path/preferences.yaml
|
|
88
|
+
echo '{...}' | uv run skf-forge-tier-rw.py register-ccc-index --target /path/forge-tier.yaml
|
|
77
89
|
uv run skf-forge-tier-rw.py clean-stale --target /path/forge-tier.yaml \\
|
|
78
90
|
--qmd-live-names foo-brief,bar-extraction --prune-missing-ccc-paths
|
|
79
91
|
"""
|
|
@@ -373,6 +385,61 @@ def cmd_register_qmd_collection(target: Path) -> None:
|
|
|
373
385
|
})
|
|
374
386
|
|
|
375
387
|
|
|
388
|
+
def cmd_register_ccc_index(target: Path) -> None:
|
|
389
|
+
raw = sys.stdin.read()
|
|
390
|
+
if not raw.strip():
|
|
391
|
+
_die(1, "register-ccc-index: empty stdin (expected JSON entry)")
|
|
392
|
+
try:
|
|
393
|
+
entry = json.loads(raw)
|
|
394
|
+
except json.JSONDecodeError as e:
|
|
395
|
+
_die(1, f"register-ccc-index: invalid JSON on stdin: {e}")
|
|
396
|
+
|
|
397
|
+
if not isinstance(entry, dict):
|
|
398
|
+
_die(1, "register-ccc-index: entry must be a JSON object")
|
|
399
|
+
source_repo = entry.get("source_repo")
|
|
400
|
+
skill_name = entry.get("skill_name")
|
|
401
|
+
if not source_repo or not isinstance(source_repo, str):
|
|
402
|
+
_die(1, "register-ccc-index: entry must include a non-empty 'source_repo' string")
|
|
403
|
+
if not skill_name or not isinstance(skill_name, str):
|
|
404
|
+
_die(1, "register-ccc-index: entry must include a non-empty 'skill_name' string")
|
|
405
|
+
|
|
406
|
+
data = _read_yaml(target)
|
|
407
|
+
if data is None:
|
|
408
|
+
_die(1, f"register-ccc-index: target does not exist: {target}. "
|
|
409
|
+
f"Run setup workflow first to create forge-tier.yaml.")
|
|
410
|
+
|
|
411
|
+
registry = list(data.get("ccc_index_registry") or [])
|
|
412
|
+
replaced = False
|
|
413
|
+
for i, existing in enumerate(registry):
|
|
414
|
+
if (isinstance(existing, dict)
|
|
415
|
+
and existing.get("source_repo") == source_repo
|
|
416
|
+
and existing.get("skill_name") == skill_name):
|
|
417
|
+
registry[i] = entry
|
|
418
|
+
replaced = True
|
|
419
|
+
break
|
|
420
|
+
if not replaced:
|
|
421
|
+
registry.append(entry)
|
|
422
|
+
|
|
423
|
+
payload = {
|
|
424
|
+
"tools": data.get("tools", {}),
|
|
425
|
+
"tier": data.get("tier", "Quick"),
|
|
426
|
+
"tier_detected_at": data.get("tier_detected_at",
|
|
427
|
+
datetime.now(timezone.utc).isoformat()),
|
|
428
|
+
"ccc_index": data.get("ccc_index", {}),
|
|
429
|
+
"ccc_index_registry": registry,
|
|
430
|
+
"qmd_collections": data.get("qmd_collections", []),
|
|
431
|
+
}
|
|
432
|
+
rendered = render_forge_tier_yaml(payload)
|
|
433
|
+
_atomic_write(target, rendered)
|
|
434
|
+
_ok({
|
|
435
|
+
"source_repo": source_repo,
|
|
436
|
+
"skill_name": skill_name,
|
|
437
|
+
"action": "replaced" if replaced else "appended",
|
|
438
|
+
"ccc_index_registry_count": len(registry),
|
|
439
|
+
"wrote": str(target),
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
|
|
376
443
|
def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
|
|
377
444
|
prune_missing_ccc_paths: bool) -> None:
|
|
378
445
|
data = _read_yaml(target)
|
|
@@ -468,6 +535,10 @@ def main() -> None:
|
|
|
468
535
|
help="Append-or-replace a single qmd_collections entry by name")
|
|
469
536
|
p_register.add_argument("--target", type=Path, required=True)
|
|
470
537
|
|
|
538
|
+
p_ccc = sub.add_parser("register-ccc-index",
|
|
539
|
+
help="Append-or-replace a single ccc_index_registry entry by source_repo+skill_name")
|
|
540
|
+
p_ccc.add_argument("--target", type=Path, required=True)
|
|
541
|
+
|
|
471
542
|
args = parser.parse_args()
|
|
472
543
|
|
|
473
544
|
if args.cmd == "read":
|
|
@@ -483,6 +554,8 @@ def main() -> None:
|
|
|
483
554
|
cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
|
|
484
555
|
elif args.cmd == "register-qmd-collection":
|
|
485
556
|
cmd_register_qmd_collection(args.target)
|
|
557
|
+
elif args.cmd == "register-ccc-index":
|
|
558
|
+
cmd_register_ccc_index(args.target)
|
|
486
559
|
|
|
487
560
|
|
|
488
561
|
if __name__ == "__main__":
|
|
@@ -21,6 +21,7 @@ system-wide:
|
|
|
21
21
|
uv run skf-validate-frontmatter.py <skill-md-path>
|
|
22
22
|
uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
|
|
23
23
|
uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500
|
|
24
|
+
uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500 --max-body-tokens 5000
|
|
24
25
|
|
|
25
26
|
Input:
|
|
26
27
|
Path to a SKILL.md file.
|
|
@@ -31,6 +32,11 @@ Input:
|
|
|
31
32
|
exceeds N lines. Opt-in — when omitted, body size is never checked and
|
|
32
33
|
the verdict is unchanged. Callers pass the skill-check `body.max_lines`
|
|
33
34
|
default (500) to pre-catch that hard reject before commit.
|
|
35
|
+
Optional --max-body-tokens N: when set, emit a high-severity `body`
|
|
36
|
+
issue if the estimated body token count exceeds N. Token count is
|
|
37
|
+
estimated as ceil(character_count / 4). Callers pass the skill-check
|
|
38
|
+
`body.max_tokens` default (5000) to pre-catch that soft/hard reject
|
|
39
|
+
before commit.
|
|
34
40
|
|
|
35
41
|
Output:
|
|
36
42
|
JSON object:
|
|
@@ -154,6 +160,28 @@ def body_line_count(content: str) -> int | None:
|
|
|
154
160
|
return len(lines) - (closing + 1)
|
|
155
161
|
|
|
156
162
|
|
|
163
|
+
def body_token_estimate(content: str) -> int | None:
|
|
164
|
+
"""Estimate the SKILL.md body token count as ceil(char_count / 4).
|
|
165
|
+
|
|
166
|
+
Returns None when the frontmatter delimiters are absent or unclosed
|
|
167
|
+
(same boundary condition as body_line_count). The heuristic matches
|
|
168
|
+
the agentskills.io spec's token estimation convention.
|
|
169
|
+
"""
|
|
170
|
+
if not content.startswith("---\n") and not content.startswith("---\r\n"):
|
|
171
|
+
return None
|
|
172
|
+
lines = content.splitlines()
|
|
173
|
+
closing = -1
|
|
174
|
+
for i in range(1, len(lines)):
|
|
175
|
+
if lines[i] == "---":
|
|
176
|
+
closing = i
|
|
177
|
+
break
|
|
178
|
+
if closing == -1:
|
|
179
|
+
return None
|
|
180
|
+
body = "\n".join(lines[closing + 1:])
|
|
181
|
+
char_count = len(body)
|
|
182
|
+
return -(-char_count // 4) # ceil division
|
|
183
|
+
|
|
184
|
+
|
|
157
185
|
def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
|
|
158
186
|
"""Validate skill name format. Aligned with canonical validator.py."""
|
|
159
187
|
issues: list[dict] = []
|
|
@@ -220,14 +248,16 @@ def validate_frontmatter(
|
|
|
220
248
|
content: str,
|
|
221
249
|
skill_dir_name: str | None = None,
|
|
222
250
|
max_body_lines: int | None = None,
|
|
251
|
+
max_body_tokens: int | None = None,
|
|
223
252
|
) -> dict:
|
|
224
253
|
"""Validate SKILL.md frontmatter against agentskills.io spec.
|
|
225
254
|
|
|
226
255
|
When `max_body_lines` is set, additionally emit a high-severity `body`
|
|
227
|
-
issue if the body exceeds that many lines
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
issue if the body exceeds that many lines. When `max_body_tokens` is
|
|
257
|
+
set, emit a high-severity `body` issue if the estimated token count
|
|
258
|
+
exceeds that limit. Both are opt-in — the verdict is unchanged when
|
|
259
|
+
they are None. Returns a result dict with status, issues, frontmatter,
|
|
260
|
+
body_lines, body_tokens, and summary.
|
|
231
261
|
"""
|
|
232
262
|
fm, issues = parse_frontmatter(content)
|
|
233
263
|
|
|
@@ -239,6 +269,14 @@ def validate_frontmatter(
|
|
|
239
269
|
"message": f"body lines {body_lines} exceeds max {max_body_lines}",
|
|
240
270
|
})
|
|
241
271
|
|
|
272
|
+
body_tokens = body_token_estimate(content)
|
|
273
|
+
if max_body_tokens is not None and body_tokens is not None and body_tokens > max_body_tokens:
|
|
274
|
+
issues.append({
|
|
275
|
+
"severity": "high",
|
|
276
|
+
"field": "body",
|
|
277
|
+
"message": f"body token estimate {body_tokens} exceeds max {max_body_tokens}",
|
|
278
|
+
})
|
|
279
|
+
|
|
242
280
|
if fm is not None:
|
|
243
281
|
# Name validation
|
|
244
282
|
name = fm.get("name", "")
|
|
@@ -301,6 +339,7 @@ def validate_frontmatter(
|
|
|
301
339
|
"issues": issues,
|
|
302
340
|
"frontmatter": fm,
|
|
303
341
|
"body_lines": body_lines,
|
|
342
|
+
"body_tokens": body_tokens,
|
|
304
343
|
"summary": {
|
|
305
344
|
"total": len(issues),
|
|
306
345
|
**severity_counts,
|
|
@@ -345,6 +384,18 @@ def main() -> int:
|
|
|
345
384
|
"the skill-check body.max_lines default (500) to pre-catch that reject."
|
|
346
385
|
),
|
|
347
386
|
)
|
|
387
|
+
parser.add_argument(
|
|
388
|
+
"--max-body-tokens",
|
|
389
|
+
metavar="N",
|
|
390
|
+
type=int,
|
|
391
|
+
default=None,
|
|
392
|
+
help=(
|
|
393
|
+
"when set, emit a high-severity 'body' issue if the estimated body "
|
|
394
|
+
"token count exceeds N (opt-in; omitted = body tokens not checked). "
|
|
395
|
+
"Token estimate uses ceil(char_count / 4). Pass the skill-check "
|
|
396
|
+
"body.max_tokens default (5000) to pre-catch that reject."
|
|
397
|
+
),
|
|
398
|
+
)
|
|
348
399
|
parser.add_argument(
|
|
349
400
|
"-o", "--output",
|
|
350
401
|
metavar="FILE",
|
|
@@ -365,7 +416,7 @@ def main() -> int:
|
|
|
365
416
|
else:
|
|
366
417
|
content = skill_md_path.read_text(encoding="utf-8")
|
|
367
418
|
skill_dir_name = args.skill_dir_name or skill_md_path.parent.name
|
|
368
|
-
result = validate_frontmatter(content, skill_dir_name, args.max_body_lines)
|
|
419
|
+
result = validate_frontmatter(content, skill_dir_name, args.max_body_lines, args.max_body_tokens)
|
|
369
420
|
|
|
370
421
|
output_text = json.dumps(result, indent=2)
|
|
371
422
|
|
|
@@ -27,7 +27,8 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
|
|
|
27
27
|
| `scripts_intent` | string | `detect` / `none` / free-text | Describes whether scripts should be extracted. Values: `detect` (auto-detect from source — default when absent), `none` (skip scripts), or a free-text description of expected scripts (e.g., "CLI validation tools in bin/"). |
|
|
28
28
|
| `assets_intent` | string | `detect` / `none` / free-text | Describes whether assets should be extracted. Values: `detect` (auto-detect from source — default when absent), `none` (skip assets), or a free-text description of expected assets (e.g., "JSON schemas in schemas/"). |
|
|
29
29
|
| `target_version` | string | Semantic version (`X.Y.Z` or `X.Y.Z-prerelease`) | User-specified target version. When present, overrides auto-detection and becomes the skill's version. Recommended for docs-only skills where auto-detection is unavailable. |
|
|
30
|
-
| `target_ref` | string | Git ref (tag or branch) | Optional. Explicit git ref used verbatim as the resolved `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. |
|
|
30
|
+
| `target_ref` | string | Git ref (tag or branch) | Optional. Explicit git ref used verbatim as the resolved `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. Mutually exclusive with `constituent_refs`. |
|
|
31
|
+
| `constituent_refs` | object | map of path→ref strings | Optional. Per-constituent git ref overrides for composite (multi-repo or multi-ref) sources. Keys must match entries in the analyze-source `project_paths` array. When absent, a single `target_ref` (or auto-detected ref) applies to all paths. Mutually exclusive with `target_ref`. |
|
|
31
32
|
| `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
|
|
32
33
|
| `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
|
|
33
34
|
|
|
@@ -48,6 +48,7 @@ For EACH detected boundary from the scan:
|
|
|
48
48
|
- Package Boundary — workspace member or independently versioned
|
|
49
49
|
- Module Boundary — logical grouping within a package
|
|
50
50
|
- Library Boundary — third-party with significant project-specific usage
|
|
51
|
+
- Composite Boundary — ≥2 boundaries that only deliver value together (detected in §3b below; not assigned during initial per-boundary classification)
|
|
51
52
|
|
|
52
53
|
**Step C — Assign scope type:**
|
|
53
54
|
- `full-library` — entire codebase of the unit
|
|
@@ -105,9 +106,28 @@ For disqualified candidates, note reason:
|
|
|
105
106
|
|------|--------|
|
|
106
107
|
| {path} | {disqualification reason} |
|
|
107
108
|
|
|
109
|
+
### 3b. Detect Composite Unit Merges
|
|
110
|
+
|
|
111
|
+
After building the classification table, apply the Composite Boundary detection heuristic from {heuristicsFile} against the qualifying units:
|
|
112
|
+
|
|
113
|
+
1. **Scan for merge candidates:** Among the qualifying units (from `kept[]`), find groups of ≥2 Package or Module boundaries that meet EITHER trigger:
|
|
114
|
+
- **Mutual hard dependency:** Every constituent imports from at least one other constituent in the group, AND no constituent's public API is self-contained
|
|
115
|
+
- **Shared integration surface:** Constituents share types/traits defined in one constituent but consumed by all others, AND the consuming constituents have no independent barrel
|
|
116
|
+
|
|
117
|
+
2. **If candidate groups are found**, propose each merge:
|
|
118
|
+
- Derive a composite name from the common namespace prefix or repo name
|
|
119
|
+
- List the constituents (boundary names and paths being merged)
|
|
120
|
+
- State the triggering heuristic and evidence
|
|
121
|
+
|
|
122
|
+
3. **If no candidate groups are found**, skip to §4.
|
|
123
|
+
|
|
124
|
+
**Merge does NOT fire for:** Units already flagged as Stack Skill Candidates in step 4 (map-and-detect §5) — those are multi-unit groupings that deliver value *separately* but are *also* useful together. Composite merges are for units that are *only* useful together (the key distinction). If a group of units is independently useful but commonly combined, it remains as separate units and is flagged as a stack skill candidate later.
|
|
125
|
+
|
|
126
|
+
**This step is a recommendation — not automatic.** Merges are presented to the user in §5 for confirmation (see "Composite Merge Proposals" below). If the user rejects a merge, the constituents remain as separate units in the classification table.
|
|
127
|
+
|
|
108
128
|
### 4. Detect Primary Language Per Unit
|
|
109
129
|
|
|
110
|
-
For each qualifying unit, determine the primary programming language based on:
|
|
130
|
+
For each qualifying unit (including any approved composites from §3b), determine the primary programming language based on:
|
|
111
131
|
- File extensions in the unit directory
|
|
112
132
|
- Manifest file type (package.json → JS/TS, Cargo.toml → Rust, go.mod → Go, etc.)
|
|
113
133
|
- Entry point file extension
|
|
@@ -126,20 +146,34 @@ For each qualifying unit, determine the primary programming language based on:
|
|
|
126
146
|
**Already-Skilled Units:** {count from existing_skills match}
|
|
127
147
|
{List with recommendation to run update-skill if source has changed}
|
|
128
148
|
|
|
149
|
+
{IF composite merge proposals exist from §3b:}
|
|
150
|
+
|
|
151
|
+
**Composite Merge Proposals:** {count}
|
|
152
|
+
|
|
153
|
+
| # | Composite Name | Constituents | Heuristic | Evidence |
|
|
154
|
+
|---|----------------|--------------|-----------|----------|
|
|
155
|
+
| 1 | {name} | {list of constituent unit names} | {mutual hard dependency / shared integration surface} | {brief evidence} |
|
|
156
|
+
|
|
157
|
+
If approved, each composite replaces its constituents in the classification table as a single Composite Boundary unit. The constituents are recorded in the composite's metadata for downstream workflows (create-skill reads constituents to scope extraction across all member paths).
|
|
158
|
+
|
|
159
|
+
{END IF}
|
|
160
|
+
|
|
129
161
|
**Notes:**
|
|
130
162
|
- {Any observations about project structure patterns}
|
|
131
163
|
- {Any ambiguous boundaries that need user clarification}
|
|
132
164
|
|
|
133
|
-
Do these classifications look correct? Should any units be added, removed, or reclassified?
|
|
165
|
+
Do these classifications look correct? Should any units be added, removed, or reclassified?
|
|
166
|
+
{IF composites proposed:} Are the composite merge proposals correct? (Accept/reject each individually.)"
|
|
134
167
|
|
|
135
|
-
Wait for user feedback. Adjust classifications based on user input.
|
|
168
|
+
Wait for user feedback. Adjust classifications based on user input. For approved composites: remove the constituent rows from the qualifying units table and add a single composite row with `Boundary Type: Composite`, scope type inherited from the dominant constituent, and confidence reflecting the merge heuristic strength.
|
|
136
169
|
|
|
137
170
|
### 6. Append to Report
|
|
138
171
|
|
|
139
172
|
Append the complete "## Identified Units" section to {outputFile}:
|
|
140
173
|
|
|
141
174
|
Replace the placeholder `[Appended by identify-units]` with:
|
|
142
|
-
- Classification table (qualifying units)
|
|
175
|
+
- Classification table (qualifying units, including approved composites)
|
|
176
|
+
- Composite merge details (if any): composite name, constituents list, heuristic, evidence
|
|
143
177
|
- Disqualification table
|
|
144
178
|
- Already-skilled units list
|
|
145
179
|
- Language detection results
|
|
@@ -149,8 +183,11 @@ Update {outputFile} frontmatter:
|
|
|
149
183
|
```yaml
|
|
150
184
|
stepsCompleted: [append 'identify-units' to existing array]
|
|
151
185
|
lastStep: 'identify-units'
|
|
186
|
+
confirmed_composites: [{list of approved composite merge objects: {name, constituents[], heuristic}}]
|
|
152
187
|
```
|
|
153
188
|
|
|
189
|
+
(`confirmed_composites` is an empty array when no composites were proposed or all were rejected.)
|
|
190
|
+
|
|
154
191
|
### 7. Present MENU OPTIONS
|
|
155
192
|
|
|
156
193
|
Display: "**Select:** [C] Continue to Export Mapping and Integration Detection | [X] Cancel and exit"
|
|
@@ -50,6 +50,8 @@ Look for {outputFile}.
|
|
|
50
50
|
|
|
51
51
|
**Headless flag consumption:** If `project_paths[]` is already populated (e.g. collected by the section-1 stale-collision guard) OR `--project-path <path>` was passed at invocation, set/keep `project_paths[]` (comma-split the flag value if multiple paths were supplied), skip the prompt below, and proceed to validation. Otherwise prompt as today.
|
|
52
52
|
|
|
53
|
+
**Per-path ref overrides (`--target-refs`):** If `--target-refs <mapping>` was passed at invocation, parse it as a comma-separated list of `path:ref` pairs (e.g., `owner/repo:v1.0.0,owner/repo2:main`). Build a `constituent_refs` map from the pairs. Each key must match an entry in `project_paths[]` (validated after path collection). When `--target-refs` is absent but multiple `project_paths` exist, set `constituent_refs` to `{}` (empty — all paths use default ref resolution). When only a single path exists, omit `constituent_refs` entirely (use `target_ref` if set on the brief). `constituent_refs` and `target_ref` are mutually exclusive — if both are supplied, HALT with: "`--target-refs` and `--target-ref` are mutually exclusive. Use `--target-refs` for multi-path analysis, or `--target-ref` for single-path."
|
|
54
|
+
|
|
53
55
|
"**Welcome to Analyze Source — the SKF decomposition engine.**
|
|
54
56
|
|
|
55
57
|
I'll analyze your project to identify discrete skillable units and produce skill-brief.yaml files for each recommended unit.
|
|
@@ -71,6 +73,7 @@ Wait for user input.
|
|
|
71
73
|
- For each provided path/URL: check that it exists (local) or is accessible (remote)
|
|
72
74
|
- **IF any invalid:** "Path `{path}` doesn't appear to be valid. Please correct it."
|
|
73
75
|
- Store as `project_paths[]` array in report frontmatter (single path stored as 1-element array for consistency)
|
|
76
|
+
- **IF `constituent_refs` was built from `--target-refs`:** Validate that every key in the map matches an entry in `project_paths[]`. If any key has no matching path, HALT: "constituent_refs key `{key}` does not match any entry in project_paths."
|
|
74
77
|
|
|
75
78
|
**Collect intent hint** (drives recommendation ranking in Step 5):
|
|
76
79
|
|
|
@@ -127,6 +130,7 @@ date: '{current_date}'
|
|
|
127
130
|
user_name: '{user_name}'
|
|
128
131
|
project_name: '{project_name}'
|
|
129
132
|
project_paths: ['{provided_project_path}']
|
|
133
|
+
constituent_refs: {map from --target-refs, or omit if single path}
|
|
130
134
|
forge_tier: '{detected_tier}'
|
|
131
135
|
existing_skills: [{list of existing skill names}]
|
|
132
136
|
intent_hint: '{intent_hint or empty string}'
|
|
@@ -136,6 +140,8 @@ stack_skill_candidates: []
|
|
|
136
140
|
nextWorkflow: ''
|
|
137
141
|
```
|
|
138
142
|
|
|
143
|
+
**`constituent_refs` presence rules:** Include the field only when `project_paths` has more than one entry. When present, keys are path strings matching `project_paths[]` entries, values are explicit git refs (tag/branch/commit). Paths with no explicit ref have no entry in the map (default ref resolution applies). Downstream steps (scan-project, brief generation) read this map to resolve per-constituent refs when cloning or reading off-HEAD constituents.
|
|
144
|
+
|
|
139
145
|
"**Initialization complete.**
|
|
140
146
|
|
|
141
147
|
**Project:** {project_path}
|
|
@@ -28,6 +28,7 @@ To map the complete project structure by scanning directory trees, detecting ser
|
|
|
28
28
|
|
|
29
29
|
Read {outputFile} frontmatter to obtain:
|
|
30
30
|
- `project_paths[]` — the root(s) to scan (one or more paths/URLs)
|
|
31
|
+
- `constituent_refs` — optional per-path git ref overrides (present only when `project_paths` has multiple entries and explicit refs were supplied via `--target-refs`)
|
|
31
32
|
- `forge_tier` — determines scanning depth
|
|
32
33
|
- Scope hints (if any were provided in step 01)
|
|
33
34
|
|
|
@@ -37,14 +38,16 @@ Load {heuristicsFile} for reference on detection signals.
|
|
|
37
38
|
|
|
38
39
|
**Resolve `{scanManifestsHelper}`** from `{scanManifestsProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
39
40
|
|
|
40
|
-
**For each path in `project_paths[]`**, launch a subprocess that scans the project directory structure (aggregate results across all repos with clear repo-level grouping):
|
|
41
|
+
**For each path in `project_paths[]`**, resolve the constituent ref (if any) and launch a subprocess that scans the project directory structure (aggregate results across all repos with clear repo-level grouping):
|
|
42
|
+
|
|
43
|
+
**Per-path ref resolution:** If `constituent_refs` is present and contains an entry for the current path, use that ref. For remote paths, this means cloning or fetching at the specified ref (`git clone --branch {ref} --depth 1` or `git show {ref}:<subpath>` for off-HEAD access). For local paths with a non-HEAD ref, check out or read from the specified ref using `git show {ref}:<path>`. When no `constituent_refs` entry exists for a path, use default ref resolution (HEAD for local, latest tag or HEAD for remote).
|
|
41
44
|
|
|
42
45
|
1. Map the top-level directory tree (2-3 levels deep)
|
|
43
46
|
2. Identify workspace configuration files (pnpm-workspace.yaml, lerna.json, Cargo.toml [workspace], go.work, etc.)
|
|
44
47
|
3. Enumerate package manifests deterministically — invoke `uv run {scanManifestsHelper} scan {path}` and parse the JSON envelope. The script returns `{manifests[], total_unique, monorepo, warnings?}` covering npm/python/rust/go/maven/gradle/ruby/composer/swift; record each `{path, ecosystem}` for the manifests catalog in §4 and capture `monorepo` for the boundary-signal pass in §3
|
|
45
48
|
4. Locate entry point files (index.ts, main.ts, app.ts, main.go, main.rs, __init__.py, etc.)
|
|
46
49
|
5. Detect service configuration (Dockerfile, docker-compose.yml, kubernetes manifests, serverless.yml) — keep this step LLM-driven; file glob + presence check is sufficient, no parsing required
|
|
47
|
-
6. Return structured findings — file paths and types only, not contents
|
|
50
|
+
6. Return structured findings — file paths and types only, not contents. When `constituent_refs` was used, include the resolved ref in each repo-level result group: `{path, ref, manifests[], monorepo, warnings?}`
|
|
48
51
|
|
|
49
52
|
**If subprocess unavailable:** Perform directory scanning in main thread using file I/O tools.
|
|
50
53
|
|
|
@@ -70,6 +70,22 @@ Rules for identifying discrete skillable units within a project. A "skillable un
|
|
|
70
70
|
- Props interfaces outnumber function signatures as primary API surface
|
|
71
71
|
- Scope type: `component-library`
|
|
72
72
|
|
|
73
|
+
### Composite Boundary
|
|
74
|
+
- Two or more Package or Module boundaries that only deliver value together (no constituent is independently useful to the skill consumer)
|
|
75
|
+
- Hard cross-boundary dependency: constituents share types, traits, or interfaces that are not re-exported through a single barrel — consumers must import from multiple constituents to use the integration
|
|
76
|
+
- Common pattern: a set of crates/packages in the same repo that implement a protocol together (e.g., plugin crates for a framework, verification + encoding halves of a cryptographic library)
|
|
77
|
+
- Scope type: inherits from the dominant constituent (typically `full-library` or `specific-modules`)
|
|
78
|
+
|
|
79
|
+
**Detection heuristic (apply after initial classification, before user confirmation):**
|
|
80
|
+
1. Among the qualifying units, find groups of ≥2 boundaries where EITHER:
|
|
81
|
+
- **Mutual hard dependency:** Every constituent imports from at least one other constituent in the group, AND no constituent's public API is self-contained (removing any one breaks the others)
|
|
82
|
+
- **Shared integration surface:** Constituents share types/traits defined in one constituent but consumed by all others, AND the consuming constituents have no independent barrel (their value depends on the shared definitions)
|
|
83
|
+
2. For each detected group, propose merging into a single composite unit:
|
|
84
|
+
- Name: `{common-prefix}` or `{integration-name}` (derive from shared namespace or repo name)
|
|
85
|
+
- Constituents: list of merged boundary names and paths
|
|
86
|
+
- Rationale: which heuristic triggered (mutual hard dependency or shared integration surface)
|
|
87
|
+
3. The merge is a **recommendation** — present to user for confirmation in identify-units §3b
|
|
88
|
+
|
|
73
89
|
## Disqualification Rules
|
|
74
90
|
|
|
75
91
|
Do NOT recommend as a skillable unit if:
|
|
@@ -195,19 +195,14 @@ Ensure the source path used for extraction is indexed by ccc and registered in t
|
|
|
195
195
|
|
|
196
196
|
**Registry update:**
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
Register the indexed source path via `register-ccc-index` (comment-preserving round-trip, same pattern as §6's `register-qmd-collection`). Acquire an exclusive `flock` on `{sidecar_path}/forge-tier.yaml.lock`, then:
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
- source_repo: "{brief.source_repo}"
|
|
204
|
-
path: "{source_root}"
|
|
205
|
-
skill_name: "{name}"
|
|
206
|
-
indexed_at: "{current ISO date}"
|
|
207
|
-
source_workflow: "create-skill"
|
|
200
|
+
```bash
|
|
201
|
+
echo '{"source_repo":"{brief.source_repo}","path":"{source_root}","skill_name":"{name}","indexed_at":"{current ISO date}","source_workflow":"create-skill"}' \
|
|
202
|
+
| uv run {forgeTierRw} register-ccc-index --target {forgeTierConfig}
|
|
208
203
|
```
|
|
209
204
|
|
|
210
|
-
|
|
205
|
+
Deduplicates by `source_repo` + `skill_name` (NOT local `path`, which may be ephemeral). Release the lock after the command completes. If `flock` is unavailable, fall back to read-CAS-by-mtime.
|
|
211
206
|
|
|
212
207
|
**Error handling:** If ccc indexing or registry update fails, log and continue — do NOT fail the workflow.
|
|
213
208
|
|
|
@@ -226,19 +226,19 @@ Use the schema from `{provenanceMapSchemaPath}` — see that asset for the canon
|
|
|
226
226
|
|
|
227
227
|
### 8. Pre-Commit Frontmatter & Body-Size Gate
|
|
228
228
|
|
|
229
|
-
The full schema/frontmatter validation runs in step 8 (`validate.md`) — but that runs *after* commit-dir and flip-link have already published the package. The
|
|
229
|
+
The full schema/frontmatter validation runs in step 8 (`validate.md`) — but that runs *after* commit-dir and flip-link have already published the package. The most common non-auto-fixable `skill-check` hard rejects — a `description` over the 1024-char limit (which `skill-check --fix` cannot trim for you), a SKILL.md body over the `body.max_lines` limit (default **500**), and a body exceeding the `body.max_tokens` limit (default **5000**) — would therefore only surface on an already-committed, symlink-active artifact, forcing edits to the live `SKILL.md`. Additive re-composition of an already-large stack grows the body monotonically, so body overflow (lines or tokens) is the likeliest trigger. Catch all three here, while the package is still in `.skf-tmp`.
|
|
230
230
|
|
|
231
|
-
Resolve `{frontmatterValidator}` from `{frontmatterValidatorProbeOrder}` (first existing path wins). Run it against the **staged** `SKILL.md`, passing the real skill name so the directory-match check is not fooled by the `.skf-tmp` staging suffix,
|
|
231
|
+
Resolve `{frontmatterValidator}` from `{frontmatterValidatorProbeOrder}` (first existing path wins). Run it against the **staged** `SKILL.md`, passing the real skill name so the directory-match check is not fooled by the `.skf-tmp` staging suffix, `--max-body-lines 500` to assert the skill-check body-line limit, and `--max-body-tokens 5000` to assert the skill-check body-token limit pre-commit:
|
|
232
232
|
|
|
233
233
|
```bash
|
|
234
|
-
uv run {frontmatterValidator} {skill_staging}/SKILL.md --skill-dir-name {project_name}-stack --max-body-lines 500
|
|
234
|
+
uv run {frontmatterValidator} {skill_staging}/SKILL.md --skill-dir-name {project_name}-stack --max-body-lines 500 --max-body-tokens 5000
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
-
The validator emits JSON: `status` (`pass`/`warn`/`fail`), `issues[]` (each with `severity` ∈ `high|medium|low`, `field`, `message`), `body_lines` (the counted body size), and `summary`. Disposition:
|
|
237
|
+
The validator emits JSON: `status` (`pass`/`warn`/`fail`), `issues[]` (each with `severity` ∈ `high|medium|low`, `field`, `message`), `body_lines` (the counted body size), `body_tokens` (the estimated token count), and `summary`. Disposition:
|
|
238
238
|
|
|
239
239
|
- **`status` is `fail`, OR any `issues[]` entry has `severity` `high` or `medium`** — a hard violation that `npx skill-check` (step 8) would reject and `--fix` cannot auto-correct. HALT-to-fix **in staging**, then re-run the validator until it clears. Remediate by `field`:
|
|
240
240
|
- `description` / `name` / `compatibility` — trim/correct `{skill_staging}/SKILL.md` (e.g. shorten `description` to ≤ 1024 chars).
|
|
241
|
-
- `body` (`body lines N exceeds max 500`) — reduce the staged body: prefer a **selective split** of the largest Tier-2 section(s) into `{skill_staging}/references/`, keeping Tier-1 content inline (mirrors `validate.md` §3); or trim redundant content. Re-run the gate until `body_lines ≤ 500`.
|
|
241
|
+
- `body` (`body lines N exceeds max 500` or `body token estimate N exceeds max 5000`) — reduce the staged body: prefer a **selective split** of the largest Tier-2 section(s) into `{skill_staging}/references/`, keeping Tier-1 content inline (mirrors `validate.md` §3); or trim redundant content. Re-run the gate until both `body_lines ≤ 500` and `body_tokens ≤ 5000`.
|
|
242
242
|
|
|
243
243
|
Do NOT proceed to §9 commit-dir with an unresolved high/medium issue. Note: an over-long `description` is rated `medium` and exits `0`, so key the HALT on the issue severities above — not on the exit code.
|
|
244
244
|
- **Only `low`-severity issues (e.g. an unexpected field)** — record each as a WARNING in the evidence report and proceed; these do not block the commit.
|
|
@@ -58,6 +58,20 @@
|
|
|
58
58
|
|
|
59
59
|
**When this clause does NOT apply:** any repo with a non-empty barrel file, any monorepo (use the stratified-scope clause), or any single-package repo whose `scope.type` is explicitly `public-api` / `specific-modules` / `component-library` / `docs-only` (those scope types have their own denominator semantics). Also does NOT apply when `scope.type: "reference-app"` — that scope type carries its own pattern-surface denominator semantics (the brief speaks for itself), so this clause's filesystem trigger is moot.
|
|
60
60
|
|
|
61
|
+
- **Single-crate curated subset (`scope.type: "specific-modules"`):** If the source is a single-package (non-monorepo) repo whose skill brief sets `scope.type: "specific-modules"` and uses `scope.include`/`scope.exclude` to carve a subset of the crate's public surface, the coverage denominator is the **in-scope reachable barrel** — not the full barrel.
|
|
62
|
+
|
|
63
|
+
**Resolution:** Derive the barrel as normal for the language (e.g., Rust: `pub use` chain from `lib.rs`), then filter:
|
|
64
|
+
|
|
65
|
+
1. **Exclude** any modules or items that fall under `scope.exclude` patterns.
|
|
66
|
+
2. **Include only** items reachable from the modules listed in `scope.include`.
|
|
67
|
+
3. **Respect module visibility:** items behind `mod` (not `pub mod`) boundaries that are not re-exported through the barrel are unreachable and excluded. For Rust: count only unrestricted, module-level (column-0) `pub` item declarations in barrel-reachable modules; exclude `pub(crate)` / `pub(super)` / `pub(in …)` restricted items.
|
|
68
|
+
|
|
69
|
+
The resulting count is the denominator. Annotate the coverage report with: `Specific-modules subset — denominator: in-scope reachable barrel ({N} items from {M} modules, after scope.include/exclude filtering)`.
|
|
70
|
+
|
|
71
|
+
**When `effective_denominator` is present:** prefer `metadata.json.stats.effective_denominator` (same priority-1 rule as the stratified-scope clause), subject to the same deflation guard.
|
|
72
|
+
|
|
73
|
+
**When this clause does NOT apply:** monorepo packages (use the stratified-scope clause), `scope.type: "full-library"` (use the standard barrel), or empty-barrel packages (use the empty-barrel clause). This clause is specifically for single-crate repos where the brief intentionally documents a curated subset rather than the full public surface.
|
|
74
|
+
|
|
61
75
|
Internal module symbols are **excluded** from the coverage denominator unless they are explicitly documented in SKILL.md (in which case they count as documented extras, not missing coverage).
|
|
62
76
|
|
|
63
77
|
This matches the extraction-patterns.md convention used during skill creation: coverage measures how well SKILL.md documents what users actually import, not the entire internal codebase.
|