bmad-module-skill-forge 7.0.0 → 8.1.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 (87) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/docs/_data/pinned.yaml +9 -10
  4. package/docs/campaign.md +2 -2
  5. package/docs/skill-model.md +3 -3
  6. package/docs/verifying-a-skill.md +17 -18
  7. package/docs/workflows.md +8 -8
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  13. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  14. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  17. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  18. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  19. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  21. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  22. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  23. package/src/shared/scripts/skf-quick-batch.py +8 -4
  24. package/src/shared/scripts/skf-verify-provenance-completeness.py +957 -183
  25. package/src/skf-analyze-source/SKILL.md +1 -1
  26. package/src/skf-audit-skill/SKILL.md +1 -1
  27. package/src/skf-audit-skill/customize.toml +4 -3
  28. package/src/skf-audit-skill/references/report.md +1 -1
  29. package/src/skf-brief-skill/SKILL.md +1 -1
  30. package/src/skf-campaign/SKILL.md +1 -1
  31. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  32. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  33. package/src/skf-campaign/references/step-06-batch.md +2 -2
  34. package/src/skf-campaign/references/step-07-capstone.md +1 -1
  35. package/src/skf-campaign/references/step-08-verify.md +1 -1
  36. package/src/skf-campaign/references/step-09-refine.md +1 -1
  37. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  38. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  39. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  40. package/src/skf-campaign/scripts/campaign-state.py +67 -19
  41. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  42. package/src/skf-create-skill/SKILL.md +1 -1
  43. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  44. package/src/skf-create-skill/references/report.md +2 -2
  45. package/src/skf-create-skill/references/validate.md +49 -29
  46. package/src/skf-create-stack-skill/SKILL.md +1 -1
  47. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  48. package/src/skf-create-stack-skill/references/report.md +3 -3
  49. package/src/skf-create-stack-skill/references/validate.md +9 -4
  50. package/src/skf-drop-skill/SKILL.md +1 -1
  51. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  52. package/src/skf-export-skill/SKILL.md +1 -1
  53. package/src/skf-export-skill/references/summary.md +1 -1
  54. package/src/skf-forger/SKILL.md +1 -1
  55. package/src/skf-quick-skill/SKILL.md +1 -1
  56. package/src/skf-quick-skill/references/batch-mode.md +28 -2
  57. package/src/skf-quick-skill/references/compile.md +2 -0
  58. package/src/skf-quick-skill/references/finalize.md +3 -2
  59. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  60. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  61. package/src/skf-quick-skill/references/write-and-validate.md +11 -5
  62. package/src/skf-refine-architecture/SKILL.md +1 -1
  63. package/src/skf-refine-architecture/customize.toml +1 -1
  64. package/src/skf-refine-architecture/references/report.md +2 -2
  65. package/src/skf-rename-skill/SKILL.md +1 -1
  66. package/src/skf-rename-skill/references/report.md +1 -1
  67. package/src/skf-setup/SKILL.md +1 -1
  68. package/src/skf-test-skill/SKILL.md +1 -1
  69. package/src/skf-test-skill/customize.toml +10 -0
  70. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  71. package/src/skf-test-skill/references/coverage-check.md +2 -2
  72. package/src/skf-test-skill/references/init.md +1 -1
  73. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  74. package/src/skf-test-skill/references/report.md +3 -2
  75. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  76. package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
  77. package/src/skf-test-skill/scripts/gap-ledger.py +218 -46
  78. package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
  79. package/src/skf-test-skill/templates/test-report-template.md +4 -3
  80. package/src/skf-update-skill/SKILL.md +1 -1
  81. package/src/skf-update-skill/references/gap-driven.md +1 -1
  82. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  83. package/src/skf-update-skill/references/merge.md +1 -1
  84. package/src/skf-update-skill/references/report.md +18 -7
  85. package/src/skf-update-skill/references/write.md +24 -12
  86. package/src/skf-verify-stack/SKILL.md +1 -1
  87. package/src/skf-verify-stack/references/report.md +1 -1
@@ -115,25 +115,51 @@ cites (never read; default `metadata.json`):
115
115
  name it (outside the citations), or on the next non-blank line
116
116
  after one, cite exactly one of those files: the gap
117
117
  is at that file's `declared_in` item, and two cited or none
118
- leave it Medium. The documented extra's issue names the line
119
- that documents it (with --skill-dir) and its remediation asks no
118
+ leave it Medium. The documented extra's remediation asks no
120
119
  change (update-skill never routes it) and says whether signature
121
120
  scoring compared its documented signature (a name the
122
121
  --signatures result lists in `comparedNames`). `excluded` is
123
122
  read once a stale name that is not fabricated needs it, and a
124
123
  malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
