bmad-module-skill-forge 1.4.1 → 1.5.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +0 -8
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/STABILITY.md +1 -1
  5. package/docs/architecture.md +2 -2
  6. package/docs/examples.md +1 -1
  7. package/docs/workflows.md +5 -5
  8. package/package.json +2 -2
  9. package/src/README.md +1 -1
  10. package/src/knowledge/ccc-bridge.md +12 -12
  11. package/src/knowledge/qmd-registry.md +5 -5
  12. package/src/shared/health-check.md +5 -5
  13. package/src/shared/references/description-guard-protocol.md +100 -0
  14. package/src/shared/references/output-contract-schema.md +1 -1
  15. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -6
  16. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +9 -3
  17. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +149 -0
  18. package/src/shared/scripts/schemas/skill-brief.v1.json +22 -2
  19. package/src/shared/scripts/skf-build-change-manifest.py +420 -0
  20. package/src/shared/scripts/skf-check-workspace-drift.py +321 -0
  21. package/src/shared/scripts/skf-compare-file-hashes.py +357 -0
  22. package/src/shared/scripts/skf-description-guard.py +359 -0
  23. package/src/shared/scripts/skf-detect-language.py +2 -2
  24. package/src/shared/scripts/skf-detect-scripts-assets.py +613 -0
  25. package/src/shared/scripts/skf-detect-tools.py +97 -8
  26. package/src/shared/scripts/skf-detect-workspaces.py +1 -1
  27. package/src/shared/scripts/skf-disqualify-candidates.py +576 -0
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +3 -3
  29. package/src/shared/scripts/skf-emit-result-envelope.py +93 -9
  30. package/src/shared/scripts/skf-enumerate-stack-skills.py +514 -0
  31. package/src/shared/scripts/skf-forge-tier-rw.py +11 -11
  32. package/src/shared/scripts/skf-hash-content.py +284 -0
  33. package/src/shared/scripts/skf-load-provenance.py +295 -0
  34. package/src/shared/scripts/skf-merge-ccc-exclusions.py +34 -4
  35. package/src/shared/scripts/skf-pair-intersect.py +250 -0
  36. package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
  37. package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
  38. package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
  39. package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
  40. package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
  41. package/src/shared/scripts/skf-scan-manifests.py +738 -0
  42. package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
  43. package/src/shared/scripts/skf-update-active-symlink.py +315 -0
  44. package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
  45. package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
  46. package/src/shared/scripts/skf-write-skill-brief.py +61 -5
  47. package/src/skf-analyze-source/SKILL.md +69 -16
  48. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  49. package/src/skf-analyze-source/customize.toml +55 -0
  50. package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
  51. package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
  52. package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  53. package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +44 -14
  54. package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +27 -6
  55. package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
  56. package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
  57. package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +12 -10
  58. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
  59. package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
  60. package/src/skf-audit-skill/SKILL.md +66 -15
  61. package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
  62. package/src/skf-audit-skill/customize.toml +49 -0
  63. package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  64. package/src/skf-audit-skill/{steps-c/step-01-init.md → references/init.md} +57 -19
  65. package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
  66. package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
  67. package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
  68. package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
  69. package/src/skf-audit-skill/references/severity-rules.md +2 -0
  70. package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +29 -17
  71. package/src/skf-brief-skill/SKILL.md +48 -18
  72. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  73. package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
  74. package/src/skf-brief-skill/customize.toml +44 -0
  75. package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +5 -5
  76. package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
  77. package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
  78. package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +24 -24
  79. package/src/skf-brief-skill/references/headless-args.md +9 -9
  80. package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
  81. package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
  82. package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
  83. package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
  84. package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +19 -16
  85. package/src/skf-brief-skill/references/version-resolution.md +2 -2
  86. package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +11 -10
  87. package/src/skf-create-skill/SKILL.md +25 -19
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
  89. package/src/skf-create-skill/assets/skill-sections.md +5 -5
  90. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
  91. package/src/skf-create-skill/customize.toml +35 -0
  92. package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
  93. package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
  94. package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
  95. package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
  96. package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
  97. package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
  98. package/src/skf-create-skill/references/extraction-patterns.md +8 -8
  99. package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +11 -11
  100. package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
  101. package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
  102. package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
  103. package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
  104. package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
  105. package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
  106. package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
  107. package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +46 -26
  108. package/src/skf-create-stack-skill/SKILL.md +76 -17
  109. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
  110. package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
  111. package/src/skf-create-stack-skill/customize.toml +46 -0
  112. package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
  113. package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
  114. package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +32 -11
  115. package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +28 -28
  116. package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
  117. package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
  118. package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
  119. package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
  120. package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
  121. package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +40 -17
  122. package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
  123. package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
  124. package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
  125. package/src/skf-drop-skill/SKILL.md +84 -12
  126. package/src/skf-drop-skill/customize.toml +57 -0
  127. package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
  128. package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
  129. package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  130. package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
  131. package/src/skf-export-skill/SKILL.md +85 -16
  132. package/src/skf-export-skill/assets/managed-section-format.md +3 -3
  133. package/src/skf-export-skill/assets/snippet-format.md +2 -2
  134. package/src/skf-export-skill/customize.toml +49 -0
  135. package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +11 -11
  136. package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  137. package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +11 -49
  138. package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
  139. package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
  140. package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
  141. package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
  142. package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
  143. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +74 -0
  144. package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +20 -8
  145. package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
  146. package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +109 -113
  147. package/src/skf-quick-skill/SKILL.md +63 -123
  148. package/src/skf-quick-skill/customize.toml +44 -0
  149. package/src/skf-quick-skill/references/batch-mode.md +102 -0
  150. package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
  151. package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
  152. package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
  153. package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
  154. package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
  155. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  156. package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
  157. package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
  158. package/src/skf-refine-architecture/SKILL.md +86 -16
  159. package/src/skf-refine-architecture/customize.toml +49 -0
  160. package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
  161. package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
  162. package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  163. package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
  164. package/src/skf-refine-architecture/references/init.md +144 -0
  165. package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
  166. package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
  167. package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
  168. package/src/skf-rename-skill/SKILL.md +82 -12
  169. package/src/skf-rename-skill/customize.toml +52 -0
  170. package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
  171. package/src/skf-rename-skill/references/health-check.md +30 -0
  172. package/src/skf-rename-skill/references/rebuild-context.md +110 -0
  173. package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  174. package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
  175. package/src/skf-setup/SKILL.md +35 -35
  176. package/src/skf-setup/customize.toml +33 -0
  177. package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
  178. package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +16 -31
  179. package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
  180. package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
  181. package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
  182. package/src/skf-setup/references/tier-rules.md +1 -1
  183. package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
  184. package/src/skf-test-skill/SKILL.md +77 -15
  185. package/src/skf-test-skill/customize.toml +54 -0
  186. package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +28 -71
  187. package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
  188. package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
  189. package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
  190. package/src/skf-test-skill/references/health-check.md +14 -0
  191. package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
  192. package/src/skf-test-skill/references/migration-section-rules.md +4 -2
  193. package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
  194. package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
  195. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  196. package/src/skf-test-skill/references/source-access-protocol.md +7 -5
  197. package/src/skf-test-skill/scripts/compute-score.py +88 -17
  198. package/src/skf-test-skill/templates/test-report-template.md +13 -13
  199. package/src/skf-update-skill/SKILL.md +23 -14
  200. package/src/skf-update-skill/customize.toml +44 -0
  201. package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
  202. package/src/skf-update-skill/references/health-check.md +32 -0
  203. package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
  204. package/src/skf-update-skill/references/manual-section-rules.md +4 -0
  205. package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
  206. package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
  207. package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
  208. package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
  209. package/src/skf-update-skill/references/report.md +214 -0
  210. package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
  211. package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
  212. package/src/skf-verify-stack/SKILL.md +89 -17
  213. package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
  214. package/src/skf-verify-stack/customize.toml +50 -0
  215. package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
  216. package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +7 -7
  217. package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
  218. package/src/skf-verify-stack/references/init.md +166 -0
  219. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  220. package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +12 -12
  221. package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +22 -41
  222. package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +7 -7
  223. package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +8 -8
  224. package/tools/validate-docs-drift.js +1 -1
  225. package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
  226. package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
  227. package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
  228. package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
  229. package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
  230. package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
