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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  # /// script
3
3
  # requires-python = ">=3.11"
4
- # dependencies = []
4
+ # dependencies = ["pyyaml"]
5
5
  # ///
6
6
  """Print the stdin payload of a shared SKF helper, read from the files on disk.
7
7
 
@@ -13,7 +13,7 @@ This script reads the files and prints the payload, and the step pipes it
13
13
  into the helper:
14
14
 
15
15
  uv run stage-helper-payload.py detect-workspaces --source-root <dir> \\
16
- | uv run skf-detect-workspaces.py
16
+ [--brief <skill-brief.yaml>] | uv run skf-detect-workspaces.py
17
17
 
18
18
  (The Quick-tier export scan needs no payload: skf-extract-public-api.py
19
19
  --mode quick reads its files itself, with --manifest-file and --entry-file.)
@@ -24,28 +24,48 @@ detect-workspaces prints {"tree": [...], "manifests": {...}}:
24
24
  it, sorted. Folders named in SKIPPED_DIRS (installed
25
25
  dependencies, virtual environments, build output, caches) and
26
26
  hidden folders (a name that starts with ".") are not read, and a
27
- linked folder is not followed.
27
+ linked folder is not followed. With --brief, the tree is the
28
+ detector's scoped_tree of it under the brief's scope.exclude
29
+ globs (#780): a workspace member counts only through the files
30
+ the brief keeps, so excluding a member's source files drops it,
31
+ excluding only its manifest does not, every root manifest
32
+ stays, and a workspace with no member left is no workspace.
28
33
  manifests the text of each file directly in --source-root that is not
29
34
  hidden, holds at most MAX_ROOT_FILE_BYTES and reads as UTF-8, by
30
- its name. The detector reads the root manifests it knows
31
- (package.json, pnpm-workspace.yaml, lerna.json, Cargo.toml ...)
32
- and ignores the rest, so this script keeps no list of their
33
- names to fall out of step with the detector's.
35
+ its name, whatever the brief excludes. The detector reads the
36
+ root manifests it knows (package.json, pnpm-workspace.yaml,
37
+ lerna.json, Cargo.toml ...) and ignores the rest, so this script
38
+ keeps no list of their names to fall out of step with the
39
+ detector's.
40
+
41
+ --brief reads the brief with load-coverage-inputs.py's read_brief (the
42
+ script beside this one, which loads skf-resolve-authoritative-files.py's
43
+ parse_brief_yaml from the shared scripts folder), and matches each
44
+ scope.exclude glob by its glob_match, skf-resolve-authoritative-files.py's
45
+ rule (`**` spans any number of path segments, none included; `*` and `?`
46
+ stay inside one), the rule skf-extract-public-api.py applies to the same
47
+ brief. skf-detect-workspaces.py's scoped_tree, loaded from the shared
48
+ scripts folder (SHARED_SCRIPTS), keeps the paths: the monorepo arm of
49
+ skf-extract-public-api.py reads the same function.
34
50
 
35
51
  The payload is one line of ASCII JSON on stdout, so a pipe carries the same
36
52
  bytes on every platform.
37
53
 
38
54
  Exit codes:
39
55
  0 the payload is printed
40
- 1 --source-root is not a folder; one JSON line {"status": "error",
41
- "error": ...} goes to stderr and nothing to stdout, so the helper the
42
- payload was piped into reads no input and fails too
56
+ 1 --source-root is not a folder, or --brief cannot be read (missing, not
57
+ UTF-8, not YAML, a value YAML cannot build such as an invalid date, not
58
+ a mapping, PyYAML not installed) or a script it loads is missing; one
59
+ JSON line {"status": "error", "error": ...} goes to stderr and nothing
60
+ to stdout, so the helper the payload was piped into reads no input and
61
+ fails too
43
62
  2 bad arguments (argparse: a missing or unknown argument, usage on stderr)
44
63
  """
45
64
 
46
65
  from __future__ import annotations
47
66
 
48
67
  import argparse
68
+ import importlib.util
49
69
  import json
