bmad-module-skill-forge 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/404.md +4 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/RELEASING.md +7 -4
  5. package/docs/agents.md +2 -2
  6. package/docs/architecture.md +1 -1
  7. package/docs/bmad-synergy.md +13 -13
  8. package/docs/campaign.md +3 -3
  9. package/docs/concepts.md +3 -3
  10. package/docs/examples.md +9 -9
  11. package/docs/forge-auto.md +6 -6
  12. package/docs/getting-started.md +12 -12
  13. package/docs/how-it-works.md +7 -7
  14. package/docs/index.md +7 -1
  15. package/docs/skill-model.md +2 -2
  16. package/docs/troubleshooting.md +7 -7
  17. package/docs/why-skf.md +8 -8
  18. package/docs/workflows.md +5 -5
  19. package/package.json +7 -3
  20. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  21. package/src/shared/scripts/skf-detect-language.py +20 -0
  22. package/src/shared/scripts/skf-detect-tools.py +34 -0
  23. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  24. package/src/shared/scripts/skf-language-corpora.py +20 -0
  25. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  26. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  27. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  28. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  29. package/src/shared/scripts/skf-shape-detect.py +20 -0
  30. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  31. package/src/skf-analyze-source/references/recommend.md +1 -1
  32. package/src/skf-audit-skill/references/re-index.md +1 -1
  33. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  34. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  35. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  36. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  37. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  38. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  39. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  40. package/src/skf-create-skill/references/compile.md +12 -3
  41. package/src/skf-create-skill/references/extract.md +1 -1
  42. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  43. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  44. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  45. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  46. package/src/skf-setup/references/ccc-index.md +2 -2
  47. package/src/skf-setup/references/report.md +1 -1
  48. package/src/skf-test-skill/references/coverage-check.md +11 -5
  49. package/src/skf-test-skill/references/score.md +7 -1
  50. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  51. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  52. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  53. package/tools/cli/lib/installer.js +31 -1
  54. package/tools/validate-docs-links.js +665 -0
package/docs/why-skf.md CHANGED
@@ -44,7 +44,7 @@ For docs-only skills, the audit shape is the same — `provenance-map.json` stil
44
44
 
45
45
  If it doesn't, **that's a bug.** [Open an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) and SKF republishes the skill with a new commit SHA and a new provenance map. No other AI-skills tool treats disagreement between claim and source as a defect. SKF does.
46
46
 
47
- See the [Verifying a Skill](../verifying-a-skill/) page for the full three-step audit on real skills, the test reports that log *exactly* where coverage falls short, and the scoring formula behind the 80% pass threshold.
47
+ See the [Verifying a Skill](/docs/verifying-a-skill.md) page for the full three-step audit on real skills, the test reports that log *exactly* where coverage falls short, and the scoring formula behind the 80% pass threshold.
48
48
 
49
49
  ---
50
50
 
@@ -53,27 +53,27 @@ See the [Verifying a Skill](../verifying-a-skill/) page for the full three-step
53
53
  ### The curious developer
54
54
 
55
55
  Your agent just hallucinated a method that doesn't exist, again. You want this to stop, and you don't want to read a long architecture reference before running your first command.
56
- → Start with [Getting Started](../getting-started/).
56
+ → Start with [Getting Started](/docs/getting-started.md).
57
57
 
58
58
  ### The BMAD user
59
59
 
60
60
  You already use BMAD Method, BMM phases, TEA, or BMB, and you want to know where SKF fits.
61
- → Read [BMAD Synergy](../bmad-synergy/) for the phase-by-phase integration playbook.
61
+ → Read [BMAD Synergy](/docs/bmad-synergy.md) for the phase-by-phase integration playbook.
62
62
 
63
63
  ### The skeptic
64
64
 
65
65
  "AI docs for AI" sounds like the problem pretending to be the solution. You want receipts before you install anything.
66
- → Start with [Verifying a Skill](../verifying-a-skill/) — the three-step audit on real skills, including the 1% that fails.
66
+ → Start with [Verifying a Skill](/docs/verifying-a-skill.md) — the three-step audit on real skills, including the 1% that fails.
67
67
 
68
68
  ### The OSS maintainer
69
69
 
70
70
  You want to ship verified skills alongside your library releases — `npx skills publish`-ready, drift-detectable, version-pinned.