@@ -0,0 +1,321 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Check Workspace Drift — pre-flight guard for gap-driven update-skill runs.
6
+
7
+ `skf-update-skill/references/re-extract.md §0.a` defines a four-state guard:
8
+ the workspace at `source_root` must point at the commit the skill was pinned
9
+ against (`metadata.source_commit`), otherwise gap-driven spot-checks read
10
+ bytes that differ from the pinned tree and silently produce wrong results
11
+ (symbols appear "verified" because the recorded line now points at different
12
+ code).
13
+
14
+ The guard's logic is deterministic — three git commands and a comparison —
15
+ but the prose form asked the LLM to chain them per run, with subtle short-
16
+ SHA prefix matching and skip-paths for non-git workspaces. This script bakes
17
+ the dispatch in.
18
+
19
+ CLI:
20
+ uv run skf-check-workspace-drift.py <source-root> \\
21
+ --pinned-commit <SHA or empty> \\
22
+ [--source-ref <ref>] \\
23
+ [--allow-drift]
24
+
25
+ Inputs:
26
+ source_root Filesystem path to the workspace under test.
27
+ --pinned-commit The pinned commit SHA (full or short). Pass an empty
28
+ string OR the literal "local" to declare that this
29
+ skill has no pinned commit — the guard skips with
30
+ skip_reason="no-pinned-commit".
31
+ --source-ref Optional. The ref name (tag, branch) the workspace
32
+ was pinned to. Included in the halt message for
33
+ user-facing context only.
34
+ --allow-drift Suppress the drift halt. Mismatches still return
35
+ status="overridden" so the caller can surface a
36
+ warning in the final report.
37
+
38
+ Output (JSON on stdout):
39
+ {
40
+ "status": "ok" | "skipped" | "mismatch" | "overridden",
41
+ "skip_reason": "no-pinned-commit" | "not-a-git-tree" | null,
42
+ "pinned_commit": "<as-given>",
43
+ "head_sha": "<full SHA>" | null,
44
+ "head_short_sha": "<7-12 chars>" | null,
45
+ "match_kind": "full" | "short-prefix" | null,
46
+ "log_message": "workspace_drift_check: ok (abc1234)",
47
+ "halt_message": "<multi-line user-facing message>" | null
48
+ }
49
+
50
+ Exit codes:
51
+ 0 — caller may continue (status is ok, skipped, or overridden)
52
+ 1 — script error (bad args, source_root missing, git unavailable)
53
+ 2 — drift detected and --allow-drift was NOT passed; caller MUST halt
54
+ with status="halted-for-workspace-drift". The halt_message field
55
+ is pre-formatted with all substitutions applied.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import argparse
61
+ import json
62
+ import shutil
63
+ import subprocess
64
+ import sys
65
+ from pathlib import Path
66
+
67
+
68
+ SKIP_NO_PINNED = "no-pinned-commit"
69
+ SKIP_NOT_GIT = "not-a-git-tree"
70
+
71
+
72
+ # --------------------------------------------------------------------------
73
+ # Git probes
74
+ # --------------------------------------------------------------------------
75
+
76
+
77
+ def _git(args: list[str], *, cwd: Path) -> tuple[int, str, str]:
78
+ """Run a git command; return (rc, stdout, stderr). Stdout/stderr stripped."""
79
+ proc = subprocess.run(
80
+ ["git", "-C", str(cwd), *args],
81
+ capture_output=True,
82
+ text=True,
83
+ check=False,
84
+ )
85
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
86
+
87
+
88
+ def is_git_working_tree(source_root: Path) -> bool:
89
+ """True if `git rev-parse --is-inside-work-tree` says yes."""
90
+ rc, out, _ = _git(["rev-parse", "--is-inside-work-tree"], cwd=source_root)
91
+ return rc == 0 and out == "true"
92
+
93
+
94
+ def head_sha(source_root: Path) -> str | None:
95
+ """Return the workspace HEAD SHA, or None if it can't be read."""
96
+ rc, out, _ = _git(["rev-parse", "HEAD"], cwd=source_root)
97
+ if rc != 0 or not out:
98
+ return None
99
+ return out
100
+
101
+
102
+ # --------------------------------------------------------------------------
103
+ # Match logic
104
+ # --------------------------------------------------------------------------
105
+
106
+
107
+ def is_skippable_pinned(pinned_commit: str) -> bool:
108
+ """Recognize the scalar values that mean 'no pinned commit'."""
109
+ if pinned_commit is None:
110
+ return True
111
+ stripped = pinned_commit.strip()
112
+ return stripped == "" or stripped.lower() == "local"
113
+
114
+
115
+ def classify_match(pinned: str, head: str) -> str | None:
116
+ """Return 'full' if pinned == head, 'short-prefix' if pinned is a prefix
117
+ of head (≥ 7 chars to avoid coincidental collisions on short SHAs).
118
+ Returns None on no match."""
119
+ if pinned == head:
120
+ return "full"
121
+ if len(pinned) >= 7 and head.startswith(pinned):
122
+ return "short-prefix"
123
+ return None
124
+
125
+
126
+ def short(sha: str) -> str:
127
+ return sha[:7]
128
+
129
+
130
+ # --------------------------------------------------------------------------
131
+ # Halt message
132
+ # --------------------------------------------------------------------------
133
+
134
+
135
+ HALT_MESSAGE_TEMPLATE = """Workspace HEAD does not match the commit this skill was pinned against.
136
+
137
+ pinned (metadata.source_commit): {pinned_commit}
138
+ pinned ref (metadata.source_ref): {source_ref}
139
+ workspace HEAD ({source_root}): {head_sha}
140
+
141
+ Gap-driven spot-checks read source at pinned line numbers — verifying
142
+ against a drifted tree silently produces wrong results (symbols appear at
143
+ unintended locations). Re-sync the workspace before re-running:
144
+
145
+ git -C "{source_root}" checkout {checkout_target}
146
+
147
+ Or, to intentionally proceed against the current workspace HEAD (accepting
148
+ that spot-checks will read bytes that differ from the pinned commit),
149
+ re-run update-skill with `--allow-workspace-drift`."""
150
+
151
+
152
+ def build_halt_message(
153
+ *, source_root: Path, pinned_commit: str, head_sha_full: str, source_ref: str | None
154
+ ) -> str:
155
+ ref_display = source_ref if source_ref else "unset"
156
+ checkout_target = source_ref if source_ref else pinned_commit
157
+ return HALT_MESSAGE_TEMPLATE.format(
158
+ pinned_commit=pinned_commit,
159
+ source_ref=ref_display,
160
+ source_root=str(source_root),
161
+ head_sha=head_sha_full,
162
+ checkout_target=checkout_target,
163
+ )
164
+
165
+
166
+ # --------------------------------------------------------------------------
167
+ # Core check
168
+ # --------------------------------------------------------------------------
169
+
170
+
171
+ def check(
172
+ source_root: Path,
173
+ *,
174
+ pinned_commit: str,
175
+ source_ref: str | None,
176
+ allow_drift: bool,
177
+ ) -> dict:
178
+ """Run the four-state guard and return a result envelope."""
179
+ if is_skippable_pinned(pinned_commit):
180
+ return {
181
+ "status": "skipped",
182
+ "skip_reason": SKIP_NO_PINNED,
183
+ "pinned_commit": pinned_commit,
184
+ "head_sha": None,
185
+ "head_short_sha": None,
186
+ "match_kind": None,
187
+ "log_message": "workspace_drift_check: skipped (no pinned commit)",
188
+ "halt_message": None,
189
+ }
190
+
191
+ if not is_git_working_tree(source_root):
192
+ return {
193
+ "status": "skipped",
194
+ "skip_reason": SKIP_NOT_GIT,
195
+ "pinned_commit": pinned_commit,
196
+ "head_sha": None,
197
+ "head_short_sha": None,
198
+ "match_kind": None,
199
+ "log_message": "workspace_drift_check: skipped (not a git working tree)",
200
+ "halt_message": None,
201
+ }
202
+
203
+ head = head_sha(source_root)
204
+ if head is None:
205
+ # git tree exists but HEAD can't be resolved (orphan / empty repo).
206
+ # Treat as not-a-git-tree for guard purposes — we can't verify pinning.
207
+ return {
208
+ "status": "skipped",
209
+ "skip_reason": SKIP_NOT_GIT,
210
+ "pinned_commit": pinned_commit,
211
+ "head_sha": None,
212
+ "head_short_sha": None,
213
+ "match_kind": None,
214
+ "log_message": "workspace_drift_check: skipped (HEAD unreadable)",
215
+ "halt_message": None,
216
+ }
217
+
218
+ match_kind = classify_match(pinned_commit, head)
219
+ if match_kind is not None:
220
+ return {
221
+ "status": "ok",
222
+ "skip_reason": None,
223
+ "pinned_commit": pinned_commit,
224
+ "head_sha": head,
225
+ "head_short_sha": short(head),
226
+ "match_kind": match_kind,
227
+ "log_message": f"workspace_drift_check: ok ({short(head)})",
228
+ "halt_message": None,
229
+ }
230
+
231
+ # Mismatch path
232
+ halt_message = build_halt_message(
233
+ source_root=source_root,
234
+ pinned_commit=pinned_commit,
235
+ head_sha_full=head,
236
+ source_ref=source_ref,
237
+ )
238
+ if allow_drift:
239
+ return {
240
+ "status": "overridden",
241
+ "skip_reason": None,
242
+ "pinned_commit": pinned_commit,
243
+ "head_sha": head,
244
+ "head_short_sha": short(head),
245
+ "match_kind": None,
246
+ "log_message": (
247
+ f"workspace_drift_check: overridden "
248
+ f"(pinned={pinned_commit}, head={head})"
249
+ ),
250
+ "halt_message": halt_message, # surfaced as warning by caller
251
+ }
252
+
253
+ return {
254
+ "status": "mismatch",
255
+ "skip_reason": None,
256
+ "pinned_commit": pinned_commit,
257
+ "head_sha": head,
258
+ "head_short_sha": short(head),
259
+ "match_kind": None,
260
+ "log_message": (
261
+ f"workspace_drift_check: mismatch "
262
+ f"(pinned={pinned_commit}, head={head})"
263
+ ),
264
+ "halt_message": halt_message,
265
+ }
266
+
267
+
268
+ # --------------------------------------------------------------------------
269
+ # CLI
270
+ # --------------------------------------------------------------------------
271
+
272
+
273
+ def main(argv: list[str] | None = None) -> int:
274
+ parser = argparse.ArgumentParser(
275
+ prog="skf-check-workspace-drift",
276
+ description=(
277
+ "Verify that the source-root workspace HEAD matches the pinned "
278
+ "commit recorded in metadata.source_commit."
279
+ ),
280
+ )
281
+ parser.add_argument("source_root", help="path to the workspace under test")
282
+ parser.add_argument(
283
+ "--pinned-commit",
284
+ required=True,
285
+ help='pinned commit SHA; pass "" or "local" to skip the guard',
286
+ )
287
+ parser.add_argument(
288
+ "--source-ref",
289
+ default=None,
290
+ help="optional pinned ref (tag/branch) for halt-message display",
291
+ )
292
+ parser.add_argument(
293
+ "--allow-drift",
294
+ action="store_true",
295
+ help="suppress the drift halt; mismatch becomes status=overridden",
296
+ )
297
+ args = parser.parse_args(argv)
298
+
299
+ source_root = Path(args.source_root)
300
+ if not source_root.is_dir():
301
+ print(f"error: source-root not a directory: {source_root}", file=sys.stderr)
302
+ return 1
303
+ if shutil.which("git") is None:
304
+ print("error: git binary not on PATH", file=sys.stderr)
305
+ return 1
306
+
307
+ result = check(
308
+ source_root,
309
+ pinned_commit=args.pinned_commit,
310
+ source_ref=args.source_ref,
311
+ allow_drift=args.allow_drift,
312
+ )
313
+ json.dump(result, sys.stdout, indent=2)
314
+ sys.stdout.write("\n")
315
+ if result["status"] == "mismatch":
316
+ return 2
317
+ return 0
318
+
319
+
320
+ if __name__ == "__main__":
321
+ raise SystemExit(main())
@@ -0,0 +1,357 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Compare File Hashes — script/asset/doc drift detection for audit-skill.
6
+
7
+ This script replaces the per-file hash-compute-and-classify prose at
8
+ `src/skf-audit-skill/references/structural-diff.md` §4b (Script/Asset Drift).
9
+ It compares the `file_entries[]` block of a provenance map against the
10
+ current state of the source tree and emits three drift categories:
11
+
12
+ - **added**: files present in the source tree but NOT recorded in
13
+ `file_entries[]`. Walk is restricted to standard script/
14
+ asset/doc directories (scripts/, bin/, tools/, cli/,
15
+ assets/, templates/, schemas/, configs/, examples/) and
16
+ excludes binary extensions and generated paths — same
17
+ path discipline as `skf-detect-scripts-assets.py`.
18
+ - **removed**: files in `file_entries[]` whose `source_file` is gone
19
+ from disk under `<source-root>`.
20
+ - **changed**: files in `file_entries[]` present on disk but with a
21
+ different content hash than the stored hash.
22
+
23
+ Distinct from `skf-hash-content.py compare`: that script classifies entries
24
+ that ARE in the provenance map (UNCHANGED / MODIFIED_FILE / DELETED_FILE).
25
+ audit-skill additionally needs the **inverse walk** — what new
26
+ script/asset/doc files have appeared in the source tree since the skill
27
+ was created? That's `added[]`. We delegate the path-resolution and
28
+ hash-comparison logic for entries IN the map by duplicating the small
29
+ sha256 helper here (15 lines) rather than importing across files — keeps
30
+ the runtime import surface flat and matches the existing canonical pattern
31
+ in `skf-detect-scripts-assets.py:216`.
32
+
33
+ Hash-prefix normalization (writer-vs-reader compatibility): stored hashes
34
+ in `file_entries[].content_hash` carry a `sha256:` prefix by SKF convention
35
+ (see `skf-create-skill/references/extraction-patterns-tracing.md` §Provenance).
36
+ The reader-side normalization here is unconditionally safe — strip any
37
+ lowercase-alphanumeric prefix terminated by `:` before comparing, and
38
+ re-emit the stored value as-given in the diff record so reviewers can see
39
+ the original form. A bare-hex hash from a future writer would pass through
40
+ unchanged.
41
+
42
+ Subcommand:
43
+ compare <provenance-map.json> <source-root>
44
+ Emit JSON:
45
+ {
46
+ "added": ["<rel-path, forward-slash>", ...],
47
+ "removed": ["<rel-path, forward-slash>", ...],
48
+ "changed": [
49
+ {"path": "<rel-path>", "stored_hash": "sha256:...",
50
+ "current_hash": "sha256:..."}, ...
51
+ ],
52
+ "stats": {"added": N, "removed": N, "changed": N, "unchanged": N}
53
+ }
54
+
55
+ All paths are forward-slash relative to `<source-root>`. Stable sort
56
+ on the lists for deterministic output (lexicographic on path).
57
+
58
+ CLI examples:
59
+ uv run skf-compare-file-hashes.py compare prov-map.json /path/to/src
60
+
61
+ Exit codes:
62
+ 0 — comparison succeeded (including empty/well-formed input that yields
63
+ all-empty drift lists)
64
+ 1 — user error (missing files, malformed JSON, unreadable paths)
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import argparse
70
+ import hashlib
71
+ import json
72
+ import re
73
+ import sys
74
+ from pathlib import Path
75
+ from typing import Iterable
76
+
77
+
78
+ # --------------------------------------------------------------------------
79
+ # Constants — mirror skf-detect-scripts-assets.py so the inverse walk
80
+ # explores the same set of trees the writer side would have considered.
81
+ # --------------------------------------------------------------------------
82
+
83
+
84
+ SCRIPT_DIRS = {"scripts", "bin", "tools", "cli"}
85
+ ASSET_DIRS = {"assets", "templates", "schemas", "configs", "examples"}
86
+ DOC_DIR_PREFIXES = ("docs/authoritative/",) # synthetic namespace from create-skill §6
87
+
88
+ # Path-segment names that mark generated/vendored output trees — pruned
89
+ # from the walk so the inverse never reports build-tree artifacts as added.
90
+ EXCLUDED_DIR_NAMES = {
91
+ "node_modules", "__pycache__", "dist", "build", ".webpack",
92
+ "target", ".next", ".nuxt", "out", "coverage", ".git",
93
+ ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
94
+ ".ruff_cache", ".gradle", ".idea", ".vscode",
95
+ }
96
+
97
+ BINARY_EXTS = {
98
+ ".so", ".dll", ".jar", ".wasm", ".exe", ".dylib", ".a", ".o",
99
+ ".pyc", ".class", ".png", ".jpg", ".jpeg", ".gif", ".ico",
100
+ ".pdf", ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z",
101
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
102
+ }
103
+
104
+
105
+ _HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Hash + normalization primitives
110
+ # --------------------------------------------------------------------------
111
+
112
+
113
+ def sha256_of_file(path: Path) -> str:
114
+ """SHA-256 of file content, with sha256: prefix. Matches the convention
115
+ used by skf-hash-content.py / skf-detect-scripts-assets.py."""
116
+ h = hashlib.sha256()
117
+ with path.open("rb") as fh:
118
+ for chunk in iter(lambda: fh.read(65536), b""):
119
+ h.update(chunk)
120
+ return "sha256:" + h.hexdigest()
121
+
122
+
123
+ def normalize_hash(value: str | None) -> str | None:
124
+ """Strip a leading algorithm-name prefix (`sha256:`, `sha1:`, etc.)
125
+ from a stored hash so bare-hex and prefixed forms compare equal.
126
+
127
+ Returns None if input is None or non-string. Idempotent on bare hex.
128
+ """
129
+ if not isinstance(value, str):
130
+ return None
131
+ return _HASH_PREFIX_RE.sub("", value, count=1)
132
+
133
+
134
+ # --------------------------------------------------------------------------
135
+ # Provenance load — same shape-tolerance as skf-hash-content.load_file_entries
136
+ # --------------------------------------------------------------------------
137
+
138
+
139
+ def load_file_entries(provenance_path: Path) -> list[dict]:
140
+ """Extract `file_entries[]` from a provenance map. Accepts:
141
+ - top-level object with a `file_entries` key (canonical)
142
+ - top-level array (already extracted)
143
+ - top-level object with NO `file_entries` field → empty list
144
+ (a single-skill with no scripts/assets/docs may omit the field
145
+ entirely per skill-sections.md §file_entries)
146
+
147
+ Raises ValueError on read failure or structural defects.
148
+ """
149
+ try:
150
+ text = provenance_path.read_text(encoding="utf-8")
151
+ except OSError as exc:
152
+ raise ValueError(
153
+ f"failed to read provenance file {provenance_path}: {exc}"
154
+ ) from exc
155
+ try:
156
+ data = json.loads(text)
157
+ except json.JSONDecodeError as exc:
158
+ raise ValueError(
159
+ f"malformed JSON in provenance file {provenance_path}: {exc}"
160
+ ) from exc
161
+
162
+ if isinstance(data, list):
163
+ return list(data)
164
+ if isinstance(data, dict):
165
+ entries = data.get("file_entries")
166
+ if entries is None:
167
+ return [] # provenance with no tracked file_entries is valid
168
+ if not isinstance(entries, list):
169
+ raise ValueError(
170
+ f"`file_entries` in {provenance_path} is not an array"
171
+ )
172
+ return list(entries)
173
+ raise ValueError(
174
+ f"provenance file {provenance_path} must be an object or array; "
175
+ f"got {type(data).__name__}"
176
+ )
177
+
178
+
179
+ # --------------------------------------------------------------------------
180
+ # Walk — the inverse direction (source tree → candidate set)
181
+ # --------------------------------------------------------------------------
182
+
183
+
184
+ def _segment_in_excluded(rel_parts: tuple[str, ...]) -> bool:
185
+ return any(seg in EXCLUDED_DIR_NAMES for seg in rel_parts)
186
+
187
+
188
+ def _segment_in_tracked_dir(rel_parts: tuple[str, ...]) -> bool:
189
+ """True if any segment of the relative path is a tracked script/asset
190
+ directory."""
191
+ for seg in rel_parts:
192
+ if seg in SCRIPT_DIRS or seg in ASSET_DIRS:
193
+ return True
194
+ return False
195
+
196
+
197
+ def _matches_doc_prefix(rel_posix: str) -> bool:
198
+ return any(rel_posix.startswith(p) for p in DOC_DIR_PREFIXES)
199
+
200
+
201
+ def candidate_source_files(source_root: Path) -> Iterable[str]:
202
+ """Yield POSIX relative paths under source_root that could be tracked
203
+ as script / asset / doc file_entries.
204
+
205
+ Selection rules:
206
+ - File is under a SCRIPT_DIRS or ASSET_DIRS directory at any depth, OR
207
+ - File path matches a DOC_DIR_PREFIXES synthetic namespace.
208
+ - Binary extensions are excluded.
209
+ - Generated/vendored directories are pruned from the walk.
210
+
211
+ This is deliberately narrower than "every file" — audit-skill §4b only
212
+ tracks files matching script/asset patterns. Treating every random
213
+ source file as a candidate `added` row would drown real drift in noise.
214
+ """
215
+ stack: list[Path] = [source_root]
216
+ while stack:
217
+ current = stack.pop()
218
+ try:
219
+ entries = list(current.iterdir())
220
+ except (PermissionError, FileNotFoundError):
221
+ continue
222
+ for entry in entries:
223
+ if entry.is_symlink():
224
+ continue
225
+ if entry.is_dir():
226
+ if entry.name in EXCLUDED_DIR_NAMES:
227
+ continue
228
+ stack.append(entry)
229
+ continue
230
+ if not entry.is_file():
231
+ continue
232
+ if entry.suffix.lower() in BINARY_EXTS:
233
+ continue
234
+ try:
235
+ rel = entry.relative_to(source_root)
236
+ except ValueError:
237
+ continue
238
+ rel_parts = rel.parts[:-1]
239
+ rel_posix = rel.as_posix()
240
+ if _segment_in_tracked_dir(rel_parts) or _matches_doc_prefix(rel_posix):
241
+ yield rel_posix
242
+
243
+
244
+ # --------------------------------------------------------------------------
245
+ # Comparison
246
+ # --------------------------------------------------------------------------
247
+
248
+
249
+ def compare(source_root: Path, provenance_path: Path) -> dict:
250
+ """Compute added / removed / changed / unchanged for tracked file_entries.
251
+
252
+ Stored hashes are normalized before comparison; the diff record carries
253
+ the original stored value (writer-form) plus the freshly computed
254
+ current value (always prefixed sha256:) so reviewers see both.
255
+ """
256
+ entries = load_file_entries(provenance_path)
257
+ # Build the {posix_path -> stored_hash} index from the provenance.
258
+ stored: dict[str, str | None] = {}
259
+ for entry in entries:
260
+ if not isinstance(entry, dict):
261
+ continue
262
+ sf = entry.get("source_file")
263
+ if not isinstance(sf, str) or not sf:
264
+ continue
265
+ stored[sf.replace("\\", "/")] = entry.get("content_hash") if isinstance(
266
+ entry.get("content_hash"), str
267
+ ) else None
268
+
269
+ # Walk the source tree once; track which provenance entries are matched.
270
+ candidates_on_disk = set(candidate_source_files(source_root))
271
+
272
+ removed: list[str] = []
273
+ changed: list[dict] = []
274
+ unchanged_count = 0
275
+
276
+ for path, stored_hash in sorted(stored.items()):
277
+ full = source_root / path
278
+ if not full.is_file():
279
+ removed.append(path)
280
+ continue
281
+ current = sha256_of_file(full)
282
+ if normalize_hash(stored_hash) == normalize_hash(current):
283
+ unchanged_count += 1
284
+ else:
285
+ changed.append({
286
+ "path": path,
287
+ "stored_hash": stored_hash,
288
+ "current_hash": current,
289
+ })
290
+
291
+ # Anything on disk but NOT in the provenance store is "added".
292
+ added = sorted(candidates_on_disk - set(stored.keys()))
293
+
294
+ return {
295
+ "added": added,
296
+ "removed": sorted(removed),
297
+ "changed": sorted(changed, key=lambda r: r["path"]),
298
+ "stats": {
299
+ "added": len(added),
300
+ "removed": len(removed),
301
+ "changed": len(changed),
302
+ "unchanged": unchanged_count,
303
+ },
304
+ }
305
+
306
+
307
+ # --------------------------------------------------------------------------
308
+ # CLI
309
+ # --------------------------------------------------------------------------
310
+
311
+
312
+ def _cmd_compare(args: argparse.Namespace) -> int:
313
+ provenance = Path(args.provenance_map)
314
+ source_root = Path(args.source_root)
315
+ if not provenance.is_file():
316
+ print(f"error: provenance map not found: {provenance}", file=sys.stderr)
317
+ return 1
318
+ if not source_root.is_dir():
319
+ print(f"error: source root not a directory: {source_root}", file=sys.stderr)
320
+ return 1
321
+ try:
322
+ result = compare(source_root, provenance)
323
+ except ValueError as exc:
324
+ print(f"error: {exc}", file=sys.stderr)
325
+ return 1
326
+ json.dump(result, sys.stdout, indent=2)
327
+ sys.stdout.write("\n")
328
+ return 0
329
+
330
+
331
+ def _build_parser() -> argparse.ArgumentParser:
332
+ parser = argparse.ArgumentParser(
333
+ prog="skf-compare-file-hashes",
334
+ description=(
335
+ "Compare provenance file_entries[] against the current source "
336
+ "tree to detect script/asset/doc drift (added/removed/changed)."
337
+ ),
338
+ )
339
+ sub = parser.add_subparsers(dest="cmd", required=True)
340
+ p_cmp = sub.add_parser(
341
+ "compare",
342
+ help="classify tracked file_entries[] + walk inverse to detect added files",
343
+ )
344
+ p_cmp.add_argument("provenance_map", help="path to provenance-map.json")
345
+ p_cmp.add_argument("source_root", help="path to the source tree root")
346
+ p_cmp.set_defaults(func=_cmd_compare)
347
+ return parser
348
+
349
+
350
+ def main(argv: list[str] | None = None) -> int:
351
+ parser = _build_parser()
352
+ args = parser.parse_args(argv)
353
+ return args.func(args)
354
+
355
+
356
+ if __name__ == "__main__":
357
+ raise SystemExit(main())