bmad-module-skill-forge 5.0.0 → 7.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 (104) 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 +7 -7
  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-quick-batch.py +16 -3
  21. package/src/shared/scripts/skf-verify-provenance-completeness.py +317 -44
  22. package/src/skf-analyze-source/SKILL.md +1 -0
  23. package/src/skf-analyze-source/customize.toml +3 -2
  24. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  25. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  26. package/src/skf-analyze-source/references/headless-contract.md +3 -3
  27. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  28. package/src/skf-audit-skill/SKILL.md +2 -1
  29. package/src/skf-audit-skill/references/report.md +1 -1
  30. package/src/skf-brief-skill/SKILL.md +1 -0
  31. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  32. package/src/skf-campaign/SKILL.md +1 -0
  33. package/src/skf-campaign/references/campaign-contracts.md +3 -1
  34. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  35. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  36. package/src/skf-campaign/references/step-08-verify.md +3 -3
  37. package/src/skf-campaign/references/step-09-refine.md +3 -3
  38. package/src/skf-campaign/references/step-10-export.md +3 -2
  39. package/src/skf-campaign/scripts/campaign-state.py +22 -5
  40. package/src/skf-create-skill/SKILL.md +3 -2
  41. package/src/skf-create-skill/customize.toml +4 -4
  42. package/src/skf-create-skill/references/batch-mode.md +3 -1
  43. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  44. package/src/skf-create-skill/references/component-extraction.md +1 -1
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  47. package/src/skf-create-skill/references/report.md +9 -4
  48. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  49. package/src/skf-create-skill/references/validate.md +2 -2
  50. package/src/skf-create-stack-skill/SKILL.md +1 -0
  51. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  52. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  53. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  54. package/src/skf-create-stack-skill/references/report.md +1 -1
  55. package/src/skf-create-stack-skill/references/validate.md +12 -4
  56. package/src/skf-drop-skill/SKILL.md +1 -0
  57. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  58. package/src/skf-drop-skill/references/report.md +1 -1
  59. package/src/skf-export-skill/SKILL.md +1 -0
  60. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  61. package/src/skf-export-skill/references/summary.md +1 -1
  62. package/src/skf-forger/SKILL.md +1 -0
  63. package/src/skf-forger/references/pipeline-mode.md +2 -2
  64. package/src/skf-quick-skill/SKILL.md +2 -1
  65. package/src/skf-quick-skill/references/batch-mode.md +2 -0
  66. package/src/skf-quick-skill/references/finalize.md +1 -1
  67. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  68. package/src/skf-quick-skill/references/write-and-validate.md +20 -10
  69. package/src/skf-refine-architecture/SKILL.md +2 -1
  70. package/src/skf-refine-architecture/customize.toml +1 -1
  71. package/src/skf-refine-architecture/references/report.md +1 -1
  72. package/src/skf-rename-skill/SKILL.md +1 -0
  73. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  74. package/src/skf-setup/SKILL.md +1 -0
  75. package/src/skf-setup/references/ccc-index.md +1 -1
  76. package/src/skf-setup/references/invocation-contract.md +1 -1
  77. package/src/skf-test-skill/SKILL.md +1 -0
  78. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  79. package/src/skf-test-skill/references/coherence-check.md +8 -6
  80. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  81. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  82. package/src/skf-test-skill/references/coverage-check.md +30 -43
  83. package/src/skf-test-skill/references/init.md +1 -1
  84. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  85. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  86. package/src/skf-test-skill/references/report.md +3 -3
  87. package/src/skf-test-skill/references/scoring-rules.md +7 -7
  88. package/src/skf-test-skill/references/signature-comparison-rules.md +74 -0
  89. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  90. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  91. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  92. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  93. package/src/skf-test-skill/scripts/load-coverage-inputs.py +575 -9
  94. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  95. package/src/skf-test-skill/scripts/score-signatures.py +937 -76
  96. package/src/skf-test-skill/scripts/validate-inventory.py +325 -30
  97. package/src/skf-update-skill/SKILL.md +1 -0
  98. package/src/skf-update-skill/customize.toml +1 -1
  99. package/src/skf-update-skill/references/gap-driven.md +28 -22
  100. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  101. package/src/skf-update-skill/references/merge.md +4 -4
  102. package/src/skf-update-skill/references/report.md +11 -4
  103. package/src/skf-update-skill/references/write.md +6 -5
  104. 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"),
@@ -124,6 +124,15 @@ summarize
124
124
  "succeeded", "failed", "status", "fail_fast_triggered", "exit_code",
125
125
  "summary_path"}, with "warnings" when a summary file could not be
126
126
  written. A batch already summarized prints its event again.
127
+ The batch's result is that event and the file its summary_path names
128
+ (with summary_path null, when the per-batch file could not be written,
129
+ the event and batch.jsonl; a summary_write_failed warning beside a set
130
+ summary_path means only the copy failed): <stem>-latest.json holds the
131
+ last batch summary written, which may be another batch's, so it never
132
+ stands for this batch, and a batch cut off before summarize leaves it in
133
+ place. A summarize on a run folder that is gone (a batch whose targets
134
+ all succeeded, summarized and removed) exits 1, naming the per-batch
135
+ file whose input_file (or input_briefs) is this batch's.
127
136
 
128
137
  The batch file is one JSON object per line, so a write cut short spoils at
129
138
  most its own line, which is skipped when read: the next write ends that
@@ -628,10 +637,14 @@ def summary_event(summary: dict) -> dict:
628
637
 
629
638
  def cmd_summarize(run_dir: Path, output_dir: Path) -> dict:
630
639
  if not (run_dir / BATCH_FILE).exists() and not run_dir.exists():
631
- stem = SUMMARY_STEMS[CREATE_WORKFLOW if run_dir.name.startswith(f"{CREATE_WORKFLOW}-") else WORKFLOW]
640
+ create = run_dir.name.startswith(f"{CREATE_WORKFLOW}-")
641
+ stem = SUMMARY_STEMS[CREATE_WORKFLOW if create else WORKFLOW]
642
+ names = "input_briefs name its briefs" if create else "input_file names its batch file"
632
643
  raise BatchError(f"no batch in {run_dir.as_posix()}: a batch whose targets all succeeded removes its "
633
- f"run folder once summarized; its summary is "
634
- f"{(output_dir / f'{stem}-latest.json').as_posix()}")
644
+ f"run folder once summarized; its summary is the {stem}-<YYYYMMDD-HHmmss>.json in "
645
+ f"{output_dir.as_posix()} whose {names}, and "
646
+ f"{(output_dir / f'{stem}-latest.json').as_posix()} holds the last batch summary "
647
+ f"written, which may be another batch's")
635
648
  state = load(run_dir)
636
649
  name = workflow(state)
637
650
  if state["summary"] is not None: