bmad-module-skill-forge 2.0.2 → 2.1.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/404.md +4 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/RELEASING.md +7 -4
  5. package/docs/agents.md +2 -2
  6. package/docs/architecture.md +1 -1
  7. package/docs/bmad-synergy.md +13 -13
  8. package/docs/campaign.md +3 -3
  9. package/docs/concepts.md +3 -3
  10. package/docs/examples.md +9 -9
  11. package/docs/forge-auto.md +6 -6
  12. package/docs/getting-started.md +12 -12
  13. package/docs/how-it-works.md +7 -7
  14. package/docs/index.md +7 -1
  15. package/docs/skill-model.md +2 -2
  16. package/docs/troubleshooting.md +7 -7
  17. package/docs/why-skf.md +8 -8
  18. package/docs/workflows.md +5 -5
  19. package/package.json +7 -3
  20. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  21. package/src/shared/scripts/skf-detect-language.py +20 -0
  22. package/src/shared/scripts/skf-detect-tools.py +34 -0
  23. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  24. package/src/shared/scripts/skf-language-corpora.py +20 -0
  25. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  26. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  27. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  28. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  29. package/src/shared/scripts/skf-shape-detect.py +20 -0
  30. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  31. package/src/skf-analyze-source/references/recommend.md +1 -1
  32. package/src/skf-audit-skill/references/re-index.md +1 -1
  33. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  34. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  35. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  36. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  37. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  38. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  39. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  40. package/src/skf-create-skill/references/compile.md +12 -3
  41. package/src/skf-create-skill/references/extract.md +1 -1
  42. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  43. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  44. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  45. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  46. package/src/skf-setup/references/ccc-index.md +2 -2
  47. package/src/skf-setup/references/report.md +1 -1
  48. package/src/skf-test-skill/references/coverage-check.md +11 -5
  49. package/src/skf-test-skill/references/score.md +7 -1
  50. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  51. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  52. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  53. package/tools/cli/lib/installer.js +31 -1
  54. package/tools/validate-docs-links.js +665 -0