50
70
  import os
51
71
  import sys
@@ -57,6 +77,9 @@ SKIPPED_DIRS = frozenset({
57
77
  "venv", "site-packages", "__pycache__", "target",
58
78
  })
59
79
  MAX_ROOT_FILE_BYTES = 1024 * 1024
80
+ SCRIPTS = Path(__file__).resolve().parent
81
+ # skf-detect-workspaces.py's folder, installed and in src/ alike.
82
+ SHARED_SCRIPTS = SCRIPTS.parent.parent / "shared" / "scripts"
60
83
 
61
84
 
62
85
  class StageError(Exception):
@@ -70,6 +93,29 @@ def _source_root(value: str) -> Path:
70
93
  return root
71
94
 
72
95
 
96
+ def _load(folder: Path, filename: str):
97
+ """A script in `folder`, loaded as a module, as load-coverage-inputs.py loads its siblings."""
98
+ path = folder / filename
99
+ spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
100
+ if spec is None or spec.loader is None or not path.is_file():
101
+ raise StageError(f"{filename} not found in {folder}")
102
+ module = importlib.util.module_from_spec(spec)
103
+ spec.loader.exec_module(module)
104
+ return module
105
+
106
+
107
+ def scoped_tree(tree: list[str], brief: str) -> list[str]:
108
+ """The paths of `tree` workspace detection reads under the brief at `brief`."""
109
+ inputs = _load(SCRIPTS, "load-coverage-inputs.py")
110
+ try:
111
+ excludes = inputs.read_brief(brief)["scope"]["exclude"]
112
+ except inputs.InputError as exc:
113
+ raise StageError(str(exc)) from exc
114
+ except ValueError as exc: # a value YAML cannot build, such as an invalid date
115
+ raise StageError(f"--brief {brief} is not valid YAML: {' '.join(str(exc).split())}") from exc
116
+ return _load(SHARED_SCRIPTS, "skf-detect-workspaces.py").scoped_tree(tree, excludes, inputs.glob_match)
117
+
118
+
73
119
  # --------------------------------------------------------------------------
74
120
  # detect-workspaces
75
121
  # --------------------------------------------------------------------------
@@ -105,9 +151,13 @@ def root_texts(root: Path) -> dict[str, str]:
105
151
  return texts
106
152
 
107
153
 
108
- def workspaces_payload(source_root: str) -> dict:
154
+ def workspaces_payload(source_root: str, brief: str | None = None) -> dict:
155
+ """The tree (with `brief`, the part of it the brief keeps) and the root files."""
109
156
  root = _source_root(source_root)
110
- return {"tree": list_tree(root), "manifests": root_texts(root)}
157
+ tree = list_tree(root)
158
+ if brief is not None:
159
+ tree = scoped_tree(tree, brief)
160
+ return {"tree": tree, "manifests": root_texts(root)}
111
161
 
112
162
 
113
163
  # --------------------------------------------------------------------------
@@ -116,7 +166,7 @@ def workspaces_payload(source_root: str) -> dict:
116
166
 
117
167
 
118
168
  def _cmd_detect_workspaces(args: argparse.Namespace) -> dict:
119
- return workspaces_payload(args.source_root)
169
+ return workspaces_payload(args.source_root, args.brief)
120
170
 
121
171
 
122
172
  def _build_parser() -> argparse.ArgumentParser:
@@ -128,6 +178,7 @@ def _build_parser() -> argparse.ArgumentParser:
128
178
 
129
179
  p = sub.add_parser("detect-workspaces", help="the tree and root files for skf-detect-workspaces.py")
130
180
  p.add_argument("--source-root", required=True, help="the local source folder")
181
+ p.add_argument("--brief", help="the skill brief, whose scope.exclude globs leave paths out of the tree")
131
182
  p.set_defaults(func=_cmd_detect_workspaces)
132
183
 
133
184
  return parser
@@ -8,12 +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 — strip wrapping fences, parse JSON, assert required keys
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, and assert each cross-check mismatch carries its
14
- five fields — with exactly one correct pass/fail per input and no
15
- interpretation of meaning. It is therefore script work, not prompt work; the
16
- 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.
17
34
 
