bmad-module-skill-forge 5.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/docs/_data/pinned.yaml +1 -1
- package/docs/agents.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/campaign.md +1 -1
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +9 -9
- package/docs/workflows.md +4 -4
- 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/skf-build-change-manifest.py +332 -54
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
- package/src/shared/scripts/skf-extract-public-api.py +33 -13
- package/src/shared/scripts/skf-parse-gaps.py +3 -2
- package/src/shared/scripts/skf-verify-provenance-completeness.py +108 -11
- package/src/skf-analyze-source/SKILL.md +1 -0
- 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 +2 -1
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -0
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-campaign/SKILL.md +1 -0
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +2 -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 +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +2 -2
- 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 +1 -0
- 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-drop-skill/SKILL.md +1 -0
- 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 +1 -0
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-forger/SKILL.md +1 -0
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -1
- 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 +2 -1
- 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 +1 -0
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +1 -0
- 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 +1 -0
- package/src/skf-test-skill/assets/output-section-formats.md +4 -4
- package/src/skf-test-skill/references/coherence-check.md +8 -6
- package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +30 -43
- package/src/skf-test-skill/references/init.md +1 -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/report.md +3 -3
- package/src/skf-test-skill/references/scoring-rules.md +6 -6
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +12 -8
- 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 +78 -13
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +345 -9
- package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
- package/src/skf-test-skill/scripts/score-signatures.py +939 -74
- package/src/skf-test-skill/scripts/validate-inventory.py +245 -29
- package/src/skf-update-skill/SKILL.md +1 -0
- package/src/skf-update-skill/references/gap-driven.md +28 -22
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +4 -4
- package/src/skf-update-skill/references/report.md +1 -1
- package/src/skf-update-skill/references/write.md +5 -4
- package/src/skf-verify-stack/SKILL.md +2 -1
|
@@ -103,16 +103,21 @@ Modes, from the statuses:
|
|
|
103
103
|
|
|
104
104
|
An unknown --language value adds an `unknown-language` warning.
|
|
105
105
|
|
|
106
|
-
Library use: the one brief-scope test for script and asset paths
|
|
107
|
-
skf-new-file-diff.py (update-skill
|
|
108
|
-
skf-detect-scripts-assets.py detect --brief
|
|
109
|
-
skf-compare-file-hashes.py compare --brief (audit-skill)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`
|
|
106
|
+
Library use: the one brief-scope test for script and asset paths, and for
|
|
107
|
+
the files a gap-driven repair reads. skf-new-file-diff.py (update-skill
|
|
108
|
+
Category D, --brief), skf-detect-scripts-assets.py detect --brief
|
|
109
|
+
(create-skill), skf-compare-file-hashes.py compare --brief (audit-skill)
|
|
110
|
+
and skf-build-change-manifest.py gap-records --brief (update-skill
|
|
111
|
+
gap-driven, which passes as `cited` each file its gaps cite or their
|
|
112
|
+
Remediation names and each `entries[].source_file` of the provenance
|
|
113
|
+
map) load this file by its path and call load_scope(brief_path, cited).
|
|
114
|
+
It returns a Scope: takes(path) is the in-scope test above
|
|
115
|
+
(`_in_scope`), with the brief's `scope.include` and `scope.exclude`
|
|
116
|
+
normalized as classify normalizes them and, for an empty
|
|
117
|
+
`scope.include`, the extensions of the `cited` paths (the provenance
|
|
118
|
+
map's `entries[].source_file` for the first three callers) and of the
|
|
119
|
+
brief's `language`, so a path with no extension is not taken; wants(kind)
|
|
120
|
+
is False for a `script` or `asset` whose
|
|
116
121
|
`scripts_intent` or `assets_intent` is `none` (an absent or free-text
|
|
117
122
|
intent detects). load_scope raises ValueError, `brief not found: <path>`
|
|
118
123
|
for a path that names no file and load_brief's message for a brief it
|
|
@@ -44,10 +44,17 @@ Subcommands:
|
|
|
44
44
|
that name: the envelope warns that the `_bmad/custom/`
|
|
45
45
|
overrides were not applied.
|
|
46
46
|
|
|
47
|
-
record Append
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
that
|
|
47
|
+
record Append to the run's sink: --decision reads one auto-decision
|
|
48
|
+
(a JSON object) on stdin; --warning takes one warning's text,
|
|
49
|
+
and a repeated --warning records each; --warnings-file reads a
|
|
50
|
+
JSON file that holds the warnings, an array of strings or an
|
|
51
|
+
object whose `warnings` is one (a helper's saved stdout, such
|
|
52
|
+
as skf-parse-gaps.py translate's), and records each, in order.
|
|
53
|
+
A blank or non-string warning, an unreadable file or an object
|
|
54
|
+
with no `warnings` list exits 1 and writes nothing; an empty
|
|
55
|
+
list writes nothing and exits 0. With --workflow a decision is
|
|
56
|
+
checked against that workflow's headless_decisions item schema
|
|
57
|
+
first.
|
|
51
58
|
|
|
52
59
|
validate Read an envelope (without the prefix) as JSON on stdin and
|
|
53
60
|
verify it against the workflow's schema (skf-setup's without
|
|
@@ -131,7 +138,8 @@ envelope field of the same name, except `result_contract` and
|
|
|
131
138
|
|
|
132
139
|
Run sink. Every run owns a folder, `_bmad-output/.skf-run/<workflow>-<run_id>/`,
|
|
133
140
|
passed as --run-dir. Each gate records its auto-decision there the moment
|
|
134
|
-
it decides, and each warning as it is raised, with `record
|
|
141
|
+
it decides, and each warning as it is raised, with `record` (one call can
|
|
142
|
+
record several warnings: each line is appended in that one write):
|
|
135
143
|
|
|
136
144
|
{run_dir}/headless-decisions.jsonl one decision object per line
|
|
137
145
|
{run_dir}/warnings.jsonl one warning string per line
|
|
@@ -299,6 +307,7 @@ the first of `uv run`, `python3`, `python` and `py -3` that works.
|
|
|
299
307
|
uv run skf-emit-result-envelope.py emit-blocked --phase "step 2:write-tools" --path "<path>" --reason "<message>" --stderr-from "{run_dir}/write-tools.err"
|
|
300
308
|
echo '{"skf_setup":{...}}' | uv run skf-emit-result-envelope.py validate
|
|
301
309
|
uv run skf-emit-result-envelope.py record --run-dir "{run_dir}" --warning "customization_resolver_unavailable: <reason>"
|
|
310
|
+
uv run skf-emit-result-envelope.py record --run-dir "{run_dir}" --warnings-file "{run_dir}/translate.json"
|
|
302
311
|
|
|
303
312
|
Exit codes:
|
|
304
313
|
0 success
|
|
@@ -1788,13 +1797,42 @@ def run_emit(workflow: str | None, *, halt: bool, label: str, run_dir: str | Non
|
|
|
1788
1797
|
print(envelope_line(schema, envelope), file=sys.stderr if target == "stderr" else sys.stdout)
|
|
1789
1798
|
|
|
1790
1799
|
|
|
1800
|
+
def _warnings_to_record(args) -> list[str]:
|
|
1801
|
+
"""The warnings one `record` call appends: each --warning, or the --warnings-file's list (a JSON array of
|
|
1802
|
+
strings, or an object whose `warnings` is one). A blank or non-string entry, an unreadable file or an
|
|
1803
|
+
object with no `warnings` list exits 1 before anything is written."""
|
|
1804
|
+
if args.warnings_file is None:
|
|
1805
|
+
entries, label = list(args.warning), "--warning"
|
|
1806
|
+
else:
|
|
1807
|
+
label = f"--warnings-file {args.warnings_file}"
|
|
1808
|
+
try:
|
|
1809
|
+
data = json.loads(Path(args.warnings_file).read_bytes().decode("utf-8-sig"))
|
|
1810
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
1811
|
+
_die(1, f"record: cannot read {label}: {getattr(e, 'strerror', None) or e}")
|
|
1812
|
+
if isinstance(data, dict):
|
|
1813
|
+
if not isinstance(data.get("warnings"), list):
|
|
1814
|
+
_die(1, f"record: {label} is an object with no `warnings` list")
|
|
1815
|
+
entries = data["warnings"]
|
|
1816
|
+
elif isinstance(data, list):
|
|
1817
|
+
entries = data
|
|
1818
|
+
else:
|
|
1819
|
+
_die(1, f"record: {label} holds neither a JSON array nor an object with `warnings`")
|
|
1820
|
+
out = []
|
|
1821
|
+
for i, entry in enumerate(entries):
|
|
1822
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
1823
|
+
where = "" if args.warnings_file is None and len(entries) == 1 else f" (entry {i})"
|
|
1824
|
+
_die(1, f"record: {label} must be a non-empty string{where}")
|
|
1825
|
+
out.append(entry.strip())
|
|
1826
|
+
return out
|
|
1827
|
+
|
|
1828
|
+
|
|
1791
1829
|
def cmd_record(args) -> None:
|
|
1792
|
-
"""Append one decision or
|
|
1830
|
+
"""Append one decision, or each warning given, to the run's sink, one JSON value per line."""
|
|
1793
1831
|
run_dir = Path(args.run_dir)
|
|
1794
|
-
if args.warning is not None:
|
|
1795
|
-
|
|
1796
|
-
if not
|
|
1797
|
-
|
|
1832
|
+
if args.warning is not None or args.warnings_file is not None:
|
|
1833
|
+
values = _warnings_to_record(args)
|
|
1834
|
+
if not values:
|
|
1835
|
+
return
|
|
1798
1836
|
name = SINK_WARNINGS
|
|
1799
1837
|
else:
|
|
1800
1838
|
value = _read_stdin_json("record")
|
|
@@ -1808,14 +1846,15 @@ def cmd_record(args) -> None:
|
|
|
1808
1846
|
errors = _validate_against_schema(value, item_schema)
|
|
1809
1847
|
if errors:
|
|
1810
1848
|
_die(1, f"record: the decision fails {schema_path.name}: {'; '.join(errors)}")
|
|
1849
|
+
values = [value]
|
|
1811
1850
|
name = SINK_DECISIONS
|
|
1812
1851
|
# ASCII only: a raw RAW_LINE_BREAKS character would split the line for a
|
|
1813
|
-
# reader that breaks lines there.
|
|
1814
|
-
|
|
1852
|
+
# reader that breaks lines there. Every line goes out in one write.
|
|
1853
|
+
lines = "".join(json.dumps(v, separators=(",", ":"), ensure_ascii=True) + "\n" for v in values)
|
|
1815
1854
|
try:
|
|
1816
1855
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1817
1856
|
with open(run_dir / name, "a", encoding="utf-8", newline="\n") as fh:
|
|
1818
|
-
fh.write(
|
|
1857
|
+
fh.write(lines)
|
|
1819
1858
|
except OSError as e:
|
|
1820
1859
|
_die(1, f"record: cannot append to {(run_dir / name).as_posix()}: {e.strerror or e}")
|
|
1821
1860
|
|
|
@@ -1975,7 +2014,7 @@ def main() -> None:
|
|
|
1975
2014
|
p_emit = sub.add_parser("emit", help="Build a run's envelope from its context payload (default).")
|
|
1976
2015
|
p_halt = sub.add_parser("emit-halt", help="Build a HARD HALT's envelope from its halt payload.")
|
|
1977
2016
|
p_blocked = sub.add_parser("emit-blocked", help="Emit skf-setup's status='blocked' envelope for early-halt paths.")
|
|
1978
|
-
p_record = sub.add_parser("record", help="Append an auto-decision or
|
|
2017
|
+
p_record = sub.add_parser("record", help="Append an auto-decision or warnings to the run's sink.")
|
|
1979
2018
|
p_validate = sub.add_parser("validate", help="Validate an envelope payload against the schema.")
|
|
1980
2019
|
p_render = sub.add_parser("render-report", help="Print skf-setup's FORGE STATUS banner for its payload.")
|
|
1981
2020
|
for p in (p_emit, p_halt):
|
|
@@ -1992,7 +2031,11 @@ def main() -> None:
|
|
|
1992
2031
|
help="Check a decision against this workflow's headless_decisions schema.")
|
|
1993
2032
|
kind = p_record.add_mutually_exclusive_group(required=True)
|
|
1994
2033
|
kind.add_argument("--decision", action="store_true", help="Read one decision object on stdin.")
|
|
1995
|
-
kind.add_argument("--warning", default=None,
|
|
2034
|
+
kind.add_argument("--warning", action="append", default=None,
|
|
2035
|
+
help="A warning's text (repeatable: each one is recorded).")
|
|
2036
|
+
kind.add_argument("--warnings-file", default=None, metavar="FILE",
|
|
2037
|
+
help="A JSON file of warnings: an array of strings, or an object whose `warnings` is one "
|
|
2038
|
+
"(a helper's saved stdout).")
|
|
1996
2039
|
p_validate.add_argument("--workflow", default=None,
|
|
1997
2040
|
help="Workflow folder name or schema stem (skf-setup when omitted).")
|
|
1998
2041
|
p_render.add_argument("--run-dir", default=None, help="The setup run folder that holds the staged helper outputs.")
|
|
@@ -385,7 +385,11 @@ the line cap, say), which the caller then reads by eye. An item of a local `expo
|
|
|
385
385
|
list takes instead the type of the top-level binding it names in its file:
|
|
386
386
|
what the recipes record for that declaration exported in place (a const
|
|
387
387
|
bound to an arrow function or a function expression is a function), or
|
|
388
|
-
`re-export` for an import.
|
|
388
|
+
`re-export` for an import. When that binding is declared in the file (not
|
|
389
|
+
an import or a destructured name), the item also gets `declaration_line`
|
|
390
|
+
and `declaration_signature_line`, its `$NAME` line and the source line
|
|
391
|
+
holding it (an overload set's first signature); its `source_line`,
|
|
392
|
+
`signature_line` and `citation` stay at the list.
|
|
389
393
|
|
|
390
394
|
Entry points. The export surface each package declares: every subpath of a
|
|
391
395
|
package.json `exports` map (committed .d.ts/.d.mts targets outside src/
|
|
@@ -551,7 +555,10 @@ Output JSON (stdout, or -o):
|
|
|
551
555
|
"citation": "[AST:<file>:L<line>]", "ast_recipe",
|
|
552
556
|
"ast_node_type", "export_type", "language",
|
|
553
557
|
"from": "<module>" | null, "from_file": "<file>" | null,
|
|
554
|
-
"confidence": "T1", "extraction_method": "ast-grep"
|
|
558
|
+
"confidence": "T1", "extraction_method": "ast-grep",
|
|
559
|
+
# only on a local export list item whose binding is
|
|
560
|
+
# declared in the file:
|
|
561
|
+
"declaration_line": N, "declaration_signature_line": "<line>"}, ...],
|
|
555
562
|
"aggregates": {"exports": N, "by_type": {"function": N, ...},
|
|
556
563
|
"t1": N, "t1_low": 0},
|
|
557
564
|
"entry_points": {"status": "barrel" | "empty-barrel" | "no-entry-point"
|
|
@@ -2009,18 +2016,22 @@ def _binding_rules(language: str) -> list[dict]:
|
|
|
2009
2016
|
return rules + [r for r in _trace_rules(language) if r["id"] in IMPORT_RULES]
|
|
2010
2017
|
|
|
2011
2018
|
|
|
2012
|
-
def
|
|
2013
|
-
"""(file, name) -> the
|
|
2014
|
-
|
|
2019
|
+
def _bindings(matches: list[dict]) -> dict[tuple[str, str], dict]:
|
|
2020
|
+
"""(file, name) -> the top-level binding `name` has in that file, from
|
|
2021
|
+
the matches of _binding_rules: its form, the export type BINDING_TYPES
|
|
2022
|
+
gives it (`type`), and the `line` and `signature_line` of its match. Of
|
|
2023
|
+
several matches of the winning form (an overload set's signatures), the
|
|
2024
|
+
lowest line wins, the line merge_exports keeps for a name."""
|
|
2015
2025
|
rank = list(BINDING_TYPES)
|
|
2016
|
-
best: dict[tuple[str, str],
|
|
2026
|
+
best: dict[tuple[str, str], tuple[tuple[int, int], dict]] = {}
|
|
2017
2027
|
for m in matches:
|
|
2018
2028
|
form = "import" if m["rule"] in IMPORT_RULES else m["rule"].removeprefix(f"{BINDING_RULE}-")
|
|
2019
2029
|
if form in BINDING_TYPES and m["name"]:
|
|
2020
|
-
key = (m["file"], m["name"])
|
|
2021
|
-
if key not in best or
|
|
2022
|
-
best[key] = form
|
|
2023
|
-
|
|
2030
|
+
key, order = (m["file"], m["name"]), (rank.index(form), m["line"])
|
|
2031
|
+
if key not in best or order < best[key][0]:
|
|
2032
|
+
best[key] = (order, {"form": form, "type": BINDING_TYPES[form], "line": m["line"],
|
|
2033
|
+
"signature_line": m["signature_line"]})
|
|
2034
|
+
return {key: binding for key, (_, binding) in best.items()}
|
|
2024
2035
|
|
|
2025
2036
|
|
|
2026
2037
|
# --------------------------------------------------------------------------
|
|
@@ -2743,7 +2754,10 @@ def _type_list_items(exe: str, root: Path, folder: Path, matches: list[dict], ex
|
|
|
2743
2754
|
export_specifier with no `from`) the type of the top-level binding it
|
|
2744
2755
|
names in its file, as BINDING_TYPES has it; the recipe's own
|
|
2745
2756
|
export_type stays when no such binding has the name (a destructured
|
|
2746
|
-
one).
|
|
2757
|
+
one). A binding declared in the file (not an import) also gives the
|
|
2758
|
+
item `declaration_line` and `declaration_signature_line`, its line and
|
|
2759
|
+
source line; the item's own `source_line`, `signature_line` and
|
|
2760
|
+
citation stay at the list."""
|
|
2747
2761
|
items = [e for e in exports if e["ast_node_type"] == "export_specifier" and e["from"] is None]
|
|
2748
2762
|
if not items:
|
|
2749
2763
|
return
|
|
@@ -2755,10 +2769,16 @@ def _type_list_items(exe: str, root: Path, folder: Path, matches: list[dict], ex
|
|
|
2755
2769
|
languages = sorted({lang for f in files for lang in _rule_languages(_file_language(f) or "")}
|
|
2756
2770
|
& set(RECIPE_LANGUAGES))
|
|
2757
2771
|
rules = [rule for language in languages for rule in _binding_rules(language)]
|
|
2758
|
-
|
|
2772
|
+
bindings = _bindings(_scan(exe, root, folder, rules, files, errors, clock))
|
|
2759
2773
|
for e in items:
|
|
2760
2774
|
name = local.get((e["source_file"], e["ast_recipe"], e["export_name"]), e["export_name"])
|
|
2761
|
-
|
|
2775
|
+
binding = bindings.get((e["source_file"], name))
|
|
2776
|
+
if binding is None:
|
|
2777
|
+
continue
|
|
2778
|
+
e["export_type"] = binding["type"]
|
|
2779
|
+
if binding["form"] != "import":
|
|
2780
|
+
e["declaration_line"] = binding["line"]
|
|
2781
|
+
e["declaration_signature_line"] = binding["signature_line"]
|
|
2762
2782
|
|
|
2763
2783
|
|
|
2764
2784
|
# --------------------------------------------------------------------------
|
|
@@ -1021,8 +1021,9 @@ NOT_ROUTED_REASONS = {
|
|
|
1021
1021
|
"`scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the "
|
|
1022
1022
|
"named-export count leaves out need no fix"),
|
|
1023
1023
|
"provenance-unverified": (
|
|
1024
|
-
"the line-check rules could not verify the line,
|
|
1025
|
-
"
|
|
1024
|
+
"the line-check rules could not verify the line, or the recorded line is not a number, so a person checks "
|
|
1025
|
+
"by hand which line defines the export and, when it is not the recorded one, sets its provenance "
|
|
1026
|
+
"`source_line` to that line"),
|
|
1026
1027
|
"denominator-inflation": (
|
|
1027
1028
|
"it concerns the brief's scope and the count design, which a person decides in the skill brief, as the "
|
|
1028
1029
|
"gap's remediation says"),
|
|
@@ -71,8 +71,15 @@ not read as a missing public export nor as an orphan.
|
|
|
71
71
|
Definition lines: after a citation resolves (the file exists and the line
|
|
72
72
|
is in range, so the older reasons win), the line must define the entry's
|
|
73
73
|
export. NAME is the raw `export_name` (not canonicalized); for a dotted
|
|
74
|
-
name (`App.update`) it is the last segment.
|
|
75
|
-
`
|
|
74
|
+
name (`App.update`) it is the last segment. A bookkeeping variant
|
|
75
|
+
(`X_def`, `X_exact`, `a11y_X`, test-skill's Source Access Protocol fold
|
|
76
|
+
without `--fold-prefix` or `--keep`) that the cited file never names (no
|
|
77
|
+
whole identifier in its text) is checked against its base `X` instead,
|
|
78
|
+
its finding carries `folded_to`, and `summary.line_check_folded` counts
|
|
79
|
+
it; the raw name always comes first, so an export really named
|
|
80
|
+
`parse_exact`, or bound in a shape the rules do not cover, is judged as
|
|
81
|
+
written. classify-stale never folds. The rules
|
|
82
|
+
follow the `source_file` extension:
|
|
76
83
|
|
|
77
84
|
- Python (`.py`, `.pyi`): the `def` / `async def` / `class NAME` line
|
|
78
85
|
itself (a decorator line above it does not count); an assignment
|
|
@@ -262,7 +269,9 @@ Subcommands:
|
|
|
262
269
|
"source_line": <int|null>,
|
|
263
270
|
"reason": "file-missing" | "line-out-of-bounds" | "line-invalid"
|
|
264
271
|
| "line-not-definition",
|
|
265
|
-
"definition_lines": [<int>, ...]
|
|
272
|
+
"definition_lines": [<int>, ...], # line-not-definition only
|
|
273
|
+
"folded_to": "<base name>"}, # line-not-definition only,
|
|
274
|
+
# a variant checked as its base
|
|
266
275
|
...
|
|
267
276
|
],
|
|
268
277
|
"citations": [
|
|
@@ -302,6 +311,7 @@ Subcommands:
|
|
|
302
311
|
"stale_count": <int>,
|
|
303
312
|
"citations_checked": <int>, # entries whose citation was resolved
|
|
304
313
|
"line_check_skipped": <int>, # resolved entries with no line rule
|
|
314
|
+
"line_check_folded": <int>, # variants checked against their base
|
|
305
315
|
"stale_check": "checked" | "skipped-no-source-root",
|
|
306
316
|
"skill_citations_scanned": <int>, # [AST:]/[SRC:] with a line
|
|
307
317
|
"skill_citations_matched": <int>, # of those, citing an entry
|
|
@@ -413,12 +423,15 @@ Subcommands:
|
|
|
413
423
|
| "skipped-language",
|
|
414
424
|
"definition_lines": [<int>, ...] | null, # checked only
|
|
415
425
|
"source_line": <int> | null, # --line
|
|
416
|
-
"line_is_definition": <bool> | null
|
|
426
|
+
"line_is_definition": <bool> | null, # --line, checked only
|
|
427
|
+
"folded_to": "<base name>" # a folded variant only
|
|
417
428
|
}
|
|
418
429
|
|
|
419
430
|
With `--line`, a recorded line that is an indented match the column-0
|
|
420
431
|
rule dropped joins `definition_lines`, and one that misses a Python
|
|
421
|
-
`@overload` group gives its first line alone, as in `verify`.
|
|
432
|
+
`@overload` group gives its first line alone, as in `verify`. A
|
|
433
|
+
bookkeeping variant the file never names is checked against its base,
|
|
434
|
+
as in `verify`, and only then does the key `folded_to` appear.
|
|
422
435
|
|
|
423
436
|
classify-stale --names <coverage.json> --provenance <provenance-map.json>
|
|
424
437
|
--source-root <path> [--inventory <inventory.json>]
|
|
@@ -428,7 +441,8 @@ Subcommands:
|
|
|
428
441
|
documented name the enumerated source surface lacks): the
|
|
429
442
|
definition-lines rules above, run for every provenance entry whose raw
|
|
430
443
|
`export_name` is the name, at its `source_file` and `source_line`, with
|
|
431
|
-
its `export_type
|
|
444
|
+
its `export_type`, and with no bookkeeping-variant fold (the name is
|
|
445
|
+
judged as written). `--names` holds reconcile-coverage.py's result (its
|
|
432
446
|
`stale` names) or a JSON array of names. `--inventory` holds the
|
|
433
447
|
documented inventory, validate-inventory.py's `--output` file or its
|
|
434
448
|
whole result: the `kind` it gives each name decides the module rule
|
|
@@ -1046,6 +1060,28 @@ def definition_name(export_name: str) -> str:
|
|
|
1046
1060
|
return export_name.rsplit(".", 1)[-1]
|
|
1047
1061
|
|
|
1048
1062
|
|
|
1063
|
+
# Bookkeeping variants folded to their base name, in rule order: test-skill's
|
|
1064
|
+
# Source Access Protocol defines the fold (`X_def`, `X_exact`, `a11y_X` -> `X`).
|
|
1065
|
+
# Keep identical to FOLD_SUFFIXES and FOLD_PREFIX in load-coverage-inputs.py.
|
|
1066
|
+
FOLD_SUFFIXES = ("_def", "_exact")
|
|
1067
|
+
FOLD_PREFIX = "a11y_"
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def fold_base(export_name: str) -> str | None:
|
|
1071
|
+
"""The base name a bookkeeping variant folds to, None for a name that is
|
|
1072
|
+
no variant: a suffix in FOLD_SUFFIXES first, else FOLD_PREFIX, never the
|
|
1073
|
+
whole name (`_def` alone is no variant). The rules of
|
|
1074
|
+
load-coverage-inputs.py `fold_names`, without `--fold-prefix` or
|
|
1075
|
+
`--keep`: the line check tries the raw name first, so a real export
|
|
1076
|
+
named like a variant is never folded."""
|
|
1077
|
+
for suffix in FOLD_SUFFIXES:
|
|
1078
|
+
if export_name.endswith(suffix) and len(export_name) > len(suffix):
|
|
1079
|
+
return export_name[: -len(suffix)]
|
|
1080
|
+
if export_name.startswith(FOLD_PREFIX) and len(export_name) > len(FOLD_PREFIX):
|
|
1081
|
+
return export_name[len(FOLD_PREFIX):]
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
|
|
1049
1085
|
def _read_lines(path: Path) -> list[str] | None:
|
|
1050
1086
|
"""Read a text file as lines numbered like `check_citation` counts them
|
|
1051
1087
|
(universal newlines; a UTF-8 BOM is dropped so it never hides line 1).
|
|
@@ -2135,6 +2171,42 @@ def _definition_matches(
|
|
|
2135
2171
|
return _line_matches(suffix, path, lines, export_name, cache, declarations_only)
|
|
2136
2172
|
|
|
2137
2173
|
|
|
2174
|
+
def _folded_matches(
|
|
2175
|
+
source_file: str,
|
|
2176
|
+
export_name: str,
|
|
2177
|
+
source_root: Path,
|
|
2178
|
+
cache: _SourceCache | None = None,
|
|
2179
|
+
) -> tuple[tuple[list[int], set[int]] | None, str | None]:
|
|
2180
|
+
"""(`_definition_matches` of the name the line check judges, the base
|
|
2181
|
+
name a bookkeeping variant was folded to, else None).
|
|
2182
|
+
|
|
2183
|
+
The raw name comes first. Only when the file never names it (no whole
|
|
2184
|
+
identifier in its text, so no shape the rules miss can define it) and
|
|
2185
|
+
`fold_base` gives a base that some line matches are the base's matches
|
|
2186
|
+
returned, with the base; otherwise the raw name's, with None. So a real
|
|
2187
|
+
export named like a variant (`parse_exact` defined as itself, or bound
|
|
2188
|
+
in a shape the rules do not cover) is judged as written, and
|
|
2189
|
+
classify-stale, which never folds, does not call this.
|
|
2190
|
+
"""
|
|
2191
|
+
cache = cache if cache is not None else _SourceCache()
|
|
2192
|
+
matches = _definition_matches(source_file, export_name, source_root, cache)
|
|
2193
|
+
if matches is None or matches[1]:
|
|
2194
|
+
return matches, None
|
|
2195
|
+
base = fold_base(export_name)
|
|
2196
|
+
if base is None:
|
|
2197
|
+
return matches, None
|
|
2198
|
+
# the variant's own identifiers: `mockChannel_def`, or `a11y_App` of `a11y_App.update`
|
|
2199
|
+
own = sorted(set(export_name.split(".")) - set(base.split(".")))
|
|
2200
|
+
named = re.compile(r"(?<![\w$])(?:" + "|".join(map(re.escape, own)) + r")(?![\w$])")
|
|
2201
|
+
lines = cache.lines((source_root / source_file.strip().replace("\\", "/")).resolve())
|
|
2202
|
+
if not own or lines is None or any(named.search(text) for text in lines):
|
|
2203
|
+
return matches, None
|
|
2204
|
+
folded = _definition_matches(source_file, base, source_root, cache)
|
|
2205
|
+
if folded is None or not folded[1]:
|
|
2206
|
+
return matches, None
|
|
2207
|
+
return folded, base
|
|
2208
|
+
|
|
2209
|
+
|
|
2138
2210
|
def _line_matches(
|
|
2139
2211
|
suffix: str,
|
|
2140
2212
|
path: Path,
|
|
@@ -2223,6 +2295,7 @@ def definition_lines_report(
|
|
|
2223
2295
|
source_root: Path,
|
|
2224
2296
|
line: int | None = None,
|
|
2225
2297
|
export_type: str | None = None,
|
|
2298
|
+
fold: bool = True,
|
|
2226
2299
|
) -> dict:
|
|
2227
2300
|
"""What `verify` would say about one entry's line (the definition-lines
|
|
2228
2301
|
subcommand). Raises ValueError when the file exists but cannot be read.
|
|
@@ -2234,6 +2307,12 @@ def definition_lines_report(
|
|
|
2234
2307
|
(`_missed_definition_lines`): an indented match the column-0 rule
|
|
2235
2308
|
dropped joins `definition_lines`, so the answer carries several lines,
|
|
2236
2309
|
and a Python `@overload` group gives its first line alone.
|
|
2310
|
+
|
|
2311
|
+
With `fold` (the default, as `verify` checks), a bookkeeping variant the
|
|
2312
|
+
file never names is checked against its base name (`_folded_matches`),
|
|
2313
|
+
and the report gains `folded_to`, the base, only when it folded.
|
|
2314
|
+
classify-stale passes `fold=False`: the fabricated-signature test judges
|
|
2315
|
+
the documented name as written.
|
|
2237
2316
|
"""
|
|
2238
2317
|
report: dict = {
|
|
2239
2318
|
"source_file": source_file,
|
|
@@ -2253,15 +2332,21 @@ def definition_lines_report(
|
|
|
2253
2332
|
report["line_check"] = "skipped-export-type" if covered else "skipped-language"
|
|
2254
2333
|
return report
|
|
2255
2334
|
cache = _SourceCache()
|
|
2256
|
-
|
|
2335
|
+
if fold:
|
|
2336
|
+
matches, folded_to = _folded_matches(source_file, export_name, source_root, cache)
|
|
2337
|
+
else:
|
|
2338
|
+
matches, folded_to = _definition_matches(source_file, export_name, source_root, cache), None
|
|
2257
2339
|
if matches is None:
|
|
2258
2340
|
raise ValueError(f"failed to read source file {source_root / rel}")
|
|
2341
|
+
if folded_to is not None:
|
|
2342
|
+
report["folded_to"] = folded_to
|
|
2343
|
+
judged = folded_to or export_name
|
|
2259
2344
|
defs, every = matches
|
|
2260
2345
|
if line is not None:
|
|
2261
2346
|
report["line_is_definition"] = line in defs
|
|
2262
2347
|
if line not in defs:
|
|
2263
2348
|
defs = _missed_definition_lines(
|
|
2264
|
-
source_file,
|
|
2349
|
+
source_file, judged, source_root, cache, line, defs, every
|
|
2265
2350
|
)
|
|
2266
2351
|
report["definition_lines"] = defs
|
|
2267
2352
|
return report
|
|
@@ -2307,7 +2392,10 @@ def _entry_citation(entry: dict) -> str | None:
|
|
|
2307
2392
|
def _stale_line_check(
|
|
2308
2393
|
entry: dict, name: str, source_root: Path
|
|
2309
2394
|
) -> tuple[str, list[int] | None]:
|
|
2310
|
-
"""(line_check, definition_lines) of one provenance entry of a stale name.
|
|
2395
|
+
"""(line_check, definition_lines) of one provenance entry of a stale name.
|
|
2396
|
+
|
|
2397
|
+
The documented name is judged as written, with no bookkeeping-variant
|
|
2398
|
+
fold: a variant the cited file never defines stays fabricated."""
|
|
2311
2399
|
source_file = _entry_file(entry)
|
|
2312
2400
|
if source_file is None:
|
|
2313
2401
|
# no file to check: it neither proves the name fabricated nor clears it
|
|
@@ -2321,6 +2409,7 @@ def _stale_line_check(
|
|
|
2321
2409
|
source_root,
|
|
2322
2410
|
line if isinstance(line, int) else None,
|
|
2323
2411
|
export_type if isinstance(export_type, str) else None,
|
|
2412
|
+
fold=False,
|
|
2324
2413
|
)
|
|
2325
2414
|
except ValueError:
|
|
2326
2415
|
# the file exists but cannot be read: nothing proves it
|
|
@@ -3677,6 +3766,7 @@ def verify(
|
|
|
3677
3766
|
stale: list[dict] = []
|
|
3678
3767
|
citations_checked = 0
|
|
3679
3768
|
line_check_skipped = 0
|
|
3769
|
+
line_check_folded = 0
|
|
3680
3770
|
if source_root is not None:
|
|
3681
3771
|
cache = _SourceCache()
|
|
3682
3772
|
entries = prov.get("entries")
|
|
@@ -3717,10 +3807,14 @@ def verify(
|
|
|
3717
3807
|
if not line_rule_applies(sf, entry.get("export_type")):
|
|
3718
3808
|
line_check_skipped += 1
|
|
3719
3809
|
continue
|
|
3720
|
-
|
|
3810
|
+
# A bookkeeping variant the file never names is checked
|
|
3811
|
+
# against its base name (`folded_to`).
|
|
3812
|
+
matches, folded_to = _folded_matches(sf, name, source_root, cache)
|
|
3721
3813
|
if matches is None:
|
|
3722
3814
|
line_check_skipped += 1
|
|
3723
3815
|
continue
|
|
3816
|
+
if folded_to is not None:
|
|
3817
|
+
line_check_folded += 1
|
|
3724
3818
|
defs, every = matches
|
|
3725
3819
|
if line not in defs:
|
|
3726
3820
|
# An indented match the column-0 rule dropped joins
|
|
@@ -3728,8 +3822,10 @@ def verify(
|
|
|
3728
3822
|
# group gives its first line alone, the move target.
|
|
3729
3823
|
item["reason"] = STALE_LINE_NOT_DEFINITION
|
|
3730
3824
|
item["definition_lines"] = _missed_definition_lines(
|
|
3731
|
-
sf, name, source_root, cache, line, defs, every
|
|
3825
|
+
sf, folded_to or name, source_root, cache, line, defs, every
|
|
3732
3826
|
)
|
|
3827
|
+
if folded_to is not None:
|
|
3828
|
+
item["folded_to"] = folded_to
|
|
3733
3829
|
stale.append(item)
|
|
3734
3830
|
stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
|
|
3735
3831
|
|
|
@@ -3771,6 +3867,7 @@ def verify(
|
|
|
3771
3867
|
"stale_count": len(stale),
|
|
3772
3868
|
"citations_checked": citations_checked,
|
|
3773
3869
|
"line_check_skipped": line_check_skipped,
|
|
3870
|
+
"line_check_folded": line_check_folded,
|
|
3774
3871
|
"stale_check": "checked"
|
|
3775
3872
|
if source_root is not None
|
|
3776
3873
|
else "skipped-no-source-root",
|
|
@@ -28,6 +28,7 @@ You are a source code analyst and decomposition architect collaborating with a d
|
|
|
28
28
|
These rules apply to every step in this workflow:
|
|
29
29
|
|
|
30
30
|
- Only load one step file at a time — never preload future steps
|
|
31
|
+
- 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.
|
|
31
32
|
- Always communicate in `{communication_language}`; the text persisted into `skill-brief.yaml` (each recommendation's `description` and `scope.notes`) is written in `{document_output_language}`
|
|
32
33
|
- If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action, and record each one in the run sink as the gate says
|
|
33
34
|
|
|
@@ -287,7 +287,7 @@ The shared emitter writes the result files and prints the envelope; this section
|
|
|
287
287
|
uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
|
|
288
288
|
```
|
|
289
289
|
|
|
290
|
-
The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy (the stable path
|
|
290
|
+
The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path; a caller reads its own run's `SKF_ANALYZE_RESULT_JSON` line), and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim: it is the success signal of a headless run. A result file that could not be written leaves the line's `result_path` null and a `result_file_write_failed` warning in it, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `generate-briefs:9`, path `{forge_data_folder}`, with `brief_paths` and `unit_counts` from the payload): "The result contract could not be written: {its message}."
|
|
291
291
|
|
|
292
292
|
### 9b. On-Complete Hook (pipeline integration)
|
|
293
293
|
|
|
@@ -4,7 +4,7 @@ Canonical headless/pipeline contract for skf-analyze-source. The shared emitter,
|
|
|
4
4
|
|
|
5
5
|
## Result Envelope
|
|
6
6
|
|
|
7
|
-
The step that ends a run (step 6 §9, step 1a §9, or the docs-only branch §5) prints the envelope on **stdout** before chaining to step 7, and displays it when `{headless_mode}` is true. Every HARD HALT of a headless run prints it on **stderr** with `status: "error"`, so an automator branches on the failure class without grepping message text.
|
|
7
|
+
The step that ends a run (step 6 §9, step 1a §9, or the docs-only branch §5) prints the envelope on **stdout** before chaining to step 7, and displays it when `{headless_mode}` is true. Every HARD HALT of a headless run prints it on **stderr** with `status: "error"`, so an automator branches on the failure class without grepping message text. A caller reads the run's own `SKF_ANALYZE_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 `analyze-source-result-latest.json` may then describe an earlier run, so it is never read in its place.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
SKF_ANALYZE_RESULT_JSON: {"status":"success|error|redirect|skipped","report_path":"…|null","brief_paths":["…"],"unit_counts":{"confirmed":N,"skipped":N,"maybe":N},"exit_code":0,"halt_reason":null,"mode":"interactive|auto","result_path":"…|null"}
|
|
@@ -28,6 +28,7 @@ You are a skill auditor in Ferris Audit mode: a deterministic drift-detection wo
|
|
|
28
28
|
|
|
29
29
|
- Never fabricate findings — all data must trace to source code with file:line citations
|
|
30
30
|
- Only load one step file at a time — never preload future steps
|
|
31
|
+
- 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.
|
|
31
32
|
- Update `stepsCompleted` in output file frontmatter before loading next step
|
|
32
33
|
- Always communicate in `{communication_language}`
|
|
33
34
|
- If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action and log each auto-decision; step 1's gates (manifest-vs-link, upstream-drift, baseline confirm) also record theirs in the run sink as they decide
|
|
@@ -59,7 +60,7 @@ Stage 1b returns to init.md at §5b's **Record for report**, then §6. Step 1 §
|
|
|
59
60
|
| **Inputs** | `skill_name` [required], `skill_path` [optional: the skill folder, bypassing manifest/symlink resolution], `tier_override` [optional: Quick / Forge / Forge+ / Deep, in place of the detected tier], `upstream_drift_choice` [optional: C / S / X; answers the upstream-drift gate in references/upstream-checkout.md; C, auditing the upstream ref from a private tree, when unset] |
|
|
60
61
|
| **Gates** | step 1: Manifest-vs-Symlink Gate [N/M/X] · Upstream-Drift Gate [C/S/X] (references/upstream-checkout.md, only when upstream moved) · Baseline Confirm Gate [C/X] (only on a doubt: the forge tier is below the compile tier, or the provenance map is more than 90 days old) |
|
|
61
62
|
| **Outputs** | The drift report, the JSON it was built from and the result contract, in `{forge_version}/` (the audited version's folder): `references/headless-contract.md` names each file |
|
|
62
|
-
| **Headless** | Every gate takes its default action, a pre-supplied `upstream_drift_choice` answers the upstream-drift gate, `tier_override` sets the tier (step 1 §2), and each gate's decision lands in the envelope's `headless_decisions` |
|
|
63
|
+
| **Headless** | Every gate takes its default action, a pre-supplied `upstream_drift_choice` answers the upstream-drift gate, `tier_override` sets the tier (step 1 §2), and each gate's decision lands in the envelope's `headless_decisions`. A caller reads the run's own `SKF_AUDIT_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 `audit-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
|
|
63
64
|
| **Exit codes** | `references/headless-contract.md`: each halt class's exit code and `halt_reason`, and the `SKF_AUDIT_RESULT_JSON` envelope |
|
|
64
65
|
|
|
65
66
|
## On Activation
|
|
@@ -196,7 +196,7 @@ When On Activation step 3 fell back to the bundled `customize.toml`, add `"custo
|
|
|
196
196
|
uv run {emitEnvelopeHelper} emit --workflow skf-audit-skill --run-dir "{run_dir}" --result-dir "{forge_version}" < "{run_dir}/result-context.json"
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
It writes `{forge_version}/audit-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and its `audit-skill-result-latest.json` copy (
|
|
199
|
+
It writes `{forge_version}/audit-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and its `audit-skill-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path: a copy, not a symlink; a caller reads its own run's `SKF_AUDIT_RESULT_JSON` line), stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both, and prints the `SKF_AUDIT_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, bind `{result_envelope_line}` ← the line and do not display it here: the shared health check displays it verbatim as the run's last line, so a `claude -p` caller reads `drift_score`, `report_path`, `next_workflow`, `upstream_moved` and `upstream_ref` from the final message. A result file that could not be written leaves `result_path` null and a `result_file_write_failed` warning in the line, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HALT with **exit 4**, `halt_reason: "write-failed"`, phase `report:result-contract`: the run's result contract could not be written.
|
|
200
200
|
|
|
201
201
|
**Hard-halt envelope (headless only).** Every hard halt prints its envelope through the emitter on **stderr** with `status: "error"` and the `exit_code` / `halt_reason` for its failure class, as each step's **Halt envelope** paragraph shows: it is the only failure signal a wrapping pipeline receives. `drift_score` carries its last known value (`null` if classification never ran); `report_path` is `null` when the report write failed.
|
|
202
202
|
|
|
@@ -32,6 +32,7 @@ You are a skill scoping architect collaborating with a developer who wants to cr
|
|
|
32
32
|
These rules apply to every step in this workflow:
|
|
33
33
|
|
|
34
34
|
- Only load one step file at a time — never preload future steps
|
|
35
|
+
- 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.
|
|
35
36
|
- **Lazy-load references and assets:** `references/*.md` and `assets/*.md` files are loaded inside the section that needs them, not at step entry. If a section is skipped (e.g. `version-resolution.md` when step 2's extractor ran, `scope-templates.md` for the `docs-only` branch that bypasses §2c), do not load that file. Each unnecessary load costs context (~5-10 KB per reference) and biases the LLM toward consulting material the current path does not need.
|
|
36
37
|
- Always communicate in `{communication_language}` (the language for user-facing prose). Written artifact text — the `description`, `notes`, and other free-form fields persisted into `skill-brief.yaml` — is in `{document_output_language}`; per-step rules call this out where it applies (see step 5). The two values may be the same.
|
|
37
38
|
- If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action and log each auto-decision
|
|
@@ -9,8 +9,8 @@ Full argument set, gate map, exit codes, and headless result envelope for `skf-b
|
|
|
9
9
|
| **Inputs** | The headless arguments, each with its default and the halt it can raise, are the Arguments table of `references/headless-args.md`, which `skf-validate-brief-inputs.py` enforces: `target_repo` and `skill_name` are required, every other one is optional. Two of them route the run elsewhere: `from_brief` [optional: path to a pre-authored `skill-brief.yaml` to *ratify*: when supplied it is the source of truth, `target_repo`/`skill_name` become optional/derived-from-brief, and the run mirrors the interactive §3.1a ratify path: schema-validate, skip analyze-target/scope-definition, write through the canonical writer to `{forge_data_folder}/{name}/skill-brief.yaml`: no `force` needed when the brief already lives at that path; otherwise `force` applies as on the derive route], `[auto]` [optional: bracket modifier passed via pipeline context: when present, BS loads the upstream brief from `brief_path` in pipeline data, enriches it with doc detection, and writes through the canonical writer; requires `brief_path` from AN's `SKF_ANALYZE_RESULT_JSON`] |
|
|
10
10
|
| **Gates** | step 1: Input Gate [use args] (`references/headless-args.md`) | step 1 ratify: Choice Gate [R] | step 2: Choice Gate [P] (a truncated GitHub tree), and the workspace of a monorepo [the repo root, or the workspace an `include` glob names] | step 3: Choice Gate [the `scope_type` argument, else the recommendation] | step 3: Input Gate [the `include` and `exclude` arguments, else the scope type's boundary default; a `reference-app` scope, or a `specific-modules` `scope_type` argument naming no module, halts] | step 3: Confirm Gate [C] | step 4: Confirm Gate [C] | step 5: Overwrite Gate [Y/N] (the brief exists; headless halts `overwrite-cancelled` unless `force` was supplied) |
|
|
11
11
|
| **Outputs** | `skill-brief.yaml` at `{forge_data_folder}/{skill-name}/skill-brief.yaml`; one `SKF_BRIEF_RESULT_JSON` line on stdout at the end of a successful run when `{headless_mode}` is true or the run is in `[auto]` mode (see Result Contract below); for the run itself, a scratch folder under `{project-root}/_bmad-output/.skf-run/` holding what it hands its helpers (the repository's file list, fetched files, JSON payloads), which the run deletes once the brief is written or the user cancels (any other halt leaves it in place, less a clone of the repository) |
|
|
12
|
-
| **Headless** | All gates auto-resolve with heuristic-driven or default action when `{headless_mode}` is true; step 1's input gate (`references/headless-args.md`) validates the pre-supplied inputs before any stage uses them, and the gates that would otherwise prompt consume them; absent `source_authority` and `scope_type` are resolved by signal-driven detection (see `references/headless-args.md`); with no `include`, step 3 takes the scope type's boundary default and names it in the envelope's `warnings` (a `specific-modules` recommendation that only the module count made becomes `full-library`), while a `reference-app` scope, or a `specific-modules` `scope_type` argument whose `intent` and `scope_hint` name no module, halts with `input-missing`; existing briefs are preserved unless `--force` was supplied (HALT with `overwrite-cancelled` otherwise); supplying `from_brief <path>` instead routes step 1 to the ratify path described in the `from_brief` Inputs cell (schema-validate, skip analyze/scope, write to the forge path: no `force` needed when the brief already lives at that path; otherwise `force` applies as on the derive route) rather than deriving a new brief |
|
|
13
|
-
| **Transient-failure retry** | This workflow does **not** auto-retry network or subprocess failures. A failed call the run can go on without (a doc URL that does not answer, a failed doc detection or QMD step, an extraction script that falls back to the prompt path) is logged, added to the run's warnings and reported in the result envelope's `warnings`, and the workflow continues with whatever signal it has; a failure it cannot go on without, such as step 2 unable to read the target, is a HARD HALT. Headless pipelines that want retry semantics should wrap the invocation at their orchestrator level (e.g. CI re-runner on
|
|
12
|
+
| **Headless** | All gates auto-resolve with heuristic-driven or default action when `{headless_mode}` is true; step 1's input gate (`references/headless-args.md`) validates the pre-supplied inputs before any stage uses them, and the gates that would otherwise prompt consume them; absent `source_authority` and `scope_type` are resolved by signal-driven detection (see `references/headless-args.md`); with no `include`, step 3 takes the scope type's boundary default and names it in the envelope's `warnings` (a `specific-modules` recommendation that only the module count made becomes `full-library`), while a `reference-app` scope, or a `specific-modules` `scope_type` argument whose `intent` and `scope_hint` name no module, halts with `input-missing`; existing briefs are preserved unless `--force` was supplied (HALT with `overwrite-cancelled` otherwise); supplying `from_brief <path>` instead routes step 1 to the ratify path described in the `from_brief` Inputs cell (schema-validate, skip analyze/scope, write to the forge path: no `force` needed when the brief already lives at that path; otherwise `force` applies as on the derive route) rather than deriving a new brief. A caller reads the run's own `SKF_BRIEF_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. |
|
|
13
|
+
| **Transient-failure retry** | This workflow does **not** auto-retry network or subprocess failures. A failed call the run can go on without (a doc URL that does not answer, a failed doc detection or QMD step, an extraction script that falls back to the prompt path) is logged, added to the run's warnings and reported in the result envelope's `warnings`, and the workflow continues with whatever signal it has; a failure it cannot go on without, such as step 2 unable to read the target, is a HARD HALT. Headless pipelines that want retry semantics should wrap the invocation at their orchestrator level (e.g. a CI re-runner on an error envelope, or on a run that printed none). Rationale: brief-skill is read-mostly with one terminal write (the YAML at step 5); a partial-signal retry has more failure modes than just re-running the whole workflow, which is cheap. |
|
|
14
14
|
| **Exit codes** | See "Exit Codes" below |
|
|
15
15
|
|
|
16
16
|
## Exit Codes
|
|
@@ -73,6 +73,7 @@ These rules apply to every step in this workflow:
|
|
|
73
73
|
- Validate `_campaign-state.yaml` on every load by running `uv run scripts/campaign-validate-state.py --state-file {stateFile}` and HALT (exit code 3, `invalid-state`) on non-zero; never hand-validate the schema
|
|
74
74
|
- Zero memory dependency: campaign state is 100% recoverable from disk; never rely on conversation context for progress tracking
|
|
75
75
|
- A sub-skill's result is what the step that runs it reads (an `SKF_*_RESULT_JSON` envelope or a verdict): a missing, unparseable or error result is a sub-skill failure, and never write partial state from an unparsed envelope
|
|
76
|
+
- 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.
|
|
76
77
|
- Log every operator decision, headless default and event (skip/force, overwrite, export cancel/proceed, `.bak` recovery, user-cancel, a failed skill) as one typed entry in the append-only `{campaignWorkspacePath}/_campaign-decision-log.md` through `campaign-state.py log` (`references/campaign-contracts.md`), so rationale survives compaction and resume
|
|
77
78
|
- **Universal cancel affordance:** at any interactive gate between Setup and the Export gate, `cancel`/`exit`/`:q` triggers a HARD HALT with **exit code 12 (`user-cancelled`)**: log it and leave state intact and resumable. Exception: the Export gate's own `[C]ancel` stays exit code 11 (`export-cancelled`); never also emit 12 there, so an automator's exit-code branch stays deterministic.
|
|
78
79
|
- Always communicate in `{communication_language}`
|