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
@@ -16,7 +16,7 @@ Detection chain (all four methods attempted, results aggregated):
16
16
  4. docs_folder — markdown files in the repo's docs/ directory
17
17
 
18
18
  CLI:
19
- uv run python src/shared/scripts/skf-detect-docs.py \\
19
+ uv run src/shared/scripts/skf-detect-docs.py \\
20
20
  --repo-url <url> [--local-path <path>] [--skip-pages-api]
21
21
 
22
22
  Input:
@@ -38,6 +38,42 @@ Exit codes:
38
38
  0 found >=1 documentation source
39
39
  1 no documentation sources found (empty array)
40
40
  2 error (invalid args, gh not found, etc.)
41
+
42
+ ---------------------------------------------------------------------------
43
+ Subcommand: compare-hashes
44
+ ---------------------------------------------------------------------------
45
+
46
+ Doc-drift detection for audit-skill. Given a set of tracked doc sources
47
+ (each `{url, content_hash}`), re-fetch every URL, hash the response bytes with
48
+ the SAME fetch+sha256 primitive the compile side used, and classify each entry
49
+ as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
50
+ per-URL fetch/hash/compare/count prose at
51
+ `src/skf-audit-skill/references/step-doc-drift.md` §2-3 — the model no longer
52
+ orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
53
+ depends on whether a WebFetch tool is wired.
54
+
55
+ uv run src/shared/scripts/skf-detect-docs.py compare-hashes <source>
56
+
57
+ Input `<source>`:
58
+ - a path to a JSON file that is EITHER an array of `{url, content_hash}`
59
+ entries OR an object with a `doc_sources` array (e.g. a skill's
60
+ metadata.json), OR
61
+ - `-` to read that JSON from stdin.
62
+
63
+ Output (JSON object on stdout):
64
+ {
65
+ "changed": [{"url","old_hash","new_hash"}],
66
+ "unchanged": [{"url"}],
67
+ "fetch_failed": [{"url","old_hash","reason"}],
68
+ "skipped_null_hash": [{"url"}],
69
+ "stats": {"total_tracked","changed","unchanged",
70
+ "fetch_failed","skipped_null_hash"}
71
+ }
72
+
73
+ Each list is stably sorted by url. Stored hashes are normalized (leading
74
+ `algo:` prefix stripped) before comparison so a bare-hex writer form still
75
+ matches. Exit 0 on any well-formed input (never blocks the informational
76
+ audit); exit 2 on malformed args or JSON.
41
77
  """
42
78
 
43
79
  from __future__ import annotations
@@ -47,12 +83,13 @@ import base64
47
83
  import hashlib
48
84
  import json
49
85
  import re
86
+ import socket
50
87
  import subprocess
51
88
  import sys
52
89
  import urllib.error
53
90
  import urllib.request
54
91
  from pathlib import Path
55
- from typing import Any, Dict, List, Optional
92
+ from typing import Any, Dict, List, Optional, Tuple
56
93
 
57
94
  _GITHUB_URL_RE = re.compile(
58
95
  r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
@@ -86,13 +123,18 @@ _MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
86
123
  _HTML_LINK_RE = re.compile(r'<a\s[^>]*href=["\']([^"\']+)["\']', re.IGNORECASE)
87
124
  _BARE_URL_RE = re.compile(r"^(https?://\S+)$", re.MULTILINE)
88
125
 
126
+ # `docs?\.` (not `docs\.`) so a `doc.` subdomain matches too — language doc
127
+ # sites use the singular form (doc.rust-lang.org, doc.qt.io).
89
128
  _DOC_DOMAIN_RE = re.compile(
90
- r"(?:docs\.|\.readthedocs\.|wiki\.|documentation\.)",
129
+ r"(?:docs?\.|\.readthedocs\.|wiki\.|documentation\.)",
91
130
  re.IGNORECASE,
92
131
  )
93
132
 
133
+ # Language reference/guide path segments (a Book, a std/library API, a tutorial)
134
+ # are doc URLs even on a bare domain (doc.rust-lang.org/book/, .../std/).
94
135
  _DOC_PATH_RE = re.compile(
95
- r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/)",
136
+ r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/"
137
+ r"|/book/|/std/|/library/|/tutorial/)",
96
138
  re.IGNORECASE,
97
139
  )
98
140
 
@@ -219,6 +261,57 @@ def _fetch_and_hash(url: str) -> Optional[str]:
219
261
  return None
220
262
 
221
263
 
264
+ # Leading algorithm-name prefix on a stored hash (`sha256:`, `sha1:`, ...) so a
265
+ # bare-hex writer form and the prefixed form compare equal.
266
+ _HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
267
+
268
+
269
+ def _normalize_hash(value: Optional[str]) -> Optional[str]:
270
+ """Strip a leading `algo:` prefix from a hash so bare-hex and prefixed
271
+ forms compare equal. Returns None for non-string input. Idempotent."""
272
+ if not isinstance(value, str):
273
+ return None
274
+ return _HASH_PREFIX_RE.sub("", value, count=1)
275
+
276
+
277
+ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
278
+ """Byte-symmetric sibling of `_fetch_and_hash` that surfaces WHY a fetch
279
+ failed for drift reporting.
280
+
281
+ Uses the identical fetch+sha256 primitive as the compile side — same
282
+ `USER_AGENT`, same `_FETCH_TIMEOUT`, sha256 of the raw response bytes with
283
+ the `sha256:` prefix — so a hash produced here compares byte-for-byte
284
+ against a `content_hash` written by `_fetch_and_hash` at compile time.
285
+
286
+ Returns `(content_hash, None)` on success or `(None, reason)` on failure.
287
+ Never raises: the doc-drift audit must never abort on a bad URL.
288
+ """
289
+ if url.startswith("file://"):
290
+ local_path = url[7:]
291
+ try:
292
+ with open(local_path, "rb") as fh:
293
+ content = fh.read()
294
+ except OSError as exc:
295
+ return None, f"local read failed: {exc}"
296
+ return "sha256:" + hashlib.sha256(content).hexdigest(), None
297
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
298
+ try:
299
+ with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
300
+ content = resp.read()
301
+ except urllib.error.HTTPError as exc:
302
+ return None, f"HTTP {exc.code}"
303
+ except (socket.timeout, TimeoutError):
304
+ return None, f"timeout after {_FETCH_TIMEOUT}s"
305
+ except urllib.error.URLError as exc:
306
+ reason = getattr(exc, "reason", exc)
307
+ if isinstance(reason, (socket.timeout, TimeoutError)):
308
+ return None, f"timeout after {_FETCH_TIMEOUT}s"
309
+ return None, f"URL error: {reason}"
310
+ except Exception as exc: # never let a malformed URL crash the audit
311
+ return None, f"fetch failed: {exc}"
312
+ return "sha256:" + hashlib.sha256(content).hexdigest(), None
313
+
314
+
222
315
  # ---------------------------------------------------------------------------
223
316
  # Detection method 1 — homepageUrl
224
317
  # ---------------------------------------------------------------------------
@@ -371,11 +464,154 @@ def detect(
371
464
  return deduped
372
465
 
373
466
 
467
+ # ---------------------------------------------------------------------------
468
+ # compare-hashes subcommand — doc-drift detection for audit-skill
469
+ # ---------------------------------------------------------------------------
470
+
471
+ def _load_doc_sources(raw_text: str, source_label: str) -> List[Any]:
472
+ """Parse a compare-hashes input blob into a list of doc-source entries.
473
+
474
+ Accepts either a top-level JSON array of `{url, content_hash}` entries or a
475
+ top-level object with a `doc_sources` array (e.g. a skill's metadata.json).
476
+ An object without `doc_sources` yields an empty list (nothing tracked).
477
+
478
+ Raises ValueError on malformed JSON or an unexpected top-level shape.
479
+ """
480
+ try:
481
+ data = json.loads(raw_text)
482
+ except json.JSONDecodeError as exc:
483
+ raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
484
+ if isinstance(data, list):
485
+ return data
486
+ if isinstance(data, dict):
487
+ entries = data.get("doc_sources")
488
+ if entries is None:
489
+ return []
490
+ if not isinstance(entries, list):
491
+ raise ValueError(f"`doc_sources` in {source_label} is not an array")
492
+ return entries
493
+ raise ValueError(
494
+ f"{source_label} must be a JSON array or an object with a "
495
+ f"`doc_sources` array; got {type(data).__name__}"
496
+ )
497
+
498
+
499
+ def compare_doc_hashes(doc_sources: List[Any]) -> Dict[str, Any]:
500
+ """Fetch/hash/compare each tracked doc source and categorize it.
501
+
502
+ Deterministic: the same entries served the same upstream bytes always
503
+ yield the same categorization. Every entry lands in exactly one bucket, so
504
+ `stats.total_tracked` == the sum of the four category counts.
505
+ """
506
+ changed: List[Dict[str, Any]] = []
507
+ unchanged: List[Dict[str, Any]] = []
508
+ fetch_failed: List[Dict[str, Any]] = []
509
+ skipped_null_hash: List[Dict[str, Any]] = []
510
+
511
+ for entry in doc_sources:
512
+ if not isinstance(entry, dict):
513
+ fetch_failed.append(
514
+ {"url": "", "old_hash": None, "reason": "invalid entry: not an object"}
515
+ )
516
+ continue
517
+ url = entry.get("url")
518
+ if not isinstance(url, str) or not url:
519
+ fetch_failed.append({
520
+ "url": url if isinstance(url, str) else "",
521
+ "old_hash": entry.get("content_hash"),
522
+ "reason": "invalid entry: missing url",
523
+ })
524
+ continue
525
+ stored = entry.get("content_hash")
526
+ if stored is None:
527
+ # No baseline hash was recorded at compile time — nothing to
528
+ # compare against, so skip the fetch entirely.
529
+ skipped_null_hash.append({"url": url})
530
+ continue
531
+ new_hash, reason = _fetch_and_hash_reason(url)
532
+ if new_hash is None:
533
+ fetch_failed.append({"url": url, "old_hash": stored, "reason": reason})
534
+ elif _normalize_hash(new_hash) == _normalize_hash(stored):
535
+ unchanged.append({"url": url})
536
+ else:
537
+ changed.append({"url": url, "old_hash": stored, "new_hash": new_hash})
538
+
539
+ changed.sort(key=lambda e: e["url"])
540
+ unchanged.sort(key=lambda e: e["url"])
541
+ fetch_failed.sort(key=lambda e: e["url"])
542
+ skipped_null_hash.sort(key=lambda e: e["url"])
543
+
544
+ total = len(changed) + len(unchanged) + len(fetch_failed) + len(skipped_null_hash)
545
+ return {
546
+ "changed": changed,
547
+ "unchanged": unchanged,
548
+ "fetch_failed": fetch_failed,
549
+ "skipped_null_hash": skipped_null_hash,
550
+ "stats": {
551
+ "total_tracked": total,
552
+ "changed": len(changed),
553
+ "unchanged": len(unchanged),
554
+ "fetch_failed": len(fetch_failed),
555
+ "skipped_null_hash": len(skipped_null_hash),
556
+ },
557
+ }
558
+
559
+
560
+ def _cmd_compare_hashes(argv: List[str]) -> int:
561
+ parser = argparse.ArgumentParser(
562
+ prog="skf-detect-docs.py compare-hashes",
563
+ description=(
564
+ "Re-fetch each tracked doc URL, hash the response bytes, and "
565
+ "compare against the stored content_hash to detect doc drift."
566
+ ),
567
+ )
568
+ parser.add_argument(
569
+ "source",
570
+ help=(
571
+ "path to a JSON file (array of {url, content_hash} entries, or an "
572
+ "object with a doc_sources array such as metadata.json), or - to "
573
+ "read that JSON from stdin"
574
+ ),
575
+ )
576
+ args = parser.parse_args(argv)
577
+
578
+ if args.source == "-":
579
+ raw = sys.stdin.read()
580
+ label = "<stdin>"
581
+ else:
582
+ path = Path(args.source)
583
+ try:
584
+ raw = path.read_text(encoding="utf-8")
585
+ except OSError as exc:
586
+ json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
587
+ sys.stderr.write("\n")
588
+ return 2
589
+ label = str(path)
590
+
591
+ try:
592
+ doc_sources = _load_doc_sources(raw, label)
593
+ except ValueError as exc:
594
+ json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
595
+ sys.stderr.write("\n")
596
+ return 2
597
+
598
+ result = compare_doc_hashes(doc_sources)
599
+ json.dump(result, sys.stdout, separators=(",", ":"))
600
+ sys.stdout.write("\n")
601
+ return 0
602
+
603
+
374
604
  # ---------------------------------------------------------------------------
375
605
  # CLI entry point
376
606
  # ---------------------------------------------------------------------------
377
607
 
378
608
  def main() -> int:
609
+ # Additive subcommand: route `compare-hashes ...` to the doc-drift path
610
+ # before the existing detect parser runs. The default (no subcommand)
611
+ # invocation `--repo-url <url> ...` is unchanged.
612
+ if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
613
+ return _cmd_compare_hashes(sys.argv[2:])
614
+
379
615
  parser = argparse.ArgumentParser(
380
616
  description="Detect documentation URLs for a GitHub repository.",
381
617
  )
@@ -32,9 +32,10 @@ Detection rules (apply in order, first match wins):
32
32
  7. build.gradle (Groovy) — check tree:
33
33
  src/main/kotlin/ → kotlin (medium)
34
34
  else → java (medium)
35
- 8. *.csproj | *.sln csharp (high)
36
- 9. Gemfile ruby (high)
37
- 10. Extension-frequency fallback over the full tree.
35
+ 8. Package.swift swift (high)
36
+ 9. *.csproj | *.sln csharp (high)
37
+ 10. Gemfile → ruby (high)
38
+ 11. Extension-frequency fallback over the full tree.
38
39
  dominant extension >= 50% of code files → that language (medium)
39
40
  no clear winner → unknown (low)
40
41
 
@@ -57,6 +58,16 @@ Output (JSON on stdout):
57
58
  detection_source — human-readable string naming what fired (manifest
58
59
  basename, extension share, etc.)
59
60
  fallback_to_extension_frequency — bool (true when rule 10 fired)
61
+ detected_languages — ordered, deduplicated list of EVERY manifest-level
62
+ match in the same priority order the winner walk uses.
63
+ language == detected_languages[0] whenever the manifest
64
+ table fired, so a caller can auto-pick detected_languages[0]
65
+ and gate multi-language disambiguation on
66
+ len(detected_languages) > 1. Special cases: a
67
+ workspace_signal override (rule 0) is decisive and returns
68
+ a single-element [language]; the extension-frequency
69
+ fallback (rule 10) contributes [language] for a best guess
70
+ or [] when language is "unknown".
60
71
 
61
72
  Exit codes:
62
73
  0 — recommendation produced (even when language is "unknown")
@@ -94,6 +105,7 @@ _MANIFEST_RULES: list[tuple[str, str, str]] = [
94
105
  ("go.mod", "go", "go.mod present"),
95
106
  ("pom.xml", "java", "pom.xml present"),
96
107
  ("build.gradle.kts", "kotlin", "build.gradle.kts present"),
108
+ ("Package.swift", "swift", "Package.swift present"),
97
109
  ("Gemfile", "ruby", "Gemfile present"),
98
110
  ]
99
111
 
@@ -199,7 +211,74 @@ def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
199
211
  }
200
212
 
201
213
 
214
+ def _detected_languages(payload: dict[str, Any], winner: dict[str, Any]) -> list[str]:
215
+ """Accumulate every manifest-level match into an ordered, deduplicated list.
216
+
217
+ The walk mirrors the winner-selection priority order exactly, so
218
+ winner["language"] == detected_languages[0] whenever the manifest table
219
+ fired. Two decisive short-circuits diverge from a full walk on purpose:
220
+
221
+ * A workspace_signal override (rule 0) is authoritative — the workspace
222
+ root language wins over any nested package.json, so this returns a
223
+ single-element [winner_language] and never surfaces a spurious
224
+ multi-language gate for what workspace detection already resolved.
225
+ * When no manifest matched (rule 10 fired), the list carries the
226
+ extension-frequency best guess as a single element, or [] when the
227
+ guess was "unknown".
228
+ """
229
+ tree: list[str] = payload["tree"] # validated non-empty by _winner()
230
+
231
+ workspace_signal = payload.get("workspace_signal")
232
+ if isinstance(workspace_signal, str) and workspace_signal in _WORKSPACE_SIGNAL_LANGUAGE:
233
+ return [winner["language"]]
234
+
235
+ langs: list[str] = []
236
+
237
+ def add(lang: str) -> None:
238
+ if lang not in langs:
239
+ langs.append(lang)
240
+
241
+ # Rule 1 — package.json (tsconfig.json disambiguation)
242
+ if _has_basename(tree, "package.json"):
243
+ add("typescript" if _has_basename(tree, "tsconfig.json") else "javascript")
244
+
245
+ # Rules 2-6 / 8 / 10 — single-basename manifests (same order as _MANIFEST_RULES)
246
+ for basename, language, _source in _MANIFEST_RULES:
247
+ if _has_basename(tree, basename):
248
+ add(language)
249
+
250
+ # Rule 7b — build.gradle (Groovy) Java/Kotlin disambiguation
251
+ if _has_basename(tree, "build.gradle"):
252
+ add("kotlin" if _has_path_segment(tree, "src/main/kotlin/") else "java")
253
+
254
+ # Rules 8-9 — suffix-based (csproj, sln)
255
+ for suffix, language, _source in _SUFFIX_RULES:
256
+ if _has_suffix(tree, suffix):
257
+ add(language)
258
+
259
+ if langs:
260
+ return langs
261
+
262
+ # No manifest matched — the winner came from the extension-frequency
263
+ # fallback (rule 10). Carry its best guess, or [] when it was "unknown".
264
+ fallback_language = winner["language"]
265
+ return [] if fallback_language == "unknown" else [fallback_language]
266
+
267
+
202
268
  def detect(payload: dict[str, Any]) -> dict[str, Any]:
269
+ """Apply the documented rule walk and annotate the full manifest match set.
270
+
271
+ Returns the single-winner envelope (language/confidence/detection_source/
272
+ fallback_to_extension_frequency) unchanged, plus detected_languages[] — the
273
+ ordered, deduplicated set of every manifest-level match — so a caller can
274
+ both auto-pick and gate multi-language disambiguation off one JSON shape.
275
+ """
276
+ winner = _winner(payload)
277
+ winner["detected_languages"] = _detected_languages(payload, winner)
278
+ return winner
279
+
280
+
281
+ def _winner(payload: dict[str, Any]) -> dict[str, Any]:
203
282
  """Apply the documented rule walk. Always returns a recommendation."""
204
283
  tree = payload.get("tree")
205
284
  if not isinstance(tree, list):
@@ -79,6 +79,25 @@ DETECT_OUTPUT_SCHEMA (v1):
79
79
  "requested": "Quick"|"Forge"|"Forge+"|"Deep"|null,
80
80
  "satisfied": bool|null,
81
81
  "missing_tools": [str]
82
+ },
83
+ "prior": {
84
+ # Populated from --prior-state-from's forge-tier.yaml (first run: all null/empty).
85
+ "previous_tier": "Quick"|"Forge"|"Forge+"|"Deep"|null,
86
+ "previous_detection_date": str|null,
87
+ "previous_tools": {tool: bool, ...},
88
+ "previous_ccc_index_status": str|null,
89
+ "previous_ccc_indexed_path": str|null,
90
+ "previous_ccc_last_indexed": str|null,
91
+ "previous_ccc_staleness_threshold_hours": int|null,
92
+ # Deterministic CCC-index freshness verdict — computed against
93
+ # --project-root and datetime.now(UTC) captured at detect time, so the
94
+ # ccc-index.md step branches on a boolean instead of doing timestamp math.
95
+ "ccc_index_fresh": bool
96
+ },
97
+ "deltas": {
98
+ "tools_added": [str],
99
+ "tools_removed": [str],
100
+ "tier_changed": bool
82
101
  }
83
102
  }
84
103
  """
@@ -93,6 +112,7 @@ import subprocess
93
112
  import sys
94
113
  import tempfile
95
114
  from concurrent.futures import ThreadPoolExecutor
115
+ from datetime import datetime, timezone
96
116
 
97
117
 
98
118
  VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
@@ -219,8 +239,13 @@ def probe_ccc() -> dict:
219
239
  # `ccc` resolved to a foreign binary (e.g. code2prompt alias). Refuse.
220
240
  return {"available": False, "daemon": None, "version": None}
221
241
 
222
- rc_doctor, doctor_stdout, _ = _run(["ccc", "doctor"])
223
- version = _first_line(doctor_stdout) or _first_line(stdout)
242
+ rc_doctor, _, _ = _run(["ccc", "doctor"])
243
+ # ccc exposes no version CLI: `ccc --version` prints a usage banner, and
244
+ # `ccc doctor` / `ccc --help` lead with a settings header ("Global
245
+ # Settings") or usage line — never a version string. Capturing that first
246
+ # line mislabels a header as a version, so report daemon health (below)
247
+ # as ccc's identifier instead of a misparse.
248
+ version = None
224
249
  if rc_doctor == 0:
225
250
  return {"available": True, "daemon": "healthy", "version": version}
226
251
  # Distinguishing "stopped" from "error" requires parsing doctor output;
@@ -337,6 +362,73 @@ def read_prior_state(prior_state_path) -> dict:
337
362
  }
