bmad-module-skill-forge 3.0.0 → 4.0.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
@@ -12,13 +12,25 @@ follows from records the run already wrote, so this script builds it rather
12
12
  than the model retyping them from memory:
13
13
 
14
14
  - the report frontmatter: skillName, runId, testMode, hardGate,
15
- testResult, score, threshold, thresholdFallback, originalThreshold and
16
- evidenceReportPath (the steps write the verdict inputs there, so a
17
- compacted context cannot change them);
15
+ testResult, workspaceDrift, score, threshold, thresholdFallback,
16
+ originalThreshold and evidenceReportPath (the steps write the verdict
17
+ inputs there, so a compacted context cannot change them);
18
18
  - the gap ledger: the gap counts by severity (gap-ledger.py's summary);
19
- - the scoring script's output, which score.md saves: activeCategories and
20
- inconclusiveReasons. A run the hard gate blocked was never scored and
21
- has none.
19
+ - the scoring script's output, which score.md saves: the verdict, score
20
+ and threshold it settled, activeCategories and inconclusiveReasons. A
21
+ run the hard gate blocked was never scored and has none.
22
+
23
+ A scored run's verdict fields are checked against the scoring output
24
+ (`scored_fields`): testResult must be the verdict the output settled,
25
+ `effectiveResult` when the output has one, else `result`, in lower case,
26
+ with `pass` read as `pass-with-drift` when the frontmatter workspaceDrift
27
+ is `overridden`; score its `totalScore`; threshold the one the verdict was
28
+ settled against (compute-score.py's FALLBACK_FLOOR after a threshold
29
+ fallback, else its `threshold`); and thresholdFallback and
30
+ originalThreshold its override group's. A field copied from anything else
31
+ (`result` over a cap's FAIL, a bare pass under drift, the target threshold
32
+ of a run the fallback accepted) exits 1 and writes no payload, so
33
+ report.md halts before it publishes the report or writes a result file.
22
34
 
23
35
  The derived fields live here, in one place:
24
36
 
@@ -49,9 +61,14 @@ Payload (written to --output, UTF-8 JSON):
49
61
  "runId": <runId>, "healthCheckDispatched": <bool>}}
50
62
 
51
63
  CLI:
52
- uv run build-result-context.py --report <report.md> --ledger <ledger.json>
53
- [--score <scoring output>] [--no-health-check] [--warning <text>]...
54
- --output <result-context.json>
64
+ uv run build-result-context.py --report <report.md> [--report-path <path>]
65
+ --ledger <ledger.json> [--score <scoring output>] [--no-health-check]
66
+ [--warning <text>]... --output <result-context.json>
67
+
68
+ --score is required unless the frontmatter says the hard gate blocked the
69
+ run. --report-path is the path the payload names the report by (default:
70
+ --report): report.md builds the payload from the report before it gives
71
+ the report its public name, and passes that name here.
55
72
 
56
73
  --warning hands the emitter a warning the run kept for its result (such as
57
74
  `customization_resolver_unavailable: <reason>`); the emitter adds the ones
@@ -65,8 +82,10 @@ takes: "stderr" for a blocked run, else "stdout".
65
82
  Exit codes:
66
83
  0 the payload was written
67
84
  1 an input is missing or unreadable, the report's frontmatter holds no
