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,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# /// script
|
|
3
3
|
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = ["pyyaml"]
|
|
4
5
|
# ///
|
|
5
6
|
"""Deterministic NEW_FILE detection for skf-update-skill (detect-changes Category D).
|
|
6
7
|
|
|
@@ -10,39 +11,70 @@ file that is present in source yet absent from the provenance map: a NEW_FILE.
|
|
|
10
11
|
Deriving that set is a set-difference plus a filter with exactly one correct
|
|
11
12
|
answer per input: take every `source_file` in the scripts/assets inventory
|
|
12
13
|
(emitted by `skf-detect-scripts-assets detect`), subtract the paths already in
|
|
13
|
-
`file_entries[].source_file`, and set aside any user-authored `[MANUAL]` path
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
`file_entries[].source_file`, and set aside any user-authored `[MANUAL]` path,
|
|
15
|
+
any path the map's `entries[]` cite (code Category A and B track) and, with
|
|
16
|
+
--brief, any path the skill brief leaves out. Doing that subtraction in the
|
|
17
|
+
prompt drifts across runs; doing it here is byte-stable.
|
|
16
18
|
|
|
17
19
|
Usage:
|
|
18
|
-
skf-detect-scripts-assets.py detect <source-root>
|
|
19
|
-
| skf-new-file-diff.py <provenance-map-path>
|
|
20
|
+
skf-detect-scripts-assets.py detect <source-root> \\
|
|
21
|
+
| skf-new-file-diff.py <provenance-map-path> [--brief <brief-path>]
|
|
20
22
|
|
|
21
23
|
Reads the detect JSON on stdin (needs `scripts_inventory[]` and
|
|
22
24
|
`assets_inventory[]`, each row carrying `source_file`). Reads the provenance
|
|
23
|
-
map at the path argument (canonical `{file_entries: [...]
|
|
24
|
-
array of entries).
|
|
25
|
+
map at the path argument (canonical `{file_entries: [...], entries: [...]}`
|
|
26
|
+
object, or a bare array of file entries). A map with no `file_entries`, or a
|
|
27
|
+
null one, tracks no file (a skill with no script, asset or promoted document,
|
|
28
|
+
or a map written before the field existed); a `file_entries` or `entries`
|
|
29
|
+
that is neither null nor an array is bad input.
|
|
30
|
+
|
|
31
|
+
--brief <brief-path> (the skill's skill-brief.yaml) keeps a detected path
|
|
32
|
+
only when the brief takes it, by update-skill Category A's in-scope test
|
|
33
|
+
(skf-classify-changed-files.py load_scope, loaded from the shared
|
|
34
|
+
scripts folder, the one brief-scope test the script and asset detector's
|
|
35
|
+
and audit-skill's --brief use too, with the glob rules of
|
|
36
|
+
skf-resolve-authoritative-files.py): each `scope.include` and
|
|
37
|
+
`scope.exclude` pattern normalized (forward slashes, no leading `./`),
|
|
38
|
+
and a path in scope when an include
|
|
39
|
+
matches it and no exclude does. With an empty `scope.include`, a path is in
|
|
40
|
+
scope when no exclude matches it and its extension is one a file the map's
|
|
41
|
+
`entries[]` cite has, or one the brief's `language` uses. The brief's
|
|
42
|
+
`scripts_intent` and `assets_intent` are honoured too: `none` drops every
|
|
43
|
+
detected path of that kind, and an absent or free-text intent detects.
|
|
44
|
+
An empty --brief (an unbound brief path) is a usage error, never an
|
|
45
|
+
unscoped run. Every path, the map's and the detector's, is compared
|
|
46
|
+
stripped, with forward slashes and no leading `./`.
|
|
25
47
|
|
|
26
48
|
Output JSON (stdout):
|
|
27
49
|
{
|
|
28
50
|
"new_files": [ {"source_file": "...", "kind": "script|asset"}, ... ],
|
|
29
51
|
"skipped_manual": [ "...", ... ], # under scripts/[MANUAL]/ or assets/[MANUAL]/
|
|
30
52
|
"already_tracked": [ "...", ... ], # present in file_entries
|
|
53
|
+
"tracked_code": [ "...", ... ], # cited by entries[]: code, not a new file
|
|
54
|
+
"out_of_scope": [ "...", ... ], # --brief: outside the brief's scope
|
|
55
|
+
"intent_none": [ "...", ... ], # --brief: a kind whose intent is none
|
|
31
56
|
"stats": {"inventory_total": N, "new": N,
|
|
32
57
|
"skipped_manual": N, "already_tracked": N}
|
|
33
58
|
}
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
Each detected path lands in exactly one list, tested in this order:
|
|
61
|
+
already_tracked, skipped_manual, tracked_code, out_of_scope, intent_none,
|
|
62
|
+
and new_files for the rest. All arrays are sorted by
|
|
63
|
+
source_file; new_files carries the kind of the inventory it came from
|
|
64
|
+
(scripts -> "script", assets -> "asset"). A path found in both inventories is
|
|
65
|
+
counted once, resolved as "script". The three set-aside lists (tracked_code,
|
|
66
|
+
out_of_scope, intent_none) are never counted in `stats`, whose
|
|
67
|
+
inventory_total still counts every detected path.
|
|
38
68
|
|
|
39
69
|
Exit codes:
|
|
40
70
|
0 success (and --help)
|
|
41
71
|
2 bad input (a usage error, unreadable/invalid stdin JSON, missing/invalid
|
|
42
|
-
provenance map
|
|
72
|
+
provenance map, a missing or unreadable brief, or a shared helper that
|
|
73
|
+
cannot be loaded)
|
|
43
74
|
"""
|
|
44
75
|
|
|
45
76
|
import argparse
|
|
77
|
+
import importlib.util
|
|
46
78
|
import json
|
|
47
79
|
import re
|
|
48
80
|
import sys
|
|
@@ -51,6 +83,13 @@ from pathlib import Path
|
|
|
51
83
|
# scripts/[MANUAL]/... or assets/[MANUAL]/... anywhere in the (posix) path
|
|
52
84
|
_MANUAL_RE = re.compile(r"(?:^|/)(?:scripts|assets)/\[MANUAL\]/")
|
|
53
85
|
|
|
86
|
+
# The shared helper that holds the scope test, installed beside this skill's
|
|
87
|
+
# folder: it loads skf-resolve-authoritative-files.py beside it.
|
|
88
|
+
SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
|
|
89
|
+
CLASSIFIER = "skf-classify-changed-files.py"
|
|
90
|
+
|
|
91
|
+
_SHARED: dict[str, object] = {}
|
|
92
|
+
|
|
54
93
|
|
|
55
94
|
def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type]
|
|
56
95
|
print(json.dumps({"error": msg}), file=sys.stderr)
|
|
@@ -58,37 +97,88 @@ def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type]
|
|
|
58
97
|
|
|
59
98
|
|
|
60
99
|
def _posix(p: str) -> str:
|
|
61
|
-
|
|
100
|
+
"""A path as skf-resolve-authoritative-files.py's normalize_rel_path
|
|
101
|
+
writes it: stripped, forward slashes, no leading `./`."""
|
|
102
|
+
p = p.strip().replace("\\", "/")
|
|
103
|
+
while p.startswith("./"):
|
|
104
|
+
p = p[2:]
|
|
105
|
+
return p
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _shared(filename: str):
|
|
109
|
+
"""A script of SHARED_SCRIPTS, loaded once; a failure to load it is bad input (exit 2)."""
|
|
110
|
+
module = _SHARED.get(filename)
|
|
111
|
+
if module is None:
|
|
112
|
+
path = SHARED_SCRIPTS / filename
|
|
113
|
+
try:
|
|
114
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
|
|
115
|
+
if spec is None or spec.loader is None or not path.is_file():
|
|
116
|
+
raise ImportError(f"{filename} not found in {SHARED_SCRIPTS}")
|
|
117
|
+
module = importlib.util.module_from_spec(spec)
|
|
118
|
+
spec.loader.exec_module(module)
|
|
119
|
+
except Exception as exc: # whatever the script raises, the run reports it
|
|
120
|
+
_fail(f"cannot load {filename} from {SHARED_SCRIPTS}: {exc}; re-install SKF")
|
|
121
|
+
_SHARED[filename] = module
|
|
122
|
+
return module
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _source_files(rows: list) -> set[str]:
|
|
126
|
+
found: set[str] = set()
|
|
127
|
+
for entry in rows:
|
|
128
|
+
if isinstance(entry, dict):
|
|
129
|
+
sf = entry.get("source_file")
|
|
130
|
+
if isinstance(sf, str) and _posix(sf):
|
|
131
|
+
found.add(_posix(sf))
|
|
132
|
+
return found
|
|
62
133
|
|
|
63
134
|
|
|
64
|
-
def
|
|
65
|
-
"""
|
|
135
|
+
def load_map_paths(provenance_path: Path) -> tuple[set[str], set[str]]:
|
|
136
|
+
"""(file_entries[].source_file, entries[].source_file) of the provenance map.
|
|
137
|
+
|
|
138
|
+
A missing or null `file_entries` (or `entries`) is an empty list; one
|
|
139
|
+
that is neither null nor an array is bad input. A bare array is the
|
|
140
|
+
file entries themselves.
|
|
141
|
+
"""
|
|
66
142
|
try:
|
|
67
143
|
data = json.loads(provenance_path.read_text(encoding="utf-8"))
|
|
68
144
|
except (json.JSONDecodeError, OSError) as exc:
|
|
69
145
|
_fail(f"failed to read provenance map {provenance_path}: {exc}")
|
|
70
146
|
|
|
71
147
|
if isinstance(data, list):
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
entries = data.get("file_entries")
|
|
75
|
-
if entries is None:
|
|
76
|
-
_fail(f"provenance map {provenance_path} has no `file_entries` field")
|
|
77
|
-
if not isinstance(entries, list):
|
|
78
|
-
_fail(f"`file_entries` in {provenance_path} is not an array")
|
|
79
|
-
else:
|
|
148
|
+
return _source_files(data), set()
|
|
149
|
+
if not isinstance(data, dict):
|
|
80
150
|
_fail(
|
|
81
151
|
f"provenance map {provenance_path} must be an object or array; "
|
|
82
152
|
f"got {type(data).__name__}"
|
|
83
153
|
)
|
|
154
|
+
rows = {}
|
|
155
|
+
for key in ("file_entries", "entries"):
|
|
156
|
+
value = data.get(key)
|
|
157
|
+
if value is None:
|
|
158
|
+
value = [] # a map that tracks no such row
|
|
159
|
+
if not isinstance(value, list):
|
|
160
|
+
_fail(f"`{key}` in {provenance_path} is not an array")
|
|
161
|
+
rows[key] = value
|
|
162
|
+
return _source_files(rows["file_entries"]), _source_files(rows["entries"])
|
|
84
163
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
164
|
+
|
|
165
|
+
def load_tracked_source_files(provenance_path: Path) -> set[str]:
|
|
166
|
+
"""Return the set of file_entries[].source_file already in the provenance map."""
|
|
167
|
+
return load_map_paths(provenance_path)[0]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_scope(brief_path: Path, cited: set[str]):
|
|
171
|
+
"""The brief's Scope (skf-classify-changed-files.py load_scope): its
|
|
172
|
+
takes(path) is Category A's in-scope test and its wants(kind) the
|
|
173
|
+
brief's intent. A brief it cannot find or read, or a shared helper it
|
|
174
|
+
cannot load, is bad input (exit 2)."""
|
|
175
|
+
classifier = _shared(CLASSIFIER)
|
|
176
|
+
try:
|
|
177
|
+
return classifier.load_scope(brief_path, cited)
|
|
178
|
+
except ValueError as exc: # a brief it cannot find or read
|
|
179
|
+
_fail(str(exc))
|
|
180
|
+
except Exception as exc: # an out-of-date classifier, or a resolver beside it that cannot load
|
|
181
|
+
_fail(f"cannot load the brief's scope test from {SHARED_SCRIPTS}: {exc}; re-install SKF")
|
|
92
182
|
|
|
93
183
|
|
|
94
184
|
def collect_inventory(detect: dict) -> list[tuple[str, str]]:
|
|
@@ -114,38 +204,53 @@ def collect_inventory(detect: dict) -> list[tuple[str, str]]:
|
|
|
114
204
|
return sorted(seen.items())
|
|
115
205
|
|
|
116
206
|
|
|
117
|
-
def diff(detect: dict, tracked: set[str]
|
|
207
|
+
def diff(detect: dict, tracked: set[str], cited: set[str] | frozenset = frozenset(),
|
|
208
|
+
scope=None) -> dict:
|
|
209
|
+
"""Sort the detected paths into the output lists; `scope` is load_scope's
|
|
210
|
+
Scope (--brief) or None."""
|
|
118
211
|
new_files: list[dict] = []
|
|
119
|
-
|
|
120
|
-
|
|
212
|
+
lists: dict[str, list[str]] = {name: [] for name in (
|
|
213
|
+
"skipped_manual", "already_tracked", "tracked_code", "out_of_scope", "intent_none")}
|
|
121
214
|
|
|
122
215
|
inventory = collect_inventory(detect)
|
|
123
216
|
for sf, kind in inventory:
|
|
124
217
|
if sf in tracked:
|
|
125
|
-
already_tracked.append(sf)
|
|
218
|
+
lists["already_tracked"].append(sf)
|
|
126
219
|
elif _MANUAL_RE.search(sf):
|
|
127
|
-
skipped_manual.append(sf)
|
|
220
|
+
lists["skipped_manual"].append(sf)
|
|
221
|
+
elif sf in cited:
|
|
222
|
+
lists["tracked_code"].append(sf)
|
|
223
|
+
elif scope is not None and not scope.takes(sf):
|
|
224
|
+
lists["out_of_scope"].append(sf)
|
|
225
|
+
elif scope is not None and not scope.wants(kind):
|
|
226
|
+
lists["intent_none"].append(sf)
|
|
128
227
|
else:
|
|
129
228
|
new_files.append({"source_file": sf, "kind": kind})
|
|
130
229
|
|
|
131
230
|
return {
|
|
132
231
|
"new_files": new_files,
|
|
133
|
-
"skipped_manual": sorted(skipped_manual),
|
|
134
|
-
"already_tracked": sorted(already_tracked),
|
|
232
|
+
"skipped_manual": sorted(lists["skipped_manual"]),
|
|
233
|
+
"already_tracked": sorted(lists["already_tracked"]),
|
|
234
|
+
"tracked_code": sorted(lists["tracked_code"]),
|
|
235
|
+
"out_of_scope": sorted(lists["out_of_scope"]),
|
|
236
|
+
"intent_none": sorted(lists["intent_none"]),
|
|
135
237
|
"stats": {
|
|
136
238
|
"inventory_total": len(inventory),
|
|
137
239
|
"new": len(new_files),
|
|
138
|
-
"skipped_manual": len(skipped_manual),
|
|
139
|
-
"already_tracked": len(already_tracked),
|
|
240
|
+
"skipped_manual": len(lists["skipped_manual"]),
|
|
241
|
+
"already_tracked": len(lists["already_tracked"]),
|
|
140
242
|
},
|
|
141
243
|
}
|
|
142
244
|
|
|
143
245
|
|
|
246
|
+
USAGE = "usage: skf-new-file-diff.py <provenance-map-path> [--brief <brief-path>] (detect JSON on stdin)"
|
|
247
|
+
|
|
248
|
+
|
|
144
249
|
class _JsonErrorParser(argparse.ArgumentParser):
|
|
145
250
|
"""Report a usage error as the JSON error and exit 2 every other bad input gets."""
|
|
146
251
|
|
|
147
252
|
def error(self, message: str) -> "NoReturn": # type: ignore[valid-type]
|
|
148
|
-
_fail(f"
|
|
253
|
+
_fail(f"{USAGE}: {message}")
|
|
149
254
|
|
|
150
255
|
|
|
151
256
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -153,17 +258,26 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
153
258
|
prog="skf-new-file-diff.py",
|
|
154
259
|
description=__doc__,
|
|
155
260
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
261
|
+
allow_abbrev=False,
|
|
156
262
|
)
|
|
157
263
|
parser.add_argument(
|
|
158
264
|
"provenance_map",
|
|
159
265
|
metavar="provenance-map-path",
|
|
160
266
|
help="the skill's provenance-map.json (the detect JSON is read on stdin)",
|
|
161
267
|
)
|
|
268
|
+
parser.add_argument(
|
|
269
|
+
"--brief",
|
|
270
|
+
metavar="brief-path",
|
|
271
|
+
help="the skill brief: keep only the paths its scope takes and its intents want",
|
|
272
|
+
)
|
|
162
273
|
return parser
|
|
163
274
|
|
|
164
275
|
|
|
165
276
|
def main(argv: list[str]) -> int:
|
|
166
|
-
|
|
277
|
+
parser = _build_parser()
|
|
278
|
+
args = parser.parse_args(argv[1:])
|
|
279
|
+
if args.brief is not None and not args.brief.strip():
|
|
280
|
+
parser.error("--brief is empty (an unbound brief path?): pass the brief, or leave --brief out")
|
|
167
281
|
|
|
168
282
|
provenance_path = Path(args.provenance_map)
|
|
169
283
|
if not provenance_path.exists():
|
|
@@ -179,8 +293,9 @@ def main(argv: list[str]) -> int:
|
|
|
179
293
|
if not isinstance(detect, dict):
|
|
180
294
|
_fail(f"detect JSON must be an object; got {type(detect).__name__}")
|
|
181
295
|
|
|
182
|
-
tracked =
|
|
183
|
-
|
|
296
|
+
tracked, cited = load_map_paths(provenance_path)
|
|
297
|
+
scope = load_scope(Path(args.brief), cited) if args.brief is not None else None
|
|
298
|
+
result = diff(detect, tracked, cited, scope)
|
|
184
299
|
print(json.dumps(result, indent=2))
|
|
185
300
|
return 0
|
|
186
301
|
|
|
@@ -84,10 +84,10 @@ When `{headless_mode}` is true, step 6 prints one `SKF_VERIFY_STACK_RESULT_JSON:
|
|
|
84
84
|
|
|
85
85
|
**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
86
|
|
|
87
|
-
4. **Resolve workflow customization.**
|
|
87
|
+
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
88
|
|
|
89
89
|
```bash
|
|
90
|
-
uv run {
|
|
90
|
+
uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
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 };
|