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.
@@ -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.7.0",
33
+ "version": "1.8.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.7.0"
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.7.0",
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 (opt-in the verdict is
228
- unchanged when it is None, so the other consumers of this validator are
229
- unaffected). Returns a result dict with status, issues, frontmatter,
230
- body_lines, and summary.
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
- Read `{forgeTierConfig}` and update the `ccc_index_registry` array **under an exclusive `flock` on `{sidecar_path}/forge-tier.yaml.lock`** (same pattern as §6 — acquire lock → read → modify → atomic write via `skf-atomic-write.py write` release). If `flock` is unavailable, fall back to read-CAS-by-mtime.
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
- Deduplicate by `source_repo` + `skill_name` (NOT local `path`, which may be ephemeral). Replace existing match or append:
201
-
202
- ```yaml
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
- Write the updated forge-tier.yaml.
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 two most common non-auto-fixable `skill-check` hard rejects — a `description` over the 1024-char limit (which `skill-check --fix` cannot trim for you) and a SKILL.md body over the `body.max_lines` limit (default **500**) — 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 the body overflow is the likeliest trigger. Catch both here, while the package is still in `.skf-tmp`.
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, and `--max-body-lines 500` to assert the skill-check body limit pre-commit:
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.