bmad-module-skill-forge 6.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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +4 -4
  4. package/package.json +1 -1
  5. package/src/shared/references/output-contract-schema.md +1 -1
  6. package/src/shared/scripts/skf-quick-batch.py +16 -3
  7. package/src/shared/scripts/skf-verify-provenance-completeness.py +209 -33
  8. package/src/skf-analyze-source/customize.toml +3 -2
  9. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  10. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  11. package/src/skf-analyze-source/references/headless-contract.md +2 -2
  12. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  13. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  14. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  15. package/src/skf-campaign/references/step-08-verify.md +3 -3
  16. package/src/skf-campaign/references/step-09-refine.md +3 -3
  17. package/src/skf-campaign/scripts/campaign-state.py +22 -5
  18. package/src/skf-create-skill/SKILL.md +2 -2
  19. package/src/skf-create-skill/customize.toml +4 -4
  20. package/src/skf-create-skill/references/batch-mode.md +3 -1
  21. package/src/skf-create-skill/references/report.md +7 -2
  22. package/src/skf-create-skill/references/validate.md +1 -1
  23. package/src/skf-create-stack-skill/references/validate.md +12 -4
  24. package/src/skf-quick-skill/SKILL.md +1 -1
  25. package/src/skf-quick-skill/references/batch-mode.md +2 -0
  26. package/src/skf-quick-skill/references/write-and-validate.md +19 -9
  27. package/src/skf-test-skill/references/coverage-check.md +2 -2
  28. package/src/skf-test-skill/references/inventory-contract.md +1 -1
  29. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  30. package/src/skf-test-skill/references/signature-comparison-rules.md +3 -1
  31. package/src/skf-test-skill/scripts/load-coverage-inputs.py +241 -11
  32. package/src/skf-test-skill/scripts/score-signatures.py +9 -13
  33. package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
  34. package/src/skf-update-skill/customize.toml +1 -1
  35. package/src/skf-update-skill/references/report.md +10 -3
  36. package/src/skf-update-skill/references/write.md +1 -1
@@ -21,8 +21,9 @@ reading files and writing one JSON object:
21
21
  floor the documented inventory's floor (#790): every name SKILL.md
22
22
  and each .md file directly in references/ (a subfolder is not
23
23
  read) write in a fixed form, which validate-inventory.py --floor
24
- refuses an inventory for leaving out. It owns names only: no
25
- kind is mapped and no signature read. A name is a plain
24
+ refuses an inventory for leaving out. No kind is mapped, and a
25
+ signature is read only where the place writes it in a fixed
26
+ form (Signatures, below). A name is a plain
26
27
  identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
27
28
  everything from the first `(` or `<` are cut, so `Meta<T>`,