18
35
  With --skill-package it also runs the ground-truth spot-check (coverage-check.md
19
36
  §1a): it sorts the exports by name (a stable sort), samples the first, the
@@ -26,6 +43,27 @@ violation, and the prompt re-dispatches the subagent with them, as it does
26
43
  for a schema violation. No grep runs in the prompt, so an export named
27
44
  `$state` or `a.b` is never a shell variable or a regex.
28
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.
57
+
58
+ An entry's optional `signature_form` says how the skill presents its
59
+ signature: `partial` only when the skill labels the form partial (a
60
+ simplified form, a key-parameter summary, a `...` that stands in for
61
+ omitted members, not rest or variadic syntax), `full` otherwise.
62
+ A JSON null counts as absent, and an absent one as `full`. Any other value
63
+ is a violation naming the entry. score-signatures.py reads it: an optional
64
+ member a partial form leaves out is an Info observation, not a wrong
65
+ signature.
66
+
29
67
  Distinct from reconcile-coverage.py (coverage numerator arithmetic) and
30
68
  compute-score.py (weight tables): this script only validates the *shape* of the
31
69
  subagent inventory and, on success, echoes the fence-stripped parsed inventory
@@ -50,20 +88,32 @@ skf-scan-skill-md-structure.py usage-scope): on a side where the name ends
50
88
  in a letter, digit, `_` or `$`, the next character may not be one of those.
51
89
  So `get` is found in `get(url)` but not in `target` or `getAll`.
52
90
 
91
+ The skill-citation scan is shared too: `_skill_cited_files` (with
92
+ `_cited_path`, `skill_markdown_files` and `_SKILL_CITATION_RE`) gives the
93
+ files the skill's `[AST:]` and `[SRC:]` citations name on a line of SKILL.md
94
+ or references/**/*.md that writes a name with the match above, or on the
95
+ next non-blank line. gap-ledger.py reads it to pick the declaration of a
96
+ stale homonym, score-signatures.py to plan a signature check at the
97
+ declaration the skill cites.
98
+
53
99
  CLI usage (mirrors reconcile-coverage.py):
54
100
  uv run validate-inventory.py --input <response file> [--output <inventory file>]
55
- [--skill-package <package folder>]
101
+ [--skill-package <package folder>] [--floor <floor file>]
56
102
  uv run validate-inventory.py '<raw response>' # positional
57
103
  uv run validate-inventory.py --json-input '<raw response>' # explicit flag
58
104
  echo '<raw response>' | uv run validate-inventory.py --stdin # piped input
59
105
 
60
- --input reads the raw response from a file (UTF-8): coverage-check.md saves
61
- the subagent's response with the Write tool and passes its path, so a quote
62
- or an apostrophe in a description never meets a shell. --output writes the
63
- validated inventory object to a file (UTF-8 JSON) when it is valid, for
64
- the steps that read it by path; an invalid inventory removes a file left
65
- there by an earlier attempt. --skill-package names the skill package under
66
- test (the folder that holds SKILL.md) and adds the spot-check.
106
+ --input reads the raw response from a file (UTF-8): the coverage-check.md §1
107
+ subagent writes its response to the path it is given with the Write tool,
108
+ and the parent saves it there with the Write tool when the subagent returned
109
+ it instead or left the file absent or empty. coverage-check.md passes that
110
+ path, so a quote or an apostrophe in a description never meets a shell.
111
+ --output writes the validated inventory object to a file (UTF-8
112
+ JSON) when it is valid, for the steps that read it by path; an invalid
113
+ inventory removes a file left there by an earlier attempt. --skill-package
114
+ names the skill package under test (the folder that holds SKILL.md) and
115
+ adds the spot-check. --floor names the floor file (UTF-8 JSON) and adds the
116
+ floor check.
67
117
 
68
118
  Input: the subagent's RAW response text (JSON, optionally wrapped in a markdown