@@ -123,7 +123,13 @@ Use these values for Section 4 (pass/fail/inconclusive) and Section 6 (output fo
123
123
 
124
124
  #### 3c. Fallback (if script execution fails)
125
125
 
126
- If the script is unavailable or errors, redistribute each skipped category's weight (the `null`-scored categories in the §3a JSON — naive mode already zeroes coherence, and Quick-tier/docsOnly/state2/stackSkill/referenceApp already null out Signature Accuracy + Type Coverage) proportionally across the active categories, then report `total = Σ(weight/100 × category_score)` using the detected mode's weight table in `{scoringRulesFile}`. Report: "**Note:** Scoring script unavailable — calculated manually per scoring-rules.md."
126
+ **First distinguish "the script could not run" from "the script rejected the input" — they take opposite paths.**
127
+
128
+ A `{"error": ..., "code": "INVALID_INPUT"}` envelope on stdout means the script ran fine and refused what it was given: a field is missing, mistyped, or out of range (exit 2), or the payload was not parseable JSON at all (exit 1). Either way the *input* is wrong, not the script. **Correct the §3a input and re-run.** Do not fall through to the manual redistribution below — it would hand-compute a total from the very numbers the script just refused, so an out-of-range `exportCoverage` would silently become a score. If the input cannot be corrected, report `score: scoring input rejected — {error}` and leave the score unset rather than emitting a computed total.
129
+
130
+ The fallback below applies only when the script genuinely could not run — missing file, no `uv`, unreadable interpreter — that is, **no envelope on stdout at all**.
131
+
132
+ If the script is unavailable, redistribute each skipped category's weight (the `null`-scored categories in the §3a JSON — naive mode already zeroes coherence, and Quick-tier/docsOnly/state2/stackSkill/referenceApp already null out Signature Accuracy + Type Coverage) proportionally across the active categories, then report `total = Σ(weight/100 × category_score)` using the detected mode's weight table in `{scoringRulesFile}`. Report: "**Note:** Scoring script unavailable — calculated manually per scoring-rules.md."
127
133
 
128
134
  ### 3d. Read Post-Score Caps (applied by the script)
129
135
 
@@ -62,10 +62,14 @@ CLI usage (mirrors compute-score.py):
62
62
  uv run aggregate-coherence.py --json-input '<JSON>' # explicit flag form
63
63
  cat input.json | uv run aggregate-coherence.py --stdin # piped input
64
64
 
65
- Exit codes:
66
- 0 — input was parsed; either a result object or a schema-validation error
67
- object ({"error": ..., "code": "INVALID_INPUT"}) was emitted as JSON
65
+ Exit codes (same convention as compute-score.py / reconcile-coverage.py):
66
+ 0 — a result object was emitted
68
67
  1 — input could not be parsed at all (no input provided, or malformed JSON)
68
+ 2 — input parsed but schema/semantics invalid
69
+
70
+ Both 1 and 2 emit an {"error": ..., "code": "INVALID_INPUT"} envelope on stdout,
71
+ so the envelope's presence — not the specific code — tells a caller the input was
72
+ refused rather than scored.
69
73
  """
70
74
 
71
75
  from __future__ import annotations
@@ -269,6 +273,12 @@ def main(argv: list[str] | None = None) -> int:
269
273
 
270
274
  result = aggregate_coherence(data)
271
275
  print(json.dumps(result, indent=2))
276
+ # Same convention as compute-score.py / reconcile-coverage.py. This script
277
+ # produces the coherence percentage that score.md §3a feeds to
278
+ # compute-score.py, so a silently-rejected run here would hand a bogus or
279
+ # absent number to the gate one layer downstream.
280
+ if isinstance(result, dict) and result.get("code") == "INVALID_INPUT":
281
+ return 2
272
282
  return 0
273
283
 
274
284
 
@@ -39,11 +39,17 @@ Verdict override (post-score caps + threshold fallback — see compute_score):
39
39
  and `result` is the final verdict. `result` itself is never mutated — it is
40
40
  always the pre-cap/pre-fallback score-vs-threshold (or evidence-floor) verdict.
41
41
 
42
- Exit codes:
43
- 0 — input was parsed; either a score was computed (verdict may be
44
- PASS/FAIL/INCONCLUSIVE) or a schema/validation error object
45
- ({"error": ..., "code": "INVALID_INPUT"}) was emitted as JSON
42
+ Exit codes (same convention as reconcile-coverage.py):
43
+ 0 — a score was computed; the verdict may be PASS/FAIL/INCONCLUSIVE
46
44
  1 — input could not be parsed at all (no input provided, or malformed JSON)
45
+ 2 — input parsed but schema/semantics invalid
46
+
47
+ Both 1 and 2 emit an {"error": ..., "code": "INVALID_INPUT"} envelope on stdout,
48
+ so the presence of that envelope — not the specific code — is what tells a caller
49
+ the input was refused. A refused input is distinct from an unavailable script:
50
+ the numbers handed in are wrong and must be fixed, NOT hand-computed into a total
51
+ from the values this script declined to score. score.md §3c keys its
52
+ manual-redistribution fallback on that distinction (no envelope at all).
47
53
  """
48
54
 
49
55
  from __future__ import annotations
@@ -508,6 +514,13 @@ def main(argv: list[str] | None = None) -> int:
508
514
 
509
515
  result = compute_score(data)
510
516
  print(json.dumps(result, indent=2))
517
+ # A rejected input exits 2, matching reconcile-coverage.py. Exiting 0 here
518
+ # made a malformed scoring input indistinguishable from a scored run, so the
519
+ # error envelope could be skimmed past and the score hand-computed from the
520
+ # very numbers the script refused. score.md §3c keys the manual-redistribution
521
+ # fallback on this distinction.
522
+ if isinstance(result, dict) and result.get("code") == "INVALID_INPUT":
523
+ return 2
511
524
  return 0
512
525
 
513
526
 
@@ -22,15 +22,24 @@ It covers the three branches §2c enumerates:
22
22
  * "scalar" (§4 priority-1 effective_denominator, no enumerated name set) —
23
23
  grep each documented name across SKILL.md ∪ references/*.md;
24
24
  Documented = count present, denominator = effective_denominator,
25
- Missing = denominator − Documented, Stale not enumerable (empty),
26
- Export Coverage = Documented / denominator * 100.
25
+ Missing = max(0, denominator − Documented), Stale not enumerable (empty),
26
+ Export Coverage = min(100, Documented / denominator * 100).
27
27
 
28
28
  * "stack" (skill_type == "stack", empty source barrel) — grep each
29
29
  composition-surface name (provenance-map cited contracts, ::-excluded, or
30
30
  libraries + integration_pairs — resolved upstream) across
31
31
  SKILL.md ∪ references/*.md; denominator = stack_denominator,
32
- Missing = denominator − Documented, Stale not enumerable,
33
- Export Coverage = Documented / stack_denominator * 100.
32
+ Missing = max(0, denominator − Documented), Stale not enumerable,
33
+ Export Coverage = min(100, Documented / stack_denominator * 100).
34
+
35
+ The two grep branches bound their outputs because the numerator and the
36
+ denominator are independent measures of different sets — the grep count can
37
+ exceed a consumer-surface denominator without either being wrong. Unbounded,
38
+ that produced a negative Missing and a >100% coverage that compute-score.py
39
+ rejects as out of range. `documented` is left as the true count (it is reported
40
+ verbatim as "Documented in SKILL.md"); the derived ratio is what gets bounded,
41
+ and `numeratorSurplus` / `coverageUncapped` / `coverageCapped` report the
42
+ overshoot so a deflated denominator stays visible instead of being swallowed.
34
43
 
35
44
  CLI usage:
36
45
  uv run reconcile-coverage.py '<JSON>' # JSON literal positional
@@ -55,11 +64,14 @@ Output (stdout, one object):
55
64
  "denominator": <int>,
56
65
  "documented": <int>,
57
66
  "missing": [names], # enumerated: source names not documented; scalar/stack: []
58
- "missingCount": <int>, # always present
67
+ "missingCount": <int>, # always present; never negative
59
68
  "stale": [names], # enumerated: documented names not in source; scalar/stack: []
60
69
  "staleCount": <int>, # always present
61
70
  "staleApplicable": <bool>, # false for scalar/stack (no barrel to enumerate)
62
- "exportCoverage": <float>
71
+ "exportCoverage": <float>, # scalar/stack: capped at 100
72
+ "numeratorSurplus": <int>, # scalar/stack only: max(0, documented − denominator)
73
+ "coverageUncapped": <float>, # scalar/stack only: the ratio before the cap
74
+ "coverageCapped": <bool> # scalar/stack only: true when the cap bound the ratio
63
75
  }
64
76
 
65
77
  Exit codes:
@@ -232,18 +244,33 @@ def reconcile(inp, doc_text=None):
232
244
  "upstream §2b guard should HALT before reconciliation"
233
245
  )
234
246
 
235
- missing_count = denominator - documented
247
+ # The grep numerator and the resolved denominator are independent measures,
248
+ # so `documented` can legitimately exceed `denominator` — a consumer-surface
249
+ # denominator counts one surface while the documented body may also name
250
+ # migration aliases, re-exported sibling symbols, and other extras. Left
251
+ # unbounded that yields a negative Missing and >100% coverage (which
252
+ # compute-score.py then rejects outright as out of range).
253
+ #
254
+ # `documented` stays the true grep count: it is reported as "Documented in
255
+ # SKILL.md", so capping it would print a number that was never measured.
256
+ # The derived ratio is what gets bounded, and the surplus is surfaced rather
257
+ # than swallowed so a deflated denominator stays visible.
258
+ surplus = max(0, documented - denominator)
259
+ coverage_uncapped = round2(documented / denominator * 100)
236
260
  return {
237
261
  "branch": source,
238
262
  "denominatorSource": source,
239
263
  "denominator": denominator,
240
264
  "documented": documented,
241
265
  "missing": [],
242
- "missingCount": missing_count,
266
+ "missingCount": max(0, denominator - documented),
267
+ "numeratorSurplus": surplus,
243
268
  "stale": [],
244
269
  "staleCount": 0,
245
270
  "staleApplicable": False,
246
- "exportCoverage": round2(documented / denominator * 100),
271
+ "exportCoverage": min(100.0, coverage_uncapped),
272
+ "coverageUncapped": coverage_uncapped,
273
+ "coverageCapped": surplus > 0,
247
274
  }
248
275
 
249
276
 
@@ -205,10 +205,39 @@ class Installer {
205
205
  }
206
206
  }
207
207
 
208
+ /**
209
+ * Read the health_check_repo default from src/module.yaml (single source of truth).
210
+ */
211
+ async readHealthCheckRepoDefault() {
212
+ try {
213
+ const moduleData = yaml.load(await fs.readFile(path.join(this.srcDir, 'module.yaml'), 'utf8'));
214
+ const value = moduleData && moduleData.health_check_repo && moduleData.health_check_repo.default;
215
+ if (typeof value === 'string' && value.length > 0) return value;
216
+ } catch {
217
+ /* fall through to literal fallback */
218
+ }
219
+ return 'armelhbobdad/bmad-module-skill-forge';
220
+ }
221
+
208
222
  async writeConfig(skfDir, config) {
209
223
  // On update, restore the user's existing config
210
224
  if (config._savedConfigYaml) {
211
- await fs.writeFile(path.join(skfDir, 'config.yaml'), config._savedConfigYaml, 'utf8');
225
+ let savedYaml = config._savedConfigYaml;
226
+ try {
227
+ // Inject health_check_repo into configs generated before it existed.
228
+ // Append one line instead of re-dumping so user comments/ordering survive.
229
+ // Only mapping-shaped (or empty/comments-only) docs are appendable —
230
+ // appending a key to an array or scalar doc would corrupt the YAML.
231
+ const savedData = yaml.load(savedYaml);
232
+ const isPlainObject = typeof savedData === 'object' && savedData !== null && !Array.isArray(savedData);
233
+ if (savedData == null || (isPlainObject && !('health_check_repo' in savedData))) {
234
+ const suffix = savedYaml.endsWith('\n') ? '' : '\n';
235
+ savedYaml = `${savedYaml}${suffix}health_check_repo: ${await this.readHealthCheckRepoDefault()}\n`;
236
+ }
237
+ } catch {
238
+ /* unparseable saved config — restore verbatim */
239
+ }
240
+ await fs.writeFile(path.join(skfDir, 'config.yaml'), savedYaml, 'utf8');
212
241
  return;
213
242
  }
214
243
 
@@ -232,6 +261,7 @@ class Installer {
232
261
  forge_data_folder: config.forge_data_folder || 'forge-data',
233
262
  sidecar_path: '_bmad/_memory/forger-sidecar',
234
263
  skf_folder: config.skfFolder,
264
+ health_check_repo: await this.readHealthCheckRepoDefault(),
235
265
  ides: config.ides || [],
236
266
  install_learning: config.install_learning !== false,
237
267
  };