338
363
 
339
364
 
365
+ def _parse_iso_timestamp(value) -> datetime | None:
366
+ """Parse an ISO 8601 timestamp into a timezone-aware datetime, or None.
367
+
368
+ Normalizes a trailing 'Z' (UTC designator) to '+00:00' because
369
+ `datetime.fromisoformat` does not accept 'Z' on Python < 3.11 and
370
+ requires-python here is >=3.10. A naive result (no tzinfo) is assumed to be
371
+ UTC so it can be compared against a tz-aware `now` without raising. Returns
372
+ None on any parse failure (non-string, empty, malformed).
373
+ """
374
+ if not isinstance(value, str):
375
+ return None
376
+ text = value.strip()
377
+ if not text:
378
+ return None
379
+ if text[-1] in ("Z", "z"):
380
+ text = text[:-1] + "+00:00"
381
+ try:
382
+ dt = datetime.fromisoformat(text)
383
+ except (ValueError, TypeError):
384
+ return None
385
+ if dt.tzinfo is None:
386
+ dt = dt.replace(tzinfo=timezone.utc)
387
+ return dt
388
+
389
+
390
+ def compute_ccc_index_fresh(prior: dict, project_root, now: datetime) -> bool:
391
+ """Deterministic freshness verdict for a prior CCC index.
392
+
393
+ Replaces the ISO-8601 datetime arithmetic that ccc-index.md §2 used to ask
394
+ the model to perform (parse a timestamp, subtract from now, convert to
395
+ hours, compare against a threshold). Same inputs → same boolean.
396
+
397
+ Returns True only when ALL hold:
398
+ - the prior index covered this same project (indexed_path == project_root)
399
+ - the prior index status is "fresh" or "created"
400
+ - last_indexed parses AND (now - last_indexed) <= staleness threshold
401
+
402
+ The staleness threshold defaults to 24 hours when the prior field is null
403
+ (matching the ccc-index.md §2 default). Any null/unparseable required field
404
+ (indexed_path, status, last_indexed), path mismatch, non-fresh status,
405
+ unparseable threshold, or over-threshold delta yields False.
406
+ """
407
+ if not project_root:
408
+ return False
409
+ if prior.get("previous_ccc_indexed_path") != project_root:
410
+ return False
411
+ if prior.get("previous_ccc_index_status") not in ("fresh", "created"):
412
+ return False
413
+
414
+ last_indexed = _parse_iso_timestamp(prior.get("previous_ccc_last_indexed"))
415
+ if last_indexed is None:
416
+ return False
417
+
418
+ threshold = prior.get("previous_ccc_staleness_threshold_hours")
419
+ if threshold is None:
420
+ threshold = 24
421
+ try:
422
+ threshold_hours = float(threshold)
423
+ except (ValueError, TypeError):
424
+ return False
425
+
426
+ if now.tzinfo is None:
427
+ now = now.replace(tzinfo=timezone.utc)
428
+ delta_hours = (now - last_indexed).total_seconds() / 3600.0
429
+ return delta_hours <= threshold_hours
430
+
431
+
340
432
  def detect(args: argparse.Namespace) -> dict:
