bmad-module-skill-forge 3.0.0 → 5.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/architecture.md +2 -2
- package/docs/concepts.md +4 -4
- package/docs/examples.md +33 -26
- 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 +8 -0
- package/docs/verifying-a-skill.md +46 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +5 -5
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
- package/src/shared/scripts/skf-extract-public-api.py +1673 -189
- package/src/shared/scripts/skf-extraction-inventory.py +584 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-parse-gaps.py +135 -26
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- 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-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +2 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +3 -3
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-campaign/SKILL.md +2 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +7 -7
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +23 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +10 -11
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +21 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +2 -2
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-export-skill/SKILL.md +2 -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/load-skill.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +6 -8
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-setup/SKILL.md +2 -2
- package/src/skf-test-skill/SKILL.md +2 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +5 -4
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +69 -111
- package/src/skf-test-skill/references/init.md +5 -6
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +11 -9
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +15 -18
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
- package/src/skf-test-skill/scripts/score-signatures.py +356 -61
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +2 -2
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +37 -22
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +17 -12
- package/src/skf-update-skill/references/re-extract.md +4 -2
- package/src/skf-update-skill/references/report.md +7 -4
- package/src/skf-update-skill/references/write.md +77 -36
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# /// script
|
|
3
3
|
# requires-python = ">=3.11"
|
|
4
|
-
# dependencies = []
|
|
4
|
+
# dependencies = ["pyyaml"]
|
|
5
5
|
# ///
|
|
6
6
|
"""Print the stdin payload of a shared SKF helper, read from the files on disk.
|
|
7
7
|
|
|
@@ -13,7 +13,7 @@ This script reads the files and prints the payload, and the step pipes it
|
|
|
13
13
|
into the helper:
|
|
14
14
|
|
|
15
15
|
uv run stage-helper-payload.py detect-workspaces --source-root <dir> \\
|
|
16
|
-
| uv run skf-detect-workspaces.py
|
|
16
|
+
[--brief <skill-brief.yaml>] | uv run skf-detect-workspaces.py
|
|
17
17
|
|
|
18
18
|
(The Quick-tier export scan needs no payload: skf-extract-public-api.py
|
|
19
19
|
--mode quick reads its files itself, with --manifest-file and --entry-file.)
|
|
@@ -24,28 +24,48 @@ detect-workspaces prints {"tree": [...], "manifests": {...}}:
|
|
|
24
24
|
it, sorted. Folders named in SKIPPED_DIRS (installed
|
|
25
25
|
dependencies, virtual environments, build output, caches) and
|
|
26
26
|
hidden folders (a name that starts with ".") are not read, and a
|
|
27
|
-
linked folder is not followed.
|
|
27
|
+
linked folder is not followed. With --brief, the tree is the
|
|
28
|
+
detector's scoped_tree of it under the brief's scope.exclude
|
|
29
|
+
globs (#780): a workspace member counts only through the files
|
|
30
|
+
the brief keeps, so excluding a member's source files drops it,
|
|
31
|
+
excluding only its manifest does not, every root manifest
|
|
32
|
+
stays, and a workspace with no member left is no workspace.
|
|
28
33
|
manifests the text of each file directly in --source-root that is not
|
|
29
34
|
hidden, holds at most MAX_ROOT_FILE_BYTES and reads as UTF-8, by
|
|
30
|
-
its name. The detector reads the
|
|
31
|
-
(package.json, pnpm-workspace.yaml,
|
|
32
|
-
and ignores the rest, so this script
|
|
33
|
-
names to fall out of step with the
|
|
35
|
+
its name, whatever the brief excludes. The detector reads the
|
|
36
|
+
root manifests it knows (package.json, pnpm-workspace.yaml,
|
|
37
|
+
lerna.json, Cargo.toml ...) and ignores the rest, so this script
|
|
38
|
+
keeps no list of their names to fall out of step with the
|
|
39
|
+
detector's.
|
|
40
|
+
|
|
41
|
+
--brief reads the brief with load-coverage-inputs.py's read_brief (the
|
|
42
|
+
script beside this one, which loads skf-resolve-authoritative-files.py's
|
|
43
|
+
parse_brief_yaml from the shared scripts folder), and matches each
|
|
44
|
+
scope.exclude glob by its glob_match, skf-resolve-authoritative-files.py's
|
|
45
|
+
rule (`**` spans any number of path segments, none included; `*` and `?`
|
|
46
|
+
stay inside one), the rule skf-extract-public-api.py applies to the same
|
|
47
|
+
brief. skf-detect-workspaces.py's scoped_tree, loaded from the shared
|
|
48
|
+
scripts folder (SHARED_SCRIPTS), keeps the paths: the monorepo arm of
|
|
49
|
+
skf-extract-public-api.py reads the same function.
|
|
34
50
|
|
|
35
51
|
The payload is one line of ASCII JSON on stdout, so a pipe carries the same
|
|
36
52
|
bytes on every platform.
|
|
37
53
|
|
|
38
54
|
Exit codes:
|
|
39
55
|
0 the payload is printed
|
|
40
|
-
1 --source-root is not a folder
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
1 --source-root is not a folder, or --brief cannot be read (missing, not
|
|
57
|
+
UTF-8, not YAML, a value YAML cannot build such as an invalid date, not
|
|
58
|
+
a mapping, PyYAML not installed) or a script it loads is missing; one
|
|
59
|
+
JSON line {"status": "error", "error": ...} goes to stderr and nothing
|
|
60
|
+
to stdout, so the helper the payload was piped into reads no input and
|
|
61
|
+
fails too
|
|
43
62
|
2 bad arguments (argparse: a missing or unknown argument, usage on stderr)
|
|
44
63
|
"""
|
|
45
64
|
|
|
46
65
|
from __future__ import annotations
|
|
47
66
|
|
|
48
67
|
import argparse
|
|
68
|
+
import importlib.util
|
|
49
69
|
import json
|
|
50
70
|
import os
|
|
51
71
|
import sys
|
|
@@ -57,6 +77,9 @@ SKIPPED_DIRS = frozenset({
|
|
|
57
77
|
"venv", "site-packages", "__pycache__", "target",
|
|
58
78
|
})
|
|
59
79
|
MAX_ROOT_FILE_BYTES = 1024 * 1024
|
|
80
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
81
|
+
# skf-detect-workspaces.py's folder, installed and in src/ alike.
|
|
82
|
+
SHARED_SCRIPTS = SCRIPTS.parent.parent / "shared" / "scripts"
|
|
60
83
|
|
|
61
84
|
|
|
62
85
|
class StageError(Exception):
|
|
@@ -70,6 +93,29 @@ def _source_root(value: str) -> Path:
|
|
|
70
93
|
return root
|
|
71
94
|
|
|
72
95
|
|
|
96
|
+
def _load(folder: Path, filename: str):
|
|
97
|
+
"""A script in `folder`, loaded as a module, as load-coverage-inputs.py loads its siblings."""
|
|
98
|
+
path = folder / filename
|
|
99
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
|
|
100
|
+
if spec is None or spec.loader is None or not path.is_file():
|
|
101
|
+
raise StageError(f"{filename} not found in {folder}")
|
|
102
|
+
module = importlib.util.module_from_spec(spec)
|
|
103
|
+
spec.loader.exec_module(module)
|
|
104
|
+
return module
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def scoped_tree(tree: list[str], brief: str) -> list[str]:
|
|
108
|
+
"""The paths of `tree` workspace detection reads under the brief at `brief`."""
|
|
109
|
+
inputs = _load(SCRIPTS, "load-coverage-inputs.py")
|
|
110
|
+
try:
|
|
111
|
+
excludes = inputs.read_brief(brief)["scope"]["exclude"]
|
|
112
|
+
except inputs.InputError as exc:
|
|
113
|
+
raise StageError(str(exc)) from exc
|
|
114
|
+
except ValueError as exc: # a value YAML cannot build, such as an invalid date
|
|
115
|
+
raise StageError(f"--brief {brief} is not valid YAML: {' '.join(str(exc).split())}") from exc
|
|
116
|
+
return _load(SHARED_SCRIPTS, "skf-detect-workspaces.py").scoped_tree(tree, excludes, inputs.glob_match)
|
|
117
|
+
|
|
118
|
+
|
|
73
119
|
# --------------------------------------------------------------------------
|
|
74
120
|
# detect-workspaces
|
|
75
121
|
# --------------------------------------------------------------------------
|
|
@@ -105,9 +151,13 @@ def root_texts(root: Path) -> dict[str, str]:
|
|
|
105
151
|
return texts
|
|
106
152
|
|
|
107
153
|
|
|
108
|
-
def workspaces_payload(source_root: str) -> dict:
|
|
154
|
+
def workspaces_payload(source_root: str, brief: str | None = None) -> dict:
|
|
155
|
+
"""The tree (with `brief`, the part of it the brief keeps) and the root files."""
|
|
109
156
|
root = _source_root(source_root)
|
|
110
|
-
|
|
157
|
+
tree = list_tree(root)
|
|
158
|
+
if brief is not None:
|
|
159
|
+
tree = scoped_tree(tree, brief)
|
|
160
|
+
return {"tree": tree, "manifests": root_texts(root)}
|
|
111
161
|
|
|
112
162
|
|
|
113
163
|
# --------------------------------------------------------------------------
|
|
@@ -116,7 +166,7 @@ def workspaces_payload(source_root: str) -> dict:
|
|
|
116
166
|
|
|
117
167
|
|
|
118
168
|
def _cmd_detect_workspaces(args: argparse.Namespace) -> dict:
|
|
119
|
-
return workspaces_payload(args.source_root)
|
|
169
|
+
return workspaces_payload(args.source_root, args.brief)
|
|
120
170
|
|
|
121
171
|
|
|
122
172
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -128,6 +178,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
128
178
|
|
|
129
179
|
p = sub.add_parser("detect-workspaces", help="the tree and root files for skf-detect-workspaces.py")
|
|
130
180
|
p.add_argument("--source-root", required=True, help="the local source folder")
|
|
181
|
+
p.add_argument("--brief", help="the skill brief, whose scope.exclude globs leave paths out of the tree")
|
|
131
182
|
p.set_defaults(func=_cmd_detect_workspaces)
|
|
132
183
|
|
|
133
184
|
return parser
|
|
@@ -10,7 +10,8 @@ trust subagent output blindly: before any downstream step consumes the
|
|
|
10
10
|
documented inventory, the shape is validated. That validation is pure
|
|
11
11
|
structural checking — strip wrapping fences, parse JSON, assert required keys
|
|
12
12
|
and types, assert each export entry is a dict with a non-empty `name` and a
|
|
13
|
-
`kind` drawn from a fixed enum
|
|
13
|
+
`kind` drawn from a fixed enum (and, when present, a `signature_form` of
|
|
14
|
+
`partial` or `full`), and assert each cross-check mismatch carries its
|
|
14
15
|
five fields — with exactly one correct pass/fail per input and no
|
|
15
16
|
interpretation of meaning. It is therefore script work, not prompt work; the
|
|
16
17
|
prompt reads this verdict and owns only the HALT decision.
|
|
@@ -26,6 +27,15 @@ violation, and the prompt re-dispatches the subagent with them, as it does
|
|
|
26
27
|
for a schema violation. No grep runs in the prompt, so an export named
|
|
27
28
|
`$state` or `a.b` is never a shell variable or a regex.
|
|
28
29
|
|
|
30
|
+
An entry's optional `signature_form` says how the skill presents its
|
|
31
|
+
signature: `partial` only when the skill labels the form partial (a
|
|
32
|
+
simplified form, a key-parameter summary, a `...` that stands in for
|
|
33
|
+
omitted members, not rest or variadic syntax), `full` otherwise.
|
|
34
|
+
A JSON null counts as absent, and an absent one as `full`. Any other value
|
|
35
|
+
is a violation naming the entry. score-signatures.py reads it: an optional
|
|
36
|
+
member a partial form leaves out is an Info observation, not a wrong
|
|
37
|
+
signature.
|
|
38
|
+
|
|
29
39
|
Distinct from reconcile-coverage.py (coverage numerator arithmetic) and
|
|
30
40
|
compute-score.py (weight tables): this script only validates the *shape* of the
|
|
31
41
|
subagent inventory and, on success, echoes the fence-stripped parsed inventory
|
|
@@ -50,6 +60,14 @@ skf-scan-skill-md-structure.py usage-scope): on a side where the name ends
|
|
|
50
60
|
in a letter, digit, `_` or `$`, the next character may not be one of those.
|
|
51
61
|
So `get` is found in `get(url)` but not in `target` or `getAll`.
|
|
52
62
|
|
|
63
|
+
The skill-citation scan is shared too: `_skill_cited_files` (with
|
|
64
|
+
`_cited_path`, `skill_markdown_files` and `_SKILL_CITATION_RE`) gives the
|
|
65
|
+
files the skill's `[AST:]` and `[SRC:]` citations name on a line of SKILL.md
|
|
66
|
+
or references/**/*.md that writes a name with the match above, or on the
|
|
67
|
+
next non-blank line. gap-ledger.py reads it to pick the declaration of a
|
|
68
|
+
stale homonym, score-signatures.py to plan a signature check at the
|
|
69
|
+
declaration the skill cites.
|
|
70
|
+
|
|
53
71
|
CLI usage (mirrors reconcile-coverage.py):
|
|
54
72
|
uv run validate-inventory.py --input <response file> [--output <inventory file>]
|
|
55
73
|
[--skill-package <package folder>]
|
|
@@ -57,13 +75,16 @@ CLI usage (mirrors reconcile-coverage.py):
|
|
|
57
75
|
uv run validate-inventory.py --json-input '<raw response>' # explicit flag
|
|
58
76
|
echo '<raw response>' | uv run validate-inventory.py --stdin # piped input
|
|
59
77
|
|
|
60
|
-
--input reads the raw response from a file (UTF-8): coverage-check.md
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
--input reads the raw response from a file (UTF-8): the coverage-check.md §1
|
|
79
|
+
subagent writes its response to the path it is given with the Write tool,
|
|
80
|
+
and the parent saves it there with the Write tool when the subagent returned
|
|
81
|
+
it instead or left the file absent or empty. coverage-check.md passes that
|
|
82
|
+
path, so a quote or an apostrophe in a description never meets a shell.
|
|
83
|
+
--output writes the validated inventory object to a file (UTF-8
|
|
84
|
+
JSON) when it is valid, for the steps that read it by path; an invalid
|
|
85
|
+
inventory removes a file left there by an earlier attempt. --skill-package
|
|
86
|
+
names the skill package under test (the folder that holds SKILL.md) and
|
|
87
|
+
adds the spot-check.
|
|
67
88
|
|
|
68
89
|
Input: the subagent's RAW response text (JSON, optionally wrapped in a markdown
|
|
69
90
|
code fence — a leading line of three backticks with an optional language tag and
|
|
@@ -73,7 +94,7 @@ Output (stdout, one object):
|
|
|
73
94
|
{
|
|
74
95
|
"valid": <bool>, # true only when parse + every schema check pass
|
|
75
96
|
"violations": [<str>, ...], # human-readable failures; empty when valid
|
|
76
|
-
"rejectedCount": <int>, #
|
|
97
|
+
"rejectedCount": <int>, # exports[] entries with no valid name or kind
|
|
77
98
|
"exportsCount": <int>, # len(exports) when parsed; 0 otherwise
|
|
78
99
|
"inventory": {...} | null, # fence-stripped parsed inventory when valid; null otherwise
|
|
79
100
|
"completeness": { # present when valid
|
|
@@ -102,6 +123,7 @@ from __future__ import annotations
|
|
|
102
123
|
import argparse
|
|
103
124
|
import json
|
|
104
125
|
import os
|
|
126
|
+
import posixpath
|
|
105
127
|
import re
|
|
106
128
|
import sys
|
|
107
129
|
from pathlib import Path
|
|
@@ -126,6 +148,10 @@ VALID_KINDS = (
|
|
|
126
148
|
"adapter",
|
|
127
149
|
)
|
|
128
150
|
|
|
151
|
+
# The optional exports[].signature_form: `partial` only when the skill labels
|
|
152
|
+
# the documented form partial, else `full` (or the key left out).
|
|
153
|
+
SIGNATURE_FORMS = ("partial", "full")
|
|
154
|
+
|
|
129
155
|
REQUIRED_MISMATCH_FIELDS = (
|
|
130
156
|
"export",
|
|
131
157
|
"skill_md_line",
|
|
@@ -208,6 +234,69 @@ def names_present(names, doc_text):
|
|
|
208
234
|
return sorted(present)
|
|
209
235
|
|
|
210
236
|
|
|
237
|
+
# --- Skill citations ----------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
# A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
|
|
240
|
+
# `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
|
|
241
|
+
# Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
|
|
242
|
+
_SKILL_CITATION_RE = re.compile(
|
|
243
|
+
r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
|
|
244
|
+
)
|
|
245
|
+
# Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
|
|
246
|
+
_ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _cited_path(path: str) -> str:
|
|
250
|
+
"""A cited, `declared_in` or surface path as each compares: forward slashes, no `./`."""
|
|
251
|
+
path = path.strip().replace("\\", "/")
|
|
252
|
+
while path.startswith("./"):
|
|
253
|
+
path = path[2:]
|
|
254
|
+
return posixpath.normpath(path) if path else path
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def skill_markdown_files(skill_dir: Path) -> list[Path]:
|
|
258
|
+
"""`SKILL.md` then every `references/**/*.md`, in a stable order.
|
|
259
|
+
Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
|
|
260
|
+
files: list[Path] = []
|
|
261
|
+
skill_md = skill_dir / "SKILL.md"
|
|
262
|
+
if skill_md.is_file():
|
|
263
|
+
files.append(skill_md)
|
|
264
|
+
refs = skill_dir / "references"
|
|
265
|
+
if refs.is_dir():
|
|
266
|
+
files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
|
|
267
|
+
return files
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def skill_markdown_lines(skill_dir: Path) -> list[list[str]]:
|
|
271
|
+
"""The lines of each file skill_markdown_files lists (an unreadable one is
|
|
272
|
+
left out), read once for every name _skill_cited_files looks up."""
|
|
273
|
+
texts: list[list[str]] = []
|
|
274
|
+
for path in skill_markdown_files(skill_dir):
|
|
275
|
+
try:
|
|
276
|
+
texts.append(path.read_bytes().decode("utf-8-sig", errors="replace").split("\n"))
|
|
277
|
+
except OSError:
|
|
278
|
+
continue
|
|
279
|
+
return texts
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _skill_cited_files(skill_dir: Path, name: str, texts: list[list[str]] | None = None) -> set[str]:
|
|
283
|
+
"""The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
|
|
284
|
+
SKILL.md or references/**/*.md that writes `name` outside its citations
|
|
285
|
+
(the documented-name match), or on the next non-blank line (a heading,
|
|
286
|
+
then its citation). `texts`, skill_markdown_lines(skill_dir) read once,
|
|
287
|
+
spares a caller that looks up many names reading the files again."""
|
|
288
|
+
pattern = name_pattern(name)
|
|
289
|
+
cited: set[str] = set()
|
|
290
|
+
for lines in skill_markdown_lines(skill_dir) if texts is None else texts:
|
|
291
|
+
for number, line in enumerate(lines):
|
|
292
|
+
if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
|
|
293
|
+
continue
|
|
294
|
+
following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
|
|
295
|
+
for cited_line in (line, following):
|
|
296
|
+
cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
|
|
297
|
+
return cited
|
|
298
|
+
|
|
299
|
+
|
|
211
300
|
# --- Spot-check -------------------------------------------------------------
|
|
212
301
|
|
|
213
302
|
|
|
@@ -392,6 +481,16 @@ def validate_inventory(raw, skill_package_path=None):
|
|
|
392
481
|
f"{{{', '.join(VALID_KINDS)}}})"
|
|
393
482
|
)
|
|
394
483
|
|
|
484
|
+
# The optional signature_form names one of two forms, or is left out.
|
|
485
|
+
for idx, entry in enumerate(exports):
|
|
486
|
+
if isinstance(entry, dict) and entry.get("signature_form") is not None \
|
|
487
|
+
and entry["signature_form"] not in SIGNATURE_FORMS:
|
|
488
|
+
label = f" (`{entry['name']}`)" if isinstance(entry.get("name"), str) and entry["name"] else ""
|
|
489
|
+
violations.append(
|
|
490
|
+
f"exports[{idx}]{label}: `signature_form` must be `partial` or `full` when present, "
|
|
491
|
+
f"not {json.dumps(entry['signature_form'])}"
|
|
492
|
+
)
|
|
493
|
+
|
|
395
494
|
# Non-empty cross-check mismatch entries must carry all five fields.
|
|
396
495
|
for idx, entry in enumerate(cross):
|
|
397
496
|
if not isinstance(entry, dict):
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
Lookup-and-count helper for the SKF test-skill workflow (coverage-check.md
|
|
9
9
|
§2b, its "Numerator ground truth" paragraph; §4b records the gap). It fires
|
|
10
10
|
only on the inflation signature
|
|
11
|
-
(`stats.exports_documented == effective_denominator` exactly
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
(`stats.exports_documented == effective_denominator` exactly, or, for a
|
|
12
|
+
provenance map with no `::` entry, its distinct entry names number
|
|
13
|
+
`effective_denominator`): the numerator equals the denominator, the tell of a
|
|
14
|
+
documented count padded to force 100% coverage. On that signature the declared
|
|
15
|
+
count is not trusted: every declared export name is looked up in the skill's
|
|
16
|
+
documentation surface and only the names that actually appear count toward
|
|
17
|
+
coverage.
|
|
16
18
|
|
|
17
19
|
Distinct from reconcile-coverage.py (whose scalar/stack branches look up the §1
|
|
18
20
|
inventory names and return only a residual missing *count*): this script looks
|
|
@@ -79,10 +79,10 @@ Headless callers: the inputs, flags, gate map, outputs and the `SKF_UPDATE_RESUL
|
|
|
79
79
|
SKF_JSON
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
4. **Resolve workflow customization.**
|
|
82
|
+
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:
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
uv run {
|
|
85
|
+
uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
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}`.
|
|
@@ -7,8 +7,8 @@ hashContentProbeOrder:
|
|
|
7
7
|
- '{project-root}/_bmad/skf/shared/scripts/skf-hash-content.py'
|
|
8
8
|
- '{project-root}/src/shared/scripts/skf-hash-content.py'
|
|
9
9
|
# `{buildChangeManifestHelper}`: §3's `build` (and §1's for a docs-only
|
|
10
|
-
# skill), §2.2's `deletion-ratio
|
|
11
|
-
# if neither exists.
|
|
10
|
+
# skill), §2.2's `deletion-ratio`, Category B's `baseline-gaps` and Category
|
|
11
|
+
# C's `rename-candidates`. HALT if neither exists.
|
|
12
12
|
buildChangeManifestProbeOrder:
|
|
13
13
|
- '{project-root}/_bmad/skf/shared/scripts/skf-build-change-manifest.py'
|
|
14
14
|
- '{project-root}/src/shared/scripts/skf-build-change-manifest.py'
|
|
@@ -17,13 +17,13 @@ buildChangeManifestProbeOrder:
|
|
|
17
17
|
provenanceGapDispatchProbeOrder:
|
|
18
18
|
- '{project-root}/_bmad/skf/shared/scripts/skf-provenance-gap-dispatch.py'
|
|
19
19
|
- '{project-root}/src/shared/scripts/skf-provenance-gap-dispatch.py'
|
|
20
|
-
# `{detectScriptsAssetsHelper}`: Category D's script and asset walk,
|
|
21
|
-
# create-skill
|
|
20
|
+
# `{detectScriptsAssetsHelper}`: Category D's script and asset walk, the
|
|
21
|
+
# detector create-skill runs, over the whole source. HALT if neither exists.
|
|
22
22
|
detectScriptsAssetsProbeOrder:
|
|
23
23
|
- '{project-root}/_bmad/skf/shared/scripts/skf-detect-scripts-assets.py'
|
|
24
24
|
- '{project-root}/src/shared/scripts/skf-detect-scripts-assets.py'
|
|
25
|
-
# `{newFileDiffHelper}`: Category D's NEW_FILE set difference,
|
|
26
|
-
# this skill.
|
|
25
|
+
# `{newFileDiffHelper}`: Category D's NEW_FILE set difference, kept to the
|
|
26
|
+
# skill brief's scope, bundled with this skill.
|
|
27
27
|
newFileDiffHelper: 'scripts/skf-new-file-diff.py'
|
|
28
28
|
# `{resolveAuthoritativeFilesHelper}`: §1b's mirror. HALT if neither exists.
|
|
29
29
|
resolveAuthoritativeFilesProbeOrder:
|
|
@@ -103,6 +103,8 @@ It writes `{"mode": "docs-only", "no_changes", "changed_urls", "fetch_failed", "
|
|
|
103
103
|
|
|
104
104
|
**The brief this run reads.** Bind `{brief_path}` ← `{forge_data_folder}/{skill_name}/skill-brief.yaml`. When `detect_only_mode` or `dry_run_mode` is true and that file exists, copy it to `{run_dir}/skill-brief.yaml` and bind `{brief_path}` to the copy: §1b and §1c write each decision there, Category A reads the scope from there, and the brief itself stays as it was. Each amendment a read-only run writes to the copy is a proposed one: add `proposed-amendment: {action} {path} ({category}); not written: {--dry-run or --detect-only}` to `warnings[]` as you write it, and step 6 lists them with the change manifest.
|
|
105
105
|
|
|
106
|
+
**The scope type this run reads.** Bind `{scope_type}` ← the `scope_type` metadata.json records (init.md §2), else the `scope.type` of `{brief_path}` when that file exists and names one: a skill whose metadata.json records no scope type updates under its brief's. Category B's runner takes it as `--scope-type` and writes it to `{run_dir}/extraction.json` as `scope.type`, where step 3's `records` and step 5's `apply` read it: under `public-api`, a normal-mode update neither documents nor adds to the provenance map a new export off the runner's public surface (re-extract.md §4, write.md §3).
|
|
107
|
+
|
|
106
108
|
### 1b. Discovered Authoritative Files Protocol (Mirror)
|
|
107
109
|
|
|
108
110
|
**Purpose:** mirror `skf-create-skill` §2a into update-skill. `skf-create-skill` §2a catches authoritative AI documentation files (`llms.txt`, `AGENTS.md`, `.cursorrules`, etc.) during **creation**, but a project may add these files *after* the skill was created. Without this mirror, update-skill would either miss the new file entirely (if it doesn't match the provenance map's file patterns) or classify it as a generic ADDED file in §2 Category A with no authoritative-file treatment. The mirror surfaces the discovery with the same P/S/U prompt create-skill uses, honoring any prior amendments.
|
|
@@ -309,9 +311,22 @@ Pass `--provenance-map` whenever init.md §4 found one; `--brief` (as §1c left
|
|
|
309
311
|
-o "{run_dir}/extraction.json"
|
|
310
312
|
```
|
|
311
313
|
|
|
312
|
-
Pass `--language` and `--scope-type` when
|
|
314
|
+
Pass `--language` when metadata.json records it, and `--scope-type` when §1 bound `{scope_type}`. It takes no `--brief`: Category A applied the scope, and a tracked file the brief's scope leaves out (gap-driven.md §1's rule R1 excludes a rescoped export's file) keeps its exports. `--head-cap 0` keeps every match, since a dropped one would read as a deleted export. Step 3 reads this file and never runs the runner again. **Exit 1** (`incomplete`, its JSON written): run it once more; when still incomplete, step 2 below reads by eye each listed file with no export in `exports[]` or that an `errors[]` item lists in `unread`, and tells the user which files were read by eye. **Exit 2** (an input error, such as a file list it cannot read, or a failure it did not foresee, named in one stderr line with no JSON; `uv` failing to start it exits 2 too): HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-b"`, the stderr line that holds `error:` as `reason`). No by-eye read stands in for it: the skill is not written yet, and reading every file by eye would hide the error. **Exit 3 (`no-ast-grep`), no JSON on another exit, no candidate resolves, or Quick tier:** write `{"exports": []}` to `{run_dir}/extraction.json`, with `"scope": {"type": "{scope_type}"}` when §1 bound `{scope_type}`, so step 5's `apply` can warn that a `public-api` surface was not applied; step 2 reads every listed file by eye (at Quick tier, by text pattern). Tell the user which files were read by eye.
|
|
315
|
+
|
|
316
|
+
Then, at every tier (in degraded mode there is no map: skip it), list the map's entries the runner left out that their file still declares: the runner never reports a module, an alias that renames, a dunder such as `__version__` or another Python or Rust underscore name, and step 3's diff would read each as a deleted export. From `{project-root}`, run:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
uv run {buildChangeManifestHelper} baseline-gaps \
|
|
320
|
+
--provenance-map "{provenance_map_path}" \
|
|
321
|
+
--extraction "{run_dir}/extraction.json" \
|
|
322
|
+
--files "{run_dir}/modified-files.json" \
|
|
323
|
+
--source-root "{source_root}" \
|
|
324
|
+
-o "{run_dir}/baseline-gaps.json"
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
It writes `{"gaps": [{name, file, line, entry, export_type}], "unchecked": [{name, file, export_type}]}`: in `gaps[]` each entry of a modified file that no export of `{run_dir}/extraction.json` holds and whose file still declares it, at the line that declares it, by the verifier's rule (audit-skill's extraction snapshot applies the same one), and in `unchecked[]` each such entry no rule can check (its file neither Python nor TS/JS, a file the rule will not read, such as a test file or one with a symlink on its path, or a dotted module name), which step 2 reads by eye. Never add a gap or drop one by eye. On exit 1 (an input it cannot read, or a `skf-verify-provenance-completeness.py` beside it that cannot be loaded), no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-b"`, its stderr as `reason`): without it, every name the runner leaves out would read as deleted.
|
|
313
328
|
|
|
314
|
-
2. **What the recipes do not record.** Workers, in parallel batches (Pattern 4) when the list is long, read the modified and added files and each return ONLY `{"exports": [...]}`, with no prose and no fences. For each function export the runner found whose `params` it left null (it could not read the signature): `export_name` and `source_file` as it wrote them, with `params` (each parameter as the source writes it, `name: type`, the form the provenance map's `params` hold) and `return_type` (null when there is none), read at its `source_line`. For each export it could not find (a name `entry_point_diff.extraction_gaps[]` lists, a form Known Limitation #11 in `{extractionPatternsData}` lists, an export of a file `file_issues[]` names or that was read by eye): the same, plus `export_type`, `source_line`, `confidence: T1-low` and `extraction_method: source-read`. Write the union of their `exports` arrays to `{run_dir}/export-details.json`.
|
|
329
|
+
2. **What the recipes do not record.** Workers, in parallel batches (Pattern 4) when the list is long, read the modified and added files and each return ONLY `{"exports": [...]}`, with no prose and no fences. For each function export the runner found whose `params` it left null (it could not read the signature): `export_name` and `source_file` as it wrote them, with `params` (each parameter as the source writes it, `name: type`, the form the provenance map's `params` hold) and `return_type` (null when there is none), read at its `source_line`. For each export it could not find (a name `entry_point_diff.extraction_gaps[]` lists, a name `{run_dir}/baseline-gaps.json` lists in `gaps[]`, a form Known Limitation #11 in `{extractionPatternsData}` lists, an export of a file `file_issues[]` names or that was read by eye): the same, plus `export_type`, `source_line`, `confidence: T1-low` and `extraction_method: source-read`. Record a baseline gap at its `file` and `line` (`source_line`), with, as `export_type`, its `export_type` while the declaration there still has that kind, else the kind it has now (a `variable` now declared by a `def` is a `function`, which step 3 reports as a changed kind), and the declaration's kind when the gap gives none; `params` and `return_type` as the declaration writes them, null when it has none (a module, an alias or a variable). For each `unchecked[]` entry, read its file by eye and record it the same way, at the line that declares it, only when the file still declares it. Record each name and file once: when a file read by eye whole (at Quick tier, or when the runner could not run or left the file unread) holds a name a gap or an `unchecked[]` entry lists, that read's record stands and the gap adds none. Write the union of their `exports` arrays to `{run_dir}/export-details.json`.
|
|
315
330
|
|
|
316
331
|
3. **The diff.** Resolve `{structuralDiffHelper}` ← first existing path in `{structuralDiffProbeOrder}` and, from `{project-root}`, run:
|
|
317
332
|
|
|
@@ -368,15 +383,19 @@ The helper emits:
|
|
|
368
383
|
}
|
|
369
384
|
```
|
|
370
385
|
|
|
371
|
-
|
|
386
|
+
A map with no `file_entries` field, or a null one (a skill with no script, asset or promoted document, or a map written before the field existed), tracks no file: the comparison lists no row and the set difference below subtracts nothing.
|
|
387
|
+
|
|
388
|
+
The compare helper reports only tracked files; NEW_FILE detection (a file present in source but absent from the provenance map) is a set-difference, so it runs through a script rather than the prompt. **When `{brief_path}` does not exist** (a skill built without a brief, such as a quick skill), skip it: with no scope to keep to, the walk would read every script and asset of the repository as new. Add `new-files-not-checked: no skill brief, so no new script or asset was looked for` to `warnings[]`, write no `{run_dir}/new-files.json`, and leave `--new-files` out of §3's `build` and step 5's `apply`. Otherwise pipe the detector create-skill step 3 §4c runs (resolved via `detectScriptsAssetsProbeOrder`), which walks the whole source, into `{newFileDiffHelper}`, which keeps what the brief takes and subtracts what the map tracks:
|
|
372
389
|
|
|
373
390
|
```bash
|
|
374
391
|
uv run {detectScriptsAssetsHelper} detect "{source_root}" \
|
|
375
|
-
| uv run {newFileDiffHelper} "{provenance_map_path}" \
|
|
392
|
+
| uv run {newFileDiffHelper} "{provenance_map_path}" [--brief "{brief_path}"] \
|
|
376
393
|
> "{run_dir}/new-files.json"
|
|
377
394
|
```
|
|
378
395
|
|
|
379
|
-
|
|
396
|
+
Pass `--brief` as Category A does, `{brief_path}` as §1c left it (in a read-only run, its copy in `{run_dir}`). On exit 2 (the detector's JSON, the map or the brief it cannot read, named in its JSON `error` on stderr, which also catches a detector that printed nothing), no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-d"`, its `error` as `reason`).
|
|
397
|
+
|
|
398
|
+
It writes `{"new_files":[{source_file, kind}], "skipped_manual":[...], "already_tracked":[...], "tracked_code":[...], "out_of_scope":[...], "intent_none":[...], "stats":{...}}`. §3's `build` reads both files, typing each compare row by its `file_entries[]` row's `file_type` and each new file by its `kind`: sort no row by hand. `skipped_manual[]` are user-authored files under `scripts/[MANUAL]/` or `assets/[MANUAL]/`, preserved and not touched; `already_tracked[]` were handled by the compare above; `tracked_code[]` are code files the map's `entries[]` cite, which Categories A and B compare; `out_of_scope[]` are paths the brief's scope leaves out, by Category A's in-scope test; `intent_none[]` are paths of a kind whose `scripts_intent` or `assets_intent` is `none` (an absent or free-text intent detects). A path in these lists is no new file and no `stats` count, so step 6's report counts each of the three lists from this file, where it shows Category D: a brief scoped wrongly shows as many paths out of scope. A new file Category A lists too stays a new file: Category A lists every changed in-scope file, a genuine script included.
|
|
380
399
|
|
|
381
400
|
**Write the category JSON** to `{run_dir}/categories.json`, where §2.2 and §3 read it beside the helper files: the two flags, `degraded_mode` and `update_mode: "normal"`. Category C stays in the files its steps wrote, which §2.2, §3 and step 5's `apply` take as `--category-c` and `--ccc-pairs`:
|
|
382
401
|
|
|
@@ -488,13 +507,13 @@ The helper emits the unified manifest envelope:
|
|
|
488
507
|
"total_export_changes": N,
|
|
489
508
|
"per_file": [
|
|
490
509
|
{"file_path": "...", "status": "MODIFIED|ADDED|DELETED|MOVED",
|
|
491
|
-
"exports_affected": [{name, change_type, old_line, new_line}, ...]}
|
|
510
|
+
"exports_affected": [{name, change_type, old_line, new_line, old_file?}, ...]}
|
|
492
511
|
],
|
|
493
512
|
"category_d": {"scripts_modified": [...], ..., "docs_deleted": [...]}
|
|
494
513
|
}
|
|
495
514
|
```
|
|
496
515
|
|
|
497
|
-
`per_file` entries are sorted MODIFIED → ADDED → DELETED → MOVED, then alphabetically within each status group, so downstream stages can rely on stable ordering. MOVED entries include an extra `old_path` field. `category_d` lists the paths behind the `scripts_*`, `assets_*` and `docs_*` counts: a `docs_*` path is a tracked document, which only Category D detects, so a change to one alone is a change. `{run_dir}/change-manifest.json` is the change manifest: step 3, step 6 and step 5's `apply` read it there, so it survives a compacted context.
|
|
516
|
+
`per_file` entries are sorted MODIFIED → ADDED → DELETED → MOVED, then alphabetically within each status group, so downstream stages can rely on stable ordering. MOVED entries include an extra `old_path` field, and a `MOVED_EXPORT` across files an `old_file`, the file it left, where `apply` finds its entry. `category_d` lists the paths behind the `scripts_*`, `assets_*` and `docs_*` counts: a `docs_*` path is a tracked document, which only Category D detects, so a change to one alone is a change. `{run_dir}/change-manifest.json` is the change manifest: step 3, step 6 and step 5's `apply` read it there, so it survives a compacted context.
|
|
498
517
|
|
|
499
518
|
### 4. Check for No-Change Shortcut
|
|
500
519
|
|