69
119
  code fence — a leading line of three backticks with an optional language tag and
@@ -73,7 +123,7 @@ Output (stdout, one object):
73
123
  {
74
124
  "valid": <bool>, # true only when parse + every schema check pass
75
125
  "violations": [<str>, ...], # human-readable failures; empty when valid
76
- "rejectedCount": <int>, # count of malformed exports[] entries
126
+ "rejectedCount": <int>, # exports[] entries with no valid name or kind
77
127
  "exportsCount": <int>, # len(exports) when parsed; 0 otherwise
78
128
  "inventory": {...} | null, # fence-stripped parsed inventory when valid; null otherwise
79
129
  "completeness": { # present when valid
@@ -86,15 +136,21 @@ Output (stdout, one object):
86
136
  "absent": [<name>, ...], # the sampled names no file writes
87
137
  "files": [<path>, ...], # the files read, relative to the package
88
138
  "unread": [<path>, ...] # listed references that are missing,
89
- } # unreadable or outside the package
139
+ }, # unreadable or outside the package
140
+ "floor": { # with --floor, once the schema passed
141
+ "names": <int>, # the floor's names
142
+ "checked": <int>, # those with an origin in SKILL.md or a listed reference
143
+ "absent": [<name>, ...] # the checked names no inventory entry lists
144
+ }
90
145
  }
91
146
 
92
147
  Exit codes:
93
- 0 inventory valid (and every sampled name found)
94
- 1 no input provided, the --input file cannot be read, or the
95
- --skill-package folder holds no readable SKILL.md (usage error)
96
- 2 inventory invalid (parse failure, schema violation or a sampled name
97
- absent; result JSON emitted)
148
+ 0 inventory valid (every sampled name found, every checked floor name kept)
149
+ 1 no input provided, the --input file cannot be read, the
150
+ --skill-package folder holds no readable SKILL.md, or the --floor file
151
+ cannot be read or is not a floor (usage error)
152
+ 2 inventory invalid (parse failure, schema violation, a sampled name
153
+ absent or a floor name left out; result JSON emitted)
98
154
  """
99
155
 
100
156
  from __future__ import annotations
@@ -102,6 +158,7 @@ from __future__ import annotations
102
158
  import argparse
103
159
  import json
104
160
  import os
161
+ import posixpath
105
162
  import re
106
163
  import sys
107
164
  from pathlib import Path
@@ -126,14 +183,26 @@ VALID_KINDS = (
126
183
  "adapter",
127
184
  )
128
185
 
186
+ # The optional exports[].signature_form: `partial` only when the skill labels
187
+ # the documented form partial, else `full` (or the key left out).
188
+ SIGNATURE_FORMS = ("partial", "full")
189
+
129
190
  REQUIRED_MISMATCH_FIELDS = (
130
191
  "export",
131
- "skill_md_line",
132
192
  "reference_file",
133
193
  "reference_line",
134
194
  "issue",
135
195
  )
136
196
 
197
+ # The other side of a cross-check pair, exactly one of these shapes: the
198
+ # SKILL.md body's line, or a second reference file and its line.
199
+ BODY_SIDE = ("skill_md_line",)
200
+ REFERENCE_SIDE = ("other_file", "other_line")
201
+ OTHER_SIDE_SHAPES = (
202
+ "`skill_md_line` (the SKILL.md body) or `other_file` and `other_line` "
203
+ "(a second reference file)"
204
+ )
205
+
137
206
  # Kinds whose documented item also needs its parameters and return type to
138
207
  # be complete (the docs-only Export Coverage rule).
139
208
  SIGNATURE_KINDS = ("function", "method")
@@ -208,6 +277,69 @@ def names_present(names, doc_text):
208
277
  return sorted(present)
209
278
 
210
279
 
280
+ # --- Skill citations ----------------------------------------------------------
281
+
282
+ # A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
283
+ # `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
284
+ # Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
285
+ _SKILL_CITATION_RE = re.compile(
286
+ r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
287
+ )
288
+ # Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
289
+ _ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
290
+
291
+
292
+ def _cited_path(path: str) -> str:
293
+ """A cited, `declared_in` or surface path as each compares: forward slashes, no `./`."""
294
+ path = path.strip().replace("\\", "/")
295
+ while path.startswith("./"):
296
+ path = path[2:]
297
+ return posixpath.normpath(path) if path else path
298
+
299
+
300
+ def skill_markdown_files(skill_dir: Path) -> list[Path]:
301
+ """`SKILL.md` then every `references/**/*.md`, in a stable order.
302
+ Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
303
+ files: list[Path] = []
304
+ skill_md = skill_dir / "SKILL.md"
305
+ if skill_md.is_file():
306
+ files.append(skill_md)
307
+ refs = skill_dir / "references"
308
+ if refs.is_dir():
309
+ files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
310
+ return files
311
+
312
+
313
+ def skill_markdown_lines(skill_dir: Path) -> list[list[str]]:
314
+ """The lines of each file skill_markdown_files lists (an unreadable one is
315
+ left out), read once for every name _skill_cited_files looks up."""
316
+ texts: list[list[str]] = []
317
+ for path in skill_markdown_files(skill_dir):
318
+ try:
319
+ texts.append(path.read_bytes().decode("utf-8-sig", errors="replace").split("\n"))
320
+ except OSError:
321
+ continue
322
+ return texts
323
+
324
+
325
+ def _skill_cited_files(skill_dir: Path, name: str, texts: list[list[str]] | None = None) -> set[str]:
326
+ """The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
327
+ SKILL.md or references/**/*.md that writes `name` outside its citations
328
+ (the documented-name match), or on the next non-blank line (a heading,
329
+ then its citation). `texts`, skill_markdown_lines(skill_dir) read once,
330
+ spares a caller that looks up many names reading the files again."""
331
+ pattern = name_pattern(name)
332
+ cited: set[str] = set()
333
+ for lines in skill_markdown_lines(skill_dir) if texts is None else texts:
334
+ for number, line in enumerate(lines):
335
+ if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
336
+ continue
337
+ following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
338
+ for cited_line in (line, following):
339
+ cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
340
+ return cited
341
+
342
+
211
343
  # --- Spot-check -------------------------------------------------------------