125
- `Stale documentation: {name}` at the first line of SKILL.md,
126
- then of references/*.md in path order, that writes the name
127
- (validate-inventory.py's match, with --skill-dir), else
128
- `SKILL.md`. One whose `excluded.outsideScope` row says the brief
124
+ `Stale documentation: {name}` at the line that documents the
125
+ name (with --skill-dir), else `SKILL.md`. That line is the
126
+ first that writes it as an export, the first tier that holds
127
+ the name winning: (1) its first origin in the documented
128
+ inventory's floor (load-coverage-inputs.py floor); (2) a
129
+ heading, or the first cell of a table whose header's first
130
+ cell names exports (floor's table headers), whose text is the
131
+ name or holds a code span floor reads as the name; (3) a line
132
+ whose inline code span is the name, or the name directly
133
+ followed by `(`; (4) a column-0 declaration of it in a fenced
134
+ block of a language floor's `_declared_names` reads (JS/TS,
135
+ Python, Rust, Go), cited or not, so a def an example writes
136
+ under the name is cited only when nothing above documents it;
137
+ (5) a line of a fenced block that holds the name as a whole
138
+ quoted string (`"name"` or `'name'`), such as an `__all__` item.
139
+ Tiers 2 to 5 read SKILL.md, then every .md file under
140
+ references/, subfolders included, in path order, outside the
141
+ frontmatter (2 and 3 outside fenced code and HTML comments, 4
142
+ and 5 inside a fence outside an HTML comment and a data block
143
+ (json, yaml...)); within a tier the first file, then its lowest
144
+ line, wins. A bare word, a span that holds the name among other
145
+ words (`uitripled add`), or a quoted string outside a fence or
146
+ holding other words too (`"add x"`) documents nothing; with no
147
+ such line, or without --skill-dir, the Source is `SKILL.md`.
148
+ Floor's InputError (no SKILL.md, or a top-level file it cannot
149
+ read or decode) leaves tier 1 empty, and a skill file that
150
+ cannot be read leaves tiers 2 to 5 empty. One whose
151
+ `excluded.outsideScope` row says the brief
129
152
  scopes its file out (`excludeGlob`, the `scope.exclude` glob
130
153
  that matches it, or `outsideInclude`, no `scope.include` glob
131
154
  covering it) has its own issue, naming the entry point and the
132
155
  file and line, and remediation: to keep the documentation,
133
156
  narrow that exclude glob (a removal needs a `demoted-exclude`
134
157
  amendment) or widen `scope.include`, else remove it. A row the
135
- brief does not explain keeps the wording above. Scalar and
136
- stack branches: a
158
+ brief does not explain keeps the wording above. Every stale
159
+ name's issue (fabricated, documented extra, stale documentation
160
+ or scoped out) opens with the line that documents it, as
161
+ `file:line` (with --skill-dir), else with `the skill`, whatever
162
+ its Source. Scalar and stack branches: a
137
163
  `missingCount` above 0 is one Medium `missing-export` gap
138
164
  `{missingCount} of {denominator} exports not documented` at
139
165
  `{meta}`, except that a scalar count taken from the verified
@@ -194,15 +220,26 @@ cites (never read; default `metadata.json`):
194
220
  entries its counts differ by; a barrel one sends the fix to
195
221
  Create Skill, which rebuilds `exports[]` from the source.
196
222
  provenance-line
197
- --input skf-verify-provenance-completeness.py verify's result.
223
+ --input skf-verify-provenance-completeness.py verify's result;
224
+ --stale.
198
225
  Each `stale[]` item, at its `{source_file}:{source_line}` with
199
226
  `export`, by `reason`: `line-not-definition` is a Low
200
227
  `provenance-line` gap `Provenance line is not the definition of
201
228
  {export_name}` when `definition_lines` holds a line, else an Info
202
229
  `provenance-unverified` gap `Provenance line not verified for
203
230
  {export_name}`, and its issue names the base of a `folded_to`
204
- variant; `file-missing` is a Low `provenance-line` gap
205
- `Provenance file is missing for {export_name}`;
231
+ variant. When the --stale result (classify-stale) marks
232
+ `{export_name}` `aliased` with a `file:line` `defined_at`, that
233
+ Info gap's issue and remediation name the alias at `defined_at`,
234
+ and the remediation asks first to record the rename if no other
235
+ entry already has its `export_name` (`export_name` the item's
236
+ `renames`, else "a name a cited file declares", `reexported_as`
237
+ the export), else to cite the alias, moving the citations either
238
+ way; when `renames` is `default`, which the map keys on
239
+ `export_name` and so holds once, it asks only to cite the alias.
240
+ A --stale result that is not a list is INVALID_INPUT.
241
+ `file-missing` is a Low `provenance-line`
242
+ gap `Provenance file is missing for {export_name}`;
206
243
  `line-out-of-bounds` a Low `provenance-line` gap `Provenance line
207
244
  is out of range for {export_name}`; `line-invalid` an Info
208
245
  `provenance-unverified` gap `Provenance line not verified for
@@ -374,6 +411,9 @@ EFFORT = {
374
411
  _ID_RE = re.compile(r"^GAP-(\d{3,})$")
375
412
  _STAGE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
376
413
  _RUN_ID_FROM_NAME = re.compile(r"^test-findings-(.+)\.json$")
414
+ # A whole quoted string, `"name"` or `'name'`. The lookahead lets matches overlap, so an apostrophe earlier on the
415
+ # line (`don't 'add'`) does not swallow the next string; the text between two strings is a harmless extra key.
416
+ _QUOTED_RE = re.compile(r"""(?=(["'])([^"'\n]+)\1)""")
377
417
 
378
418
 
379
419
  class LedgerError(Exception):
@@ -747,7 +787,7 @@ ADAPTER_OPTIONS: dict[str, frozenset[str]] = {
747
787
  "guards": frozenset({"metadata"}),
748
788
  "numerator": frozenset({"metadata"}),
749
789
  "metadata-coherence": frozenset({"metadata"}),
750
- "provenance-line": frozenset(),
790
+ "provenance-line": frozenset({"stale"}),
751
791
  "coherence": frozenset(),
752
792
  "structure": frozenset({"served"}),
753
793
  }
@@ -776,8 +816,10 @@ _SIBLINGS: dict[str, object] = {}
776
816
 
777
817
  def _sibling(filename: str):
778
818
  """A script beside this one, loaded once: validate-inventory.py (the
779
- documented-name match the stale-documentation Source uses, and the
780
- skill-citation scan the homonym pick reads)."""
819
+ skill-citation scan the homonym pick reads), load-coverage-inputs.py
820
+ (the documented inventory's floor, and its fence and table readers)
821
+ and locate-export-segments.py (the skill's files and line model), the
822
+ last two for the line that documents a stale name."""
781
823
  module = _SIBLINGS.get(filename)
782
824
  if module is None:
783
825
  path = Path(__file__).resolve().parent / filename
@@ -872,21 +914,102 @@ def _provenance_sources(provenance: dict | None) -> dict[str, str]:
872
914
  return sources
873
915
 
874
916
 
917
+ def _documenting_lines(skill_dir: Path) -> dict[str, str]:
918
+ """name -> the `file:line` that documents it as an export, the first tier
919
+ that holds the name winning:
920
+
921
+ 1. its first origin in the documented inventory's floor
922
+ (load-coverage-inputs.py `floor`: SKILL.md and the top-level
923
+ references/*.md);
924
+ 2. a heading, or the first cell of a table whose header's first cell is
925
+ one of floor's `FLOOR_TABLE_HEADERS`, whose stripped text is the name
926
+ or holds a code span floor's `_floor_name` reads as the name;
927
+ 3. a line whose inline code span, stripped, is the name, or the name
928
+ directly followed by `(`;
929
+ 4. a column-0 public declaration of it in a fenced code block (floor's
930
+ `_declared_names`), cited or not, so a def an example writes under
931
+ the name (a connector author's own `mount_table_target`) is cited
932
+ only when nothing above documents it;
933
+ 5. a raw (unmasked) line of a fenced code block that holds the name as a
934
+ whole quoted string (`"name"` or `'name'`), such as an `__all__`
935
+ item; it only replaces the bare `SKILL.md` fallback.
936
+
937
+ Tiers 2 to 5 read SKILL.md, then every .md file under references/,
938
+ subfolders included, in path order (locate-export-segments.py
939
+ `skill_files`), never the frontmatter: tiers 2 and 3 outside fenced code
940
+ and HTML comments, tiers 4 and 5 inside a fence outside an HTML comment
941
+ and a data block (json, yaml...). Within a tier the first file, then its
942
+ lowest line, wins. A bare word, a span that holds the name among other
943
+ words (`uitripled add`), or a quoted string outside a fence or holding
944
+ other words too (`"add x"`) documents nothing. An input a tier cannot
945
+ read is skipped: floor's InputError (no SKILL.md, a file that is not
946
+ UTF-8) leaves tier 1 empty, an unreadable file tiers 2 to 5."""
947
+ coverage = _sibling("load-coverage-inputs.py")
948
+ segments = _sibling("locate-export-segments.py")
949
+ # In merge order: floor, heading or export-table cell, code span, fenced declaration, quoted string.
950
+ tiers: list[dict[str, str]] = [{}, {}, {}, {}, {}]
951
+ try:
952
+ for entry in coverage.floor(str(skill_dir))["names"]:
953
+ origin = entry["origins"][0]
954
+ tiers[0].setdefault(entry["name"], f"{origin['file']}:{origin['line']}")
955
+ except coverage.InputError:
956
+ pass
957
+ try:
958
+ files = segments.skill_files(skill_dir)
959
+ except OSError:
960
+ files = []
961
+ # floor's fence filter: a fence in an HTML comment or a data block declares and quotes nothing, and a Svelte
962
+ # block's `export let` is a component prop. Unlike floor, an unheaded fence counts; a Svelte, shell or untagged
963
+ # fence's quoted strings count too.
964
+ js_family = segments.JS_FAMILY - {"svelte"}
965
+ for rel, lines in files:
966
+ start = segments._frontmatter_end(lines)
967
+ blocks, code = segments._fenced_blocks(lines, start)
968
+ comments = coverage._html_comment_lines(lines, start, code)
969
+ for opening, closing, info in blocks:
970
+ language = segments._language(info)
971
+ if opening in comments or language in segments.DATA_LANGUAGES:
972
+ continue
973
+ raw = lines[opening + 1:closing]
974
+ masked = segments.mask_code("\n".join(raw), language).split("\n")
975
+ for offset, declared in coverage._declared_names(masked, language, js_family):
976
+ tiers[3].setdefault(declared, f"{rel}:{opening + offset + 2}")
977
+ for offset, line in enumerate(raw):
978
+ for match in _QUOTED_RE.finditer(line):
979
+ tiers[4].setdefault(match.group(2), f"{rel}:{opening + offset + 2}")
980
+ headings, prose = segments._prose(lines, start, code)
981
+ shaped = [(index, text) for index, _level, text, _above in headings]
982
+ written = list(shaped)
983
+ for block in prose:
984
+ written += [(i, lines[i]) for i in range(block["start"], block["end"] + 1)]
985
+ if block["kind"] == "table-row" and block["header"] is not None:
986
+ if coverage._header_word(coverage._cells(block["header"])[0]) in coverage.FLOOR_TABLE_HEADERS:
987
+ shaped.append((block["start"], coverage._cells(lines[block["start"]])[0]))
988
+ for index, text in sorted(shaped):
989
+ for key in [text.strip()] + [coverage._floor_name(span) for span in segments._inline_code(text)]:
990
+ if key:
991
+ tiers[1].setdefault(key, f"{rel}:{index + 1}")
992
+ for index, text in sorted(written):
993
+ for span in map(str.strip, segments._inline_code(text)):
994
+ for key in (span, span.split("(", 1)[0] if "(" in span else None):
995
+ if key:
996
+ tiers[2].setdefault(key, f"{rel}:{index + 1}")
997
+ found: dict[str, str] = {}
998
+ for tier in tiers:
999
+ for key, line in tier.items():
1000
+ found.setdefault(key, line)
1001
+ return found
1002
+
1003
+
875
1004
  def _documenting_line(skill_dir: Path, name: str) -> str | None:
876
- """The first line of SKILL.md, then of references/*.md in path order,
877
- that writes `name` (validate-inventory.py's match), as `file:line`."""
878
- pattern = _sibling("validate-inventory.py").name_pattern(name)
879
- refs = skill_dir / "references"
880
- files = [skill_dir / "SKILL.md"] + (sorted(refs.glob("*.md")) if refs.is_dir() else [])
881
- for path in files:
882
- try:
883
- text = path.read_bytes().decode("utf-8-sig", errors="replace")
884
- except OSError:
885
- continue
886
- for number, line in enumerate(text.split("\n"), start=1):
887
- if pattern.search(line):
888
- return f"{path.relative_to(skill_dir).as_posix()}:{number}"
889
- return None
1005
+ """The `file:line` that documents `name` as an export in the skill at
1006
+ `skill_dir` (`_documenting_lines`), else None."""
1007
+ return _documenting_lines(skill_dir).get(name)
1008
+
1009
+
1010
+ def _documents(doc: str | None, name: str) -> str:
1011
+ """The subject of a stale name's Issue: the line that documents it, else the skill."""
1012
+ return f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`"
890
1013
 
891
1014
 
892
1015
  def _scoped_out_by(row: dict) -> bool:
@@ -1069,8 +1192,8 @@ def _scoped_out_gap(name: str, row: dict, doc: str | None, brief: str) -> dict:
1069
1192
  f"Decide whether `{name}` belongs on the surface: `{brief}` scopes out `{at}`, where it is defined, since "
1070
1193
  f"{' and '.join(causes)}. To keep its documentation, {' and '.join(fixes)}; else remove the documentation "
1071
1194
  f"of `{name}` at {doc or 'SKILL.md'}.",
1072
- issue=f"{f'`{doc}`' if doc else 'SKILL.md'} documents `{name}`, which {who} exports and `{at}` defines, a "
1073
- f"file `{brief}` scopes out (`excluded.outsideScope`)",
1195
+ issue=f"{_documents(doc, name)}, which {who} exports and `{at}` defines, a file `{brief}` scopes out "
1196
+ "(`excluded.outsideScope`)",
1074
1197
  export=name)
1075
1198
 
1076
1199
 
@@ -1113,9 +1236,13 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1113
1236
  if not isinstance(stale, list):
1114
1237
  raise AdapterError("--stale is not classify-stale JSON (a list)")
1115
1238
  classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
1116
- for name in _names(cov, "stale", "--input"):
1239
+ names = _names(cov, "stale", "--input")
1240
+ # the lines that document each name, read once for every stale name
1241
+ documenting = _documenting_lines(Path(skill_dir)) if skill_dir and names else {}
1242
+ for name in names:
1117
1243
  item = classified.get(name) or {}
1118
1244
  cited = item.get("source")
1245
+ doc = documenting.get(name)
1119
1246
  if item.get("fabricated") is True:
1120
1247
  if not isinstance(cited, str) or not cited:
1121
1248
  # a Critical gap needs the line it cites: never downgrade it
@@ -1124,14 +1251,13 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1124
1251
  "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1125
1252
  f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1126
1253
  f"defines: no file its provenance cites defines it (`{cited}`).",
1127
- issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1128
- "file does not define",
1254
+ issue=f"{_documents(doc, name)}, which the source API surface lacks and the cited file does "
1255
+ "not define",
1129
1256
  export=name))
1130
1257
  continue
1131
1258
  if not outside_read:
1132
1259
  outside, outside_read = _outside_scope(surface), True
1133
1260
  extra = _documented_extra_at(name, item, outside, skill_dir)
1134
- doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1135
1261
  if extra:
1136
1262
  declared, among = extra
1137
1263
  # a homonym: the one declaring file the skill's own citations name
@@ -1146,8 +1272,8 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1146
1272
  "Info", "observation", f"Documented extra: {name}", declared,
1147
1273
  f"No change: `{name}` is a documented extra, a name the source still declares at "
1148
1274
  f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1149
- issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1150
- f"surface lacks and the source still declares{picked}",
1275
+ issue=f"{_documents(doc, name)}, which the enumerated source API surface lacks and the source "
1276
+ f"still declares{picked}",
1151
1277
  export=name))
1152
1278
  continue
1153
1279
  if outside is not None and name in outside and _scoped_out_by(outside[name]):
@@ -1158,7 +1284,7 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1158
1284
  "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1159
1285
  f"Check `{name}` against the source and update or remove its documentation at "
1160
1286
  f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1161
- issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1287
+ issue=f"{_documents(doc, name)}, which the enumerated source API surface lacks",
1162
1288
  export=name))
1163
1289
  return records
1164
1290
 
@@ -1448,10 +1574,50 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
1448
1574
  return records
1449
1575
 
1450
1576
 
1451
- def from_provenance_line(result: object) -> list[dict]:
1577
+ def _aliased_items(stale: object) -> dict[str, dict]:
1578
+ """name -> its classify-stale item (`--stale`) when it is `aliased` with a `file:line` `defined_at`: a rename
1579
+ elsewhere declares the name, so a provenance line gap of it names the alias. Raises AdapterError, as the
1580
+ coverage adapter does, when the result is not a list."""
1581
+ if stale is None:
1582
+ return {}
1583
+ if not isinstance(stale, list):
1584
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
1585
+ return {i["name"]: i for i in stale
1586
+ if isinstance(i, dict) and isinstance(i.get("name"), str) and i.get("reason") == "aliased"
1587
+ and isinstance(i.get("defined_at"), str) and i["defined_at"].rpartition(":")[0]
1588
+ and i["defined_at"].rpartition(":")[2].isdigit()}
1589
+
1590
+
1591
+ def _alias_fix(name: str, file: str, item: dict) -> str:
1592
+ """The remediation of an unverified provenance line of a name classify-stale found `aliased` (`item`): record
1593
+ the rename if no other entry already has its `export_name`, else cite the alias. The map keys on
1594
+ `export_name`, so it holds one `default` entry at most (a barrel of `export { default as X }` lines renames
1595
+ several): a rename of `default` takes the alias alone. It never says which cited file declares the renamed
1596
+ name: any checked entry's may."""
1597
+ alias, local = item["defined_at"], item.get("renames")
1598
+ alias_file, _, alias_line = alias.rpartition(":")
1599
+ named = isinstance(local, str) and bool(local)
1600
+ renamed = f"`{local}`" if named else "a name a cited file declares"
1601
+ of = f"{renamed}, which a cited file declares" if named else renamed
1602
+ head = (f"`{name}` is an alias of {of}; the source declares the alias at `{alias}`, and no line of `{file}` "
1603
+ f"defines `{name}`. ")
1604
+ cite = (f"the alias: set `source_file` to `{alias_file}` and `source_line` to `{alias_line}`, and move the "
1605
+ f"citations of `{name}` there.")
1606
+ if local == "default":
1607
+ return f"{head}Cite {cite}"
1608
+ guard = f"`export_name` {renamed}" if named else "that name as its `export_name`"
1609
+ return (f"{head}If no other entry already has {guard}, record the rename in this provenance entry: set "
1610
+ f"`export_name` to {renamed}, add `reexported_as` `{name}`, cite the line that declares {renamed} and "
1611
+ f"move the citations of `{name}` to that line. Or cite {cite}")
1612
+
1613
+
1614
+ def from_provenance_line(result: object, stale: object = None) -> list[dict]:
1452
1615
  """verify's `stale[]` items: provenance line gaps, one per citation that does not resolve or is not the
1453
- definition line."""
1616
+ definition line. `stale` (classify-stale's result, `--stale`, or None) gives the names a rename declares: an
1617
+ unverified line of one names the alias and, unless the name it renames is `default`, how to record the rename
1618
+ (#911)."""
1454
1619
  result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
1620
+ aliased = _aliased_items(stale)
1455
1621
  records: list[dict] = []
1456
1622
  for item in _list(result, "stale", "--input"):
1457
1623
  if not isinstance(item, dict):
@@ -1506,12 +1672,16 @@ def from_provenance_line(result: object) -> list[dict]:
1506
1672
  f"it; the lines that define it are {lines}.{folded}",
1507
1673
  export=name))
1508
1674
  else:
1509
- records.append(_gap(
1510
- "Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
1675
+ alias = aliased[name] if isinstance(name, str) and name in aliased else None
1676
+ fix = _alias_fix(name, file, alias) if alias else (
1511
1677
  f"Open `{file}` and confirm that line {line} defines `{base or name}`; if another line does, set "
1512
- "the provenance `source_line` to it.",
1513
- issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand"
1514
- + (f".{folded}" if folded else ""),
1678
+ "the provenance `source_line` to it.")
1679
+ issue = (f"no line of `{file}` defines `{name}`: the source declares it only as the alias at "
1680
+ f"`{alias['defined_at']}`" if alias else
1681
+ f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand")
1682
+ records.append(_gap(
1683
+ "Info", "provenance-unverified", f"Provenance line not verified for {name}", where, fix,
1684
+ issue=issue + (f".{folded}" if folded else ""),
1515
1685
  export=name))
1516
1686
  return records
1517
1687
 
@@ -1680,7 +1850,8 @@ def build_records(args: argparse.Namespace) -> list[dict]:
1680
1850
  if kind == "metadata-coherence":
1681
1851
  return from_metadata_coherence(data, args.metadata)
1682
1852
  if kind == "provenance-line":
1683
- return from_provenance_line(data)
1853
+ stale = read_result(args.stale, "--stale") if args.stale is not None else None
1854
+ return from_provenance_line(data, stale)
1684
1855
  return from_coherence(data)
1685
1856
 
1686
1857
 
@@ -1897,7 +2068,8 @@ def _build_parser() -> argparse.ArgumentParser:
1897
2068
  "the names outside scope)")
1898
2069
  p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
1899
2070
  p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
1900
- p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
2071
+ p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras); "
2072
+ "provenance-line: the same (the names a rename declares)")
1901
2073
  p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
1902
2074
  p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
1903
2075
  p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
@@ -25,9 +25,11 @@ reading files and writing one JSON object:
25
25
  signature is read only where the place writes it in a fixed
26
26
  form (Signatures, below). A name is a plain
27
27
  identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
28
- everything from the first `(` or `<` are cut, so `Meta<T>`,
29
- `fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
30
- `fetch` and `Client`. A dotted or `::` name gives none, so a
28
+ everything from the first `(`, `<` or `[` are cut, so
29
+ `Meta<T>`, `App[P, R]`, `fetch(url): Promise<T>` and
30
+ `new Client(opts)` give `Meta`, `App`, `fetch` and `Client`;
31
+ an empty `[]` at the cut (a TS array type, `Foo[]`) gives
32
+ none. A dotted or `::` name gives none, so a
31
33
  namespace-style skill such as zod (`z.object`) gets no table
32
34
  floor. The forms, read outside the frontmatter and HTML
33
35
  comments (a table row or heading inside fenced code gives none):
@@ -614,12 +616,16 @@ def census(skill_dir: str) -> dict:
614
616
 
615
617
 
616
618
  def _floor_name(text: str) -> str | None:
617
- """The plain identifier `text` writes once a leading `new ` and everything from the first `(` or `<` are
618
- cut; None for any other text, a dotted or `::` name among them."""
619
+ """The plain identifier `text` writes once a leading `new ` and everything from the first `(`, `<` or `[` are
620
+ cut; None for any other text, a dotted or `::` name and an array type (`Foo[]`, an empty `[]` at the cut)
621
+ among them."""
619
622
  text = text.strip()
620
623
  if text.startswith("new "):
621
624
  text = text[4:]
622
- text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
625
+ cut = re.match(r"([^(<\[]*)(\[\s*\])?", text)
626
+ if cut.group(2):
627
+ return None
628
+ text = cut.group(1).strip()
623
629
  return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
624
630
 
625
631
 
@@ -25,9 +25,10 @@ nextWorkflow: ''
25
25
  Each stage writes its section in place of the heading below that it owns and
26
26
  the placeholder comment under that heading, so every heading occurs once.
27
27
  Before the result files are written, report.md §4c checks that each heading
28
- occurs exactly once and that no placeholder comment is left: a stage that
29
- skipped its section stops the run there. Do not reorder or delete the
30
- headings or their placeholder comments.
28
+ occurs exactly once, that no placeholder comment is left and that the title
29
+ above names the skill (init.md §6c fills it): a stage that skipped its
30
+ section stops the run there. Do not change the title line, and do not
31
+ reorder or delete the headings or their placeholder comments.
31
32
 
32
33
  Heading / owning step:
33
34
  Test Summary → detect-mode §2
@@ -86,7 +86,7 @@ Headless callers: the inputs, flags, gate map, outputs and the `SKF_UPDATE_RESUL
86
86
  uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
87
87
  ```
88
88
 
89
- It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-update-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. Record the warning in the run log at once: write `customization_resolver_unavailable: <reason>` to `{run_dir}/resolver-warning.txt` with a file write (a resolver error can hold quotes or `$( )`, so never echo it or type it into an argument), then run `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning "$(cat "{run_dir}/resolver-warning.txt")"` from `{project-root}`.
89
+ It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-update-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `SKF helper skf-resolve-customization.py not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. Record the warning in the run log at once: write `customization_resolver_unavailable: <reason>` to `{run_dir}/resolver-warning.txt` with a file write (a resolver error can hold quotes or `$( )`, so never echo it or type it into an argument), then run `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning "$(cat "{run_dir}/resolver-warning.txt")"` from `{project-root}`.
90
90
 
91
91
  Apply the resolved values so no surface is a silent no-op: execute each entry in `workflow.activation_steps_prepend` in order now; treat every entry in `workflow.persistent_facts` as standing context for the whole run (entries prefixed `file:` are paths or globs whose contents load as facts: the bundled default loads every `project-context.md` under `{project-root}`; an entry prefixed `!` drops each earlier entry it names and loads nothing itself, so an override's `"!file:{project-root}/**/project-context.md"` turns that default off); resolve `{onCompleteCommand}` ← `workflow.on_complete` if non-empty, else empty string, and stash it in workflow context (`references/report.md` §5b runs it after a finished run's result files, never in `--detect-only`, `--dry-run` or a halt; empty = no-op). After activation completes, execute each entry in `workflow.activation_steps_append` in order before `init.md` runs.
