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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.nvmrc +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +2 -2
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-detect-docs.py +232 -1
- package/src/shared/scripts/skf-detect-language.py +77 -0
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +39 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +42 -180
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +6 -14
- package/src/skf-brief-skill/references/confirm-brief.md +2 -14
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +19 -22
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +4 -14
- package/src/skf-brief-skill/references/step-auto-brief.md +1 -5
- package/src/skf-brief-skill/references/step-auto-validate.md +1 -5
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +17 -10
- package/src/skf-campaign/SKILL.md +15 -84
- package/src/skf-campaign/customize.toml +2 -0
- package/src/skf-campaign/manifest.yaml +4 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +14 -103
- package/src/skf-campaign/references/step-01-setup.md +4 -5
- package/src/skf-campaign/references/step-02-strategy.md +8 -3
- package/src/skf-campaign/references/step-03-pins.md +1 -1
- package/src/skf-campaign/references/step-04-provenance.md +1 -1
- package/src/skf-campaign/references/step-05-skill-loop.md +1 -1
- package/src/skf-campaign/references/step-06-batch.md +7 -6
- package/src/skf-campaign/references/step-11-maintenance.md +2 -2
- package/src/skf-campaign/references/step-resume.md +9 -8
- package/src/skf-campaign/scripts/campaign-deps.py +10 -1
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +1 -1
- package/src/skf-create-skill/SKILL.md +14 -3
- package/src/skf-create-skill/assets/compile-assembly-rules.md +17 -38
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +34 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +14 -29
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +7 -7
- package/src/skf-create-skill/references/generate-artifacts.md +9 -23
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +12 -15
- package/src/skf-create-skill/references/source-resolution-protocols.md +3 -3
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +30 -12
- package/src/skf-create-skill/references/step-doc-sources.md +2 -2
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +8 -50
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +59 -28
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +51 -31
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +42 -10
- package/src/skf-drop-skill/references/select.md +88 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +27 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +14 -7
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +9 -7
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +13 -10
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +107 -78
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +20 -12
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +9 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +6 -8
- package/src/skf-setup/references/report.md +22 -31
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +109 -75
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +38 -20
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +6 -6
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +131 -1
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +14 -0
- package/src/skf-update-skill/references/detect-changes.md +19 -37
- package/src/skf-update-skill/references/health-check.md +3 -9
- package/src/skf-update-skill/references/init.md +39 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +12 -24
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- 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,
|
|
223
|
-
version
|
|
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
|