bmad-module-skill-forge 1.5.0 → 1.6.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/package.json +1 -1
- package/src/shared/scripts/skf-description-guard.py +33 -80
- package/src/shared/scripts/skf-detect-tools.py +50 -8
- package/src/shared/scripts/skf-manifest-ops.py +22 -6
- package/src/skf-analyze-source/references/continue.md +3 -1
- package/src/skf-analyze-source/references/identify-units.md +8 -4
- package/src/skf-analyze-source/references/init.md +6 -4
- package/src/skf-analyze-source/references/scan-project.md +6 -2
- package/src/skf-audit-skill/references/init.md +9 -3
- package/src/skf-audit-skill/references/structural-diff.md +9 -3
- package/src/skf-brief-skill/SKILL.md +3 -1
- package/src/skf-brief-skill/references/analyze-target.md +22 -10
- package/src/skf-brief-skill/references/gather-intent.md +71 -6
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +8 -4
- package/src/skf-brief-skill/references/write-brief.md +24 -8
- package/src/skf-create-skill/assets/compile-assembly-rules.md +1 -1
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +2 -0
- package/src/skf-create-skill/references/validate.md +2 -2
- package/src/skf-create-stack-skill/references/detect-integrations.md +7 -2
- package/src/skf-create-stack-skill/references/detect-manifests.md +6 -2
- package/src/skf-create-stack-skill/references/parallel-extract.md +6 -2
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +6 -1
- package/src/skf-export-skill/references/summary.md +4 -4
- package/src/skf-export-skill/references/update-context.md +2 -2
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/references/coherence-check.md +14 -7
- package/src/skf-update-skill/references/init.md +8 -1
- package/src/skf-verify-stack/references/coverage.md +6 -2
- package/src/skf-verify-stack/references/init.md +6 -2
- package/src/skf-verify-stack/references/integrations.md +6 -2
- package/src/skf-verify-stack/references/report.md +6 -2
- package/src/skf-verify-stack/references/requirements.md +7 -3
- package/src/skf-verify-stack/references/synthesize.md +6 -2
package/docs/_data/pinned.yaml
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
# Enforced two ways: (1) release.yaml bumps this line in the same commit
|
|
27
27
|
# as package.json + marketplace.json on every release; (2) validate-docs-drift.js
|
|
28
28
|
# cross-checks this value against package.json.version and fails on mismatch.
|
|
29
|
-
skf_version: "1.
|
|
29
|
+
skf_version: "1.6.0"
|
|
30
30
|
|
|
31
31
|
# Path to the oh-my-skills repo, resolved relative to this repo's root.
|
|
32
32
|
# Override at runtime with the OMS environment variable if oh-my-skills
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-module-skill-forge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"bmad",
|
|
@@ -160,17 +160,45 @@ def is_diverged(diff_kind: str) -> bool:
|
|
|
160
160
|
def restore_description(skill_md: Path, captured: str) -> None:
|
|
161
161
|
"""Atomically rewrite SKILL.md so its frontmatter `description` equals captured.
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
Parses the entire frontmatter via PyYAML, replaces the top-level
|
|
164
|
+
`description` value, and re-emits the frontmatter via `yaml.safe_dump`.
|
|
165
|
+
This guarantees valid YAML output regardless of the source representation
|
|
166
|
+
(inline, double-quoted, single-quoted, folded `>`, literal `|`), and
|
|
167
|
+
avoids the line-level pitfalls a previous implementation had with folded
|
|
168
|
+
block scalars and nested `description:` keys in sibling mappings.
|
|
169
|
+
|
|
170
|
+
Key order is preserved (PyYAML's `safe_dump` honours dict insertion order;
|
|
171
|
+
this module's `requires-python = ">=3.10"` guarantees ordered dicts).
|
|
172
|
+
Quoting style of *other* fields may change to whatever `safe_dump`
|
|
173
|
+
chooses for each scalar — downstream readers parse YAML, so any valid
|
|
174
|
+
YAML emission is acceptable.
|
|
167
175
|
"""
|
|
168
176
|
text = skill_md.read_text(encoding="utf-8")
|
|
169
177
|
leading, fm_yaml, body = _split_frontmatter(text)
|
|
170
178
|
if not fm_yaml:
|
|
171
179
|
raise ValueError(f"cannot restore: no frontmatter in {skill_md}")
|
|
172
180
|
|
|
173
|
-
|
|
181
|
+
try:
|
|
182
|
+
fm = yaml.safe_load(fm_yaml)
|
|
183
|
+
except yaml.YAMLError as exc:
|
|
184
|
+
raise ValueError(f"frontmatter in {skill_md} is not valid YAML: {exc}") from exc
|
|
185
|
+
if not isinstance(fm, dict):
|
|
186
|
+
raise ValueError(f"frontmatter in {skill_md} is not a mapping")
|
|
187
|
+
if "description" not in fm:
|
|
188
|
+
raise ValueError(f"frontmatter in {skill_md} has no `description` field")
|
|
189
|
+
|
|
190
|
+
fm["description"] = captured
|
|
191
|
+
|
|
192
|
+
# `width=10**9` keeps the description on one line regardless of length;
|
|
193
|
+
# PyYAML otherwise inserts line breaks at ~80 chars which would re-introduce
|
|
194
|
+
# folded-scalar continuation lines — the exact failure mode this rewrite fixes.
|
|
195
|
+
new_fm = yaml.safe_dump(
|
|
196
|
+
fm,
|
|
197
|
+
sort_keys=False,
|
|
198
|
+
default_flow_style=False,
|
|
199
|
+
allow_unicode=True,
|
|
200
|
+
width=10**9,
|
|
201
|
+
).rstrip("\n")
|
|
174
202
|
new_text = f"{leading}{new_fm}\n---\n{body}"
|
|
175
203
|
|
|
176
204
|
# atomic: write to a sibling temp file, fsync, rename
|
|
@@ -191,81 +219,6 @@ def restore_description(skill_md: Path, captured: str) -> None:
|
|
|
191
219
|
raise
|
|
192
220
|
|
|
193
221
|
|
|
194
|
-
def _rewrite_description_line(fm_yaml: str, new_description: str) -> str:
|
|
195
|
-
"""Replace the `description:` value in a YAML frontmatter block.
|
|
196
|
-
|
|
197
|
-
Preserves other keys verbatim. Handles three common shapes:
|
|
198
|
-
description: single-line value
|
|
199
|
-
description: "double-quoted value"
|
|
200
|
-
description: | # block scalar (folded variant: >)
|
|
201
|
-
multi-line
|
|
202
|
-
value here
|
|
203
|
-
"""
|
|
204
|
-
lines = fm_yaml.split("\n")
|
|
205
|
-
out: list[str] = []
|
|
206
|
-
i = 0
|
|
207
|
-
quoted = _yaml_quote_inline(new_description)
|
|
208
|
-
replaced = False
|
|
209
|
-
while i < len(lines):
|
|
210
|
-
line = lines[i]
|
|
211
|
-
if not replaced and _is_description_key_line(line):
|
|
212
|
-
stripped = line.lstrip()
|
|
213
|
-
indent = line[: len(line) - len(stripped)]
|
|
214
|
-
# detect block-scalar indicator (| or >)
|
|
215
|
-
after_key = stripped[len("description:") :].lstrip()
|
|
216
|
-
if after_key.startswith("|") or after_key.startswith(">"):
|
|
217
|
-
# skip block-scalar continuation lines (deeper indent than the key line)
|
|
218
|
-
key_indent = len(indent)
|
|
219
|
-
i += 1
|
|
220
|
-
while i < len(lines):
|
|
221
|
-
nxt = lines[i]
|
|
222
|
-
if nxt.strip() == "":
|
|
223
|
-
# blank lines belong to the block scalar
|
|
224
|
-
i += 1
|
|
225
|
-
continue
|
|
226
|
-
nxt_indent = len(nxt) - len(nxt.lstrip())
|
|
227
|
-
if nxt_indent <= key_indent:
|
|
228
|
-
break
|
|
229
|
-
i += 1
|
|
230
|
-
out.append(f"{indent}description: {quoted}")
|
|
231
|
-
replaced = True
|
|
232
|
-
continue
|
|
233
|
-
out.append(f"{indent}description: {quoted}")
|
|
234
|
-
replaced = True
|
|
235
|
-
i += 1
|
|
236
|
-
continue
|
|
237
|
-
out.append(line)
|
|
238
|
-
i += 1
|
|
239
|
-
if not replaced:
|
|
240
|
-
raise ValueError("description key not found while rewriting frontmatter")
|
|
241
|
-
return "\n".join(out)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _is_description_key_line(line: str) -> bool:
|
|
245
|
-
stripped = line.lstrip()
|
|
246
|
-
return stripped.startswith("description:") and (
|
|
247
|
-
len(stripped) == len("description:") or stripped[len("description:")] in (" ", "\t", "")
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _yaml_quote_inline(value: str) -> str:
|
|
252
|
-
"""Emit a YAML scalar suitable as the inline value of `description: `.
|
|
253
|
-
|
|
254
|
-
Uses double-quoted form so control characters and embedded quotes are
|
|
255
|
-
safe. Block scalars (| / >) are not used — the description field is a
|
|
256
|
-
single semantic string and downstream readers (skill-check, agentskills)
|
|
257
|
-
expect inline form.
|
|
258
|
-
"""
|
|
259
|
-
escaped = (
|
|
260
|
-
value.replace("\\", "\\\\")
|
|
261
|
-
.replace("\"", "\\\"")
|
|
262
|
-
.replace("\n", "\\n")
|
|
263
|
-
.replace("\r", "\\r")
|
|
264
|
-
.replace("\t", "\\t")
|
|
265
|
-
)
|
|
266
|
-
return f'"{escaped}"'
|
|
267
|
-
|
|
268
|
-
|
|
269
222
|
# --------------------------------------------------------------------------
|
|
270
223
|
# CLI
|
|
271
224
|
# --------------------------------------------------------------------------
|
|
@@ -88,13 +88,26 @@ from __future__ import annotations
|
|
|
88
88
|
import argparse
|
|
89
89
|
import json
|
|
90
90
|
import os
|
|
91
|
+
import shutil
|
|
91
92
|
import subprocess
|
|
92
93
|
import sys
|
|
94
|
+
import tempfile
|
|
93
95
|
from concurrent.futures import ThreadPoolExecutor
|
|
94
96
|
|
|
95
97
|
|
|
96
98
|
VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
97
99
|
PROBE_TIMEOUT_SEC = 8 # per-tool subprocess.run timeout
|
|
100
|
+
# Prefer the OS `timeout(1)` utility to bound each probe. A daemon-backed
|
|
101
|
+
# tool (e.g. `qmd status`) can block in an uninterruptible syscall against
|
|
102
|
+
# its daemon; `subprocess.run`'s own post-timeout `process.wait()` is
|
|
103
|
+
# unbounded and never returns in that case, hanging the whole detector.
|
|
104
|
+
# `timeout --kill-after` reaps the child (and its session) at the OS level,
|
|
105
|
+
# so Python's wait always returns. POSIX-only: Windows' `timeout.exe` is an
|
|
106
|
+
# unrelated builtin (waits for input; cannot run a command), and the
|
|
107
|
+
# uninterruptible-wait hang is itself POSIX-specific (Windows uses a
|
|
108
|
+
# forcible TerminateProcess), so on Windows we fall back to subprocess.run's
|
|
109
|
+
# own timeout. None when unavailable.
|
|
110
|
+
_TIMEOUT_BIN = shutil.which("timeout") if os.name == "posix" else None
|
|
98
111
|
CCC_IDENTITY_MARKER = "cocoindex code" # case-insensitive substring
|
|
99
112
|
|
|
100
113
|
|
|
@@ -115,16 +128,45 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
|
|
|
115
128
|
Treats every failure mode (FileNotFoundError, TimeoutExpired, OSError,
|
|
116
129
|
CalledProcessError) as a failed probe — returns rc=127 and an empty
|
|
117
130
|
stdout/stderr. Tool detection should never crash the workflow.
|
|
131
|
+
|
|
132
|
+
The child is wrapped in the OS `timeout(1)` utility (when available) so a
|
|
133
|
+
daemon-backed probe blocked in an uninterruptible syscall (e.g.
|
|
134
|
+
`qmd status` waiting on its daemon socket) is reaped at the OS level —
|
|
135
|
+
`subprocess.run`'s own post-timeout `process.wait()` is unbounded and
|
|
136
|
+
would otherwise never return, hanging the whole detector. Child
|
|
137
|
+
stdout/stderr are redirected to temp files (no pipe to drain), and
|
|
138
|
+
`start_new_session=True` isolates the child's process group; the
|
|
139
|
+
Python-level timeout is a secondary net set slightly above the OS one.
|
|
118
140
|
"""
|
|
141
|
+
if _TIMEOUT_BIN:
|
|
142
|
+
run_cmd = [_TIMEOUT_BIN, "--kill-after=2", str(timeout), *cmd]
|
|
143
|
+
py_timeout = timeout + 5
|
|
144
|
+
else:
|
|
145
|
+
run_cmd = cmd
|
|
146
|
+
py_timeout = timeout
|
|
119
147
|
try:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
run_cmd,
|
|
151
|
+
stdin=subprocess.DEVNULL,
|
|
152
|
+
stdout=out_f,
|
|
153
|
+
stderr=err_f,
|
|
154
|
+
timeout=py_timeout,
|
|
155
|
+
check=False,
|
|
156
|
+
start_new_session=True,
|
|
157
|
+
)
|
|
158
|
+
# Mocked unit tests patch `subprocess.run` to return a fake with
|
|
159
|
+
# `.stdout`/`.stderr` strings set; real runs redirect to the temp
|
|
160
|
+
# files (so `result.stdout` is None — read the files instead).
|
|
161
|
+
stdout = result.stdout
|
|
162
|
+
if stdout is None:
|
|
163
|
+
out_f.seek(0)
|
|
164
|
+
stdout = out_f.read().decode("utf-8", "replace")
|
|
165
|
+
stderr = result.stderr
|
|
166
|
+
if stderr is None:
|
|
167
|
+
err_f.seek(0)
|
|
168
|
+
stderr = err_f.read().decode("utf-8", "replace")
|
|
169
|
+
return result.returncode, stdout or "", stderr or ""
|
|
128
170
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
129
171
|
return 127, "", ""
|
|
130
172
|
|
|
@@ -12,7 +12,10 @@ CLI: python3 skf-manifest-ops.py <skills-folder> <command> [args]
|
|
|
12
12
|
Commands:
|
|
13
13
|
read — Read entire manifest
|
|
14
14
|
get <skill-name> — Get a single skill entry
|
|
15
|
-
set <skill-name> <version>
|
|
15
|
+
set <skill-name> <version> [--ides a,b]
|
|
16
|
+
— Add/update skill with active version;
|
|
17
|
+
--ides unions a comma-separated IDE list
|
|
18
|
+
into the version entry (deduped, sorted)
|
|
16
19
|
remove <skill-name> — Remove skill from manifest
|
|
17
20
|
deprecate <skill-name> [ver] — Mark skill or version as deprecated
|
|
18
21
|
rename <old-name> <new-name> — Rename a skill entry
|
|
@@ -124,7 +127,7 @@ def cmd_get(manifest_path, skill_name):
|
|
|
124
127
|
return {"status": "ok", "skill": skill_name, "entry": exports[skill_name]}
|
|
125
128
|
|
|
126
129
|
|
|
127
|
-
def cmd_set(manifest_path, skill_name, version):
|
|
130
|
+
def cmd_set(manifest_path, skill_name, version, ides=None):
|
|
128
131
|
data, err = read_manifest(manifest_path)
|
|
129
132
|
if err:
|
|
130
133
|
return {"status": "error", "error": err}
|
|
@@ -140,10 +143,18 @@ def cmd_set(manifest_path, skill_name, version):
|
|
|
140
143
|
if old_active and old_active in versions and old_active != version:
|
|
141
144
|
versions[old_active]["status"] = "archived"
|
|
142
145
|
|
|
143
|
-
# Add or update the new active version
|
|
146
|
+
# Add or update the new active version. Absent --ides preserves the
|
|
147
|
+
# existing ides/platforms list verbatim (backward-compatible). When
|
|
148
|
+
# --ides is supplied, union it in (dedupe, keep sorted).
|
|
144
149
|
existing_version = versions.get(version, {})
|
|
150
|
+
merged_ides = list(existing_version.get("ides", existing_version.get("platforms", [])))
|
|
151
|
+
if ides:
|
|
152
|
+
for ide in ides:
|
|
153
|
+
if ide not in merged_ides:
|
|
154
|
+
merged_ides.append(ide)
|
|
155
|
+
merged_ides = sorted(merged_ides)
|
|
145
156
|
versions[version] = {
|
|
146
|
-
"ides":
|
|
157
|
+
"ides": merged_ides,
|
|
147
158
|
"last_exported": today,
|
|
148
159
|
"status": "active",
|
|
149
160
|
}
|
|
@@ -205,7 +216,7 @@ def cmd_rename(manifest_path, old_name, new_name):
|
|
|
205
216
|
def main():
|
|
206
217
|
if len(sys.argv) < 3:
|
|
207
218
|
print("Usage: python3 skf-manifest-ops.py <skills-folder> <command> [args]", file=sys.stderr)
|
|
208
|
-
print("Commands: read, get <name>, set <name> <version
|
|
219
|
+
print("Commands: read, get <name>, set <name> <version> [--ides a,b], remove <name>, deprecate <name> [version], rename <old> <new>", file=sys.stderr)
|
|
209
220
|
sys.exit(1)
|
|
210
221
|
|
|
211
222
|
skills_folder = Path(sys.argv[1])
|
|
@@ -217,7 +228,12 @@ def main():
|
|
|
217
228
|
elif command == "get" and len(sys.argv) >= 4:
|
|
218
229
|
result = cmd_get(manifest_path, sys.argv[3])
|
|
219
230
|
elif command == "set" and len(sys.argv) >= 5:
|
|
220
|
-
|
|
231
|
+
ides_arg = None
|
|
232
|
+
if "--ides" in sys.argv:
|
|
233
|
+
idx = sys.argv.index("--ides")
|
|
234
|
+
if idx + 1 < len(sys.argv):
|
|
235
|
+
ides_arg = [s for s in sys.argv[idx + 1].split(",") if s]
|
|
236
|
+
result = cmd_set(manifest_path, sys.argv[3], sys.argv[4], ides_arg)
|
|
221
237
|
elif command == "remove" and len(sys.argv) >= 4:
|
|
222
238
|
result = cmd_remove(manifest_path, sys.argv[3])
|
|
223
239
|
elif command == "deprecate" and len(sys.argv) >= 4:
|
|
@@ -6,6 +6,7 @@ nextStepOptions:
|
|
|
6
6
|
step 4: 'map-and-detect.md'
|
|
7
7
|
step 5: 'recommend.md'
|
|
8
8
|
step 6: 'generate-briefs.md'
|
|
9
|
+
step 7: 'health-check.md'
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
<!-- Config: communicate in {communication_language}. -->
|
|
@@ -61,8 +62,9 @@ Map the last completed step to the next step file:
|
|
|
61
62
|
| identify-units | map-and-detect |
|
|
62
63
|
| map-and-detect | recommend |
|
|
63
64
|
| recommend | generate-briefs |
|
|
65
|
+
| generate-briefs | health-check |
|
|
64
66
|
|
|
65
|
-
**IF
|
|
67
|
+
**IF `health-check` is in `stepsCompleted`:**
|
|
66
68
|
"**This analysis appears to be complete.** All steps have been finished. Would you like to start a new analysis?"
|
|
67
69
|
|
|
68
70
|
### 5. Update and Route
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
nextStepFile: 'map-and-detect.md'
|
|
3
3
|
outputFile: '{forge_data_folder}/analyze-source-report-{project_name}.md'
|
|
4
4
|
heuristicsFile: 'references/unit-detection-heuristics.md'
|
|
5
|
-
|
|
5
|
+
disqualifyCandidatesProbeOrder:
|
|
6
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-disqualify-candidates.py'
|
|
7
|
+
- '{project-root}/src/shared/scripts/skf-disqualify-candidates.py'
|
|
6
8
|
---
|
|
7
9
|
|
|
8
10
|
<!-- Config: communicate in {communication_language}. -->
|
|
@@ -32,6 +34,8 @@ Load {heuristicsFile} for classification rules.
|
|
|
32
34
|
|
|
33
35
|
### 2. Apply Detection Heuristics
|
|
34
36
|
|
|
37
|
+
**Resolve `{disqualifyCandidatesHelper}`** from `{disqualifyCandidatesProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
38
|
+
|
|
35
39
|
For EACH detected boundary from the scan:
|
|
36
40
|
|
|
37
41
|
**Step A — Count detection signals:**
|
|
@@ -58,16 +62,16 @@ Run the shared disqualification helper to apply the deterministic subset of the
|
|
|
58
62
|
```json
|
|
59
63
|
[
|
|
60
64
|
{"name": "<unit-name>",
|
|
61
|
-
"path": "<rel-from-
|
|
65
|
+
"path": "<rel-from-analyzed-source-root (project_paths[0])>",
|
|
62
66
|
"files": ["<rel-path>", ...]},
|
|
63
67
|
...
|
|
64
68
|
]
|
|
65
69
|
```
|
|
66
70
|
2. **Invoke the script** via stdin:
|
|
67
71
|
```bash
|
|
68
|
-
uv run {
|
|
72
|
+
uv run {disqualifyCandidatesHelper} filter --boundaries - --source-root {project_paths[0]}
|
|
69
73
|
```
|
|
70
|
-
piping the boundaries JSON on stdin. The script emits:
|
|
74
|
+
piping the boundaries JSON on stdin. `--source-root` is the analyzed-source root (`project_paths[0]`) — the directory the boundaries/manifest scan ran against — not `{project-root}` (the forge workspace), which differs whenever the analyzed target lives outside the forge workspace. The script emits:
|
|
71
75
|
```json
|
|
72
76
|
{
|
|
73
77
|
"kept": [{"name": "...", "path": "...", "files_count": N, "loc_total": L}, ...],
|
|
@@ -26,9 +26,11 @@ To initialize the analyze-source workflow by loading configuration, detecting co
|
|
|
26
26
|
Look for {outputFile}.
|
|
27
27
|
|
|
28
28
|
**IF the file exists AND has `stepsCompleted` with entries:**
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
29
|
+
- The report filename is keyed to `{project_name}` (the forge workspace), not the analyzed target — so a report from a *different* target can collide here. Before resuming, establish the requested target and compare it to the existing report:
|
|
30
|
+
- Determine the requested target now: if `--project-path <path>` was passed at invocation, set `project_paths[]` from it (comma-split if multiple); otherwise collect the path(s) using the section-3 "Collect Project Path" prompt and store as `project_paths[]`. (Section 3 must NOT re-prompt when `project_paths[]` is already populated here.)
|
|
31
|
+
- Read the existing report's frontmatter `project_paths`.
|
|
32
|
+
- **IF the existing report's `project_paths` matches the requested target:** "**Found an existing analysis report. Resuming previous session...**" — Load, read entirely, then execute {continueFile}. **STOP HERE** — do not continue this sequence.
|
|
33
|
+
- **ELSE (different target — stale collision):** the existing report belongs to another analysis. Archive it by renaming to `{forge_data_folder}/analyze-source-report-{project_name}-<UTC-timestamp>.md`, announce "**Existing report belongs to a different target — archived as <name>; starting a fresh analysis.**", then continue to section 2 (skip re-collecting the path in section 3 — it is already set).
|
|
32
34
|
|
|
33
35
|
**IF the file does not exist OR stepsCompleted is empty:**
|
|
34
36
|
- Continue to section 2
|
|
@@ -46,7 +48,7 @@ Look for {outputFile}.
|
|
|
46
48
|
|
|
47
49
|
### 3. Collect Project Path
|
|
48
50
|
|
|
49
|
-
**Headless flag consumption:** If `--project-path <path>` was passed at invocation, set `project_paths[]`
|
|
51
|
+
**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.
|
|
50
52
|
|
|
51
53
|
"**Welcome to Analyze Source — the SKF decomposition engine.**
|
|
52
54
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
nextStepFile: 'identify-units.md'
|
|
3
3
|
outputFile: '{forge_data_folder}/analyze-source-report-{project_name}.md'
|
|
4
4
|
heuristicsFile: 'references/unit-detection-heuristics.md'
|
|
5
|
-
|
|
5
|
+
scanManifestsProbeOrder:
|
|
6
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-scan-manifests.py'
|
|
7
|
+
- '{project-root}/src/shared/scripts/skf-scan-manifests.py'
|
|
6
8
|
---
|
|
7
9
|
|
|
8
10
|
<!-- Config: communicate in {communication_language}. -->
|
|
@@ -33,11 +35,13 @@ Load {heuristicsFile} for reference on detection signals.
|
|
|
33
35
|
|
|
34
36
|
### 2. Scan Directory Structure
|
|
35
37
|
|
|
38
|
+
**Resolve `{scanManifestsHelper}`** from `{scanManifestsProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
39
|
+
|
|
36
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):
|
|
37
41
|
|
|
38
42
|
1. Map the top-level directory tree (2-3 levels deep)
|
|
39
43
|
2. Identify workspace configuration files (pnpm-workspace.yaml, lerna.json, Cargo.toml [workspace], go.work, etc.)
|
|
40
|
-
3. Enumerate package manifests deterministically — invoke `uv run {
|
|
44
|
+
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
|
|
41
45
|
4. Locate entry point files (index.ts, main.ts, app.ts, main.go, main.rs, __init__.py, etc.)
|
|
42
46
|
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
|
|
43
47
|
6. Return structured findings — file paths and types only, not contents
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
nextStepFile: 're-index.md'
|
|
3
3
|
outputFile: '{forge_version}/drift-report-{timestamp}.md'
|
|
4
4
|
templateFile: 'assets/drift-report-template.md'
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
loadProvenanceProbeOrder:
|
|
6
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-load-provenance.py'
|
|
7
|
+
- '{project-root}/src/shared/scripts/skf-load-provenance.py'
|
|
8
|
+
compareFileHashesProbeOrder:
|
|
9
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-compare-file-hashes.py'
|
|
10
|
+
- '{project-root}/src/shared/scripts/skf-compare-file-hashes.py'
|
|
7
11
|
---
|
|
8
12
|
|
|
9
13
|
<!-- Config: communicate in {communication_language}. -->
|
|
@@ -98,12 +102,14 @@ Load the following from the skill directory:
|
|
|
98
102
|
|
|
99
103
|
Search for provenance map at `{forge_data_folder}/{skill_name}/{active_version}/provenance-map.json` (i.e., `{forge_version}/provenance-map.json`). If not found at the versioned path, fall back to `{forge_data_folder}/{skill_name}/provenance-map.json`:
|
|
100
104
|
|
|
105
|
+
**Resolve `{loadProvenanceHelper}`** from `{loadProvenanceProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
106
|
+
|
|
101
107
|
**If found:**
|
|
102
108
|
- Record provenance map age (days since last extraction) from file mtime
|
|
103
109
|
- Normalize the map's deterministic projections in one subprocess call:
|
|
104
110
|
|
|
105
111
|
```bash
|
|
106
|
-
uv run {
|
|
112
|
+
uv run {loadProvenanceHelper} normalize {provenanceMap}
|
|
107
113
|
```
|
|
108
114
|
|
|
109
115
|
Parse the emitted JSON and stash these fields in workflow context (downstream steps read them directly — no re-walk):
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
nextStepFile: 'semantic-diff.md'
|
|
3
3
|
outputFile: '{forge_version}/drift-report-{timestamp}.md'
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
loadProvenanceProbeOrder:
|
|
5
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-load-provenance.py'
|
|
6
|
+
- '{project-root}/src/shared/scripts/skf-load-provenance.py'
|
|
7
|
+
compareFileHashesProbeOrder:
|
|
8
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-compare-file-hashes.py'
|
|
9
|
+
- '{project-root}/src/shared/scripts/skf-compare-file-hashes.py'
|
|
6
10
|
---
|
|
7
11
|
|
|
8
12
|
<!-- Config: communicate in {communication_language}. -->
|
|
@@ -89,10 +93,12 @@ For each changed export, record:
|
|
|
89
93
|
|
|
90
94
|
**Only execute if provenance-map.json contains `file_entries`.**
|
|
91
95
|
|
|
96
|
+
**Resolve `{compareFileHashesHelper}`** from `{compareFileHashesProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
97
|
+
|
|
92
98
|
Run one deterministic comparison subprocess — it walks tracked file_entries[] AND the inverse direction (source-tree → candidate set in standard script/asset/doc directories) so the LLM does not orchestrate per-file hashing:
|
|
93
99
|
|
|
94
100
|
```bash
|
|
95
|
-
uv run {
|
|
101
|
+
uv run {compareFileHashesHelper} compare {provenanceMap} {sourceRoot}
|
|
96
102
|
```
|
|
97
103
|
|
|
98
104
|
Parse the emitted JSON:
|
|
@@ -13,6 +13,8 @@ A good skill brief sets a tight, cohesive boundary: one capability with 3-8 prim
|
|
|
13
13
|
|
|
14
14
|
Brief-skill is split from create-skill so the scoping conversation runs *once*, on cheap signals (manifests, top-level exports, intent), without paying for AST extraction. Compilation is expensive; scoping decisions are cheap to revise. Keeping them in separate workflows lets a user iterate on the brief, share it for review, and re-run create-skill against the same brief whenever the upstream version moves.
|
|
15
15
|
|
|
16
|
+
**Ratify path (interactive only).** When the user already has a `skill-brief.yaml` produced by another workflow — typically `skf-analyze-source`'s `generate-briefs` step, where one analyze pass emits several recommended briefs — invoking `/skf-brief-skill` with a path to that brief at the first prompt routes to a fast-path review: gather-intent §3.1a loads the YAML, hydrates the brief context, and jumps straight to step 4 (confirm-brief) where the standard review/edit cycle still applies before step 5 writes. This skips re-deriving fields the upstream draft already supplies and saves the 5-10 minutes a full re-run of intent + analyze + scope would cost.
|
|
17
|
+
|
|
16
18
|
## Conventions
|
|
17
19
|
|
|
18
20
|
- Bare paths (e.g. `references/<name>.md`) resolve from the skill root.
|
|
@@ -30,7 +32,7 @@ You are a skill scoping architect collaborating with a developer who wants to cr
|
|
|
30
32
|
These rules apply to every step in this workflow:
|
|
31
33
|
|
|
32
34
|
- Only load one step file at a time — never preload future steps
|
|
33
|
-
- **Lazy-load references and assets:** `references/*.md` and `assets/*.md` files are loaded inside the section that needs them, not at step entry. If a section is skipped (e.g. `version-resolution.md` when `{
|
|
35
|
+
- **Lazy-load references and assets:** `references/*.md` and `assets/*.md` files are loaded inside the section that needs them, not at step entry. If a section is skipped (e.g. `version-resolution.md` when `{extractPublicApiHelper}` already returned a version, `scope-templates.md` for the `docs-only` branch that bypasses §2c), do not load that file. Each unnecessary load costs context (~5-10 KB per reference) and biases the LLM toward consulting material the current path does not need.
|
|
34
36
|
- Always communicate in `{communication_language}` (the language for user-facing prose). Written artifact text — the `description`, `notes`, and other free-form fields persisted into `skill-brief.yaml` — is in `{document_output_language}`; per-step rules call this out where it applies (see step 5). The two values may be the same.
|
|
35
37
|
- If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action and log each auto-decision
|
|
36
38
|
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
nextStepFile: 'scope-definition.md'
|
|
3
3
|
versionResolutionFile: 'references/version-resolution.md'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
extractPublicApiProbeOrder:
|
|
5
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-extract-public-api.py'
|
|
6
|
+
- '{project-root}/src/shared/scripts/skf-extract-public-api.py'
|
|
7
|
+
detectWorkspacesProbeOrder:
|
|
8
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-detect-workspaces.py'
|
|
9
|
+
- '{project-root}/src/shared/scripts/skf-detect-workspaces.py'
|
|
10
|
+
detectLanguageProbeOrder:
|
|
11
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-detect-language.py'
|
|
12
|
+
- '{project-root}/src/shared/scripts/skf-detect-language.py'
|
|
7
13
|
---
|
|
8
14
|
|
|
9
15
|
<!-- Config: communicate in {communication_language}. -->
|
|
@@ -58,11 +64,13 @@ Display: "**Resolving target...**"
|
|
|
58
64
|
|
|
59
65
|
### 1b. Detect Monorepo / Workspace Layout
|
|
60
66
|
|
|
61
|
-
|
|
67
|
+
**Resolve `{detectWorkspacesHelper}`** from `{detectWorkspacesProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
68
|
+
|
|
69
|
+
Delegate workspace detection to `{detectWorkspacesHelper}` instead of reasoning through manifest rules in prose. Build a payload from the tree fetched in §1 plus the small set of root manifests the detector needs, then invoke the script:
|
|
62
70
|
|
|
63
71
|
```bash
|
|
64
72
|
echo '{"tree": [<flat list of repo-relative file paths>], "manifests": {"package.json": "<raw text>", "Cargo.toml": "<raw text>", "pnpm-workspace.yaml": "<raw text>", "lerna.json": "<raw text>"}}' | \
|
|
65
|
-
uv run {
|
|
73
|
+
uv run {detectWorkspacesHelper}
|
|
66
74
|
```
|
|
67
75
|
|
|
68
76
|
- **`tree`** — pass the flat list of repo-relative file paths already fetched in §1 (for GitHub: the `path` values from the `gh api .../git/trees/HEAD?recursive=1` response; for local: the equivalent listing).
|
|
@@ -104,10 +112,12 @@ List the top-level directory structure:
|
|
|
104
112
|
|
|
105
113
|
### 3. Detect Primary Language
|
|
106
114
|
|
|
107
|
-
|
|
115
|
+
**Resolve `{detectLanguageHelper}`** from `{detectLanguageProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
116
|
+
|
|
117
|
+
Delegate the rule walk to `{detectLanguageHelper}` instead of evaluating manifest presence and extension frequency in prose:
|
|
108
118
|
|
|
109
119
|
```bash
|
|
110
|
-
echo '{"tree": [<flat list of repo-relative file paths from §1>]}' | uv run {
|
|
120
|
+
echo '{"tree": [<flat list of repo-relative file paths from §1>]}' | uv run {detectLanguageHelper}
|
|
111
121
|
```
|
|
112
122
|
|
|
113
123
|
The script returns `{language, confidence, detection_source, fallback_to_extension_frequency}` after walking the documented rule table (manifest presence first — package.json with tsconfig.json disambiguation, Cargo.toml, pyproject.toml/setup.py/setup.cfg, go.mod, pom.xml, build.gradle.kts, build.gradle Groovy with Java/Kotlin disambiguation, *.csproj/*.sln, Gemfile — then extension-frequency fallback over recognized source extensions). Use the returned values directly:
|
|
@@ -120,7 +130,9 @@ If `confidence` is `low` (or `unknown` is returned for `language`): flag for use
|
|
|
120
130
|
|
|
121
131
|
### 4. List Top-Level Modules and Exports
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
**Resolve `{extractPublicApiHelper}`** from `{extractPublicApiProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
134
|
+
|
|
135
|
+
Identify the public API surface. **Delegate the parsing to `{extractPublicApiHelper}` whenever the detected language is supported** — the script is the single source of truth for manifest parsing, export discovery, and version detection across the whole SKF pipeline. Hand-rolling these in prose creates drift seams the LLM cannot fully close.
|
|
124
136
|
|
|
125
137
|
**Script-supported languages** (use the script): `js`, `ts`, `javascript`, `typescript`, `python`, `rust`, `go`, `java`, `kotlin`.
|
|
126
138
|
|
|
@@ -153,7 +165,7 @@ This section runs exactly one of §4.1 (script path) or §4.2 (fallback path) ba
|
|
|
153
165
|
3. Invoke the script and parse its JSON stdout:
|
|
154
166
|
|
|
155
167
|
```bash
|
|
156
|
-
echo '<payload-json>' | uv run {
|
|
168
|
+
echo '<payload-json>' | uv run {extractPublicApiHelper} --mode quick
|
|
157
169
|
```
|
|
158
170
|
|
|
159
171
|
On a non-zero exit (codes 1 or 2 per the script's docstring), capture stderr, log it, and fall through to §4.2 (the prose-fallback path) — never HALT just because the script choked on an unusual manifest.
|
|
@@ -200,7 +212,7 @@ If CCC is unavailable or returns no results: skip this subsection silently.
|
|
|
200
212
|
|
|
201
213
|
### 4b. Detect Source Version
|
|
202
214
|
|
|
203
|
-
**When the language was script-supported (§4 took the script path):** the `version` field returned by `{
|
|
215
|
+
**When the language was script-supported (§4 took the script path):** the `version` field returned by `{extractPublicApiHelper}` IS the detected version — do not re-derive it and do not load `{versionResolutionFile}`. The script already implements the language-specific lookups documented in that reference, so loading the reference here only burns context.
|
|
204
216
|
|
|
205
217
|
**When the language was not script-supported:** load `{versionResolutionFile}` and follow the prose Detection Algorithm directly (Ruby / C# / Swift / etc. fall outside the script's coverage).
|
|
206
218
|
|