68
- settled verdict, or --output cannot be written ({"status": "error",
69
- "error"} on stdout)
85
+ settled verdict or a verdict field the scoring output does not settle,
86
+ or --output cannot be written ({"status": "error", "error"} on stdout)
87
+ 2 a usage error: no --score for a run the hard gate did not block (the
88
+ same JSON on stdout), or a flag argparse refuses (its usage on stderr)
70
89
  """
71
90
 
72
91
  from __future__ import annotations
@@ -93,6 +112,10 @@ class ContextError(Exception):
93
112
  """An input the payload cannot be built from: exit 1."""
94
113
 
95
114
 
115
+ class UsageError(ContextError):
116
+ """A command the caller can correct and run again: exit 2."""
117
+
118
+
96
119
  def _sibling(name: str, module: str):
97
120
  """Import a script that sits beside this one (gap-ledger.py, hard-gate.py)."""
98
121
  path = Path(__file__).resolve().parent / name
@@ -152,9 +175,60 @@ def _read_json(path: str, label: str):
152
175
  raise ContextError(f"{label} {path} cannot be read: {exc}") from exc
153
176
 
154
177
 
178
+ def _fallback_floor() -> int:
179
+ """The threshold a fallback accepts a run at: compute-score.py's, which applies the fallback."""
180
+ return _sibling("compute-score.py", "skf_compute_score_floor").FALLBACK_FLOOR
181
+
182
+
183
+ def settled_test_result(scored: dict, workspace_drift: str | None) -> str | None:
184
+ """The testResult the scoring output settles, or None when it holds no verdict.
185
+
186
+ >>> settled_test_result({"result": "PASS"}, "ok")
187
+ 'pass'
188
+ >>> settled_test_result({"result": "PASS", "effectiveResult": "FAIL", "capReason": "Cap 1"}, "ok")
189
+ 'fail'
190
+ >>> settled_test_result({"result": "FAIL", "effectiveResult": "PASS"}, "overridden")
191
+ 'pass-with-drift'
192
+ >>> settled_test_result({"result": "INCONCLUSIVE"}, "overridden")
193
+ 'inconclusive'
194
+ >>> settled_test_result({"error": "bad flag", "code": "INVALID_INPUT"}, "ok") is None
195
+ True
196
+ """
197
+ verdict = scored.get("effectiveResult") or scored.get("result")
198
+ settled = verdict.lower() if isinstance(verdict, str) else None
199
+ if settled not in ("pass", "fail", "inconclusive"):
200
+ return None
201
+ return "pass-with-drift" if settled == "pass" and workspace_drift == "overridden" else settled
202
+
203
+
204
+ def scored_fields(scored: dict, workspace_drift: str | None) -> dict | None:
205
+ """The frontmatter verdict fields the scoring output settles, or None when it holds no verdict.
206
+
207
+ >>> scored_fields({"result": "PASS", "totalScore": 91.5, "threshold": 80}, "ok")
208
+ {'testResult': 'pass', 'score': 91.5, 'threshold': 80, 'thresholdFallback': False, 'originalThreshold': None}
209
+ >>> fell_back = {"result": "FAIL", "effectiveResult": "PASS", "capReason": None, "thresholdFallback": True,
210
+ ... "originalThreshold": 90, "totalScore": 85, "threshold": 90}
211
+ >>> scored_fields(fell_back, "ok")
212
+ {'testResult': 'pass', 'score': 85, 'threshold': 80, 'thresholdFallback': True, 'originalThreshold': 90}
213
+ """
214
+ test_result = settled_test_result(scored, workspace_drift)
215
+ if test_result is None:
216
+ return None
217
+ fallback = scored.get("thresholdFallback") is True
218
+ return {
219
+ "testResult": test_result,
220
+ "score": scored.get("totalScore"),
221
+ "threshold": _fallback_floor() if fallback else scored.get("threshold"),
222
+ "thresholdFallback": fallback,
223
+ "originalThreshold": scored.get("originalThreshold") if fallback else None,
224
+ }
225
+
226
+
155
227
  def build(report: str, ledger: str, score: str | None = None, health_check: bool = True,
156
- warnings: list[str] | None = None) -> dict:
157
- """The emitter payload for the run whose report and ledger are given."""
228
+ warnings: list[str] | None = None, report_path: str | None = None) -> dict:
229
+ """The emitter payload for the run whose report and ledger are given; `report_path`
230
+ (default `report`) is the path the payload names the report by."""
231
+ report_path = report_path or report
158
232
  try:
159
233
  front = read_frontmatter(Path(report).read_bytes().decode("utf-8-sig"))
160
234
  except (OSError, UnicodeDecodeError) as exc:
@@ -170,7 +244,7 @@ def build(report: str, ledger: str, score: str | None = None, health_check: bool
170
244
  skill = front.get("skillName") or None
171
245
  blocked = front.get("hardGate") == "blocked"
172
246
  if blocked:
173
- envelope = dict(_sibling("hard-gate.py", "skf_hard_gate").blocked_envelope(skill, report))
247
+ envelope = dict(_sibling("hard-gate.py", "skf_hard_gate").blocked_envelope(skill, report_path))
174
248
  result, active, inconclusive = "FAIL", [], None
175
249
  else:
176
250
  test_result = front.get("testResult", "")
@@ -183,14 +257,29 @@ def build(report: str, ledger: str, score: str | None = None, health_check: bool
183
257
  "verdict": verdict,
184
258
  "score": percent(front.get("score")),
185
259
  "threshold": percent(front.get("threshold")),
186
- "report_path": report,
260
+ "report_path": report_path,
187
261
  "next_workflow": next_workflow,
188
262
  "exit_code": exit_code,
189
263
  "halt_reason": None,
190
264
  }
191
- scored = _read_json(score, "--score") if score else {}
265
+ if not score:
266
+ raise UsageError("--score is required for a run the hard gate did not block: its verdict "
267
+ "fields are checked against the scoring output")
268
+ scored = _read_json(score, "--score")
192
269
  if not isinstance(scored, dict):
193
270
  raise ContextError(f"--score {score} is not a JSON object")
271
+ settled = scored_fields(scored, front.get("workspaceDrift"))
272
+ if settled is None:
273
+ raise ContextError(f"--score {score} holds no verdict (no effectiveResult or result)")
274
+ fell_back = front.get("thresholdFallback", "").lower() == "true"
275
+ written = {"testResult": test_result, "score": envelope["score"], "threshold": envelope["threshold"],
276
+ "thresholdFallback": fell_back,
277
+ "originalThreshold": percent(front.get("originalThreshold")) if fell_back else None}
278
+ wrong = [f"{key} {written[key]!r} where it settles {settled[key]!r}"
279
+ for key in settled if written[key] != settled[key]]
280
+ if wrong:
281
+ raise ContextError(f"--report {report} disagrees with the scoring output {score}: "
282
+ + "; ".join(wrong))
194
283
  active = scored.get("activeCategories", [])
195
284
  inconclusive = scored.get("inconclusiveReasons") if verdict == "INCONCLUSIVE" else None
196
285
 
@@ -216,7 +305,7 @@ def build(report: str, ledger: str, score: str | None = None, health_check: bool
216
305
  summary["hardGate"] = front.get("hardGate") or None
217
306
 
218
307
  contract = {"skill": WORKFLOW, **envelope,
219
- "outputs": [{"type": "report", "path": report}],
308
+ "outputs": [{"type": "report", "path": report_path}],
220
309
  "summary": summary,
221
310
  "runId": front.get("runId") or None,
222
311
  "healthCheckDispatched": health_check}
@@ -233,9 +322,12 @@ def _build_parser() -> argparse.ArgumentParser:
233
322
  description=("Build the shared emitter's payload for a finished test-skill run from its "
234
323
  "report frontmatter, its gap ledger and the scoring output."),
235
324
  )
236
- parser.add_argument("--report", required=True, help="the test report, at its published name")
325
+ parser.add_argument("--report", required=True, help="the test report to read")
326
+ parser.add_argument("--report-path",
327
+ help="the path the payload names the report by: its public name (default: --report)")
237
328
  parser.add_argument("--ledger", required=True, help="the run's gap ledger")
238
- parser.add_argument("--score", help="the scoring script's output (absent for a blocked run)")
329
+ parser.add_argument("--score", help="the scoring script's output (required unless the hard gate blocked "
330
+ "the run)")
239
331
  parser.add_argument("--no-health-check", action="store_true",
240
332
  help="the run was given --no-health-check (healthCheckDispatched false)")
241
333
  parser.add_argument("--warning", action="append", default=[],
@@ -247,13 +339,14 @@ def _build_parser() -> argparse.ArgumentParser:
247
339
  def main(argv: list[str] | None = None) -> int:
248
340
  args = _build_parser().parse_args(argv)
249
341
  try:
250
- payload = build(args.report, args.ledger, args.score, not args.no_health_check, args.warning)
342
+ payload = build(args.report, args.ledger, args.score, not args.no_health_check, args.warning,
343
+ args.report_path)
251
344
  out = Path(args.output)
252
345
  out.parent.mkdir(parents=True, exist_ok=True)
253
346
  out.write_bytes((json.dumps(payload, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
254
347
  except (ContextError, OSError) as exc:
255
348
  print(json.dumps({"status": "error", "error": str(exc)}))
256
- return 1
349
+ return 2 if isinstance(exc, UsageError) else 1
257
350
  blocked = payload["halt_reason"] is not None
258
351
  print(json.dumps({
259
352
  "status": payload["status"],