28
29
  `fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
@@ -71,11 +72,74 @@ reading files and writing one JSON object:
71
72
  `type (`, `var (` or `const (` group too. A
72
73
  block in another language, an untagged one or a
73
74
  data block (json, yaml, text...) gives none.
75
+ Signatures (#888): an origin whose place writes the name's
76
+ signature in a fixed form that is surely complete also carries
77
+ it as `signature`, {"params", "return_type"}, two strings, each
78
+ one's whitespace runs read as one space, which
79
+ validate-inventory.py --floor refuses an inventory for
80
+ changing; any other place pins nothing, since a wrong pin would
81
+ be forced into the inventory. `params` is the text inside the
82
+ parameter group (score-signatures.py's _group: the first pair
83
+ of parentheses outside every bracket pair and string), and
84
+ `return_type` the type written after it (score-signatures.py's
85
+ _signature_parts), "" for none. A form that writes neither
86
+ (`f()`, `useQuery<T>()`) gives none, so an empty call claims no
87
+ form. A span (a table cell's or a heading's) is complete when
88
+ it is the name (or no name, as a Signature cell's `(x: A) =>
89
+ B`), its type parameters, one parameter group, then nothing or
90
+ a return separator (`->`, `=>`, `→` or `:`) and the return, with
91
+ nothing else: `useQuery(key).data`, `f(a) / f(a, b)`, a
92
+ trailing comment, a `;` or `,` outside the return's brackets,
93
+ an unclosed bracket, `Meta<T>` (no group) and `new Client(opts)`
94
+ give none. By form:
95
+ table-row a row whose first cell names one name (a row
96
+ naming several gives none), each cell's `\\|`
97
+ read as `|`: the leading code span of its
98
+ Signature or Signatures cell, else, when that
99
+ span writes no parameter group (`Typed<X>`),
100
+ the one span of its first cell that gives the
101
+ name (another export's span there, as in
102
+ `fetch` (same as `http.request(opts)`), gives
103
+ nothing). A Signature span with a group that is
104
+ no complete form (`client.get(k) -> str`,
105
+ `new Client(opts)`) gives none, so a shorter
106
+ first-cell call never stands in for it. A header
107
+ cell matches whole, so a labelled one is not a
108
+ Signature column, and a Props, Params or
109
+ Parameters cell is never read (its shared
110
+ members and usage forms stay the reader's).
111
+ heading its code span
112
+ declaration a function's (`function`, `def`, `fn` or Go's
113
+ `func` before its name; a `const` holding an
114
+ arrow gives none): its lines as one, as
115
+ skf-extract-public-api.py's
116
+ _declaration_signature joins them (a parameter
117
+ list split over lines joined, line comments
118
+ left out), the body cut off first, at the first
119
+ `:` (Python) or `{`, `;` or ` where ` (any other
120
+ language) outside every bracket pair and string
121
+ after the parameter group, a `{` directly after
122
+ a TS return `:` opening the return type
123
+ (`(): { a: T } {` returns `{ a: T }`). None for
124
+ one with anything but type parameters between
125
+ its name and the group (a Go `func(...)`
126
+ return), one whose opener follows, or whose
127
+ uncut text ends with, `interface`, `struct`,
128
+ `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`, `=>` or
129
+ `->` (the opener or the line end is inside the
130
+ return type: Go's `interface{}`, a TS `asserts
131
+ x is {...}`, `() => {...}` or conditional type,
132
+ `): Promise<A> |`), or one whose brackets or
133
+ strings do not all close.
134
+ A form written only in prose is never read: it stays the
135
+ reader's. Each origin keeps the first signature its place
136
+ writes.
74
137
  Output: {"files": [...], "names": [{"name", "kindLabel",
75
- "origins": [{"file", "line", "form"}]}], "summary": {"names",
76
- "byForm"}}, names sorted, each one's origins in file order then
77
- line, `byForm` counting the origins of each form; the same skill
78
- gives the same bytes.
138
+ "origins": [{"file", "line", "form", "signature"?}]}],
139
+ "summary": {"names", "byForm"}}, names sorted, each one's
140
+ origins in file order then line, `signature` only on an origin
141
+ that has one, `byForm` counting the origins of each form; the
142
+ same skill gives the same bytes.
79
143
 
80
144
  metadata the metadata.json and provenance-map counts. Cluster A
81
145
  (`stats.exports_public_api`, the `exports[]` length) and Cluster
@@ -371,6 +435,23 @@ FLOOR_TABLE_HEADERS = frozenset({
371
435
  "export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
372
436
  "type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
373
437
  "enum", "enums", "api", "apis"})
438
+ # The header words (as _header_word reads them, whole) of a floor table's column that writes the row's signature
439
+ # (#888); a labelled header, such as `Signature (simplified)`, is not one.
440
+ FLOOR_SIGNATURE_HEADERS = frozenset({"signature", "signatures"})
441
+ # A complete form's name, before its type parameters and parameter group (group 1, absent for none).
442
+ SPAN_NAME_RE = re.compile(r"\s*([A-Za-z_$][\w$]*)?\s*")
443
+ # What a complete form writes after its parameter group before its return: `->`, `=>`, `→` or `:` (not `::`).
444
+ RETURN_SEPARATOR_RE = re.compile(r"\s*(?:->|=>|→|:(?!:))")
445
+ # A declaration that gets a form: a function keyword before its name, on its masked line.
446
+ FUNCTION_KEYWORD_RE = re.compile(r"(?<![\w$])(?:function|def|fn|func)(?![\w$])")
447
+ # After a declaration's parameter group: the `:` a TS return type follows (a `{` right after it opens the type, not
448
+ # the body), and a Rust `where` clause, which the body comes after.
449
+ RETURN_COLON_RE = re.compile(r"\s*:\s*")
450
+ WHERE_CLAUSE_RE = re.compile(r"\s+where(?![\w$])")
451
+ # A declaration's text that ends in the middle of its return type: a body opener after one of these is part of that
452
+ # type, cut short (Go's `interface{}` and `struct{...}`, a TS union, intersection, `asserts x is {...}`, `keyof {...}`,
453
+ # conditional or function type), and so is a declaration that ends with one (`): Promise<A> |`, Python's `\`).
454
+ OPENER_IN_TYPE_RE = re.compile(r"(?:(?<![\w$])(?:interface|struct|is|keyof)|[|&?:\\]|=>|->)\s*$")
374
455
  # A header cell wrapped in `**` or `__` (group 2 its text).
375
456
  EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
376
457
  PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
@@ -650,6 +731,144 @@ def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[in
650
731
  return found
651
732
 
652
733
 
734
+ def _form(text: str, group: tuple[int, int], extension: str = "") -> dict | None:
735
+ """{"params", "return_type"} of a form whose parameter group is `group`, whitespace runs read as one space: the
736
+ text inside the group, and the return after it (score-signatures.py's _signature_parts), "" for none; None for a
737
+ form that writes neither (`f()`)."""
738
+ form = {"params": " ".join(text[group[0] + 1:group[1]].split()),
739
+ "return_type": " ".join(_sibling("score-signatures.py")._signature_parts(text, extension)[1].split())}
740
+ return form if form["params"] or form["return_type"] else None
741
+
742
+
743
+ def _type_parameters_only(text: str) -> bool:
744
+ """Whether `text` is empty or one `<...>` or `[...]` pair, the type parameters a form writes before its group."""
745
+ text = text.strip()
746
+ if not text:
747
+ return True
748
+ if text[0] not in "<[" or text[-1] != {"<": ">", "[": "]"}[text[0]]:
749
+ return False
750
+ depth = 0
751
+ for i, ch in enumerate(text):
752
+ if ch in "<[(":
753
+ depth += 1
754
+ elif ch in ">])" and not (ch == ">" and text[i - 1:i] in ("-", "=")):
755
+ depth -= 1
756
+ if depth == 0 and i != len(text) - 1:
757
+ return False
758
+ return depth == 0
759
+
760
+
761
+ def _call_form(span: str, name: str | None) -> dict | None:
762
+ """The signature a span writes when it is a complete form (_form): `name` or no name (a Signature cell's
763
+ `(x: A) => B`), its type parameters, one parameter group (score-signatures.py's _group), then nothing, or a
764
+ return separator (`->`, `=>`, `→` or `:`) and the return, with nothing else: no other text after the group
765
+ (`useQuery(key).data`, `f(a) / f(a, b)`, a trailing comment), no `;`, `,`, `#`, `//` or `/*` in the return
766
+ outside its brackets and strings, and every bracket and string closed. None for any other span, a constructor
767
+ call (`new Client(opts)`) and a span with no parameter group (`Meta<T>`) among them."""
768
+ scorer = _sibling("score-signatures.py")
769
+ text = span.strip()
770
+ group = scorer._group(text)
771
+ if group is None or text.startswith("new ") or scorer._levels(text + " ")[-1] != 0:
772
+ return None
773
+ head = SPAN_NAME_RE.match(text, 0, group[0])
774
+ if head.group(1) not in (None, name) or not _type_parameters_only(text[head.end():group[0]]):
775
+ return None
776
+ tail = text[group[1] + 1:]
777
+ separator = RETURN_SEPARATOR_RE.match(tail)
778
+ returned = tail[separator.end():] if separator else ""
779
+ if (separator is None and tail.strip()) or (separator is not None and not returned.strip()):
780
+ return None
781
+ levels = scorer._levels(returned)
782
+ if any(levels[i] == 0 and (ch in ";,#" or returned.startswith(("//", "/*"), i)) for i, ch in enumerate(returned)):
783
+ return None
784
+ return _form(text, group)
785
+
786
+
787
+ def _leading_span(cell: str) -> str | None:
788
+ """The code span a table cell opens with, None when it opens with none."""
789
+ run = len(cell) - len(cell.lstrip("`"))
790
+ spans = _sibling("locate-export-segments.py")._inline_code(cell) if run else []
791
+ return spans[0] if spans and cell.startswith("`" * run + spans[0] + "`" * run) else None
792
+
793
+
794
+ def _row_form(header: list[str], row: list[str]) -> dict | None:
795
+ """The signature a floor table's row writes for the one name its first cell names (a row naming several gives
796
+ none), each cell's `\\|` read as `|`: the leading code span of its Signature or Signatures cell, else, when that
797
+ span writes no parameter group, the one span of its first cell that gives that name (another export's span there
798
+ gives nothing), each a complete form (_call_form). A Signature span with a group that is no complete form gives
799
+ none. A Props, Params or Parameters cell is never read."""
800
+ segments = _sibling("locate-export-segments.py")
801
+ names = {name for name in map(_floor_name, segments._inline_code(row[0])) if name}
802
+ if len(names) != 1:
803
+ return None
804
+ [name] = names
805
+ cells = [cell.replace("\\|", "|") for cell in row]
806
+ at = next((i for i, cell in enumerate(header) if _header_word(cell) in FLOOR_SIGNATURE_HEADERS), None)
807
+ span = _leading_span(cells[at]) if at is not None and at < len(cells) else None
808
+ if span is not None and (form := _call_form(span, name)):
809
+ return form
810
+ if span is not None and _sibling("score-signatures.py")._group(span.strip()) is not None:
811
+ return None # the cell writes a call the floor cannot pin: a shorter first-cell form would hide it
812
+ own = [span for span in segments._inline_code(cells[0]) if _floor_name(span) == name]
813
+ return _call_form(own[0], name) if len(own) == 1 else None
814
+
815
+
816
+ def _function_declaration(line: str, language: str, js_family) -> int | None:
817
+ """Where the name ends on a block's masked line, one _declared_names reads, that declares a function (`function`,
818
+ `def`, `fn` or Go's `func` before its name); None for a line that declares no function."""
819
+ if language in js_family:
820
+ m, at = JS_DECLARATION_RE.match(line), 1
821
+ elif language in PYTHON_LANGUAGES:
822
+ m, at = PYTHON_DECLARATION_RE.match(line), 1
823
+ elif language in RUST_LANGUAGES:
824
+ m, at = RUST_DECLARATION_RE.match(line), 1
825
+ elif language in GO_LANGUAGES:
826
+ m, at = GO_DECLARATION_RE.match(line), 2
827
+ else:
828
+ return False
829
+ return m.end(at) if m and FUNCTION_KEYWORD_RE.search(line, 0, m.start(at)) else None
830
+
831
+
832
+ def _declaration_form(rows: list[str], offset: int, language: str, name_end: int) -> dict | None:
833
+ """The signature the function declaration at `offset` of a block's unmasked `rows` writes: the declaration on one
834
+ line (skf-extract-public-api.py's _declaration_signature: a parameter list split over lines joined, line comments
835
+ left out), its body cut at the first `:` (Python) or `{`, `;` or ` where ` (any other language) outside every
836
+ bracket pair and string after the parameter group, a `{` directly after a TS return `:` opening the return type,
837
+ then read as _form reads it. None when anything but type parameters sits between the name's end (`name_end`,
838
+ the masked line's offset, which masking keeps) and the group (a Go `func(...)` return read as the group), when
839
+ the opener follows, or the uncut text ends with, `interface`, `struct`, `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`,
840
+ `=>` or `->` (it is inside the return type: Go's `interface{}`, a TS `asserts x is {...}`, `() => {...}` or
841
+ conditional type, a line that ends mid-type), or a bracket or string it opens does not close."""
842
+ scorer = _sibling("score-signatures.py")
843
+ extractor = _sibling("skf-extract-public-api.py", SHARED_SCRIPTS)
844
+ python, rust = language in PYTHON_LANGUAGES, language in RUST_LANGUAGES
845
+ extension = ".rs" if rust else ""
846
+ text = extractor._declaration_signature(rows, offset, "python" if python else "rust" if rust else language)
847
+ group = scorer._group(text, extension)
848
+ if group is None or not _type_parameters_only(text[name_end:group[0]]):
849
+ return None
850
+ levels = scorer._levels(text, extension)
851
+ for i in range(group[1] + 1, len(text)):
852
+ if levels[i] != 0:
853
+ continue
854
+ if python:
855
+ body = text[i] == ":"
856
+ else:
857
+ body = (text[i] == ";" or bool(WHERE_CLAUSE_RE.match(text, i))
858
+ or (text[i] == "{" and not RETURN_COLON_RE.fullmatch(text, group[1] + 1, i)))
859
+ if body:
860
+ if OPENER_IN_TYPE_RE.search(text, 0, i):
861
+ return None
862
+ text = text[:i]
863
+ break
864
+ else:
865
+ if OPENER_IN_TYPE_RE.search(text):
866
+ return None
867
+ if scorer._levels(text + " ", extension)[-1] != 0:
868
+ return None
869
+ return _form(text, group, extension)
870
+
871
+
653
872
  def floor(skill_dir: str) -> dict:
654
873
  """The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
655
874
  root = Path(skill_dir)
@@ -670,10 +889,14 @@ def floor(skill_dir: str) -> dict:
670
889
  for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
671
890
 
672
891
  found: dict[str, set[tuple[int, int, str, str | None]]] = {}
892
+ signatures: dict[tuple[str, int, int, str], dict] = {} # the first per (name, file, line, form), in span order
673
893
 
674
- def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None) -> None:
894
+ def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None,
895
+ signature: dict | None = None) -> None:
675
896
  if name:
676
897
  found.setdefault(name, set()).add((file_index, index + 1, form, label))
898
+ if signature is not None:
899
+ signatures.setdefault((name, file_index, index + 1, form), signature)
677
900
 
678
901
  js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
679
902
  for file_index, lines in enumerate(texts):
@@ -689,12 +912,13 @@ def floor(skill_dir: str) -> dict:
689
912
  row = _cells(lines[block["start"]])
690
913
  kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
691
914
  label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
915
+ signature = _row_form(header, row)
692
916
  for span in segments._inline_code(row[0]):
693
- add(_floor_name(span), file_index, block["start"], "table-row", label)
917
+ add(_floor_name(span), file_index, block["start"], "table-row", label, signature)
694
918
  for index, _level, text, _above in headings:
695
919
  span = _whole_span(text, segments)
696
920
  if span is not None:
697
- add(_floor_name(span), file_index, index, "heading")
921
+ add(_floor_name(span), file_index, index, "heading", None, _call_form(span, _floor_name(span)))
698
922
  comments = _html_comment_lines(lines, start, code)
699
923
  for opening, closing, info in blocks:
700
924
  language = segments._language(info)
@@ -703,7 +927,10 @@ def floor(skill_dir: str) -> dict:
703
927
  continue
704
928
  masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
705
929
  for offset, name in _declared_names(masked, language, js_family):
706
- add(name, file_index, opening + 1 + offset, "declaration")
930
+ name_end = _function_declaration(masked[offset], language, js_family)
931
+ signature = (_declaration_form(lines[opening + 1:closing], offset, language, name_end)
932
+ if name_end is not None else None)
933
+ add(name, file_index, opening + 1 + offset, "declaration", None, signature)
707
934
 
708
935
  names = []
709
936
  by_form = dict.fromkeys(FLOOR_FORMS, 0)
@@ -713,7 +940,10 @@ def floor(skill_dir: str) -> dict:
713
940
  for file_index, line, form, _label in origins:
714
941
  if (file_index, line, form) not in seen:
715
942
  seen.add((file_index, line, form))
716
- kept.append({"file": files[file_index], "line": line, "form": form})
943
+ origin = {"file": files[file_index], "line": line, "form": form}
944
+ if (name, file_index, line, form) in signatures:
945
+ origin["signature"] = signatures[(name, file_index, line, form)]
946
+ kept.append(origin)
717
947
  by_form[form] += 1
718
948
  label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
719
949
  names.append({"name": name, "kindLabel": label, "origins": kept})
@@ -245,7 +245,6 @@ from pathlib import Path, PurePosixPath
245
245
  # Surface kinds that count toward total_types. A Rust struct is a class and a
246
246
  # trait an interface; `type` is a type alias.
247
247
  TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
248
- SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
249
248
  MISMATCH_FIELDS = ("name", "line", "source_sig", "documented_sig", "issue")
250
249
  # The optional `difference` a judge tags a mismatch with, defined in
251
250
  # references/signature-comparison-rules.md (Difference tags): only the
@@ -518,7 +517,7 @@ def _has_signature(entry: dict) -> bool:
518
517
  return True
519
518
  return any(
520
519
  isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
521
- for f in SIGNATURE_FIELDS
520
+ for f in _inventory().SIGNATURE_FIELDS
522
521
  )
523
522
 
524
523
 
@@ -552,26 +551,23 @@ def _rank(fields: dict) -> tuple[bool, bool, bool]:
552
551
 
553
552
  def _form(entry: dict) -> dict:
554
553
  """The documented signature fields of one inventory entry, with its `signature_form` when it has one."""
555
- fields = {f: entry[f] for f in SIGNATURE_FIELDS if f in entry}
554
+ fields = {f: entry[f] for f in _inventory().SIGNATURE_FIELDS if f in entry}
556
555
  if entry.get("signature_form") is not None:
557
556
  fields["signature_form"] = entry["signature_form"]
558
557
  return fields
559
558
 
560
559
 
561
560
  def _field_text(value: object) -> str:
562
- """A signature field as one text: a list's items joined with `, `, whitespace runs as one space, "" for null."""
563
- if value is None:
564
- return ""
565
- if isinstance(value, list):
566
- value = ", ".join(v if isinstance(v, str) else json.dumps(v, sort_keys=True) for v in value)
567
- return " ".join(value.split()) if isinstance(value, str) else json.dumps(value, sort_keys=True)
561
+ """A signature field as one text (validate-inventory.py's field_text, which the floor check reads too): a
562
+ list's items joined with `, `, whitespace runs as one space, "" for null."""
563
+ return _inventory().field_text(value)
568
564
 
569
565
 
570
566
  def _form_key(fields: dict) -> tuple:
571
- """What a form says, for telling two entries apart (_field_text), a
572
- null, blank or empty key read as absent, and the `signature_form` label
573
- left out (documented_forms keeps the full one)."""
574
- return tuple((f, _field_text(fields[f])) for f in SIGNATURE_FIELDS if f in fields and _field_text(fields[f]))
567
+ """What a form says, for telling two entries apart (validate-inventory.py's form_key, which the floor check
568
+ reads too): a null, blank or empty key read as absent, and the `signature_form` label left out
569
+ (documented_forms keeps the full one)."""
570
+ return _inventory().form_key(fields)
575
571
 
576
572
 
577
573
  def documented_forms(inventory: list[dict]) -> dict[str, list[dict]]:
@@ -53,7 +53,19 @@ kept when any inventory entry has that name, whatever its kind: kinds stay
53
53
  the reader's. Each checked name not
54
54
  kept is a violation naming its first checked origin, and the inventory is
55
55
  invalid, as for a fabricated name, so the prompt re-dispatches the subagent
56
- with the names it left out.
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.
57
69
 
58
70
  An entry's optional `signature_form` says how the skill presents its
59
71
  signature: `partial` only when the skill labels the form partial (a
@@ -86,7 +98,10 @@ directly in references/ writes it as a fixed string, case-sensitive, never
86
98
  inside a longer identifier (the Export match rule of
87
99
  skf-scan-skill-md-structure.py usage-scope): on a side where the name ends
88
100
  in a letter, digit, `_` or `$`, the next character may not be one of those.
89
- 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`.
90
105
 
91
106
  The skill-citation scan is shared too: `_skill_cited_files` (with
92
107
  `_cited_path`, `skill_markdown_files` and `_SKILL_CITATION_RE`) gives the
@@ -145,12 +160,15 @@ Output (stdout, one object):
145
160
  }
146
161
 
147
162
  Exit codes:
148
- 0 inventory valid (every sampled name found, every checked floor name kept)
163
+ 0 inventory valid (every sampled name found, every checked floor name
164
+ kept and every checked floor signature carried)
149
165
  1 no input provided, the --input file cannot be read, the
150
166
  --skill-package folder holds no readable SKILL.md, or the --floor file
151
- cannot be read or is not a floor (usage error)
167
+ cannot be read or is not a floor, a malformed `signature` among them
168
+ (usage error)
152
169
  2 inventory invalid (parse failure, schema violation, a sampled name
153
- absent or a floor name left out; result JSON emitted)
170
+ absent, a floor name left out or a floor signature no entry carries;
171
+ result JSON emitted)
154
172
  """
155
173
 
156
174
  from __future__ import annotations
@@ -207,6 +225,13 @@ OTHER_SIDE_SHAPES = (
207
225
  # be complete (the docs-only Export Coverage rule).
208
226
  SIGNATURE_KINDS = ("function", "method")
209
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
+
210
235
  # Characters that continue an identifier in the languages SKF documents
211
236
  # (`$` for JS/TS names such as `$state`).
212
237
  IDENT_CHARS = r"\w$"
@@ -409,6 +434,25 @@ def spot_check(inventory, skill_package_path):
409
434
  }
410
435
 
411
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
+
412
456
  # --- Floor ------------------------------------------------------------------
413
457
 
414
458
 
@@ -416,10 +460,17 @@ class FloorError(Exception):
416
460
  """The --floor file cannot be read or is not a floor: exit 1."""
417
461
 
418
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
+
419
469
  def _is_origin(origin):
420
470
  return (isinstance(origin, dict) and isinstance(origin.get("file"), str) and bool(origin["file"])
421
471
  and isinstance(origin.get("line"), int) and not isinstance(origin["line"], bool)
422
- and isinstance(origin.get("form"), str))
472
+ and isinstance(origin.get("form"), str)
473
+ and ("signature" not in origin or _is_signature(origin["signature"])))
423
474
 
424
475
 
425
476
  def read_floor(path):
@@ -436,7 +487,8 @@ def read_floor(path):
436
487
  and isinstance(item.get("origins"), list) and all(_is_origin(o) for o in item["origins"])
437
488
  for item in names):
438
489
  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)")
490
+ "(each a file, a line and a form, and an optional `signature` of the strings "
491
+ "`params` and `return_type`)")
440
492
  return data
441
493
 
442
494
 
@@ -450,33 +502,60 @@ def _listed_path(ref, skill_package_path):
450
502
  return _cited_path(ref)
451
503
 
452
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
+
453
516
  def floor_check(inventory, floor, skill_package_path=None):
454
- """The floor names the inventory leaves out: (the `floor` result, its violations).
517
+ """The floor names and signatures the inventory leaves out: (the `floor` result, its violations).
455
518
 
456
519
  A floor name is checked when one of its origins is SKILL.md or a file
457
520
  the inventory's `references` lists (the spot-check's files, an absolute
458
521
  path inside `skill_package_path` matched by its path relative to it),
459
522
  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.
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.
461
530
  """
462
531
  listed = inventory.get("references")
463
532
  files = {"SKILL.md"} | {_listed_path(ref, skill_package_path)
464
533
  for ref in (listed if isinstance(listed, list) else [])
465
534
  if isinstance(ref, str) and ref.strip()}
466
- kept = {e.get("name") for e in inventory.get("exports") or [] if isinstance(e, dict)}
535
+ entries = [e for e in inventory.get("exports") or [] if isinstance(e, dict)]
536
+ kept = {e.get("name") for e in entries}
467
537
  names = floor.get("names") or []
468
- checked, absent, violations = 0, [], []
538
+ checked, absent, violations, signed = 0, [], [], []
469
539
  for item in names:
470
540
  origins = [o for o in item["origins"] if _cited_path(o["file"]) in files]
471
541
  if not origins:
472
542
  continue
473
543
  checked += 1
474
544
  name = item["name"]
545
+ signed += [(name, o) for o in origins if "signature" in o]
475
546
  if name in kept:
476
547
  continue
477
548
  absent.append(name)
478
549
  where = f"{origins[0]['file']}:{origins[0]['line']} ({origins[0]['form']})"
479
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")
480
559
  return {"names": len(names), "checked": checked, "absent": absent}, violations
481
560
 
482
561
 
@@ -782,7 +861,7 @@ def _build_parser():
782
861
  parser.add_argument(
783
862
  "--floor",
784
863
  metavar="PATH",
785
- help="Check the inventory keeps the names of this load-coverage-inputs.py floor file.",
864
+ help="Check the inventory keeps the names and signatures of this load-coverage-inputs.py floor file.",
786
865
  )
787
866
  return parser
788
867
 
@@ -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
@@ -267,20 +267,27 @@ It writes the per-run record `{forge_version}/update-skill-result-{YYYYMMDD-HHmm
267
267
  SKF_UPDATE_RESULT_JSON: {"skf_update": {"status": "success", "skill_name": ..., "version": ..., "previous_version": ..., "update_mode": ..., "files_written": [...], "headless_decisions": [...], "warnings": [...], "error": null}}
268
268
  ```
269
269
 
270
- In `{headless_mode}`, bind `{result_envelope_line}` to that line and do not display it here: the shared health check displays it verbatim as the run's last line. An interactive run runs the command for its result files and need not show the line. When the emitter exits non-zero and its `message` names the payload, fix the payload once and run it again; a write it could not make adds `result_file_write_failed` to the line's `warnings[]`.
270
+ In `{headless_mode}`, bind `{result_envelope_line}` to that line and do not display it here: the shared health check displays it verbatim as the run's last line. An interactive run runs the command for its result files and need not show the line. When the emitter exits non-zero and its `message` names the payload, fix the payload once and run it again; a write it could not make adds `result_file_write_failed` to the line's `warnings[]`. When the emitter exits non-zero and no retry applies, or fails again, or no path resolved for `{emitEnvelopeHelper}`, this run has no result contract: display its `message` (or that the emitter is missing), bind no `{result_envelope_line}`, and skip the hook as below.
271
271
 
272
272
  - `status`: `"success"` here; §1 stages `"no-changes"`, §1a `"detect-only"` and §1b `"dry-run"`; a halt prints its own `halted-for-*` or `blocked` line through its step's halt procedure and never reaches this step. The full enum lives in the schema.
273
273
  - `files_written[]`: `references` stands for every `references/*.md` file of the package this run wrote, added or removed, one value however many: merge.md §6b's merge, at any priority, write.md §6's provenance fixes and write.md §7's body split. Read it from the files on disk, never from memory: a `references/` path in the `files_written[]` write.md §6's provenance `fix` printed; a file the `Body split` row lists in this run's `## Update Operation` section of `{forge_version}/evidence-report.md`, the one write.md §4 appended, never an earlier update's; outside gap-driven mode, a `references/*.md` file that `{skill_package}` and the previous version's package (`{skill_group}/{baseline_version}/{skill_name}/references/`) do not hold alike, one lacking it or the two differing; in gap-driven mode, which repairs the version in place, a file in `{run_dir}/references/`, where merge.md §6b staged each one it wrote, or a path `{run_dir}/references-removed.txt` lists.
274
274
  - `headless_decisions[]`: every gate's record in the run's decision log (init.md §4 degraded-rebuild and §8 confirmation, detect-changes.md §1b / §1c / §2.2, gap-driven.md §1 rule R1, merge.md §8). Each entry `{gate, default_action, taken_action, reason, evidence?}`. Empty when no gate auto-resolved.
275
275
  - `error`: null on every exit this step prints. A halt's line carries `{phase, path?, reason}`, and pipelines branch on `error !== null` for non-zero exit semantics.
276
- - `warnings[]`: every entry the run recorded, among them `source-tree:`, `source-not-fetched`, `file-diff-unavailable`, `no-baseline-time`, `moved-check-skipped` and `unknown-language` (detect-changes.md §2.1 Category A), `new-files-not-checked` (Category D: a skill with no brief, so no new script or asset was looked for), `source-version-lower`, `unconsumed-test-report` (init.md §4b), `interrupted-run-cleaned` and `interrupted-run-not-cleaned` (init.md §1b), `test-report:` entries (what the test report's helpers could not read, and `test-report: not routed: {id} ({category}): {reason}` for each gap gap-driven.md §1 did not route), `proposed-amendment:` (a read-only run's brief decisions, not written), `doc-fetch-failed`, `doc-not-hashed` and `doc-drift-not-checked` (a docs-only skill's documents), `workspace-clone-not-updated`, `target-ref-not-recorded`, `workspace_drift_overridden` (gap-driven.md §3), `run-state-not-finished`, `public-api-counted-by-hand:` (write.md §2: the recipe runner exited 2, so the public names were found without it; it quotes the runner's error line) and `provenance:` entries (write.md §3 and §6: provenance findings left for a person, and spot-check entries §3 left for a person to decide).
276
+ - `warnings[]`: every entry the run recorded, among them `source-tree:`, `source-not-fetched`, `file-diff-unavailable`, `no-baseline-time`, `moved-check-skipped` and `unknown-language` (detect-changes.md §2.1 Category A), `new-files-not-checked` (Category D: a skill with no brief, so no new script or asset was looked for), `source-version-lower`, `unconsumed-test-report` (init.md §4b), `interrupted-run-cleaned` and `interrupted-run-not-cleaned` (init.md §1b), `test-report:` entries (what the test report's helpers could not read, and `test-report: not routed: {id} ({category}): {reason}` for each gap gap-driven.md §1 did not route), `proposed-amendment:` (a read-only run's brief decisions, not written), `doc-fetch-failed`, `doc-not-hashed` and `doc-drift-not-checked` (a docs-only skill's documents), `workspace-clone-not-updated`, `target-ref-not-recorded`, `workspace_drift_overridden` (gap-driven.md §3), `run-state-not-finished`, `result_file_write_failed` (a result file the emitter could not write; the hook then does not run), `public-api-counted-by-hand:` (write.md §2: the recipe runner exited 2, so the public names were found without it; it quotes the runner's error line) and `provenance:` entries (write.md §3 and §6: provenance findings left for a person, and spot-check entries §3 left for a person to decide).
277
277
 
278
- **Post-finalization hook.** A finished run, this one or §1's no-change exit, fires the hook; `--detect-only`, `--dry-run` and a halt never do (customize.toml says so). If `{onCompleteCommand}` (resolved in SKILL.md On Activation §4 from `workflow.on_complete`) is non-empty, invoke it after the emitter wrote both result files:
278
+ **Post-finalization hook.** A finished run, this one or §1's no-change exit, fires the hook; `--detect-only`, `--dry-run` and a halt never do (customize.toml says so). If `{onCompleteCommand}` (resolved in SKILL.md On Activation §4 from `workflow.on_complete`) is non-empty, the emitter exited 0 and the line's `warnings[]` hold no entry that starts with `result_file_write_failed` (the emitter wrote both result files), invoke it:
279
279
 
280
280
  ```bash
281
281
  {onCompleteCommand} --result-path={forge_version}/update-skill-result-latest.json
282
282
  ```
283
283
 
284
+ The line has no `result_path`: any `result_file_write_failed` entry means this run's `update-skill-result-latest.json` was not written, since the emitter stops at the first write that fails. An interactive run reads the line for this check without displaying it. When `{onCompleteCommand}` is non-empty but the hook does not run, skip it and say why:
285
+
286
+ - The run has no result contract (above): "on_complete skipped: no result contract was written ({the reason})".
287
+ - The line holds such an entry: "on_complete skipped: `update-skill-result-latest.json` was not written."
288
+
289
+ The hook never gets a `-latest` copy this run failed to write.
290
+
284
291
  Run it with a bounded timeout (default 60s). On success, log an Info note and continue; on non-zero exit, timeout, or any failure, tell the user in one line with the reason (the line and the result files are written before the hook runs, so they cannot carry its failure) and continue. The hook must never fail the workflow: it is integration glue (notify a CI router, chain audit/export/test) orthogonal to the update outcome. Empty `{onCompleteCommand}` = no-op, no log entry.
285
292
 
286
293
  ### 6. Chain to Health Check
@@ -461,7 +461,7 @@ Do not rely on per-call ad-hoc preservation logic: use the helper.
461
461
 
462
462
  **If skill-check is available:**
463
463
 
464
- - Run `npx skill-check check "{skill_package}" --fix --format json` **inside the §0 guard**. One call validates the package against the spec, scores it, fixes what has one answer and runs the security scan (on unless `--no-security-scan` is passed). Read the JSON, not the exit code: the quality score (`scores[].score`, the entry matching the skill, or a top-level `qualityScore` on an older skill-check), the remaining `diagnostics[]`, `fixed[]` and the security findings. When the scan could not run (no `SNYK_TOKEN`), record `Security: SKIP (SNYK_TOKEN not configured)`.
464
+ - Run `npx skill-check check "{skill_package}" --fix --format json` **inside the §0 guard**. One call validates the package against the spec, scores it, fixes what has one answer and runs the security scan (on unless `--no-security-scan` is passed). Read the JSON, not the exit code: the quality score (`scores[].score`, the entry matching the skill, or a top-level `qualityScore` on an older skill-check), the remaining `diagnostics[]`, `fixed[]` and the security findings. When the scan could not run (no `SNYK_TOKEN`: skill-check exits non-zero and its stderr names `SNYK_TOKEN`), record `Security: SKIP (SNYK_TOKEN not configured)`.
465
465
  - **Context sync after --fix:** if `fixed[]` is non-empty, re-read the SKILL.md `--fix` changed into the in-context copy, so the report does not work from a copy that differs from the file on disk. The §0 guard has already restored `description` if it diverged.
466
466
  - If `body.max_lines` or `body.max_tokens` is reported, split the body selectively with `{shardBodyHelper}` (resolve it ← first existing path in `{shardBodyProbeOrder}`), **inside the §0 guard**: from `{project-root}`, run `uv run {shardBodyHelper} "{skill_package}/SKILL.md" --budget 400 --word-budget 4500 --skip-manual`, with `--budget 500` in place of `--budget 400` when only `body.max_tokens` is reported. It moves the largest Tier 2 (`## Full`) sections to `references/`, each replaced by a `> See` pointer to a file it never overwrites, until the body is within that many lines and 4,500 words, the same headroom under skill-check's 5,000 that 400 lines leave under its 500, and keeps Tier 1 inline (inline passive context achieves 100% task accuracy vs 79% for on-demand retrieval). **Never move a section that holds a `[MANUAL]` block:** init.md §5 inventories SKILL.md only, so a block moved to `references/` would leave the next update's inventory; `--skip-manual` keeps the helper off every section a block reaches into. No Tier 1 content is trimmed, and `split-body` never runs after the helper. Add each WARN below to `warnings[]` as well. Act on its report:
467
467
  - **Exit 1 or 2, or no JSON:** HALT with status `blocked` (halt procedure: `phase: "write:body-split"`, `path: "{skill_package}/SKILL.md"`, `reason: "body split failed: {its stderr line}"`). On exit 2 that line ends `already written: {each references/*.md file it wrote before the failure, or none}`: name each of those files in the halt message.