212
344
 
213
345
 
@@ -277,6 +409,77 @@ def spot_check(inventory, skill_package_path):
277
409
  }
278
410
 
279
411
 
412
+ # --- Floor ------------------------------------------------------------------
413
+
414
+
415
+ class FloorError(Exception):
416
+ """The --floor file cannot be read or is not a floor: exit 1."""
417
+
418
+
419
+ def _is_origin(origin):
420
+ return (isinstance(origin, dict) and isinstance(origin.get("file"), str) and bool(origin["file"])
421
+ and isinstance(origin.get("line"), int) and not isinstance(origin["line"], bool)
422
+ and isinstance(origin.get("form"), str))
423
+
424
+
425
+ def read_floor(path):
426
+ """The floor load-coverage-inputs.py floor wrote to `path`, its shape checked."""
427
+ try:
428
+ data = json.loads(Path(path).read_bytes().decode("utf-8-sig"))
429
+ except (OSError, UnicodeDecodeError) as exc:
430
+ raise FloorError(f"cannot read {path}: {exc}") from exc
431
+ except json.JSONDecodeError as exc:
432
+ raise FloorError(f"{path} is not JSON: {exc.msg}") from exc
433
+ names = data.get("names") if isinstance(data, dict) else None
434
+ if not isinstance(names, list) or not all(
435
+ isinstance(item, dict) and isinstance(item.get("name"), str) and item["name"]
436
+ and isinstance(item.get("origins"), list) and all(_is_origin(o) for o in item["origins"])
437
+ for item in names):
438
+ raise FloorError(f"{path} is not a floor: `names` must give each name's `name` and `origins` "
439
+ "(each a file, a line and a form)")
440
+ return data
441
+
442
+
443
+ def _listed_path(ref, skill_package_path):
444
+ """A listed reference as a floor origin names it: relative to the package, forward slashes. An absolute
445
+ path inside the package is made relative to it (it needs the package to be)."""
446
+ if skill_package_path is not None and os.path.isabs(ref):
447
+ target, root = os.path.realpath(ref), os.path.realpath(skill_package_path)
448
+ if _inside(target, root):
449
+ return Path(os.path.relpath(target, root)).as_posix()
450
+ return _cited_path(ref)
451
+
452
+
453
+ def floor_check(inventory, floor, skill_package_path=None):
454
+ """The floor names the inventory leaves out: (the `floor` result, its violations).
455
+
456
+ A floor name is checked when one of its origins is SKILL.md or a file
457
+ the inventory's `references` lists (the spot-check's files, an absolute
458
+ path inside `skill_package_path` matched by its path relative to it),
459
+ and kept when any inventory entry has that name, whatever its kind. A
460
+ name left out is a violation naming its first checked origin.
461
+ """
462
+ listed = inventory.get("references")
463
+ files = {"SKILL.md"} | {_listed_path(ref, skill_package_path)
464
+ for ref in (listed if isinstance(listed, list) else [])
465
+ if isinstance(ref, str) and ref.strip()}
466
+ kept = {e.get("name") for e in inventory.get("exports") or [] if isinstance(e, dict)}
467
+ names = floor.get("names") or []
468
+ checked, absent, violations = 0, [], []
469
+ for item in names:
470
+ origins = [o for o in item["origins"] if _cited_path(o["file"]) in files]
471
+ if not origins:
472
+ continue
473
+ checked += 1
474
+ name = item["name"]
475
+ if name in kept:
476
+ continue
477
+ absent.append(name)
478
+ where = f"{origins[0]['file']}:{origins[0]['line']} ({origins[0]['form']})"
479
+ violations.append(f"floor: `{name}` is documented at {where}, but no inventory entry lists it")
480
+ return {"names": len(names), "checked": checked, "absent": absent}, violations
481
+
482
+
280
483
  # --- Completeness -------------------------------------------------------------
