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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/agents.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/campaign.md +1 -1
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +42 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +6 -6
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
- package/src/shared/scripts/skf-extract-public-api.py +472 -120
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +136 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +3 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/generate-briefs.md +1 -1
- package/src/skf-analyze-source/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/SKILL.md +4 -3
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +3 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +3 -2
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +4 -3
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +3 -2
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +8 -3
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +3 -2
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +3 -2
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +3 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +3 -2
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +4 -3
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +8 -9
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +3 -2
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +3 -2
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +3 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +11 -8
- package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +60 -97
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +5 -5
- package/src/skf-test-skill/references/scoring-rules.md +8 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +14 -10
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
- package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +3 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +54 -33
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +6 -5
- package/src/skf-update-skill/references/write.md +42 -20
- package/src/skf-verify-stack/SKILL.md +4 -3
- 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
|
|
108
|
-
skf-detect-scripts-assets.py detect --brief
|
|
109
|
-
skf-compare-file-hashes.py compare --brief (audit-skill)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
that
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
1795
|
-
if not
|
|
1796
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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.")
|