bmad-module-skill-forge 4.0.0 → 6.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 (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -103,16 +103,21 @@ Modes, from the statuses:
103
103
 
104
104
  An unknown --language value adds an `unknown-language` warning.
105
105
 
106
- Library use: the one brief-scope test for script and asset paths.
107
- skf-new-file-diff.py (update-skill Category D, --brief),
108
- skf-detect-scripts-assets.py detect --brief (create-skill) and
109
- skf-compare-file-hashes.py compare --brief (audit-skill) load this file by
110
- its path and call load_scope(brief_path, cited). It returns a Scope:
111
- takes(path) is the in-scope test above (`_in_scope`), with the brief's
112
- `scope.include` and `scope.exclude` normalized as classify normalizes them
113
- and, for an empty `scope.include`, the extensions of the `cited` paths
114
- (the provenance map's `entries[].source_file`) and of the brief's
115
- `language`; wants(kind) is False for a `script` or `asset` whose
106
+ Library use: the one brief-scope test for script and asset paths, and for
107
+ the files a gap-driven repair reads. skf-new-file-diff.py (update-skill
108
+ Category D, --brief), skf-detect-scripts-assets.py detect --brief
109
+ (create-skill), skf-compare-file-hashes.py compare --brief (audit-skill)
110
+ and skf-build-change-manifest.py gap-records --brief (update-skill
111
+ gap-driven, which passes as `cited` each file its gaps cite or their
112
+ Remediation names and each `entries[].source_file` of the provenance
113
+ map) load this file by its path and call load_scope(brief_path, cited).
114
+ It returns a Scope: takes(path) is the in-scope test above
115
+ (`_in_scope`), with the brief's `scope.include` and `scope.exclude`
116
+ normalized as classify normalizes them and, for an empty
117
+ `scope.include`, the extensions of the `cited` paths (the provenance
118
+ map's `entries[].source_file` for the first three callers) and of the
119
+ brief's `language`, so a path with no extension is not taken; wants(kind)
120
+ is False for a `script` or `asset` whose
116
121
  `scripts_intent` or `assets_intent` is `none` (an absent or free-text
117
122
  intent detects). load_scope raises ValueError, `brief not found: <path>`
118
123
  for a path that names no file and load_brief's message for a brief it
@@ -23,11 +23,24 @@ Measured artifacts (all relative to the positional skill-package dir):
23
23
  extracted from the first --target-file that contains one
24
24
  (shared across the batch; measured once)
25
25
 
26
+ The managed_section object also gives `skills`, the number of skill rows (each
27
+ `[name v...]` row header) in the measured block, and `target_files`, every --target-file
28
+ that holds a block, in the order passed and each once. A target file that cannot be read
29
+ as UTF-8 text is skipped.
30
+
26
31
  package_total sums context-snippet.md + SKILL.md + metadata.json + references_total and
27
32
  DELIBERATELY EXCLUDES the managed-section row — the managed section is a shared all-skills
28
33
  cost reported separately, and including it would double-count this skill's own snippet
29
34
  (see references/token-report.md §1).
30
35
 
36
+ context_budget gives the token report's Context Budget Impact values, in tokens:
37
+ - always_on: the managed section (loaded in every session); null when no
38
+ target file holds a block
39
+ - on_trigger: SKILL.md (loaded when the skill triggers)
40
+ - full_disclosure: SKILL.md + references_total (loaded when every reference is read too);
41
+ neither the snippet nor metadata.json, so it is not package_total
42
+ - snippet: context-snippet.md (this skill's share of the managed section)
43
+
31
44
  CLI:
32
45
  python3 skf-count-tokens.py <skill-package-dir>
33
46
  python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md
@@ -52,6 +65,13 @@ MANAGED_SECTION_RE = re.compile(
52
65
  re.DOTALL,
53
66
  )
54
67
 
68
+ # A skill row header of the managed section: `[name v<version>]` at the start of a line,
69
+ # optionally after the format's leading `|`, with or without a `root:` field. The
70
+ # ` v<version>` keeps the section's own `[SKF Skills]|{n} skills|{m} stack` header out.
71
+ # This is skf-rebuild-managed-sections.py's ROW_HEADER_PATTERN, so both scripts count the
72
+ # rows parse_managed_rows returns (test-skf-count-tokens.py keeps them in step).
73
+ MANAGED_ROW_RE = re.compile(r"^\|?\[(?P<name>[^\]]+?) v(?P<version>[^\]\s]+)\]", re.MULTILINE)
74
+
55
75
  # Core per-skill artifacts, in report order. (references/ is handled separately.)
56
76
  CORE_ARTIFACTS = [
57
77
  ("context-snippet.md", "context-snippet"),
@@ -87,6 +107,11 @@ def extract_managed_section(text: str) -> str | None:
87
107
  return m.group(0) if m else None
88
108
 
89
109
 
110
+ def count_managed_skills(section: str) -> int:
111
+ """The number of skill rows (`[name v...]` row headers) in a managed section."""
112
+ return len(MANAGED_ROW_RE.findall(section))
113
+
114
+
90
115
  def measure_reference_files(references_dir: Path) -> tuple[list[dict], dict]:
91
116
  """Measure every file under references/ (recursively, sorted). Returns (per-file rows, total)."""
92
117
  rows: list[dict] = []
@@ -144,20 +169,38 @@ def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> di
144
169
  "tokens": sum(core_by_role[r]["tokens"] for r in core_by_role) + references_total["tokens"],
145
170
  }
146
171
 
147
- # Managed section — first --target-file that actually contains a block wins.
148
- managed_section = {"present": False, "source_file": None, "words": 0, "tokens": 0}
172
+ # Managed section: the first --target-file that actually contains a block is measured;
173
+ # target_files lists every one that holds a block.
174
+ managed_section = {"present": False, "source_file": None, "target_files": [], "skills": 0,
175
+ "words": 0, "tokens": 0}
176
+ seen: set[Path] = set()
149
177
  for tf in target_files:
150
178
  tf = Path(tf)
151
- if not tf.is_file():
179
+ if tf.resolve() in seen or not tf.is_file():
152
180
  continue
153
- section = extract_managed_section(_read_text(tf))
154
- if section is not None:
155
- managed_section = {
156
- "present": True,
157
- "source_file": tf.as_posix(),
181
+ seen.add(tf.resolve())
182
+ try:
183
+ section = extract_managed_section(_read_text(tf))
184
+ except (OSError, UnicodeDecodeError):
185
+ continue # unreadable or not UTF-8: it holds no block this helper can measure
186
+ if section is None:
187
+ continue
188
+ managed_section["target_files"].append(tf.as_posix())
189
+ if not managed_section["present"]:
190
+ managed_section.update(
191
+ present=True,
192
+ source_file=tf.as_posix(),
193
+ skills=count_managed_skills(section),
158
194
  **measure(section),
159
- }
160
- break
195
+ )
196
+
197
+ # The Context Budget Impact values token-report.md §2 renders.
198
+ context_budget = {
199
+ "always_on": managed_section["tokens"] if managed_section["present"] else None,
200
+ "on_trigger": core_by_role["skill-md"]["tokens"],
201
+ "full_disclosure": core_by_role["skill-md"]["tokens"] + references_total["tokens"],
202
+ "snippet": core_by_role["context-snippet"]["tokens"],
203
+ }
161
204
 
162
205
  return {
163
206
  "status": "ok",
@@ -166,6 +209,7 @@ def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> di
166
209
  "references_total": references_total,
167
210
  "managed_section": managed_section,
168
211
  "package_total": package_total,
212
+ "context_budget": context_budget,
169
213
  }
170
214
 
171
215
 
@@ -160,6 +160,26 @@ skf-recommend-scope-type.py reads (registry.ts, components.ts and their .tsx
160
160
  forms, at any depth), found by that script's own rule (the sibling in this
161
161
  folder, which must sit beside this script).
162
162
 
163
+ Brief scope (scoped_tree)
164
+ -------------------------
165
+
166
+ scoped_tree(tree, excludes, glob_match) is the tree a skill brief's
167
+ scope.exclude globs leave, as workspace detection reads it (#780): a
168
+ workspace member counts only through the files the brief keeps. It holds
169
+ each file no exclude glob matches, but a manifest (a MEMBER_MANIFESTS name);
170
+ each root manifest (a file at the root named in MEMBER_MANIFESTS,
171
+ CONTENT_MANIFESTS or nx.json), always; and each member manifest (a
172
+ MEMBER_MANIFESTS name below the root) whose folder still holds, at any
173
+ depth, a non-manifest file no exclude glob matches, whether or not a glob
174
+ matches the manifest itself. A member manifest whose folder holds no
175
+ non-manifest file at all stays unless a glob matches it. So `rust/**`,
176
+ `rust/**/*.rs` or `rust/*/src/**` drops the members under rust/,
177
+ `**/Cargo.toml` alone drops none, and a workspace with no member left is
178
+ no workspace, while one with a member left still is. With no exclude glob
179
+ the tree is returned as given. Callers (test-skill's stage-helper-payload.py
180
+ and skf-extract-public-api.py's monorepo arm) pass the glob rule of
181
+ skf-resolve-authoritative-files.py, which this script does not load.
182
+
163
183
  Exit codes:
164
184
 
165
185
  0 success (regardless of is_monorepo value)
@@ -181,6 +201,7 @@ import posixpath
181
201
  import re
182
202
  import sys
183
203
  import tomllib
204
+ from collections.abc import Callable
184
205
  from pathlib import Path
185
206
  from typing import Optional
186
207
 
@@ -200,6 +221,11 @@ RECOGNISED_MANIFESTS = (
200
221
  # in the tree alone): --manifest-files lists them, --manifest-dir reads them,
201
222
  # and a warning names each one the tree lists that was not supplied.
202
223
  CONTENT_MANIFESTS = ("package.json", "pnpm-workspace.yaml", "lerna.json", "rush.json", "Cargo.toml")
224
+ # The manifest a workspace member is found by: a recognised one, or an Nx
225
+ # project's project.json. scoped_tree keeps one while its folder keeps a file.
226
+ MEMBER_MANIFESTS = RECOGNISED_MANIFESTS + ("project.json",)
227
+ # The root files scoped_tree keeps whatever the brief excludes.
228
+ ROOT_MANIFESTS = frozenset(MEMBER_MANIFESTS + CONTENT_MANIFESTS + ("nx.json",))
203
229
  MAX_MANIFEST_BYTES = 1024 * 1024
204
230
  # Folders a snapshot's module root is never followed down through (names
205
231
  # matched in lower case): tests, test helpers and fixtures, docs and sites,
@@ -698,6 +724,47 @@ def detect(payload: dict) -> dict:
698
724
  }
699
725
 
700
726
 
727
+ # --------------------------------------------------------------------------
728
+ # Brief scope
729
+ # --------------------------------------------------------------------------
730
+
731
+
732
+ def scoped_tree(tree: list[str], excludes: list[str], glob_match: Callable[[str, str], bool]) -> list[str]:
733
+ """The paths of `tree` workspace detection reads under a brief whose
734
+ scope.exclude globs are `excludes`, in their order: see "Brief scope" in
735
+ the module docstring. `glob_match(path, glob)` is the brief's glob rule."""
736
+ if not excludes:
737
+ return list(tree)
738
+
739
+ def manifest(path: str) -> bool:
740
+ return posixpath.basename(path) in MEMBER_MANIFESTS
741
+
742
+ excluded = {path for path in tree if any(glob_match(path, glob) for glob in excludes)}
743
+ holding: set[str] = set() # folders holding a non-manifest file
744
+ kept_holding: set[str] = set() # folders holding one no exclude glob matches
745
+ for path in tree:
746
+ if manifest(path):
747
+ continue
748
+ parts = path.split("/")
749
+ for depth in range(1, len(parts)):
750
+ folder = "/".join(parts[:depth])
751
+ holding.add(folder)
752
+ if path not in excluded:
753
+ kept_holding.add(folder)
754
+ kept = []
755
+ for path in tree:
756
+ if "/" not in path and path in ROOT_MANIFESTS:
757
+ keep = True
758
+ elif not manifest(path):
759
+ keep = path not in excluded
760
+ else:
761
+ folder = posixpath.dirname(path)
762
+ keep = folder in kept_holding or (folder not in holding and path not in excluded)
763
+ if keep:
764
+ kept.append(path)
765
+ return kept
766
+
767
+
701
768
  # --------------------------------------------------------------------------
702
769
  # Tree snapshot
703
770
  # --------------------------------------------------------------------------
@@ -44,10 +44,17 @@ Subcommands:
44
44
  that name: the envelope warns that the `_bmad/custom/`
45
45
  overrides were not applied.
46
46
 
47
- record Append one entry to the run's sink: --decision reads one
48
- auto-decision (a JSON object) on stdin, --warning takes the
49
- warning text. With --workflow a decision is checked against
50
- that workflow's headless_decisions item schema first.
47
+ record Append to the run's sink: --decision reads one auto-decision
48
+ (a JSON object) on stdin; --warning takes one warning's text,
49
+ and a repeated --warning records each; --warnings-file reads a
50
+ JSON file that holds the warnings, an array of strings or an
51
+ object whose `warnings` is one (a helper's saved stdout, such
52
+ as skf-parse-gaps.py translate's), and records each, in order.
53
+ A blank or non-string warning, an unreadable file or an object
54
+ with no `warnings` list exits 1 and writes nothing; an empty
55
+ list writes nothing and exits 0. With --workflow a decision is
56
+ checked against that workflow's headless_decisions item schema
57
+ first.
51
58
 
52
59
  validate Read an envelope (without the prefix) as JSON on stdin and
53
60
  verify it against the workflow's schema (skf-setup's without
@@ -131,7 +138,8 @@ envelope field of the same name, except `result_contract` and
131
138
 
132
139
  Run sink. Every run owns a folder, `_bmad-output/.skf-run/<workflow>-<run_id>/`,
133
140
  passed as --run-dir. Each gate records its auto-decision there the moment
134
- it decides, and each warning as it is raised, with `record`:
141
+ it decides, and each warning as it is raised, with `record` (one call can
142
+ record several warnings: each line is appended in that one write):
135
143
 
136
144
  {run_dir}/headless-decisions.jsonl one decision object per line
137
145
  {run_dir}/warnings.jsonl one warning string per line
@@ -299,6 +307,7 @@ the first of `uv run`, `python3`, `python` and `py -3` that works.
299
307
  uv run skf-emit-result-envelope.py emit-blocked --phase "step 2:write-tools" --path "<path>" --reason "<message>" --stderr-from "{run_dir}/write-tools.err"
300
308
  echo '{"skf_setup":{...}}' | uv run skf-emit-result-envelope.py validate
301
309
  uv run skf-emit-result-envelope.py record --run-dir "{run_dir}" --warning "customization_resolver_unavailable: <reason>"
310
+ uv run skf-emit-result-envelope.py record --run-dir "{run_dir}" --warnings-file "{run_dir}/translate.json"
302
311
 
303
312
  Exit codes:
304
313
  0 success
@@ -423,11 +432,12 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
423
432
  def _resolver_warning(payload: dict) -> list[str]:
424
433
  """The customization_resolver_unavailable warning, when the payload reports one.
425
434
 
426
- Every workflow's On Activation runs resolve_customization.py. When that
427
- script is missing or fails, the run falls back to the skill's own
428
- customize.toml, so the team and user overrides under `_bmad/custom/`
429
- silently do nothing; the payload key carries the resolver's reason so
430
- the envelope says so.
435
+ Every workflow's On Activation runs skf-resolve-customization.py, which
436
+ runs BMAD core's resolve_customization.py. When either script is missing
437
+ or fails, or a core before 6.11.0 would read another folder's overrides,
438
+ the run falls back to the skill's own customize.toml, so the team and
439
+ user overrides under `_bmad/custom/` silently do nothing; the payload
440
+ key carries the reason, whatever its words, so the envelope says so.
431
441
  """
432
442
  reason = payload.get("customization_resolver_unavailable")
433
443
  if not reason:
@@ -1787,13 +1797,42 @@ def run_emit(workflow: str | None, *, halt: bool, label: str, run_dir: str | Non
1787
1797
  print(envelope_line(schema, envelope), file=sys.stderr if target == "stderr" else sys.stdout)
1788
1798
 
1789
1799
 
1800
+ def _warnings_to_record(args) -> list[str]:
1801
+ """The warnings one `record` call appends: each --warning, or the --warnings-file's list (a JSON array of
1802
+ strings, or an object whose `warnings` is one). A blank or non-string entry, an unreadable file or an
1803
+ object with no `warnings` list exits 1 before anything is written."""
1804
+ if args.warnings_file is None:
1805
+ entries, label = list(args.warning), "--warning"
1806
+ else:
1807
+ label = f"--warnings-file {args.warnings_file}"
1808
+ try:
1809
+ data = json.loads(Path(args.warnings_file).read_bytes().decode("utf-8-sig"))
1810
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
1811
+ _die(1, f"record: cannot read {label}: {getattr(e, 'strerror', None) or e}")
1812
+ if isinstance(data, dict):
1813
+ if not isinstance(data.get("warnings"), list):
1814
+ _die(1, f"record: {label} is an object with no `warnings` list")
1815
+ entries = data["warnings"]
1816
+ elif isinstance(data, list):
1817
+ entries = data
1818
+ else:
1819
+ _die(1, f"record: {label} holds neither a JSON array nor an object with `warnings`")
1820
+ out = []
1821
+ for i, entry in enumerate(entries):
1822
+ if not isinstance(entry, str) or not entry.strip():
1823
+ where = "" if args.warnings_file is None and len(entries) == 1 else f" (entry {i})"
1824
+ _die(1, f"record: {label} must be a non-empty string{where}")
1825
+ out.append(entry.strip())
1826
+ return out
1827
+
1828
+
1790
1829
  def cmd_record(args) -> None:
1791
- """Append one decision or one warning to the run's sink, one JSON value per line."""
1830
+ """Append one decision, or each warning given, to the run's sink, one JSON value per line."""
1792
1831
  run_dir = Path(args.run_dir)
1793
- if args.warning is not None:
1794
- value = args.warning.strip()
1795
- if not value:
1796
- _die(1, "record: --warning must be a non-empty string")
1832
+ if args.warning is not None or args.warnings_file is not None:
1833
+ values = _warnings_to_record(args)
1834
+ if not values:
1835
+ return
1797
1836
  name = SINK_WARNINGS
1798
1837
  else:
1799
1838
  value = _read_stdin_json("record")
@@ -1807,14 +1846,15 @@ def cmd_record(args) -> None:
1807
1846
  errors = _validate_against_schema(value, item_schema)
1808
1847
  if errors:
1809
1848
  _die(1, f"record: the decision fails {schema_path.name}: {'; '.join(errors)}")
1849
+ values = [value]
1810
1850
  name = SINK_DECISIONS
1811
1851
  # ASCII only: a raw RAW_LINE_BREAKS character would split the line for a
1812
- # reader that breaks lines there.
1813
- line = json.dumps(value, separators=(",", ":"), ensure_ascii=True) + "\n"
1852
+ # reader that breaks lines there. Every line goes out in one write.
1853
+ lines = "".join(json.dumps(v, separators=(",", ":"), ensure_ascii=True) + "\n" for v in values)
1814
1854
  try:
1815
1855
  run_dir.mkdir(parents=True, exist_ok=True)
1816
1856
  with open(run_dir / name, "a", encoding="utf-8", newline="\n") as fh:
1817
- fh.write(line)
1857
+ fh.write(lines)
1818
1858
  except OSError as e:
1819
1859
  _die(1, f"record: cannot append to {(run_dir / name).as_posix()}: {e.strerror or e}")
1820
1860
 
@@ -1974,7 +2014,7 @@ def main() -> None:
1974
2014
  p_emit = sub.add_parser("emit", help="Build a run's envelope from its context payload (default).")
1975
2015
  p_halt = sub.add_parser("emit-halt", help="Build a HARD HALT's envelope from its halt payload.")
1976
2016
  p_blocked = sub.add_parser("emit-blocked", help="Emit skf-setup's status='blocked' envelope for early-halt paths.")
1977
- p_record = sub.add_parser("record", help="Append an auto-decision or a warning to the run's sink.")
2017
+ p_record = sub.add_parser("record", help="Append an auto-decision or warnings to the run's sink.")
1978
2018
  p_validate = sub.add_parser("validate", help="Validate an envelope payload against the schema.")
1979
2019
  p_render = sub.add_parser("render-report", help="Print skf-setup's FORGE STATUS banner for its payload.")
1980
2020
  for p in (p_emit, p_halt):
@@ -1991,7 +2031,11 @@ def main() -> None:
1991
2031
  help="Check a decision against this workflow's headless_decisions schema.")
1992
2032
  kind = p_record.add_mutually_exclusive_group(required=True)
1993
2033
  kind.add_argument("--decision", action="store_true", help="Read one decision object on stdin.")
1994
- kind.add_argument("--warning", default=None, help="The warning text.")
2034
+ kind.add_argument("--warning", action="append", default=None,
2035
+ help="A warning's text (repeatable: each one is recorded).")
2036
+ kind.add_argument("--warnings-file", default=None, metavar="FILE",
2037
+ help="A JSON file of warnings: an array of strings, or an object whose `warnings` is one "
2038
+ "(a helper's saved stdout).")
1995
2039
  p_validate.add_argument("--workflow", default=None,
1996
2040
  help="Workflow folder name or schema stem (skf-setup when omitted).")
1997
2041
  p_render.add_argument("--run-dir", default=None, help="The setup run folder that holds the staged helper outputs.")