71
- → See [Examples → OSS Maintainer Publishing Official Skills](../examples/#scenario-h-oss-maintainer-publishing-official-skills).
71
+ → See [Examples → OSS Maintainer Publishing Official Skills](/docs/examples.md#scenario-h-oss-maintainer-publishing-official-skills).
72
72
 
73
73
  ### The team lead evaluating adoption
74
74
 
75
75
  You're considering running SKF across a brownfield platform. You need to know about rollback safety, `[MANUAL]` section preservation, and the health-check feedback loop before committing.
76
- → Start with [Architecture](../architecture/), then [Workflows → Workflow Health Check](../workflows/#terminal-step-health-check).
76
+ → Start with [Architecture](/docs/architecture.md), then [Workflows → Workflow Health Check](/docs/workflows.md#terminal-step-health-check).
77
77
 
78
78
  ---
79
79
 
@@ -89,6 +89,6 @@ Everything else is downstream of one question: *are the instructions your AI rea
89
89
 
90
90
  ## Next
91
91
 
92
- - **[Install SKF](../getting-started/#install)** — Node ≥ 22, Python ≥ 3.10, `uv`, one `npx` command
93
- - **[Audit a skill in 60 seconds](../verifying-a-skill/)** — see the receipts before you install
92
+ - **[Install SKF](/docs/getting-started.md#install)** — Node ≥ 22, Python ≥ 3.10, `uv`, one `npx` command
93
+ - **[Audit a skill in 60 seconds](/docs/verifying-a-skill.md)** — see the receipts before you install
94
94
  - **[Browse real skills](https://github.com/armelhbobdad/oh-my-skills)** — four Deep-tier skills, all shipping their audit trails
package/docs/workflows.md CHANGED
@@ -3,9 +3,9 @@ title: Workflows
3
3
  description: All 15 SKF workflows with commands, steps, and connection diagram
4
4
  ---
5
5
 
6
- Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../concepts/) for definitions.
6
+ Trigger workflows by typing commands to [Ferris](/docs/agents.md). See [Concepts](/docs/concepts.md) for definitions.
7
7
 
8
- > Already using BMAD? See [BMAD Synergy](../bmad-synergy/) for when to invoke each SKF workflow during BMM phases and alongside TEA, BMB, and GDS.
8
+ > Already using BMAD? See [BMAD Synergy](/docs/bmad-synergy.md) for when to invoke each SKF workflow during BMM phases and alongside TEA, BMB, and GDS.
9
9
 
10
10
  ---
11
11
 
@@ -83,7 +83,7 @@ Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../
83
83
 
84
84
  **Purpose:** Brief-less fast skill with package-to-repo resolution.
85
85
 
86
- **Note:** QS is **tier-unaware** — it always runs at community tier and does not use ast-grep, CCC, or QMD even when your forge is configured at Forge+/Deep. For tier-aware compilation, use `BS → CS`, `forge`, or `forge-auto`. See [Skill Model](../skill-model/).
86
+ **Note:** QS is **tier-unaware** — it always runs at community tier and does not use ast-grep, CCC, or QMD even when your forge is configured at Forge+/Deep. For tier-aware compilation, use `BS → CS`, `forge`, or `forge-auto`. See [Skill Model](/docs/skill-model.md).
87
87
 
88
88
  **When to Use:** When you need a skill quickly — no brief needed. Accepts package names or GitHub URLs. Append `@version` to target a specific version (e.g., `@Ferris QS cognee@1.0.0`).
89
89
 
@@ -164,7 +164,7 @@ Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../
164
164
 
165
165
  **Key Steps:** Load skill → Detect mode → Coverage check → Coherence check → External validation (skill-check, tessl) → Hard gate → Score → Gap report
166
166
 
167
- **Scored Categories:** Export Coverage (36%), Signature Accuracy (22%), Type Coverage (14%), Coherence (18%), External Validation (10%). Default pass threshold: **80%** (per-pipeline defaults: forge-auto 90%, forge 80%). Pass routes to Export Skill; fail routes to Update Skill with a gap report. See [Completeness Scoring](../verifying-a-skill/#how-the-score-is-computed) for the full formula and tier adjustments.
167
+ **Scored Categories:** Export Coverage (36%), Signature Accuracy (22%), Type Coverage (14%), Coherence (18%), External Validation (10%). Default pass threshold: **80%** (per-pipeline defaults: forge-auto 90%, forge 80%). Pass routes to Export Skill; fail routes to Update Skill with a gap report. See [Completeness Scoring](/docs/verifying-a-skill.md#how-the-score-is-computed) for the full formula and tier adjustments.
168
168
 
169
169
  **Agent:** Ferris (Audit mode)
170
170
 
@@ -315,7 +315,7 @@ flowchart TD
315
315
  EXPORT --> MAINT[Maintenance + Campaign Report]
316
316
  ```
317
317
 
318
- > **One workflow per session** (unless using pipeline mode or campaign). Each arrow in the standard and compose-mode diagrams represents a new conversation session. Campaign manages its own multi-session orchestration internally — see [Campaign Orchestration](../campaign/). Clear your context between workflows for best results — or use pipeline mode to chain them automatically. See [Pipeline Mode](#pipeline-mode) below.
318
+ > **One workflow per session** (unless using pipeline mode or campaign). Each arrow in the standard and compose-mode diagrams represents a new conversation session. Campaign manages its own multi-session orchestration internally — see [Campaign Orchestration](/docs/campaign.md). Clear your context between workflows for best results — or use pipeline mode to chain them automatically. See [Pipeline Mode](#pipeline-mode) below.
319
319
 
320
320
  ---
321
321
 
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": "2.0.2",
4
+ "version": "2.1.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",
@@ -44,18 +44,21 @@
44
44
  "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
45
45
  "lint:md": "markdownlint-cli2 \"**/*.md\"",
46
46
  "prepare": "husky || exit 0",
47
- "quality": "npm run format:check && npm run lint && npm run lint:md && npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run docs:validate-drift",
47
+ "quality": "npm run format:check && npm run lint && npm run lint:md && npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:rehype && npm run test:docs-links-tool && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run validate:docs-links && npm run docs:validate-drift",
48
48
  "skf:install": "node tools/cli/skf-cli.js install",
49
49
  "skf:status": "node tools/cli/skf-cli.js status",
50
50
  "skf:uninstall": "node tools/cli/skf-cli.js uninstall",
51
51
  "skf:update": "node tools/cli/skf-cli.js update",
52
- "test": "npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run lint && npm run lint:md && npm run format:check",
52
+ "test": "npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:rehype && npm run test:docs-links-tool && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run validate:docs-links && npm run lint && npm run lint:md && npm run format:check",
53
53
  "test:cli": "node test/test-cli-integration.js",
54
+ "test:docs-links-tool": "node test/test-validate-docs-links.js",
54
55
  "test:install": "node test/test-installation-components.js",
55
56
  "test:knowledge": "node test/test-knowledge-base.js",
56
57
  "test:python": "uv run --with pytest --with pyyaml --with jsonschema pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-atomic-write.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py test/test-skf-validate-brief-inputs.py test/test-skf-emit-brief-result-envelope.py test/test-skf-write-skill-brief.py test/test-skf-detect-workspaces.py test/test-skf-recommend-scope-type.py test/test-skf-detect-language.py test/test-skf-language-corpora.py test/test-skf-merge-doc-urls.py test/test-skf-derive-assembly-shape.py test/test-skf-description-guard.py test/test-skf-detect-scripts-assets.py test/test-skf-hash-content.py test/test-skf-validate-brief-schema.py test/test-skf-check-workspace-drift.py test/test-skf-update-active-symlink.py test/test-skf-build-change-manifest.py test/test-skf-provenance-gap-dispatch.py test/test-skf-resolve-authoritative-files.py test/test-skf-scan-manifests.py test/test-skf-pair-intersect.py test/test-skf-enumerate-stack-skills.py test/test-skf-compare-file-hashes.py test/test-skf-load-provenance.py test/test-skf-scan-skill-md-structure.py test/test-skf-disqualify-candidates.py test/test-skf-chain-reachability.py test/test-skf-shape-detect.py test/test-skf-detect-docs.py test/test-skf-step-doc-sources.py test/test-skf-step-doc-drift.py test/test-skf-step-hard-gate.py test/test-skf-per-pipeline-thresholds.py test/test-skf-evidence-report-fallback.py test/test-skf-validate-pins.py test/test-skf-auto-shard.py test/test-skf-preapply.py test/test-skf-step-doc-rot.py test/test-skf-campaign-state.py test/test-skf-campaign-pins.py test/test-skf-campaign-deps.py test/test-skf-campaign-report.py test/test-skf-campaign-stepfiles.py test/test-skf-campaign-validate-state.py test/test-skf-campaign-provenance.py test/test-skf-campaign-parse-manifest.py test/test-skf-campaign-render-kickoff.py test/test-skf-campaign-render-batch.py test/test-skf-shard-body.py test/test-skf-render-metadata-stats.py test/test-skf-comention-pairs.py test/test-skf-count-tokens.py test/test-skf-reconcile-coverage.py test/test-skf-verify-provenance-completeness.py test/test-skf-find-cycles.py test/test-skf-validate-feasibility-report.py test/test-skf-rewrite-skill-name.py test/test-skf-verify-no-trace.py test/test-skf-validate-inventory.py test/test-skf-check-metadata-coherence.py test/test-skf-scan-doc-rot.py test/test-skf-aggregate-coherence.py test/test-skf-dir-sizes.py test/test-skf-parse-pipeline.py test/test-skf-validate-rename-name.py test/test-skf-verify-declared-numerator.py test/test-skf-combine-external-scores.py test/test-skf-new-file-diff.py test/test-skf-coverage-tally.py test/test-skf-report-delta.py test/test-skf-verdict-rollup.py -v",
58
+ "test:rehype": "node test/test-rehype-markdown-links.js",
57
59
  "test:schemas": "node test/test-agent-schema.js",
58
60
  "test:workflow": "node test/test-workflow-state.js",
61
+ "validate:docs-links": "node tools/validate-docs-links.js --strict",
59
62
  "validate:refs": "node tools/validate-file-refs.js --strict",
60
63
  "validate:schemas": "node test/validate-agent-schema.js",
61
64
  "validate:skills": "node tools/validate-skills.js --strict"
@@ -111,6 +114,7 @@
111
114
  "prettier-plugin-packagejson": "^2.5.19",
112
115
  "semver": "^7.6.3",
113
116
  "sharp": "^0.33.5",
117
+ "unist-util-visit": "^5.1.0",
114
118
  "wrap-ansi": "^7.0.0",
115
119
  "xml2js": "^0.6.2",
116
120
  "yaml": "^2.7.0",
@@ -84,6 +84,12 @@
84
84
  "enum": ["full-library", "specific-modules", "public-api", "component-library", "reference-app", "docs-only"]
85
85
  },
86
86
  "include": { "type": "array", "items": { "type": "string" } },
87
+ "tier_a_include": {
88
+ "type": "array",
89
+ "description": "Optional narrower tier-A include list. When present it becomes the coverage-denominator basis instead of the coarse scope.include, and it exempts the brief from the denominator deflation guard in skf-test-skill references/source-access-protocol.md — so an empty or blank-item list must not be accepted: it would silence that guard while narrowing nothing.",
90
+ "minItems": 1,
91
+ "items": { "type": "string", "minLength": 1 }
92
+ },
87
93
  "exclude": { "type": "array", "items": { "type": "string" } },
88
94
  "notes": { "type": "string" },
89
95
  "rationale": {
@@ -378,7 +378,27 @@ def _parse_argv(argv: list[str]) -> dict:
378
378
  return payload
379
379
 
380
380
 
381
+ def _force_utf8(*streams) -> None:
382
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
383
+
384
+ A default Windows console decodes stdio as cp1252, which cannot carry
385
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
386
+ each stream's existing error handler — reconfigure(encoding=...) alone
387
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
388
+ Linux. For stdin this must run before the first read. Skips in-process
389
+ test doubles without reconfigure().
390
+ """
391
+ for stream in streams:
392
+ if hasattr(stream, "reconfigure"):
393
+ errors = getattr(stream, "errors", None)
394
+ if errors is None:
395
+ stream.reconfigure(encoding="utf-8")
396
+ else:
397
+ stream.reconfigure(encoding="utf-8", errors=errors)
398
+
399
+
381
400
  def main(argv: list[str]) -> int:
401
+ _force_utf8(sys.stdin, sys.stdout)
382
402
  payload = _parse_argv(argv)
383
403
  result = detect(payload)
384
404
  json.dump(result, sys.stdout, ensure_ascii=False)
@@ -142,6 +142,29 @@ def _ok(payload: dict) -> None:
142
142
  print(json.dumps(payload))
143
143
 
144
144
 
145
+ def _resolve_outside_cwd(command: str) -> str | None:
146
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
147
+
148
+ shutil.which on Windows searches the current directory ahead of PATH,
149
+ and CWD here is the repo under analysis — a bare-name lookup resolving
150
+ into CWD would execute a repo-planted shim (e.g. ast-grep.cmd). Such a
151
+ resolution is treated as not-found. Explicit paths supplied by callers
152
+ (containing a separator) are honored as-is. Keep identical to the
153
+ sibling guard in skf-qmd-classify-collections.py.
154
+ """
155
+ resolved = shutil.which(command)
156
+ if resolved is None:
157
+ return None
158
+ if os.sep in command or (os.altsep and os.altsep in command):
159
+ return resolved
160
+ resolved_dir = os.path.dirname(resolved)
161
+ if resolved_dir:
162
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
163
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
164
+ return None
165
+ return resolved
166
+
167
+
145
168
  def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, str]:
146
169
  """Run a subprocess. Return (returncode, stdout, stderr). Never raises.
147
170
 
@@ -149,6 +172,13 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
149
172
  CalledProcessError) as a failed probe — returns rc=127 and an empty
150
173
  stdout/stderr. Tool detection should never crash the workflow.
151
174
 
175
+ cmd[0] is resolved through shutil.which() before spawning (with the
176
+ CWD-shim guard in _resolve_outside_cwd): Windows CreateProcess only
177
+ ever appends .exe to a bare name, so npm-installed shims
178
+ (ast-grep.CMD, qmd.CMD) would raise FileNotFoundError and read as
179
+ "tool absent". An unresolvable command returns rc=127 without
180
+ spawning anything.
181
+
152
182
  The child is wrapped in the OS `timeout(1)` utility (when available) so a
153
183
  daemon-backed probe blocked in an uninterruptible syscall (e.g.
154
184
  `qmd status` waiting on its daemon socket) is reaped at the OS level —
@@ -158,6 +188,10 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
158
188
  `start_new_session=True` isolates the child's process group; the
159
189
  Python-level timeout is a secondary net set slightly above the OS one.
160
190
  """
191
+ resolved = _resolve_outside_cwd(cmd[0])
192
+ if resolved is None:
193
+ return 127, "", ""
194
+ cmd = [resolved, *cmd[1:]]
161
195
  if _TIMEOUT_BIN:
162
196
  run_cmd = [_TIMEOUT_BIN, "--kill-after=2", str(timeout), *cmd]
163
197
  py_timeout = timeout + 5
@@ -496,7 +496,27 @@ def cmd_emit_blocked() -> None:
496
496
  print(emit_envelope_line(envelope))
497
497
 
498
498
 
499
+ def _force_utf8(*streams) -> None:
500
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
501
+
502
+ A default Windows console decodes stdio as cp1252, which cannot carry
503
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
504
+ each stream's existing error handler — reconfigure(encoding=...) alone
505
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
506
+ Linux. For stdin this must run before the first read. Skips in-process
507
+ test doubles without reconfigure().
508
+ """
509
+ for stream in streams:
510
+ if hasattr(stream, "reconfigure"):
511
+ errors = getattr(stream, "errors", None)
512
+ if errors is None:
513
+ stream.reconfigure(encoding="utf-8")
514
+ else:
515
+ stream.reconfigure(encoding="utf-8", errors=errors)
516
+
517
+
499
518
  def main() -> None:
519
+ _force_utf8(sys.stdin, sys.stdout)
500
520
  parser = argparse.ArgumentParser(
501
521
  description=__doc__,
502
522
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -80,7 +80,27 @@ def corpora_for(language: str) -> list[dict]:
80
80
  return out
81
81
 
82
82
 
83
+ def _force_utf8(*streams) -> None:
84
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
85
+
86
+ A default Windows console decodes stdio as cp1252, which cannot carry
87
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
88
+ each stream's existing error handler — reconfigure(encoding=...) alone
89
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
90
+ Linux. For stdin this must run before the first read. Skips in-process
91
+ test doubles without reconfigure().
92
+ """
93
+ for stream in streams:
94
+ if hasattr(stream, "reconfigure"):
95
+ errors = getattr(stream, "errors", None)
96
+ if errors is None:
97
+ stream.reconfigure(encoding="utf-8")
98
+ else:
99
+ stream.reconfigure(encoding="utf-8", errors=errors)
100
+
101
+
83
102
  def main(argv: list[str]) -> int:
103
+ _force_utf8(sys.stdout, sys.stderr)
84
104
  parser = argparse.ArgumentParser(
85
105
  description="Look up canonical companion prose corpora for a language.",
86
106
  )
@@ -213,7 +213,27 @@ def merge_doc_urls(
213
213
  return kept, suppressed
214
214
 
215
215
 
216
+ def _force_utf8(*streams) -> None:
217
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
218
+
219
+ A default Windows console decodes stdio as cp1252, which cannot carry
220
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
221
+ each stream's existing error handler — reconfigure(encoding=...) alone
222
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
223
+ Linux. For stdin this must run before the first read. Skips in-process
224
+ test doubles without reconfigure().
225
+ """
226
+ for stream in streams:
227
+ if hasattr(stream, "reconfigure"):
228
+ errors = getattr(stream, "errors", None)
229
+ if errors is None:
230
+ stream.reconfigure(encoding="utf-8")
231
+ else:
232
+ stream.reconfigure(encoding="utf-8", errors=errors)
233
+
234
+
216
235
  def main() -> int:
236
+ _force_utf8(sys.stdin, sys.stdout)
217
237
  raw = sys.stdin.read()
218
238
  if not raw or not raw.strip():
219
239
  sys.stderr.write("skf-merge-doc-urls: empty stdin (expected JSON object)\n")
@@ -79,7 +79,9 @@ from __future__ import annotations
79
79
 
80
80
  import argparse
81
81
  import json
82
+ import os
82
83
  import re
84
+ import shutil
83
85
  import sys
84
86
  from pathlib import Path
85
87
 
@@ -92,6 +94,10 @@ FOREIGN_SAMPLE_CAP = 5
92
94
  # Header line emitted by newer qmd builds, e.g. "Collections (56):".
93
95
  _QMD_HEADER_RE = re.compile(r"^Collections \(\d+\):\s*$")
94
96
 
97
+ # Empty-state message from `qmd collection list`, e.g.
98
+ # "No collections found. Run 'qmd collection add .' to create one."
99
+ _QMD_EMPTY_STATE_PREFIX = "No collections found"
100
+
95
101
 
96
102
  def _die(code: int, message: str) -> None:
97
103
  print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
@@ -199,7 +205,12 @@ def parse_collection_list_output(raw: str) -> list[str]:
199
205
  the trailing ` (qmd://name/)` URI and is a no-op for the bare-name form.
200
206
  Without this, suffixed entries fail the `is_forge_owned` suffix check and
201
207
  every forge collection is mis-classified as foreign.
208
+
209
+ The empty-state message ("No collections found. …") is recognized before
210
+ per-line tokenizing — it would otherwise parse as a collection named "No".
202
211
  """
212
+ if raw.lstrip().startswith(_QMD_EMPTY_STATE_PREFIX):
213
+ return []
203
214
  names: list[str] = []
204
215
  for line in raw.splitlines():
205
216
  if not line.strip():
@@ -212,6 +223,29 @@ def parse_collection_list_output(raw: str) -> list[str]:
212
223
  return names
213
224
 
214
225
 
226
+ def _resolve_outside_cwd(command: str) -> str | None:
227
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
228
+
229
+ shutil.which on Windows searches the current directory ahead of PATH,
230
+ and CWD here is the repo under analysis — a bare-name lookup resolving
231
+ into CWD would execute a repo-planted shim (e.g. qmd.cmd). Such a
232
+ resolution is treated as not-found. Explicit paths supplied by callers
233
+ (containing a separator) are honored as-is. Keep identical to the
234
+ sibling guard in skf-detect-tools.py.
235
+ """
236
+ resolved = shutil.which(command)
237
+ if resolved is None:
238
+ return None
239
+ if os.sep in command or (os.altsep and os.altsep in command):
240
+ return resolved
241
+ resolved_dir = os.path.dirname(resolved)
242
+ if resolved_dir:
243
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
244
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
245
+ return None
246
+ return resolved
247
+
248
+
215
249
  def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
216
250
  """Invoke `qmd collection list` and return (names, error).
217
251
 
@@ -221,11 +255,19 @@ def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
221
255
  counts as a "live collection name".
222
256
  """
223
257
  import subprocess
258
+ # Resolve before spawning: on Windows qmd ships from npm as a .CMD
259
+ # shim, which a bare-name subprocess.run cannot launch (WinError 2).
260
+ qmd = _resolve_outside_cwd("qmd")
261
+ if qmd is None:
262
+ return [], "qmd not found on PATH"
224
263
  try:
264
+ # qmd emits UTF-8; a locale-default decode (cp1252 on Windows)
265
+ # mojibakes names or raises UnicodeDecodeError on unmapped bytes.
225
266
  result = subprocess.run(
226
- ["qmd", "collection", "list"],
267
+ [qmd, "collection", "list"],
227
268
  capture_output=True,
228
- text=True,
269
+ encoding="utf-8",
270
+ errors="replace",
229
271
  timeout=30,
230
272
  check=False,
231
273
  )
@@ -356,7 +356,27 @@ def _parse_argv(argv: list[str]) -> dict:
356
356
  return payload
357
357
 
358
358
 
359
+ def _force_utf8(*streams) -> None:
360
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
361
+
362
+ A default Windows console decodes stdio as cp1252, which cannot carry
363
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
364
+ each stream's existing error handler — reconfigure(encoding=...) alone
365
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
366
+ Linux. For stdin this must run before the first read. Skips in-process
367
+ test doubles without reconfigure().
368
+ """
369
+ for stream in streams:
370
+ if hasattr(stream, "reconfigure"):
371
+ errors = getattr(stream, "errors", None)
372
+ if errors is None:
373
+ stream.reconfigure(encoding="utf-8")
374
+ else:
375
+ stream.reconfigure(encoding="utf-8", errors=errors)
376
+
377
+
359
378
  def main(argv: list[str]) -> int:
379
+ _force_utf8(sys.stdin, sys.stdout)
360
380
  payload = _parse_argv(argv)
361
381
  result = recommend(payload)
362
382
  json.dump(result, sys.stdout, ensure_ascii=False)
@@ -64,7 +64,7 @@ Judgment payload (stdin JSON, compute mode)::
64
64
  {
65
65
  "exports_public_api": 40, # int — public-entry-point export count (judgment)
66
66
  "exports_internal": 19, # int — non-underscore internal export count (judgment)
67
- "effective_denominator": 12, # int|null — optional, stratified-scope monorepo only
67
+ "effective_denominator": 12, # int|null — optional, curated-subset scopes only
68
68
  "pattern_surfaces_documented": 8, # int — reference-app only (exports_documented proxy)
69
69
  "exports_documented": 0, # int — stack only (own-barrel count, usually 0)
70
70
  "scripts": [ ... ], # list — scripts[] array (or "scripts_count": int)
@@ -1075,7 +1075,27 @@ def path_to_manifest_name(ecosystem: str) -> str:
1075
1075
  # CLI wiring
1076
1076
  # ---------------------------------------------------------------------------
1077
1077
 
1078
+ def _force_utf8(*streams) -> None:
1079
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
1080
+
1081
+ A default Windows console decodes stdio as cp1252, which cannot carry
1082
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
1083
+ each stream's existing error handler — reconfigure(encoding=...) alone
1084
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
1085
+ Linux. For stdin this must run before the first read. Skips in-process
1086
+ test doubles without reconfigure().
1087
+ """
1088
+ for stream in streams:
1089
+ if hasattr(stream, "reconfigure"):
1090
+ errors = getattr(stream, "errors", None)
1091
+ if errors is None:
1092
+ stream.reconfigure(encoding="utf-8")
1093
+ else:
1094
+ stream.reconfigure(encoding="utf-8", errors=errors)
1095
+
1096
+
1078
1097
  def main(argv: list[str]) -> int:
1098
+ _force_utf8(sys.stdout, sys.stderr)
1079
1099
  parser = argparse.ArgumentParser(
1080
1100
  description="Classify a repo into a known skill shape from its manifest files.",
1081
1101
  )
@@ -13,7 +13,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
13
13
  | source_repo | string | GitHub URL or local path | Repository or project root (optional when `source_type: "docs-only"`) |
14
14
  | language | string | Recognized language | Primary programming language |
15
15
  | scope | object | See Scope Object below | Boundary definition |
16
- | description | string | 1-3 sentences | What the skill covers |
16
+ | description | string | 1-3 sentences | What the skill covers. Must contain a literal `Use when` clause — validators test for that exact phrase. |
17
17
  | forge_tier | string | `Quick` / `Forge` / `Forge+` / `Deep` | Inherited from forge-tier.yaml (Title Case) |
18
18
  | created | string | ISO date `YYYY-MM-DD` | Generation date |
19
19
  | created_by | string | user_name from config | Who generated the brief |
@@ -31,6 +31,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
31
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`. |
32
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"`. |
33
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. |
34
+ | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional narrower tier-A include list for a curated-subset skill — stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`. **It also exempts the brief from the denominator deflation guard**, so supply it only when it genuinely narrows the surface — never as an empty list, and never as a way to widen. See `skf-test-skill/references/source-access-protocol.md`. |
34
35
 
35
36
  When `source_type: "docs-only"`:
36
37
  - `source_repo` becomes optional (set to doc site URL for reference)
@@ -59,7 +59,7 @@ For each qualifying unit, prepare a recommendation card:
59
59
  - name: {suggested kebab-case name}
60
60
  - scope.type: {full-library / specific-modules / public-api / component-library / reference-app / docs-only}
61
61
  - scope.include: {suggested glob patterns}
62
- - description: {suggested 1-3 sentence description}
62
+ - description: {suggested 1-3 sentence description, containing a literal `Use when` clause}
63
63
  ```
64
64
 
65
65
  ### 3. Present Recommendations
@@ -143,7 +143,7 @@ Skip this section. Temporal context requires Deep tier.
143
143
 
144
144
  For each export in the skill baseline that was NOT found at its recorded file path during re-extraction (potential "deleted" export):
145
145
 
146
- 1. Run `ccc_bridge.search("{export_name}", source_root, top_k=5)` — **Tool resolution:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `ccc search "{export_name}" --path {source_root} --top 5` (CLI) — to find candidate current locations
146
+ 1. Run `ccc_bridge.search("{export_name}", source_root, top_k=5)` — **Tool resolution:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `cd {source_root} && ccc search --limit 5 "{export_name}"` (CLI) — to find candidate current locations. `ccc search` reads the index in the current working directory and has no project-selector flag (`--path` is a file-path glob filter *within* the index, and the result cap is `--limit`, not `--top`) — see `knowledge/ccc-bridge.md`.
147
147
  2. If CCC returns files containing the export name:
148
148
  - Run ast-grep verification on each candidate file
149
149
  - If verified at a new location: reclassify from "deleted" to "moved" with the new file:line reference
@@ -6,9 +6,9 @@ Loaded by step 1 §7b only. The five examples below show the *range* of acceptab
6
6
 
7
7
  > Render Markdown to HTML using the marked library. Use when the user pastes raw Markdown and wants formatted output, or asks how to convert MD files in a build pipeline.
8
8
 
9
- > Stripe API client for Node.js — payment intents, subscriptions, customer portal, webhooks. Triggers on tasks involving Stripe-managed payments, subscription billing, or webhook event handling.
9
+ > Stripe API client for Node.js — payment intents, subscriptions, customer portal, webhooks. Use when the task involves Stripe-managed payments, subscription billing, or webhook event handling.
10
10
 
11
- > Charts and visualizations powered by D3.js. Reach for this when the user asks to plot data, build interactive graphs, or wants bare D3 control instead of a React-charts abstraction.
11
+ > Charts and visualizations powered by D3.js. Use when the user asks to plot data, build interactive graphs, or wants bare D3 control instead of a React-charts abstraction.
12
12
 
13
13
  > Lint Python code with Ruff. Use when the user wants to add or configure Ruff in a Python project, debug rule selectors, or understand why a specific check fired.
14
14
 
@@ -16,4 +16,6 @@ Loaded by step 1 §7b only. The five examples below show the *range* of acceptab
16
16
 
17
17
  ## Notes on Voice
18
18
 
19
- Each example leads differently (verb / noun / "Charts and..." / verb / noun-phrase) and matches the trigger phrasing ("Use when...", "Triggers on...", "Reach for this when...") to the voice rather than copy-pasting a single template. Compose in that spirit using the gathered material — the target repo, the user's intent, the version if set, and any scope hints — but **do not template-stamp**.
19
+ Each example leads differently (verb / noun / "Charts and..." / verb / noun-phrase) rather than copy-pasting a single template. Compose in that spirit using the gathered material — the target repo, the user's intent, the version if set, and any scope hints — but **do not template-stamp**.
20
+
21
+ The **lead** is where the voice varies; the **trigger clause** is fixed. Every description must contain a literal `Use when` clause somewhere, because validators test for that exact phrase — `skill-check`'s `description.use_when_phrase` rule scores a description below 100 without it. Phrasings like "Triggers on…" or "Reach for this when…" read well but do not satisfy it on their own, so keep them for additional text rather than as the only trigger.
@@ -9,7 +9,7 @@
9
9
  | source_repo | string | GitHub URL or local path | Repository or project root (optional when `source_type: "docs-only"`) |
10
10
  | language | string | Recognized language | Primary programming language |
11
11
  | scope | object | See Scope Object below | Boundary definition |
12
- | description | string | 1-3 sentences | What the skill covers |
12
+ | description | string | 1-3 sentences | What the skill covers. Must contain a literal `Use when` clause — validators test for that exact phrase. |
13
13
  | forge_tier | string | `Quick` / `Forge` / `Forge+` / `Deep` | Inherited from forge-tier.yaml (Title Case) |
14
14
  | created | string | ISO date `YYYY-MM-DD` | Generation date |
15
15
  | created_by | string | user_name from config | Who generated the brief |
@@ -26,7 +26,7 @@
26
26
  | `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. |
27
27
  | `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"`. |
28
28
  | `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
29
- | `scope.tier_a_include` | array | Glob patterns | Optional. Narrower tier-A include list for stratified-scope monorepo skills. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`, so the denominator reflects the authoring surface rather than incidentally-matched internal infrastructure. See `skf-test-skill/references/source-access-protocol.md` stratified-scope resolution. |
29
+ | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional. Narrower tier-A include list for a curated-subset skill — stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`, so the denominator reflects the authoring surface rather than incidentally-matched internal infrastructure. **It also exempts the brief from the denominator deflation guard**, so supply it only when it genuinely narrows the surface — never as an empty list, and never as a way to widen (listing files outside `scope.include` inverts its meaning). See `skf-test-skill/references/source-access-protocol.md` stratified-scope resolution. |
30
30
 
31
31
  When `source_type: "docs-only"`:
32
32
  - `source_repo` becomes optional (set to doc site URL for reference)
@@ -206,9 +206,9 @@ Fall back to ad-hoc inspection — `Gemfile` / `*.csproj` / `*.sln` / `Package.s
206
206
  If `tools.ccc` is true in forge-tier.yaml, supplement the module listing with a semantic discovery pass:
207
207
 
208
208
  **CCC Semantic Discovery:**
209
- - **Claude Code:** Use `/ccc search "{repo_name} public API exports modules" {source_path}`
209
+ - **Claude Code:** Use `/ccc search "{repo_name} public API exports modules"` from `{source_path}` — the query is variadic, so a trailing path is swallowed into the search string rather than selecting a project
210
210
  - **Cursor:** Use `ccc` MCP server `search` tool with query `"{repo_name} public API exports modules"` and path `{source_path}`
211
- - **CLI fallback:** `ccc search "{repo_name} public API exports modules" --path {source_path} --limit 10`
211
+ - **CLI fallback:** `cd {source_path} && ccc search --limit 10 "{repo_name} public API exports modules"` — `ccc search` reads the index in the current working directory and has no project-selector flag (`--path` is a file-path glob filter *within* the index)
212
212
 
213
213
  See `knowledge/tool-resolution.md` for full bridge-to-tool mapping.
214
214
 
@@ -352,7 +352,9 @@ The schema's `description` field is 1-3 sentences and surfaces in skill registri
352
352
 
353
353
  Compose a candidate 1-3 sentence description from the gathered material. **Write like a human library maintainer would** — what does an agent get from this skill, and when should it route here? Two facts must come through (what the skill is, when to use it); everything else is voice. Resist filling in the same skeleton every time.
354
354
 
355
- Load `{descriptionVoiceExamplesPath}` for the five voice examples (range of acceptable leads, structures, and trigger phrasings) and the "do not template-stamp" guidance, then compose in that spirit. The asset documents what "in that spirit" means; the gathered material to draw on is the target repo, the user's intent, the version if set, and any scope hints.
355
+ Load `{descriptionVoiceExamplesPath}` for the five voice examples (range of acceptable leads and structures) and the "do not template-stamp" guidance, then compose in that spirit. The asset documents what "in that spirit" means; the gathered material to draw on is the target repo, the user's intent, the version if set, and any scope hints.
356
+
357
+ **Whatever lead you choose, the description must contain a literal `Use when` clause somewhere** — validators test for that exact phrase, so alternatives like "Triggers on…" or "Reach for this when…" do not satisfy it on their own. The clause need not lead: a descriptive opener followed by `Use when …` is both good voice and validator-clean.
356
358
 
357
359
  Present:
358
360
 
@@ -360,7 +362,7 @@ Present:
360
362
 
361
363
  > {synthesized description}
362
364
 
363
- This is the text agents read when deciding whether to route to your skill — it sits in the registry row alongside dozens of other skills. Specific triggers ('use when…', 'reach for this when…') help agents match real user requests; generic descriptions blend in and get skipped. Edit, replace, or accept as-is."
365
+ This is the text agents read when deciding whether to route to your skill — it sits in the registry row alongside dozens of other skills. A specific 'use when…' trigger helps agents match real user requests; generic descriptions blend in and get skipped. Edit, replace, or accept as-is."
364
366
 
365
367
  Wait for user confirmation or alternative.
366
368
 
@@ -81,7 +81,9 @@ For each ready Tier A skill:
81
81
  - `{{directive_content}}` — raw content of the file at `campaign.directive_path`, or "No directive configured" if unset/missing.
82
82
  Present the completed kickoff message as the context for the skill's pipeline run.
83
83
  - **AN → BS → CS → TS** — standard forge pipeline for this skill. When BS (Brief Synthesis) produces the skill's brief, set `skills[current].brief_path` to the brief path from the BS result envelope so the field the schema declares is populated and available for resume and reporting.
84
- - **Doc-rot check** — grep feeder artifacts for corrections emitted during the pipeline run. Append any doc-rot findings to the skill's `workarounds_applied` array (prefixed with `[doc-rot]`) so they survive state write and are available for §6 propagation.
84
+ - **Doc-rot check** — **record** what CS step 5c already produced; do not re-derive it. Because the pipeline body runs inline (see the §5 note above), that step's context is directly readable here: `corrections_added`, `correction_matches` (the enriched match records — `source`, `pattern`, `category`, `context_line`, `affected`), `corrections_deduped` and `corrections_capped`. Append one `[doc-rot]`-prefixed entry per `correction_matches` record to the skill's `workarounds_applied` array so they survive state write and are available for §6 propagation. When step 5c took its skip branch (`doc_rot_triggered: false`, `corrections_added: 0`), there is nothing to record — append nothing and move on.
85
+
86
+ **Do not hand-grep the feeder artifacts here, and do not re-run the scan.** `skf-create-skill/references/step-doc-rot.md` §2 requires the scan to run in its helper precisely so identical feeders yield identical matches; a second pass in this loop is free to disagree with the corrections CS actually wrote. It would also be self-contaminating: step 5c has already written `## CORRECTION` blocks into the compiled SKILL.md, and the helper's exclusion windows cover only that file's frontmatter and its Migration & Deprecation Warnings section — so re-scanning it would re-match the blocks 5c itself authored and record them as fresh findings.
85
87
  3. **Record results:**
86
88
  - On success: set `status` to `"completed"`, set `completed_at` to current ISO-8601 with timezone, record `quality_score`. Backup and write state.
87
89
  - On failure: set `status` to `"failed"`. Log the failure reason (the sub-skill's `halt_reason`/exit code, or "unparseable result envelope") to the decision log so `campaign status` and the report surface *why* a skill failed without the operator opening sub-skill logs. Backup and write state. Downstream skills whose `depends_on` does NOT include the failed skill continue processing normally; those that DO depend on it are blocked at §4's dependency gate.