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.
Files changed (91) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/agents.md +1 -1
  4. package/docs/architecture.md +3 -3
  5. package/docs/campaign.md +1 -1
  6. package/docs/troubleshooting.md +9 -3
  7. package/docs/verifying-a-skill.md +9 -9
  8. package/docs/workflows.md +4 -4
  9. package/package.json +1 -1
  10. package/src/knowledge/tool-resolution.md +6 -6
  11. package/src/shared/references/headless-gate-convention.md +4 -1
  12. package/src/shared/references/output-contract-schema.md +3 -3
  13. package/src/shared/references/pipeline-contracts.md +2 -1
  14. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/skf-build-change-manifest.py +332 -54
  16. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  17. package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
  18. package/src/shared/scripts/skf-extract-public-api.py +33 -13
  19. package/src/shared/scripts/skf-parse-gaps.py +3 -2
  20. package/src/shared/scripts/skf-verify-provenance-completeness.py +108 -11
  21. package/src/skf-analyze-source/SKILL.md +1 -0
  22. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  23. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  24. package/src/skf-audit-skill/SKILL.md +2 -1
  25. package/src/skf-audit-skill/references/report.md +1 -1
  26. package/src/skf-brief-skill/SKILL.md +1 -0
  27. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  28. package/src/skf-campaign/SKILL.md +1 -0
  29. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  30. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  31. package/src/skf-campaign/references/step-10-export.md +3 -2
  32. package/src/skf-create-skill/SKILL.md +2 -1
  33. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  34. package/src/skf-create-skill/references/component-extraction.md +1 -1
  35. package/src/skf-create-skill/references/extract.md +1 -1
  36. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  37. package/src/skf-create-skill/references/report.md +2 -2
  38. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  39. package/src/skf-create-skill/references/validate.md +1 -1
  40. package/src/skf-create-stack-skill/SKILL.md +1 -0
  41. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  42. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  43. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  44. package/src/skf-create-stack-skill/references/report.md +1 -1
  45. package/src/skf-drop-skill/SKILL.md +1 -0
  46. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  47. package/src/skf-drop-skill/references/report.md +1 -1
  48. package/src/skf-export-skill/SKILL.md +1 -0
  49. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  50. package/src/skf-export-skill/references/summary.md +1 -1
  51. package/src/skf-forger/SKILL.md +1 -0
  52. package/src/skf-forger/references/pipeline-mode.md +2 -2
  53. package/src/skf-quick-skill/SKILL.md +2 -1
  54. package/src/skf-quick-skill/references/finalize.md +1 -1
  55. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  56. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  57. package/src/skf-refine-architecture/SKILL.md +2 -1
  58. package/src/skf-refine-architecture/customize.toml +1 -1
  59. package/src/skf-refine-architecture/references/report.md +1 -1
  60. package/src/skf-rename-skill/SKILL.md +1 -0
  61. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  62. package/src/skf-setup/SKILL.md +1 -0
  63. package/src/skf-setup/references/ccc-index.md +1 -1
  64. package/src/skf-setup/references/invocation-contract.md +1 -1
  65. package/src/skf-test-skill/SKILL.md +1 -0
  66. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  67. package/src/skf-test-skill/references/coherence-check.md +8 -6
  68. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  69. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  70. package/src/skf-test-skill/references/coverage-check.md +30 -43
  71. package/src/skf-test-skill/references/init.md +1 -1
  72. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  73. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  74. package/src/skf-test-skill/references/report.md +3 -3
  75. package/src/skf-test-skill/references/scoring-rules.md +6 -6
  76. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  77. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  78. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  79. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  80. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +345 -9
  82. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  83. package/src/skf-test-skill/scripts/score-signatures.py +939 -74
  84. package/src/skf-test-skill/scripts/validate-inventory.py +245 -29
  85. package/src/skf-update-skill/SKILL.md +1 -0
  86. package/src/skf-update-skill/references/gap-driven.md +28 -22
  87. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  88. package/src/skf-update-skill/references/merge.md +4 -4
  89. package/src/skf-update-skill/references/report.md +1 -1
  90. package/src/skf-update-skill/references/write.md +5 -4
  91. 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 Category D, --brief),
108
- skf-detect-scripts-assets.py detect --brief (create-skill) and
109
- skf-compare-file-hashes.py compare --brief (audit-skill) load this file by
110
- its path and call load_scope(brief_path, cited). It returns a Scope:
111
- takes(path) is the in-scope test above (`_in_scope`), with the brief's
112
- `scope.include` and `scope.exclude` normalized as classify normalizes them
113
- and, for an empty `scope.include`, the extensions of the `cited` paths
114
- (the provenance map's `entries[].source_file`) and of the brief's
115
- `language`; wants(kind) is False for a `script` or `asset` whose
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 one entry to the run's sink: --decision reads one
48
- auto-decision (a JSON object) on stdin, --warning takes the
49
- warning text. With --workflow a decision is checked against
50
- that workflow's headless_decisions item schema first.
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 one warning to the run's sink, one JSON value per line."""
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
- value = args.warning.strip()
1796
- if not value:
1797
- _die(1, "record: --warning must be a non-empty string")
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
- line = json.dumps(value, separators=(",", ":"), ensure_ascii=True) + "\n"
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(line)
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 a warning to the run's sink.")
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, help="The warning text.")
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 _binding_types(matches: list[dict]) -> dict[tuple[str, str], str]:
2013
- """(file, name) -> the export type of the top-level binding `name` has
2014
- in that file, from the matches of _binding_rules."""
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], 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 rank.index(form) < rank.index(best[key]):
2022
- best[key] = form
2023
- return {key: BINDING_TYPES[form] for key, form in best.items()}
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
- types = _binding_types(_scan(exe, root, folder, rules, files, errors, clock))
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
- e["export_type"] = types.get((e["source_file"], name), e["export_type"])
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, so a person checks by hand that it defines the export and, "
1025
- "when another line does, sets its provenance `source_line` to that line"),
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. The rules follow the
75
- `source_file` extension:
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>, ...]}, # line-not-definition only
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 # --line, checked only
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`. `--names` holds reconcile-coverage.py's result (its
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
- matches = _definition_matches(source_file, export_name, source_root, cache)
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, export_name, source_root, cache, line, defs, every
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
- matches = _definition_matches(sf, name, source_root, cache)
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 pipeline consumers read), 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}."
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 (stable path for pipeline consumers: a copy, not a symlink), 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.
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 non-zero exit). 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. |
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}`