92
92
 
@@ -99,7 +99,7 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
99
99
 
100
100
  - `stale-documentation` (the skill documents a name the source's enumerated surface lacks and that is not a documented extra, an Info `observation`): the source has no line to repair the name from, so a person removes its documentation when the name is gone from the source, fixes the brief's scope when the surface missed it, or, when the source still declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it.
101
101
  - `metadata-drift` (divergent export counts in the metadata): update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and `stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold (under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and `stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with (denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding `scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the named-export count leaves out need no fix.
102
- - `provenance-unverified`: the line-check rules could not verify the line, or the recorded line is not a number, so a person checks by hand which line defines the export and, when it is not the recorded one, sets its provenance `source_line` to that line.
102
+ - `provenance-unverified`: the line-check rules could not verify the line, or the recorded line is not a number, so a person checks by hand which line defines the export and, when it is not the recorded one, sets its provenance `source_line` to that line; when the gap names an alias, no line of the cited file defines the export, so a person records the rename in its provenance entry (`export_name` the name the alias renames, `reexported_as` the export) only when no other entry has that `export_name` and the alias does not rename a default export, and otherwise cites the alias line, as the remediation says.
103
103
  - `denominator-inflation`: it concerns the brief's scope and the count design, which a person decides in the skill brief, as the gap's remediation says.
104
104
  - `brief-scope-stale`: a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill does not rewrite them.
105
105
  - `multi-denominator`: no fix is needed, since the barrel and the documented surface measure different sets by design and the report records both counts.
@@ -9,4 +9,4 @@ The argument set, gate map, outputs and headless result line of `skf-update-skil
9
9
  | **Gates** | step 1: init.md §4's degraded-rebuild gate [D]/[X] when no provenance map is found (headless: halts, or takes [D] under `--allow-degraded`; a gap-driven run halts `blocked` there instead); init.md §4b's [G]/[S] test-report offer, interactive only (headless warns `unconsumed-test-report`); Confirm Gate [C] (init.md §8) | step 2, each only when triggered: detect-changes.md §1b's promote prompt and §1c's scope-expansion prompt [P]/[S]/[U] (headless: defers each candidate to a person), §2.2's deletion-ratio gate [C]/[B]/[A] (headless: [C]) | step 2g (gap-driven): gap-driven.md §1 rule R1's [D]/[R] for each gap whose remediation names removal (headless: [D], or [R] only when the export is internal or `#[doc(hidden)]`) | step 4: Confirm Gate [C] only after the user resolved [MANUAL] conflicts (merge.md §4); a clean merge auto-proceeds, and a headless run with conflicts halts `halted-for-manual-mismatch` before anything is written |
10
10
  | **Outputs** | A new version folder `{skill_group}/{new_version}/` and its forge folder, beside the unchanged previous version (a gap-driven repair writes the current version in place), and a finished run's `update-skill-result-{YYYYMMDD-HHmmss}.json` and `update-skill-result-latest.json` in that version's forge folder (a run that found no change: the current one's). `--detect-only` and `--dry-run` write none of these, nor the skill brief. Run state goes to a run folder under `{project-root}/_bmad-output/.skf-run/` that step 7 removes; a halt keeps it |
11
11
  | **Concurrency** | A second real update of the same skill halts `halted-for-concurrent-run` while the first holds its run lock (init.md §1b); `--detect-only` and `--dry-run` take no lock. write.md §8 halts with the same status when another flip of the `active` link holds its lock for 5 seconds. A remote skill is read from a private tree, and no mode writes to the shared workspace clone before write.md §9. |
12
- | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. A caller reads the run's own `SKF_UPDATE_RESULT_JSON` line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `update-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
12
+ | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. A headless run whose emitter refused its result line for good ends on a `halted-for-write-failure` line instead, with `error.phase` `report:result-contract` and an `error.reason` that starts `result-contract-not-written`, and nothing it wrote is undone. A caller reads the run's own `SKF_UPDATE_RESULT_JSON` line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `update-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
@@ -299,7 +299,7 @@ uv run {runLockHelper} acquire \
299
299
  ```
300
300
 
301
301
  It renames the staged folder to `{skill_group}/{new_version}/` in one step, so no reader ever sees a half-copied version.
302
- 6. **Create the forge folder** `{forge_data_folder}/{skill_name}/{new_version}/` and copy `provenance-map.json`, `evidence-report.md` and `extraction-rules.yaml` from `{forge_version}` into it, each when it exists: step 5 updates the provenance map and appends this update to the evidence report there, and audit-skill reads the extraction rules there. Test reports, drift reports and result files belong to the version they were made for and stay where they are.
302
+ 6. **Create the forge folder** `{forge_data_folder}/{skill_name}/{new_version}/` and copy into it each of `provenance-map.json`, `evidence-report.md` and `extraction-rules.yaml`: from `{forge_version}` when it is there, else from `{forge_data_folder}/{skill_name}/` when it is there and init.md §4 bound `{provenance_map_path}` to that flat folder's map (an older skill's flat forge data, `knowledge/version-paths.md`). Copy, never move: the previous version's files and the flat ones stay as they are. Step 5 updates the provenance map and appends this update to the evidence report there; the extraction rules record how the skill was extracted. Test reports, drift reports and result files belong to the version they were made for and stay where they are.
303
303
  7. **Rebind** `{skill_package}` ← `{skill_group}/{new_version}/{skill_name}` and `{forge_version}` ← `{forge_data_folder}/{skill_name}/{new_version}`. Every later step reads and writes the new version there; `{manual_inventory}` keeps the amended path §4 bound.
304
304
 
305
305
  If no `{atomicWriteProbeOrder}` candidate resolves, or the stage, copy, write, publish or forge-folder step fails, HALT with status `halted-for-write-failure`, naming the failed step (for a missing helper: "skf-atomic-write.py is missing; re-install SKF"). The halt procedure takes `phase: "merge:new-version-folder"`, `path: "{skill_group}/{new_version}"`, `reason: "<the failed step>: <its error>"`; its rollback removes what this section created, `{version_staging}`, `{skill_group}/{new_version}/` and `{forge_data_folder}/{skill_name}/{new_version}/`, none of which existed before `begin` recorded them. The previous version is unchanged.
@@ -246,18 +246,19 @@ When `{run_dir}/warnings.jsonl` holds a `workspace-clone-not-updated:` line, add
246
246
 
247
247
  ### 5b. Result Contract
248
248
 
249
- The shared emitter writes the result contract and prints the line; never type either. Stage the payload in the run folder, from the files this run wrote rather than from memory (`{run_dir}/change-manifest.json` for the counts, the artifacts write.md wrote for `files_written`, the Validation Summary §3 read for `validation_status`):
249
+ The shared emitter writes the result contract and prints the line; never type either. Stage the payload in the run folder, from the files this run wrote rather than from memory (`{run_dir}/change-manifest.json` for the counts, the artifacts write.md wrote for `files_written`, the Validation Summary §3 read for `validation_status` and for `security_result`, the first word of its `Security` row with `PASS` as `pass`, `WARN` as `warn` and `SKIP` as `skipped`, left out of the payload and its `summary` when that row is missing or starts with another word; §1, §1a and §1b run no scan and leave `security_result` out):
250
250
 
251
251
  ```bash
252
252
  cat > "{run_dir}/result-context.json" <<'SKF_JSON'
253
- {"status": "success", "skill_name": "{skill_name}", "version": "{new_version}", "previous_version": "{baseline_version}", "update_mode": "{update_mode}", "files_written": [<each artifact write.md wrote and verified: SKILL.md, references (when this run wrote, added or removed a references/*.md file, as the files on disk show), metadata.json, provenance-map.json, evidence-report.md, context-snippet.md, active-symlink>], "error": null,
254
- "result_contract": {"skill": "skf-update-skill", "status": "success", "outputs": [{"type": "skill", "path": "<each modified file's path>"}], "summary": {"update_status": "success", "exports_affected": <total_export_changes, or the gap count in gap-driven mode>, "files_modified": <the files written>, "validation_status": "<passed, warnings or failures>"}}}
253
+ {"status": "success", "skill_name": "{skill_name}", "version": "{new_version}", "previous_version": "{baseline_version}", "update_mode": "{update_mode}", "security_result": "<pass, warn or skipped>", "files_written": [<each artifact write.md wrote and verified: SKILL.md, references (when this run wrote, added or removed a references/*.md file, as the files on disk show), metadata.json, provenance-map.json, evidence-report.md, context-snippet.md, active-symlink>], "error": null,
254
+ "result_contract": {"skill": "skf-update-skill", "status": "success", "outputs": [{"type": "skill", "path": "<each modified file's path>"}], "summary": {"update_status": "success", "exports_affected": <total_export_changes, or the gap count in gap-driven mode>, "files_modified": <the files written>, "validation_status": "<passed, warnings or failures>", "security_result": "<pass, warn or skipped>"}}}
255
255
  SKF_JSON
256
256
  ```
257
257
 
258
- Then, from `{project-root}`, run the emitter with the run folder and the new version's forge folder:
258
+ Then, from `{project-root}`, create the version's forge folder and run the emitter with the run folder and that folder. The emitter writes result files only into a folder that exists, and §1's no-change exit may find none when the skill's provenance map is still flat; a folder that cannot be created leaves a `result_file_write_failed` warning, so the hook does not run:
259
259
 
260
260
  ```bash
261
+ mkdir -p "{forge_version}" || uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning 'result_file_write_failed: cannot create the version forge folder'
261
262
  uv run {emitEnvelopeHelper} emit --workflow skf-update-skill --run-dir "{run_dir}" --result-dir "{forge_version}" < "{run_dir}/result-context.json"
262
263
  ```
263
264
 
@@ -267,12 +268,22 @@ It writes the per-run record `{forge_version}/update-skill-result-{YYYYMMDD-HHmm
267
268
  SKF_UPDATE_RESULT_JSON: {"skf_update": {"status": "success", "skill_name": ..., "version": ..., "previous_version": ..., "update_mode": ..., "files_written": [...], "headless_decisions": [...], "warnings": [...], "error": null}}
268
269
  ```
269
270
 
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
+ 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}` when the run is interactive, when no emitter resolved or when the failure line below fails too, and skip the hook as below.
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
+ A headless run whose emitter resolved then prints a failure line in place of the result line, so a pipeline learns why the run has none. From `{project-root}`, run `emit-halt` with the values staged in `{run_dir}/result-context.json` and with no `--result-dir` (with one, `emit-halt` would overwrite `update-skill-result-latest.json`):
274
+
275
+ ```bash
276
+ uv run {emitEnvelopeHelper} emit-halt --workflow skf-update-skill --run-dir "{run_dir}" <<'SKF_JSON'
277
+ {"status": "halted-for-write-failure", "phase": "report:result-contract", "path": "{forge_version}", "reason": "result-contract-not-written: <the emitter's message>", "skill_name": "{skill_name}", "version": "<the staged version>", "previous_version": "<the staged previous_version>", "update_mode": "<the staged update_mode>", "files_written": [<the staged files_written>]}
278
+ SKF_JSON
279
+ ```
280
+
281
+ Leave `files_written` out when the emitter's message names it (the line then holds `[]`), and write each value as a JSON string, as init.md's halt procedure does: escape `"` and `\`, and write every path with `/`. Bind `{result_envelope_line}` to the line it prints and do not display it here: the shared health check displays it as the run's last line. When it exits non-zero or prints no line, bind nothing. Either way the run has no result contract (`emit-halt` writes none, even when it exits 0): skip the hook with the no-result-contract line below, then go on to §6. The failure line undoes nothing: what this run wrote stays in place.
282
+
283
+ - `status`: `"success"` here; §1 stages `"no-changes"`, §1a `"detect-only"` and §1b `"dry-run"`; the failure line above prints `"halted-for-write-failure"` at `error.phase` `report:result-contract`; 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
284
  - `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
285
  - `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
- - `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.
286
+ - `error`: null on every exit this step prints except the failure line above, which carries `{phase: "report:result-contract", path, reason}`. A halt's line carries `{phase, path?, reason}`, and pipelines branch on `error !== null` for non-zero exit semantics.
276
287
  - `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
288
 
278
289
  **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: