bmad-module-skill-forge 5.0.0 → 7.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/docs/_data/pinned.yaml +1 -1
- package/docs/agents.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/campaign.md +1 -1
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +9 -9
- package/docs/workflows.md +7 -7
- 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/skf-build-change-manifest.py +332 -54
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
- package/src/shared/scripts/skf-extract-public-api.py +33 -13
- package/src/shared/scripts/skf-parse-gaps.py +3 -2
- package/src/shared/scripts/skf-quick-batch.py +16 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +317 -44
- package/src/skf-analyze-source/SKILL.md +1 -0
- package/src/skf-analyze-source/customize.toml +3 -2
- package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
- package/src/skf-analyze-source/references/generate-briefs.md +5 -3
- package/src/skf-analyze-source/references/headless-contract.md +3 -3
- package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
- package/src/skf-audit-skill/SKILL.md +2 -1
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -0
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-campaign/SKILL.md +1 -0
- package/src/skf-campaign/references/campaign-contracts.md +3 -1
- package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
- package/src/skf-campaign/references/step-07-capstone.md +3 -3
- package/src/skf-campaign/references/step-08-verify.md +3 -3
- package/src/skf-campaign/references/step-09-refine.md +3 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-campaign/scripts/campaign-state.py +22 -5
- package/src/skf-create-skill/SKILL.md +3 -2
- package/src/skf-create-skill/customize.toml +4 -4
- package/src/skf-create-skill/references/batch-mode.md +3 -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 +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +9 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +2 -2
- package/src/skf-create-stack-skill/SKILL.md +1 -0
- 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 +12 -4
- package/src/skf-drop-skill/SKILL.md +1 -0
- 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 +1 -0
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-forger/SKILL.md +1 -0
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -1
- package/src/skf-quick-skill/references/batch-mode.md +2 -0
- 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 +20 -10
- package/src/skf-refine-architecture/SKILL.md +2 -1
- 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 +1 -0
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +1 -0
- 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 +1 -0
- package/src/skf-test-skill/assets/output-section-formats.md +4 -4
- package/src/skf-test-skill/references/coherence-check.md +8 -6
- package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +30 -43
- package/src/skf-test-skill/references/init.md +1 -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/report.md +3 -3
- package/src/skf-test-skill/references/scoring-rules.md +7 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +74 -0
- package/src/skf-test-skill/references/source-access-protocol.md +12 -8
- 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 +78 -13
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +575 -9
- package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
- package/src/skf-test-skill/scripts/score-signatures.py +937 -76
- package/src/skf-test-skill/scripts/validate-inventory.py +325 -30
- package/src/skf-update-skill/SKILL.md +1 -0
- package/src/skf-update-skill/customize.toml +1 -1
- package/src/skf-update-skill/references/gap-driven.md +28 -22
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +4 -4
- package/src/skf-update-skill/references/report.md +11 -4
- package/src/skf-update-skill/references/write.md +6 -5
- package/src/skf-verify-stack/SKILL.md +2 -1
|
@@ -8,13 +8,29 @@ Structural validator for the SKF test-skill coverage step (coverage-check.md
|
|
|
8
8
|
§1a "Schema validation" block). test-skill is a quality gate, so it must not
|
|
9
9
|
trust subagent output blindly: before any downstream step consumes the
|
|
10
10
|
documented inventory, the shape is validated. That validation is pure
|
|
11
|
-
structural checking
|
|
11
|
+
structural checking (strip wrapping fences, parse JSON, assert required keys
|
|
12
12
|
and types, assert each export entry is a dict with a non-empty `name` and a
|
|
13
|
-
`kind` drawn from a fixed enum
|
|
14
|
-
`partial` or `full
|
|
15
|
-
|
|
16
|
-
interpretation of meaning. It is therefore script work, not prompt
|
|
17
|
-
prompt reads this verdict and owns only the HALT decision.
|
|
13
|
+
`kind` drawn from a fixed enum and, when present, a `signature_form` of
|
|
14
|
+
`partial` or `full`, and assert each cross-check mismatch carries its fields
|
|
15
|
+
and one other side, in order), with exactly one correct pass/fail per input
|
|
16
|
+
and no interpretation of meaning. It is therefore script work, not prompt
|
|
17
|
+
work; the prompt reads this verdict and owns only the HALT decision.
|
|
18
|
+
|
|
19
|
+
A cross-check mismatch (coverage-check.md §1 compares the SKILL.md body
|
|
20
|
+
with each reference file, and two reference files the body does not settle)
|
|
21
|
+
carries `export`, `reference_file`, `reference_line` and `issue`, and the
|
|
22
|
+
other side of its pair: `skill_md_line` for the SKILL.md body, or
|
|
23
|
+
`other_file` and `other_line` for a second reference file, never both and
|
|
24
|
+
never neither (a JSON null counts as absent). Each file is a
|
|
25
|
+
`references/**/*.md` file of the package, normalized to its path relative
|
|
26
|
+
to the package with forward slashes (an absolute path counts only inside
|
|
27
|
+
--skill-package), and the inventory echoed back carries that path; each line
|
|
28
|
+
is a positive integer. A pair of references is written in one order,
|
|
29
|
+
(`other_file`, `other_line`) sorting before (`reference_file`,
|
|
30
|
+
`reference_line`) in codepoint order, so a rerun gives the gap one title and
|
|
31
|
+
the ledger records it once; one export documented twice in one reference is
|
|
32
|
+
a pair of its two lines. A violation of the other side names both shapes,
|
|
33
|
+
and one of the order names both places.
|
|
18
34
|
|
|
19
35
|
With --skill-package it also runs the ground-truth spot-check (coverage-check.md
|
|
20
36
|
§1a): it sorts the exports by name (a stable sort), samples the first, the
|
|
@@ -27,6 +43,30 @@ violation, and the prompt re-dispatches the subagent with them, as it does
|
|
|
27
43
|
for a schema violation. No grep runs in the prompt, so an export named
|
|
28
44
|
`$state` or `a.b` is never a shell variable or a regex.
|
|
29
45
|
|
|
46
|
+
With --floor it also checks the inventory against the floor
|
|
47
|
+
load-coverage-inputs.py floor wrote (#790): the names the skill documents in
|
|
48
|
+
a fixed form. A floor name is checked when one of its origins is SKILL.md
|
|
49
|
+
or a file the inventory's `references` lists (the spot-check's files; a
|
|
50
|
+
listed absolute path inside the --skill-package folder counts as its path
|
|
51
|
+
relative to it); a name only an unlisted reference writes is not. It is
|
|
52
|
+
kept when any inventory entry has that name, whatever its kind: kinds stay
|
|
53
|
+
the reader's. Each checked name not
|
|
54
|
+
kept is a violation naming its first checked origin, and the inventory is
|
|
55
|
+
invalid, as for a fabricated name, so the prompt re-dispatches the subagent
|
|
56
|
+
with the names it left out. A checked origin that carries a `signature`
|
|
57
|
+
(#888: the `params` and `return_type` the skill writes there in a fixed
|
|
58
|
+
form) is carried when an entry of that name, whatever its kind, reads as it
|
|
59
|
+
does: each of `params` and `return_type` the signature pins non-blank gives
|
|
60
|
+
the same field_text (whitespace runs as one space, a list's items joined
|
|
61
|
+
with `, `, a null field as blank), and a `params` that opens with the shared
|
|
62
|
+
text the contract has several names' entries carry, then `; `, carries the
|
|
63
|
+
pinned one after it. A field the signature leaves blank, and the entry's
|
|
64
|
+
other fields, a `usage_signature` among them, are not read.
|
|
65
|
+
Each checked origin no entry carries is a violation naming the origin and
|
|
66
|
+
its signature, for a name left out too, and the inventory is invalid: the
|
|
67
|
+
form is never overwritten, so the re-dispatched subagent copies it as
|
|
68
|
+
given.
|
|
69
|
+
|
|
30
70
|
An entry's optional `signature_form` says how the skill presents its
|
|
31
71
|
signature: `partial` only when the skill labels the form partial (a
|
|
32
72
|
simplified form, a key-parameter summary, a `...` that stands in for
|
|
@@ -58,7 +98,10 @@ directly in references/ writes it as a fixed string, case-sensitive, never
|
|
|
58
98
|
inside a longer identifier (the Export match rule of
|
|
59
99
|
skf-scan-skill-md-structure.py usage-scope): on a side where the name ends
|
|
60
100
|
in a letter, digit, `_` or `$`, the next character may not be one of those.
|
|
61
|
-
So `get` is found in `get(url)` but not in `target` or `getAll`.
|
|
101
|
+
So `get` is found in `get(url)` but not in `target` or `getAll`. The form
|
|
102
|
+
helpers `field_text` and `form_key` are shared too: score-signatures.py
|
|
103
|
+
tells an entry's forms apart with them, and the floor check reads a floor
|
|
104
|
+
origin's `signature` with `field_text`.
|
|
62
105
|
|
|
63
106
|
The skill-citation scan is shared too: `_skill_cited_files` (with
|
|
64
107
|
`_cited_path`, `skill_markdown_files` and `_SKILL_CITATION_RE`) gives the
|
|
@@ -70,7 +113,7 @@ declaration the skill cites.
|
|
|
70
113
|
|
|
71
114
|
CLI usage (mirrors reconcile-coverage.py):
|
|
72
115
|
uv run validate-inventory.py --input <response file> [--output <inventory file>]
|
|
73
|
-
[--skill-package <package folder>]
|
|
116
|
+
[--skill-package <package folder>] [--floor <floor file>]
|
|
74
117
|
uv run validate-inventory.py '<raw response>' # positional
|
|
75
118
|
uv run validate-inventory.py --json-input '<raw response>' # explicit flag
|
|
76
119
|
echo '<raw response>' | uv run validate-inventory.py --stdin # piped input
|
|
@@ -84,7 +127,8 @@ path, so a quote or an apostrophe in a description never meets a shell.
|
|
|
84
127
|
JSON) when it is valid, for the steps that read it by path; an invalid
|
|
85
128
|
inventory removes a file left there by an earlier attempt. --skill-package
|
|
86
129
|
names the skill package under test (the folder that holds SKILL.md) and
|
|
87
|
-
adds the spot-check.
|
|
130
|
+
adds the spot-check. --floor names the floor file (UTF-8 JSON) and adds the
|
|
131
|
+
floor check.
|
|
88
132
|
|
|
89
133
|
Input: the subagent's RAW response text (JSON, optionally wrapped in a markdown
|
|
90
134
|
code fence — a leading line of three backticks with an optional language tag and
|
|
@@ -107,15 +151,24 @@ Output (stdout, one object):
|
|
|
107
151
|
"absent": [<name>, ...], # the sampled names no file writes
|
|
108
152
|
"files": [<path>, ...], # the files read, relative to the package
|
|
109
153
|
"unread": [<path>, ...] # listed references that are missing,
|
|
110
|
-
}
|
|
154
|
+
}, # unreadable or outside the package
|
|
155
|
+
"floor": { # with --floor, once the schema passed
|
|
156
|
+
"names": <int>, # the floor's names
|
|
157
|
+
"checked": <int>, # those with an origin in SKILL.md or a listed reference
|
|
158
|
+
"absent": [<name>, ...] # the checked names no inventory entry lists
|
|
159
|
+
}
|
|
111
160
|
}
|
|
112
161
|
|
|
113
162
|
Exit codes:
|
|
114
|
-
0 inventory valid (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
163
|
+
0 inventory valid (every sampled name found, every checked floor name
|
|
164
|
+
kept and every checked floor signature carried)
|
|
165
|
+
1 no input provided, the --input file cannot be read, the
|
|
166
|
+
--skill-package folder holds no readable SKILL.md, or the --floor file
|
|
167
|
+
cannot be read or is not a floor, a malformed `signature` among them
|
|
168
|
+
(usage error)
|
|
169
|
+
2 inventory invalid (parse failure, schema violation, a sampled name
|
|
170
|
+
absent, a floor name left out or a floor signature no entry carries;
|
|
171
|
+
result JSON emitted)
|
|
119
172
|
"""
|
|
120
173
|
|
|
121
174
|
from __future__ import annotations
|
|
@@ -154,16 +207,31 @@ SIGNATURE_FORMS = ("partial", "full")
|
|
|
154
207
|
|
|
155
208
|
REQUIRED_MISMATCH_FIELDS = (
|
|
156
209
|
"export",
|
|
157
|
-
"skill_md_line",
|
|
158
210
|
"reference_file",
|
|
159
211
|
"reference_line",
|
|
160
212
|
"issue",
|
|
161
213
|
)
|
|
162
214
|
|
|
215
|
+
# The other side of a cross-check pair, exactly one of these shapes: the
|
|
216
|
+
# SKILL.md body's line, or a second reference file and its line.
|
|
217
|
+
BODY_SIDE = ("skill_md_line",)
|
|
218
|
+
REFERENCE_SIDE = ("other_file", "other_line")
|
|
219
|
+
OTHER_SIDE_SHAPES = (
|
|
220
|
+
"`skill_md_line` (the SKILL.md body) or `other_file` and `other_line` "
|
|
221
|
+
"(a second reference file)"
|
|
222
|
+
)
|
|
223
|
+
|
|
163
224
|
# Kinds whose documented item also needs its parameters and return type to
|
|
164
225
|
# be complete (the docs-only Export Coverage rule).
|
|
165
226
|
SIGNATURE_KINDS = ("function", "method")
|
|
166
227
|
|
|
228
|
+
# An entry's signature fields, the ones form_key reads (score-signatures.py reads them from here).
|
|
229
|
+
SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
|
|
230
|
+
|
|
231
|
+
# The fields of a floor origin's `signature` (#888), each a string, and the
|
|
232
|
+
# ones the floor check compares with an entry's.
|
|
233
|
+
FLOOR_SIGNATURE_FIELDS = ("params", "return_type")
|
|
234
|
+
|
|
167
235
|
# Characters that continue an identifier in the languages SKF documents
|
|
168
236
|
# (`$` for JS/TS names such as `$state`).
|
|
169
237
|
IDENT_CHARS = r"\w$"
|
|
@@ -366,6 +434,131 @@ def spot_check(inventory, skill_package_path):
|
|
|
366
434
|
}
|
|
367
435
|
|
|
368
436
|
|
|
437
|
+
# --- Signature forms ----------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def field_text(value: object) -> str:
|
|
441
|
+
"""A signature field as one text: a list's items joined with `, `, whitespace runs as one space, "" for null."""
|
|
442
|
+
if value is None:
|
|
443
|
+
return ""
|
|
444
|
+
if isinstance(value, list):
|
|
445
|
+
value = ", ".join(v if isinstance(v, str) else json.dumps(v, sort_keys=True) for v in value)
|
|
446
|
+
return " ".join(value.split()) if isinstance(value, str) else json.dumps(value, sort_keys=True)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def form_key(fields: dict) -> tuple:
|
|
450
|
+
"""What a form says, for telling two entries apart (field_text), a
|
|
451
|
+
null, blank or empty key read as absent, and the `signature_form` label
|
|
452
|
+
left out (score-signatures.py's documented_forms keeps the full one)."""
|
|
453
|
+
return tuple((f, field_text(fields[f])) for f in SIGNATURE_FIELDS if f in fields and field_text(fields[f]))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# --- Floor ------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class FloorError(Exception):
|
|
460
|
+
"""The --floor file cannot be read or is not a floor: exit 1."""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _is_signature(value):
|
|
464
|
+
"""A floor origin's `signature`: an object of exactly the strings `params` and `return_type`."""
|
|
465
|
+
return (isinstance(value, dict) and sorted(value) == sorted(FLOOR_SIGNATURE_FIELDS)
|
|
466
|
+
and all(isinstance(value[f], str) for f in FLOOR_SIGNATURE_FIELDS))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _is_origin(origin):
|
|
470
|
+
return (isinstance(origin, dict) and isinstance(origin.get("file"), str) and bool(origin["file"])
|
|
471
|
+
and isinstance(origin.get("line"), int) and not isinstance(origin["line"], bool)
|
|
472
|
+
and isinstance(origin.get("form"), str)
|
|
473
|
+
and ("signature" not in origin or _is_signature(origin["signature"])))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def read_floor(path):
|
|
477
|
+
"""The floor load-coverage-inputs.py floor wrote to `path`, its shape checked."""
|
|
478
|
+
try:
|
|
479
|
+
data = json.loads(Path(path).read_bytes().decode("utf-8-sig"))
|
|
480
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
481
|
+
raise FloorError(f"cannot read {path}: {exc}") from exc
|
|
482
|
+
except json.JSONDecodeError as exc:
|
|
483
|
+
raise FloorError(f"{path} is not JSON: {exc.msg}") from exc
|
|
484
|
+
names = data.get("names") if isinstance(data, dict) else None
|
|
485
|
+
if not isinstance(names, list) or not all(
|
|
486
|
+
isinstance(item, dict) and isinstance(item.get("name"), str) and item["name"]
|
|
487
|
+
and isinstance(item.get("origins"), list) and all(_is_origin(o) for o in item["origins"])
|
|
488
|
+
for item in names):
|
|
489
|
+
raise FloorError(f"{path} is not a floor: `names` must give each name's `name` and `origins` "
|
|
490
|
+
"(each a file, a line and a form, and an optional `signature` of the strings "
|
|
491
|
+
"`params` and `return_type`)")
|
|
492
|
+
return data
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _listed_path(ref, skill_package_path):
|
|
496
|
+
"""A listed reference as a floor origin names it: relative to the package, forward slashes. An absolute
|
|
497
|
+
path inside the package is made relative to it (it needs the package to be)."""
|
|
498
|
+
if skill_package_path is not None and os.path.isabs(ref):
|
|
499
|
+
target, root = os.path.realpath(ref), os.path.realpath(skill_package_path)
|
|
500
|
+
if _inside(target, root):
|
|
501
|
+
return Path(os.path.relpath(target, root)).as_posix()
|
|
502
|
+
return _cited_path(ref)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _carries(entry, signature):
|
|
506
|
+
"""Whether an entry carries a floor origin's signature: each field it pins non-blank has the same field_text
|
|
507
|
+
there, a `params` that opens with members the skill states once for several names (the contract's shared
|
|
508
|
+
text, then `; `) included."""
|
|
509
|
+
for field in FLOOR_SIGNATURE_FIELDS:
|
|
510
|
+
wanted, given = field_text(signature[field]), field_text(entry.get(field))
|
|
511
|
+
if wanted and given != wanted and not (field == "params" and given.endswith("; " + wanted)):
|
|
512
|
+
return False
|
|
513
|
+
return True
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def floor_check(inventory, floor, skill_package_path=None):
|
|
517
|
+
"""The floor names and signatures the inventory leaves out: (the `floor` result, its violations).
|
|
518
|
+
|
|
519
|
+
A floor name is checked when one of its origins is SKILL.md or a file
|
|
520
|
+
the inventory's `references` lists (the spot-check's files, an absolute
|
|
521
|
+
path inside `skill_package_path` matched by its path relative to it),
|
|
522
|
+
and kept when any inventory entry has that name, whatever its kind. A
|
|
523
|
+
name left out is a violation naming its first checked origin. Then each
|
|
524
|
+
checked origin with a `signature` is carried when an entry of that name,
|
|
525
|
+
whatever its kind, gives the same field_text to each field the
|
|
526
|
+
signature pins non-blank (a blank pinned `return_type` leaves the
|
|
527
|
+
entry's free; a `params` may open with shared text and `; `, _carries);
|
|
528
|
+
one no entry carries is a violation naming the origin and the
|
|
529
|
+
signature, for a name left out too.
|
|
530
|
+
"""
|
|
531
|
+
listed = inventory.get("references")
|
|
532
|
+
files = {"SKILL.md"} | {_listed_path(ref, skill_package_path)
|
|
533
|
+
for ref in (listed if isinstance(listed, list) else [])
|
|
534
|
+
if isinstance(ref, str) and ref.strip()}
|
|
535
|
+
entries = [e for e in inventory.get("exports") or [] if isinstance(e, dict)]
|
|
536
|
+
kept = {e.get("name") for e in entries}
|
|
537
|
+
names = floor.get("names") or []
|
|
538
|
+
checked, absent, violations, signed = 0, [], [], []
|
|
539
|
+
for item in names:
|
|
540
|
+
origins = [o for o in item["origins"] if _cited_path(o["file"]) in files]
|
|
541
|
+
if not origins:
|
|
542
|
+
continue
|
|
543
|
+
checked += 1
|
|
544
|
+
name = item["name"]
|
|
545
|
+
signed += [(name, o) for o in origins if "signature" in o]
|
|
546
|
+
if name in kept:
|
|
547
|
+
continue
|
|
548
|
+
absent.append(name)
|
|
549
|
+
where = f"{origins[0]['file']}:{origins[0]['line']} ({origins[0]['form']})"
|
|
550
|
+
violations.append(f"floor: `{name}` is documented at {where}, but no inventory entry lists it")
|
|
551
|
+
for name, origin in signed:
|
|
552
|
+
signature = {f: origin["signature"][f] for f in FLOOR_SIGNATURE_FIELDS}
|
|
553
|
+
if any(_carries(e, signature) for e in entries if e.get("name") == name):
|
|
554
|
+
continue
|
|
555
|
+
where = f"{origin['file']}:{origin['line']} ({origin['form']})"
|
|
556
|
+
violations.append(f"floor: `{name}` is documented at {where} with the signature "
|
|
557
|
+
f"{json.dumps(signature, ensure_ascii=False)}, but no inventory entry of that name "
|
|
558
|
+
"carries it; copy it as given")
|
|
559
|
+
return {"names": len(names), "checked": checked, "absent": absent}, violations
|
|
560
|
+
|
|
561
|
+
|
|
369
562
|
# --- Completeness -------------------------------------------------------------
|
|
370
563
|
|
|
371
564
|
|
|
@@ -415,13 +608,100 @@ def completeness(exports):
|
|
|
415
608
|
# --- Validation ---------------------------------------------------------------
|
|
416
609
|
|
|
417
610
|
|
|
418
|
-
def
|
|
419
|
-
"""
|
|
611
|
+
def _reference_path(value, skill_package_path=None):
|
|
612
|
+
"""A pair's file as the package names it (`_listed_path`: relative, forward slashes, no `./`), or None when
|
|
613
|
+
it is no `references/**/*.md` file of the package: empty, not a string, `SKILL.md`, another file, or an
|
|
614
|
+
absolute path outside the package (or with no package to make it relative to)."""
|
|
615
|
+
if not isinstance(value, str) or not value.strip():
|
|
616
|
+
return None
|
|
617
|
+
path = _listed_path(value.strip(), skill_package_path)
|
|
618
|
+
parts = path.split("/")
|
|
619
|
+
if len(parts) < 2 or parts[0] != "references" or ".." in parts or not parts[-1].endswith(".md") \
|
|
620
|
+
or parts[-1] == ".md":
|
|
621
|
+
return None
|
|
622
|
+
return path
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _is_line(value):
|
|
626
|
+
"""A line number: a positive integer, never a bool or a string."""
|
|
627
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def mismatch_violations(idx, entry, skill_package_path=None):
|
|
631
|
+
"""The violations of one cross_check_mismatches entry (an object).
|
|
632
|
+
|
|
633
|
+
A required field left out; a `reference_file` or `other_file` that is no
|
|
634
|
+
`references/**/*.md` path of the package; a line that is no positive
|
|
635
|
+
integer; an other side that is not exactly one shape, `skill_md_line` or
|
|
636
|
+
both of `other_file` and `other_line` (a JSON null counts as absent); or a
|
|
637
|
+
pair of references out of order: (`other_file`, `other_line`) must sort
|
|
638
|
+
before (`reference_file`, `reference_line`) in codepoint order, so the pair
|
|
639
|
+
has one title. Each path is checked, and left in the entry, as the package
|
|
640
|
+
names it, relative and with forward slashes.
|
|
641
|
+
"""
|
|
642
|
+
where = f"cross_check_mismatches[{idx}]"
|
|
643
|
+
violations = []
|
|
644
|
+
missing = [f for f in REQUIRED_MISMATCH_FIELDS if f not in entry]
|
|
645
|
+
if missing:
|
|
646
|
+
violations.append(f"{where} missing field(s): {', '.join(missing)}")
|
|
647
|
+
ref = None
|
|
648
|
+
if "reference_file" in entry:
|
|
649
|
+
ref = _reference_path(entry["reference_file"], skill_package_path)
|
|
650
|
+
if ref is None:
|
|
651
|
+
violations.append(f"{where}: `reference_file` must be a `references/*.md` file of the skill package, "
|
|
652
|
+
f"not {json.dumps(entry['reference_file'])}")
|
|
653
|
+
else:
|
|
654
|
+
entry["reference_file"] = ref
|
|
655
|
+
lines_ok = True
|
|
656
|
+
for field in ("reference_line",) + BODY_SIDE + REFERENCE_SIDE[1:]:
|
|
657
|
+
given = entry.get(field) is not None or (field == "reference_line" and field in entry)
|
|
658
|
+
if given and not _is_line(entry[field]):
|
|
659
|
+
lines_ok = False
|
|
660
|
+
violations.append(f"{where}: `{field}` must be a line number (a positive integer), "
|
|
661
|
+
f"not {json.dumps(entry[field])}")
|
|
662
|
+
body = [f for f in BODY_SIDE if entry.get(f) is not None]
|
|
663
|
+
reference = [f for f in REFERENCE_SIDE if entry.get(f) is not None]
|
|
664
|
+
if body and reference:
|
|
665
|
+
violations.append(f"{where} gives two other sides: give one, {OTHER_SIDE_SHAPES}")
|
|
666
|
+
return violations
|
|
667
|
+
if not body and not reference:
|
|
668
|
+
violations.append(f"{where} missing its other side: give {OTHER_SIDE_SHAPES}")
|
|
669
|
+
return violations
|
|
670
|
+
if body:
|
|
671
|
+
return violations
|
|
672
|
+
absent = [f for f in REFERENCE_SIDE if f not in reference]
|
|
673
|
+
if absent:
|
|
674
|
+
violations.append(f"{where} missing field(s): {', '.join(absent)}; its other side is "
|
|
675
|
+
f"{OTHER_SIDE_SHAPES}")
|
|
676
|
+
return violations
|
|
677
|
+
other = _reference_path(entry["other_file"], skill_package_path)
|
|
678
|
+
if other is None:
|
|
679
|
+
violations.append(f"{where}: `other_file` must be a `references/*.md` file of the skill package, "
|
|
680
|
+
f"not {json.dumps(entry['other_file'])}")
|
|
681
|
+
return violations
|
|
682
|
+
entry["other_file"] = other
|
|
683
|
+
if ref is None or not lines_ok or "reference_line" not in entry:
|
|
684
|
+
return violations
|
|
685
|
+
first, last = (other, entry["other_line"]), (ref, entry["reference_line"])
|
|
686
|
+
if not first < last:
|
|
687
|
+
relation = "names the same line as" if first == last else "sorts after"
|
|
688
|
+
violations.append(
|
|
689
|
+
f"{where}: `other_file` {json.dumps(other)} at `other_line` {first[1]} {relation} "
|
|
690
|
+
f"`reference_file` {json.dumps(ref)} at `reference_line` {last[1]}: a pair of references gives the "
|
|
691
|
+
"one that sorts first in codepoint order, by path and then line, as `other_file` and `other_line`, "
|
|
692
|
+
"and the one that sorts last as `reference_file` and `reference_line`"
|
|
693
|
+
)
|
|
694
|
+
return violations
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def validate_inventory(raw, skill_package_path=None, floor=None):
|
|
698
|
+
"""Structural validation of the subagent inventory response, then the spot-check and the floor check.
|
|
420
699
|
|
|
421
700
|
`raw` is the raw response text. Returns the result dict documented in the
|
|
422
701
|
module docstring. With `skill_package_path`, an inventory that passed the
|
|
423
|
-
schema is spot-checked against the package (spot_check)
|
|
424
|
-
|
|
702
|
+
schema is spot-checked against the package (spot_check); with `floor`
|
|
703
|
+
(read_floor's object), it is checked against the floor (floor_check).
|
|
704
|
+
Deterministic: identical input yields an identical verdict.
|
|
425
705
|
"""
|
|
426
706
|
violations = []
|
|
427
707
|
inner = strip_fences(raw)
|
|
@@ -491,25 +771,27 @@ def validate_inventory(raw, skill_package_path=None):
|
|
|
491
771
|
f"not {json.dumps(entry['signature_form'])}"
|
|
492
772
|
)
|
|
493
773
|
|
|
494
|
-
#
|
|
774
|
+
# Each cross-check mismatch carries its fields and one other side of its
|
|
775
|
+
# pair, a pair of references in order.
|
|
495
776
|
for idx, entry in enumerate(cross):
|
|
496
777
|
if not isinstance(entry, dict):
|
|
497
778
|
violations.append(f"cross_check_mismatches[{idx}] is not an object")
|
|
498
779
|
continue
|
|
499
|
-
|
|
500
|
-
if missing:
|
|
501
|
-
violations.append(
|
|
502
|
-
f"cross_check_mismatches[{idx}] missing field(s): {', '.join(missing)}"
|
|
503
|
-
)
|
|
780
|
+
violations.extend(mismatch_violations(idx, entry, skill_package_path))
|
|
504
781
|
|
|
782
|
+
schema_valid = not violations
|
|
505
783
|
checked = None
|
|
506
|
-
if
|
|
784
|
+
if schema_valid and skill_package_path is not None:
|
|
507
785
|
checked = spot_check(data, skill_package_path)
|
|
508
786
|
for name in checked["absent"]:
|
|
509
787
|
violations.append(
|
|
510
788
|
f"spot-check: `{name}` is listed as an export, but SKILL.md and the "
|
|
511
789
|
f"listed reference files never write it"
|
|
512
790
|
)
|
|
791
|
+
floor_result = None
|
|
792
|
+
if schema_valid and floor is not None:
|
|
793
|
+
floor_result, floor_violations = floor_check(data, floor, skill_package_path)
|
|
794
|
+
violations.extend(floor_violations)
|
|
513
795
|
|
|
514
796
|
valid = not violations
|
|
515
797
|
result = {
|
|
@@ -523,6 +805,8 @@ def validate_inventory(raw, skill_package_path=None):
|
|
|
523
805
|
result["completeness"] = completeness(exports)
|
|
524
806
|
if checked is not None:
|
|
525
807
|
result["spotCheck"] = checked
|
|
808
|
+
if floor_result is not None:
|
|
809
|
+
result["floor"] = floor_result
|
|
526
810
|
return result
|
|
527
811
|
|
|
528
812
|
|
|
@@ -536,8 +820,9 @@ def _build_parser():
|
|
|
536
820
|
"Deterministic schema validation of the §1 subagent inventory JSON "
|
|
537
821
|
"(coverage-check.md §1a). Strips wrapping fences, parses, and asserts "
|
|
538
822
|
"the required-keys/types/enum/mismatch-field contract, then with "
|
|
539
|
-
"--skill-package spot-checks sampled names against the package
|
|
540
|
-
"
|
|
823
|
+
"--skill-package spot-checks sampled names against the package and --floor checks it "
|
|
824
|
+
"keeps the floor's names, returning "
|
|
825
|
+
"{valid, violations, rejectedCount, exportsCount, inventory, completeness, spotCheck, floor}."
|
|
541
826
|
),
|
|
542
827
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
543
828
|
)
|
|
@@ -573,6 +858,11 @@ def _build_parser():
|
|
|
573
858
|
metavar="DIR",
|
|
574
859
|
help="Spot-check sampled names against this package's SKILL.md and listed references.",
|
|
575
860
|
)
|
|
861
|
+
parser.add_argument(
|
|
862
|
+
"--floor",
|
|
863
|
+
metavar="PATH",
|
|
864
|
+
help="Check the inventory keeps the names and signatures of this load-coverage-inputs.py floor file.",
|
|
865
|
+
)
|
|
576
866
|
return parser
|
|
577
867
|
|
|
578
868
|
|
|
@@ -612,7 +902,12 @@ def main(argv=None):
|
|
|
612
902
|
return 1
|
|
613
903
|
|
|
614
904
|
try:
|
|
615
|
-
|
|
905
|
+
floor = read_floor(args.floor) if args.floor is not None else None
|
|
906
|
+
except FloorError as exc:
|
|
907
|
+
print(f"error: --floor: {exc}", file=sys.stderr)
|
|
908
|
+
return 1
|
|
909
|
+
try:
|
|
910
|
+
result = validate_inventory(raw, args.skill_package, floor)
|
|
616
911
|
except PackageError as exc:
|
|
617
912
|
print(f"error: --skill-package: {exc}", file=sys.stderr)
|
|
618
913
|
return 1
|
|
@@ -31,6 +31,7 @@ These rules apply to every step in this workflow:
|
|
|
31
31
|
- Never hallucinate — every statement must have AST provenance
|
|
32
32
|
- [MANUAL] sections survive regeneration with zero content loss
|
|
33
33
|
- Only load one step file at a time — never preload future steps
|
|
34
|
+
- Run every subagent in the foreground (parallel ones as several calls in one message, never with `run_in_background`) and wait for every long-running command to exit before the next instruction; in a headless run, never end a turn while either still runs, since under `claude -p` a run stops when its turn ends.
|
|
34
35
|
- Always communicate in `{communication_language}`
|
|
35
36
|
- If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action and log each auto-decision
|
|
36
37
|
- Every HALT, ABORT or other exit before step 7 runs the **Halt procedure** of the step file it fires in: one `{runStateHelper}` `halt` call that undoes this run's writes, removes the private source tree, releases the run lock and, headless, prints the halt's line. A halt never falls through to step 6.
|
|
@@ -52,7 +52,7 @@ persistent_facts = [
|
|
|
52
52
|
# command is called as:
|
|
53
53
|
# <on_complete> --result-path=<{forge_version}/update-skill-result-latest.json>
|
|
54
54
|
# It is not called for --detect-only or --dry-run, which write no result
|
|
55
|
-
# file, nor when a run halts.
|
|
55
|
+
# file, nor when a run halts or did not write its -latest copy.
|
|
56
56
|
# Useful for Slack notifications, dashboard ingest, CI hooks, or chaining a
|
|
57
57
|
# downstream skill (audit, export, test). A failure is reported to the user
|
|
58
58
|
# in one line and never fails the workflow; it does not reach the result
|