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.
Files changed (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +10 -5
  3. package/docs/_data/pinned.yaml +2 -2
  4. package/docs/agents.md +11 -2
  5. package/docs/architecture.md +5 -4
  6. package/docs/bmad-synergy.md +42 -3
  7. package/docs/campaign.md +172 -0
  8. package/docs/examples.md +7 -3
  9. package/docs/forge-auto.md +90 -0
  10. package/docs/getting-started.md +9 -0
  11. package/docs/how-it-works.md +6 -4
  12. package/docs/index.md +4 -3
  13. package/docs/skill-model.md +1 -1
  14. package/docs/troubleshooting.md +17 -1
  15. package/docs/verifying-a-skill.md +9 -3
  16. package/docs/why-skf.md +1 -1
  17. package/docs/workflows.md +73 -27
  18. package/package.json +2 -2
  19. package/src/module-help.csv +2 -1
  20. package/src/shared/_known-workarounds.yaml +155 -0
  21. package/src/shared/data/language-corpora.json +32 -0
  22. package/src/shared/references/pipeline-contracts.md +14 -2
  23. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  26. package/src/shared/scripts/skf-detect-docs.py +417 -0
  27. package/src/shared/scripts/skf-detect-language.py +5 -3
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  29. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  30. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  31. package/src/shared/scripts/skf-language-corpora.py +100 -0
  32. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  33. package/src/shared/scripts/skf-preapply.py +192 -0
  34. package/src/shared/scripts/skf-shape-detect.py +1114 -0
  35. package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
  36. package/src/shared/scripts/skf-validate-pins.py +368 -0
  37. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  38. package/src/skf-analyze-source/SKILL.md +26 -20
  39. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  40. package/src/skf-analyze-source/references/init.md +30 -0
  41. package/src/skf-analyze-source/references/step-auto-scope.md +677 -0
  42. package/src/skf-analyze-source/references/step-shape-detect.md +86 -0
  43. package/src/skf-audit-skill/SKILL.md +1 -0
  44. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  45. package/src/skf-audit-skill/references/init.md +1 -1
  46. package/src/skf-audit-skill/references/report.md +4 -0
  47. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  48. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  49. package/src/skf-brief-skill/SKILL.md +7 -3
  50. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  51. package/src/skf-brief-skill/references/analyze-target.md +7 -4
  52. package/src/skf-brief-skill/references/confirm-brief.md +0 -1
  53. package/src/skf-brief-skill/references/gather-intent.md +22 -2
  54. package/src/skf-brief-skill/references/scope-definition.md +2 -1
  55. package/src/skf-brief-skill/references/step-auto-brief.md +188 -0
  56. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  57. package/src/skf-brief-skill/references/write-brief.md +2 -3
  58. package/src/skf-campaign/SKILL.md +190 -0
  59. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  60. package/src/skf-campaign/customize.toml +73 -0
  61. package/src/skf-campaign/manifest.yaml +11 -0
  62. package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
  63. package/src/skf-campaign/references/health-check.md +35 -0
  64. package/src/skf-campaign/references/step-01-setup.md +122 -0
  65. package/src/skf-campaign/references/step-02-strategy.md +97 -0
  66. package/src/skf-campaign/references/step-03-pins.md +56 -0
  67. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  68. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  69. package/src/skf-campaign/references/step-06-batch.md +87 -0
  70. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  71. package/src/skf-campaign/references/step-08-verify.md +75 -0
  72. package/src/skf-campaign/references/step-09-refine.md +83 -0
  73. package/src/skf-campaign/references/step-10-export.md +106 -0
  74. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  75. package/src/skf-campaign/references/step-resume.md +114 -0
  76. package/src/skf-campaign/scripts/.gitkeep +0 -0
  77. package/src/skf-campaign/scripts/campaign-deps.py +235 -0
  78. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  79. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  80. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  81. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  82. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  83. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  84. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  85. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  86. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  87. package/src/skf-create-skill/SKILL.md +4 -1
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
  89. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  90. package/src/skf-create-skill/references/compile.md +5 -2
  91. package/src/skf-create-skill/references/extract.md +9 -1
  92. package/src/skf-create-skill/references/extraction-patterns.md +3 -1
  93. package/src/skf-create-skill/references/generate-artifacts.md +11 -2
  94. package/src/skf-create-skill/references/health-check.md +2 -2
  95. package/src/skf-create-skill/references/report.md +17 -1
  96. package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
  97. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  98. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  99. package/src/skf-create-skill/references/step-doc-sources.md +123 -0
  100. package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
  101. package/src/skf-create-skill/references/validate.md +12 -3
  102. package/src/skf-drop-skill/SKILL.md +2 -2
  103. package/src/skf-drop-skill/references/execute.md +20 -9
  104. package/src/skf-drop-skill/references/report.md +2 -0
  105. package/src/skf-drop-skill/references/select.md +7 -2
  106. package/src/skf-forger/SKILL.md +15 -4
  107. package/src/skf-refine-architecture/SKILL.md +2 -1
  108. package/src/skf-refine-architecture/references/compile.md +1 -1
  109. package/src/skf-refine-architecture/references/report.md +1 -1
  110. package/src/skf-rename-skill/SKILL.md +2 -2
  111. package/src/skf-rename-skill/references/execute.md +5 -4
  112. package/src/skf-rename-skill/references/rebuild-context.md +2 -2
  113. package/src/skf-rename-skill/references/select.md +3 -3
  114. package/src/skf-setup/SKILL.md +1 -1
  115. package/src/skf-setup/references/auto-index.md +3 -1
  116. package/src/skf-setup/references/detect-and-tier.md +4 -0
  117. package/src/skf-setup/references/report.md +4 -1
  118. package/src/skf-setup/references/tier-rules.md +1 -1
  119. package/src/skf-test-skill/SKILL.md +8 -7
  120. package/src/skf-test-skill/customize.toml +2 -1
  121. package/src/skf-test-skill/references/coverage-check.md +10 -0
  122. package/src/skf-test-skill/references/external-validators.md +1 -1
  123. package/src/skf-test-skill/references/init.md +16 -1
  124. package/src/skf-test-skill/references/report.md +5 -1
  125. package/src/skf-test-skill/references/score.md +95 -6
  126. package/src/skf-test-skill/references/source-access-protocol.md +13 -3
  127. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  128. package/src/skf-test-skill/scripts/compute-score.py +4 -3
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/customize.toml +0 -9
  131. package/src/skf-update-skill/references/detect-changes.md +33 -3
  132. package/src/skf-update-skill/references/health-check.md +2 -2
  133. package/src/skf-update-skill/references/init.md +1 -0
  134. package/src/skf-update-skill/references/re-extract.md +15 -1
  135. package/src/skf-verify-stack/references/report.md +1 -1
  136. 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. *.csproj | *.sln csharp (high)
36
- 9. Gemfile ruby (high)
37
- 10. Extension-frequency fallback over the full tree.
35
+ 8. Package.swift swift (high)
36
+ 9. *.csproj | *.sln csharp (high)
37
+ 10. Gemfile → ruby (high)
38
+ 11. Extension-frequency fallback over the full tree.
38
39
  dominant extension >= 50% of code files → that language (medium)
39
40
  no clear winner → unknown (low)
40
41
 
@@ -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