bmad-module-skill-forge 1.4.0 → 1.4.1

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.4.0",
33
+ "version": "1.4.1",
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.4.0"
29
+ skf_version: "1.4.1"
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.0",
4
+ "version": "1.4.1",
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",
@@ -33,6 +33,11 @@ Validation rules for the 2 conditional values (PR #248):
33
33
  → produces `**//abs/path`, `**/~/x`, or `**/./rel` — malformed glob
34
34
  - REJECT values containing glob meta-characters (`*`, `?`, `[`)
35
35
  → interpolation collides with the surrounding pattern syntax
36
+ - REJECT values containing unresolved `{project-root}`-style placeholders
37
+ (any `{` or `}` character) — produces `**/{project-root}/x`, which
38
+ cocoindex accepts as a literal-segment glob and silently never matches.
39
+ This is the bug from issue #293: the step file was passing template
40
+ strings straight through.
36
41
 
37
42
  Rejected values are SKIPPED (the pattern is not added) and a warning
38
43
  is appended to the output. The 4 always-include patterns are applied
@@ -96,6 +101,7 @@ ALWAYS_INCLUDE = (
96
101
  "**/_skf-learn",
97
102
  )
98
103
  GLOB_META_CHARS = set("*?[")
104
+ PLACEHOLDER_CHARS = set("{}")
99
105
 
100
106
 
101
107
  def _die(code: int, message: str) -> None:
@@ -163,6 +169,14 @@ def validate_config_value(key: str, raw_value: str) -> tuple[str | None, str | N
163
169
  f"pattern syntax — fix the value in {{project-root}}/_bmad/skf/config.yaml"
164
170
  )
165
171
 
172
+ if any(ch in PLACEHOLDER_CHARS for ch in value):
173
+ return None, (
174
+ f"{key} contains an unresolved template placeholder ({{ or }}); "
175
+ f"refused for ccc exclusion because the step file is supposed to "
176
+ f"substitute {{project-root}} and reduce to a basename before "
177
+ f"invoking the helper — see step-01b §3 in src/skf-setup/steps-c/"
178
+ )
179
+
166
180
  return value, None
167
181
 
168
182
 
@@ -29,10 +29,31 @@ Which skill would you like to audit? Please provide the skill name or path."
29
29
 
30
30
  **If user provides skill name (not full path) — version-aware path resolution (see `knowledge/version-paths.md`):**
31
31
  1. Read `{skills_output_folder}/.export-manifest.json` and look up the skill name in `exports` to get `active_version`
32
- 2. If found: resolve to `{skill_package}` = `{skills_output_folder}/{skill_name}/{active_version}/{skill_name}/`
32
+ 2. If found: tentatively resolve `{skill_package}` = `{skills_output_folder}/{skill_name}/{active_version}/{skill_name}/`. **Manifest-vs-symlink drift gate:** before committing, also read the `active` symlink at `{skills_output_folder}/{skill_name}/active`. When the symlink target disagrees with `active_version`, the manifest lags behind on-disk state — typical sequence is `update-skill` flipped the symlink but `export-skill` has not yet rewritten the manifest. Auditing the older manifest version would re-audit a skill the user no longer cares about (or has already audited). Decide which version to audit:
33
+ - Read `forge_data_folder/{skill_name}/{active_version}/provenance-map.json` (manifest version) and `forge_data_folder/{skill_name}/{symlink_target}/provenance-map.json` (symlink target). Compare their `generated_at` timestamps (or `mtime` if the field is absent).
34
+ - If both versions exist and the symlink target's provenance is **fresher** than the manifest's `last_exported`, present a gate:
35
+
36
+ "**Manifest lags behind active symlink.**
37
+
38
+ | | Manifest | Symlink |
39
+ |---|---|---|
40
+ | Version | `{active_version}` | `{symlink_target}` |
41
+ | Exported / forged | `{manifest.last_exported}` | `{symlink_provenance_generated_at}` |
42
+
43
+ The manifest's `active_version` was set by an earlier export-skill run; the symlink was flipped later (typically by update-skill). Auditing the manifest version will re-audit a skill the user no longer treats as active. Options:
44
+
45
+ - **[N] Audit symlink target ({symlink_target})** — recommended. The drift report describes the version the skill currently resolves to.
46
+ - **[M] Audit manifest version ({active_version})** — only useful when investigating the older version specifically.
47
+ - **[X] Abort** — halt without producing a report. Run `[EX] Export Skill` to reconcile the manifest before re-running audit-skill."
48
+
49
+ Default is **[N]**. Headless mode auto-selects **[N]** with a loud log line: `"headless: manifest active_version ({active_version}) is older than symlink target ({symlink_target}); auditing symlink target. Run export-skill to reconcile."` This mirrors §5b's upstream-drift handling — when the manifest and the working tree disagree, the working tree is the more honest signal under automation.
50
+
51
+ - When the symlink target's provenance is **older** than (or equal to) the manifest's `last_exported`, the symlink predates the export — this is the normal post-export shape, no gate needed. Resolve to the manifest's `active_version`.
52
+ - When only one of the two versions has a provenance map, resolve to the version that has one (the other is inert — auditing it would degrade to text-diff). Log the choice.
53
+
33
54
  3. If not in manifest: check for `active` symlink at `{skills_output_folder}/{skill_name}/active` — resolve to `{skill_group}/active/{skill_name}/`
34
55
  4. If neither: fall back to flat path `{skills_output_folder}/{skill_name}/`. If SKILL.md exists at the flat path, auto-migrate per `knowledge/version-paths.md` migration rules
35
- 5. Store the resolved path as `{resolved_skill_package}`
56
+ 5. Store the resolved path as `{resolved_skill_package}`. Also store `audit_target_version` = the version that was actually selected (manifest, symlink, or flat) for step-06 Provenance to surface. When the gate above fired, also record the rejected version under `manifest_symlink_drift = {manifest: {active_version}, symlink: {symlink_target}, audited: {audit_target_version}, reason: {fresher-provenance|operator-choice|headless-default}}` so reviewers can audit the choice.
36
57
 
37
58
  **If user provides full path:**
38
59
  - Use as provided
@@ -161,7 +182,32 @@ When skipping, log the reason, then set the audit-ref context variables to basel
161
182
  **Select:** [C] / [S] / [X]"
162
183
 
163
184
  **Gate handling:**
164
- - **[C]:** Acquire an exclusive lock on `{source_root}/.skf-workspace.lock` (`flock -x` or `fcntl.flock(LOCK_EX)`) before mutating the working tree — matches the concurrency discipline in `src/skf-create-skill/references/source-resolution-protocols.md` and avoids racing with a concurrent create-skill / test-skill run against the same workspace clone. If `flock` is unavailable, emit a warning and proceed. Then `git -C {source_root} checkout {chosen_ref}` (prefer `latest_tag` when present, else `remote_head`). Set `audit_ref = {chosen_ref}`, `audit_ref_source = "checkout-latest"`, `audit_commit = git rev-parse HEAD`. Hold the lock through step-02 re-extraction and release only after the extraction snapshot is complete.
185
+ - **[C]:** Acquire an exclusive lock on `{source_root}/.skf-workspace.lock` (`flock -x` or `fcntl.flock(LOCK_EX)`) before mutating the working tree — matches the concurrency discipline in `src/skf-create-skill/references/source-resolution-protocols.md` and avoids racing with a concurrent create-skill / test-skill run against the same workspace clone. If `flock` is unavailable, emit a warning and proceed.
186
+
187
+ **Dirty-worktree probe (mandatory before checkout).** Run `git -C {source_root} status --porcelain` after acquiring the lock and before the checkout. If the output is non-empty, the working tree has uncommitted changes — `git checkout {chosen_ref}` will abort with `error: Your local changes to the following files would be overwritten by checkout`, halting the workflow mid-step. The most common benign cause is a tooling-generated edit (e.g. the CCC daemon appending a `.cocoindex_code/` line to `.gitignore` after `setup-forge` pointed it at this clone), but the changes could also be the operator's in-progress work. Surface a sub-gate before mutating:
188
+
189
+ "**Working tree has uncommitted changes.** `git status --porcelain` returned:
190
+
191
+ ```
192
+ {first 20 lines of porcelain output, ellipsis if more}
193
+ ```
194
+
195
+ A `git checkout` would abort. Options:
196
+ - **[T] Transient stash** — `git stash push -m 'skf-audit: pre-checkout' --include-untracked`, perform the checkout, and pop the stash on the way out. Recommended when the changes look tooling-generated (e.g. a `.gitignore` line referencing `.cocoindex_code/`, lockfile churn from an indexer).
197
+ - **[A] Abort** — halt the workflow and let the operator commit, stash, or discard manually before retrying.
198
+ - **[F] Force checkout** — `git checkout --force` discards uncommitted changes irrecoverably. Only choose this after confirming the changes are safe to lose."
199
+
200
+ **Gate handling:**
201
+ - **[T]:** Run `git -C {source_root} stash push -m 'skf-audit-skill: pre-checkout {chosen_ref}' --include-untracked`. Capture the stash ref from the command output (e.g. `stash@{0}`) and store as `pre_checkout_stash_ref` in workflow context for step-06 Provenance to surface. Proceed to the checkout. (After audit completes, the operator restores the stash with `git stash pop` — step-06 puts the literal command in the report as a workflow-level convention rather than per-author ad-hoc prose.)
202
+ - **[A]:** HALT the workflow. Do not write a drift report — the audit was never started.
203
+ - **[F]:** Run `git -C {source_root} checkout --force {chosen_ref}` instead of the plain checkout. Record `pre_checkout_force_discard: true` in workflow context for step-06 to surface as a loud warning. Skip the stash path.
204
+ - **Other input:** help user, redisplay the sub-gate.
205
+
206
+ **Headless default** (when `{headless_mode}`): auto-select **[A] Abort** rather than silently mutating the working tree. Emit a loud log line: `"headless: dirty worktree detected at {source_root}; refusing to checkout {chosen_ref} or stash. Re-run interactively to choose [T]/[A]/[F]."` Stashing under automation could lose work if the operator never returns to pop; force-checkout under automation could destroy uncommitted work outright. Abort is the only safe non-interactive default.
207
+
208
+ If `git status --porcelain` is empty, skip the sub-gate and proceed directly to the checkout.
209
+
210
+ Then `git -C {source_root} checkout {chosen_ref}` (prefer `latest_tag` when present, else `remote_head`). Set `audit_ref = {chosen_ref}`, `audit_ref_source = "checkout-latest"`, `audit_commit = git rev-parse HEAD`. Hold the lock through step-02 re-extraction and release only after the extraction snapshot is complete.
165
211
  - **[S]:** Keep baseline. Set `audit_ref = baseline_ref`, `audit_ref_source = "baseline"`, `audit_commit = baseline_commit`.
166
212
  - **[X]:** HALT workflow — do not create drift report.
167
213
  - **Other input:** help user, redisplay gate.
@@ -89,11 +89,12 @@ For each changed export, record:
89
89
 
90
90
  For each entry in `file_entries`:
91
91
  1. Locate the source file at the original `source_file` path
92
- 2. Compute current SHA-256 content hash
93
- 3. Compare against stored `content_hash`
94
- - CHANGED: hash mismatch record as script/asset content drift
95
- - MISSING: source file no longer exists → record as removed
96
- - NEW: source contains files matching script/asset patterns not in `file_entries` → record as added
92
+ 2. Compute current SHA-256 content hash (bare hex — `hashlib.sha256(...).hexdigest()`, `sha256sum`, and `openssl dgst` all produce this form)
93
+ 3. **Normalize the stored hash before comparison.** `skf-create-skill` writes `content_hash` with a leading algorithm-name prefix (`"sha256:879bfcc2…"`). A bare-hex hash from `hashlib` will never equal the prefixed value byte-for-byte, so without normalization every `file_entry` flags as CHANGED on every audit. Strip the algorithm prefix before comparing — accept any leading lowercase-alphanumeric prefix terminated by `:` (e.g. `sha256:`, `sha1:`, `md5:`); if no prefix is present, leave the value unchanged. The reader-side normalization is unconditionally safe — applying it to bare-hex values produced by a fixed writer is a no-op.
94
+ 4. Compare the normalized stored hash against the freshly computed hash:
95
+ - CHANGED: hash mismatch → record as script/asset content drift
96
+ - MISSING: source file no longer exists → record as removed
97
+ - NEW: source contains files matching script/asset patterns not in `file_entries` → record as added
97
98
 
98
99
  Append results to the Structural Drift section as "### Script/Asset Drift ({count})".
99
100
 
@@ -106,11 +106,19 @@ The helper returns non-zero (exit 2) if `{skill_group}/active` already exists as
106
106
  ### 5. Verify Write Completion
107
107
 
108
108
  After all files are written, verify:
109
- - All 4 deliverable artifact types exist (SKILL.md, context-snippet.md, metadata.json, at least one file in references/), all 3 workspace artifacts exist (provenance-map.json, evidence-report.md, extraction-rules.yaml), plus scripts/ and assets/ files when inventories are non-empty
109
+ - All 4 deliverable artifact types exist (SKILL.md, context-snippet.md, metadata.json, **and** either at least one file in `references/` **or** `references/` is empty AND Tier-2 content is inline in SKILL.md — see "Empty `references/` exception" below), all 3 workspace artifacts exist (provenance-map.json, evidence-report.md, extraction-rules.yaml), plus scripts/ and assets/ files when inventories are non-empty
110
110
  - The `active` symlink at `{skill_group}/active` resolves to `{version}`
111
111
  - Store `ref_count` = count of files written to `references/` for use in step-08 report
112
112
  - List each file with its path and size
113
113
 
114
+ **Empty `references/` exception (Tier-2 inline):** `ref_count == 0` is a valid completion state when step-06 kept Tier-2 content inline in SKILL.md — e.g., the body was already under the size limit, or `skill-check` was unavailable and the manual fallback (step-06 §3) skipped the split. In that case, append a single line to `{forge_version}/evidence-report.md` recording the inline state so downstream tooling and audits can distinguish "inline by design" from "split-body skipped due to error":
115
+
116
+ ```
117
+ ref_count: 0 # Tier-2 kept inline in SKILL.md (no split performed in step-06)
118
+ ```
119
+
120
+ When `ref_count > 0` is expected (because step-06 ran a split) but no files were written, halt with: "Split-body produced zero reference files. Investigate step-06 output before retrying — empty `references/` after a split is never a valid state."
121
+
114
122
  **If any write failed:**
115
123
  Halt with: "Artifact generation failed: could not write `{file_path}`. Check permissions and disk space."
116
124
 
@@ -165,14 +165,22 @@ Load `{sidecar_path}/preferences.yaml` (if exists):
165
165
 
166
166
  ### 4b. Check Test Report (Quality Gate)
167
167
 
168
- Search for a test report at `{forge_data_folder}/{skill_name}/{active_version}/test-report-{skill_name}.md` (i.e., `{forge_version}/test-report-{skill_name}.md`). If not found at the versioned path, fall back to `{forge_data_folder}/{skill_name}/test-report-{skill_name}.md`:
168
+ `skf-test-skill` writes timestamped test-report filenames (`test-report-{skill_name}-{ISO-TIMESTAMP}-{HASH}.md`) there is no exact-name `test-report-{skill_name}.md` on disk. Locate the most recent report by glob, not by exact filename:
169
+
170
+ 1. Glob `{forge_data_folder}/{skill_name}/{active_version}/test-report-{skill_name}-*.md` (i.e. `{forge_version}/test-report-{skill_name}-*.md`). Sort matches descending by the parsed ISO-timestamp segment in the filename (`YYYYMMDDTHHMMSSZ` between the skill name and the hash — `sort -r` on the filename works because the timestamp is the first variable component). Take the first match.
171
+ 2. If the versioned glob returns nothing, fall back to the same glob at the flat path `{forge_data_folder}/{skill_name}/test-report-{skill_name}-*.md`. Pick the newest by parsed timestamp.
172
+ 3. If neither glob returns anything, look for the stable companion `skf-test-skill-result-latest.json` in the same two directories (versioned first, then flat). Read the report path from `outputs[]` per the canonical contract documented at `shared/references/output-contract-schema.md` (resolved by skf-test-skill step-06 §4c) and load that file.
173
+ 4. If all three lookups fail, the skill has no test report.
174
+
175
+ **If a test report is found:**
169
176
 
170
- **If test report found:**
171
177
  - Read frontmatter `testResult` and `score`
172
178
  - If `testResult: fail`: warn: "**Warning:** This skill failed its last test (score: {score}%). Consider running `@Ferris TS` and addressing gaps before export."
173
179
  - If `testResult: pass`: note: "Last test: **PASS** ({score}%)"
180
+ - Always surface the actual file picked in the message (e.g. `test-report-my-base-ui-20260507T050917Z-487606-9b2f.md`) — not the no-longer-existent `test-report-{skill_name}.md` — so an operator can navigate to the report from the log.
181
+
182
+ **If no test report found** (all three lookups returned nothing):
174
183
 
175
- **If no test report found:**
176
184
  - Warn: "**Note:** No test report found for this skill. Consider running `@Ferris TS` before export to verify completeness."
177
185
 
178
186
  Continue to step 5 regardless — this is advisory, not blocking.
@@ -62,9 +62,15 @@ Store `{skill_root}` for use in snippet generation. The context-snippet.md writt
62
62
  3. Read SKILL.md to extract: heading slugs for `#quick-start` and `#key-types`, inline summary of key types (~10 words)
63
63
  4. **Anchor verification (split-body awareness):** For each section anchor (`#quick-start`, `#key-types`), verify the heading exists in SKILL.md. If a `references/` directory exists and `## Full` headings in SKILL.md are absent or stubs (indicating split-body, not a stack skill's structural references), rewrite the anchor to point to the reference file path (e.g., `references/{file}.md#key-types`). If the heading cannot be resolved in either location, omit that anchor line from the snippet.
64
64
  5. Derive gotchas from: T2-future annotations in evidence report (breaking changes), async requirements, version-specific behavior.
65
+
66
+ **Detect first-export state before applying carry-forward logic.** The `[CARRIED]` one-cycle expiry is meaningful only on a *re-export*. On a first export, the prior `context-snippet.md` was authored by `create-skill` (or `update-skill`) from the evidence report inside the same forge cycle — those gotchas are freshly derived, not "left over from a previous export." Treating them as carry-forward primes them for premature expiry on the second export.
67
+
68
+ Read `{skills_output_folder}/.export-manifest.json` (the same file step-04 §4a will rewrite). If the skill name is **absent** from `exports`, OR present with no resolvable `last_exported` for any version under `versions`, this is a first export — set `is_first_export = true`. Otherwise `is_first_export = false`. (Step-04 reads the manifest authoritatively for the rebuild; this read is the lightweight probe step-03 needs to choose the right branch below.)
69
+
65
70
  - **If new gotchas are derived:** Use them (they supersede any prior gotchas). Write as `|gotchas: {pitfall-1}, {pitfall-2}` with no marker.
66
- - **If NO new gotchas are derived BUT `prior_gotchas` exists AND `prior_gotchas_already_carried == false`:** First carry-forward cycle preserve the prior gotchas line, prefixing the value with `[CARRIED]` so the next export can detect that expiry has been reached. Write as `|gotchas: [CARRIED] {prior gotchas content}`. Emit warning: "**Gotchas preserved from prior export (one-cycle carry-forward).** These gotchas will be DROPPED on the next export unless new gotchas are derived or you manually refresh them. Review now if they are still applicable."
67
- - **If NO new gotchas are derived AND `prior_gotchas` exists AND `prior_gotchas_already_carried == true`:** Expiry reacheddrop the gotchas line entirely. Emit warning: "**Stale gotchas dropped** the prior gotchas were already carried forward once and cannot be derived from the current evidence report. The snippet now has no gotchas line. If the prior gotchas are still relevant, re-add them to the evidence report's T2-future section and re-run export."
71
+ - **If NO new gotchas are derived AND `is_first_export == true` AND `prior_gotchas` exists:** Treat the prior gotchas as **freshly derived** by create-skill/update-skill write them **without** the `[CARRIED]` marker. (The marker only applies to re-exports.) No warning needed; this is the normal first-export shape.
72
+ - **If NO new gotchas are derived BUT `prior_gotchas` exists AND `is_first_export == false` AND `prior_gotchas_already_carried == false`:** First carry-forward cycle on a re-export preserve the prior gotchas line, prefixing the value with `[CARRIED]` so the next export can detect that expiry has been reached. Write as `|gotchas: [CARRIED] {prior gotchas content}`. Emit warning: "**Gotchas preserved from prior export (one-cycle carry-forward).** These gotchas will be DROPPED on the next export unless new gotchas are derived or you manually refresh them. Review now if they are still applicable."
73
+ - **If NO new gotchas are derived AND `prior_gotchas` exists AND `prior_gotchas_already_carried == true`:** Expiry reached (re-export only — first-export branch above takes precedence) — drop the gotchas line entirely. Emit warning: "**Stale gotchas dropped** — the prior gotchas were already carried forward once and cannot be derived from the current evidence report. The snippet now has no gotchas line. If the prior gotchas are still relevant, re-add them to the evidence report's T2-future section and re-run export."
68
74
  - **If NO new gotchas derived AND no `prior_gotchas`:** Omit the gotchas line.
69
75
 
70
76
  Generate:
@@ -156,6 +156,35 @@ Instead of globbing `{skills_output_folder}/*/context-snippet.md`, resolve snipp
156
156
 
157
157
  **If no snippets pass the filter:** Generate managed section with zero skills — header only, no skill entries.
158
158
 
159
+ #### 4c.1 Detect Orphaned Managed-Section Rows (manifest-absent skills)
160
+
161
+ A managed-section row becomes orphaned when a `[skill-name v...]` entry already exists in the prior managed section but the skill is not in the exported skill set built in 4b — typically an externally-installed skill that was authored in a different repo and dropped into the user's `{skills_output_folder}` without going through export-skill. Strict ADR-K would silently drop such rows, but the user's managed section is load-bearing — silent removal of an installed skill is a regression an attentive operator caught manually in this run, recording a `deviations[].kind = "preserve_external_skills"` ad-hoc.
162
+
163
+ Detect this case before §5 assembly so the operator (or `{headless_mode}`) makes an explicit choice:
164
+
165
+ 1. Read the prior managed section from the **first target context file** (`target_context_files[0].context_file`). Parse the `[skill-name v...]` rows between `<!-- SKF:BEGIN -->` and `<!-- SKF:END -->` into `prior_section_rows` — a list of `{skill_name, version}` pairs. If the file does not exist or has no managed section, set `prior_section_rows = []` and skip to §5.
166
+ 2. Build `orphan_managed_rows` — every entry in `prior_section_rows` whose `skill_name` is NOT in the exported skill set built in 4b (manifest entries plus current-export targets). For each orphan, capture the original snippet line(s) verbatim from the prior section.
167
+ 3. **If `orphan_managed_rows` is non-empty**, present a halt-gate:
168
+
169
+ "**Managed-section rows present but absent from manifest:**
170
+
171
+ {list each as `- {skill_name} v{version}`}
172
+
173
+ These skills appear in the existing managed section in `{first-context-file}` but no entry exists in `.export-manifest.json` and no source draft exists under `{skills_output_folder}/{skill_name}/`. They were likely installed from a different repo and never run through export-skill in this project. Options:
174
+
175
+ - **(a) Drop** — remove these rows from the rebuilt managed section (strict ADR-K behavior). The skills' on-disk files are not touched, but they will no longer appear in any context file's managed index.
176
+ - **(b) Preserve verbatim** — copy each orphan's existing snippet line(s) into the rebuilt managed section unchanged. Records `deviations[].kind = \"preserve_external_skills\"` with the affected skill names and versions in the result contract for audit.
177
+ - **(c) Cancel** — abort export. Run export-skill against each external skill (or remove the orphan rows from the context file manually) before re-running."
178
+
179
+ 4. **Gate handling:**
180
+ - **(a) Drop:** Do not include any orphan rows in the rebuilt section. Record `orphans_dropped = [{skill_name, version}, …]` in workflow context for the §6 result contract.
181
+ - **(b) Preserve verbatim:** Append each captured snippet to the assembled section after the manifest-driven entries, preserving alphabetical order in the merged list. Append `{kind: "preserve_external_skills", skills: [{name, version}, …], rationale: "managed-section row exists but no manifest entry / no source draft"}` to the `deviations[]` array in the §6 result contract. The same orphans are written to **every** target context file in the loop (so all configured IDEs end up with consistent managed sections).
182
+ - **(c) Cancel:** HALT the workflow. Do not rewrite any context file. Do not update the manifest. Do not produce a result contract.
183
+
184
+ 5. **Headless default** (when `{headless_mode}`): auto-select **(b) Preserve verbatim**, with the same `deviations[]` entry. Emit a loud log line: `"headless: {N} managed-section rows had no manifest entry; preserving verbatim with deviations[].kind = preserve_external_skills. Run export-skill against each to migrate them into the manifest."` Silent drop under automation would regress the user's managed section without consent; cancel under automation would block the whole export over an externally-installed skill the user did not author. Preservation matches the prior-attentive-operator convention captured in `skills/export-skill-result-latest.json` deviations.
185
+
186
+ This detection runs once per export run (not per target context file) — orphan rows are inherent to the prior state of the first context file and the choice is global.
187
+
159
188
  #### 4d. Rewrite Root Paths for Target Context File
160
189
 
161
190
  The context-snippet.md files on disk contain root paths for the IDE they were originally exported to. When assembling the managed section for the current target context file, rewrite root paths if they differ from the target's `skill_root`.
@@ -132,6 +132,18 @@ No files were written. To run the export for real:
132
132
 
133
133
  Write the result contract per `shared/references/output-contract-schema.md`: the per-run record at `{skills_output_folder}/export-skill-result-{YYYYMMDD-HHmmss}.json` (UTC timestamp, resolution to seconds) and a copy at `{skills_output_folder}/export-skill-result-latest.json` (stable path for pipeline consumers — copy, not symlink). Include all context files and target managed-section files in `outputs`; include total always-on and on-trigger token counts in `summary`.
134
134
 
135
+ **`deviations[]` field (export-skill-specific extension):** when step-04 §4c.1 detected manifest-orphan managed rows and the operator (or `{headless_mode}`) chose **(b) Preserve verbatim**, also include a `deviations[]` array in the contract. Each entry has the shape:
136
+
137
+ ```json
138
+ {
139
+ "kind": "preserve_external_skills",
140
+ "skills": [{"name": "skill-name", "version": "1.0.0"}, …],
141
+ "rationale": "managed-section row exists but no manifest entry / no source draft"
142
+ }
143
+ ```
144
+
145
+ Other workflow choices that diverge from the strict spec path may add their own entries with a distinct `kind` value — auditors then have one place to look for "what did this run choose to do differently from the canonical flow." Omit the field when there are no deviations rather than writing an empty array, so the absence is readable as "ran the canonical path."
146
+
135
147
  ### 7. Chain to Health Check
136
148
 
137
149
  ONLY WHEN the export summary, distribution instructions, and result contract are complete will you then load, read the full file, and execute `{nextStepFile}`. The health-check step is the true terminal step — do not stop here even though the summary reads as final.
@@ -60,13 +60,20 @@ Check the `ccc_index` section:
60
60
 
61
61
  SKF infrastructure and output directories must be excluded from the CCC index — they contain workflow instructions, build artifacts, and generated skills that pollute semantic search results with zero extraction value.
62
62
 
63
- Run the merge helper, forwarding the resolved config values from the workflow activation context. Invoke via `uv run` so PEP 723 inline metadata resolves the script's PyYAML dependency automatically (per `docs/getting-started.md`'s prereq list uv exists for this exact purpose). Bare `python3` will fail on a fresh Python with `ModuleNotFoundError: No module named 'yaml'`.
63
+ **Pre-process the config values before invocation.** `skills_output_folder` and `forge_data_folder` come from `{project-root}/_bmad/skf/config.yaml` as template strings literally containing `{project-root}` (e.g. `'{project-root}/skills'`, `'{project-root}/forge-data'`). The merge helper interpolates each value into a `**/{value}` glob, so passing the raw template produces the malformed pattern `**/{project-root}/skills`. Before invoking the helper:
64
+
65
+ 1. Substitute `{project-root}` in each value with the resolved absolute project root.
66
+ 2. Reduce the substituted value to its last path segment (basename) — `**/skills`, `**/forge-data` are the patterns the script is designed to emit. Sub-paths under `{project-root}` (e.g. `_bmad-output/forge-data`) keep the segments below `{project-root}` (`_bmad-output/forge-data`), not the full absolute path.
67
+
68
+ Concretely: given `skills_output_folder: '{project-root}/skills'`, pass `skills`. Given `forge_data_folder: '{project-root}/_bmad-output/forge-data'`, pass `_bmad-output/forge-data`. The script's PR #248 validator rejects unresolved placeholders (`{`, `}`) and absolute paths (`/abs/...`) as a backstop, but the step is responsible for delivering values the script can interpolate cleanly — surfacing a warning and silently dropping the pattern is worse than getting the pre-processing right here.
69
+
70
+ Run the merge helper, forwarding the **resolved** config values. Invoke via `uv run` so PEP 723 inline metadata resolves the script's PyYAML dependency automatically (per `docs/getting-started.md`'s prereq list — uv exists for this exact purpose). Bare `python3` will fail on a fresh Python with `ModuleNotFoundError: No module named 'yaml'`.
64
71
 
65
72
  ```bash
66
73
  uv run {mergeCccExclusionsHelper} \
67
74
  --project-root "{project-root}" \
68
- --skills-output-folder "{skills_output_folder}" \
69
- --forge-data-folder "{forge_data_folder}"
75
+ --skills-output-folder "{resolved_skills_basename}" \
76
+ --forge-data-folder "{resolved_forge_data_path}"
70
77
  ```
71
78
 
72
79
  The script (see `src/shared/scripts/skf-merge-ccc-exclusions.py` docstring for the full schema) builds the SKF exclusion list (4 always-include hardcoded patterns + 2 conditional from validated config), applies the PR #248 validation rules to reject empty / absolute / glob-meta config values with actionable warnings, and performs an idempotent set-union merge into `{project-root}/.cocoindex_code/settings.yml`. User customizations are preserved. When the file does not exist yet (first-time setup before `ccc init`) the script creates it; when nothing new needs adding the script skips the write entirely (mtime preserved).