bmad-module-skill-forge 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +42 -3
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +9 -3
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +73 -27
- package/package.json +2 -2
- package/src/module-help.csv +2 -1
- package/src/shared/_known-workarounds.yaml +155 -0
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +14 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +417 -0
- package/src/shared/scripts/skf-detect-language.py +5 -3
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +192 -0
- package/src/shared/scripts/skf-shape-detect.py +1114 -0
- package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/init.md +30 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +677 -0
- package/src/skf-analyze-source/references/step-shape-detect.md +86 -0
- package/src/skf-audit-skill/SKILL.md +1 -0
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
- package/src/skf-audit-skill/references/init.md +1 -1
- package/src/skf-audit-skill/references/report.md +4 -0
- package/src/skf-audit-skill/references/severity-classify.md +1 -1
- package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
- package/src/skf-brief-skill/SKILL.md +7 -3
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/references/analyze-target.md +7 -4
- package/src/skf-brief-skill/references/confirm-brief.md +0 -1
- package/src/skf-brief-skill/references/gather-intent.md +22 -2
- package/src/skf-brief-skill/references/scope-definition.md +2 -1
- package/src/skf-brief-skill/references/step-auto-brief.md +188 -0
- package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
- package/src/skf-brief-skill/references/write-brief.md +2 -3
- package/src/skf-campaign/SKILL.md +190 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +73 -0
- package/src/skf-campaign/manifest.yaml +11 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +122 -0
- package/src/skf-campaign/references/step-02-strategy.md +97 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +87 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +114 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +235 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +4 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -0
- package/src/skf-create-skill/references/compile.md +5 -2
- package/src/skf-create-skill/references/extract.md +9 -1
- package/src/skf-create-skill/references/extraction-patterns.md +3 -1
- package/src/skf-create-skill/references/generate-artifacts.md +11 -2
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/report.md +17 -1
- package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
- package/src/skf-create-skill/references/step-auto-shard.md +110 -0
- package/src/skf-create-skill/references/step-doc-rot.md +109 -0
- package/src/skf-create-skill/references/step-doc-sources.md +123 -0
- package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
- package/src/skf-create-skill/references/validate.md +12 -3
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-drop-skill/references/execute.md +20 -9
- package/src/skf-drop-skill/references/report.md +2 -0
- package/src/skf-drop-skill/references/select.md +7 -2
- package/src/skf-forger/SKILL.md +15 -4
- package/src/skf-refine-architecture/SKILL.md +2 -1
- package/src/skf-refine-architecture/references/compile.md +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-rename-skill/references/execute.md +5 -4
- package/src/skf-rename-skill/references/rebuild-context.md +2 -2
- package/src/skf-rename-skill/references/select.md +3 -3
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-setup/references/auto-index.md +3 -1
- package/src/skf-setup/references/detect-and-tier.md +4 -0
- package/src/skf-setup/references/report.md +4 -1
- package/src/skf-setup/references/tier-rules.md +1 -1
- package/src/skf-test-skill/SKILL.md +8 -7
- package/src/skf-test-skill/customize.toml +2 -1
- package/src/skf-test-skill/references/coverage-check.md +10 -0
- package/src/skf-test-skill/references/external-validators.md +1 -1
- package/src/skf-test-skill/references/init.md +16 -1
- package/src/skf-test-skill/references/report.md +5 -1
- package/src/skf-test-skill/references/score.md +95 -6
- package/src/skf-test-skill/references/source-access-protocol.md +13 -3
- package/src/skf-test-skill/references/step-hard-gate.md +73 -0
- package/src/skf-test-skill/scripts/compute-score.py +4 -3
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/customize.toml +0 -9
- package/src/skf-update-skill/references/detect-changes.md +33 -3
- package/src/skf-update-skill/references/health-check.md +2 -2
- package/src/skf-update-skill/references/init.md +1 -0
- package/src/skf-update-skill/references/re-extract.md +15 -1
- package/src/skf-verify-stack/references/report.md +1 -1
- package/tools/cli/lib/ui.js +3 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"description": "Single-line JSON document emitted by skf-brief-skill step 5 §4b when {headless_mode} is true, prefixed with the literal 'SKF_BRIEF_RESULT_JSON: '. Pipelines grep one line out of the workflow log to branch on the brief outcome without parsing prose. Schema mirrors the contract in src/skf-brief-skill/SKILL.md Result Contract section.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
|
-
"required": ["status", "brief_path", "skill_name", "version", "language", "scope_type", "exit_code", "halt_reason"],
|
|
8
|
+
"required": ["status", "brief_path", "skill_name", "version", "language", "scope_type", "exit_code", "halt_reason", "mode"],
|
|
9
9
|
"properties": {
|
|
10
10
|
"status": {
|
|
11
11
|
"type": "string",
|
|
@@ -53,6 +53,11 @@
|
|
|
53
53
|
"user-cancelled"
|
|
54
54
|
],
|
|
55
55
|
"description": "Null on success. One of the documented HALT classes on error. Pipelines branch on this to surface the right operator message without grepping the prose error."
|
|
56
|
+
},
|
|
57
|
+
"mode": {
|
|
58
|
+
"type": ["string", "null"],
|
|
59
|
+
"enum": ["auto", null],
|
|
60
|
+
"description": "'auto' when BS was invoked with the [auto] bracket modifier (pipeline auto-brief generation). Null otherwise (interactive or headless-without-auto)."
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
}
|
|
@@ -51,7 +51,12 @@
|
|
|
51
51
|
"required": ["url"],
|
|
52
52
|
"properties": {
|
|
53
53
|
"url": { "type": "string", "minLength": 1 },
|
|
54
|
-
"label": { "type": "string" }
|
|
54
|
+
"label": { "type": "string" },
|
|
55
|
+
"source": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"enum": ["language-registry", "readme-detection", "homepage", "pages-api", "docs-folder"],
|
|
58
|
+
"description": "Optional per-corpus provenance. `language-registry` marks a registry-guaranteed companion corpus (skf-language-corpora.py); the others mark opportunistically-detected docs by detection method. Absent on legacy/hand-authored entries."
|
|
59
|
+
}
|
|
55
60
|
}
|
|
56
61
|
},
|
|
57
62
|
"minItems": 1,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Derive Assembly Shape — decide whether a brief is a whole-language reference.
|
|
6
|
+
|
|
7
|
+
create-skill's SKILL.md assembly is mostly LLM-driven prose, but the BRANCH
|
|
8
|
+
SELECTION — does this skill get the whole-language "Language Guide" treatment
|
|
9
|
+
(prose foregrounded, compiler internals demoted) or the standard
|
|
10
|
+
library-export layout? — must be deterministic. This helper computes that one
|
|
11
|
+
decision from the brief so the assembler (and the doc-fetch step) branch on a
|
|
12
|
+
machine value instead of grepping free-text `scope.notes`.
|
|
13
|
+
|
|
14
|
+
The gate is a single, structured, schema-validated condition: the brief's
|
|
15
|
+
`doc_urls` contains at least one entry with `source: "language-registry"`
|
|
16
|
+
(issue #432). Registry-sourced corpora are stamped ONLY by
|
|
17
|
+
skf-language-corpora.py, which is invoked ONLY for a whole-language reference
|
|
18
|
+
(a grammar_file:/tree_triad: signal) in step-auto-scope.md §6b. So the gate
|
|
19
|
+
fires exactly when there is registry-guaranteed prose to foreground, and never
|
|
20
|
+
for an ordinary library, a parser library (pest), a component-library, a
|
|
21
|
+
reference-app, or a docs-only brief — none of which carry a language-registry
|
|
22
|
+
entry. A registry-miss (N==0) whole-language reference carries no such entry
|
|
23
|
+
and is intentionally NOT gated: there is no canonical prose to foreground, so
|
|
24
|
+
it keeps the standard layout (and its scope.notes DEGRADED caveat stands).
|
|
25
|
+
|
|
26
|
+
Gating on the structured `source` field — not the operator-editable
|
|
27
|
+
`scope.notes` substring — means a hand-edited or interactive brief cannot
|
|
28
|
+
silently disable (or misfire) the override.
|
|
29
|
+
|
|
30
|
+
CLI:
|
|
31
|
+
uv run skf-derive-assembly-shape.py <path/to/skill-brief.yaml>
|
|
32
|
+
cat brief.yaml | uv run skf-derive-assembly-shape.py -
|
|
33
|
+
|
|
34
|
+
Output (JSON on stdout):
|
|
35
|
+
{
|
|
36
|
+
"assembly_shape": "whole-language-reference" | "standard",
|
|
37
|
+
"gate_signal": "language-registry" | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Exit codes:
|
|
41
|
+
0 success (shape emitted)
|
|
42
|
+
2 error (missing/unreadable/invalid brief)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import json
|
|
49
|
+
import sys
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
import yaml
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def derive_assembly_shape(brief: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Return the assembly-shape decision for a parsed brief."""
|
|
58
|
+
doc_urls = brief.get("doc_urls")
|
|
59
|
+
if isinstance(doc_urls, list):
|
|
60
|
+
for entry in doc_urls:
|
|
61
|
+
if isinstance(entry, dict) and entry.get("source") == "language-registry":
|
|
62
|
+
return {
|
|
63
|
+
"assembly_shape": "whole-language-reference",
|
|
64
|
+
"gate_signal": "language-registry",
|
|
65
|
+
}
|
|
66
|
+
return {"assembly_shape": "standard", "gate_signal": None}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _die(message: str) -> None:
|
|
70
|
+
json.dump({"error": message}, sys.stderr)
|
|
71
|
+
sys.stderr.write("\n")
|
|
72
|
+
sys.exit(2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
parser = argparse.ArgumentParser(
|
|
77
|
+
prog="skf-derive-assembly-shape",
|
|
78
|
+
description="Decide whether a skill brief is a whole-language reference.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"path",
|
|
82
|
+
help="path to skill-brief.yaml; pass `-` to read YAML from stdin",
|
|
83
|
+
)
|
|
84
|
+
args = parser.parse_args(argv)
|
|
85
|
+
|
|
86
|
+
if args.path == "-":
|
|
87
|
+
text = sys.stdin.read()
|
|
88
|
+
else:
|
|
89
|
+
p = Path(args.path)
|
|
90
|
+
if not p.is_file():
|
|
91
|
+
_die(f"Brief not found at {p.as_posix()}")
|
|
92
|
+
text = p.read_text(encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
brief = yaml.safe_load(text)
|
|
96
|
+
except yaml.YAMLError as exc:
|
|
97
|
+
_die(f"Brief is not valid YAML: {exc}")
|
|
98
|
+
if not isinstance(brief, dict):
|
|
99
|
+
_die("Brief must be a YAML mapping")
|
|
100
|
+
|
|
101
|
+
json.dump(derive_assembly_shape(brief), sys.stdout)
|
|
102
|
+
sys.stdout.write("\n")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Detect Docs — discover documentation URLs for a GitHub repository.
|
|
6
|
+
|
|
7
|
+
Standalone doc-detection script that discovers documentation URLs through
|
|
8
|
+
multiple methods. BS, CS, AS, and campaign workflows all locate a repo's
|
|
9
|
+
docs through this shared entry point, eliminating duplicate detection logic.
|
|
10
|
+
|
|
11
|
+
Detection chain (all four methods attempted, results aggregated):
|
|
12
|
+
|
|
13
|
+
1. homepageUrl — GitHub repo metadata homepage field
|
|
14
|
+
2. readme_link — links parsed from the repo README
|
|
15
|
+
3. pages_api — GitHub Pages site URL
|
|
16
|
+
4. docs_folder — markdown files in the repo's docs/ directory
|
|
17
|
+
|
|
18
|
+
CLI:
|
|
19
|
+
uv run src/shared/scripts/skf-detect-docs.py \\
|
|
20
|
+
--repo-url <url> [--local-path <path>] [--skip-pages-api]
|
|
21
|
+
|
|
22
|
+
Input:
|
|
23
|
+
--repo-url GitHub repository URL (required)
|
|
24
|
+
--local-path local clone path for docs/ folder scan (optional)
|
|
25
|
+
--skip-pages-api skip GitHub Pages API detection (optional flag)
|
|
26
|
+
|
|
27
|
+
Output (JSON array on stdout):
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
"url": "https://docs.example.com",
|
|
31
|
+
"detected_via": "homepageUrl",
|
|
32
|
+
"content_hash": "sha256:a1b2c3...",
|
|
33
|
+
"content_type": "api-docs"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
Exit codes:
|
|
38
|
+
0 found >=1 documentation source
|
|
39
|
+
1 no documentation sources found (empty array)
|
|
40
|
+
2 error (invalid args, gh not found, etc.)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import base64
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
import re
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
import urllib.error
|
|
53
|
+
import urllib.request
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Any, Dict, List, Optional
|
|
56
|
+
|
|
57
|
+
_GITHUB_URL_RE = re.compile(
|
|
58
|
+
r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
|
|
59
|
+
re.IGNORECASE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
USER_AGENT = "skf-detect-docs/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
|
|
63
|
+
|
|
64
|
+
_FETCH_TIMEOUT = 15
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# URL exclusion patterns (AC #5)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
_EXCLUSION_PATTERNS = re.compile(
|
|
71
|
+
r"(?:^|/)(?:"
|
|
72
|
+
r"CHANGELOG|CHANGES|HISTORY"
|
|
73
|
+
r"|MIGRATION|MIGRATING|UPGRADE"
|
|
74
|
+
r"|RELEASE|release-notes|releases"
|
|
75
|
+
r"|CONTRIBUTING|CODE_OF_CONDUCT"
|
|
76
|
+
r"|LICENSE|SECURITY"
|
|
77
|
+
r")(?:\.|/|$)",
|
|
78
|
+
re.IGNORECASE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# README link scanning patterns
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
|
86
|
+
_HTML_LINK_RE = re.compile(r'<a\s[^>]*href=["\']([^"\']+)["\']', re.IGNORECASE)
|
|
87
|
+
_BARE_URL_RE = re.compile(r"^(https?://\S+)$", re.MULTILINE)
|
|
88
|
+
|
|
89
|
+
# `docs?\.` (not `docs\.`) so a `doc.` subdomain matches too — language doc
|
|
90
|
+
# sites use the singular form (doc.rust-lang.org, doc.qt.io).
|
|
91
|
+
_DOC_DOMAIN_RE = re.compile(
|
|
92
|
+
r"(?:docs?\.|\.readthedocs\.|wiki\.|documentation\.)",
|
|
93
|
+
re.IGNORECASE,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Language reference/guide path segments (a Book, a std/library API, a tutorial)
|
|
97
|
+
# are doc URLs even on a bare domain (doc.rust-lang.org/book/, .../std/).
|
|
98
|
+
_DOC_PATH_RE = re.compile(
|
|
99
|
+
r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/"
|
|
100
|
+
r"|/book/|/std/|/library/|/tutorial/)",
|
|
101
|
+
re.IGNORECASE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_DOC_TEXT_RE = re.compile(
|
|
105
|
+
r"(?:documentation|docs|api\s+reference|guide|wiki)",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
_REJECT_URL_RE = re.compile(
|
|
110
|
+
r"(?:"
|
|
111
|
+
r"github\.com/[^/]+/[^/]+/(?:issues|pull|actions)"
|
|
112
|
+
r"|img\.shields\.io"
|
|
113
|
+
r"|badge"
|
|
114
|
+
r"|\.(?:svg|png|gif|jpg|jpeg)(?:\?|$)"
|
|
115
|
+
r"|travis-ci\."
|
|
116
|
+
r"|circleci\."
|
|
117
|
+
r"|npmjs\.com"
|
|
118
|
+
r"|pypi\.org"
|
|
119
|
+
r"|crates\.io"
|
|
120
|
+
r"|twitter\.com|x\.com"
|
|
121
|
+
r"|discord\.(?:gg|com)"
|
|
122
|
+
r"|slack\.com"
|
|
123
|
+
r")",
|
|
124
|
+
re.IGNORECASE,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Content type classification
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
_API_DOCS_RE = re.compile(r"(?:/api/|/reference/|/sdk/|api-docs|api\.)", re.IGNORECASE)
|
|
133
|
+
_GUIDE_RE = re.compile(
|
|
134
|
+
r"(?:/guide/|/tutorial/|/getting-started|/quickstart|/howto)",
|
|
135
|
+
re.IGNORECASE,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _classify_content_type(url: str) -> str:
|
|
140
|
+
if _API_DOCS_RE.search(url):
|
|
141
|
+
return "api-docs"
|
|
142
|
+
if _GUIDE_RE.search(url):
|
|
143
|
+
return "guide"
|
|
144
|
+
return "reference"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# URL helpers
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _is_excluded(url: str) -> bool:
|
|
152
|
+
return bool(_EXCLUSION_PATTERNS.search(url))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_github_self_url(url: str, owner: str, repo: str) -> bool:
|
|
156
|
+
return bool(re.match(
|
|
157
|
+
rf"https?://(?:www\.)?github\.com/{re.escape(owner)}/{re.escape(repo)}/?$",
|
|
158
|
+
url,
|
|
159
|
+
re.IGNORECASE,
|
|
160
|
+
))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_doc_url(url: str, link_text: str = "") -> bool:
|
|
164
|
+
if _REJECT_URL_RE.search(url):
|
|
165
|
+
return False
|
|
166
|
+
if _DOC_DOMAIN_RE.search(url):
|
|
167
|
+
return True
|
|
168
|
+
if _DOC_PATH_RE.search(url):
|
|
169
|
+
return True
|
|
170
|
+
if link_text and _DOC_TEXT_RE.search(link_text):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# gh CLI helper
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _run_gh(args: List[str]) -> Optional[str]:
|
|
180
|
+
try:
|
|
181
|
+
result = subprocess.run(
|
|
182
|
+
["gh"] + args,
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
)
|
|
186
|
+
if result.returncode == 0:
|
|
187
|
+
return result.stdout.strip()
|
|
188
|
+
return None
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _check_gh_available() -> bool:
|
|
194
|
+
try:
|
|
195
|
+
subprocess.run(
|
|
196
|
+
["gh", "--version"],
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
)
|
|
200
|
+
return True
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Content hashing
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _fetch_and_hash(url: str) -> Optional[str]:
|
|
210
|
+
if url.startswith("file://"):
|
|
211
|
+
try:
|
|
212
|
+
local_path = url[7:]
|
|
213
|
+
with open(local_path, "rb") as fh:
|
|
214
|
+
content = fh.read()
|
|
215
|
+
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
216
|
+
except (OSError, IOError):
|
|
217
|
+
return None
|
|
218
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
219
|
+
try:
|
|
220
|
+
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
221
|
+
content = resp.read()
|
|
222
|
+
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
223
|
+
except Exception:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Detection method 1 — homepageUrl
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _detect_homepage_url(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
232
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}", "--jq", ".homepage"])
|
|
233
|
+
if not raw or raw == "null":
|
|
234
|
+
return []
|
|
235
|
+
url = raw.strip()
|
|
236
|
+
if not url:
|
|
237
|
+
return []
|
|
238
|
+
if _is_github_self_url(url, owner, repo):
|
|
239
|
+
return []
|
|
240
|
+
return [{"url": url, "detected_via": "homepageUrl", "content_type": _classify_content_type(url)}]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Detection method 2 — README link scanning
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _detect_readme_links(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
248
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/readme"])
|
|
249
|
+
if not raw:
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
data = json.loads(raw)
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
return []
|
|
255
|
+
encoded = data.get("content", "")
|
|
256
|
+
if not encoded:
|
|
257
|
+
return []
|
|
258
|
+
try:
|
|
259
|
+
readme_text = base64.b64decode(encoded).decode("utf-8", errors="replace")
|
|
260
|
+
except Exception:
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
urls_with_text: List[tuple] = []
|
|
264
|
+
for text, url in _MD_LINK_RE.findall(readme_text):
|
|
265
|
+
urls_with_text.append((url.strip(), text.strip()))
|
|
266
|
+
for url in _HTML_LINK_RE.findall(readme_text):
|
|
267
|
+
urls_with_text.append((url.strip(), ""))
|
|
268
|
+
for url in _BARE_URL_RE.findall(readme_text):
|
|
269
|
+
urls_with_text.append((url.strip(), ""))
|
|
270
|
+
|
|
271
|
+
results: List[Dict[str, Any]] = []
|
|
272
|
+
seen: set = set()
|
|
273
|
+
for url, text in urls_with_text:
|
|
274
|
+
if not url.startswith("http"):
|
|
275
|
+
continue
|
|
276
|
+
if url in seen:
|
|
277
|
+
continue
|
|
278
|
+
if _is_doc_url(url, text):
|
|
279
|
+
seen.add(url)
|
|
280
|
+
results.append({
|
|
281
|
+
"url": url,
|
|
282
|
+
"detected_via": "readme_link",
|
|
283
|
+
"content_type": _classify_content_type(url),
|
|
284
|
+
})
|
|
285
|
+
return results
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Detection method 3 — Pages API
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _detect_pages_api(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
293
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/pages", "--jq", ".html_url"])
|
|
294
|
+
if not raw or raw == "null":
|
|
295
|
+
return []
|
|
296
|
+
url = raw.strip()
|
|
297
|
+
if not url:
|
|
298
|
+
return []
|
|
299
|
+
return [{"url": url, "detected_via": "pages_api", "content_type": _classify_content_type(url)}]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Detection method 4 — docs/ folder
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def _detect_docs_folder(owner: str, repo: str, local_path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
307
|
+
results: List[Dict[str, Any]] = []
|
|
308
|
+
if local_path:
|
|
309
|
+
docs_dir = Path(local_path) / "docs"
|
|
310
|
+
if docs_dir.is_dir():
|
|
311
|
+
for md_file in sorted(docs_dir.rglob("*.md")):
|
|
312
|
+
file_url = "file://" + md_file.as_posix()
|
|
313
|
+
results.append({
|
|
314
|
+
"url": file_url,
|
|
315
|
+
"detected_via": "docs_folder",
|
|
316
|
+
"content_type": _classify_content_type(md_file.as_posix()),
|
|
317
|
+
})
|
|
318
|
+
else:
|
|
319
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/contents/docs"])
|
|
320
|
+
if not raw:
|
|
321
|
+
return []
|
|
322
|
+
try:
|
|
323
|
+
entries = json.loads(raw)
|
|
324
|
+
except json.JSONDecodeError:
|
|
325
|
+
return []
|
|
326
|
+
if not isinstance(entries, list):
|
|
327
|
+
return []
|
|
328
|
+
for entry in entries:
|
|
329
|
+
name = entry.get("name", "")
|
|
330
|
+
if not name.lower().endswith(".md"):
|
|
331
|
+
continue
|
|
332
|
+
download_url = entry.get("download_url", "")
|
|
333
|
+
if download_url:
|
|
334
|
+
results.append({
|
|
335
|
+
"url": download_url,
|
|
336
|
+
"detected_via": "docs_folder",
|
|
337
|
+
"content_type": _classify_content_type(download_url),
|
|
338
|
+
})
|
|
339
|
+
return results
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Main detection orchestrator
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def detect(
|
|
347
|
+
repo_url: str,
|
|
348
|
+
local_path: Optional[str] = None,
|
|
349
|
+
skip_pages_api: bool = False,
|
|
350
|
+
) -> List[Dict[str, Any]]:
|
|
351
|
+
m = _GITHUB_URL_RE.match(repo_url.strip())
|
|
352
|
+
if not m:
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
owner, repo = m.group(1), m.group(2)
|
|
356
|
+
|
|
357
|
+
all_results: List[Dict[str, Any]] = []
|
|
358
|
+
all_results.extend(_detect_homepage_url(owner, repo))
|
|
359
|
+
all_results.extend(_detect_readme_links(owner, repo))
|
|
360
|
+
if not skip_pages_api:
|
|
361
|
+
all_results.extend(_detect_pages_api(owner, repo))
|
|
362
|
+
all_results.extend(_detect_docs_folder(owner, repo, local_path))
|
|
363
|
+
|
|
364
|
+
filtered = [r for r in all_results if not _is_excluded(r["url"])]
|
|
365
|
+
|
|
366
|
+
seen: set = set()
|
|
367
|
+
deduped: List[Dict[str, Any]] = []
|
|
368
|
+
for r in filtered:
|
|
369
|
+
if r["url"] not in seen:
|
|
370
|
+
seen.add(r["url"])
|
|
371
|
+
deduped.append(r)
|
|
372
|
+
|
|
373
|
+
for entry in deduped:
|
|
374
|
+
entry["content_hash"] = _fetch_and_hash(entry["url"])
|
|
375
|
+
|
|
376
|
+
return deduped
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
# CLI entry point
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
def main() -> int:
|
|
384
|
+
parser = argparse.ArgumentParser(
|
|
385
|
+
description="Detect documentation URLs for a GitHub repository.",
|
|
386
|
+
)
|
|
387
|
+
parser.add_argument("--repo-url", required=True, help="GitHub repository URL")
|
|
388
|
+
parser.add_argument("--local-path", default=None, help="Local clone path for docs/ folder scan")
|
|
389
|
+
parser.add_argument("--skip-pages-api", action="store_true", help="Skip GitHub Pages API detection")
|
|
390
|
+
args = parser.parse_args()
|
|
391
|
+
|
|
392
|
+
if not _check_gh_available():
|
|
393
|
+
json.dump({"error": "gh CLI not found", "code": "GH_NOT_FOUND"}, sys.stderr)
|
|
394
|
+
sys.stderr.write("\n")
|
|
395
|
+
return 2
|
|
396
|
+
|
|
397
|
+
m = _GITHUB_URL_RE.match(args.repo_url.strip())
|
|
398
|
+
if not m:
|
|
399
|
+
json.dump({"error": f"Not a GitHub URL: {args.repo_url}", "code": "INVALID_URL"}, sys.stderr)
|
|
400
|
+
sys.stderr.write("\n")
|
|
401
|
+
return 2
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
results = detect(args.repo_url, args.local_path, args.skip_pages_api)
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
json.dump({"error": str(exc), "code": "DETECTION_ERROR"}, sys.stderr)
|
|
407
|
+
sys.stderr.write("\n")
|
|
408
|
+
return 2
|
|
409
|
+
|
|
410
|
+
json.dump(results, sys.stdout, separators=(",", ":"))
|
|
411
|
+
sys.stdout.write("\n")
|
|
412
|
+
|
|
413
|
+
return 0 if results else 1
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
raise SystemExit(main())
|
|
@@ -32,9 +32,10 @@ Detection rules (apply in order, first match wins):
|
|
|
32
32
|
7. build.gradle (Groovy) — check tree:
|
|
33
33
|
src/main/kotlin/ → kotlin (medium)
|
|
34
34
|
else → java (medium)
|
|
35
|
-
8.
|
|
36
|
-
9.
|
|
37
|
-
10.
|
|
35
|
+
8. Package.swift → swift (high)
|
|
36
|
+
9. *.csproj | *.sln → csharp (high)
|
|
37
|
+
10. Gemfile → ruby (high)
|
|
38
|
+
11. Extension-frequency fallback over the full tree.
|
|
38
39
|
dominant extension >= 50% of code files → that language (medium)
|
|
39
40
|
no clear winner → unknown (low)
|
|
40
41
|
|
|
@@ -94,6 +95,7 @@ _MANIFEST_RULES: list[tuple[str, str, str]] = [
|
|
|
94
95
|
("go.mod", "go", "go.mod present"),
|
|
95
96
|
("pom.xml", "java", "pom.xml present"),
|
|
96
97
|
("build.gradle.kts", "kotlin", "build.gradle.kts present"),
|
|
98
|
+
("Package.swift", "swift", "Package.swift present"),
|
|
97
99
|
("Gemfile", "ruby", "Gemfile present"),
|
|
98
100
|
]
|
|
99
101
|
|
|
@@ -44,7 +44,8 @@ Context payload shape (consumed by `emit`):
|
|
|
44
44
|
"halt_reason": null | "input-missing" | "input-invalid" |
|
|
45
45
|
"forge-tier-missing" | "target-inaccessible" |
|
|
46
46
|
"gh-auth-failed" | "write-failed" |
|
|
47
|
-
"overwrite-cancelled" | "user-cancelled"
|
|
47
|
+
"overwrite-cancelled" | "user-cancelled",
|
|
48
|
+
"mode": null | "auto"
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
The caller does NOT supply exit_code — the script derives it from
|
|
@@ -105,6 +106,8 @@ HALT_TO_EXIT = {
|
|
|
105
106
|
"user-cancelled": 6,
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
VALID_MODES = {None, "auto"}
|
|
110
|
+
|
|
108
111
|
# Envelope key order — fixed so byte-stable diffs are possible.
|
|
109
112
|
KEY_ORDER = [
|
|
110
113
|
"status",
|
|
@@ -115,6 +118,7 @@ KEY_ORDER = [
|
|
|
115
118
|
"scope_type",
|
|
116
119
|
"exit_code",
|
|
117
120
|
"halt_reason",
|
|
121
|
+
"mode",
|
|
118
122
|
]
|
|
119
123
|
|
|
120
124
|
|
|
@@ -152,6 +156,10 @@ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
|
|
|
152
156
|
if not skill_name or not isinstance(skill_name, str):
|
|
153
157
|
_die(f"skill_name is required and must be a non-empty string; got {skill_name!r}")
|
|
154
158
|
|
|
159
|
+
mode = ctx.get("mode")
|
|
160
|
+
if mode not in VALID_MODES:
|
|
161
|
+
_die(f"mode must be one of {sorted(m for m in VALID_MODES if m is not None)} or null; got {mode!r}")
|
|
162
|
+
|
|
155
163
|
envelope = {
|
|
156
164
|
"status": status,
|
|
157
165
|
"brief_path": ctx.get("brief_path"),
|
|
@@ -161,6 +169,7 @@ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
|
|
|
161
169
|
"scope_type": scope_type,
|
|
162
170
|
"exit_code": HALT_TO_EXIT[halt_reason],
|
|
163
171
|
"halt_reason": halt_reason,
|
|
172
|
+
"mode": mode,
|
|
164
173
|
}
|
|
165
174
|
# Re-emit in canonical key order
|
|
166
175
|
return {k: envelope[k] for k in KEY_ORDER}
|
|
@@ -181,6 +190,8 @@ def validate(envelope: dict[str, Any]) -> None:
|
|
|
181
190
|
_die(f"halt_reason invalid: {envelope.get('halt_reason')!r}")
|
|
182
191
|
if envelope.get("scope_type") not in VALID_SCOPE_TYPES:
|
|
183
192
|
_die(f"scope_type invalid: {envelope.get('scope_type')!r}")
|
|
193
|
+
if envelope.get("mode") not in VALID_MODES:
|
|
194
|
+
_die(f"mode invalid: {envelope.get('mode')!r}")
|
|
184
195
|
if envelope.get("exit_code") not in {0, 2, 3, 4, 5, 6}:
|
|
185
196
|
_die(f"exit_code invalid: {envelope.get('exit_code')!r}")
|
|
186
197
|
expected_exit = HALT_TO_EXIT[envelope.get("halt_reason")]
|
|
@@ -54,9 +54,17 @@ Context payload shape (consumed by `emit`):
|
|
|
54
54
|
"ccc_exclusion_warnings": ["string", ...],
|
|
55
55
|
"ccc_registry_stale_removed": ["/path", ...],
|
|
56
56
|
"ccc_indexing_failed_reason": "string|null",
|
|
57
|
+
"orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|orphan-action-flag"},
|
|
57
58
|
"error": null|{"phase","path","reason"}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
When the step-3 orphan-removal gate is resolved non-interactively
|
|
62
|
+
(headless default Keep, or an explicit --orphan-action), pass
|
|
63
|
+
`orphan_auto_resolution` so the audit trail lands in `warnings` —
|
|
64
|
+
most importantly when the destructive `remove` ran headlessly, which a
|
|
65
|
+
pipeline otherwise could not distinguish from a no-op by reading the
|
|
66
|
+
envelope alone.
|
|
67
|
+
|
|
60
68
|
Caller does NOT need to compute warnings, tools_added/removed, or
|
|
61
69
|
tier_changed — the script derives them from the inputs above.
|
|
62
70
|
|
|
@@ -176,6 +184,15 @@ def _assemble_warnings(payload: dict) -> list[str]:
|
|
|
176
184
|
warnings.append(
|
|
177
185
|
f"require_tier_failed: missing {', '.join(missing) if missing else '<none>'}"
|
|
178
186
|
)
|
|
187
|
+
orphan = payload.get("orphan_auto_resolution")
|
|
188
|
+
if isinstance(orphan, dict) and orphan.get("action"):
|
|
189
|
+
action = str(orphan.get("action"))
|
|
190
|
+
count = orphan.get("count", 0)
|
|
191
|
+
source = str(orphan.get("source", "headless-default"))
|
|
192
|
+
warnings.append(
|
|
193
|
+
f"orphan_auto_resolution: {action} {count} orphaned collection(s) "
|
|
194
|
+
f"(non-interactive, {source})"
|
|
195
|
+
)
|
|
179
196
|
return warnings
|
|
180
197
|
|
|
181
198
|
|