281
484
 
282
485
 
@@ -326,13 +529,100 @@ def completeness(exports):
326
529
  # --- Validation ---------------------------------------------------------------
327
530
 
328
531
 
329
- def validate_inventory(raw, skill_package_path=None):
330
- """Structural validation of the subagent inventory response, then the spot-check.
532
+ def _reference_path(value, skill_package_path=None):
533
+ """A pair's file as the package names it (`_listed_path`: relative, forward slashes, no `./`), or None when
534
+ it is no `references/**/*.md` file of the package: empty, not a string, `SKILL.md`, another file, or an
535
+ absolute path outside the package (or with no package to make it relative to)."""
536
+ if not isinstance(value, str) or not value.strip():
537
+ return None
538
+ path = _listed_path(value.strip(), skill_package_path)
539
+ parts = path.split("/")
540
+ if len(parts) < 2 or parts[0] != "references" or ".." in parts or not parts[-1].endswith(".md") \
541
+ or parts[-1] == ".md":
542
+ return None
543
+ return path
544
+
545
+
546
+ def _is_line(value):
547
+ """A line number: a positive integer, never a bool or a string."""
548
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
549
+
550
+
551
+ def mismatch_violations(idx, entry, skill_package_path=None):
552
+ """The violations of one cross_check_mismatches entry (an object).
553
+
554
+ A required field left out; a `reference_file` or `other_file` that is no
555
+ `references/**/*.md` path of the package; a line that is no positive
556
+ integer; an other side that is not exactly one shape, `skill_md_line` or
557
+ both of `other_file` and `other_line` (a JSON null counts as absent); or a
558
+ pair of references out of order: (`other_file`, `other_line`) must sort
559
+ before (`reference_file`, `reference_line`) in codepoint order, so the pair
560
+ has one title. Each path is checked, and left in the entry, as the package
561
+ names it, relative and with forward slashes.
562
+ """
563
+ where = f"cross_check_mismatches[{idx}]"
564
+ violations = []
565
+ missing = [f for f in REQUIRED_MISMATCH_FIELDS if f not in entry]
566
+ if missing:
567
+ violations.append(f"{where} missing field(s): {', '.join(missing)}")
568
+ ref = None
569
+ if "reference_file" in entry:
570
+ ref = _reference_path(entry["reference_file"], skill_package_path)
571
+ if ref is None:
572
+ violations.append(f"{where}: `reference_file` must be a `references/*.md` file of the skill package, "
573
+ f"not {json.dumps(entry['reference_file'])}")
574
+ else:
575
+ entry["reference_file"] = ref
576
+ lines_ok = True
577
+ for field in ("reference_line",) + BODY_SIDE + REFERENCE_SIDE[1:]:
578
+ given = entry.get(field) is not None or (field == "reference_line" and field in entry)
579
+ if given and not _is_line(entry[field]):
580
+ lines_ok = False
581
+ violations.append(f"{where}: `{field}` must be a line number (a positive integer), "
582
+ f"not {json.dumps(entry[field])}")
583
+ body = [f for f in BODY_SIDE if entry.get(f) is not None]
584
+ reference = [f for f in REFERENCE_SIDE if entry.get(f) is not None]
585
+ if body and reference:
586
+ violations.append(f"{where} gives two other sides: give one, {OTHER_SIDE_SHAPES}")
587
+ return violations
588
+ if not body and not reference:
589
+ violations.append(f"{where} missing its other side: give {OTHER_SIDE_SHAPES}")
590
+ return violations
591
+ if body:
592
+ return violations
593
+ absent = [f for f in REFERENCE_SIDE if f not in reference]
594
+ if absent:
595
+ violations.append(f"{where} missing field(s): {', '.join(absent)}; its other side is "
596
+ f"{OTHER_SIDE_SHAPES}")
597
+ return violations
598
+ other = _reference_path(entry["other_file"], skill_package_path)
599
+ if other is None:
600
+ violations.append(f"{where}: `other_file` must be a `references/*.md` file of the skill package, "
601
+ f"not {json.dumps(entry['other_file'])}")
602
+ return violations
603
+ entry["other_file"] = other
604
+ if ref is None or not lines_ok or "reference_line" not in entry:
605
+ return violations
606
+ first, last = (other, entry["other_line"]), (ref, entry["reference_line"])
607
+ if not first < last:
608
+ relation = "names the same line as" if first == last else "sorts after"
609
+ violations.append(
610
+ f"{where}: `other_file` {json.dumps(other)} at `other_line` {first[1]} {relation} "
611
+ f"`reference_file` {json.dumps(ref)} at `reference_line` {last[1]}: a pair of references gives the "
612
+ "one that sorts first in codepoint order, by path and then line, as `other_file` and `other_line`, "
613
+ "and the one that sorts last as `reference_file` and `reference_line`"
614
+ )
615
+ return violations
616
+
617
+
618
+ def validate_inventory(raw, skill_package_path=None, floor=None):
619
+ """Structural validation of the subagent inventory response, then the spot-check and the floor check.
331
620
 
