bmad-module-skill-forge 1.9.0 → 2.0.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.
Files changed (281) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/README.md +10 -5
  4. package/docs/_data/pinned.yaml +2 -2
  5. package/docs/agents.md +11 -2
  6. package/docs/architecture.md +5 -4
  7. package/docs/bmad-synergy.md +30 -7
  8. package/docs/campaign.md +172 -0
  9. package/docs/examples.md +7 -3
  10. package/docs/forge-auto.md +90 -0
  11. package/docs/getting-started.md +9 -0
  12. package/docs/how-it-works.md +6 -4
  13. package/docs/index.md +4 -3
  14. package/docs/skill-model.md +1 -1
  15. package/docs/troubleshooting.md +17 -1
  16. package/docs/verifying-a-skill.md +2 -2
  17. package/docs/why-skf.md +1 -1
  18. package/docs/workflows.md +64 -24
  19. package/package.json +2 -2
  20. package/src/knowledge/skill-lifecycle.md +1 -1
  21. package/src/module-help.csv +4 -3
  22. package/src/shared/data/language-corpora.json +32 -0
  23. package/src/shared/references/pipeline-contracts.md +6 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  26. package/src/shared/scripts/skf-count-tokens.py +222 -0
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  28. package/src/shared/scripts/skf-detect-docs.py +240 -4
  29. package/src/shared/scripts/skf-detect-language.py +82 -3
  30. package/src/shared/scripts/skf-detect-tools.py +105 -2
  31. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  32. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  33. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  34. package/src/shared/scripts/skf-find-cycles.py +246 -0
  35. package/src/shared/scripts/skf-hash-content.py +487 -2
  36. package/src/shared/scripts/skf-language-corpora.py +100 -0
  37. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  39. package/src/shared/scripts/skf-preapply.py +1 -1
  40. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  41. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  42. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  43. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  44. package/src/shared/scripts/skf-shape-detect.py +576 -25
  45. package/src/shared/scripts/skf-shard-body.py +461 -0
  46. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  47. package/src/shared/scripts/skf-structural-diff.py +307 -67
  48. package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
  49. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  50. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  51. package/src/shared/scripts/skf-validate-output.py +443 -10
  52. package/src/shared/scripts/skf-validate-pins.py +3 -3
  53. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  54. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  55. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  56. package/src/skf-analyze-source/SKILL.md +17 -32
  57. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  58. package/src/skf-analyze-source/customize.toml +2 -0
  59. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  60. package/src/skf-analyze-source/references/continue.md +8 -13
  61. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  62. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  63. package/src/skf-analyze-source/references/health-check.md +1 -2
  64. package/src/skf-analyze-source/references/identify-units.md +25 -36
  65. package/src/skf-analyze-source/references/init.md +12 -25
  66. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  67. package/src/skf-analyze-source/references/recommend.md +4 -14
  68. package/src/skf-analyze-source/references/scan-project.md +3 -11
  69. package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
  70. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  71. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  72. package/src/skf-audit-skill/SKILL.md +9 -6
  73. package/src/skf-audit-skill/customize.toml +2 -0
  74. package/src/skf-audit-skill/references/init.md +58 -32
  75. package/src/skf-audit-skill/references/re-index.md +15 -34
  76. package/src/skf-audit-skill/references/report.md +11 -58
  77. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  78. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  79. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  80. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  81. package/src/skf-brief-skill/SKILL.md +7 -36
  82. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  83. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  84. package/src/skf-brief-skill/customize.toml +14 -0
  85. package/src/skf-brief-skill/references/analyze-target.md +13 -18
  86. package/src/skf-brief-skill/references/confirm-brief.md +2 -15
  87. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  88. package/src/skf-brief-skill/references/gather-intent.md +27 -24
  89. package/src/skf-brief-skill/references/headless-args.md +1 -1
  90. package/src/skf-brief-skill/references/health-check.md +1 -22
  91. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  92. package/src/skf-brief-skill/references/scope-definition.md +6 -15
  93. package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
  94. package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
  95. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  96. package/src/skf-brief-skill/references/write-brief.md +19 -13
  97. package/src/skf-campaign/SKILL.md +121 -0
  98. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  99. package/src/skf-campaign/customize.toml +75 -0
  100. package/src/skf-campaign/manifest.yaml +15 -0
  101. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  102. package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
  103. package/src/skf-campaign/references/health-check.md +35 -0
  104. package/src/skf-campaign/references/step-01-setup.md +121 -0
  105. package/src/skf-campaign/references/step-02-strategy.md +102 -0
  106. package/src/skf-campaign/references/step-03-pins.md +56 -0
  107. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  108. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  109. package/src/skf-campaign/references/step-06-batch.md +88 -0
  110. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  111. package/src/skf-campaign/references/step-08-verify.md +75 -0
  112. package/src/skf-campaign/references/step-09-refine.md +83 -0
  113. package/src/skf-campaign/references/step-10-export.md +106 -0
  114. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  115. package/src/skf-campaign/references/step-resume.md +115 -0
  116. package/src/skf-campaign/scripts/.gitkeep +0 -0
  117. package/src/skf-campaign/scripts/campaign-deps.py +244 -0
  118. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  119. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  120. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  121. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  122. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  123. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  124. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  125. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  126. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  127. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  128. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  129. package/src/skf-create-skill/SKILL.md +15 -4
  130. package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
  131. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  132. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  133. package/src/skf-create-skill/customize.toml +13 -0
  134. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  135. package/src/skf-create-skill/references/compile.md +37 -47
  136. package/src/skf-create-skill/references/component-extraction.md +8 -35
  137. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  138. package/src/skf-create-skill/references/enrich.md +4 -17
  139. package/src/skf-create-skill/references/extract.md +20 -27
  140. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  141. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  142. package/src/skf-create-skill/references/generate-artifacts.md +20 -25
  143. package/src/skf-create-skill/references/health-check.md +2 -2
  144. package/src/skf-create-skill/references/load-brief.md +16 -18
  145. package/src/skf-create-skill/references/report.md +27 -14
  146. package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
  147. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  148. package/src/skf-create-skill/references/step-doc-rot.md +33 -15
  149. package/src/skf-create-skill/references/step-doc-sources.md +13 -4
  150. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  151. package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
  152. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  153. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  154. package/src/skf-create-skill/references/validate.md +69 -29
  155. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  156. package/src/skf-create-stack-skill/SKILL.md +15 -9
  157. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  158. package/src/skf-create-stack-skill/customize.toml +10 -0
  159. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  160. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  161. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  162. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  163. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  164. package/src/skf-create-stack-skill/references/init.md +9 -81
  165. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  166. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  167. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  168. package/src/skf-create-stack-skill/references/report.md +27 -62
  169. package/src/skf-create-stack-skill/references/validate.md +35 -31
  170. package/src/skf-drop-skill/SKILL.md +20 -23
  171. package/src/skf-drop-skill/customize.toml +10 -0
  172. package/src/skf-drop-skill/references/execute.md +67 -36
  173. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  174. package/src/skf-drop-skill/references/health-check.md +1 -13
  175. package/src/skf-drop-skill/references/report.md +44 -10
  176. package/src/skf-drop-skill/references/select.md +93 -48
  177. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  178. package/src/skf-export-skill/SKILL.md +17 -14
  179. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  180. package/src/skf-export-skill/customize.toml +10 -6
  181. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  182. package/src/skf-export-skill/references/load-skill.md +44 -42
  183. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  184. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  185. package/src/skf-export-skill/references/package.md +27 -33
  186. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  187. package/src/skf-export-skill/references/result-envelope.md +28 -0
  188. package/src/skf-export-skill/references/summary.md +11 -5
  189. package/src/skf-export-skill/references/token-report.md +42 -24
  190. package/src/skf-export-skill/references/update-context.md +32 -33
  191. package/src/skf-forger/SKILL.md +32 -39
  192. package/src/skf-forger/references/pipeline-mode.md +34 -0
  193. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  194. package/src/skf-quick-skill/SKILL.md +10 -60
  195. package/src/skf-quick-skill/customize.toml +9 -0
  196. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  197. package/src/skf-quick-skill/references/compile.md +15 -52
  198. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  199. package/src/skf-quick-skill/references/finalize.md +12 -6
  200. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  201. package/src/skf-quick-skill/references/health-check.md +3 -4
  202. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  203. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  204. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  205. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  206. package/src/skf-refine-architecture/SKILL.md +16 -8
  207. package/src/skf-refine-architecture/customize.toml +9 -0
  208. package/src/skf-refine-architecture/references/compile.md +10 -8
  209. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  210. package/src/skf-refine-architecture/references/health-check.md +1 -1
  211. package/src/skf-refine-architecture/references/improvements.md +9 -25
  212. package/src/skf-refine-architecture/references/init.md +9 -16
  213. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  214. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  215. package/src/skf-refine-architecture/references/report.md +14 -11
  216. package/src/skf-rename-skill/SKILL.md +11 -21
  217. package/src/skf-rename-skill/customize.toml +10 -0
  218. package/src/skf-rename-skill/references/execute.md +112 -82
  219. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  220. package/src/skf-rename-skill/references/report.md +16 -8
  221. package/src/skf-rename-skill/references/select.md +69 -42
  222. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  223. package/src/skf-setup/SKILL.md +21 -13
  224. package/src/skf-setup/customize.toml +10 -0
  225. package/src/skf-setup/references/auto-index.md +11 -10
  226. package/src/skf-setup/references/ccc-index.md +14 -15
  227. package/src/skf-setup/references/detect-and-tier.md +10 -8
  228. package/src/skf-setup/references/report.md +26 -32
  229. package/src/skf-setup/references/tier-rules.md +2 -32
  230. package/src/skf-setup/references/write-config.md +9 -10
  231. package/src/skf-test-skill/SKILL.md +10 -8
  232. package/src/skf-test-skill/customize.toml +2 -0
  233. package/src/skf-test-skill/references/coherence-check.md +19 -31
  234. package/src/skf-test-skill/references/coverage-check.md +111 -67
  235. package/src/skf-test-skill/references/detect-mode.md +10 -41
  236. package/src/skf-test-skill/references/external-validators.md +11 -19
  237. package/src/skf-test-skill/references/init.md +39 -21
  238. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  239. package/src/skf-test-skill/references/report.md +60 -40
  240. package/src/skf-test-skill/references/score.md +32 -64
  241. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  242. package/src/skf-test-skill/references/source-access-protocol.md +15 -5
  243. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  244. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  245. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  246. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  247. package/src/skf-test-skill/scripts/compute-score.py +135 -4
  248. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  249. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  250. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  251. package/src/skf-update-skill/SKILL.md +17 -5
  252. package/src/skf-update-skill/customize.toml +11 -6
  253. package/src/skf-update-skill/references/detect-changes.md +48 -36
  254. package/src/skf-update-skill/references/health-check.md +5 -11
  255. package/src/skf-update-skill/references/init.md +40 -32
  256. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  257. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  258. package/src/skf-update-skill/references/merge.md +13 -57
  259. package/src/skf-update-skill/references/re-extract.md +27 -25
  260. package/src/skf-update-skill/references/report.md +14 -7
  261. package/src/skf-update-skill/references/validate.md +32 -30
  262. package/src/skf-update-skill/references/write.md +86 -51
  263. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  264. package/src/skf-verify-stack/SKILL.md +11 -21
  265. package/src/skf-verify-stack/customize.toml +12 -3
  266. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  267. package/src/skf-verify-stack/references/coverage.md +21 -10
  268. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  269. package/src/skf-verify-stack/references/health-check.md +1 -1
  270. package/src/skf-verify-stack/references/init.md +20 -25
  271. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  272. package/src/skf-verify-stack/references/integrations.md +44 -49
  273. package/src/skf-verify-stack/references/report.md +28 -34
  274. package/src/skf-verify-stack/references/requirements.md +3 -3
  275. package/src/skf-verify-stack/references/synthesize.md +36 -39
  276. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  277. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  278. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  279. package/tools/cli/lib/ui.js +3 -2
  280. package/docs/deepwiki.md +0 -89
  281. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -19,6 +19,12 @@ Commands:
19
19
  remove <skill-name> — Remove skill from manifest
20
20
  deprecate <skill-name> [ver] — Mark skill or version as deprecated
21
21
  rename <old-name> <new-name> — Rename a skill entry
22
+ affected-versions <name> — List every version of <name> that a rename must
23
+ touch: the union of manifest
24
+ exports.<name>.versions keys and on-disk
25
+ version dirs under <skills-folder>/<name>/
26
+ (excluding the `active` symlink), deduped and
27
+ sorted semver-descending (newest first)
22
28
  """
23
29
 
24
30
  from __future__ import annotations
@@ -217,10 +223,68 @@ def cmd_rename(manifest_path, old_name, new_name):
217
223
  return {"status": "ok", "action": "renamed", "from": old_name, "to": new_name}
218
224
 
219
225
 
226
+ def _version_sort_key(version):
227
+ """Semver-descending sort key (used with reverse=True → newest first).
228
+
229
+ Splits `X.Y.Z[-prerelease][+build]` into numeric release components so
230
+ 0.10.0 sorts above 0.9.0 (a known LLM-unreliable numeric-vs-lexical case).
231
+ A non-numeric component sorts below any numeric one at the same position;
232
+ per semver, a version WITHOUT a prerelease outranks the same version WITH
233
+ one (1.0.0 > 1.0.0-rc1).
234
+ """
235
+ core = str(version).split("+", 1)[0]
236
+ main_part, _, pre = core.partition("-")
237
+ release = []
238
+ for part in main_part.split("."):
239
+ if part.isdigit():
240
+ release.append((1, int(part), ""))
241
+ else:
242
+ release.append((0, 0, part))
243
+ # pre == "" (no prerelease) must sort ABOVE a prerelease, i.e. compare higher.
244
+ return (release, pre == "", pre)
245
+
246
+
247
+ def cmd_affected_versions(manifest_path, skills_folder, skill_name):
248
+ data, err = read_manifest(manifest_path)
249
+ if err:
250
+ return {"status": "error", "error": err}
251
+
252
+ manifest_versions = []
253
+ entry = data.get("exports", {}).get(skill_name)
254
+ if entry:
255
+ versions = entry.get("versions", {})
256
+ if isinstance(versions, dict):
257
+ manifest_versions = list(versions.keys())
258
+ elif isinstance(versions, list): # residual v1 safety
259
+ manifest_versions = list(versions)
260
+
261
+ on_disk_versions = []
262
+ skill_dir = Path(skills_folder) / skill_name
263
+ if skill_dir.is_dir():
264
+ for child in skill_dir.iterdir():
265
+ if child.name == "active":
266
+ continue # the active symlink is not a version
267
+ if child.is_dir():
268
+ on_disk_versions.append(child.name)
269
+
270
+ union = sorted(set(manifest_versions) | set(on_disk_versions), key=_version_sort_key, reverse=True)
271
+
272
+ return {
273
+ "status": "ok",
274
+ "skill": skill_name,
275
+ "affected_versions": union,
276
+ "count": len(union),
277
+ "sources": {
278
+ "manifest": sorted(set(manifest_versions), key=_version_sort_key, reverse=True),
279
+ "on_disk": sorted(set(on_disk_versions), key=_version_sort_key, reverse=True),
280
+ },
281
+ }
282
+
283
+
220
284
  def main():
221
285
  if len(sys.argv) < 3:
222
286
  print("Usage: python3 skf-manifest-ops.py <skills-folder> <command> [args]", file=sys.stderr)
223
- print("Commands: read, get <name>, set <name> <version> [--ides a,b], remove <name>, deprecate <name> [version], rename <old> <new>", file=sys.stderr)
287
+ print("Commands: read, get <name>, set <name> <version> [--ides a,b], remove <name>, deprecate <name> [version], rename <old> <new>, affected-versions <name>", file=sys.stderr)
224
288
  sys.exit(1)
225
289
 
226
290
  skills_folder = Path(sys.argv[1])
@@ -245,6 +309,8 @@ def main():
245
309
  result = cmd_deprecate(manifest_path, sys.argv[3], ver)
246
310
  elif command == "rename" and len(sys.argv) >= 5:
247
311
  result = cmd_rename(manifest_path, sys.argv[3], sys.argv[4])
312
+ elif command == "affected-versions" and len(sys.argv) >= 4:
313
+ result = cmd_affected_versions(manifest_path, skills_folder, sys.argv[3])
248
314
  else:
249
315
  result = {"status": "error", "error": f"Unknown command or missing args: {command}"}
250
316
 
@@ -0,0 +1,244 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Merge Doc URLs — merge corpora-seeded + detected docs for a skill brief.
6
+
7
+ Deterministic merge of an upstream brief's `doc_urls` (registry-seeded
8
+ companion corpora, source: language-registry) with the docs freshly
9
+ discovered by `skf-detect-docs.py` (mapped to the brief `{url, label, source}`
10
+ contract). Replaces the prose dedup/suppression that lived inline in
11
+ `skf-brief-skill/references/step-auto-brief.md` §3 — that logic is fiddly
12
+ (normalized-URL collisions, non-corpus path segments, locale duplicates) and
13
+ must behave identically on every run, so it belongs in a tested script.
14
+
15
+ Three operations, in order:
16
+
17
+ 1. Normalized-URL dedup — lowercase host, strip a trailing `/index.html`
18
+ and any trailing `/` before comparing, so a seeded `…/book/` and a
19
+ README's `…/book/index.html` collapse to one entry. Existing
20
+ (corpora-seeded) entries always win and are NEVER dropped.
21
+
22
+ 2. Non-corpus segment suppression (whole-language references only) — when
23
+ the brief is a whole-language reference (scope_type == "full-library"
24
+ AND ≥1 existing entry carries source == "language-registry"), drop a
25
+ README-detected entry whose host matches a registry corpus host and
26
+ whose path contains a known non-corpus segment (whatsnew / contribute /
27
+ wiki). Path-component-anchored, never a bare substring — `/whatsnew/`
28
+ and `/whatsnew-2024/` drop, `/docs/whatsnewfeatures/` does not.
29
+
30
+ 3. Non-primary-locale collapse (whole-language references only) — drop a
31
+ README-detected entry on a registry host whose leading path segment is a
32
+ non-primary locale (e.g. `/ja/master/`) when a locale-stripped twin is
33
+ already kept (e.g. a seeded `/en/master/` or `/master/`). Twin-required:
34
+ a `/ja/…` page with no `/en/…` counterpart is kept.
35
+
36
+ Suppression is GATED on the whole-language marker so ordinary skills pass
37
+ through with byte-identical merge behaviour (dedup only). N==0 whole-language
38
+ references (registry miss → no seeded corpora) carry no language-registry
39
+ entry, so the gate is inactive and their README docs are NOT suppressed — the
40
+ DEGRADED case is intentionally out of scope (there is no canonical corpus host
41
+ to anchor suppression against).
42
+
43
+ CLI:
44
+ echo '{"scope_type": "...", "existing": [...], "detected": [...]}' \
45
+ | uv run src/shared/scripts/skf-merge-doc-urls.py
46
+
47
+ Input (JSON object on stdin):
48
+ {
49
+ "scope_type": "full-library",
50
+ "existing": [{"url": "...", "label": "...", "source": "language-registry"}, ...],
51
+ "detected": [{"url": "...", "label": "...", "source": "readme-detection"}, ...]
52
+ }
53
+
54
+ Output (JSON object on stdout):
55
+ {
56
+ "doc_urls": [{"url": "...", "label": "...", "source": "..."}, ...],
57
+ "suppressed": [{"url": "...", "reason": "non-corpus-segment|non-primary-locale-dup"}, ...]
58
+ }
59
+
60
+ Exit codes:
61
+ 0 success (merged list emitted)
62
+ 2 error (bad JSON, wrong input shape)
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import json
68
+ import re
69
+ import sys
70
+ from typing import Any
71
+ from urllib.parse import urlsplit
72
+
73
+ # Path components (case-insensitive) that mark a non-corpus page on a doc host:
74
+ # changelogs/news, contribution guides, wikis. Matched as whole path components
75
+ # (or component + a separator), never as a bare substring.
76
+ _NON_CORPUS_SEGMENTS = ("whatsnew", "contribute", "wiki")
77
+
78
+ # Closed allowlist of plausible doc-site locale codes. A leading path segment is
79
+ # treated as a locale only when it is in this set (or a `ll-rr` form whose
80
+ # language part is). Keeps a genuine subsection like `/go/` or `/is/` from being
81
+ # misread as a locale.
82
+ _LOCALE_CODES = {
83
+ "en", "ja", "zh", "ko", "fr", "de", "es", "pt", "ru", "it", "nl", "pl",
84
+ "tr", "uk", "cs", "id", "vi", "fa", "ar", "hi", "th", "sv", "da", "fi",
85
+ "nb", "no", "hu", "ro", "el", "he", "bg", "hr", "sk", "sl", "et", "lv",
86
+ "lt", "sr", "ca",
87
+ }
88
+ _LOCALE_REGION_RE = re.compile(r"^([a-z]{2})-[a-z]{2}$")
89
+
90
+
91
+ def _norm_parts(url: str) -> tuple[str, str]:
92
+ """Return (lowercased host, normalized path) for dedup / host matching.
93
+
94
+ Path normalization strips a trailing `/index.html` and any trailing `/`.
95
+ Scheme, query, and fragment are ignored — a doc page is identified by its
96
+ host + path.
97
+ """
98
+ parts = urlsplit(url.strip())
99
+ host = parts.netloc.lower()
100
+ path = parts.path
101
+ low = path.lower()
102
+ if low.endswith("/index.html"):
103
+ path = path[: -len("/index.html")]
104
+ path = path.rstrip("/")
105
+ return host, path
106
+
107
+
108
+ def _norm_key(url: str) -> tuple[str, str]:
109
+ return _norm_parts(url)
110
+
111
+
112
+ def _is_locale(seg: str) -> bool:
113
+ s = seg.lower()
114
+ if s in _LOCALE_CODES:
115
+ return True
116
+ m = _LOCALE_REGION_RE.match(s)
117
+ return bool(m) and m.group(1) in _LOCALE_CODES
118
+
119
+
120
+ def _is_primary_locale(seg: str) -> bool:
121
+ s = seg.lower()
122
+ return s == "en" or s.startswith("en-")
123
+
124
+
125
+ def _path_segments(path: str) -> list[str]:
126
+ return [s for s in path.split("/") if s]
127
+
128
+
129
+ def _locale_stripped_key(url: str) -> tuple[str, str, bool, bool]:
130
+ """Return (host, path-without-leading-locale, has_locale, is_primary).
131
+
132
+ The third/fourth flags let the caller tell `/ja/master/` (non-primary
133
+ locale) apart from `/master/` (no locale) and `/en/master/` (primary).
134
+ """
135
+ host, path = _norm_parts(url)
136
+ segs = _path_segments(path)
137
+ has_locale = bool(segs) and _is_locale(segs[0])
138
+ is_primary = has_locale and _is_primary_locale(segs[0])
139
+ if has_locale:
140
+ segs = segs[1:]
141
+ return host, "/".join(segs), has_locale, is_primary
142
+
143
+
144
+ def _matches_non_corpus_segment(path: str) -> bool:
145
+ for seg in _path_segments(path):
146
+ low = seg.lower()
147
+ for token in _NON_CORPUS_SEGMENTS:
148
+ if low == token:
149
+ return True
150
+ # component + separator (whatsnew-2024) — but NOT whatsnewfeatures
151
+ if low.startswith(token) and len(low) > len(token) and not low[len(token)].isalnum():
152
+ return True
153
+ return False
154
+
155
+
156
+ def merge_doc_urls(
157
+ scope_type: str,
158
+ existing: list[dict[str, Any]],
159
+ detected: list[dict[str, Any]],
160
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
161
+ """Merge existing (seeded) + detected doc_urls. Returns (doc_urls, suppressed)."""
162
+ kept: list[dict[str, Any]] = []
163
+ suppressed: list[dict[str, Any]] = []
164
+ seen: set[tuple[str, str]] = set()
165
+
166
+ # 1. Existing (corpora-seeded) entries win and are never dropped.
167
+ for e in existing:
168
+ if not isinstance(e, dict) or not e.get("url"):
169
+ continue
170
+ key = _norm_key(e["url"])
171
+ if key in seen:
172
+ continue
173
+ seen.add(key)
174
+ kept.append(e)
175
+
176
+ # Whole-language gate: registry-seeded corpora present on a full-library brief.
177
+ registry_hosts = {
178
+ _norm_parts(e["url"])[0]
179
+ for e in kept
180
+ if isinstance(e, dict) and e.get("url") and e.get("source") == "language-registry"
181
+ }
182
+ suppression_active = scope_type == "full-library" and bool(registry_hosts)
183
+
184
+ for d in detected:
185
+ if not isinstance(d, dict) or not d.get("url"):
186
+ continue
187
+ url = d["url"]
188
+ key = _norm_key(url)
189
+ if key in seen:
190
+ continue # dedup — a seeded/earlier entry already covers this URL
191
+
192
+ if suppression_active:
193
+ host, path = _norm_parts(url)
194
+ if host in registry_hosts:
195
+ if _matches_non_corpus_segment(path):
196
+ suppressed.append({"url": url, "reason": "non-corpus-segment"})
197
+ continue
198
+ dh, dpath, has_locale, is_primary = _locale_stripped_key(url)
199
+ if has_locale and not is_primary:
200
+ twin = any(
201
+ _locale_stripped_key(k["url"])[0] == dh
202
+ and _locale_stripped_key(k["url"])[1] == dpath
203
+ for k in kept
204
+ if k.get("url")
205
+ )
206
+ if twin:
207
+ suppressed.append({"url": url, "reason": "non-primary-locale-dup"})
208
+ continue
209
+
210
+ seen.add(key)
211
+ kept.append(d)
212
+
213
+ return kept, suppressed
214
+
215
+
216
+ def main() -> int:
217
+ raw = sys.stdin.read()
218
+ if not raw or not raw.strip():
219
+ sys.stderr.write("skf-merge-doc-urls: empty stdin (expected JSON object)\n")
220
+ return 2
221
+ try:
222
+ payload = json.loads(raw)
223
+ except json.JSONDecodeError as exc:
224
+ sys.stderr.write(f"skf-merge-doc-urls: invalid JSON on stdin: {exc}\n")
225
+ return 2
226
+ if not isinstance(payload, dict):
227
+ sys.stderr.write("skf-merge-doc-urls: input must be a JSON object\n")
228
+ return 2
229
+
230
+ scope_type = payload.get("scope_type") or ""
231
+ existing = payload.get("existing") or []
232
+ detected = payload.get("detected") or []
233
+ if not isinstance(existing, list) or not isinstance(detected, list):
234
+ sys.stderr.write("skf-merge-doc-urls: 'existing' and 'detected' must be arrays\n")
235
+ return 2
236
+
237
+ doc_urls, suppressed = merge_doc_urls(scope_type, existing, detected)
238
+ json.dump({"doc_urls": doc_urls, "suppressed": suppressed}, sys.stdout, ensure_ascii=False)
239
+ sys.stdout.write("\n")
240
+ return 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main())
@@ -9,7 +9,7 @@ registry, then scans target directory files for fingerprint matches and applies
9
9
  the corresponding fixes.
10
10
 
11
11
  CLI:
12
- uv run python src/shared/scripts/skf-preapply.py \
12
+ uv run src/shared/scripts/skf-preapply.py \
13
13
  --target-dir <path> [--registry <path>] [--local-registry <path>] [--log-dir <path>]
14
14
 
15
15
  Input:
@@ -16,10 +16,23 @@ Actions:
16
16
  clear — Remove managed section entirely (markers + content)
17
17
  insert — Insert managed section if not present (at end of file)
18
18
  check — Check if managed section exists, report status
19
+
20
+ Query actions (read-only, take positional file lists instead of <file> <action>):
21
+ orphan-detect <context-file>... --exported-skills a,b
22
+ — Parse `[skill-name v...]` rows across one or more context
23
+ files, drop rows whose skill_name is in the exported set,
24
+ and report the survivors (deduped by (skill_name, version),
25
+ with verbatim snippet_text and sorted source_files provenance)
26
+ as `orphan_managed_rows`.
27
+ root-probe <snippet-file>... --reference-root .claude/skills/
28
+ — Read each snippet's first line, parse its `root:` prefix
29
+ (trailing `{skill-name}/` stripped), collect the unique
30
+ `observed_prefixes`, and flag `mismatch` against the reference.
19
31
  """
20
32
 
21
33
  from __future__ import annotations
22
34
 
35
+ import argparse
23
36
  import json
24
37
  import os
25
38
  import re
@@ -34,6 +47,16 @@ MARKER_PATTERN = re.compile(
34
47
  re.DOTALL,
35
48
  )
36
49
 
50
+ # A managed-section skill row header: `[skill-name v1.2.3]…`, optionally
51
+ # prefixed by the format's leading `|`. Requires a ` v<version>` inside the
52
+ # brackets so the section header `[SKF Skills]|{n} skills|{m} stack` (no ` v`)
53
+ # is never mistaken for a skill row.
54
+ ROW_HEADER_PATTERN = re.compile(
55
+ r"^\|?\[(?P<name>[^\]]+?) v(?P<version>[^\]\s]+)\]"
56
+ )
57
+ # The `root:` field of a snippet's first line: `…|root: {prefix}{skill-name}/`.
58
+ SNIPPET_ROOT_PATTERN = re.compile(r"root:\s*(?P<root>\S.*?)\s*$")
59
+
37
60
 
38
61
  def read_context_file(file_path):
39
62
  """Read a context file. Returns (content, None) or (None, error)."""
@@ -222,7 +245,164 @@ def cmd_insert(file_path, new_content):
222
245
  return {"status": "ok", "action": "inserted", "bytes_written": len(updated)}