341
433
  tools: dict = {}
342
434
  with ThreadPoolExecutor(max_workers=4) as ex:
@@ -389,6 +481,9 @@ def detect(args: argparse.Namespace) -> dict:
389
481
  require_satisfied = None
390
482
 
391
483
  prior = read_prior_state(getattr(args, "prior_state_from", None))
484
+ prior["ccc_index_fresh"] = compute_ccc_index_fresh(
485
+ prior, getattr(args, "project_root", None), datetime.now(timezone.utc)
486
+ )
392
487
  deltas = compute_deltas(tools, prior, calculated)
393
488
 
394
489
  return {
@@ -470,6 +565,14 @@ def main() -> None:
470
565
  help="Environment variable name to check for security-scan availability"
471
566
  " (informational only — does NOT affect tier). Default: SNYK_TOKEN.",
472
567
  )
568
+ parser.add_argument(
569
+ "--project-root",
570
+ default=None,
571
+ help="Absolute project root of the current run. Used only to compute"
572
+ " prior.ccc_index_fresh: the prior CCC index counts as fresh only"
573
+ " when its indexed_path equals this value (and its status/timestamp"
574
+ " still qualify). Omitted → ccc_index_fresh is always false.",
575
+ )
473
576
  parser.add_argument(
474
577
  "--prior-state-from",
475
578
  default=None,
@@ -54,9 +54,17 @@ Context payload shape (consumed by `emit`):
54
54
  "ccc_exclusion_warnings": ["string", ...],
55
55
  "ccc_registry_stale_removed": ["/path", ...],
56
56
  "ccc_indexing_failed_reason": "string|null",
57
+ "orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|orphan-action-flag"},
57
58
  "error": null|{"phase","path","reason"}
