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
@@ -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)?/?$",
@@ -224,6 +261,57 @@ def _fetch_and_hash(url: str) -> Optional[str]:
224
261
  return None
225
262
 
226
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
+
227
315
  # ---------------------------------------------------------------------------
228
316
  # Detection method 1 — homepageUrl
229
317
  # ---------------------------------------------------------------------------
@@ -376,11 +464,154 @@ def detect(
376
464
  return deduped
377
465
 
378
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
+
379
604
  # ---------------------------------------------------------------------------
380
605
  # CLI entry point
381
606
  # ---------------------------------------------------------------------------
382
607
 
383
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
+
384
615
  parser = argparse.ArgumentParser(
385
616
  description="Detect documentation URLs for a GitHub repository.",
386
617
  )
@@ -58,6 +58,16 @@ Output (JSON on stdout):
58
58
  detection_source — human-readable string naming what fired (manifest
59
59
  basename, extension share, etc.)
60
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".
61
71
 
62
72
  Exit codes:
63
73
  0 — recommendation produced (even when language is "unknown")
@@ -201,7 +211,74 @@ def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
201
211
  }
202
212
 
203
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
+
204
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]:
205
282
  """Apply the documented rule walk. Always returns a recommendation."""
206
283
  tree = payload.get("tree")
207
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,
@@ -22,7 +22,7 @@ SKILL.md, then extract usage patterns per skill (the part that does
22
22
  benefit from LLM judgment).
23
23
 
24
24
  Subcommand:
25
- enumerate <skills-root>
25
+ enumerate <skills-root> [--pairs] [--reliability]
26
26
  Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]}
27
27
  describing every subdirectory of <skills-root> that resolves to a
28
28
  skill package. Both layouts from knowledge/version-paths.md are
@@ -84,18 +84,47 @@ Symlink handling:
84
84
  Per-skill errors (malformed metadata.json, OSError, etc.) are captured
85
85
  as warnings on the top-level result; they do not exit the process.
86
86
 
87
+ Optional derived output (additive flags — off by default, so existing
88
+ consumers that read only skills/cycles/warnings are unaffected):
89
+
90
+ --pairs
91
+ Attach the complete set of unique library pairs derived from the
92
+ inventory: `pairs = [{"library_a": a, "library_b": b}, ...]` over
93
+ `itertools.combinations` of the sorted, deduplicated skill names,
94
+ plus `pair_count == N*(N-1)/2`. This is the deterministic
95
+ combinatorics the refine-architecture gap-analysis prompt used to
96
+ do in-context (where a dropped pair is a silently missed
97
+ integration gap). Sorting the names first makes ordering
98
+ independent of directory-scan order and byte-stable across runs.
99
+
100
+ --reliability
101
+ Attach the inventory reliability verdict computed from the counts
102
+ the script already owns:
103
+ skill_count — len(skills)
104
+ warning_count — len(warnings)
105
+ unreliable_ratio — warning_count / (skill_count + warning_count),
106
+ or 0.0 when the inventory is empty
107
+ inventory_reliable — unreliable_ratio <= 0.20 (the RELIABILITY
108
+ THRESHOLD lives here, in one unit-tested place,
109
+ so consuming prompts read a boolean instead of
110
+ re-deriving a ratio + threshold comparison).
111
+ The raw counts are emitted alongside the boolean so a caller's halt
112
+ message can still render "{warning_count}/{skill_count+warning_count}
113
+ skills returned malformed metadata".
114
+
87
115
  Exit codes:
88
116
  0 enumeration succeeded (including zero skills found)
89
117
  1 user error (bad skills-root path)
90
118
 
91
119
  CLI:
