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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/docs/_data/pinned.yaml +9 -10
- package/docs/campaign.md +2 -2
- package/docs/skill-model.md +3 -3
- package/docs/verifying-a-skill.md +17 -18
- package/docs/workflows.md +8 -8
- package/package.json +1 -1
- package/src/shared/references/description-guard-protocol.md +12 -10
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
- package/src/shared/scripts/skf-parse-gaps.py +4 -1
- package/src/shared/scripts/skf-quick-batch.py +8 -4
- package/src/shared/scripts/skf-verify-provenance-completeness.py +957 -183
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-audit-skill/SKILL.md +1 -1
- package/src/skf-audit-skill/customize.toml +4 -3
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-campaign/SKILL.md +1 -1
- package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
- package/src/skf-campaign/references/campaign-contracts.md +1 -1
- package/src/skf-campaign/references/step-06-batch.md +2 -2
- package/src/skf-campaign/references/step-07-capstone.md +1 -1
- package/src/skf-campaign/references/step-08-verify.md +1 -1
- package/src/skf-campaign/references/step-09-refine.md +1 -1
- package/src/skf-campaign/references/step-11-maintenance.md +1 -1
- package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
- package/src/skf-campaign/scripts/campaign-report.py +60 -4
- package/src/skf-campaign/scripts/campaign-state.py +67 -19
- package/src/skf-campaign/templates/campaign-report-template.md +4 -0
- package/src/skf-create-skill/SKILL.md +1 -1
- package/src/skf-create-skill/assets/skill-sections.md +2 -2
- package/src/skf-create-skill/references/report.md +2 -2
- package/src/skf-create-skill/references/validate.md +49 -29
- package/src/skf-create-stack-skill/SKILL.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/report.md +3 -3
- package/src/skf-create-stack-skill/references/validate.md +9 -4
- package/src/skf-drop-skill/SKILL.md +1 -1
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/SKILL.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-forger/SKILL.md +1 -1
- package/src/skf-quick-skill/SKILL.md +1 -1
- package/src/skf-quick-skill/references/batch-mode.md +28 -2
- package/src/skf-quick-skill/references/compile.md +2 -0
- package/src/skf-quick-skill/references/finalize.md +3 -2
- package/src/skf-quick-skill/references/halt-contract.md +3 -3
- package/src/skf-quick-skill/references/quick-extract.md +2 -1
- package/src/skf-quick-skill/references/write-and-validate.md +11 -5
- package/src/skf-refine-architecture/SKILL.md +1 -1
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +2 -2
- package/src/skf-rename-skill/SKILL.md +1 -1
- package/src/skf-rename-skill/references/report.md +1 -1
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/customize.toml +10 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +2 -2
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/references/report.md +3 -2
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
- package/src/skf-test-skill/scripts/gap-ledger.py +218 -46
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
- package/src/skf-test-skill/templates/test-report-template.md +4 -3
- package/src/skf-update-skill/SKILL.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +1 -1
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +1 -1
- package/src/skf-update-skill/references/report.md +18 -7
- package/src/skf-update-skill/references/write.md +24 -12
- package/src/skf-verify-stack/SKILL.md +1 -1
- 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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
136
|
-
|
|
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
|
|
205
|
-
`
|
|
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
|
-
|
|
780
|
-
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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"{
|
|
1073
|
-
|
|
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
|
-
|
|
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"
|
|
1128
|
-
"
|
|
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"{
|
|
1150
|
-
f"
|
|
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"
|
|
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
|
|
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
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
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 `(
|
|
29
|
-
`fetch(url): Promise<T>` and
|
|
30
|
-
`
|
|
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 `(
|
|
618
|
-
cut; None for any other text, a dotted or `::` name
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
|
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:
|