bmad-module-skill-forge 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/agents.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/campaign.md +1 -1
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +42 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +6 -6
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
- package/src/shared/scripts/skf-extract-public-api.py +472 -120
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +136 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +3 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/generate-briefs.md +1 -1
- package/src/skf-analyze-source/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/SKILL.md +4 -3
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +3 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +3 -2
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +4 -3
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +3 -2
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +8 -3
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +3 -2
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +3 -2
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +3 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +3 -2
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +4 -3
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +8 -9
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +3 -2
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +3 -2
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +3 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +11 -8
- package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +60 -97
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +5 -5
- package/src/skf-test-skill/references/scoring-rules.md +8 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +14 -10
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
- package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +3 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +54 -33
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +6 -5
- package/src/skf-update-skill/references/write.md +42 -20
- package/src/skf-verify-stack/SKILL.md +4 -3
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -89,6 +89,14 @@ compareDocHashesProbeOrder:
|
|
|
89
89
|
validateOutputProbeOrder:
|
|
90
90
|
- '{project-root}/_bmad/skf/shared/scripts/skf-validate-output.py'
|
|
91
91
|
- '{project-root}/src/shared/scripts/skf-validate-output.py'
|
|
92
|
+
# Resolve `{shardBodyHelper}` to the first existing path. §7 splits an
|
|
93
|
+
# oversized body with it when skill-check reports `body.max_lines` or
|
|
94
|
+
# `body.max_tokens`. If neither exists, §7 falls back to `skill-check
|
|
95
|
+
# split-body` for `body.max_lines` when SKILL.md holds no [MANUAL] block,
|
|
96
|
+
# and otherwise splits nothing and records a WARN.
|
|
97
|
+
shardBodyProbeOrder:
|
|
98
|
+
- '{project-root}/_bmad/skf/shared/scripts/skf-shard-body.py'
|
|
99
|
+
- '{project-root}/src/shared/scripts/skf-shard-body.py'
|
|
92
100
|
# `{extractPublicApiHelper}`: §2's public API counts, the runner's or those
|
|
93
101
|
# `{entryPointsByHandData}` has it make; if neither exists, §2 halts.
|
|
94
102
|
extractPublicApiProbeOrder:
|
|
@@ -135,7 +143,7 @@ A `Write` or `Edit` call in §2 to §5 that errors (permission denied, disk full
|
|
|
135
143
|
|
|
136
144
|
### 0. Description Guard Protocol
|
|
137
145
|
|
|
138
|
-
**Used by:** §7 (`skill-check check --fix` and `skill-check split-body --write`).
|
|
146
|
+
**Used by:** §7 (`skill-check check --fix`, the `{shardBodyHelper}` body split and `skill-check split-body --write`).
|
|
139
147
|
|
|
140
148
|
The deterministic phases are executed via `{descriptionGuardHelper}`: §7 invokes the helper at the capture and verify-restore points around every `skill-check` call.
|
|
141
149
|
|
|
@@ -184,7 +192,7 @@ uv run {runLockHelper} acquire \
|
|
|
184
192
|
`{lock_recovery}` is, outside gap-driven mode, "The previous version is unchanged, and the halt removed `{skill_group}/{new_version}/` and `{forge_data_folder}/{skill_name}/{new_version}/`, which step 4 created for this update: re-run update-skill once no other update of {skill_name} runs." and, in gap-driven mode, "This repair already wrote SKILL.md in place; the halt restored the package and the skill brief from the snapshot step 4 took, so the skill is as it was: re-run update-skill once no other update of {skill_name} runs."
|
|
185
193
|
|
|
186
194
|
The `metadata` call below writes `{skill_package}/metadata.json`, never by hand:
|
|
187
|
-
- **First, apply any queued `metadata_patches[]`** (staged by merge Priority 8b from gap-driven `metadata update` entries) to `metadata.json`, before §3's `apply` reads it: each surgical patch described in the gap's remediation
|
|
195
|
+
- **First, apply any queued `metadata_patches[]`** (staged by merge Priority 8b from gap-driven `metadata update` entries) to `metadata.json`, before §3's `apply` reads it: each surgical patch described in the gap's remediation. The `metadata` call keeps a patch in any field but those its `--help` lists (e.g., `exports_documented`), whose value it writes; apply none to `exports[]`, which §3's `apply` builds from the list it reads. Log each patch the call overwrites, drops (its `dropped_stats`) or you leave out, so a still-stale value surfaces in the report rather than being silently overwritten.
|
|
188
196
|
- **For gap-driven rescopes** (`DELETED_EXPORT` / verification `rescoped`): §3's `apply` drops the removed exports from `exports`, and `stats` recompute from that reduced surface, which the `brief.scope.exclude` + `scope.amendments[]` (`action: "excluded"`) step 4 §6b wrote from gap-driven.md §1's rule R1 justify.
|
|
189
197
|
- `version` is `{new_version}`, the version step 4 §6b chose: **if `update_mode == "gap-driven"`, that is the unchanged version**: the skill is being repaired against the same source commit, so of the fields that mark an update only `generation_date` below changes, and step 4 wrote into the existing version directory (step 1 §6c records no source version in gap-driven mode). Otherwise it is the version whose folder step 4 §6b created: `{source_version_detected}` when step 1 §6c recorded `source_version_detected`, else the next patch version. Never pick another value here: `version` must name the folder `metadata.json` sits in, the one §8 points the `active` link at. From here on `{version}` is `{new_version}`.
|
|
190
198
|
- Bind `{generation_date}` ← the current UTC time to the second, read from the clock with `date -u +%Y-%m-%dT%H:%M:%SZ`, never typed: step 1 §4b offers a test report only when it is newer. It is the only update bookkeeping metadata.json carries, in every mode: §3's update operation block records `last_update` and `update_type` in provenance-map.json, and the `metadata` call removes either key an older SKF version left in metadata.json.
|
|
@@ -215,9 +223,9 @@ The `metadata` call below writes `{skill_package}/metadata.json`, never by hand:
|
|
|
215
223
|
| uv run {renderMetadataStatsHelper} {forge_version}/provenance-map.json --shape reference-app -o "{run_dir}/stats.json"
|
|
216
224
|
```
|
|
217
225
|
|
|
218
|
-
If its `coherence.ok` (in `{run_dir}/stats.json`, which the `metadata` call reads) is false, fix the provenance map and re-run the helper; never hand-edit the stats. A `confidence_distribution` violation means some provenance entries carry a missing or unrecognized `signature_source
|
|
226
|
+
If its `coherence.ok` (in `{run_dir}/stats.json`, which the `metadata` call reads) is false, fix the provenance map and re-run the helper; never hand-edit the stats. A `confidence_distribution` violation means some provenance entries carry a missing or unrecognized `signature_source`, which only an edit made after §3's `apply` leaves: `apply` gives every entry one, so set each the way it does (its `confidence` when that is `T1-low`, `T2` or `T3`, else `T1` when its `extraction_method` is `ast-grep` or `ast_bridge`, else `T1-low`).
|
|
219
227
|
|
|
220
|
-
**Label violations.** A violation whose `field` is `provenance.entries[<i>].<field>` means entry `<i>` (its `export_name` is in the violation) carries a label its `extraction_method` does not allow: relabel it by the `## Relabel Rule` section of `{extractionPatternsData}`, with `{source_root}` as the tree its node-kind lookup reads. **Under the drift override** (`{workspace_drift_status}` is `overridden`, gap-driven.md §3) run no recipe at `{source_root}`, so no `kind-at`: HEAD is not the pinned commit, so a kind read there says nothing about the entry. Leave each violation that needs a kind from the tree in place and list it as a WARN ending ` (drift override: HEAD {head_short_sha} is not pinned {pinned_short_sha})`; a relabel that reads nothing from the tree still applies. With the status `ok` or `skipped`, run the lookup as the rule says. Rewrite `{forge_version}/provenance-map.json` with the relabeled entries and re-run the helper with the same payload, `--shape` and `-o`, whose file the `metadata` call reads: a relabeled `signature_source` moves the distribution, and a violation left as a WARN does not block the write. This relabel applies to every entry the helper flags, including a gap-driven `verified` entry (§3). Record the relabeled export names,
|
|
228
|
+
**Label violations.** A violation whose `field` is `provenance.entries[<i>].<field>` means entry `<i>` (its `export_name` is in the violation) carries a label its `extraction_method` does not allow: relabel it by the `## Relabel Rule` section of `{extractionPatternsData}`, with `{source_root}` as the tree its node-kind lookup reads. **Under the drift override** (`{workspace_drift_status}` is `overridden`, gap-driven.md §3) run no recipe at `{source_root}`, so no `kind-at`: HEAD is not the pinned commit, so a kind read there says nothing about the entry. Leave each violation that needs a kind from the tree in place and list it as a WARN ending ` (drift override: HEAD {head_short_sha} is not pinned {pinned_short_sha})`; a relabel that reads nothing from the tree still applies. With the status `ok` or `skipped`, run the lookup as the rule says. Rewrite `{forge_version}/provenance-map.json` with the relabeled entries and re-run the helper with the same payload, `--shape` and `-o`, whose file the `metadata` call reads: a relabeled `signature_source` moves the distribution, and a violation left as a WARN does not block the write. This relabel applies to every entry the helper flags, including a gap-driven `verified` entry (§3). Record the relabeled export names, each WARN left, and each name §3's `apply` listed in `relabeled`, as `{provenance_relabels}` in workflow context: the update report lists them under its Confidence Tier Breakdown (report.md), so no entry moves from T1 to T1-low unannounced.
|
|
221
229
|
- **Write metadata.json** once the stats helper's last run wrote `{run_dir}/stats.json`. From `{project-root}`, run:
|
|
222
230
|
|
|
223
231
|
```bash
|
|
@@ -227,9 +235,13 @@ The `metadata` call below writes `{skill_package}/metadata.json`, never by hand:
|
|
|
227
235
|
--stats "{run_dir}/stats.json" \
|
|
228
236
|
--version "{new_version}" \
|
|
229
237
|
--generation-date "{generation_date}" \
|
|
238
|
+
--skf-root "{project-root}/_bmad/skf" \
|
|
239
|
+
[--extraction "{run_dir}/extraction.json"] [--qmd-version "{qmd_version}"] \
|
|
230
240
|
[--source-commit "{target_commit}"] [--source-ref "{target_ref}"]
|
|
231
241
|
```
|
|
232
242
|
|
|
243
|
+
It sets `tool_versions` to the toolchain this run used, as create-skill stamps it: `skf` read from the installed SKF under `--skf-root`, `ast_grep` from the `ast_grep.version` of `--extraction`, `qmd` from `--qmd-version`; a tool this run did not use keeps the version metadata.json records. Pass `--extraction` when step 2 wrote `{run_dir}/extraction.json`, and `--qmd-version` when re-extract.md §3 queried QMD and bound `{qmd_version}`. When the `effective_denominator` this run stores differs from the one metadata.json records, or only one of them has one, it drops each `effective_denominator_*` stats key, which explained the old count; `dropped_stats` lists each key it drops, a stats helper field this run's stats left out included. On exit 0, add each of its `warnings` to `warnings[]`, recorded as **Warnings go to the run log** says, so the report lists each dropped key, each `unknown` tool version and each stats key create-skill does not write that an earlier run left. Leave out only a warning that a stats key was `kept from an earlier run` when this run's `metadata_patches[]` wrote that key; a dropped key's warning always goes to `warnings[]`.
|
|
244
|
+
|
|
233
245
|
On exit 1, fix the input its stderr names and run it again; when it fails again, prints no JSON or no candidate resolves, HALT with status `halted-for-write-failure` (halt procedure: `phase: "write:metadata"`, `path: "{skill_package}/metadata.json"`, `reason: "<its stderr>"`).
|
|
234
246
|
|
|
235
247
|
**A docs-only skill's document hashes.** When step 2 wrote `{run_dir}/doc-hashes.json` (its docs-only branch), record the new hash of each document it found changed, after the writes above, so the next update compares against the documents this one read. Resolve `{compareDocHashesHelper}` ← first existing path in `{compareDocHashesProbeOrder}` and, from `{project-root}`, run:
|
|
@@ -270,15 +282,16 @@ uv run {buildChangeManifestHelper} apply \
|
|
|
270
282
|
--not-public-out "{run_dir}/not-public.json" \
|
|
271
283
|
--metadata-in "{skill_package}/metadata.json" \
|
|
272
284
|
--lists-out "{run_dir}/metadata-lists.json" \
|
|
273
|
-
-o "{forge_version}/provenance-map.json"
|
|
285
|
+
-o "{forge_version}/provenance-map.json" \
|
|
286
|
+
> "{run_dir}/apply.json"
|
|
274
287
|
```
|
|
275
288
|
|
|
276
|
-
`{update_type}` follows the run's mode as report.md §2 names it (`{update_mode}`): `incremental` for `normal`, `gap-driven` for `gap-driven`, `full` for `degraded` (a degraded run has no map: leave out `--provenance-map`, and `apply` starts a new one). Pass each `{run_dir}` file the run wrote: the change manifest and the records in every mode, `category-c.json` and `ccc-pairs.json` (step 2's Category C and its CCC check), `extraction.json` and `export-details.json` when step 2 wrote them, `merge-records.json` when step 4 did, the Category D files (`category-d-compare.json`, and `new-files.json` when step 2 wrote it: a skill with no brief has none) with `--source-root` when step 2 ran Category D, and `promoted-docs.json` when step 2 §1b wrote it. `{generation_date}` is the value §2 bound, `--test-report-run-id` goes only with a gap-driven run (the `{test_report_run_id}` of the report it applied), `{manual_sections_preserved}` is `len(preserved) + len(moved)` from the §1 `manual-verify` verdict (never a marker count), and `{map_source_commit}` and `{map_source_ref}` are the `source_commit` and `source_ref` §2 writes to `metadata.json` (an empty string for a null one): audit-skill reads its baseline commit from the map's top-level `source_commit` and `source_ref`. Pass `--drift-head` and `--drift-pinned` only when `{workspace_drift_status}` is `overridden` (gap-driven.md §3). Pass `--metadata-in` and `--lists-out` in every mode: `apply` writes there the lists §2's `metadata` call reads. Every entry `apply` writes carries `signature_source`, which §2's stats helper bins, and `apply` sets the map's update operation block (`"last_update"`, `"update_type"` and its other keys), replacing an earlier update's.
|
|
289
|
+
`{update_type}` follows the run's mode as report.md §2 names it (`{update_mode}`): `incremental` for `normal`, `gap-driven` for `gap-driven`, `full` for `degraded` (a degraded run has no map: leave out `--provenance-map`, and `apply` starts a new one). Pass each `{run_dir}` file the run wrote: the change manifest and the records in every mode, `category-c.json` and `ccc-pairs.json` (step 2's Category C and its CCC check), `extraction.json` and `export-details.json` when step 2 wrote them, `merge-records.json` when step 4 did, the Category D files (`category-d-compare.json`, and `new-files.json` when step 2 wrote it: a skill with no brief has none) with `--source-root` when step 2 ran Category D, and `promoted-docs.json` when step 2 §1b wrote it. `{generation_date}` is the value §2 bound, `--test-report-run-id` goes only with a gap-driven run (the `{test_report_run_id}` of the report it applied), `{manual_sections_preserved}` is `len(preserved) + len(moved)` from the §1 `manual-verify` verdict (never a marker count), and `{map_source_commit}` and `{map_source_ref}` are the `source_commit` and `source_ref` §2 writes to `metadata.json` (an empty string for a null one): audit-skill reads its baseline commit from the map's top-level `source_commit` and `source_ref`. Pass `--drift-head` and `--drift-pinned` only when `{workspace_drift_status}` is `overridden` (gap-driven.md §3). Pass `--metadata-in` and `--lists-out` in every mode: `apply` writes there the lists §2's `metadata` call reads. Every entry `apply` writes carries `signature_source`, which §2's stats helper bins (an entry the map held with none, or with a value other than `T1`, `T1-low`, `T2` or `T3`, as a map written before the key existed does, takes its `confidence` when that is `T1-low`, `T2` or `T3`, else `T1` when its `extraction_method` is `ast-grep` or `ast_bridge`, else `T1-low`), and `apply` sets the map's update operation block (`"last_update"`, `"update_type"` and its other keys), replacing an earlier update's.
|
|
277
290
|
|
|
278
291
|
Dispatch on its exit code:
|
|
279
292
|
|
|
280
|
-
- **0:** it printed `{status: "written", entries, not_public, file_entries, warnings, map}`. Bind `{provenance_spot_check_warnings}` ← its `warnings`, and add each to `warnings[]`; the update report lists each as a WARN (report.md §2). `not_public` lists, `{name, file}`, each new export of a `public-api` skill it did not add in normal mode as off the public surface (the `public: false` mark of `reextract-records.json`, else the same rule over the `scope.type` and `entry_point_diff` of `extraction.json`), and one of its `warnings` counts them; it also wrote that list to `{run_dir}/not-public.json` (`[]` when it left none out). When `extraction.json` names `public-api` but gives no surface the rule can read (Quick tier, a runner failure, an incomplete run), it adds every name, with one warning that says so.
|
|
281
|
-
- **3** (`status` `refused`): a blocking gap reached the provenance write with no source line (`blocking_unresolved[]`: a `NEW_EXPORT` the map does not hold, `unknown`, with a severity other than `Medium`, `Low` or `Info`, a missing one included). gap-driven.md §4 sends every such gap to its §4a and halts with `halted-for-remediation-path` before merge when §4a does not find it, so this happens only when that check was skipped or bypassed: `apply` wrote nothing, and no null citation is written. HALT with status `blocked` and write no `metadata.json`, `provenance-map.json` or other artifact. Tell the user: "**A blocking gap reached the provenance write with no source line:** {each export name, with its severity or "no severity"}. gap-driven.md §4 should have stopped on it with `halted-for-remediation-path`. The halt restored SKILL.md and references/ from the snapshot step 4 took; give each gap a `file:line` Source or name the file that defines its export in its Remediation (or downgrade it to Medium, Low or Info), then re-run update-skill." The halt procedure takes `phase: "write:provenance-map"`, `path: "{forge_version}/provenance-map.json"`, `reason: "blocking-gap-unresolved: {export names}: gap-driven.md §4 should have halted with halted-for-remediation-path"`.
|
|
293
|
+
- **0:** it printed `{status: "written", entries, not_public, relabeled, signature_source_backfilled, file_entries, warnings, map}` to `{run_dir}/apply.json`. Bind `{provenance_spot_check_warnings}` ← its `warnings`, and add each to `warnings[]`, recording them in one call, from `{project-root}`: `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warnings-file "{run_dir}/apply.json"`; the update report lists each as a WARN (report.md §2). In gap-driven mode a `NEW_EXPORT` or `MODIFIED_EXPORT` the map holds whose spot-check ended `missing` or `unknown` keeps its entry unchanged, whatever its severity, with one of these warnings, which names the cause of an `unknown` (several map entries of its name, no definition line or several at the entry, a file no rule covers, a failed or skipped check, a citation of another file than the map's entries of its name); for a blocking gap (any severity but `Medium`, `Low` or `Info`) it also names the gap and the one fix a re-run acts on (gap-driven.md §4 bullet 1): fix the entry by hand, or, for several entries of its name, give the gap a `file:line` Source at the definition when that file holds only one of them, then re-run update-skill. A `cited-elsewhere` gap, whatever its severity, takes its own warning and fix in their place (gap-driven.md §4 bullet 1). A rule R5 `MOVED_EXPORT` whose map entry's file is gone keeps its entry unchanged too, whatever its severity, with a warning that names the file and the hand fix. An `outside-scope` gap (the skill brief's scope leaves out the file it names), whatever its severity, adds no entry, a placeholder included, and is never refused, with its own warning, which names the files left out and the fix: when the skill covers that file, change the brief's scope so it takes it (its `scope.include` or `scope.exclude`), then re-run update-skill; otherwise re-run Test Skill (gap-driven.md §4 bullet 1). `not_public` lists, `{name, file}`, each new export of a `public-api` skill it did not add in normal mode as off the public surface (the `public: false` mark of `reextract-records.json`, else the same rule over the `scope.type` and `entry_point_diff` of `extraction.json`), and one of its `warnings` counts them; it also wrote that list to `{run_dir}/not-public.json` (`[]` when it left none out). When `extraction.json` names `public-api` but gives no surface the rule can read (Quick tier, a runner failure, an incomplete run), it adds every name, with one warning that says so. `relabeled` lists, `{name, file, from, to}`, each entry whose `signature_source` a gap-driven repair changed when it wrote the signature §4a's record gave (a record read by eye moves an `ast-grep` entry from T1 to T1-low), each with one of its `warnings`: §2 adds each name to `{provenance_relabels}`. `signature_source_backfilled` counts, by value, the entries it gave a `signature_source` because they lacked or held an unrecognized one (`{"T1-low": 2}`, `{}` when none), with one of its `warnings`, which also lists each unrecognized value it replaced (`name: old -> new`); it does not list them in `entries.updated`. In a map naming several libraries, one of its `warnings` counts and names each new entry it gave the map's first `source_library` because the entry's file or folder did not settle which (`<name> in <file>`, or `<name> (no file)`): check that entry's `source_library` in `{forge_version}/provenance-map.json` and correct it by hand when it names the wrong library.
|
|
294
|
+
- **3** (`status` `refused`): a blocking gap reached the provenance write with no source line (`blocking_unresolved[]`: a `NEW_EXPORT` the map does not hold, `unknown` for any cause but `outside-scope`, with a severity other than `Medium`, `Low` or `Info`, a missing one included). gap-driven.md §4 sends every such gap to its §4a and halts with `halted-for-remediation-path` before merge when §4a does not find it, so this happens only when that check was skipped or bypassed: `apply` wrote nothing, and no null citation is written. HALT with status `blocked` and write no `metadata.json`, `provenance-map.json` or other artifact. Tell the user: "**A blocking gap reached the provenance write with no source line:** {each export name, with its severity or "no severity"}. gap-driven.md §4 should have stopped on it with `halted-for-remediation-path`. The halt restored SKILL.md and references/ from the snapshot step 4 took; give each gap a `file:line` Source or name the file that defines its export in its Remediation (or downgrade it to Medium, Low or Info), then re-run update-skill." The halt procedure takes `phase: "write:provenance-map"`, `path: "{forge_version}/provenance-map.json"`, `reason: "blocking-gap-unresolved: {export names}: gap-driven.md §4 should have halted with halted-for-remediation-path"`.
|
|
282
295
|
- **Exit 1, no JSON, or no candidate resolves:** HALT with status `halted-for-write-failure` (halt procedure: `phase: "write:provenance-map"`, `path: "{forge_version}/provenance-map.json"`, `reason: "<its stderr, or skf-build-change-manifest.py is missing; re-install SKF>"`).
|
|
283
296
|
|
|
284
297
|
**For script/asset file changes:** `apply` updates the rows; copy or remove the files themselves in `{skill_package}`, each path the change manifest's `category_d` lists: a `scripts_*` path under `scripts/`, an `assets_*` path under `assets/`:
|
|
@@ -320,6 +333,7 @@ Append update operation section to `{forge_version}/evidence-report.md` (create
|
|
|
320
333
|
|
|
321
334
|
### Validation Summary
|
|
322
335
|
- Spec compliance: {PASS/WARN/FAIL/SKIP} (quality score {score}/100)
|
|
336
|
+
- Body split: {each section §7 moved and the references/ file it added, or none}
|
|
323
337
|
- [MANUAL] integrity: {PASS/FAIL}
|
|
324
338
|
- Confidence tiers: {PASS/WARN}
|
|
325
339
|
- Provenance: {PASS/WARN}
|
|
@@ -340,7 +354,7 @@ Append update operation section to `{forge_version}/evidence-report.md` (create
|
|
|
340
354
|
|
|
341
355
|
**Scope and Targeted Re-Extraction population:** read `{run_dir}/evidence-records.jsonl`, where step 2 (§1b, §1c and §2.2) and gap-driven.md §4 appended their records, one JSON line each. Write each record under its key as it was recorded, and `none` for a key no line holds (or when the file does not exist), so a headless decision such as a deletion-ratio continue stays visible after the run.
|
|
342
356
|
|
|
343
|
-
**Validation Summary population:** §4 writes `[MANUAL] integrity` from §1's `manual-verify` verdict (`PASS` when `ok`) and `Confidence tiers` from §2 (`PASS` when the stats helper reported no label violation, else `WARN` naming `{provenance_relabels}`), with placeholders for the rest. §6 fills `Provenance`, and §7 fills `Spec compliance`, `Diff` and `Security`, each in the on-disk report, as §5 fills the Context Snippet sub-block.
|
|
357
|
+
**Validation Summary population:** §4 writes `[MANUAL] integrity` from §1's `manual-verify` verdict (`PASS` when `ok`) and `Confidence tiers` from §2 (`PASS` when the stats helper reported no label violation, else `WARN` naming `{provenance_relabels}`), with placeholders for the rest. §6 fills `Provenance`, and §7 fills `Spec compliance`, `Diff` and `Security`, and `Body split` (`none` when no split ran), each in the on-disk report, as §5 fills the Context Snippet sub-block.
|
|
344
358
|
|
|
345
359
|
**Description Guard population** (used by §7 Post-Write Validation when the §0 protocol fires): fill all four fields from context when `description_guard_restored == true` (triggering tool, whether restore succeeded, and what changed, based on the recorded `description_guard_diff_kind`). When `Restored: false`, the other three fields are `—` — this is the clean-run expected state — except when `description_guard_refused == "empty-capture"` (§0's empty-snapshot rule): then set `Triggering tool` to the recorded tool name, `Original description preserved: false`, and `Notes: guard refused — empty captured snapshot (empty-capture)`, so a refused restore is distinguishable from a run where the guard never fired. Same field semantics and populator logic as create-skill step 6 §8.
|
|
346
360
|
|
|
@@ -350,23 +364,24 @@ Append update operation section to `{forge_version}/evidence-report.md` (create
|
|
|
350
364
|
|
|
351
365
|
**Regenerate `context-snippet.md` if stale:**
|
|
352
366
|
|
|
353
|
-
`context-snippet.md` is a `{skill_package}` deliverable that goes stale whenever **headline exports**, **version**, or **
|
|
367
|
+
`context-snippet.md` is a `{skill_package}` deliverable that goes stale whenever **headline exports**, **version**, **gotchas** or **section anchors** change in this run. Regenerate it only when at least one of these triggers fired; otherwise skip: a skip is the correct outcome for gap-driven repairs and other runs that touch internals below the snippet's surface, where regenerating would produce byte-identical content.
|
|
354
368
|
|
|
355
369
|
**Staleness triggers:**
|
|
356
370
|
|
|
357
|
-
- **Headline exports changed
|
|
371
|
+
- **Headline exports changed**: the api names `skf-export-skill/references/generate-snippet.md` §3b ranks for this run differ from the prior snippet's `|api:` line (a listed name left `exports` or is now deprecated, a `NEW_EXPORT` took a free slot, or a `MODIFIED_EXPORT` changed the kind of a listed name). In gap-driven mode it fires only when a name of the prior line left `exports` or SKILL.md now marks it deprecated or removed: a line that differs by the ranking alone (its order, a free slot or the tier's limit) waits for the next export.
|
|
358
372
|
- **Version changed** — §2 bumped `version` (normal mode with detected source drift; never fires in gap-driven mode per §2's carve-out).
|
|
359
|
-
- **Gotchas changed
|
|
373
|
+
- **Gotchas changed**: the gotchas line `skf-export-skill/references/generate-snippet.md` §3a resolves from the merged SKILL.md and this run's evidence report (at most 3 pitfalls, in its order) differs from the prior snippet's `|gotchas:` line, `[CARRIED]` markers aside.
|
|
374
|
+
- **Anchors changed**: the prior snippet's `|quick-start:` or `|key-types:` anchor differs from the one `generate-snippet.md` §3b item 4 gives for the merged SKILL.md (an anchor points into `references/` although SKILL.md has the heading, or a heading moved). Record it as `anchors` in `Triggers fired`.
|
|
360
375
|
|
|
361
376
|
**Record the decision on the on-disk evidence report:** open `{forge_version}/evidence-report.md` (written by §4 with placeholder values in the `### Context Snippet` sub-block) and update that sub-block under the Update Operation section just written. Set `Regenerated: true|false`, fill `Triggers fired:` with the list of triggers that fired (or `—` when none), and write a one-sentence `Notes:` entry. See §4's "Context Snippet population" note for field semantics.
|
|
362
377
|
|
|
363
378
|
**If no trigger fired:** skip regeneration: do not touch `context-snippet.md` on disk. The snippet remains valid against the prior run's surface. Continue to §6.
|
|
364
379
|
|
|
365
|
-
**If at least one trigger fired:** regenerate the snippet
|
|
380
|
+
**If at least one trigger fired:** regenerate the snippet. Its content comes from `skf-export-skill/references/generate-snippet.md` §3a and §3b, as the paragraph below says; `skf-create-skill/assets/skill-sections.md` gives only its line format (pipe-delimited indexed format).
|
|
366
381
|
|
|
367
382
|
Use the **flat draft form** for the `root:` path in the draft snippet: `root: skills/{skill-name}/`. The per-IDE skill root (e.g., `.claude/skills/`, `.windsurf/skills/`, `.github/skills/` — see `skf-export-skill/assets/managed-section-format.md`) is applied later by `export-skill` step 3 when the skill is exported. Do not choose an IDE-specific prefix in update-skill — that is an export-time decision that depends on config.yaml.
|
|
368
383
|
|
|
369
|
-
|
|
384
|
+
Take the version from the updated metadata.json and the inline summaries from the merged SKILL.md. Rank the api names and verify the section anchors as `skf-export-skill/references/generate-snippet.md` §3b states, and derive the gotchas as its §3a states: the merged SKILL.md, this run's evidence report (`{forge_version}/evidence-report.md`) and the provenance map §3 wrote (`{forge_version}/provenance-map.json`, its `{forge_provenance_map}`) are the current sources, and the prior `context-snippet.md` gives `prior_api` and `prior_gotchas` (its §2.5). A prior pitfall no current source states takes a free slot with the `[CARRIED]` marker, or is dropped when already carried, by that file's carry-forward protocol (one-cycle limit).
|
|
370
385
|
|
|
371
386
|
Write the regenerated snippet to `{skill_package}/context-snippet.md`, preserving file permissions.
|
|
372
387
|
|
|
@@ -389,7 +404,7 @@ The helper reads its `--source-root` (falling back to the provenance map's own `
|
|
|
389
404
|
- `missing[]` — documented exports (metadata `exports[]`) with no provenance entry: a coverage gap.
|
|
390
405
|
- `orphaned[]` — provenance `entries[].export_name` whose export was removed but the entry remains.
|
|
391
406
|
- `summary.set_diff`: `not-applicable` for a reference app (`scope_type: reference-app` in `{skill_package}/metadata.json`), whose `exports[]` is empty by design while its entries follow each citation: the helper then diffs no sets, `missing[]` and `orphaned[]` stay empty, and its `status` comes from `stale[]`, `citations[]` and `node_kinds[]` alone. `checked` for every other skill.
|
|
392
|
-
- `stale[]`: entries whose `source_file:source_line` no longer resolves or is not the line that defines the export; each carries a `reason` of `file-missing`, `line-out-of-bounds`, `line-invalid` or `line-not-definition`. A `line-not-definition` item lists in `definition_lines` every line of the file that defines the export (the `def` or declaration line itself, not a decorator or blank line above it). An empty list means the rules found no definition line, which may be a shape they do not cover: the entry is unverified, not gone. Internal names are canonicalized through `reexport_map` before the diff, so a barrel-renamed export does not read as missing or orphaned.
|
|
407
|
+
- `stale[]`: entries whose `source_file:source_line` no longer resolves or is not the line that defines the export; each carries a `reason` of `file-missing`, `line-out-of-bounds`, `line-invalid` or `line-not-definition`. A `line-not-definition` item lists in `definition_lines` every line of the file that defines the export (the `def` or declaration line itself, not a decorator or blank line above it), except that a Python `@overload` group gives its first stub alone. An empty list means the rules found no definition line, which may be a shape they do not cover: the entry is unverified, not gone. A bookkeeping variant (`X_def`, `X_exact`, `a11y_X`) its file never names is checked against its base `X`, and its item names that base in `folded_to`. A bookkeeping variant (`X_def`, `X_exact`, `a11y_X`) its file never names is checked against its base `X`, and its item names that base in `folded_to`. Internal names are canonicalized through `reexport_map` before the diff, so a barrel-renamed export does not read as missing or orphaned.
|
|
393
408
|
- `citations[]`: `[AST:]` / `[SRC:]` citations in SKILL.md and `references/` whose prefix disagrees with the entry they cite (`prefix-mismatch`: `ast-grep` gives `[AST:]`, `source-read` gives `[SRC:]`), and `[AST:]` citations in a skill whose map has no `ast-grep` entry (`ast-without-ast-grep`). Each gives the markdown `file` and `line`, the `citation` text and its `expected_prefix`.
|
|
394
409
|
- `summary.stale_check` — `"checked"` when citations were resolved against the source tree, or `"skipped-no-source-root"` when no source root resolved on disk (the completeness + orphan diffs still ran; `stale` is empty by construction, not clean-by-verification).
|
|
395
410
|
- `summary.skill_citations_scanned` and `summary.skill_citations_matched`: when citations were scanned and none matched, no citation names a path and line the map records (the citations use another root), so the prefix check compared nothing. List that as a WARN.
|
|
@@ -414,7 +429,7 @@ When the helper exits 2 it writes no JSON: record `Provenance: WARN (not run: ve
|
|
|
414
429
|
- **`manual_verify.ok` is false** (a [MANUAL] block changed): HALT with status `halted-for-manual-mismatch`. The halt procedure takes `phase: "write:verify-manual-integrity"`, `path: "{skill_package}/SKILL.md"`, `reason: "[MANUAL] blocks changed after the provenance fixes: ..."`, as §1's does. Alert the user: "**[MANUAL] section integrity failure after the provenance fixes.** Blocks modified (interior changed): {modified}. Blocks missing (markers lost): {missing}. Verified against the inventory `{manual_inventory}`. {manual_recovery}", with `{manual_recovery}` as §1 gives it: §8 has not pointed the `active` link at a new version yet, so this halt rolls back as §1's does.
|
|
415
430
|
- **Exit 2** (no JSON; its stderr line names any file it already wrote): record `Provenance: WARN (not fixed: verifier error)` with that line, re-read each file it names into context, and go on to step 2.
|
|
416
431
|
- **Otherwise:** re-read each file in `files_written[]` into the in-context copies (the merged SKILL.md and `references/` content and §3's provenance map), so later sections and the report read the fixed text, and add `SKILL.md` and `provenance-map.json` to `files_written[]` (report.md §5b) when `fix` wrote them.
|
|
417
|
-
2. **Node kinds.** For each `node_kinds[]` item, take the entry at its `entry_index` in `{forge_version}/provenance-map.json` as step 1 left it (its `export_name` and `source_file` confirm it). When this run's extraction record (a `files` record of `{run_dir}/reextract-records.json`, which re-extract.md §4 or gap-driven.md §4 wrote) names the recipe that matched that export (`ast_recipe`), set the entry's `ast_node_type` to the `kind` that recipe declares in `{extractionPatternsData}` (the ast-grep Patterns table in create-skill's `extraction-patterns-by-hand.md`, which that section of `{extractionPatternsData}` points to, gives the kind of a `find_code` pattern). When no record names one (a `verified` or `moved` entry has none), look the kind up at the entry's `source_file` and `source_line`: from `{project-root}`, run `uv run {verifyProvenanceCompletenessHelper} kind-at --source-root "{source_root}" --file "{source_file}" --line {source_line} --name "{export_name}" --recipes "{extractionPatternsData}"` and set the `kind` it prints when `status` is `found`. List a WARN, with the status, for any other status or an exit 2, when there is no local source tree, or when `{workspace_drift_status}` is `overridden` (gap-driven.md §3: the tree read is not the recorded commit, so run no `kind-at`). Never invent a kind, and never change `extraction_method` to clear the finding. Write the map with `python3 {atomicWriteHelper} write --target {forge_version}/provenance-map.json` (resolve it ← first existing path in `{atomicWriteProbeOrder}`, the new map on stdin); if neither path resolves, fix no node kind and list each one as a WARN. Apply the same change to §3's in-context map, and add `provenance-map.json` to `files_written[]`.
|
|
432
|
+
2. **Node kinds.** For each `node_kinds[]` item, take the entry at its `entry_index` in `{forge_version}/provenance-map.json` as step 1 left it (its `export_name` and `source_file` confirm it). When this run's extraction record (a `files` record of `{run_dir}/reextract-records.json`, which re-extract.md §4 or gap-driven.md §4 wrote) names the recipe that matched that export (`ast_recipe`), set the entry's `ast_node_type` to the `kind` that recipe declares in `{extractionPatternsData}` (the ast-grep Patterns table in create-skill's `extraction-patterns-by-hand.md`, which that section of `{extractionPatternsData}` points to, gives the kind of a `find_code` pattern). When no record names one (a `verified` or `moved` entry has none unless gap-driven.md §4 copied the record §4a found at its line), look the kind up at the entry's `source_file` and `source_line`: from `{project-root}`, run `uv run {verifyProvenanceCompletenessHelper} kind-at --source-root "{source_root}" --file "{source_file}" --line {source_line} --name "{export_name}" --recipes "{extractionPatternsData}"` and set the `kind` it prints when `status` is `found`. List a WARN, with the status, for any other status or an exit 2, when there is no local source tree, or when `{workspace_drift_status}` is `overridden` (gap-driven.md §3: the tree read is not the recorded commit, so run no `kind-at`). Never invent a kind, and never change `extraction_method` to clear the finding. Write the map with `python3 {atomicWriteHelper} write --target {forge_version}/provenance-map.json` (resolve it ← first existing path in `{atomicWriteProbeOrder}`, the new map on stdin); if neither path resolves, fix no node kind and list each one as a WARN. Apply the same change to §3's in-context map, and add `provenance-map.json` to `files_written[]`.
|
|
418
433
|
|
|
419
434
|
These fixes apply to any entry, a gap-driven `verified` or `moved` entry included (§3). Fix nothing else: each `left_as_warn[]` item stays for a person to decide, and so do `missing` / `orphaned` exports. A `line-not-definition` item with an empty `definition_lines` is never fixed: list its export as unverified.
|
|
420
435
|
|
|
@@ -426,16 +441,16 @@ Map the re-run's `status` to the `Provenance:` line of the §4 evidence report's
|
|
|
426
441
|
|
|
427
442
|
### 7. Run Post-Write Validation
|
|
428
443
|
|
|
429
|
-
skill-check runs once per update, here, against the written package. Check that it is available, never assume it: run `npx skill-check -h`.
|
|
444
|
+
skill-check runs once per update, here, against the written package, and once more after a body split (below) to read the score the split left. Check that it is available, never assume it: run `npx skill-check -h`.
|
|
430
445
|
|
|
431
|
-
**Description Guard Protocol:** every invocation below that may modify SKILL.md (`skill-check check --fix` and any `split-body` write) must run inside the four-phase guard defined in §0. Invoke `{descriptionGuardHelper}` at the capture and verify-restore points around each call:
|
|
446
|
+
**Description Guard Protocol:** every invocation below that may modify SKILL.md (`skill-check check --fix`, the `{shardBodyHelper}` body split and any `split-body` write) must run inside the four-phase guard defined in §0. Invoke `{descriptionGuardHelper}` at the capture and verify-restore points around each call:
|
|
432
447
|
|
|
433
448
|
```bash
|
|
434
449
|
# Phase 1: capture before any frontmatter-touching tool call
|
|
435
450
|
uv run {descriptionGuardHelper} capture "{skill_package}/SKILL.md"
|
|
436
451
|
# stash returned `description` as `guarded_description`
|
|
437
452
|
|
|
438
|
-
# Phase 2: run the tool (skill-check --fix, split-body --write)
|
|
453
|
+
# Phase 2: run the tool (skill-check --fix, the body split, split-body --write)
|
|
439
454
|
|
|
440
455
|
# Phases 3+4: verify and restore after the tool call
|
|
441
456
|
uv run {descriptionGuardHelper} verify-restore "{skill_package}/SKILL.md" \
|
|
@@ -448,7 +463,14 @@ Do not rely on per-call ad-hoc preservation logic: use the helper.
|
|
|
448
463
|
|
|
449
464
|
- Run `npx skill-check check "{skill_package}" --fix --format json` **inside the §0 guard**. One call validates the package against the spec, scores it, fixes what has one answer and runs the security scan (on unless `--no-security-scan` is passed). Read the JSON, not the exit code: the quality score (`scores[].score`, the entry matching the skill, or a top-level `qualityScore` on an older skill-check), the remaining `diagnostics[]`, `fixed[]` and the security findings. When the scan could not run (no `SNYK_TOKEN`), record `Security: SKIP (SNYK_TOKEN not configured)`.
|
|
450
465
|
- **Context sync after --fix:** if `fixed[]` is non-empty, re-read the SKILL.md `--fix` changed into the in-context copy, so the report does not work from a copy that differs from the file on disk. The §0 guard has already restored `description` if it diverged.
|
|
451
|
-
- If `body.max_lines` is reported,
|
|
466
|
+
- If `body.max_lines` or `body.max_tokens` is reported, split the body selectively with `{shardBodyHelper}` (resolve it ← first existing path in `{shardBodyProbeOrder}`), **inside the §0 guard**: from `{project-root}`, run `uv run {shardBodyHelper} "{skill_package}/SKILL.md" --budget 400 --word-budget 4500 --skip-manual`, with `--budget 500` in place of `--budget 400` when only `body.max_tokens` is reported. It moves the largest Tier 2 (`## Full`) sections to `references/`, each replaced by a `> See` pointer to a file it never overwrites, until the body is within that many lines and 4,500 words, the same headroom under skill-check's 5,000 that 400 lines leave under its 500, and keeps Tier 1 inline (inline passive context achieves 100% task accuracy vs 79% for on-demand retrieval). **Never move a section that holds a `[MANUAL]` block:** init.md §5 inventories SKILL.md only, so a block moved to `references/` would leave the next update's inventory; `--skip-manual` keeps the helper off every section a block reaches into. No Tier 1 content is trimmed, and `split-body` never runs after the helper. Add each WARN below to `warnings[]` as well. Act on its report:
|
|
467
|
+
- **Exit 1 or 2, or no JSON:** HALT with status `blocked` (halt procedure: `phase: "write:body-split"`, `path: "{skill_package}/SKILL.md"`, `reason: "body split failed: {its stderr line}"`). On exit 2 that line ends `already written: {each references/*.md file it wrote before the failure, or none}`: name each of those files in the halt message.
|
|
468
|
+
- **`tier1_preserved` false (it then wrote nothing) or `xref_ok` false:** HALT with status `blocked` (halt procedure: `phase: "write:body-split"`, `path: "{skill_package}/SKILL.md"`, `reason: "body split: tier1_missing {tier1_missing}; xref_ok {xref_ok}"`).
|
|
469
|
+
- **`under_budget` false:** record `Spec compliance: WARN (body.max_lines: no Tier 2 section without a [MANUAL] block left to move)`.
|
|
470
|
+
- **`under_word_budget` false:** record `Spec compliance: WARN (body.max_tokens: no Tier 2 section without a [MANUAL] block left to move)`.
|
|
471
|
+
- **No `{shardBodyProbeOrder}` candidate resolves:** when `body.max_lines` is reported and SKILL.md holds no `[MANUAL]` block (§1's verdict lists none), fall back to `npx skill-check split-body "{skill_package}" --write`, **inside the §0 guard** (it can also touch frontmatter): it moves every section. When `body.max_lines` is reported and SKILL.md holds a `[MANUAL]` block, split nothing and record `Spec compliance: WARN (body.max_lines: skf-shard-body.py missing)`; when only `body.max_tokens` is reported, split nothing and record `Spec compliance: WARN (body.max_tokens: skf-shard-body.py missing)`.
|
|
472
|
+
|
|
473
|
+
After a split (the helper's `sections_extracted` is not empty, or the fallback ran), re-read SKILL.md and each new `references/*.md` file into the in-context copy, as after `--fix`; re-run `npx skill-check check "{skill_package}" --format json --no-security-scan` and take the quality score and the remaining `diagnostics[]` from it; list in the Validation Summary's `Body split` row each section moved and the `references/` file it added; and verify anchors resolve.
|
|
452
474
|
- When a previous version is on disk (outside gap-driven mode), run `npx skill-check diff "{skill_group}/{baseline_version}/{skill_name}" "{skill_package}"` and record its new and fixed issues as informational; otherwise record `Diff: SKIP`.
|
|
453
475
|
|
|
454
476
|
**Re-check the [MANUAL] blocks** once `--fix` and any split have run, from `{project-root}`:
|
|
@@ -33,6 +33,7 @@ These rules apply to every step in this workflow:
|
|
|
33
33
|
- Every verdict must cite evidence from the generated skills
|
|
34
34
|
- Only load one step file at a time — never preload future steps
|
|
35
35
|
- If any instruction references a subprocess or tool you lack, achieve the outcome in your main context thread
|
|
36
|
+
- Run every subagent in the foreground (parallel ones as several calls in one message, never with `run_in_background`) and wait for every long-running command to exit before the next instruction; in a headless run, never end a turn while either still runs, since under `claude -p` a run stops when its turn ends.
|
|
36
37
|
- Always communicate in `{communication_language}`
|
|
37
38
|
- At any interactive prompt, the inputs `cancel`, `exit`, `[X]`, `q`, or `:q` exit cleanly with exit code 6 (`halt_reason: "user-cancelled"`)
|
|
38
39
|
- If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action and log each auto-decision; a gate that picks its default for the user (step 1's offer of the newest earlier report) also records that decision in the run sink the moment it decides
|
|
@@ -61,7 +62,7 @@ These rules apply to every step in this workflow:
|
|
|
61
62
|
| **Flags** | `--headless` / `-H` (auto-resolve all gates); `--architecture-doc <path>` (skip step 1 prompt for the required input); `--prd <path>` (skip step 1 prompt for the optional PRD); `--previous-report <path>` (skip step 1 prompt for delta comparison) |
|
|
62
63
|
| **Gates** | step 1 Input Gate (use args), including the offer to compare against the newest earlier report (headless default: use it). No later stage waits for input: a run with 0% coverage or only Blocked pairs prints one warning and continues, so its report still carries the recommendations. |
|
|
63
64
|
| **Outputs** | `feasibility-report-{project_slug}-{timestamp}.md` and its `feasibility-report-{project_slug}-latest.md` copy (not a symlink), written by step 6 once the finished report passes the feasibility-report check, both per the SKF shared feasibility report schema (`{project-root}/_bmad/skf/shared/references/feasibility-report-schema.md`; `{project-root}/src/shared/references/feasibility-report-schema.md` in a dev checkout), with integration verdicts, coverage analysis, recommendations, and evidence sources; plus `verify-stack-result-{YYYYMMDD-HHmmss}.json` and `verify-stack-result-latest.json` in `{forge_data_folder}` |
|
|
64
|
-
| **Headless** | All gates auto-resolve with default action when `{headless_mode}` is true. Per-flag args (`--architecture-doc`, `--prd`, `--previous-report`) consumed at the gates that would otherwise prompt. |
|
|
65
|
+
| **Headless** | All gates auto-resolve with default action when `{headless_mode}` is true. Per-flag args (`--architecture-doc`, `--prd`, `--previous-report`) consumed at the gates that would otherwise prompt. A caller reads the run's own `SKF_VERIFY_STACK_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 `verify-stack-result-latest.json` may then describe an earlier run, so it is never read in its place. |
|
|
65
66
|
| **Exit codes** | See `references/exit-codes.md` |
|
|
66
67
|
|
|
67
68
|
## Result Contract (Headless)
|
|
@@ -84,10 +85,10 @@ When `{headless_mode}` is true, step 6 prints one `SKF_VERIFY_STACK_RESULT_JSON:
|
|
|
84
85
|
|
|
85
86
|
**Bind the inputs** in every mode: `{architecture_doc_path}` ← the `--architecture-doc` value, `{prd_path}` ← `--prd` and `{previous_report_path}` ← `--previous-report`, each null when its flag is absent. init.md §1 asks only for those still null.
|
|
86
87
|
|
|
87
|
-
4. **Resolve workflow customization.**
|
|
88
|
+
4. **Resolve workflow customization.** Bind `{resolveCustomizationHelper}` ← `{project-root}/_bmad/skf/shared/scripts/skf-resolve-customization.py`, else `{project-root}/src/shared/scripts/skf-resolve-customization.py` (the first that exists), and run:
|
|
88
89
|
|
|
89
90
|
```bash
|
|
90
|
-
uv run {
|
|
91
|
+
uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
|
|
91
92
|
```
|
|
92
93
|
|
|
93
94
|
It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-verify-stack.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. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for init.md to record once it has created `{run_dir}`.
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Each probe runs a binary resolved on PATH outside the project folder, with
|
|
9
9
|
* no shell and fixed arguments, and never npx, which can download. A .cmd or
|
|
10
|
-
* .bat shim on Windows runs through cmd.exe.
|
|
11
|
-
*
|
|
10
|
+
* .bat shim on Windows runs through cmd.exe. tessl and skill-check are never
|
|
11
|
+
* run: tessl's version is read from the name of the binary it installs, and
|
|
12
|
+
* skill-check's, which has no --version, from its package.json. A missing,
|
|
13
|
+
* hanging or failing probe reads as unknown: nothing here throws or changes
|
|
14
|
+
* an exit code.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
const { spawn } = require('node:child_process');
|
|
15
18
|
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
16
20
|
const path = require('node:path');
|
|
17
21
|
const chalk = require('chalk');
|
|
18
22
|
const yaml = require('js-yaml');
|
|
@@ -27,6 +31,25 @@ const MINIMUM = /^\d+(?:\.\d+)*$/;
|
|
|
27
31
|
const NPX_TOOLS = new Set(['tessl', 'skill_check']);
|
|
28
32
|
// ccc has no --version: `uv tool list` names each package uv installed, with its version.
|
|
29
33
|
const CCC_PACKAGE = /^cocoindex-code v(\S+)/m;
|
|
34
|
+
// skill-check has no --version either (1.2.0 exits 2 on it). Its version is
|
|
35
|
+
// read from the package.json of the install its binary belongs to. A
|
|
36
|
+
// package.json is looked for at most this many folders above a binary's real
|
|
37
|
+
// path.
|
|
38
|
+
const SKILL_CHECK_PACKAGE = 'skill-check';
|
|
39
|
+
const PACKAGE_DEPTH = 4;
|
|
40
|
+
const MAX_PACKAGE_JSON = 1024 * 1024;
|
|
41
|
+
// tessl is not run either: on a tessl that has never run, any command,
|
|
42
|
+
// --version included, accepts tessl's Terms and turns on usage sharing for
|
|
43
|
+
// the user, and a run can start tessl's own update. tessl's installers name
|
|
44
|
+
// each binary tessl-<version>-<platform> in a tessl/versions folder and point
|
|
45
|
+
// the PATH entry at one: a symlink on Linux and macOS, a copy in tessl\bin on
|
|
46
|
+
// Windows.
|
|
47
|
+
const TESSL_BINARY = /^tessl-(.+)-(?:linux|darwin|win32)-(?:x64|arm64)(?:-musl)?(?:\.exe)?$/;
|
|
48
|
+
// The npm (@tessl/cli) and Homebrew (tessl) packages hold a launcher that
|
|
49
|
+
// runs the binary tessl installed, so their version is not the one that runs.
|
|
50
|
+
// @tessl/cli before 0.63.0 held the CLI itself.
|
|
51
|
+
const TESSL_LAUNCHERS = ['@tessl/cli', 'tessl'];
|
|
52
|
+
const FIRST_TESSL_LAUNCHER = '0.63.0';
|
|
30
53
|
const MAX_OUTPUT = 64 * 1024;
|
|
31
54
|
const STYLES = { ok: chalk.green, upgrade: chalk.yellow, unknown: chalk.yellow, missing: chalk.red, optional: chalk.dim };
|
|
32
55
|
|
|
@@ -172,6 +195,129 @@ async function versionOf(file, args, context) {
|
|
|
172
195
|
return ok ? findVersion(stdout) : null;
|
|
173
196
|
}
|
|
174
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The package.json at `file` as an object, or null when there is none, it is
|
|
200
|
+
* not a regular file under MAX_PACKAGE_JSON (a FIFO would block the check
|
|
201
|
+
* past its timeout) or it cannot be read.
|
|
202
|
+
*/
|
|
203
|
+
function readPackage(file) {
|
|
204
|
+
try {
|
|
205
|
+
const stat = fs.statSync(file);
|
|
206
|
+
if (!stat.isFile() || stat.size > MAX_PACKAGE_JSON) return null;
|
|
207
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
208
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The package.json of the package named one of `names` that installed
|
|
216
|
+
* `file`, the binary `bin` on PATH, or null: the shim folder's
|
|
217
|
+
* node_modules/<name>/package.json (npm's global layout on Windows), else the
|
|
218
|
+
* first package.json with a name above the binary's real path, when it is
|
|
219
|
+
* one of `names` and declares that file as its `bin` (npm, yarn, bun and
|
|
220
|
+
* Homebrew link the binary into its package on Linux and macOS). null for
|
|
221
|
+
* any other layout, such as pnpm's or Volta's shims.
|
|
222
|
+
*/
|
|
223
|
+
function packageOf(file, bin, names) {
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
const shim = readPackage(path.join(path.dirname(file), 'node_modules', ...name.split('/'), 'package.json'));
|
|
226
|
+
if (shim?.name === name) return shim;
|
|
227
|
+
}
|
|
228
|
+
const target = realpath(file);
|
|
229
|
+
const declares = (data, dir) => {
|
|
230
|
+
const entry = typeof data.bin === 'string' ? data.bin : data.bin?.[bin];
|
|
231
|
+
return typeof entry === 'string' && realpath(path.resolve(dir, entry)) === target;
|
|
232
|
+
};
|
|
233
|
+
let dir = path.dirname(target);
|
|
234
|
+
for (let depth = 0; depth < PACKAGE_DEPTH; depth++) {
|
|
235
|
+
const data = readPackage(path.join(dir, 'package.json'));
|
|
236
|
+
if (typeof data?.name === 'string') return names.includes(data.name) && declares(data, dir) ? data : null;
|
|
237
|
+
const parent = path.dirname(dir);
|
|
238
|
+
if (parent === dir) break;
|
|
239
|
+
dir = parent;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A file's stat, or null when it cannot be read (a dangling link, say). */
|
|
245
|
+
function statOf(file) {
|
|
246
|
+
try {
|
|
247
|
+
return fs.statSync(file);
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The last part of `file`, in lower case on Windows, where names ignore case. */
|
|
254
|
+
function nameOf(file) {
|
|
255
|
+
return process.platform === 'win32' ? path.basename(file).toLowerCase() : path.basename(file);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The version of the tessl binary `file`, from its name: tessl-<version>-
|
|
260
|
+
* <platform> once its real path is resolved, or, for a copy at
|
|
261
|
+
* tessl/bin/tessl or tessl.exe (tessl's layout on Windows), the name of the
|
|
262
|
+
* one file of tessl/versions that has its size. null when there is no such
|
|
263
|
+
* file, or more than one.
|
|
264
|
+
*/
|
|
265
|
+
function tesslBinaryVersion(file) {
|
|
266
|
+
const target = realpath(file);
|
|
267
|
+
const named = TESSL_BINARY.exec(nameOf(target));
|
|
268
|
+
if (named) return findVersion(named[1]);
|
|
269
|
+
const bin = path.dirname(target);
|
|
270
|
+
if (!/^tessl(?:\.exe)?$/.test(nameOf(target)) || nameOf(bin) !== 'bin' || nameOf(path.dirname(bin)) !== 'tessl') return null;
|
|
271
|
+
const size = statOf(target)?.size;
|
|
272
|
+
const versions = path.join(path.dirname(bin), 'versions');
|
|
273
|
+
let names;
|
|
274
|
+
try {
|
|
275
|
+
names = fs.readdirSync(versions);
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const copied = names.filter((name) => {
|
|
280
|
+
const stat = TESSL_BINARY.test(nameOf(name)) ? statOf(path.join(versions, name)) : null;
|
|
281
|
+
return stat?.isFile() && stat.size === size;
|
|
282
|
+
});
|
|
283
|
+
return size !== undefined && copied.length === 1 ? findVersion(TESSL_BINARY.exec(nameOf(copied[0]))[1]) : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The binary tessl's launchers run: ~/.local/bin/tessl, or
|
|
288
|
+
* %LOCALAPPDATA%\tessl\bin\tessl.exe on Windows. null when the home folder
|
|
289
|
+
* is not an absolute path, which would be read from the project folder.
|
|
290
|
+
*/
|
|
291
|
+
function tesslLauncherTarget(env) {
|
|
292
|
+
const target =
|
|
293
|
+
process.platform === 'win32'
|
|
294
|
+
? path.join(
|
|
295
|
+
envValue(env, 'LOCALAPPDATA') ?? path.join(envValue(env, 'USERPROFILE') || os.homedir(), 'AppData', 'Local'),
|
|
296
|
+
'tessl',
|
|
297
|
+
'bin',
|
|
298
|
+
'tessl.exe',
|
|
299
|
+
)
|
|
300
|
+
: path.join(env.HOME || os.homedir(), '.local', 'bin', 'tessl');
|
|
301
|
+
return path.isAbsolute(target) ? target : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* tessl's version, read without running it: from the binary on PATH (see
|
|
306
|
+
* tesslBinaryVersion), or, when that is the npm or Homebrew launcher, from
|
|
307
|
+
* the binary the launcher runs. An @tessl/cli older than the first launcher
|
|
308
|
+
* is the CLI itself, so its package.json has the version.
|
|
309
|
+
*/
|
|
310
|
+
function tesslVersion(file, env) {
|
|
311
|
+
const own = tesslBinaryVersion(file);
|
|
312
|
+
if (own) return own;
|
|
313
|
+
const launcher = packageOf(file, 'tessl', TESSL_LAUNCHERS);
|
|
314
|
+
if (!launcher) return null;
|
|
315
|
+
const packaged = findVersion(launcher.version);
|
|
316
|
+
if (launcher.name === '@tessl/cli' && packaged && compareVersions(packaged, FIRST_TESSL_LAUNCHER)) return packaged;
|
|
317
|
+
const target = tesslLauncherTarget(env);
|
|
318
|
+
return target ? tesslBinaryVersion(target) : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
175
321
|
/** One tool: { found, version }, the version null when it cannot be read. */
|
|
176
322
|
async function probe(key, tool, context) {
|
|
177
323
|
if (key === 'node') return { found: true, version: process.versions.node };
|
|
@@ -190,6 +336,16 @@ async function probe(key, tool, context) {
|
|
|
190
336
|
const match = ok ? CCC_PACKAGE.exec(stdout) : null;
|
|
191
337
|
return { found: true, version: match ? findVersion(match[1]) : null };
|
|
192
338
|
}
|
|
339
|
+
if (key === 'skill_check') {
|
|
340
|
+
const file = resolveOutsideProject(SKILL_CHECK_PACKAGE, context);
|
|
341
|
+
return file
|
|
342
|
+
? { found: true, version: findVersion(packageOf(file, SKILL_CHECK_PACKAGE, [SKILL_CHECK_PACKAGE])?.version) }
|
|
343
|
+
: { found: false };
|
|
344
|
+
}
|
|
345
|
+
if (key === 'tessl') {
|
|
346
|
+
const file = resolveOutsideProject('tessl', context);
|
|
347
|
+
return file ? { found: true, version: tesslVersion(file, context.env) } : { found: false };
|
|
348
|
+
}
|
|
193
349
|
const [name, ...args] = Array.isArray(tool.version_command) ? tool.version_command.map(String) : [];
|
|
194
350
|
const file = name ? resolveOutsideProject(name, context) : null;
|
|
195
351
|
if (!file) return { found: false };
|