bmad-module-skill-forge 2.0.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 (235) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/package.json +2 -2
  5. package/src/knowledge/skill-lifecycle.md +1 -1
  6. package/src/module-help.csv +2 -2
  7. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  8. package/src/shared/scripts/skf-count-tokens.py +222 -0
  9. package/src/shared/scripts/skf-detect-docs.py +232 -1
  10. package/src/shared/scripts/skf-detect-language.py +77 -0
  11. package/src/shared/scripts/skf-detect-tools.py +105 -2
  12. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  13. package/src/shared/scripts/skf-find-cycles.py +246 -0
  14. package/src/shared/scripts/skf-hash-content.py +487 -2
  15. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  16. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  17. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  18. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  19. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  20. package/src/shared/scripts/skf-shard-body.py +461 -0
  21. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  22. package/src/shared/scripts/skf-structural-diff.py +307 -67
  23. package/src/shared/scripts/skf-validate-brief-schema.py +39 -1
  24. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  25. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  26. package/src/shared/scripts/skf-validate-output.py +443 -10
  27. package/src/shared/scripts/skf-validate-pins.py +1 -1
  28. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  30. package/src/skf-analyze-source/SKILL.md +17 -32
  31. package/src/skf-analyze-source/customize.toml +2 -0
  32. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  33. package/src/skf-analyze-source/references/continue.md +8 -13
  34. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  35. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  36. package/src/skf-analyze-source/references/health-check.md +1 -2
  37. package/src/skf-analyze-source/references/identify-units.md +25 -36
  38. package/src/skf-analyze-source/references/init.md +12 -25
  39. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  40. package/src/skf-analyze-source/references/recommend.md +4 -14
  41. package/src/skf-analyze-source/references/scan-project.md +3 -11
  42. package/src/skf-analyze-source/references/step-auto-scope.md +42 -180
  43. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  44. package/src/skf-audit-skill/SKILL.md +9 -6
  45. package/src/skf-audit-skill/customize.toml +2 -0
  46. package/src/skf-audit-skill/references/init.md +58 -32
  47. package/src/skf-audit-skill/references/re-index.md +15 -34
  48. package/src/skf-audit-skill/references/report.md +11 -58
  49. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  50. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  51. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  52. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  53. package/src/skf-brief-skill/SKILL.md +7 -36
  54. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  55. package/src/skf-brief-skill/customize.toml +14 -0
  56. package/src/skf-brief-skill/references/analyze-target.md +6 -14
  57. package/src/skf-brief-skill/references/confirm-brief.md +2 -14
  58. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  59. package/src/skf-brief-skill/references/gather-intent.md +19 -22
  60. package/src/skf-brief-skill/references/headless-args.md +1 -1
  61. package/src/skf-brief-skill/references/health-check.md +1 -22
  62. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  63. package/src/skf-brief-skill/references/scope-definition.md +4 -14
  64. package/src/skf-brief-skill/references/step-auto-brief.md +1 -5
  65. package/src/skf-brief-skill/references/step-auto-validate.md +1 -5
  66. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  67. package/src/skf-brief-skill/references/write-brief.md +17 -10
  68. package/src/skf-campaign/SKILL.md +15 -84
  69. package/src/skf-campaign/customize.toml +2 -0
  70. package/src/skf-campaign/manifest.yaml +4 -0
  71. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  72. package/src/skf-campaign/references/campaign-directive-spec.md +14 -103
  73. package/src/skf-campaign/references/step-01-setup.md +4 -5
  74. package/src/skf-campaign/references/step-02-strategy.md +8 -3
  75. package/src/skf-campaign/references/step-03-pins.md +1 -1
  76. package/src/skf-campaign/references/step-04-provenance.md +1 -1
  77. package/src/skf-campaign/references/step-05-skill-loop.md +1 -1
  78. package/src/skf-campaign/references/step-06-batch.md +7 -6
  79. package/src/skf-campaign/references/step-11-maintenance.md +2 -2
  80. package/src/skf-campaign/references/step-resume.md +9 -8
  81. package/src/skf-campaign/scripts/campaign-deps.py +10 -1
  82. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  83. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  84. package/src/skf-campaign/templates/campaign-brief-template.yaml +1 -1
  85. package/src/skf-create-skill/SKILL.md +14 -3
  86. package/src/skf-create-skill/assets/compile-assembly-rules.md +17 -38
  87. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  88. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  89. package/src/skf-create-skill/customize.toml +13 -0
  90. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  91. package/src/skf-create-skill/references/compile.md +34 -47
  92. package/src/skf-create-skill/references/component-extraction.md +8 -35
  93. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  94. package/src/skf-create-skill/references/enrich.md +4 -17
  95. package/src/skf-create-skill/references/extract.md +14 -29
  96. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  97. package/src/skf-create-skill/references/extraction-patterns.md +7 -7
  98. package/src/skf-create-skill/references/generate-artifacts.md +9 -23
  99. package/src/skf-create-skill/references/health-check.md +1 -1
  100. package/src/skf-create-skill/references/load-brief.md +16 -18
  101. package/src/skf-create-skill/references/report.md +12 -15
  102. package/src/skf-create-skill/references/source-resolution-protocols.md +3 -3
  103. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  104. package/src/skf-create-skill/references/step-doc-rot.md +30 -12
  105. package/src/skf-create-skill/references/step-doc-sources.md +2 -2
  106. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  107. package/src/skf-create-skill/references/sub/fetch-docs.md +8 -50
  108. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  109. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  110. package/src/skf-create-skill/references/validate.md +59 -28
  111. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  112. package/src/skf-create-stack-skill/SKILL.md +15 -9
  113. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  114. package/src/skf-create-stack-skill/customize.toml +10 -0
  115. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  116. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  117. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  118. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  119. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  120. package/src/skf-create-stack-skill/references/init.md +9 -81
  121. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  122. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  123. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  124. package/src/skf-create-stack-skill/references/report.md +27 -62
  125. package/src/skf-create-stack-skill/references/validate.md +35 -31
  126. package/src/skf-drop-skill/SKILL.md +20 -23
  127. package/src/skf-drop-skill/customize.toml +10 -0
  128. package/src/skf-drop-skill/references/execute.md +51 -31
  129. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  130. package/src/skf-drop-skill/references/health-check.md +1 -13
  131. package/src/skf-drop-skill/references/report.md +42 -10
  132. package/src/skf-drop-skill/references/select.md +88 -48
  133. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  134. package/src/skf-export-skill/SKILL.md +17 -14
  135. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  136. package/src/skf-export-skill/customize.toml +10 -6
  137. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  138. package/src/skf-export-skill/references/load-skill.md +44 -42
  139. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  140. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  141. package/src/skf-export-skill/references/package.md +27 -33
  142. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  143. package/src/skf-export-skill/references/result-envelope.md +28 -0
  144. package/src/skf-export-skill/references/summary.md +11 -5
  145. package/src/skf-export-skill/references/token-report.md +42 -24
  146. package/src/skf-export-skill/references/update-context.md +32 -33
  147. package/src/skf-forger/SKILL.md +27 -39
  148. package/src/skf-forger/references/pipeline-mode.md +34 -0
  149. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  150. package/src/skf-quick-skill/SKILL.md +10 -60
  151. package/src/skf-quick-skill/customize.toml +9 -0
  152. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  153. package/src/skf-quick-skill/references/compile.md +15 -52
  154. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  155. package/src/skf-quick-skill/references/finalize.md +12 -6
  156. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  157. package/src/skf-quick-skill/references/health-check.md +3 -4
  158. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  159. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  160. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  161. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  162. package/src/skf-refine-architecture/SKILL.md +14 -7
  163. package/src/skf-refine-architecture/customize.toml +9 -0
  164. package/src/skf-refine-architecture/references/compile.md +9 -7
  165. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  166. package/src/skf-refine-architecture/references/health-check.md +1 -1
  167. package/src/skf-refine-architecture/references/improvements.md +9 -25
  168. package/src/skf-refine-architecture/references/init.md +9 -16
  169. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  170. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  171. package/src/skf-refine-architecture/references/report.md +13 -10
  172. package/src/skf-rename-skill/SKILL.md +11 -21
  173. package/src/skf-rename-skill/customize.toml +10 -0
  174. package/src/skf-rename-skill/references/execute.md +107 -78
  175. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  176. package/src/skf-rename-skill/references/report.md +16 -8
  177. package/src/skf-rename-skill/references/select.md +69 -42
  178. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  179. package/src/skf-setup/SKILL.md +20 -12
  180. package/src/skf-setup/customize.toml +10 -0
  181. package/src/skf-setup/references/auto-index.md +9 -10
  182. package/src/skf-setup/references/ccc-index.md +14 -15
  183. package/src/skf-setup/references/detect-and-tier.md +6 -8
  184. package/src/skf-setup/references/report.md +22 -31
  185. package/src/skf-setup/references/tier-rules.md +2 -32
  186. package/src/skf-setup/references/write-config.md +9 -10
  187. package/src/skf-test-skill/SKILL.md +10 -8
  188. package/src/skf-test-skill/customize.toml +2 -0
  189. package/src/skf-test-skill/references/coherence-check.md +19 -31
  190. package/src/skf-test-skill/references/coverage-check.md +109 -75
  191. package/src/skf-test-skill/references/detect-mode.md +10 -41
  192. package/src/skf-test-skill/references/external-validators.md +11 -19
  193. package/src/skf-test-skill/references/init.md +38 -20
  194. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  195. package/src/skf-test-skill/references/report.md +60 -40
  196. package/src/skf-test-skill/references/score.md +32 -64
  197. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  198. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  199. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  200. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  201. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  202. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  203. package/src/skf-test-skill/scripts/compute-score.py +131 -1
  204. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  205. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  206. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  207. package/src/skf-update-skill/SKILL.md +17 -5
  208. package/src/skf-update-skill/customize.toml +14 -0
  209. package/src/skf-update-skill/references/detect-changes.md +19 -37
  210. package/src/skf-update-skill/references/health-check.md +3 -9
  211. package/src/skf-update-skill/references/init.md +39 -32
  212. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  213. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  214. package/src/skf-update-skill/references/merge.md +13 -57
  215. package/src/skf-update-skill/references/re-extract.md +12 -24
  216. package/src/skf-update-skill/references/report.md +14 -7
  217. package/src/skf-update-skill/references/validate.md +32 -30
  218. package/src/skf-update-skill/references/write.md +86 -51
  219. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  220. package/src/skf-verify-stack/SKILL.md +11 -21
  221. package/src/skf-verify-stack/customize.toml +12 -3
  222. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  223. package/src/skf-verify-stack/references/coverage.md +21 -10
  224. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  225. package/src/skf-verify-stack/references/health-check.md +1 -1
  226. package/src/skf-verify-stack/references/init.md +20 -25
  227. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  228. package/src/skf-verify-stack/references/integrations.md +44 -49
  229. package/src/skf-verify-stack/references/report.md +28 -34
  230. package/src/skf-verify-stack/references/requirements.md +3 -3
  231. package/src/skf-verify-stack/references/synthesize.md +36 -39
  232. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  233. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  234. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  235. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # ///