223
246
 
224
247
 
248
+ def _is_row_separator(line):
249
+ """A blank/separator line between snippets: bare `|` or empty."""
250
+ return line.strip() in ("", "|")
251
+
252
+
253
+ def parse_managed_rows(body):
254
+ """Split the between-marker `body` into skill rows.
255
+
256
+ Returns a list of {skill_name, version, snippet_text} dicts, one per
257
+ `[skill-name v...]` header found. `snippet_text` is the header line plus its
258
+ continuation lines, captured verbatim (byte-preserved) up to — but not
259
+ including — the next bare-`|` separator, the next row header, or end of body.
260
+ Non-row lines (the `[SKF Skills]` header, the IMPORTANT preamble) are skipped.
261
+ """
262
+ lines = body.split("\n")
263
+ rows = []
264
+ i = 0
265
+ n = len(lines)
266
+ while i < n:
267
+ m = ROW_HEADER_PATTERN.match(lines[i])
268
+ if not m:
269
+ i += 1
270
+ continue
271
+ start = i
272
+ i += 1
273
+ while i < n and not ROW_HEADER_PATTERN.match(lines[i]) and not _is_row_separator(lines[i]):
274
+ i += 1
275
+ rows.append(
276
+ {
277
+ "skill_name": m.group("name"),
278
+ "version": m.group("version"),
279
+ "snippet_text": "\n".join(lines[start:i]),
280
+ }
281
+ )
282
+ return rows
283
+
284
+
285
+ def cmd_orphan_detect(context_files, exported_skills):
286
+ """Detect managed-section rows absent from the exported skill set.
287
+
288
+ Scans every context file that exists and has a managed section, parses its
289
+ skill rows, drops those whose skill_name is in `exported_skills`, and
290
+ accumulates the survivors keyed by (skill_name, version). The first-seen
291
+ snippet_text wins; each file the row appears in is added to source_files.
292
+ Deterministic: rows sorted by (skill_name, version), source_files sorted.
293
+ """
294
+ exported = {s.strip() for s in exported_skills.split(",") if s.strip()}
295
+ orphans = {} # (name, version) -> {skill_name, version, snippet_text, source_files:set}
296
+ for cf in context_files:
297
+ content, err = read_context_file(cf)
298
+ if err:
299
+ continue # missing file — no orphans by definition
300
+ match = find_managed_section(content)
301
+ if not match:
302
+ continue # no managed section — no orphans
303
+ for row in parse_managed_rows(match.group(2)):
304
+ if row["skill_name"] in exported:
305
+ continue # not an orphan — it is being (re)exported
306
+ key = (row["skill_name"], row["version"])
307
+ if key in orphans:
308
+ orphans[key]["source_files"].add(cf) # keep first-seen snippet_text
309
+ else:
310
+ orphans[key] = {
311
+ "skill_name": row["skill_name"],
312
+ "version": row["version"],
313
+ "snippet_text": row["snippet_text"],
314
+ "source_files": {cf},
315
+ }
316
+ orphan_rows = [
317
+ {
318
+ "skill_name": v["skill_name"],
319
+ "version": v["version"],
320
+ "snippet_text": v["snippet_text"],
321
+ "source_files": sorted(v["source_files"]),
322
+ }
323
+ for _, v in sorted(orphans.items())
324
+ ]
325
+ return {"status": "ok", "orphan_managed_rows": orphan_rows}
326
+
327
+
328
+ def cmd_root_probe(snippet_files, reference_root):
329
+ """Observe snippet root prefixes and flag mismatch against a reference.
330
+
331
+ Reads the first line of each snippet that exists, parses its `root:` value,
332
+ strips the trailing `{skill-name}/` to recover the prefix, and collects the
333
+ unique prefixes. `mismatch` is True when any observed prefix differs from
334
+ `reference_root`. Deterministic: observed_prefixes sorted.
335
+ """
336
+ observed = set()
337
+ for sf in snippet_files:
338
+ content, err = read_context_file(sf)
339
+ if err:
340
+ continue # missing snippet — skip
341
+ first_line = content.split("\n", 1)[0]
342
+ header = ROW_HEADER_PATTERN.match(first_line)
343
+ root_match = SNIPPET_ROOT_PATTERN.search(first_line)
344
+ if not root_match:
345
+ continue # no root: field — skip
346
+ root_value = root_match.group("root")
347
+ prefix = root_value
348
+ if header:
349
+ suffix = header.group("name") + "/"
350
+ if root_value.endswith(suffix):
351
+ prefix = root_value[: -len(suffix)]
352
+ observed.add(prefix)
353
+ observed_prefixes = sorted(observed)
354
+ return {
355
+ "status": "ok",
356
+ "reference_root": reference_root,
357
+ "observed_prefixes": observed_prefixes,
358
+ "mismatch": any(p != reference_root for p in observed_prefixes),
359
+ }
360
+
361
+
362
+ def _run_query_action(argv):
363
+ """Dispatch the read-only query actions (orphan-detect / root-probe).
364
+
365
+ These take a positional file list rather than the legacy `<file> <action>`
366
+ shape, so they are parsed with argparse and routed here before the legacy
367
+ path. Returns (result_dict, exit_code).
368
+ """
369
+ action = argv[0]
370
+ if action == "orphan-detect":
371
+ parser = argparse.ArgumentParser(
372
+ prog="skf-rebuild-managed-sections.py orphan-detect",
373
+ description="Detect managed-section rows absent from the exported skill set.",
374
+ )
375
+ parser.add_argument("context_files", nargs="+", help="Context file paths to scan")
376
+ parser.add_argument(
377
+ "--exported-skills",
378
+ default="",
379
+ help="Comma-separated skill names being exported (excluded from orphans)",
380
+ )
381
+ args = parser.parse_args(argv[1:])
382
+ return cmd_orphan_detect(args.context_files, args.exported_skills), 0
383
+ # action == "root-probe"
384
+ parser = argparse.ArgumentParser(
385
+ prog="skf-rebuild-managed-sections.py root-probe",
386
+ description="Observe snippet root prefixes and flag mismatch against a reference.",
387
+ )
388
+ parser.add_argument("snippet_files", nargs="+", help="Snippet file paths to probe")
389
+ parser.add_argument(
390
+ "--reference-root",
391
+ required=True,
392
+ help="Reference skill root prefix to compare observed prefixes against",
393
+ )
394
+ args = parser.parse_args(argv[1:])
395
+ return cmd_root_probe(args.snippet_files, args.reference_root), 0
396
+
397
+
225
398
  def main():