332
621
  `raw` is the raw response text. Returns the result dict documented in the
333
622
  module docstring. With `skill_package_path`, an inventory that passed the
334
- schema is spot-checked against the package (spot_check). Deterministic:
335
- identical input yields an identical verdict.
623
+ schema is spot-checked against the package (spot_check); with `floor`
624
+ (read_floor's object), it is checked against the floor (floor_check).
625
+ Deterministic: identical input yields an identical verdict.
336
626
  """
337
627
  violations = []
338
628
  inner = strip_fences(raw)
@@ -392,25 +682,37 @@ def validate_inventory(raw, skill_package_path=None):
392
682
  f"{{{', '.join(VALID_KINDS)}}})"
393
683
  )
394
684
 
395
- # Non-empty cross-check mismatch entries must carry all five fields.
685
+ # The optional signature_form names one of two forms, or is left out.
686
+ for idx, entry in enumerate(exports):
687
+ if isinstance(entry, dict) and entry.get("signature_form") is not None \
688
+ and entry["signature_form"] not in SIGNATURE_FORMS:
689
+ label = f" (`{entry['name']}`)" if isinstance(entry.get("name"), str) and entry["name"] else ""
690
+ violations.append(
691
+ f"exports[{idx}]{label}: `signature_form` must be `partial` or `full` when present, "
692
+ f"not {json.dumps(entry['signature_form'])}"
693
+ )
694
+
695
+ # Each cross-check mismatch carries its fields and one other side of its
696
+ # pair, a pair of references in order.
396
697
  for idx, entry in enumerate(cross):
397
698
  if not isinstance(entry, dict):
398
699
  violations.append(f"cross_check_mismatches[{idx}] is not an object")
399
700
  continue
400
- missing = [f for f in REQUIRED_MISMATCH_FIELDS if f not in entry]
401
- if missing:
402
- violations.append(
403
- f"cross_check_mismatches[{idx}] missing field(s): {', '.join(missing)}"
404
- )
701
+ violations.extend(mismatch_violations(idx, entry, skill_package_path))
405
702
 
703
+ schema_valid = not violations
406
704
  checked = None
407
- if not violations and skill_package_path is not None:
705
+ if schema_valid and skill_package_path is not None:
408
706
  checked = spot_check(data, skill_package_path)
409
707
  for name in checked["absent"]:
410
708
  violations.append(
411
709
  f"spot-check: `{name}` is listed as an export, but SKILL.md and the "
412
710
  f"listed reference files never write it"
413
711
  )
712
+ floor_result = None
713
+ if schema_valid and floor is not None:
714
+ floor_result, floor_violations = floor_check(data, floor, skill_package_path)
715
+ violations.extend(floor_violations)
414
716
 
415
717
  valid = not violations
416
718
  result = {
@@ -424,6 +726,8 @@ def validate_inventory(raw, skill_package_path=None):
424
726
  result["completeness"] = completeness(exports)
425
727
  if checked is not None:
426
728
  result["spotCheck"] = checked
729
+ if floor_result is not None:
730
+ result["floor"] = floor_result
427
731
  return result
428
732
 
429
733
 
@@ -437,8 +741,9 @@ def _build_parser():
437
741
  "Deterministic schema validation of the §1 subagent inventory JSON "
438
742
  "(coverage-check.md §1a). Strips wrapping fences, parses, and asserts "
439
743
  "the required-keys/types/enum/mismatch-field contract, then with "
440
- "--skill-package spot-checks sampled names against the package, returning "
441
- "{valid, violations, rejectedCount, exportsCount, inventory, completeness, spotCheck}."
744
+ "--skill-package spot-checks sampled names against the package and --floor checks it "
745
+ "keeps the floor's names, returning "
746
+ "{valid, violations, rejectedCount, exportsCount, inventory, completeness, spotCheck, floor}."
442
747
  ),
443
748
  formatter_class=argparse.RawDescriptionHelpFormatter,
444
749
  )
@@ -474,6 +779,11 @@ def _build_parser():
474
779
  metavar="DIR",
475
780
  help="Spot-check sampled names against this package's SKILL.md and listed references.",
476
781
  )
782
+ parser.add_argument(
783
+ "--floor",
784
+ metavar="PATH",
785
+ help="Check the inventory keeps the names of this load-coverage-inputs.py floor file.",
786
+ )
477
787
  return parser
478
788
 
479
789
 
@@ -513,7 +823,12 @@ def main(argv=None):
513
823
  return 1
514
824
 
515
825
  try:
516
- result = validate_inventory(raw, args.skill_package)
826
+ floor = read_floor(args.floor) if args.floor is not None else None
827
+ except FloorError as exc:
828
+ print(f"error: --floor: {exc}", file=sys.stderr)
829
+ return 1
830
+ try:
831
+ result = validate_inventory(raw, args.skill_package, floor)
517
832
  except PackageError as exc:
518
833
  print(f"error: --skill-package: {exc}", file=sys.stderr)
519
834
  return 1