okstra 0.196.0 → 0.197.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/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -1
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
- package/runtime/prompts/lead/plan-body-verification.md +21 -3
- package/runtime/prompts/profiles/implementation-planning.md +3 -2
- package/runtime/prompts/wizard/prompts.ko.json +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
- package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
- package/runtime/python/okstra_ctl/conformance.py +10 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
- package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
- package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
- package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
- package/runtime/python/okstra_ctl/plan_items.py +14 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
- package/runtime/python/okstra_ctl/run.py +54 -0
- package/runtime/python/okstra_ctl/session_transcript.py +4 -4
- package/runtime/python/okstra_ctl/stage_close.py +244 -0
- package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
- package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
- package/runtime/skills/okstra-run/SKILL.md +63 -4
- package/runtime/validators/validate-implementation-plan-stages.py +109 -23
- package/runtime/validators/validate-run.py +30 -6
|
@@ -21,6 +21,11 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
|
|
|
21
21
|
sys.path.insert(0, str(_ssot_dir))
|
|
22
22
|
|
|
23
23
|
from okstra_ctl.md_table import split_pipe_row # noqa: E402
|
|
24
|
+
from okstra_ctl.tdd_bypass import ( # noqa: E402
|
|
25
|
+
REASON_TOKEN as TDD_USER_BYPASS_TOKEN,
|
|
26
|
+
bypass_file,
|
|
27
|
+
granted_stages,
|
|
28
|
+
)
|
|
24
29
|
from okstra_ctl.stage_map import ( # noqa: E402
|
|
25
30
|
STAGE_MAP_HEADING,
|
|
26
31
|
StageMapError,
|
|
@@ -185,6 +190,15 @@ TDD_EXEMPTION = re.compile(_LABEL_PREFIX + r"TDD exemption\s*:\s*(?:\*\*)?\s*(.+
|
|
|
185
190
|
# Profile implementation-planning.md:81 limits the exemption to these three
|
|
186
191
|
# categories; any other reason (e.g. "refactor") must not waive RED/GREEN.
|
|
187
192
|
TDD_EXEMPTION_ALLOWED = ("doc-only", "config-only", "pure-rename")
|
|
193
|
+
# The fourth reason is not a category the plan may assert on its own: it holds
|
|
194
|
+
# only while `<task-root>/qa/tdd-bypass.json` records the user's grant for that
|
|
195
|
+
# stage. Every caller therefore passes the granted stage numbers in, and a plan
|
|
196
|
+
# naming the token with no grant fails S10e like any arbitrary reason. Without
|
|
197
|
+
# it a stage that is genuinely none of the three has no passable value and the
|
|
198
|
+
# plan misdescribes itself to get through (2026-09-10, fontsninja-v3-site
|
|
199
|
+
# dev-10628-3: product work already committed and conformance-proved in a prior
|
|
200
|
+
# run of the same stage, filed as `config-only`).
|
|
201
|
+
TDD_EXEMPTION_USER_BYPASS = TDD_USER_BYPASS_TOKEN
|
|
188
202
|
TEST_CASE_CATEGORIES = ("success", "boundary", "failure")
|
|
189
203
|
TEST_CASE = {
|
|
190
204
|
cat: re.compile(
|
|
@@ -196,18 +210,48 @@ CONFORMANCE_TESTS = re.compile(_LABEL_PREFIX + r"Conformance tests\s*:\s*(?:\*\*
|
|
|
196
210
|
CONFORMANCE_EXEMPTION = re.compile(_LABEL_PREFIX + r"Conformance exemption\s*:\s*(?:\*\*)?\s*\S", re.M)
|
|
197
211
|
|
|
198
212
|
|
|
199
|
-
def
|
|
200
|
-
"""
|
|
201
|
-
|
|
202
|
-
|
|
213
|
+
def _exemption_reason_message(stage_number: int) -> str:
|
|
214
|
+
"""S10e 의 거절 문구. 통과할 수 있는 값을 전부 이름으로 말한다.
|
|
215
|
+
|
|
216
|
+
사유 목록만 나열하면 세 카테고리 중 어느 것도 사실이 아닌 stage 는
|
|
217
|
+
거짓 신고 외에 길이 없다. 네 번째 값과 그 값을 얻는 명령을 함께 적어
|
|
218
|
+
다음 행동이 문구 안에 있게 한다.
|
|
219
|
+
"""
|
|
220
|
+
return (
|
|
221
|
+
"S10e: 'tddExemption' reason must be one of "
|
|
222
|
+
+ " / ".join(TDD_EXEMPTION_ALLOWED)
|
|
223
|
+
+ f" — or `{TDD_EXEMPTION_USER_BYPASS}`, which holds only while the "
|
|
224
|
+
"user has granted it for this stage: re-run prepare with "
|
|
225
|
+
f'`--tdd-bypass "{stage_number or 1}:<reason>"` so the reason is '
|
|
226
|
+
"recorded verbatim in <task-root>/qa/tdd-bypass.json. An empty or "
|
|
227
|
+
"arbitrary reason cannot waive RED/GREEN and the three test cases"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _exemption_reason_allowed(
|
|
232
|
+
section: str, *, stage_number: int = 0, user_bypassed: frozenset[int] = frozenset(),
|
|
233
|
+
) -> bool:
|
|
234
|
+
"""True when a `TDD exemption:` line is present AND its reason is allowed.
|
|
235
|
+
|
|
236
|
+
Allowed means one of the three categories (doc-only / config-only /
|
|
237
|
+
pure-rename, case-insensitive), or `user-bypass` for a stage the user
|
|
238
|
+
granted — that grant lives outside the plan, so it is passed in.
|
|
239
|
+
A present-but-unlisted reason returns False so S10e can reject it.
|
|
240
|
+
"""
|
|
203
241
|
m = TDD_EXEMPTION.search(section)
|
|
204
242
|
if not m:
|
|
205
243
|
return False
|
|
206
244
|
reason = m.group(1).lower()
|
|
245
|
+
if TDD_EXEMPTION_USER_BYPASS in reason:
|
|
246
|
+
return stage_number in user_bypassed
|
|
207
247
|
return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)
|
|
208
248
|
|
|
209
249
|
|
|
210
|
-
def _check_slice_tdd(
|
|
250
|
+
def _check_slice_tdd(
|
|
251
|
+
text: str,
|
|
252
|
+
stages: List[StageMapStage],
|
|
253
|
+
user_bypassed: frozenset[int] = frozenset(),
|
|
254
|
+
) -> List[ValidationError]:
|
|
211
255
|
"""S10: each stage declares a vertical slice and follows RED→GREEN ordering.
|
|
212
256
|
|
|
213
257
|
S10a — `Slice value:` line with a non-empty value.
|
|
@@ -237,11 +281,11 @@ def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationE
|
|
|
237
281
|
"S10b: 'Acceptance:' line missing or empty"))
|
|
238
282
|
|
|
239
283
|
if TDD_EXEMPTION.search(section):
|
|
240
|
-
if not _exemption_reason_allowed(
|
|
284
|
+
if not _exemption_reason_allowed(
|
|
285
|
+
section, stage_number=s.stage_number, user_bypassed=user_bypassed,
|
|
286
|
+
):
|
|
241
287
|
errs.append(ValidationError("S10", s.stage_number,
|
|
242
|
-
|
|
243
|
-
"doc-only / config-only / pure-rename — an arbitrary reason "
|
|
244
|
-
"cannot waive RED/GREEN"))
|
|
288
|
+
_exemption_reason_message(s.stage_number)))
|
|
245
289
|
continue
|
|
246
290
|
|
|
247
291
|
missing_cases = [
|
|
@@ -419,7 +463,9 @@ def _check_parallel_safety(
|
|
|
419
463
|
})
|
|
420
464
|
|
|
421
465
|
|
|
422
|
-
def collect_validation_errors(
|
|
466
|
+
def collect_validation_errors(
|
|
467
|
+
text: str, user_bypassed: frozenset[int] = frozenset(),
|
|
468
|
+
) -> List[ValidationError]:
|
|
423
469
|
"""All S1–S11 checks against the report text; empty list means valid.
|
|
424
470
|
|
|
425
471
|
S1 (missing `## 5.5 Stage Map` heading) makes the rest unparseable, so it
|
|
@@ -437,7 +483,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
|
|
|
437
483
|
return [ValidationError("S2", 0, exc.reason)]
|
|
438
484
|
if stages:
|
|
439
485
|
errors.extend(_check_each_stage_section(text, stages))
|
|
440
|
-
errors.extend(_check_slice_tdd(text, stages))
|
|
486
|
+
errors.extend(_check_slice_tdd(text, stages, user_bypassed))
|
|
441
487
|
errors.extend(_check_markdown_step_commands(text, stages))
|
|
442
488
|
errors.extend(_check_conformance_declaration(text, stages))
|
|
443
489
|
errors.extend(_check_depends_on(stages))
|
|
@@ -465,7 +511,9 @@ def _data_stage_metas(
|
|
|
465
511
|
return rows, _stage_numbers_monotonic(rows)
|
|
466
512
|
|
|
467
513
|
|
|
468
|
-
def _check_data_slice_tdd(
|
|
514
|
+
def _check_data_slice_tdd(
|
|
515
|
+
stage: dict, user_bypassed: frozenset[int] = frozenset(),
|
|
516
|
+
) -> List[ValidationError]:
|
|
469
517
|
"""S10c / S10e over one schema-v2 `stages[]` entry.
|
|
470
518
|
|
|
471
519
|
The schema already requires `sliceValue`, `acceptance`, and — through its
|
|
@@ -479,12 +527,12 @@ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
|
|
|
479
527
|
number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
|
|
480
528
|
if "tddExemption" in stage:
|
|
481
529
|
reason = str(stage.get("tddExemption") or "").lower()
|
|
530
|
+
if TDD_EXEMPTION_USER_BYPASS in reason:
|
|
531
|
+
if number in user_bypassed:
|
|
532
|
+
return []
|
|
533
|
+
return [ValidationError("S10", number, _exemption_reason_message(number))]
|
|
482
534
|
if not any(cat in reason for cat in TDD_EXEMPTION_ALLOWED):
|
|
483
|
-
return [ValidationError("S10", number,
|
|
484
|
-
"S10e: 'tddExemption' reason must be one of "
|
|
485
|
-
+ " / ".join(TDD_EXEMPTION_ALLOWED)
|
|
486
|
-
+ " — an empty or arbitrary reason cannot waive RED/GREEN "
|
|
487
|
-
"and the three test cases")]
|
|
535
|
+
return [ValidationError("S10", number, _exemption_reason_message(number))]
|
|
488
536
|
return []
|
|
489
537
|
|
|
490
538
|
steps = [s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)]
|
|
@@ -625,7 +673,9 @@ def _check_data_stage_identities(
|
|
|
625
673
|
return errs
|
|
626
674
|
|
|
627
675
|
|
|
628
|
-
def collect_data_validation_errors(
|
|
676
|
+
def collect_data_validation_errors(
|
|
677
|
+
planning: dict, user_bypassed: frozenset[int] = frozenset(),
|
|
678
|
+
) -> List[ValidationError]:
|
|
629
679
|
"""The S-checks that schema v2 cannot express, over `implementationPlanning`.
|
|
630
680
|
|
|
631
681
|
`collect_validation_errors` scans rendered v1 Markdown, which a v2 report
|
|
@@ -663,7 +713,7 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
|
|
|
663
713
|
if not meta.depends_on
|
|
664
714
|
}))
|
|
665
715
|
for stage in stages:
|
|
666
|
-
errors.extend(_check_data_slice_tdd(stage))
|
|
716
|
+
errors.extend(_check_data_slice_tdd(stage, user_bypassed))
|
|
667
717
|
number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
|
|
668
718
|
for step in stage.get("stepwiseExecution") or []:
|
|
669
719
|
if isinstance(step, dict):
|
|
@@ -671,26 +721,62 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
|
|
|
671
721
|
return errors
|
|
672
722
|
|
|
673
723
|
|
|
674
|
-
def collect_plan_errors(
|
|
724
|
+
def collect_plan_errors(
|
|
725
|
+
plan_path: Path, user_bypassed: frozenset[int] | None = None,
|
|
726
|
+
) -> List[ValidationError]:
|
|
675
727
|
"""The S-checks for one approved plan, whichever schema wrote it.
|
|
676
728
|
|
|
677
729
|
A schema-v2 report keeps its stage map in the `.data.json` sidecar and
|
|
678
730
|
renders no `## 5.5 Stage Map` section, so scanning its markdown reports the
|
|
679
731
|
section as missing and blocks every run that approved such a plan.
|
|
680
732
|
"""
|
|
733
|
+
granted = (
|
|
734
|
+
user_bypassed if user_bypassed is not None
|
|
735
|
+
else user_bypassed_stages_for_plan(plan_path)
|
|
736
|
+
)
|
|
681
737
|
planning = schema_v2_report(plan_path).get("implementationPlanning")
|
|
682
738
|
if isinstance(planning, dict) and planning:
|
|
683
|
-
return collect_data_validation_errors(planning)
|
|
684
|
-
return collect_validation_errors(plan_path.read_text(encoding="utf-8"))
|
|
739
|
+
return collect_data_validation_errors(planning, granted)
|
|
740
|
+
return collect_validation_errors(plan_path.read_text(encoding="utf-8"), granted)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def user_bypassed_stages_for_plan(plan_path: Path) -> frozenset[int]:
|
|
744
|
+
"""이 계획이 속한 task 의 우회 원장에서 부여된 stage 번호들.
|
|
745
|
+
|
|
746
|
+
레이아웃 해석은 `RunRef.from_run_dir` 이 소유한다 — 부모를 세는 계산은
|
|
747
|
+
배치가 하나만 바뀌어도 조용히 다른 디렉터리를 가리킨다. run 디렉터리로
|
|
748
|
+
해석되지 않는 입력은 빈 집합이고, 그때는 `--tdd-bypass-ledger` 로 원장을
|
|
749
|
+
직접 넘기는 것이 경로다. 원장이 없으면 빈 집합 — 우회 없음이다.
|
|
750
|
+
"""
|
|
751
|
+
from okstra_ctl.paths import RunRef
|
|
752
|
+
|
|
753
|
+
try:
|
|
754
|
+
task_root = RunRef.from_run_dir(plan_path.resolve().parent.parent).task_root
|
|
755
|
+
except (ValueError, IndexError):
|
|
756
|
+
return frozenset()
|
|
757
|
+
return frozenset(granted_stages(bypass_file(task_root)))
|
|
685
758
|
|
|
686
759
|
|
|
687
760
|
def main(argv: List[str]) -> int:
|
|
688
761
|
p = argparse.ArgumentParser()
|
|
689
762
|
p.add_argument("--plan", required=True)
|
|
763
|
+
p.add_argument(
|
|
764
|
+
"--tdd-bypass-ledger",
|
|
765
|
+
default="",
|
|
766
|
+
dest="tdd_bypass_ledger",
|
|
767
|
+
help=(
|
|
768
|
+
"path to <task-root>/qa/tdd-bypass.json; defaults to the ledger of "
|
|
769
|
+
"the task the --plan report lives in"
|
|
770
|
+
),
|
|
771
|
+
)
|
|
690
772
|
args = p.parse_args(argv)
|
|
691
773
|
|
|
774
|
+
granted = (
|
|
775
|
+
frozenset(granted_stages(Path(args.tdd_bypass_ledger)))
|
|
776
|
+
if args.tdd_bypass_ledger else None
|
|
777
|
+
)
|
|
692
778
|
try:
|
|
693
|
-
errors = collect_plan_errors(Path(args.plan))
|
|
779
|
+
errors = collect_plan_errors(Path(args.plan), granted)
|
|
694
780
|
except StageMapError as exc:
|
|
695
781
|
print(f"S0 stage=0: {exc.reason}", file=sys.stderr)
|
|
696
782
|
return 1
|
|
@@ -41,6 +41,7 @@ from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
|
|
|
41
41
|
from okstra_project.resolver import resolve_architecture # noqa: E402
|
|
42
42
|
|
|
43
43
|
from okstra_ctl.conformance import ( # noqa: E402
|
|
44
|
+
conformance_result_file,
|
|
44
45
|
detect_surfaces,
|
|
45
46
|
exempt_stage_surface_conflicts,
|
|
46
47
|
evaluate_conformance,
|
|
@@ -56,6 +57,10 @@ from okstra_ctl.dispatch_state import ( # noqa: E402
|
|
|
56
57
|
v2_worker_state_key,
|
|
57
58
|
)
|
|
58
59
|
from okstra_ctl.paths import RunRef, okstra_home, project_rel # noqa: E402
|
|
60
|
+
from okstra_ctl.tdd_bypass import ( # noqa: E402
|
|
61
|
+
bypass_file as tdd_bypass_file,
|
|
62
|
+
granted_stages,
|
|
63
|
+
)
|
|
59
64
|
from okstra_ctl.reconcile import settle_run_row # noqa: E402
|
|
60
65
|
from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION # noqa: E402
|
|
61
66
|
from okstra_ctl.release_gate import ( # noqa: E402
|
|
@@ -1700,7 +1705,7 @@ def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
|
|
|
1700
1705
|
key = entry.get("stageKey") if isinstance(entry, dict) else None
|
|
1701
1706
|
if not isinstance(key, str) or not key:
|
|
1702
1707
|
continue
|
|
1703
|
-
sidecar = qa_dir
|
|
1708
|
+
sidecar = conformance_result_file(qa_dir, key)
|
|
1704
1709
|
if not sidecar.is_file():
|
|
1705
1710
|
continue
|
|
1706
1711
|
try:
|
|
@@ -6352,9 +6357,14 @@ def _plan_verify_dispatched_results(
|
|
|
6352
6357
|
# critic 동수 라운드는 `kind: "critic"` 으로 나간다 — 결과 파일명은
|
|
6353
6358
|
# 같은 `-plan-verify-r<N>-` 꼴이다. reverify 계열만 세면 동수가 있던
|
|
6354
6359
|
# run 마다 critic 표가 "디스패치 기록 없음" 으로 오탐된다(2026-09-09,
|
|
6355
|
-
# fontsninja-v3-site dev-10627 planning 002).
|
|
6360
|
+
# fontsninja-v3-site dev-10627 planning 002). 계획 본문 라운드 자신의
|
|
6361
|
+
# kind 인 `plan-verify-r<N>` 도 같은 이유로 센다.
|
|
6356
6362
|
kind = str(row.get("kind") or "")
|
|
6357
|
-
if not (
|
|
6363
|
+
if not (
|
|
6364
|
+
kind.startswith("reverify-r")
|
|
6365
|
+
or kind.startswith("plan-verify-r")
|
|
6366
|
+
or kind == "critic"
|
|
6367
|
+
):
|
|
6358
6368
|
continue
|
|
6359
6369
|
name = Path(str(row.get("workerResultPath") or "")).name
|
|
6360
6370
|
if "-plan-verify-r" not in name:
|
|
@@ -8003,13 +8013,19 @@ def _load_stage_validator():
|
|
|
8003
8013
|
return mod
|
|
8004
8014
|
|
|
8005
8015
|
|
|
8006
|
-
def _append_stage_data_failures(
|
|
8016
|
+
def _append_stage_data_failures(
|
|
8017
|
+
data: Mapping[str, Any], failures: list[str], task_root: Path | None = None,
|
|
8018
|
+
) -> None:
|
|
8007
8019
|
"""Run the stage relationship checks that schema v2 cannot express.
|
|
8008
8020
|
|
|
8009
8021
|
The depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and
|
|
8010
8022
|
the TDD-exemption vocabulary are relationships between stages, which a
|
|
8011
8023
|
JSON Schema cannot state. They are enforced here, against the data.json,
|
|
8012
8024
|
by the same validator that owns the rule vocabulary.
|
|
8025
|
+
|
|
8026
|
+
`tddExemption: user-bypass` is the one reason the plan cannot assert by
|
|
8027
|
+
itself, so the user's grants are read from the task's own bypass ledger
|
|
8028
|
+
and passed in; without the task root no stage counts as granted.
|
|
8013
8029
|
"""
|
|
8014
8030
|
if (data or {}).get("schemaVersion") != CURRENT_REPORT_SCHEMA_VERSION:
|
|
8015
8031
|
return # Schema validation already rejected an unknown version.
|
|
@@ -8020,7 +8036,11 @@ def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) ->
|
|
|
8020
8036
|
if mod is None: # pragma: no cover — repo/runtime always ship the file
|
|
8021
8037
|
failures.append(f"cannot load Stage Map validator at {_STAGE_VALIDATOR_PATH}")
|
|
8022
8038
|
return
|
|
8023
|
-
|
|
8039
|
+
granted = (
|
|
8040
|
+
frozenset(granted_stages(tdd_bypass_file(task_root)))
|
|
8041
|
+
if task_root is not None else frozenset()
|
|
8042
|
+
)
|
|
8043
|
+
for e in mod.collect_data_validation_errors(dict(planning), granted):
|
|
8024
8044
|
failures.append(
|
|
8025
8045
|
f"implementation-planning stage contract invalid "
|
|
8026
8046
|
f"[{e.code} stage={e.stage}]: {e.message}"
|
|
@@ -9485,7 +9505,11 @@ def main() -> int:
|
|
|
9485
9505
|
)
|
|
9486
9506
|
_validate_stage_has_requirement(validation_data, failures)
|
|
9487
9507
|
if task_type == "implementation-planning":
|
|
9488
|
-
_append_stage_data_failures(
|
|
9508
|
+
_append_stage_data_failures(
|
|
9509
|
+
validation_data,
|
|
9510
|
+
failures,
|
|
9511
|
+
_task_root_from_run_dir(report_path.parent.parent),
|
|
9512
|
+
)
|
|
9489
9513
|
if task_type == "improvement-discovery":
|
|
9490
9514
|
run_dir = report_path.parent.parent
|
|
9491
9515
|
_validate_improvement_discovery(report_path, run_dir, brief_path, failures)
|