399
+ # Query actions take a positional file list, so they are action-first
400
+ # (`orphan-detect <file>...`) rather than the legacy `<file> <action>` shape.
401
+ if len(sys.argv) >= 2 and sys.argv[1] in ("orphan-detect", "root-probe"):
402
+ result, code = _run_query_action(sys.argv[1:])
403
+ print(json.dumps(result, indent=2))
404
+ sys.exit(code)
405
+
226
406
  if len(sys.argv) < 3:
227
407
  print("Usage: python3 skf-rebuild-managed-sections.py <context-file> <action> [--content <text>]", file=sys.stderr)
228
408
  print("Actions: read, replace, clear, insert, check", file=sys.stderr)
@@ -15,7 +15,7 @@ The five-rule ladder (apply in order, first match wins):
15
15
  entries or `Component[]` annotation when contents available;
16
16
  presence-only when contents not available)
17
17
  2. reference-app — intent text mentions wiring / integration / starter
18
- / lifecycle / build-config keywords, OR analysis flagged as demo/example
18
+ / build-config keywords, OR analysis flagged as demo/example
19
19
  3. specific-modules — intent names a specific subset ("just the X",
20
20
  "only the Y") OR module_count >= 6
21
21
  4. public-api — export_count <= 8 AND intent mentions "the API",
@@ -71,7 +71,6 @@ REFERENCE_APP_KEYWORDS = (
71
71
  "integration example",
72
72
  "integration sample",
73
73
  "starter",
74
- "lifecycle",
75
74
  "build config",
76
75
  "build-config",
77
76
  "demo app",