5
+ """SKF Verify No Trace — post-rename commit gate.
6
+
7
+ Confirms that no structural reference to the old skill name survives inside the
8
+ freshly-materialized new location before the rename is committed (before the old
9
+ directories are deleted). Every check is a fixed-file-set, case-sensitive scan
10
+ plus a region partition — fully deterministic, so the prompt keeps only the
11
+ roll-back-or-commit decision.
12
+
13
+ The scan matches the old name only as a complete skill-name token: an occurrence
14
+ NOT adjacent to another skill-name character `[a-z0-9-]` on either side. This is
15
+ what a literal substring grep intends but gets wrong when the old name is a
16
+ substring of the new name (rename -> rename-skill): a bare substring scan would
17
+ flag every correctly-renamed `rename-skill` as a leftover and make the rename
18
+ un-committable. Token matching flags a genuine leftover path segment
19
+ (`.claude/skills/rename/`), JSON value (`"rename"`), or header (`[rename v...]`)
20
+ while ignoring `rename-skill` and `renamed`.
21
+
22
+ For each version under --versions it scans:
23
+
24
+ {skill_group}/{v}/{new_name}/SKILL.md -> frontmatter matches are HARD;
25
+ body matches (below the closing
26
+ `---`) are advisory warnings
27
+ {skill_group}/{v}/{new_name}/metadata.json -> any match is HARD
28
+ {skill_group}/{v}/{new_name}/context-snippet.md-> any match is HARD
29
+ {forge_group}/{v}/provenance-map.json -> any match is HARD
30
+
31
+ and the directory listing:
32
+
33
+ {skill_group}/{v}/ MUST contain {new_name}/ and MUST NOT contain {old_name}/
34
+
35
+ The SKILL.md frontmatter/body split uses the same closing-`---`-on-its-own-line
36
+ rule as skf-validate-output.py, so a legitimate body mention of the old name (a
37
+ changelog line, a cross-reference) is a warning, never a blocker.
38
+
39
+ Output (stdout, always JSON):
40
+ {hard_matches[], body_warnings[], dir_violations[], skipped[], clean}
41
+
42
+ Exit codes:
43
+ 0 clean (no hard_matches and no dir_violations)
44
+ 1 not clean (at least one hard match or dir violation)
45
+ 2 operation error (bad args)
46
+
47
+ CLI example:
48
+ python3 skf-verify-no-trace.py /out/rename-skill \
49
+ --forge-group /forge/rename-skill \
50
+ --old-name rename --new-name rename-skill --versions 1.0.0,0.9.0
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import argparse
56
+ import json
57
+ import re
58
+ import sys
59
+ from pathlib import Path
60
+
61
+
62
+ def _name_token_re(old_name: str) -> "re.Pattern[str]":
63
+ """Match old_name only where it is a complete skill-name token.
64
+
65
+ Negative lookbehind/lookahead on `[a-z0-9-]` so `rename` matches inside
66
+ `/rename/` or `"rename"` but not inside `rename-skill` or `renamed`.
67
+ """
68
+ return re.compile(r"(?<![a-z0-9-])" + re.escape(old_name) + r"(?![a-z0-9-])")
69
+
70
+
71
+ def _split_frontmatter_body(content: str):
72
+ """Return (frontmatter_lines, body_lines) as lists of (lineno, text).
73
+
74
+ Uses the closing-`---`-on-its-own-line rule (matching skf-validate-output.py).
75
+ When there is no valid frontmatter block, the whole file is treated as body —
76
+ a SKILL.md without frontmatter has no "structural" region to protect, so any
77
+ old-name match there is advisory, and the separate metadata/snippet/provenance
78
+ scans still gate the commit.
79
+ """
80
+ lines = content.split("\n")
81
+ numbered = list(enumerate(lines, start=1))
82
+ if not content.startswith("---\n"):
83
+ return [], numbered
84
+ close_idx = None # 0-based index into `lines` of the closing '---'
85
+ for i in range(1, len(lines)):
86
+ if lines[i].rstrip() == "---":
87
+ close_idx = i
88
+ break
89
+ if close_idx is None:
90
+ return [], numbered
91
+ # Frontmatter = lines strictly between the opening (line 1) and closing '---'.
92
+ frontmatter = numbered[1:close_idx]
93
+ body = numbered[close_idx + 1 :]
94
+ return frontmatter, body
95
+
96
+
97
+ def _scan_lines(numbered, pattern):
98
+ """Return [(lineno, text)] for every line where pattern matches (name token)."""
99
+ return [(n, text) for (n, text) in numbered if pattern.search(text)]
100
+
101
+
102
+ def verify(skill_group: Path, forge_group: Path, old_name: str, new_name: str, versions):
103
+ hard_matches = []
104
+ body_warnings = []
105
+ dir_violations = []
106
+ skipped = []
107
+ pattern = _name_token_re(old_name)
108
+
109
+ def scan_file(path: Path, region: str, version: str):
110
+ if not path.exists():
111
+ skipped.append({"version": version, "file": str(path), "reason": "missing"})
112
+ return None
113
+ try:
114
+ return path.read_text(encoding="utf-8")
115
+ except OSError as e:
116
+ skipped.append({"version": version, "file": str(path), "reason": f"read-error: {e}"})
117
+ return None
118
+
119
+ for v in versions:
120
+ inner = skill_group / v / new_name
121
+
122
+ # SKILL.md — region-partitioned (frontmatter=hard, body=warning).
123
+ skill_md = inner / "SKILL.md"
124
+ content = scan_file(skill_md, "skill-md", v)
125
+ if content is not None:
126
+ fm, body = _split_frontmatter_body(content)
127
+ for n, text in _scan_lines(fm, pattern):
128
+ hard_matches.append(
129
+ {"version": v, "file": str(skill_md), "region": "skill-frontmatter",
130
+ "line": n, "text": text.strip()}
131
+ )
132
+ for n, text in _scan_lines(body, pattern):
133
+ body_warnings.append(
134
+ {"version": v, "file": str(skill_md), "region": "skill-body",
135
+ "line": n, "text": text.strip()}
136
+ )
137
+
138
+ # Whole-file hard scans.
139
+ for rel, region, base in (
140
+ (inner / "metadata.json", "metadata-json", skill_group),
141
+ (inner / "context-snippet.md", "context-snippet", skill_group),
142
+ (forge_group / v / "provenance-map.json", "provenance-json", forge_group),
143
+ ):
144
+ content = scan_file(rel, region, v)
145
+ if content is None:
146
+ continue
147
+ for n, text in _scan_lines(list(enumerate(content.split("\n"), start=1)), pattern):
148
+ hard_matches.append(
149
+ {"version": v, "file": str(rel), "region": region,
150
+ "line": n, "text": text.strip()}
151
+ )
152
+
153
+ # Directory listing: must contain {new_name}/, must not contain {old_name}/.
154
+ version_dir = skill_group / v
155
+ old_dir = version_dir / old_name
156
+ new_dir = version_dir / new_name
157
+ if old_dir.exists():
158
+ dir_violations.append(
159
+ {"version": v, "path": str(version_dir),
160
+ "issue": "old-name-dir-present", "detail": f"{old_name}/"}
161
+ )
162
+ if not new_dir.exists():
163
+ dir_violations.append(
164
+ {"version": v, "path": str(version_dir),
165
+ "issue": "new-name-dir-missing", "detail": f"{new_name}/"}
166
+ )
167
+
168
+ clean = not hard_matches and not dir_violations
169
+ return {
170
+ "status": "ok",
171
+ "old_name": old_name,
172
+ "new_name": new_name,
173
+ "versions": list(versions),
174
+ "hard_matches": hard_matches,
175
+ "body_warnings": body_warnings,
176
+ "dir_violations": dir_violations,
177
+ "skipped": skipped,
178
+ "clean": clean,
179
+ }
180
+
181
+
182
+ def main() -> None:
183
+ parser = argparse.ArgumentParser(
184
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
185
+ )
186
+ parser.add_argument("skill_group", type=Path, help="new_skill_group ({skills_output_folder}/{new_name})")
187
+ parser.add_argument("--forge-group", type=Path, required=True, help="new_forge_group ({forge_data_folder}/{new_name})")
188
+ parser.add_argument("--old-name", required=True, help="Old skill name to scan for")
189
+ parser.add_argument("--new-name", required=True, help="New skill name")
190
+ parser.add_argument("--versions", required=True, help="Comma-separated version list (affected_versions)")
191
+ parser.add_argument("--verbose", action="store_true", help="Diagnostics to stderr")
192
+ args = parser.parse_args()
193
+
194
+ versions = [v for v in (s.strip() for s in args.versions.split(",")) if v]
195
+ if not versions:
196
+ print(json.dumps({"status": "error", "message": "no versions provided"}), file=sys.stderr)
197
+ sys.exit(2)
198
+
199
+ result = verify(args.skill_group, args.forge_group, args.old_name, args.new_name, versions)
200
+
201
+ if args.verbose:
202
+ print(
203
+ f"[skf-verify-no-trace] hard={len(result['hard_matches'])} "
204
+ f"warn={len(result['body_warnings'])} dir={len(result['dir_violations'])} "
205
+ f"clean={result['clean']}",
206
+ file=sys.stderr,
207
+ )
208
+
209
+ print(json.dumps(result, indent=2))
210
+ sys.exit(0 if result["clean"] else 1)
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = []
5
+ # ///
6
+ """SKF Verify Provenance Completeness — deterministic Check D for update-skill.
7
+
8
+ `skf-update-skill/references/validate.md §2 Check D (Provenance Completeness)`
9
+ previously asked the model to perform three deterministic set/citation
10
+ operations by eye, against in-context data:
11
+
12
+ 1. **Completeness** — verify every documented export has a provenance-map
13
+ entry (metadata `exports[]` \\ provenance `entries[].export_name`).
14
+ 2. **Orphans** — flag provenance entries whose export was removed
15
+ (provenance `entries[].export_name` \\ metadata `exports[]`).
16
+ 3. **Stale citations** — flag entries whose `source_file:source_line`
17
+ no longer resolves (file gone, or line past end of file).
18
+
19
+ An LLM set-diff can silently pass a dropped or orphaned entry, and eyeballing
20
+ whether a cited line still exists is not something the model can do reliably.
21
+ Both inputs are machine-readable — `metadata.json` (`exports[]`, a list of
22
+ export-name strings) and `provenance-map.json` (`entries[]` with
23
+ `export_name` / `source_file` / `source_line`, plus an optional
24
+ `reexport_map` for stack-skill barrel renames) are written by
25
+ `write.md` §2 and §3. This script diffs the two sets and re-checks the
26
+ citations exactly, so Check D consumes JSON rather than computing it.
27
+
28
+ Timing: this runs **post-write** (`write.md` §6), after `metadata.json`
29
+ (§2) and `provenance-map.json` (§3) are on disk. `validate.md` Check D
30
+ defers here — the provenance map does not exist yet at validate time.
31
+
32
+ Determinism:
33
+ - Set operations are pure over the two JSON inputs.
34
+ - Citation resolution reads the source tree at `source_root` (from the
35
+ provenance map, or `--source-root`). When no source root resolves on
36
+ disk, the stale check is SKIPPED (`summary.stale_check` reports it) and
37
+ only completeness + orphan diffs run — those need no source tree. Same
38
+ input tree → same output.
39
+
40
+ Stack-skill canonicalization: a provenance entry's `export_name` may be the
41
+ internal name of a barrel-renamed export (e.g. `_Impl` re-exported as
42
+ `Public`). Names are canonicalized through `reexport_map`
43
+ (`{internal: public}`) before the set-diff, mirroring
44
+ `skf-load-provenance.extract_reexport_map`, so an internal-named entry does
45
+ not read as a missing public export nor as an orphan.
46
+
47
+ Subcommand:
48
+ verify --metadata <metadata.json> --provenance <provenance-map.json>
49
+ [--source-root <path>] [-o <out.json>]
50
+
51
+ Emit JSON:
52
+ {
53
+ "status": "pass" | "findings",
54
+ "missing": ["<export documented but not in provenance>", ...],
55
+ "orphaned": ["<provenance entry with no documented export>", ...],
56
+ "stale": [
57
+ {"export_name": "<raw entry name>",
58
+ "source_file": "<rel path>",
59
+ "source_line": <int|null>,
60
+ "reason": "file-missing" | "line-out-of-bounds" | "line-invalid"},
61
+ ...
62
+ ],
63
+ "summary": {
64
+ "exports_checked": <int>, # documented exports (metadata)
65
+ "entries_checked": <int>, # provenance entries with export_name
66
+ "missing_count": <int>,
67
+ "orphaned_count": <int>,
68
+ "stale_count": <int>,
69
+ "citations_checked": <int>, # entries whose citation was resolved
70
+ "stale_check": "checked" | "skipped-no-source-root"
71
+ }
72
+ }
73
+
74
+ `missing` / `orphaned` are sorted, deduplicated public names.
75
+ `stale` is sorted by (export_name, source_file).
76
+
77
+ CLI examples:
78
+ uv run skf-verify-provenance-completeness.py verify \\
79
+ --metadata {skill_package}/metadata.json \\
80
+ --provenance {forge_version}/provenance-map.json \\
81
+ --source-root {source_root}
82
+
83
+ Exit codes:
84
+ 0 — verification ran and found nothing (status "pass")
85
+ 1 — verification ran and found missing / orphaned / stale entries
86
+ (status "findings"); advisory — the caller decides how to surface it
87
+ 2 — error (input file not found, malformed JSON, invalid structure)
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import argparse
93
+ import json
94
+ import sys
95
+ from pathlib import Path
96
+
97
+
98
+ STALE_FILE_MISSING = "file-missing"
99
+ STALE_LINE_OOB = "line-out-of-bounds"
100
+ STALE_LINE_INVALID = "line-invalid"
101
+
102
+
103
+ # --------------------------------------------------------------------------
104
+ # I/O
105
+ # --------------------------------------------------------------------------
106
+
107
+
108
+ def load_json_object(path: Path, label: str) -> dict:
109
+ """Read a JSON file and require a top-level object. Raises ValueError."""
110
+ try:
111
+ text = path.read_text(encoding="utf-8")
112
+ except OSError as exc:
113
+ raise ValueError(f"failed to read {label} {path}: {exc}") from exc
114
+ try:
115
+ data = json.loads(text)
116
+ except json.JSONDecodeError as exc:
117
+ raise ValueError(f"malformed JSON in {label} {path}: {exc}") from exc
118
+ if not isinstance(data, dict):
119
+ raise ValueError(
120
+ f"{label} {path} must be a JSON object at top level; "
121
+ f"got {type(data).__name__}"
122
+ )
123
+ return data
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # Extraction / canonicalization
128
+ # --------------------------------------------------------------------------
129
+
130
+
131
+ def extract_export_names(metadata: dict) -> list[str]:
132
+ """Pull documented export names from metadata.exports.
133
+
134
+ `exports` is a list of export-name strings (per the skill templates).
135
+ Defensively also accept dict items carrying `name` / `export_name`.
136
+ Non-string / unnamed items are skipped. Order preserved; caller dedupes.
137
+ """
138
+ names: list[str] = []
139
+ exports = metadata.get("exports")
140
+ if not isinstance(exports, list):
141
+ return names
142
+ for item in exports:
143
+ if isinstance(item, str) and item:
144
+ names.append(item)
145
+ elif isinstance(item, dict):
146
+ nm = item.get("name") or item.get("export_name")
147
+ if isinstance(nm, str) and nm:
148
+ names.append(nm)
149
+ return names
150
+
151
+
152
+ def extract_reexport_map(prov: dict) -> dict[str, str]:
153
+ """Build the `{internal: public}` re-export map.
154
+
155
+ Mirrors `skf-load-provenance.extract_reexport_map`: top-level
156
+ `reexport_map` is authoritative; per-entry `reexported_as` fills gaps.
157
+ Non-string keys/values skipped.
158
+ """
159
+ out: dict[str, str] = {}
160
+ top = prov.get("reexport_map")
161
+ if isinstance(top, dict):
162
+ for k, v in top.items():
163
+ if isinstance(k, str) and isinstance(v, str):
164
+ out[k] = v
165
+ entries = prov.get("entries")
166
+ if isinstance(entries, list):
167
+ for entry in entries:
168
+ if not isinstance(entry, dict):
169
+ continue
170
+ internal = entry.get("export_name")
171
+ public = entry.get("reexported_as")
172
+ if isinstance(internal, str) and isinstance(public, str):
173
+ out.setdefault(internal, public)
174
+ return out
175
+
176
+
177
+ def canon(name: str, reexport_map: dict[str, str]) -> str:
178
+ """Canonicalize an export name to its public form via reexport_map.
179
+
180
+ Idempotent for names that are not internal (public names are not keys).
181
+ """
182
+ return reexport_map.get(name, name)
183
+
184
+
185
+ def provenance_entry_names(prov: dict) -> list[str]:
186
+ """Raw `export_name` of every well-formed provenance entry (order kept)."""
187
+ names: list[str] = []
188
+ entries = prov.get("entries")
189
+ if not isinstance(entries, list):
190
+ return names
191
+ for entry in entries:
192
+ if not isinstance(entry, dict):
193
+ continue
194
+ nm = entry.get("export_name")
195
+ if isinstance(nm, str) and nm:
196
+ names.append(nm)
197
+ return names
198
+
199
+
200
+ # --------------------------------------------------------------------------
201
+ # Citation resolution
202
+ # --------------------------------------------------------------------------
203
+
204
+
205
+ def _coerce_line(value: object) -> int | None | str:
206
+ """Return an int line number, None (tolerated), or a sentinel str for
207
+ an un-coercible non-null value (reported as line-invalid)."""
208
+ if value is None:
209
+ return None
210
+ if isinstance(value, bool):
211
+ # bool is an int subclass — a boolean line number is nonsense
212
+ return "invalid"
213
+ if isinstance(value, int):
214
+ return value
215
+ if isinstance(value, str):
216
+ s = value.strip()
217
+ if not s:
218
+ return None
219
+ try:
220
+ return int(s)
221
+ except ValueError:
222
+ return "invalid"
223
+ return "invalid"
224
+
225
+
226
+ def check_citation(
227
+ source_file: object, source_line: object, source_root: Path
228
+ ) -> str | None:
229
+ """Resolve a single `source_file:source_line` citation under source_root.
230
+
231
+ Returns a stale reason (one of the STALE_* constants) when the citation
232
+ does not resolve, or None when it is valid OR tolerated.
233
+
234
+ Tolerated (returns None): source_file missing/empty, or source_line null
235
+ or empty — `unknown`-outcome entries legitimately carry null citations
236
+ (write.md §3), so an absent citation is not "stale".
237
+ """
238
+ if not isinstance(source_file, str) or not source_file.strip():
239
+ return None
240
+ line = _coerce_line(source_line)
241
+ if line is None:
242
+ return None
243
+ if isinstance(line, str): # sentinel for un-coercible non-null value
244
+ return STALE_LINE_INVALID
245
+
246
+ rel = source_file.strip().replace("\\", "/")
247
+ target = (source_root / rel).resolve()
248
+ if not target.is_file():
249
+ return STALE_FILE_MISSING
250
+
251
+ if line < 1:
252
+ return STALE_LINE_OOB
253
+ try:
254
+ with target.open("r", encoding="utf-8", errors="replace") as fh:
255
+ line_count = sum(1 for _ in fh)
256
+ except OSError:
257
+ return STALE_FILE_MISSING
258
+ if line > line_count:
259
+ return STALE_LINE_OOB
260
+ return None
261
+
262
+
263
+ # --------------------------------------------------------------------------
264
+ # Verification
265
+ # --------------------------------------------------------------------------
266
+
267
+
268
+ def resolve_source_root(prov: dict, override: str | None) -> Path | None:
269
+ """Pick the source root for citation resolution.
270
+
271
+ `--source-root` override wins; otherwise the provenance map's top-level
272
+ `source_root`. Returns None when neither resolves to an on-disk dir.
273
+ """
274
+ candidate: str | None = None
275
+ if override:
276
+ candidate = override
277
+ else:
278
+ sr = prov.get("source_root")
279
+ if isinstance(sr, str) and sr:
280
+ candidate = sr
281
+ if not candidate:
282
+ return None
283
+ root = Path(candidate)
284
+ return root if root.is_dir() else None
285
+
286
+
287
+ def verify(metadata: dict, prov: dict, source_root: Path | None) -> dict:
288
+ """Compute the completeness / orphan / stale report over the two maps."""
289
+ reexport_map = extract_reexport_map(prov)
290
+
291
+ documented = extract_export_names(metadata)
292
+ documented_canon = {canon(n, reexport_map) for n in documented}
293
+
294
+ raw_entry_names = provenance_entry_names(prov)
295
+ entry_canon = {canon(n, reexport_map) for n in raw_entry_names}
296
+
297
+ missing = sorted(documented_canon - entry_canon)
298
+ orphaned = sorted(entry_canon - documented_canon)
299
+
300
+ stale: list[dict] = []
301
+ citations_checked = 0
302
+ if source_root is not None:
303
+ entries = prov.get("entries")
304
+ if isinstance(entries, list):
305
+ for entry in entries:
306
+ if not isinstance(entry, dict):
307
+ continue
308
+ name = entry.get("export_name")
309
+ if not isinstance(name, str) or not name:
310
+ continue
311
+ sf = entry.get("source_file")
312
+ sl = entry.get("source_line")
313
+ # Only count a resolvable (non-null) citation as "checked".
314
+ if isinstance(sf, str) and sf.strip() and _coerce_line(sl) is not None:
315
+ citations_checked += 1
316
+ reason = check_citation(sf, sl, source_root)
317
+ if reason is not None:
318
+ stale.append(
319
+ {
320
+ "export_name": name,
321
+ "source_file": sf if isinstance(sf, str) else None,
322
+ "source_line": sl if isinstance(sl, int)
323
+ and not isinstance(sl, bool) else sl,
324
+ "reason": reason,
325
+ }
326
+ )
327
+ stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
328
+
329
+ status = "pass" if not (missing or orphaned or stale) else "findings"
330
+ return {
331
+ "status": status,
332
+ "missing": missing,
333
+ "orphaned": orphaned,
334
+ "stale": stale,
335
+ "summary": {
336
+ "exports_checked": len(documented_canon),
337
+ "entries_checked": len(raw_entry_names),
338
+ "missing_count": len(missing),
339
+ "orphaned_count": len(orphaned),
340
+ "stale_count": len(stale),
341
+ "citations_checked": citations_checked,
342
+ "stale_check": "checked"
343
+ if source_root is not None
344
+ else "skipped-no-source-root",
345
+ },
346
+ }
347
+
348
+
349
+ # --------------------------------------------------------------------------
350
+ # CLI
351
+ # --------------------------------------------------------------------------
352
+
353
+
354
+ def _cmd_verify(args: argparse.Namespace) -> int:
355
+ meta_path = Path(args.metadata)
356
+ prov_path = Path(args.provenance)
357
+ if not meta_path.is_file():
358
+ print(f"error: metadata not found: {meta_path}", file=sys.stderr)
359
+ return 2
360
+ if not prov_path.is_file():
361
+ print(f"error: provenance map not found: {prov_path}", file=sys.stderr)
362
+ return 2
363
+ try:
364
+ metadata = load_json_object(meta_path, "metadata")
365
+ prov = load_json_object(prov_path, "provenance map")
366
+ except ValueError as exc:
367
+ print(f"error: {exc}", file=sys.stderr)
368
+ return 2
369
+
370
+ source_root = resolve_source_root(prov, args.source_root)
371
+ if args.verbose:
372
+ if source_root is None:
373
+ print(
374
+ "verbose: no source root resolved — skipping stale-citation "
375
+ "check (completeness + orphan diffs only)",
376
+ file=sys.stderr,
377
+ )
378
+ else:
379
+ print(f"verbose: resolving citations under {source_root}", file=sys.stderr)
380
+
381
+ result = verify(metadata, prov, source_root)
382
+
383
+ out_text = json.dumps(result, indent=2) + "\n"
384
+ if args.output and args.output != "-":
385
+ Path(args.output).write_text(out_text, encoding="utf-8")
386
+ else:
387
+ sys.stdout.write(out_text)
388
+
389
+ return 0 if result["status"] == "pass" else 1
390
+
391
+
392
+ def _build_parser() -> argparse.ArgumentParser:
393
+ parser = argparse.ArgumentParser(
394
+ prog="skf-verify-provenance-completeness",
395
+ description=(
396
+ "Cross-reference documented exports (metadata.json) against "
397
+ "provenance-map entries — completeness, orphans, and stale "
398
+ "file:line citations — emitting findings as JSON."
399
+ ),
400
+ )
401
+ sub = parser.add_subparsers(dest="cmd", required=True)
402
+ p = sub.add_parser(
403
+ "verify",
404
+ help="emit provenance completeness / orphan / stale-citation findings",
405
+ )
406
+ p.add_argument("--metadata", required=True, help="path to metadata.json")
407
+ p.add_argument(
408
+ "--provenance", required=True, help="path to provenance-map.json"
409
+ )
410
+ p.add_argument(
411
+ "--source-root",
412
+ default=None,
413
+ help=(
414
+ "source tree root for citation resolution; overrides the "
415
+ "provenance map's source_root. Stale check is skipped when no "
416
+ "root resolves on disk."
417
+ ),
418
+ )
419
+ p.add_argument(
420
+ "-o",
421
+ "--output",
422
+ default=None,
423
+ help="write JSON to this file instead of stdout ('-' for stdout)",
424
+ )
425
+ p.add_argument(
426
+ "--verbose", action="store_true", help="diagnostics to stderr"
427
+ )
428
+ p.set_defaults(func=_cmd_verify)
429
+ return parser
430
+
431
+
432
+ def main(argv: list[str] | None = None) -> int:
433
+ parser = _build_parser()
434
+ args = parser.parse_args(argv)
435
+ return args.func(args)
436
+
437
+
438
+ if __name__ == "__main__":
439
+ raise SystemExit(main())