58
59
  }
59
60
 
61
+ When the step-3 orphan-removal gate is resolved non-interactively
62
+ (headless default Keep, or an explicit --orphan-action), pass
63
+ `orphan_auto_resolution` so the audit trail lands in `warnings` —
64
+ most importantly when the destructive `remove` ran headlessly, which a
65
+ pipeline otherwise could not distinguish from a no-op by reading the
66
+ envelope alone.
67
+
60
68
  Caller does NOT need to compute warnings, tools_added/removed, or
61
69
  tier_changed — the script derives them from the inputs above.
62
70
 
@@ -176,6 +184,15 @@ def _assemble_warnings(payload: dict) -> list[str]:
176
184
  warnings.append(
177
185
  f"require_tier_failed: missing {', '.join(missing) if missing else '<none>'}"
178
186
  )
187
+ orphan = payload.get("orphan_auto_resolution")
188
+ if isinstance(orphan, dict) and orphan.get("action"):
189
+ action = str(orphan.get("action"))
190
+ count = orphan.get("count", 0)
191
+ source = str(orphan.get("source", "headless-default"))
192
+ warnings.append(
193
+ f"orphan_auto_resolution: {action} {count} orphaned collection(s) "
194
+ f"(non-interactive, {source})"
195
+ )
179
196
  return warnings
180
197
 
181
198