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,368 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Validate Pins — resolve and validate version pins for a GitHub repository.
|
|
6
|
+
|
|
7
|
+
Shared pin validation script consumed by skf-analyze-source (AN auto-scope,
|
|
8
|
+
deepwiki --pin) and campaign workflows. Validates that a user-supplied --pin
|
|
9
|
+
resolves to an existing tag or branch, or auto-resolves the latest release tag
|
|
10
|
+
when no --pin is provided.
|
|
11
|
+
|
|
12
|
+
Tag matching priority mirrors source-resolution-protocols.md:
|
|
13
|
+
|
|
14
|
+
1. Exact match: {pin}
|
|
15
|
+
2. v-prefixed: v{pin}
|
|
16
|
+
3. Package scope: {name}@{pin}, @{scope}/{name}@{pin}
|
|
17
|
+
4. Crate prefix: {name}/v{pin}, {name}/{pin}, {name}-v{pin}
|
|
18
|
+
|
|
19
|
+
CLI:
|
|
20
|
+
uv run python src/shared/scripts/skf-validate-pins.py \\
|
|
21
|
+
--repo-url <url> [--pin <version>] [--format tag|branch|any]
|
|
22
|
+
|
|
23
|
+
Input:
|
|
24
|
+
--repo-url GitHub repository URL (required)
|
|
25
|
+
--pin Version pin to validate (optional — resolves latest release when absent)
|
|
26
|
+
--format Restrict matching to tag, branch, or any (optional, default: any)
|
|
27
|
+
|
|
28
|
+
Output (JSON on stdout):
|
|
29
|
+
{
|
|
30
|
+
"status": "valid" | "invalid" | "resolved",
|
|
31
|
+
"pin": "<input_pin_or_null>",
|
|
32
|
+
"resolved_ref": "<matched_tag_or_branch>",
|
|
33
|
+
"ref_type": "tag" | "branch",
|
|
34
|
+
"version": "<semver_or_null>",
|
|
35
|
+
"suggestions": ["<nearest_tags_on_invalid>"]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Exit codes:
|
|
39
|
+
0 valid/resolved (pin matches a tag or branch, or latest release resolved)
|
|
40
|
+
1 invalid (no match found for the supplied pin, or no releases for auto-resolve)
|
|
41
|
+
2 error (invalid args, gh not found, network error, etc.)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import re
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
52
|
+
|
|
53
|
+
_GITHUB_URL_RE = re.compile(
|
|
54
|
+
r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_SEMVER_RE = re.compile(
|
|
59
|
+
r"^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?(?:\+[a-zA-Z0-9.]+)?)$"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# gh CLI helpers
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _run_gh(args: List[str]) -> Optional[str]:
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["gh"] + args,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode == 0:
|
|
75
|
+
return result.stdout.strip()
|
|
76
|
+
return None
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_gh_available() -> bool:
|
|
82
|
+
try:
|
|
83
|
+
subprocess.run(
|
|
84
|
+
["gh", "--version"],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
)
|
|
88
|
+
return True
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_git(args: List[str]) -> Optional[str]:
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git"] + args,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
)
|
|
100
|
+
if result.returncode == 0:
|
|
101
|
+
return result.stdout.strip()
|
|
102
|
+
return None
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Version extraction
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def extract_version(tag: str) -> Optional[str]:
|
|
112
|
+
m = _SEMVER_RE.match(tag)
|
|
113
|
+
if m:
|
|
114
|
+
return m.group(1)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Tag listing
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def list_tags(owner: str, repo: str, repo_url: str) -> List[str]:
|
|
123
|
+
raw = _run_gh([
|
|
124
|
+
"api", f"repos/{owner}/{repo}/tags",
|
|
125
|
+
"--paginate", "--jq", ".[].name",
|
|
126
|
+
])
|
|
127
|
+
if raw:
|
|
128
|
+
return [t for t in raw.splitlines() if t.strip()]
|
|
129
|
+
|
|
130
|
+
raw = _run_git(["ls-remote", "--tags", repo_url])
|
|
131
|
+
if raw:
|
|
132
|
+
tags = []
|
|
133
|
+
for line in raw.splitlines():
|
|
134
|
+
parts = line.split("refs/tags/")
|
|
135
|
+
if len(parts) == 2:
|
|
136
|
+
tag = parts[1].strip()
|
|
137
|
+
if not tag.endswith("^{}"):
|
|
138
|
+
tags.append(tag)
|
|
139
|
+
return tags
|
|
140
|
+
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Branch verification
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def check_branch_exists(repo_url: str, branch: str) -> bool:
|
|
149
|
+
raw = _run_git(["ls-remote", "--heads", repo_url, branch])
|
|
150
|
+
if raw and branch in raw:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Tag matching (mirrors source-resolution-protocols.md)
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _derive_name_from_url(owner: str, repo: str) -> str:
|
|
160
|
+
return repo.lower()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def match_tag(pin: str, tags: List[str], owner: str, repo: str) -> Optional[str]:
|
|
164
|
+
name = _derive_name_from_url(owner, repo)
|
|
165
|
+
|
|
166
|
+
candidates = [
|
|
167
|
+
pin,
|
|
168
|
+
f"v{pin}",
|
|
169
|
+
f"{name}@{pin}",
|
|
170
|
+
f"{name}/v{pin}",
|
|
171
|
+
f"{name}/{pin}",
|
|
172
|
+
f"{name}-v{pin}",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
for candidate in candidates:
|
|
176
|
+
if candidate in tags:
|
|
177
|
+
return candidate
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Suggestion generation
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _semver_sort_key(tag: str) -> Tuple:
|
|
187
|
+
v = extract_version(tag)
|
|
188
|
+
if v is None:
|
|
189
|
+
return (0, 0, 0, tag)
|
|
190
|
+
parts = v.split("-")[0].split(".")
|
|
191
|
+
try:
|
|
192
|
+
return tuple(int(p) for p in parts[:3])
|
|
193
|
+
except ValueError:
|
|
194
|
+
return (0, 0, 0, tag)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def generate_suggestions(pin: str, tags: List[str], max_count: int = 5) -> List[str]:
|
|
198
|
+
if not tags:
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
prefixes_to_try = []
|
|
202
|
+
if "." in pin:
|
|
203
|
+
prefixes_to_try.append(pin.rsplit(".", 1)[0] + ".")
|
|
204
|
+
prefixes_to_try.append(pin.split(".")[0])
|
|
205
|
+
prefixes_to_try.append(pin)
|
|
206
|
+
|
|
207
|
+
for prefix in prefixes_to_try:
|
|
208
|
+
prefix_matches = [t for t in tags if t.startswith(prefix)]
|
|
209
|
+
if prefix_matches:
|
|
210
|
+
sorted_matches = sorted(prefix_matches, key=_semver_sort_key, reverse=True)
|
|
211
|
+
return sorted_matches[:max_count]
|
|
212
|
+
|
|
213
|
+
sorted_tags = sorted(tags, key=_semver_sort_key, reverse=True)
|
|
214
|
+
return sorted_tags[:max_count]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Latest release resolution
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def resolve_latest_release(owner: str, repo: str) -> Optional[str]:
|
|
222
|
+
raw = _run_gh([
|
|
223
|
+
"api", f"repos/{owner}/{repo}/releases/latest",
|
|
224
|
+
"--jq", ".tag_name",
|
|
225
|
+
])
|
|
226
|
+
if raw and raw != "null":
|
|
227
|
+
return raw.strip()
|
|
228
|
+
|
|
229
|
+
raw = _run_gh([
|
|
230
|
+
"api", f"repos/{owner}/{repo}/tags",
|
|
231
|
+
"--jq", ".[0].name",
|
|
232
|
+
])
|
|
233
|
+
if raw and raw != "null":
|
|
234
|
+
return raw.strip()
|
|
235
|
+
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Core validation logic
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def validate_pin(
|
|
244
|
+
repo_url: str,
|
|
245
|
+
pin: Optional[str] = None,
|
|
246
|
+
format_filter: str = "any",
|
|
247
|
+
) -> Dict[str, Any]:
|
|
248
|
+
m = _GITHUB_URL_RE.match(repo_url.strip())
|
|
249
|
+
if not m:
|
|
250
|
+
return {
|
|
251
|
+
"status": "invalid",
|
|
252
|
+
"pin": pin,
|
|
253
|
+
"resolved_ref": None,
|
|
254
|
+
"ref_type": None,
|
|
255
|
+
"version": None,
|
|
256
|
+
"suggestions": [],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
owner, repo = m.group(1), m.group(2)
|
|
260
|
+
|
|
261
|
+
if pin is None:
|
|
262
|
+
tag = resolve_latest_release(owner, repo)
|
|
263
|
+
if tag is None:
|
|
264
|
+
return {
|
|
265
|
+
"status": "invalid",
|
|
266
|
+
"pin": None,
|
|
267
|
+
"resolved_ref": None,
|
|
268
|
+
"ref_type": None,
|
|
269
|
+
"version": None,
|
|
270
|
+
"suggestions": [],
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
"status": "resolved",
|
|
274
|
+
"pin": None,
|
|
275
|
+
"resolved_ref": tag,
|
|
276
|
+
"ref_type": "tag",
|
|
277
|
+
"version": extract_version(tag),
|
|
278
|
+
"suggestions": [],
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
tags: List[str] = []
|
|
282
|
+
if format_filter in ("tag", "any"):
|
|
283
|
+
tags = list_tags(owner, repo, repo_url)
|
|
284
|
+
matched = match_tag(pin, tags, owner, repo)
|
|
285
|
+
if matched:
|
|
286
|
+
return {
|
|
287
|
+
"status": "valid",
|
|
288
|
+
"pin": pin,
|
|
289
|
+
"resolved_ref": matched,
|
|
290
|
+
"ref_type": "tag",
|
|
291
|
+
"version": extract_version(matched),
|
|
292
|
+
"suggestions": [],
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if format_filter in ("branch", "any"):
|
|
296
|
+
if check_branch_exists(repo_url, pin):
|
|
297
|
+
return {
|
|
298
|
+
"status": "valid",
|
|
299
|
+
"pin": pin,
|
|
300
|
+
"resolved_ref": pin,
|
|
301
|
+
"ref_type": "branch",
|
|
302
|
+
"version": None,
|
|
303
|
+
"suggestions": [],
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if not tags:
|
|
307
|
+
tags = list_tags(owner, repo, repo_url)
|
|
308
|
+
suggestions = generate_suggestions(pin, tags)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"status": "invalid",
|
|
312
|
+
"pin": pin,
|
|
313
|
+
"resolved_ref": None,
|
|
314
|
+
"ref_type": None,
|
|
315
|
+
"version": None,
|
|
316
|
+
"suggestions": suggestions,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# CLI entry point
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def main() -> int:
|
|
325
|
+
parser = argparse.ArgumentParser(
|
|
326
|
+
description="Validate and resolve version pins for a GitHub repository.",
|
|
327
|
+
)
|
|
328
|
+
parser.add_argument("--repo-url", required=True, help="GitHub repository URL")
|
|
329
|
+
parser.add_argument("--pin", default=None, help="Version pin to validate")
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"--format",
|
|
332
|
+
choices=["tag", "branch", "any"],
|
|
333
|
+
default="any",
|
|
334
|
+
help="Restrict matching to tag, branch, or any (default: any)",
|
|
335
|
+
)
|
|
336
|
+
args = parser.parse_args()
|
|
337
|
+
|
|
338
|
+
if not _check_gh_available():
|
|
339
|
+
json.dump({"error": "gh CLI not found", "code": "GH_NOT_FOUND"}, sys.stderr)
|
|
340
|
+
sys.stderr.write("\n")
|
|
341
|
+
return 2
|
|
342
|
+
|
|
343
|
+
m = _GITHUB_URL_RE.match(args.repo_url.strip())
|
|
344
|
+
if not m:
|
|
345
|
+
json.dump(
|
|
346
|
+
{"error": f"Not a GitHub URL: {args.repo_url}", "code": "INVALID_URL"},
|
|
347
|
+
sys.stderr,
|
|
348
|
+
)
|
|
349
|
+
sys.stderr.write("\n")
|
|
350
|
+
return 2
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
result = validate_pin(args.repo_url, args.pin, args.format)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
json.dump({"error": str(exc), "code": "VALIDATION_ERROR"}, sys.stderr)
|
|
356
|
+
sys.stderr.write("\n")
|
|
357
|
+
return 2
|
|
358
|
+
|
|
359
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
360
|
+
sys.stdout.write("\n")
|
|
361
|
+
|
|
362
|
+
if result["status"] == "invalid":
|
|
363
|
+
return 1
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
raise SystemExit(main())
|
|
@@ -31,26 +31,32 @@ These rules apply to every step in this workflow:
|
|
|
31
31
|
|
|
32
32
|
## Stages
|
|
33
33
|
|
|
34
|
-
| # | Step | File | Auto-proceed |
|
|
35
|
-
|
|
36
|
-
| 1 | Initialize | references/init.md | Yes |
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
34
|
+
| # | Step | File | Auto-proceed | Condition |
|
|
35
|
+
|---|------|------|--------------|-----------|
|
|
36
|
+
| 1 | Initialize | references/init.md | Yes | Always |
|
|
37
|
+
| 1a | Auto-Scope | references/step-auto-scope.md | Yes | `[auto]` mode only. Includes §0b pin resolution — validates and resolves version pin before scoping. Includes §0c coexistence detection — checks for existing skills before proceeding. Includes §0 docs-only URL detection — doc URLs short-circuit auto-scope entirely |
|
|
38
|
+
| 1b | Continue (session resume) | references/continue.md | Yes | Always |
|
|
39
|
+
| 2 | Scan Project | references/scan-project.md | No (confirm) | Interactive mode only |
|
|
40
|
+
| 3 | Identify Units | references/identify-units.md | No (confirm) | Interactive mode only |
|
|
41
|
+
| 4 | Map & Detect | references/map-and-detect.md | Yes | Interactive mode only |
|
|
42
|
+
| 5 | Recommend | references/recommend.md | No (confirm) | Interactive mode only |
|
|
43
|
+
| 6 | Generate Briefs | references/generate-briefs.md | Yes | Interactive mode only |
|
|
44
|
+
| 7 | Workflow Health Check | references/health-check.md | Yes | Always |
|
|
45
|
+
|
|
46
|
+
**Auto mode path:** When `[auto]` flag is present, init (step 1) routes directly to step 1a, which performs manifest scan → shape detection → scope generation → brief write → health check, bypassing steps 2–6. After URL type detection (§0), pin resolution (§0b) validates the `--pin` argument (or resolves the latest release tag) and stores the resolved ref for downstream brief writes. Coexistence detection (§0c) then checks for existing skills matching the target. If found, the user chooses alongside/merge/skip. Headless mode auto-selects alongside. Auto-scope may produce N > 1 confirmed units when decomposition thresholds are met (`export_count > 500` `[PENDING VALIDATION]` or `package_count > 3` `[PENDING VALIDATION]`), resulting in N briefs and N `brief_paths` in the envelope. When the target is a documentation URL (not a GitHub repo or local path), auto-scope detects the docs-only input at §0, validates URL reachability, writes a docs-only brief, and emits the envelope without performing source analysis.
|
|
47
|
+
|
|
48
|
+
**Shape detection reference:** `references/step-shape-detect.md` — loaded by step 1a as a reference doc (not a chained step).
|
|
44
49
|
|
|
45
50
|
## Invocation Contract
|
|
46
51
|
|
|
47
52
|
| Aspect | Detail |
|
|
48
53
|
|--------|--------|
|
|
49
|
-
| **Inputs** | project_path [required], scope_hint [optional] |
|
|
50
|
-
| **Headless inputs** | `--project-path <path>` (skip Step 1 project-path prompt), `--scope-hint <text>` (skip Step 1 scope-hint prompt), `--intent-hint <text>` (pre-supply analysis intent; drives recommendation ranking in Step 5) |
|
|
54
|
+
| **Inputs** | project_path [required], scope_hint [optional]. `project_path` can be a GitHub repo URL, a local filesystem path, or a documentation URL for docs-only mode. The URL type heuristic at §0 determines the mode. |
|
|
55
|
+
| **Headless inputs** | `--project-path <path>` (skip Step 1 project-path prompt; accepts documentation URLs for docs-only mode), `--scope-hint <text>` (skip Step 1 scope-hint prompt), `--intent-hint <text>` (pre-supply analysis intent; drives recommendation ranking in Step 5), `--pin <version>` (pin to a specific version tag or branch; accepts semver tags, git tags, and branch names; when absent, resolves to the latest release tag) |
|
|
51
56
|
| **Headless flag** | `--headless` / `-H` flips every confirm gate to auto-proceed |
|
|
52
|
-
| **
|
|
53
|
-
| **
|
|
57
|
+
| **Auto flag** | `[auto]` bracket modifier — activates auto-scope mode (step 1a). Pipelines pass this as `AN[auto]`. When active, init routes to `step-auto-scope.md` which performs shape detection → scope generation → brief write, bypassing interactive steps 2–6. Requires `--project-path`. |
|
|
58
|
+
| **Gates** | step 2: Confirm Gate [C] | step 3: Confirm Gate [C] | step 5: Confirm Gate [C] (all skipped in auto mode) |
|
|
59
|
+
| **Outputs** | analysis-report.md, skill-brief.yaml files (one per recommended unit); final `SKF_ANALYZE_RESULT_JSON` line on stdout when `{headless_mode}` is true. In auto mode, the envelope includes `"mode":"auto"`. |
|
|
54
60
|
| **Headless** | All gates auto-resolve with default action when `{headless_mode}` is true |
|
|
55
61
|
| **Exit codes** | See "Exit Codes" below |
|
|
56
62
|
|
|
@@ -60,21 +66,21 @@ Every HARD HALT in this workflow exits with a stable code so headless automators
|
|
|
60
66
|
|
|
61
67
|
| Code | Meaning | Raised by |
|
|
62
68
|
| ---- | -------------------- | ------------------------------------------------------------------------------------------ |
|
|
63
|
-
| 0 | success
|
|
64
|
-
| 2 | input-missing | step 1 §2-3 — required config absent (config.yaml not loadable, project path empty/invalid in headless mode) |
|
|
65
|
-
| 3 | resolution-failure | step 1 §2 (`forge-tier.yaml` missing at `{sidecar_path}/forge-tier.yaml`); step 1 §3 (project path does not exist or remote URL inaccessible) |
|
|
69
|
+
| 0 | success / skipped / redirect | step 7 (terminal — health check completion); also covers coexistence `"skipped"` and `"redirect"` statuses from §0c |
|
|
70
|
+
| 2 | input-missing | step 1 §2-3 — required config absent (config.yaml not loadable, project path empty/invalid in headless mode); step 1 §2b — auto mode without `--project-path` |
|
|
71
|
+
| 3 | resolution-failure | step 1 §2 (`forge-tier.yaml` missing at `{sidecar_path}/forge-tier.yaml`); step 1 §3 (project path does not exist or remote URL inaccessible); step 1a §0a (docs-only URL unreachable); step 1a §0b (pin validation failure — `halt_reason: "pin-invalid"` when the supplied `--pin` does not match any tag or branch); step 1a §3 (shape detection script error, exit code 2) |
|
|
66
72
|
| 4 | write-failure | step 1 §6 (analysis report write failed); step 6 §5 (skill-brief.yaml write failed); step 6 §9 (result contract write failed) |
|
|
67
73
|
| 6 | user-cancelled | any interactive menu in steps 2/3/5/6 (user selected `[X]` Cancel and exit) |
|
|
68
74
|
|
|
69
75
|
## Result Contract (Headless)
|
|
70
76
|
|
|
71
|
-
When `{headless_mode}` is true, step 6 emits a single-line JSON envelope on **stdout** before chaining to step 7, and every HARD HALT emits the same envelope shape on **stderr** with `status: "error"`:
|
|
77
|
+
When `{headless_mode}` is true, step 6 (interactive) or step 1a (auto) emits a single-line JSON envelope on **stdout** before chaining to step 7, and every HARD HALT emits the same envelope shape on **stderr** with `status: "error"`:
|
|
72
78
|
|
|
73
79
|
```
|
|
74
|
-
SKF_ANALYZE_RESULT_JSON: {"status":"success|error","report_path":"…|null","brief_paths":["…"],"unit_counts":{"confirmed":N,"skipped":N,"maybe":N},"exit_code":0,"halt_reason":null}
|
|
80
|
+
SKF_ANALYZE_RESULT_JSON: {"status":"success|error","report_path":"…|null","brief_paths":["…"],"unit_counts":{"confirmed":N,"skipped":N,"maybe":N},"exit_code":0,"halt_reason":null,"mode":"interactive|auto"}
|
|
75
81
|
```
|
|
76
82
|
|
|
77
|
-
`status` is `"success"` on the terminal happy path, `"error"` on any HALT. `halt_reason` is one of: `null` (success), `"input-missing"`, `"forge-tier-missing"`, `"path-invalid"`, `"write-failed"`, `"user-cancelled"`. `exit_code` matches the table above. `brief_paths` is an array of absolute paths to every generated `skill-brief.yaml` (empty array if none were generated). `unit_counts` reports confirmed/skipped/maybe counts from step 5's user decisions.
|
|
83
|
+
`status` is `"success"` on the terminal happy path, `"error"` on any HALT, `"redirect"` when coexistence detection routes to US (merge), or `"skipped"` when the user skips a conflicting target. `halt_reason` is one of: `null` (success), `"input-missing"`, `"forge-tier-missing"`, `"path-invalid"`, `"pin-invalid"`, `"write-failed"`, `"user-cancelled"`. `exit_code` matches the table above. `brief_paths` is an array of absolute paths to every generated `skill-brief.yaml` (empty array if none were generated). `unit_counts` reports confirmed/skipped/maybe counts from step 5's user decisions. `mode` is `"auto"` when the `[auto]` flag was active, `"interactive"` otherwise (omitting `mode` is equivalent to `"interactive"` for backward compatibility). The `coexistence` field (present when §0c triggers) is `"alongside"`, `"merge"`, or `"skip"`, indicating the user's coexistence decision. The `pinned_ref` and `pinned_version` fields (present when §0b resolves a pin) record the resolved git ref and extracted semver version for provenance tracking.
|
|
78
84
|
|
|
79
85
|
## On Activation
|
|
80
86
|
|
|
@@ -27,7 +27,8 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
|
|
|
27
27
|
| `scripts_intent` | string | `detect` / `none` / free-text | Describes whether scripts should be extracted. Values: `detect` (auto-detect from source — default when absent), `none` (skip scripts), or a free-text description of expected scripts (e.g., "CLI validation tools in bin/"). |
|
|
28
28
|
| `assets_intent` | string | `detect` / `none` / free-text | Describes whether assets should be extracted. Values: `detect` (auto-detect from source — default when absent), `none` (skip assets), or a free-text description of expected assets (e.g., "JSON schemas in schemas/"). |
|
|
29
29
|
| `target_version` | string | Semantic version (`X.Y.Z` or `X.Y.Z-prerelease`) | User-specified target version. When present, overrides auto-detection and becomes the skill's version. Recommended for docs-only skills where auto-detection is unavailable. |
|
|
30
|
-
| `target_ref` | string | Git ref (tag or branch) | Optional. Explicit git ref used verbatim as the resolved `source_ref`, bypassing version-to-tag matching. Escape hatch for monorepo crate tags whose prefix differs from the skill name (e.g. tag `livekit/v0.7.42` for skill `livekit-rust`). Remote sources only. |
|
|
30
|
+
| `target_ref` | string | Git ref (tag or branch) | Optional. Explicit git ref used verbatim as the resolved `source_ref`, bypassing version-to-tag matching. Escape hatch for monorepo crate tags whose prefix differs from the skill name (e.g. tag `livekit/v0.7.42` for skill `livekit-rust`). Remote sources only. Mutually exclusive with `constituent_refs`. |
|
|
31
|
+
| `constituent_refs` | object | map of path→ref strings | Optional. Per-constituent git ref overrides for composite (multi-repo or multi-ref) sources. Keys must match entries in the analyze-source `project_paths` array. When absent, a single `target_ref` (or auto-detected ref) applies to all paths. Mutually exclusive with `target_ref`. |
|
|
31
32
|
| `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
|
|
32
33
|
| `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
|
|
33
34
|
|
|
@@ -48,6 +48,7 @@ For EACH detected boundary from the scan:
|
|
|
48
48
|
- Package Boundary — workspace member or independently versioned
|
|
49
49
|
- Module Boundary — logical grouping within a package
|
|
50
50
|
- Library Boundary — third-party with significant project-specific usage
|
|
51
|
+
- Composite Boundary — ≥2 boundaries that only deliver value together (detected in §3b below; not assigned during initial per-boundary classification)
|
|
51
52
|
|
|
52
53
|
**Step C — Assign scope type:**
|
|
53
54
|
- `full-library` — entire codebase of the unit
|
|
@@ -105,9 +106,28 @@ For disqualified candidates, note reason:
|
|
|
105
106
|
|------|--------|
|
|
106
107
|
| {path} | {disqualification reason} |
|
|
107
108
|
|
|
109
|
+
### 3b. Detect Composite Unit Merges
|
|
110
|
+
|
|
111
|
+
After building the classification table, apply the Composite Boundary detection heuristic from {heuristicsFile} against the qualifying units:
|
|
112
|
+
|
|
113
|
+
1. **Scan for merge candidates:** Among the qualifying units (from `kept[]`), find groups of ≥2 Package or Module boundaries that meet EITHER trigger:
|
|
114
|
+
- **Mutual hard dependency:** Every constituent imports from at least one other constituent in the group, AND no constituent's public API is self-contained
|
|
115
|
+
- **Shared integration surface:** Constituents share types/traits defined in one constituent but consumed by all others, AND the consuming constituents have no independent barrel
|
|
116
|
+
|
|
117
|
+
2. **If candidate groups are found**, propose each merge:
|
|
118
|
+
- Derive a composite name from the common namespace prefix or repo name
|
|
119
|
+
- List the constituents (boundary names and paths being merged)
|
|
120
|
+
- State the triggering heuristic and evidence
|
|
121
|
+
|
|
122
|
+
3. **If no candidate groups are found**, skip to §4.
|
|
123
|
+
|
|
124
|
+
**Merge does NOT fire for:** Units already flagged as Stack Skill Candidates in step 4 (map-and-detect §5) — those are multi-unit groupings that deliver value *separately* but are *also* useful together. Composite merges are for units that are *only* useful together (the key distinction). If a group of units is independently useful but commonly combined, it remains as separate units and is flagged as a stack skill candidate later.
|
|
125
|
+
|
|
126
|
+
**This step is a recommendation — not automatic.** Merges are presented to the user in §5 for confirmation (see "Composite Merge Proposals" below). If the user rejects a merge, the constituents remain as separate units in the classification table.
|
|
127
|
+
|
|
108
128
|
### 4. Detect Primary Language Per Unit
|
|
109
129
|
|
|
110
|
-
For each qualifying unit, determine the primary programming language based on:
|
|
130
|
+
For each qualifying unit (including any approved composites from §3b), determine the primary programming language based on:
|
|
111
131
|
- File extensions in the unit directory
|
|
112
132
|
- Manifest file type (package.json → JS/TS, Cargo.toml → Rust, go.mod → Go, etc.)
|
|
113
133
|
- Entry point file extension
|
|
@@ -126,20 +146,34 @@ For each qualifying unit, determine the primary programming language based on:
|
|
|
126
146
|
**Already-Skilled Units:** {count from existing_skills match}
|
|
127
147
|
{List with recommendation to run update-skill if source has changed}
|
|
128
148
|
|
|
149
|
+
{IF composite merge proposals exist from §3b:}
|
|
150
|
+
|
|
151
|
+
**Composite Merge Proposals:** {count}
|
|
152
|
+
|
|
153
|
+
| # | Composite Name | Constituents | Heuristic | Evidence |
|
|
154
|
+
|---|----------------|--------------|-----------|----------|
|
|
155
|
+
| 1 | {name} | {list of constituent unit names} | {mutual hard dependency / shared integration surface} | {brief evidence} |
|
|
156
|
+
|
|
157
|
+
If approved, each composite replaces its constituents in the classification table as a single Composite Boundary unit. The constituents are recorded in the composite's metadata for downstream workflows (create-skill reads constituents to scope extraction across all member paths).
|
|
158
|
+
|
|
159
|
+
{END IF}
|
|
160
|
+
|
|
129
161
|
**Notes:**
|
|
130
162
|
- {Any observations about project structure patterns}
|
|
131
163
|
- {Any ambiguous boundaries that need user clarification}
|
|
132
164
|
|
|
133
|
-
Do these classifications look correct? Should any units be added, removed, or reclassified?
|
|
165
|
+
Do these classifications look correct? Should any units be added, removed, or reclassified?
|
|
166
|
+
{IF composites proposed:} Are the composite merge proposals correct? (Accept/reject each individually.)"
|
|
134
167
|
|
|
135
|
-
Wait for user feedback. Adjust classifications based on user input.
|
|
168
|
+
Wait for user feedback. Adjust classifications based on user input. For approved composites: remove the constituent rows from the qualifying units table and add a single composite row with `Boundary Type: Composite`, scope type inherited from the dominant constituent, and confidence reflecting the merge heuristic strength.
|
|
136
169
|
|
|
137
170
|
### 6. Append to Report
|
|
138
171
|
|
|
139
172
|
Append the complete "## Identified Units" section to {outputFile}:
|
|
140
173
|
|
|
141
174
|
Replace the placeholder `[Appended by identify-units]` with:
|
|
142
|
-
- Classification table (qualifying units)
|
|
175
|
+
- Classification table (qualifying units, including approved composites)
|
|
176
|
+
- Composite merge details (if any): composite name, constituents list, heuristic, evidence
|
|
143
177
|
- Disqualification table
|
|
144
178
|
- Already-skilled units list
|
|
145
179
|
- Language detection results
|
|
@@ -149,8 +183,11 @@ Update {outputFile} frontmatter:
|
|
|
149
183
|
```yaml
|
|
150
184
|
stepsCompleted: [append 'identify-units' to existing array]
|
|
151
185
|
lastStep: 'identify-units'
|
|
186
|
+
confirmed_composites: [{list of approved composite merge objects: {name, constituents[], heuristic}}]
|
|
152
187
|
```
|
|
153
188
|
|
|
189
|
+
(`confirmed_composites` is an empty array when no composites were proposed or all were rejected.)
|
|
190
|
+
|
|
154
191
|
### 7. Present MENU OPTIONS
|
|
155
192
|
|
|
156
193
|
Display: "**Select:** [C] Continue to Export Mapping and Integration Detection | [X] Cancel and exit"
|
|
@@ -46,10 +46,42 @@ Look for {outputFile}.
|
|
|
46
46
|
|
|
47
47
|
"**Forge tier detected:** {tier} — analysis depth will be calibrated accordingly."
|
|
48
48
|
|
|
49
|
+
### 2b. Auto Mode Check
|
|
50
|
+
|
|
51
|
+
**Check for `[auto]` flag:** If `[auto]` was passed as a bracket modifier in the pipeline context (e.g., `AN[auto]`), set `{auto_mode}` = true.
|
|
52
|
+
|
|
53
|
+
**IF `{auto_mode}` is true:**
|
|
54
|
+
|
|
55
|
+
1. **Resolve project path:** If `project_paths[]` is already populated (from §1 continuation detection or `--project-path` arg), use it. Otherwise, if `--project-path <path>` was passed at invocation, set `project_paths[]` from it (comma-split if multiple). If neither is available, HARD HALT with exit code 2 (`input-missing`): "**Auto mode requires `--project-path` — no project path available.**"
|
|
56
|
+
2. **Validate the path(s):** For each provided path/URL, check that it exists (local) or is accessible (remote). If any invalid: HARD HALT with exit code 3 (`resolution-failure`): "**Path `{path}` doesn't appear to be valid.**"
|
|
57
|
+
3. **Create the analysis report** from {templateFile}. Populate frontmatter:
|
|
58
|
+
```yaml
|
|
59
|
+
stepsCompleted: ['init']
|
|
60
|
+
lastStep: 'init'
|
|
61
|
+
lastContinued: ''
|
|
62
|
+
date: '{current_date}'
|
|
63
|
+
user_name: '{user_name}'
|
|
64
|
+
project_name: '{project_name}'
|
|
65
|
+
project_paths: ['{provided_project_path}']
|
|
66
|
+
forge_tier: '{detected_tier}'
|
|
67
|
+
existing_skills: []
|
|
68
|
+
confirmed_units: []
|
|
69
|
+
stack_skill_candidates: []
|
|
70
|
+
nextWorkflow: ''
|
|
71
|
+
mode: 'auto'
|
|
72
|
+
```
|
|
73
|
+
4. "**Auto mode activated — bypassing interactive analysis.**"
|
|
74
|
+
5. **Route to auto-scope:** Load, read fully, then execute `references/step-auto-scope.md`. **STOP HERE** — do not continue to §3 or any subsequent section.
|
|
75
|
+
|
|
76
|
+
**IF `{auto_mode}` is NOT true:**
|
|
77
|
+
Continue to §3 as normal — the entire interactive flow below is unchanged.
|
|
78
|
+
|
|
49
79
|
### 3. Collect Project Path
|
|
50
80
|
|
|
51
81
|
**Headless flag consumption:** If `project_paths[]` is already populated (e.g. collected by the section-1 stale-collision guard) OR `--project-path <path>` was passed at invocation, set/keep `project_paths[]` (comma-split the flag value if multiple paths were supplied), skip the prompt below, and proceed to validation. Otherwise prompt as today.
|
|
52
82
|
|
|
83
|
+
**Per-path ref overrides (`--target-refs`):** If `--target-refs <mapping>` was passed at invocation, parse it as a comma-separated list of `path:ref` pairs (e.g., `owner/repo:v1.0.0,owner/repo2:main`). Build a `constituent_refs` map from the pairs. Each key must match an entry in `project_paths[]` (validated after path collection). When `--target-refs` is absent but multiple `project_paths` exist, set `constituent_refs` to `{}` (empty — all paths use default ref resolution). When only a single path exists, omit `constituent_refs` entirely (use `target_ref` if set on the brief). `constituent_refs` and `target_ref` are mutually exclusive — if both are supplied, HALT with: "`--target-refs` and `--target-ref` are mutually exclusive. Use `--target-refs` for multi-path analysis, or `--target-ref` for single-path."
|
|
84
|
+
|
|
53
85
|
"**Welcome to Analyze Source — the SKF decomposition engine.**
|
|
54
86
|
|
|
55
87
|
I'll analyze your project to identify discrete skillable units and produce skill-brief.yaml files for each recommended unit.
|
|
@@ -71,6 +103,7 @@ Wait for user input.
|
|
|
71
103
|
- For each provided path/URL: check that it exists (local) or is accessible (remote)
|
|
72
104
|
- **IF any invalid:** "Path `{path}` doesn't appear to be valid. Please correct it."
|
|
73
105
|
- Store as `project_paths[]` array in report frontmatter (single path stored as 1-element array for consistency)
|
|
106
|
+
- **IF `constituent_refs` was built from `--target-refs`:** Validate that every key in the map matches an entry in `project_paths[]`. If any key has no matching path, HALT: "constituent_refs key `{key}` does not match any entry in project_paths."
|
|
74
107
|
|
|
75
108
|
**Collect intent hint** (drives recommendation ranking in Step 5):
|
|
76
109
|
|
|
@@ -127,6 +160,7 @@ date: '{current_date}'
|
|
|
127
160
|
user_name: '{user_name}'
|
|
128
161
|
project_name: '{project_name}'
|
|
129
162
|
project_paths: ['{provided_project_path}']
|
|
163
|
+
constituent_refs: {map from --target-refs, or omit if single path}
|
|
130
164
|
forge_tier: '{detected_tier}'
|
|
131
165
|
existing_skills: [{list of existing skill names}]
|
|
132
166
|
intent_hint: '{intent_hint or empty string}'
|
|
@@ -136,6 +170,8 @@ stack_skill_candidates: []
|
|
|
136
170
|
nextWorkflow: ''
|
|
137
171
|
```
|
|
138
172
|
|
|
173
|
+
**`constituent_refs` presence rules:** Include the field only when `project_paths` has more than one entry. When present, keys are path strings matching `project_paths[]` entries, values are explicit git refs (tag/branch/commit). Paths with no explicit ref have no entry in the map (default ref resolution applies). Downstream steps (scan-project, brief generation) read this map to resolve per-constituent refs when cloning or reading off-HEAD constituents.
|
|
174
|
+
|
|
139
175
|
"**Initialization complete.**
|
|
140
176
|
|
|
141
177
|
**Project:** {project_path}
|
|
@@ -28,6 +28,7 @@ To map the complete project structure by scanning directory trees, detecting ser
|
|
|
28
28
|
|
|
29
29
|
Read {outputFile} frontmatter to obtain:
|
|
30
30
|
- `project_paths[]` — the root(s) to scan (one or more paths/URLs)
|
|
31
|
+
- `constituent_refs` — optional per-path git ref overrides (present only when `project_paths` has multiple entries and explicit refs were supplied via `--target-refs`)
|
|
31
32
|
- `forge_tier` — determines scanning depth
|
|
32
33
|
- Scope hints (if any were provided in step 01)
|
|
33
34
|
|
|
@@ -37,14 +38,16 @@ Load {heuristicsFile} for reference on detection signals.
|
|
|
37
38
|
|
|
38
39
|
**Resolve `{scanManifestsHelper}`** from `{scanManifestsProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
39
40
|
|
|
40
|
-
**For each path in `project_paths[]`**, launch a subprocess that scans the project directory structure (aggregate results across all repos with clear repo-level grouping):
|
|
41
|
+
**For each path in `project_paths[]`**, resolve the constituent ref (if any) and launch a subprocess that scans the project directory structure (aggregate results across all repos with clear repo-level grouping):
|
|
42
|
+
|
|
43
|
+
**Per-path ref resolution:** If `constituent_refs` is present and contains an entry for the current path, use that ref. For remote paths, this means cloning or fetching at the specified ref (`git clone --branch {ref} --depth 1` or `git show {ref}:<subpath>` for off-HEAD access). For local paths with a non-HEAD ref, check out or read from the specified ref using `git show {ref}:<path>`. When no `constituent_refs` entry exists for a path, use default ref resolution (HEAD for local, latest tag or HEAD for remote).
|
|
41
44
|
|
|
42
45
|
1. Map the top-level directory tree (2-3 levels deep)
|
|
43
46
|
2. Identify workspace configuration files (pnpm-workspace.yaml, lerna.json, Cargo.toml [workspace], go.work, etc.)
|
|
44
47
|
3. Enumerate package manifests deterministically — invoke `uv run {scanManifestsHelper} scan {path}` and parse the JSON envelope. The script returns `{manifests[], total_unique, monorepo, warnings?}` covering npm/python/rust/go/maven/gradle/ruby/composer/swift; record each `{path, ecosystem}` for the manifests catalog in §4 and capture `monorepo` for the boundary-signal pass in §3
|
|
45
48
|
4. Locate entry point files (index.ts, main.ts, app.ts, main.go, main.rs, __init__.py, etc.)
|
|
46
49
|
5. Detect service configuration (Dockerfile, docker-compose.yml, kubernetes manifests, serverless.yml) — keep this step LLM-driven; file glob + presence check is sufficient, no parsing required
|
|
47
|
-
6. Return structured findings — file paths and types only, not contents
|
|
50
|
+
6. Return structured findings — file paths and types only, not contents. When `constituent_refs` was used, include the resolved ref in each repo-level result group: `{path, ref, manifests[], monorepo, warnings?}`
|
|
48
51
|
|
|
49
52
|
**If subprocess unavailable:** Perform directory scanning in main thread using file I/O tools.
|
|
50
53
|
|