92
- uv run skf-enumerate-stack-skills.py enumerate <skills-root>
120
+ uv run skf-enumerate-stack-skills.py enumerate <skills-root> [--pairs] [--reliability]
93
121
  """
94
122
 
95
123
  from __future__ import annotations
96
124
 
97
125
  import argparse
98
126
  import hashlib
127
+ import itertools
99
128
  import json
100
129
  import re
101
130
  import sys
@@ -112,6 +141,13 @@ SOURCE_REFERENCES = "references"
112
141
  SOURCE_SKILL_MD = "skill-md"
113
142
  SOURCE_UNKNOWN = "unknown"
114
143
 
144
+ # Inventory reliability policy (single source of truth). If the fraction of
145
+ # skip/malformed warnings exceeds this threshold, the inventory is deemed
146
+ # unreliable. The gate is `ratio <= THRESHOLD` (i.e. a ratio of exactly 0.20
147
+ # is still reliable) so a single malformed skill in a small 3-5 skill
148
+ # inventory does not trip the halt.
149
+ RELIABILITY_THRESHOLD = 0.20
150
+
115
151
  _CONFIDENCE_BY_SOURCE = {
116
152
  SOURCE_METADATA: "T1",
117
153
  SOURCE_REFERENCES: "T2",
@@ -552,6 +588,53 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
552
588
  return result
553
589
 
554
590
 
591
+ # --------------------------------------------------------------------------
592
+ # Derived output: unique library pairs (--pairs)
593
+ # --------------------------------------------------------------------------
594
+
595
+
596
+ def compute_pairs(skills: list[dict]) -> list[dict]:
597
+ """Deterministic unique library pairs from an inventory's skills[].
598
+
599
+ Returns `[{"library_a": a, "library_b": b}, ...]` over every
600
+ combination of the sorted, deduplicated skill names — exactly
601
+ N*(N-1)/2 entries for N distinct names. Sorting first makes the order
602
+ independent of directory-scan order and byte-stable across runs; the
603
+ dedup guards against a pathological repeated name yielding a duplicate
604
+ pair. Never drops or duplicates a pair, unlike in-context enumeration
605
+ at larger N.
606
+ """
607
+ names = sorted({e["name"] for e in skills})
608
+ return [
609
+ {"library_a": a, "library_b": b}
610
+ for a, b in itertools.combinations(names, 2)
611
+ ]
612
+
613
+
614
+ # --------------------------------------------------------------------------
615
+ # Derived output: inventory reliability verdict (--reliability)
616
+ # --------------------------------------------------------------------------
617
+
618
+
619
+ def compute_reliability(skill_count: int, warning_count: int) -> dict:
620
+ """Reliability verdict from the inventory's own counts.
621
+
622
+ `unreliable_ratio` = warning_count / (skill_count + warning_count),
623
+ or 0.0 for an empty inventory (no ZeroDivisionError). The inventory is
624
+ reliable when that ratio is <= RELIABILITY_THRESHOLD (boundary
625
+ inclusive). Emits the raw counts too so a caller's halt message can
626
+ render "{warning_count}/{skill_count+warning_count}".
627
+ """
628
+ total = skill_count + warning_count
629
+ unreliable_ratio = (warning_count / total) if total else 0.0
630
+ return {
631
+ "inventory_reliable": unreliable_ratio <= RELIABILITY_THRESHOLD,
632
+ "unreliable_ratio": unreliable_ratio,
633
+ "skill_count": skill_count,
634
+ "warning_count": warning_count,
635
+ }
636
+
637
+
555
638
  # --------------------------------------------------------------------------
556
639
  # CLI
557
640
  # --------------------------------------------------------------------------
@@ -566,6 +649,15 @@ def _cmd_enumerate(args: argparse.Namespace) -> int:
566
649
  )
567
650
  return 1
568
651
  result = enumerate_stack_skills(skills_root)
652
+ # Additive derived output — attached only when the flag is set, so the
653
+ # default shape stays {skills, cycles, warnings} for existing consumers.
654
+ if args.pairs:
655
+ result["pairs"] = compute_pairs(result["skills"])
656
+ result["pair_count"] = len(result["pairs"])
657
+ if args.reliability:
658
+ result.update(
659
+ compute_reliability(len(result["skills"]), len(result["warnings"]))
660
+ )
569
661
  json.dump(result, sys.stdout, indent=2)
570
662
  sys.stdout.write("\n")
571
663
  return 0
@@ -590,6 +682,23 @@ def _build_parser() -> argparse.ArgumentParser:
590
682
  "skills_root",
591
683
  help="directory containing skill packages (each subdir has SKILL.md)",
592
684
  )
685
+ p_enum.add_argument(
686
+ "--pairs",
687
+ action="store_true",
688
+ help=(
689
+ "additionally emit `pairs[]` (unique {library_a, library_b} "
690
+ "combinations over the sorted skill names) and `pair_count`"
691
+ ),
692
+ )
693
+ p_enum.add_argument(
694
+ "--reliability",
695
+ action="store_true",
696
+ help=(
697
+ "additionally emit the reliability verdict "
698
+ "(`inventory_reliable`, `unreliable_ratio`, `skill_count`, "
699
+ f"`warning_count`; threshold {RELIABILITY_THRESHOLD})"
700
+ ),
701
+ )
593
702
  p_enum.set_defaults(func=_cmd_enumerate)
594
703
 
595
704
  return parser