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.
Files changed (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/package.json +1 -1
  4. package/src/shared/scripts/skf-description-guard.py +33 -80
  5. package/src/shared/scripts/skf-detect-tools.py +50 -8
  6. package/src/shared/scripts/skf-manifest-ops.py +22 -6
  7. package/src/skf-analyze-source/references/continue.md +3 -1
  8. package/src/skf-analyze-source/references/identify-units.md +8 -4
  9. package/src/skf-analyze-source/references/init.md +6 -4
  10. package/src/skf-analyze-source/references/scan-project.md +6 -2
  11. package/src/skf-audit-skill/references/init.md +9 -3
  12. package/src/skf-audit-skill/references/structural-diff.md +9 -3
  13. package/src/skf-brief-skill/SKILL.md +3 -1
  14. package/src/skf-brief-skill/references/analyze-target.md +22 -10
  15. package/src/skf-brief-skill/references/gather-intent.md +71 -6
  16. package/src/skf-brief-skill/references/headless-args.md +1 -1
  17. package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
  18. package/src/skf-brief-skill/references/scope-definition.md +8 -4
  19. package/src/skf-brief-skill/references/write-brief.md +24 -8
  20. package/src/skf-create-skill/assets/compile-assembly-rules.md +1 -1
  21. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  22. package/src/skf-create-skill/references/extraction-patterns.md +2 -0
  23. package/src/skf-create-skill/references/validate.md +2 -2
  24. package/src/skf-create-stack-skill/references/detect-integrations.md +7 -2
  25. package/src/skf-create-stack-skill/references/detect-manifests.md +6 -2
  26. package/src/skf-create-stack-skill/references/parallel-extract.md +6 -2
  27. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +6 -1
  28. package/src/skf-export-skill/references/summary.md +4 -4
  29. package/src/skf-export-skill/references/update-context.md +2 -2
  30. package/src/skf-test-skill/SKILL.md +1 -1
  31. package/src/skf-test-skill/references/coherence-check.md +14 -7
  32. package/src/skf-update-skill/references/init.md +8 -1
  33. package/src/skf-verify-stack/references/coverage.md +6 -2
  34. package/src/skf-verify-stack/references/init.md +6 -2
  35. package/src/skf-verify-stack/references/integrations.md +6 -2
  36. package/src/skf-verify-stack/references/report.md +6 -2
  37. package/src/skf-verify-stack/references/requirements.md +7 -3
  38. package/src/skf-verify-stack/references/synthesize.md +6 -2
@@ -30,7 +30,7 @@
30
30
  "name": "skill-forge",
31
31
  "source": "./",
32
32
  "description": "Evidence-based agent skills compiler with progressive capability tiers (Quick/Forge/Forge+/Deep).",
33
- "version": "1.5.0",
33
+ "version": "1.6.0",
34
34
  "author": {
35
35
  "name": "Armel"
36
36
  },
@@ -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.5.0"
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.5.0",
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
- Preserves frontmatter key order and surrounding whitespace by doing a
164
- line-level rewrite of just the `description:` value, not a full YAML
165
- re-dump (which would re-order keys, change quoting style, and likely
166
- fail roundtrip tests downstream).
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
- new_fm = _rewrite_description_line(fm_yaml, captured)
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
- result = subprocess.run(
121
- cmd,
122
- capture_output=True,
123
- text=True,
124
- timeout=timeout,
125
- check=False,
126
- )
127
- return result.returncode, result.stdout or "", result.stderr or ""
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> Add/update skill with active 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": existing_version.get("ides", existing_version.get("platforms", [])),
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>, remove <name>, deprecate <name> [version], rename <old> <new>", file=sys.stderr)
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
- result = cmd_set(manifest_path, sys.argv[3], sys.argv[4])
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 all steps completed:**
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
- disqualifyCandidatesScript: '{project-root}/src/shared/scripts/skf-disqualify-candidates.py'
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-project-root>",
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 {disqualifyCandidatesScript} filter --boundaries - --source-root {project-root}
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
- - "**Found an existing analysis report. Resuming previous session...**"
30
- - Load, read entirely, then execute {continueFile}
31
- - **STOP HERE** do not continue this sequence
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[]` directly from the flag value (comma-split if multiple paths were supplied), skip the prompt below, and proceed to validation. Otherwise prompt as today.
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
- scanManifestsScript: '{project-root}/src/shared/scripts/skf-scan-manifests.py'
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 {scanManifestsScript} 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
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
- loadProvenanceScript: '{project-root}/src/shared/scripts/skf-load-provenance.py'
6
- compareFileHashesScript: '{project-root}/src/shared/scripts/skf-compare-file-hashes.py'
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 {loadProvenanceScript} normalize {provenanceMap}
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
- loadProvenanceScript: '{project-root}/src/shared/scripts/skf-load-provenance.py'
5
- compareFileHashesScript: '{project-root}/src/shared/scripts/skf-compare-file-hashes.py'
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 {compareFileHashesScript} compare {provenanceMap} {sourceRoot}
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 `{extractPublicApiScript}` 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.
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
- extractPublicApiScript: '{project-root}/src/shared/scripts/skf-extract-public-api.py'
5
- detectWorkspacesScript: '{project-root}/src/shared/scripts/skf-detect-workspaces.py'
6
- detectLanguageScript: '{project-root}/src/shared/scripts/skf-detect-language.py'
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
- Delegate workspace detection to `{detectWorkspacesScript}` 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:
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 {detectWorkspacesScript}
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
- Delegate the rule walk to `{detectLanguageScript}` instead of evaluating manifest presence and extension frequency in prose:
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 {detectLanguageScript}
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
- Identify the public API surface. **Delegate the parsing to `{extractPublicApiScript}` 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.
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 {extractPublicApiScript} --language <lang> --mode quick
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 `{extractPublicApiScript}` 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.
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