bmad-module-skill-forge 1.7.0 → 1.9.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/docs/_data/pinned.yaml +1 -1
- package/docs/bmad-synergy.md +16 -0
- package/docs/deepwiki.md +89 -0
- package/docs/verifying-a-skill.md +8 -2
- package/docs/workflows.md +17 -11
- package/package.json +2 -2
- package/src/shared/_known-workarounds.yaml +155 -0
- package/src/shared/references/pipeline-contracts.md +11 -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-detect-docs.py +412 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-preapply.py +192 -0
- package/src/shared/scripts/skf-shape-detect.py +563 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +56 -5
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
- package/src/skf-analyze-source/references/identify-units.md +41 -4
- package/src/skf-analyze-source/references/init.md +36 -0
- package/src/skf-analyze-source/references/scan-project.md +5 -2
- package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
- package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +16 -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/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/references/gather-intent.md +14 -0
- package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
- package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
- package/src/skf-create-skill/SKILL.md +3 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -0
- package/src/skf-create-skill/references/compile.md +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +5 -10
- 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 +114 -0
- package/src/skf-create-stack-skill/references/generate-output.md +5 -5
- package/src/skf-forger/SKILL.md +8 -2
- package/src/skf-test-skill/SKILL.md +8 -7
- package/src/skf-test-skill/customize.toml +2 -1
- 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 +14 -0
- package/src/skf-test-skill/references/step-hard-gate.md +73 -0
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
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 python 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
|
+
_DOC_DOMAIN_RE = re.compile(
|
|
90
|
+
r"(?:docs\.|\.readthedocs\.|wiki\.|documentation\.)",
|
|
91
|
+
re.IGNORECASE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_DOC_PATH_RE = re.compile(
|
|
95
|
+
r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/)",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_DOC_TEXT_RE = re.compile(
|
|
100
|
+
r"(?:documentation|docs|api\s+reference|guide|wiki)",
|
|
101
|
+
re.IGNORECASE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_REJECT_URL_RE = re.compile(
|
|
105
|
+
r"(?:"
|
|
106
|
+
r"github\.com/[^/]+/[^/]+/(?:issues|pull|actions)"
|
|
107
|
+
r"|img\.shields\.io"
|
|
108
|
+
r"|badge"
|
|
109
|
+
r"|\.(?:svg|png|gif|jpg|jpeg)(?:\?|$)"
|
|
110
|
+
r"|travis-ci\."
|
|
111
|
+
r"|circleci\."
|
|
112
|
+
r"|npmjs\.com"
|
|
113
|
+
r"|pypi\.org"
|
|
114
|
+
r"|crates\.io"
|
|
115
|
+
r"|twitter\.com|x\.com"
|
|
116
|
+
r"|discord\.(?:gg|com)"
|
|
117
|
+
r"|slack\.com"
|
|
118
|
+
r")",
|
|
119
|
+
re.IGNORECASE,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Content type classification
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
_API_DOCS_RE = re.compile(r"(?:/api/|/reference/|/sdk/|api-docs|api\.)", re.IGNORECASE)
|
|
128
|
+
_GUIDE_RE = re.compile(
|
|
129
|
+
r"(?:/guide/|/tutorial/|/getting-started|/quickstart|/howto)",
|
|
130
|
+
re.IGNORECASE,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _classify_content_type(url: str) -> str:
|
|
135
|
+
if _API_DOCS_RE.search(url):
|
|
136
|
+
return "api-docs"
|
|
137
|
+
if _GUIDE_RE.search(url):
|
|
138
|
+
return "guide"
|
|
139
|
+
return "reference"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# URL helpers
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _is_excluded(url: str) -> bool:
|
|
147
|
+
return bool(_EXCLUSION_PATTERNS.search(url))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_github_self_url(url: str, owner: str, repo: str) -> bool:
|
|
151
|
+
return bool(re.match(
|
|
152
|
+
rf"https?://(?:www\.)?github\.com/{re.escape(owner)}/{re.escape(repo)}/?$",
|
|
153
|
+
url,
|
|
154
|
+
re.IGNORECASE,
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_doc_url(url: str, link_text: str = "") -> bool:
|
|
159
|
+
if _REJECT_URL_RE.search(url):
|
|
160
|
+
return False
|
|
161
|
+
if _DOC_DOMAIN_RE.search(url):
|
|
162
|
+
return True
|
|
163
|
+
if _DOC_PATH_RE.search(url):
|
|
164
|
+
return True
|
|
165
|
+
if link_text and _DOC_TEXT_RE.search(link_text):
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# gh CLI helper
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _run_gh(args: List[str]) -> Optional[str]:
|
|
175
|
+
try:
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
["gh"] + args,
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
)
|
|
181
|
+
if result.returncode == 0:
|
|
182
|
+
return result.stdout.strip()
|
|
183
|
+
return None
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _check_gh_available() -> bool:
|
|
189
|
+
try:
|
|
190
|
+
subprocess.run(
|
|
191
|
+
["gh", "--version"],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
)
|
|
195
|
+
return True
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Content hashing
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _fetch_and_hash(url: str) -> Optional[str]:
|
|
205
|
+
if url.startswith("file://"):
|
|
206
|
+
try:
|
|
207
|
+
local_path = url[7:]
|
|
208
|
+
with open(local_path, "rb") as fh:
|
|
209
|
+
content = fh.read()
|
|
210
|
+
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
211
|
+
except (OSError, IOError):
|
|
212
|
+
return None
|
|
213
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
214
|
+
try:
|
|
215
|
+
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
216
|
+
content = resp.read()
|
|
217
|
+
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
218
|
+
except Exception:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Detection method 1 — homepageUrl
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def _detect_homepage_url(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
227
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}", "--jq", ".homepage"])
|
|
228
|
+
if not raw or raw == "null":
|
|
229
|
+
return []
|
|
230
|
+
url = raw.strip()
|
|
231
|
+
if not url:
|
|
232
|
+
return []
|
|
233
|
+
if _is_github_self_url(url, owner, repo):
|
|
234
|
+
return []
|
|
235
|
+
return [{"url": url, "detected_via": "homepageUrl", "content_type": _classify_content_type(url)}]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Detection method 2 — README link scanning
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _detect_readme_links(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
243
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/readme"])
|
|
244
|
+
if not raw:
|
|
245
|
+
return []
|
|
246
|
+
try:
|
|
247
|
+
data = json.loads(raw)
|
|
248
|
+
except json.JSONDecodeError:
|
|
249
|
+
return []
|
|
250
|
+
encoded = data.get("content", "")
|
|
251
|
+
if not encoded:
|
|
252
|
+
return []
|
|
253
|
+
try:
|
|
254
|
+
readme_text = base64.b64decode(encoded).decode("utf-8", errors="replace")
|
|
255
|
+
except Exception:
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
urls_with_text: List[tuple] = []
|
|
259
|
+
for text, url in _MD_LINK_RE.findall(readme_text):
|
|
260
|
+
urls_with_text.append((url.strip(), text.strip()))
|
|
261
|
+
for url in _HTML_LINK_RE.findall(readme_text):
|
|
262
|
+
urls_with_text.append((url.strip(), ""))
|
|
263
|
+
for url in _BARE_URL_RE.findall(readme_text):
|
|
264
|
+
urls_with_text.append((url.strip(), ""))
|
|
265
|
+
|
|
266
|
+
results: List[Dict[str, Any]] = []
|
|
267
|
+
seen: set = set()
|
|
268
|
+
for url, text in urls_with_text:
|
|
269
|
+
if not url.startswith("http"):
|
|
270
|
+
continue
|
|
271
|
+
if url in seen:
|
|
272
|
+
continue
|
|
273
|
+
if _is_doc_url(url, text):
|
|
274
|
+
seen.add(url)
|
|
275
|
+
results.append({
|
|
276
|
+
"url": url,
|
|
277
|
+
"detected_via": "readme_link",
|
|
278
|
+
"content_type": _classify_content_type(url),
|
|
279
|
+
})
|
|
280
|
+
return results
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Detection method 3 — Pages API
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _detect_pages_api(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
288
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/pages", "--jq", ".html_url"])
|
|
289
|
+
if not raw or raw == "null":
|
|
290
|
+
return []
|
|
291
|
+
url = raw.strip()
|
|
292
|
+
if not url:
|
|
293
|
+
return []
|
|
294
|
+
return [{"url": url, "detected_via": "pages_api", "content_type": _classify_content_type(url)}]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# Detection method 4 — docs/ folder
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def _detect_docs_folder(owner: str, repo: str, local_path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
302
|
+
results: List[Dict[str, Any]] = []
|
|
303
|
+
if local_path:
|
|
304
|
+
docs_dir = Path(local_path) / "docs"
|
|
305
|
+
if docs_dir.is_dir():
|
|
306
|
+
for md_file in sorted(docs_dir.rglob("*.md")):
|
|
307
|
+
file_url = "file://" + md_file.as_posix()
|
|
308
|
+
results.append({
|
|
309
|
+
"url": file_url,
|
|
310
|
+
"detected_via": "docs_folder",
|
|
311
|
+
"content_type": _classify_content_type(md_file.as_posix()),
|
|
312
|
+
})
|
|
313
|
+
else:
|
|
314
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/contents/docs"])
|
|
315
|
+
if not raw:
|
|
316
|
+
return []
|
|
317
|
+
try:
|
|
318
|
+
entries = json.loads(raw)
|
|
319
|
+
except json.JSONDecodeError:
|
|
320
|
+
return []
|
|
321
|
+
if not isinstance(entries, list):
|
|
322
|
+
return []
|
|
323
|
+
for entry in entries:
|
|
324
|
+
name = entry.get("name", "")
|
|
325
|
+
if not name.lower().endswith(".md"):
|
|
326
|
+
continue
|
|
327
|
+
download_url = entry.get("download_url", "")
|
|
328
|
+
if download_url:
|
|
329
|
+
results.append({
|
|
330
|
+
"url": download_url,
|
|
331
|
+
"detected_via": "docs_folder",
|
|
332
|
+
"content_type": _classify_content_type(download_url),
|
|
333
|
+
})
|
|
334
|
+
return results
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Main detection orchestrator
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def detect(
|
|
342
|
+
repo_url: str,
|
|
343
|
+
local_path: Optional[str] = None,
|
|
344
|
+
skip_pages_api: bool = False,
|
|
345
|
+
) -> List[Dict[str, Any]]:
|
|
346
|
+
m = _GITHUB_URL_RE.match(repo_url.strip())
|
|
347
|
+
if not m:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
owner, repo = m.group(1), m.group(2)
|
|
351
|
+
|
|
352
|
+
all_results: List[Dict[str, Any]] = []
|
|
353
|
+
all_results.extend(_detect_homepage_url(owner, repo))
|
|
354
|
+
all_results.extend(_detect_readme_links(owner, repo))
|
|
355
|
+
if not skip_pages_api:
|
|
356
|
+
all_results.extend(_detect_pages_api(owner, repo))
|
|
357
|
+
all_results.extend(_detect_docs_folder(owner, repo, local_path))
|
|
358
|
+
|
|
359
|
+
filtered = [r for r in all_results if not _is_excluded(r["url"])]
|
|
360
|
+
|
|
361
|
+
seen: set = set()
|
|
362
|
+
deduped: List[Dict[str, Any]] = []
|
|
363
|
+
for r in filtered:
|
|
364
|
+
if r["url"] not in seen:
|
|
365
|
+
seen.add(r["url"])
|
|
366
|
+
deduped.append(r)
|
|
367
|
+
|
|
368
|
+
for entry in deduped:
|
|
369
|
+
entry["content_hash"] = _fetch_and_hash(entry["url"])
|
|
370
|
+
|
|
371
|
+
return deduped
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# CLI entry point
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def main() -> int:
|
|
379
|
+
parser = argparse.ArgumentParser(
|
|
380
|
+
description="Detect documentation URLs for a GitHub repository.",
|
|
381
|
+
)
|
|
382
|
+
parser.add_argument("--repo-url", required=True, help="GitHub repository URL")
|
|
383
|
+
parser.add_argument("--local-path", default=None, help="Local clone path for docs/ folder scan")
|
|
384
|
+
parser.add_argument("--skip-pages-api", action="store_true", help="Skip GitHub Pages API detection")
|
|
385
|
+
args = parser.parse_args()
|
|
386
|
+
|
|
387
|
+
if not _check_gh_available():
|
|
388
|
+
json.dump({"error": "gh CLI not found", "code": "GH_NOT_FOUND"}, sys.stderr)
|
|
389
|
+
sys.stderr.write("\n")
|
|
390
|
+
return 2
|
|
391
|
+
|
|
392
|
+
m = _GITHUB_URL_RE.match(args.repo_url.strip())
|
|
393
|
+
if not m:
|
|
394
|
+
json.dump({"error": f"Not a GitHub URL: {args.repo_url}", "code": "INVALID_URL"}, sys.stderr)
|
|
395
|
+
sys.stderr.write("\n")
|
|
396
|
+
return 2
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
results = detect(args.repo_url, args.local_path, args.skip_pages_api)
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
json.dump({"error": str(exc), "code": "DETECTION_ERROR"}, sys.stderr)
|
|
402
|
+
sys.stderr.write("\n")
|
|
403
|
+
return 2
|
|
404
|
+
|
|
405
|
+
json.dump(results, sys.stdout, separators=(",", ":"))
|
|
406
|
+
sys.stdout.write("\n")
|
|
407
|
+
|
|
408
|
+
return 0 if results else 1
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
raise SystemExit(main())
|
|
@@ -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")]
|
|
@@ -42,6 +42,17 @@ Subcommands:
|
|
|
42
42
|
collections without re-rendering the whole file in
|
|
43
43
|
prose.
|
|
44
44
|
|
|
45
|
+
register-ccc-index
|
|
46
|
+
Append-or-replace a single entry in the
|
|
47
|
+
`ccc_index_registry` array. Reads the entry as JSON on
|
|
48
|
+
stdin (must include `source_repo` and `skill_name`;
|
|
49
|
+
composite key `source_repo`+`skill_name` is the upsert
|
|
50
|
+
key — existing entry with the same composite key is
|
|
51
|
+
replaced, otherwise appended). All other forge-tier
|
|
52
|
+
state is preserved verbatim. Used by skf-create-skill
|
|
53
|
+
§6b to register CCC-indexed source paths without
|
|
54
|
+
re-rendering the whole file in prose.
|
|
55
|
+
|
|
45
56
|
clean-stale Two cleanup operations gated by flags:
|
|
46
57
|
--qmd-live-names a,b,c — remove qmd_collections
|
|
47
58
|
entries whose `name` is not in the comma-separated
|
|
@@ -74,6 +85,7 @@ system-wide:
|
|
|
74
85
|
uv run skf-forge-tier-rw.py read --target /path/forge-tier.yaml
|
|
75
86
|
echo '{...}' | uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml
|
|
76
87
|
uv run skf-forge-tier-rw.py init-prefs --target /path/preferences.yaml
|
|
88
|
+
echo '{...}' | uv run skf-forge-tier-rw.py register-ccc-index --target /path/forge-tier.yaml
|
|
77
89
|
uv run skf-forge-tier-rw.py clean-stale --target /path/forge-tier.yaml \\
|
|
78
90
|
--qmd-live-names foo-brief,bar-extraction --prune-missing-ccc-paths
|
|
79
91
|
"""
|
|
@@ -373,6 +385,61 @@ def cmd_register_qmd_collection(target: Path) -> None:
|
|
|
373
385
|
})
|
|
374
386
|
|
|
375
387
|
|
|
388
|
+
def cmd_register_ccc_index(target: Path) -> None:
|
|
389
|
+
raw = sys.stdin.read()
|
|
390
|
+
if not raw.strip():
|
|
391
|
+
_die(1, "register-ccc-index: empty stdin (expected JSON entry)")
|
|
392
|
+
try:
|
|
393
|
+
entry = json.loads(raw)
|
|
394
|
+
except json.JSONDecodeError as e:
|
|
395
|
+
_die(1, f"register-ccc-index: invalid JSON on stdin: {e}")
|
|
396
|
+
|
|
397
|
+
if not isinstance(entry, dict):
|
|
398
|
+
_die(1, "register-ccc-index: entry must be a JSON object")
|
|
399
|
+
source_repo = entry.get("source_repo")
|
|
400
|
+
skill_name = entry.get("skill_name")
|
|
401
|
+
if not source_repo or not isinstance(source_repo, str):
|
|
402
|
+
_die(1, "register-ccc-index: entry must include a non-empty 'source_repo' string")
|
|
403
|
+
if not skill_name or not isinstance(skill_name, str):
|
|
404
|
+
_die(1, "register-ccc-index: entry must include a non-empty 'skill_name' string")
|
|
405
|
+
|
|
406
|
+
data = _read_yaml(target)
|
|
407
|
+
if data is None:
|
|
408
|
+
_die(1, f"register-ccc-index: target does not exist: {target}. "
|
|
409
|
+
f"Run setup workflow first to create forge-tier.yaml.")
|
|
410
|
+
|
|
411
|
+
registry = list(data.get("ccc_index_registry") or [])
|
|
412
|
+
replaced = False
|
|
413
|
+
for i, existing in enumerate(registry):
|
|
414
|
+
if (isinstance(existing, dict)
|
|
415
|
+
and existing.get("source_repo") == source_repo
|
|
416
|
+
and existing.get("skill_name") == skill_name):
|
|
417
|
+
registry[i] = entry
|
|
418
|
+
replaced = True
|
|
419
|
+
break
|
|
420
|
+
if not replaced:
|
|
421
|
+
registry.append(entry)
|
|
422
|
+
|
|
423
|
+
payload = {
|
|
424
|
+
"tools": data.get("tools", {}),
|
|
425
|
+
"tier": data.get("tier", "Quick"),
|
|
426
|
+
"tier_detected_at": data.get("tier_detected_at",
|
|
427
|
+
datetime.now(timezone.utc).isoformat()),
|
|
428
|
+
"ccc_index": data.get("ccc_index", {}),
|
|
429
|
+
"ccc_index_registry": registry,
|
|
430
|
+
"qmd_collections": data.get("qmd_collections", []),
|
|
431
|
+
}
|
|
432
|
+
rendered = render_forge_tier_yaml(payload)
|
|
433
|
+
_atomic_write(target, rendered)
|
|
434
|
+
_ok({
|
|
435
|
+
"source_repo": source_repo,
|
|
436
|
+
"skill_name": skill_name,
|
|
437
|
+
"action": "replaced" if replaced else "appended",
|
|
438
|
+
"ccc_index_registry_count": len(registry),
|
|
439
|
+
"wrote": str(target),
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
|
|
376
443
|
def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
|
|
377
444
|
prune_missing_ccc_paths: bool) -> None:
|
|
378
445
|
data = _read_yaml(target)
|
|
@@ -468,6 +535,10 @@ def main() -> None:
|
|
|
468
535
|
help="Append-or-replace a single qmd_collections entry by name")
|
|
469
536
|
p_register.add_argument("--target", type=Path, required=True)
|
|
470
537
|
|
|
538
|
+
p_ccc = sub.add_parser("register-ccc-index",
|
|
539
|
+
help="Append-or-replace a single ccc_index_registry entry by source_repo+skill_name")
|
|
540
|
+
p_ccc.add_argument("--target", type=Path, required=True)
|
|
541
|
+
|
|
471
542
|
args = parser.parse_args()
|
|
472
543
|
|
|
473
544
|
if args.cmd == "read":
|
|
@@ -483,6 +554,8 @@ def main() -> None:
|
|
|
483
554
|
cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
|
|
484
555
|
elif args.cmd == "register-qmd-collection":
|
|
485
556
|
cmd_register_qmd_collection(args.target)
|
|
557
|
+
elif args.cmd == "register-ccc-index":
|
|
558
|
+
cmd_register_ccc_index(args.target)
|
|
486
559
|
|
|
487
560
|
|
|
488
561
|
if __name__ == "__main__":
|