okstra 0.151.1 → 0.153.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.
@@ -89,12 +89,12 @@ _none_
89
89
  Day: 1 5 10 15 20 25 30
90
90
  | | | | | | |
91
91
  Phase 1
92
- <TASK-ID> (<size>) ██████ ! crit
93
- <TASK-ID> (<size>) ████████
92
+ <TASK-ID>/S<stage-number> ██████ ! crit days=<lower>~<upper>
93
+ <TASK-ID>/S<stage-number> ████████ days=<lower>~<upper>
94
94
  Phase 2
95
- <TASK-ID> (<size>) ██████░░ est
95
+ <TASK-ID>/S<stage-number> ██████░░ est days=<lower>~<upper>
96
96
  Phase 3
97
- <TASK-ID> (<size>) ████ (after <TASK-ID>)
97
+ <TASK-ID>/S<stage-number> ████ (after <TASK-ID>/S<stage-number>) days=<lower>~<upper>
98
98
  ```
99
99
 
100
100
  > The axis is in **relative day-counts** (Day 1 = Phase 1 start). Legend: `! crit` = critical path, `est` = estimated allocation, `█` = confirmed span, `░` = upper-bound / uncertain span.
@@ -110,7 +110,7 @@ Phase 3
110
110
  | **Category** | <category> |
111
111
  | **Priority** | <P0~P3> |
112
112
  | **Effort** | **<S/M/L/XL>** (<scope summary>) |
113
- | **Status** | <workStatus + currentPhase summary> |
113
+ | **Status** | <taskType> / <currentPhase> |
114
114
  | **Risk** | <risk> |
115
115
  | **Scope** | <files / repos summary> |
116
116
  | **Repo** | <repos> |
@@ -121,9 +121,10 @@ Phase 3
121
121
 
122
122
  **Work Breakdown**:
123
123
 
124
- | Step | File | Action | Detail |
125
- |------|------|--------|--------|
126
- | 1 | <path> | CREATE/MODIFY/VERIFY | <detail> |
124
+ | Stage | Title | Steps | Depends On | Days |
125
+ |---:|---|---:|---|---:|
126
+ | 2 | Build adapter | 3 | 1 (done) | 2.0 ~ 3.0 |
127
+ | 3 | Wire consumer | 2 | 2 | 1.0 ~ 2.0 |
127
128
 
128
129
  **Verification Commands**:
129
130
  ```bash
@@ -183,4 +184,3 @@ _none_ <!-- or repeat per-task block -->
183
184
  |------|-------------|
184
185
  | FC-5 | Missing payment gateway timeout handling |
185
186
  -->
186
-
@@ -20,8 +20,13 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
20
20
  sys.path.insert(0, str(_ssot_dir))
21
21
 
22
22
  from okstra_ctl.md_table import split_pipe_row # noqa: E402
23
+ from okstra_ctl.stage_map import ( # noqa: E402
24
+ STAGE_MAP_HEADING,
25
+ StageMapError,
26
+ StageMapStage,
27
+ parse_stage_map_text,
28
+ )
23
29
 
24
- STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.M)
25
30
  HARD_STEP_CAP = 8
26
31
  REQUIRED_SUBSECTIONS = (
27
32
  "Carry-In",
@@ -36,15 +41,6 @@ EXIT_CONTRACT_HEADING = re.compile(r"^###\s+Stage Exit Contract\b", re.M)
36
41
  PATH_TOKEN = re.compile(r"(?:[\w.@-]+/)+[\w.@-]+")
37
42
 
38
43
 
39
- @dataclass
40
- class StageMeta:
41
- stage_number: int
42
- title: str
43
- depends_on: List[int]
44
- step_count: int
45
- exit_contract_summary: str
46
-
47
-
48
44
  @dataclass
49
45
  class ValidationError:
50
46
  code: str # S1..S11
@@ -59,51 +55,26 @@ def _check_stage_map_present(text: str) -> List[ValidationError]:
59
55
  return []
60
56
 
61
57
 
62
- def _parse_stage_map(text: str) -> Tuple[List[StageMeta], List[ValidationError]]:
63
- m = STAGE_MAP_HEADING.search(text)
64
- if not m:
65
- return [], [] # S1 already reported
66
- body = text[m.end():]
67
- rows = []
68
- for line in body.splitlines():
69
- if line.startswith("##"):
70
- break
71
- if not line.strip().startswith("|"):
72
- continue
73
- cells = split_pipe_row(line)
74
- if len(cells) != 5:
75
- continue
76
- # skip header and separator rows (all-dash of any length is covered by set check)
77
- if cells[0] == "stage" or set(cells[0]) <= set("-"):
78
- continue
79
- try:
80
- n = int(cells[0])
81
- except ValueError:
82
- continue
83
- depends_raw = cells[2].strip()
84
- if depends_raw in ("(none)", ""):
85
- depends = []
86
- else:
87
- try:
88
- depends = [int(x.strip()) for x in depends_raw.split(",") if x.strip()]
89
- except ValueError:
90
- # 비정수 depends_on 셀 → 행 skip (비정수 stage_number 와 동일). raw
91
- # ValueError 를 흘리면 _stage_map_reject_detail·handoff 의 except
92
- # PrepareError 를 우회해 traceback 으로 죽으므로, 누락된 행을 하류
93
- # S2(비단조) 검사가 잡게 한다.
94
- continue
95
- try:
96
- step_count = int(cells[3])
97
- except ValueError:
98
- step_count = -1
99
- rows.append(StageMeta(n, cells[1], depends, step_count, cells[4]))
100
- errors: List[ValidationError] = []
101
- for i, r in enumerate(rows, start=1):
102
- if r.stage_number != i:
103
- errors.append(ValidationError("S2", r.stage_number,
104
- f"stage numbers must be 1..N monotonic, got {r.stage_number} at row {i}"))
105
- return rows, errors
58
+ def _parse_depends_on_cell(raw: str) -> List[int] | None:
59
+ """Stage numbers from schema-v2 `stageMap[].dependsOn`."""
60
+ value = raw.strip()
61
+ if value in ("(none)", ""):
62
+ return []
63
+ try:
64
+ return [int(x.strip()) for x in value.split(",") if x.strip()]
65
+ except ValueError:
66
+ return None
67
+
106
68
 
69
+ def _stage_numbers_monotonic(
70
+ stages: List[StageMapStage],
71
+ ) -> List[ValidationError]:
72
+ return [
73
+ ValidationError("S2", r.stage_number,
74
+ f"stage numbers must be 1..N monotonic, got {r.stage_number} at row {i}")
75
+ for i, r in enumerate(stages, start=1)
76
+ if r.stage_number != i
77
+ ]
107
78
 
108
79
  def _slice_stage_section(text: str, stage_number: int) -> str:
109
80
  """Return the body of `## 5.5.<n> Stage <n>:` up to the next stage heading."""
@@ -151,7 +122,7 @@ def _count_effective_steps(section: str) -> int:
151
122
  return len(_effective_step_rows(section))
152
123
 
153
124
 
154
- def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[ValidationError]:
125
+ def _check_each_stage_section(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
155
126
  errs: List[ValidationError] = []
156
127
  for s in stages:
157
128
  if not re.search(
@@ -213,7 +184,7 @@ def _exemption_reason_allowed(section: str) -> bool:
213
184
  return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)
214
185
 
215
186
 
216
- def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError]:
187
+ def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
217
188
  """S10: each stage declares a vertical slice and follows RED→GREEN ordering.
218
189
 
219
190
  S10a — `Slice value:` line with a non-empty value.
@@ -291,7 +262,7 @@ def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationEr
291
262
 
292
263
 
293
264
  def _check_conformance_declaration(
294
- text: str, stages: List[StageMeta]
265
+ text: str, stages: List[StageMapStage]
295
266
  ) -> List[ValidationError]:
296
267
  """S11: 각 stage 는 conformance 검증을 선언하거나 명시적으로 면제한다.
297
268
 
@@ -312,7 +283,7 @@ def _check_conformance_declaration(
312
283
  return errs
313
284
 
314
285
 
315
- def _check_depends_on(stages: List[StageMeta]) -> List[ValidationError]:
286
+ def _check_depends_on(stages: List[StageMapStage]) -> List[ValidationError]:
316
287
  errs: List[ValidationError] = []
317
288
  valid = {s.stage_number for s in stages}
318
289
  for s in stages:
@@ -358,16 +329,8 @@ def _extract_exit_contract_files(section: str) -> set:
358
329
  return set(PATH_TOKEN.findall(body))
359
330
 
360
331
 
361
- def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[ValidationError]:
362
- """S9: two `depends-on (none)` stages must not predict the same file —
363
- otherwise two parallel implementation runs would edit it concurrently."""
364
- files = {
365
- s.stage_number: _extract_exit_contract_files(
366
- _slice_stage_section(text, s.stage_number)
367
- )
368
- for s in stages
369
- if not s.depends_on
370
- }
332
+ def _report_shared_parallel_files(files: dict) -> List[ValidationError]:
333
+ """S9 over an already-extracted {stage_number: {path}} map."""
371
334
  errs: List[ValidationError] = []
372
335
  nums = sorted(files)
373
336
  for i in range(len(nums)):
@@ -381,6 +344,20 @@ def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[Validatio
381
344
  return errs
382
345
 
383
346
 
347
+ def _check_parallel_safety(
348
+ text: str, stages: List[StageMapStage],
349
+ ) -> List[ValidationError]:
350
+ """S9: two `depends-on (none)` stages must not predict the same file —
351
+ otherwise two parallel implementation runs would edit it concurrently."""
352
+ return _report_shared_parallel_files({
353
+ s.stage_number: _extract_exit_contract_files(
354
+ _slice_stage_section(text, s.stage_number)
355
+ )
356
+ for s in stages
357
+ if not s.depends_on
358
+ })
359
+
360
+
384
361
  def collect_validation_errors(text: str) -> List[ValidationError]:
385
362
  """All S1–S11 checks against the report text; empty list means valid.
386
363
 
@@ -393,8 +370,10 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
393
370
  return present
394
371
 
395
372
  errors: List[ValidationError] = []
396
- stages, s2_errs = _parse_stage_map(text)
397
- errors.extend(s2_errs)
373
+ try:
374
+ stages = parse_stage_map_text(text)
375
+ except StageMapError as exc:
376
+ return [ValidationError("S2", 0, exc.reason)]
398
377
  if stages:
399
378
  errors.extend(_check_each_stage_section(text, stages))
400
379
  errors.extend(_check_slice_tdd(text, stages))
@@ -404,6 +383,130 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
404
383
  return errors
405
384
 
406
385
 
386
+ def _data_stage_metas(
387
+ stage_map: List[dict],
388
+ ) -> Tuple[List[StageMapStage], List[ValidationError]]:
389
+ rows = []
390
+ for row in stage_map:
391
+ if not isinstance(row, dict) or not isinstance(row.get("stage"), int):
392
+ continue
393
+ depends = _parse_depends_on_cell(str(row.get("dependsOn") or ""))
394
+ if depends is None:
395
+ continue
396
+ rows.append(StageMapStage(
397
+ row["stage"],
398
+ str(row.get("title") or ""),
399
+ depends,
400
+ row.get("stepCount") if isinstance(row.get("stepCount"), int) else -1,
401
+ str(row.get("exitContractSummary") or ""),
402
+ ))
403
+ return rows, _stage_numbers_monotonic(rows)
404
+
405
+
406
+ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
407
+ """S10c / S10e over one schema-v2 `stages[]` entry.
408
+
409
+ The schema already requires `sliceValue`, `acceptance`, and — through its
410
+ `if not tddExemption then testCase*` conditional — the three test cases, so
411
+ S10a/S10b/S10d are covered declaratively. Two rules a JSON Schema cannot
412
+ state are left: the RED→GREEN ordering across `stepwiseExecution` rows, and
413
+ that a `tddExemption` naming no allowed category cannot waive them. The
414
+ schema types `tddExemption` as a plain string, so `""` currently satisfies
415
+ the conditional and drops all three test cases with no reason given.
416
+ """
417
+ number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
418
+ if "tddExemption" in stage:
419
+ reason = str(stage.get("tddExemption") or "").lower()
420
+ if not any(cat in reason for cat in TDD_EXEMPTION_ALLOWED):
421
+ return [ValidationError("S10", number,
422
+ "S10e: 'tddExemption' reason must be one of "
423
+ + " / ".join(TDD_EXEMPTION_ALLOWED)
424
+ + " — an empty or arbitrary reason cannot waive RED/GREEN "
425
+ "and the three test cases")]
426
+ return []
427
+
428
+ steps = [s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)]
429
+ actions = [str(s.get("action") or "") for s in steps]
430
+ if not (actions and actions[0].startswith("RED:")
431
+ and any(a.startswith("GREEN:") for a in actions)):
432
+ return [ValidationError("S10", number,
433
+ "S10c: first stepwiseExecution action must start with 'RED:' and "
434
+ "some action with 'GREEN:', or declare a 'tddExemption'")]
435
+
436
+ errs: List[ValidationError] = []
437
+ for step in steps:
438
+ action = str(step.get("action") or "")
439
+ expected = str(step.get("expected") or "")
440
+ if action.startswith("RED:") and "FAIL" not in expected.upper():
441
+ errs.append(ValidationError("S10", number,
442
+ f"S10c: 'RED:' step's expected must read FAIL, got '{expected}'"))
443
+ elif action.startswith("GREEN:") and "PASS" not in expected.upper():
444
+ errs.append(ValidationError("S10", number,
445
+ f"S10c: 'GREEN:' step's expected must read PASS, got '{expected}'"))
446
+ return errs
447
+
448
+
449
+ def _check_data_step_counts(
450
+ stage_map: List[StageMapStage], stages: List[dict]
451
+ ) -> List[ValidationError]:
452
+ """The Stage Map row is the plan's own index of its stage body. A row
453
+ claiming a step count the body does not have makes the map unusable for
454
+ sizing a stage, which is the only reason the cell exists."""
455
+ by_number = {
456
+ s.get("stage"): s for s in stages
457
+ if isinstance(s, dict) and isinstance(s.get("stage"), int)
458
+ }
459
+ errs: List[ValidationError] = []
460
+ for meta in stage_map:
461
+ stage = by_number.get(meta.stage_number)
462
+ if stage is None:
463
+ errs.append(ValidationError("S3", meta.stage_number,
464
+ "stageMap row has no matching stages[] entry"))
465
+ continue
466
+ actual = len([
467
+ s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)
468
+ ])
469
+ if meta.step_count != actual:
470
+ errs.append(ValidationError("S4", meta.stage_number,
471
+ f"stageMap stepCount {meta.step_count} != "
472
+ f"{actual} stepwiseExecution rows"))
473
+ for number in sorted(n for n in by_number if n not in {m.stage_number for m in stage_map}):
474
+ errs.append(ValidationError("S3", number,
475
+ "stages[] entry has no matching stageMap row"))
476
+ return errs
477
+
478
+
479
+ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
480
+ """The S-checks that schema v2 cannot express, over `implementationPlanning`.
481
+
482
+ `collect_validation_errors` scans rendered v1 Markdown, and
483
+ `validate_phase_boundary` returns before calling it for a v2 report — so on
484
+ the current schema nothing enforced the depends-on DAG, parallel-stage file
485
+ safety, RED→GREEN ordering, or the TDD-exemption vocabulary. The schema
486
+ covers presence and cardinality; this covers the relationships between
487
+ fields, which is what a JSON Schema has no way to say.
488
+ """
489
+ stage_map, errors = _data_stage_metas(planning.get("stageMap") or [])
490
+ stages = [s for s in (planning.get("stages") or []) if isinstance(s, dict)]
491
+ if not stage_map and not stages:
492
+ return errors
493
+
494
+ errors.extend(_check_data_step_counts(stage_map, stages))
495
+ errors.extend(_check_depends_on(stage_map))
496
+ errors.extend(_report_shared_parallel_files({
497
+ meta.stage_number: set(PATH_TOKEN.findall(
498
+ str((next(
499
+ (s for s in stages if s.get("stage") == meta.stage_number), {}
500
+ )).get("exitContract") or "")
501
+ ))
502
+ for meta in stage_map
503
+ if not meta.depends_on
504
+ }))
505
+ for stage in stages:
506
+ errors.extend(_check_data_slice_tdd(stage))
507
+ return errors
508
+
509
+
407
510
  def main(argv: List[str]) -> int:
408
511
  p = argparse.ArgumentParser()
409
512
  p.add_argument("--plan", required=True)
@@ -3249,6 +3249,10 @@ def validate_final_report_data(
3249
3249
  (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
3250
3250
  ):
3251
3251
  print(f"validate-run: warning: {warning}", file=sys.stderr)
3252
+ for warning in _detect_uniform_verifier(
3253
+ (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
3254
+ ):
3255
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
3252
3256
  for warning in _detect_unmapped_incremental_fallback(data, report_path):
3253
3257
  print(f"validate-run: warning: {warning}", file=sys.stderr)
3254
3258
  for warning in _detect_missing_dependency_precondition(
@@ -3259,6 +3263,7 @@ def validate_final_report_data(
3259
3263
  _validate_self_fix_grouping(data, failures)
3260
3264
  _validate_clarification_evidence_note(data, failures)
3261
3265
  _validate_plan_body_verdict_provenance(data, report_path, failures)
3266
+ _validate_reverify_result_addresses_prior_dissent(data, report_path, failures)
3262
3267
  _validate_aborted_gate_has_clarification(data, failures)
3263
3268
  _validate_round_recorded_verdicts(data, failures)
3264
3269
  _validate_verdicts_match_current_subjects(data, failures)
@@ -5048,6 +5053,111 @@ def _validate_plan_body_verdict_provenance(
5048
5053
  )
5049
5054
 
5050
5055
 
5056
+ _UNIFORM_VERIFIER_MIN_ITEMS = 5
5057
+
5058
+
5059
+ def _detect_uniform_verifier(pbv: dict) -> list[str]:
5060
+ """Verifiers whose every vote in the round was the same verdict.
5061
+
5062
+ `participatingAnalysers` counts whether a worker voted, not whether the
5063
+ votes carried information. A verifier that answers AGREE to every item is
5064
+ counted as a third opinion while contributing no refutation signal, so the
5065
+ report reads as a three-way cross-check backed by two. (fontsninja-nlpvibe
5066
+ `nlpvibe-vs-fontradar-baseline` seq 001: 63/63 AGREE off six inspected
5067
+ evidence paths, on a round where the two other analysers jointly refuted a
5068
+ real defect.)
5069
+
5070
+ Advisory only. A unanimous round is a legitimate outcome, and any ratio
5071
+ strict enough to catch a rubber stamp also fails honest agreement, so this
5072
+ reports the counts and leaves the judgement to the reader.
5073
+ """
5074
+ items = pbv.get("planItems") if isinstance(pbv, dict) else None
5075
+ if not isinstance(items, list):
5076
+ return []
5077
+ verdicts_by_worker: dict[str, set[str]] = {}
5078
+ counts: dict[str, int] = {}
5079
+ for item in items:
5080
+ if not isinstance(item, dict):
5081
+ continue
5082
+ for verdict in item.get("verdicts") or []:
5083
+ if not isinstance(verdict, dict):
5084
+ continue
5085
+ worker = str(verdict.get("worker") or "").strip()
5086
+ value = str(verdict.get("verdict") or "").strip()
5087
+ if not worker or not value or value == "verification-error":
5088
+ continue
5089
+ verdicts_by_worker.setdefault(worker, set()).add(value)
5090
+ counts[worker] = counts.get(worker, 0) + 1
5091
+ warnings = []
5092
+ for worker in sorted(verdicts_by_worker):
5093
+ distinct = verdicts_by_worker[worker]
5094
+ total = counts[worker]
5095
+ if len(distinct) != 1 or total < _UNIFORM_VERIFIER_MIN_ITEMS:
5096
+ continue
5097
+ warnings.append(
5098
+ f"plan-body verification: {worker} returned `{next(iter(distinct))}` "
5099
+ f"for all {total} items it voted on, so this round's refutation "
5100
+ "signal came from its peers alone. Confirm the worker actually "
5101
+ "opened the cited evidence (its `-audit-` sidecar lists what it "
5102
+ "read) before reading the gate as a full cross-check."
5103
+ )
5104
+ return warnings
5105
+
5106
+
5107
+ _PRIOR_DISSENT_ANCHOR = "**Prior dissent**"
5108
+ _PLAN_VERIFY_ROUND_RE = re.compile(r"-plan-verify-r(?P<round>\d+)-")
5109
+
5110
+
5111
+ def _plan_verify_round_number(file_name: str) -> int | None:
5112
+ match = _PLAN_VERIFY_ROUND_RE.search(file_name)
5113
+ return int(match.group("round")) if match else None
5114
+
5115
+
5116
+ def _validate_reverify_result_addresses_prior_dissent(
5117
+ data: dict,
5118
+ report_path: Path,
5119
+ failures: list[str],
5120
+ ) -> None:
5121
+ """A round 2+ plan-body verdict must engage the dissent it re-verifies.
5122
+
5123
+ The self-fix loop re-dispatches the same workers against a corrected plan,
5124
+ but nothing carried the previous round's objection into the new prompt. The
5125
+ worker that objected then restates its verdict unchanged and its peers
5126
+ re-judge from nothing, so the loop spends its whole `selfFixMaxRounds`
5127
+ budget re-deriving one split instead of settling it. (fontsninja-nlpvibe
5128
+ `nlpvibe-vs-fontradar-baseline` seq 001: three self-fix rounds to
5129
+ `max-rounds-reached`, gate still `blocked-by-disagreement`, codex holding
5130
+ every DISAGREE it opened.)
5131
+ """
5132
+ worker_results_dir = report_path.parent.parent / "worker-results"
5133
+ if not worker_results_dir.is_dir():
5134
+ return
5135
+ task_type = str((data.get("header") or {}).get("taskType") or "")
5136
+ seq = _report_run_seq(report_path)
5137
+ pattern = f"*-plan-verify-r*-{task_type}-{seq or '*'}.md"
5138
+ silent = []
5139
+ for path in sorted(worker_results_dir.glob(pattern)):
5140
+ if "-audit-plan-verify-r" in path.name:
5141
+ continue
5142
+ round_number = _plan_verify_round_number(path.name)
5143
+ if round_number is None or round_number < 2:
5144
+ continue
5145
+ body = path.read_text(encoding="utf-8", errors="replace")
5146
+ if _PRIOR_DISSENT_ANCHOR not in body:
5147
+ silent.append(path.name)
5148
+ if silent:
5149
+ failures.append(
5150
+ "plan-body re-verification: "
5151
+ f"{silent} carry no `{_PRIOR_DISSENT_ANCHOR}` line. A round 2+ "
5152
+ "verdict exists to settle the previous round's objection, so the "
5153
+ "prompt MUST carry that dissent forward and the worker MUST answer "
5154
+ "whether the correction resolved it. Without it the objecting "
5155
+ "worker repeats its verdict and its peers judge from nothing, and "
5156
+ "the self-fix budget drains on the same split "
5157
+ '(plan-body-verification.md §"Re-verification rounds (round 2+)").'
5158
+ )
5159
+
5160
+
5051
5161
  def _validate_plan_item_extraction_completeness(
5052
5162
  data: dict,
5053
5163
  failures: list[str],
@@ -6177,6 +6287,31 @@ def _load_stage_validator():
6177
6287
  return mod
6178
6288
 
6179
6289
 
6290
+ def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) -> None:
6291
+ """Run the stage relationship checks that schema v2 cannot express.
6292
+
6293
+ `_append_stage_structure_failures` scans rendered Markdown and sits after
6294
+ the v2 early return in `validate_phase_boundary`, so for a v2 report the
6295
+ depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and the
6296
+ TDD-exemption vocabulary had nothing enforcing them. The same validator
6297
+ owns both modes so the rule vocabulary stays defined once.
6298
+ """
6299
+ if (data or {}).get("schemaVersion") != "2.0":
6300
+ return # v1 reports are covered by the Markdown scan.
6301
+ planning = (data or {}).get("implementationPlanning")
6302
+ if not isinstance(planning, Mapping):
6303
+ return # Schema validation already reported the missing block.
6304
+ mod = _load_stage_validator()
6305
+ if mod is None: # pragma: no cover — repo/runtime always ship the file
6306
+ failures.append(f"cannot load Stage Map validator at {_STAGE_VALIDATOR_PATH}")
6307
+ return
6308
+ for e in mod.collect_data_validation_errors(dict(planning)):
6309
+ failures.append(
6310
+ f"implementation-planning stage contract invalid "
6311
+ f"[{e.code} stage={e.stage}]: {e.message}"
6312
+ )
6313
+
6314
+
6180
6315
  def _append_stage_structure_failures(content: str, failures: list[str]) -> None:
6181
6316
  """Enforce the Stage Map structural contract at the implementation-planning
6182
6317
  boundary. Without this, a plan missing `## 5.5 Stage Map` passes the
@@ -7420,6 +7555,7 @@ def main() -> int:
7420
7555
  validation_data, brief_path, failures
7421
7556
  )
7422
7557
  _validate_stage_has_requirement(validation_data, failures)
7558
+ _append_stage_data_failures(validation_data, failures)
7423
7559
  if task_type == "improvement-discovery":
7424
7560
  run_dir = report_path.parent.parent
7425
7561
  _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
@@ -5,10 +5,12 @@ defined in skills/okstra-schedule-gen/SKILL.md.
5
5
 
6
6
  Usage:
7
7
  python3 validators/validate-schedule.py <path-to-schedule.md>
8
+ python3 validators/validate-schedule.py <path-to-schedule.md> \
9
+ --selection-json <selection.json>
8
10
 
9
- Exits 0 if compliant, 1 with a list of violations otherwise. Intended to be
10
- called by the okstra-schedule-gen skill (self-validation step) and by humans /
11
- hooks before committing a schedule.
11
+ Exits 0 if compliant, 1 with a list of violations, or 2 for invalid arguments.
12
+ Intended to be called by the okstra-schedule-gen skill (self-validation step)
13
+ and by humans / hooks before committing a schedule.
12
14
  """
13
15
 
14
16
  from __future__ import annotations
@@ -25,6 +27,11 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
25
27
  sys.path.insert(0, str(_ssot_dir))
26
28
 
27
29
  from okstra_ctl.md_table import split_pipe_row # noqa: E402
30
+ from okstra_ctl.schedule_semantics import ( # noqa: E402
31
+ ScheduleSemanticError,
32
+ parse_effort_ranges,
33
+ validate_schedule_semantics,
34
+ )
28
35
 
29
36
  REQUIRED_SECTIONS_IN_ORDER: list[str] = [
30
37
  "## At a Glance",
@@ -142,12 +149,31 @@ DECISION_ITEM_ALLOWLIST = set() # nothing legitimate matches this shape
142
149
  MILESTONE_RE = re.compile(r"\bM(\d+)\b")
143
150
 
144
151
 
145
- def validate(path: Path) -> list[str]:
152
+ def _outside_fenced_lines(text: str) -> list[str]:
153
+ visible: list[str] = []
154
+ fence_marker = ""
155
+ for line in text.splitlines():
156
+ stripped = line.lstrip()
157
+ marker = stripped[:3] if stripped.startswith(("```", "~~~")) else ""
158
+ if fence_marker:
159
+ visible.append("")
160
+ if marker == fence_marker:
161
+ fence_marker = ""
162
+ elif marker:
163
+ fence_marker = marker
164
+ visible.append("")
165
+ else:
166
+ visible.append(line)
167
+ return visible
168
+
169
+
170
+ def _validate_format(path: Path) -> list[str]:
146
171
  if not path.exists():
147
172
  return [f"file not found: {path}"]
148
173
 
149
174
  text = path.read_text(encoding="utf-8")
150
175
  lines = text.splitlines()
176
+ visible_lines = _outside_fenced_lines(text)
151
177
  violations: list[str] = []
152
178
 
153
179
  # 1. Title must end with "— Work Schedule"
@@ -172,7 +198,7 @@ def validate(path: Path) -> list[str]:
172
198
  # Phase 1: Critical Fixes.
173
199
  section_positions: dict[str, int] = {}
174
200
  optional_positions: dict[str, int] = {}
175
- for idx, line in enumerate(lines):
201
+ for idx, line in enumerate(visible_lines):
176
202
  stripped = line.rstrip()
177
203
  if stripped in REQUIRED_SECTIONS_IN_ORDER and stripped not in section_positions:
178
204
  section_positions[stripped] = idx
@@ -186,6 +212,14 @@ def validate(path: Path) -> list[str]:
186
212
  for s in missing:
187
213
  violations.append(f"missing required section: {s!r}")
188
214
 
215
+ for authority in ("## At a Glance", REQUIRED_EXEC_SUMMARY_SUBSECTION):
216
+ count = sum(line.rstrip() == authority for line in visible_lines)
217
+ if count > 1:
218
+ violations.append(
219
+ f"semantic authority {authority!r} must appear exactly once; "
220
+ f"found {count}"
221
+ )
222
+
189
223
  if not missing:
190
224
  ordered_actual = sorted(section_positions, key=lambda s: section_positions[s])
191
225
  if ordered_actual != REQUIRED_SECTIONS_IN_ORDER:
@@ -206,10 +240,19 @@ def validate(path: Path) -> list[str]:
206
240
  "'## Task Dependency Graph' and '## Phase 1: Critical Fixes'"
207
241
  )
208
242
  # 4. Executive Summary subsection
209
- if REQUIRED_EXEC_SUMMARY_SUBSECTION not in text:
243
+ effort_heading_count = sum(
244
+ line.rstrip() == REQUIRED_EXEC_SUMMARY_SUBSECTION
245
+ for line in visible_lines
246
+ )
247
+ if effort_heading_count == 0:
210
248
  violations.append(
211
249
  f"missing subsection {REQUIRED_EXEC_SUMMARY_SUBSECTION!r} inside Executive Summary"
212
250
  )
251
+ elif effort_heading_count == 1:
252
+ try:
253
+ parse_effort_ranges(text)
254
+ except ScheduleSemanticError as exc:
255
+ violations.append(str(exc))
213
256
 
214
257
  # 5. Forbidden translated/extra headings
215
258
  for match in FORBIDDEN_HEADINGS_RE.finditer(text):
@@ -503,6 +546,16 @@ def validate(path: Path) -> list[str]:
503
546
  return violations
504
547
 
505
548
 
549
+ def validate(
550
+ path: Path, selection_path: Path | None = None,
551
+ ) -> list[str]:
552
+ violations = _validate_format(path)
553
+ if violations or selection_path is None:
554
+ return violations
555
+ text = path.read_text(encoding="utf-8")
556
+ return violations + validate_schedule_semantics(text, selection_path)
557
+
558
+
506
559
  def _strip_code_fences(text: str) -> str:
507
560
  """Remove ``` fenced blocks so opaque-id regex doesn't false-positive
508
561
  on shell commands, file paths, etc."""
@@ -654,11 +707,26 @@ def _check_self_contained_identifiers(text: str, lines: list[str],
654
707
 
655
708
 
656
709
  def main(argv: list[str]) -> int:
657
- if len(argv) != 2:
658
- print(f"usage: {argv[0]} <path-to-schedule.md>", file=sys.stderr)
710
+ usage = (
711
+ f"usage: {argv[0]} <path-to-schedule.md> "
712
+ "[--selection-json <selection.json>]"
713
+ )
714
+ args = argv[1:]
715
+ if len(args) == 1 and not args[0].startswith("-"):
716
+ path = Path(args[0])
717
+ selection_path = None
718
+ elif (
719
+ len(args) == 3
720
+ and args[1] == "--selection-json"
721
+ and not args[0].startswith("-")
722
+ and not args[2].startswith("-")
723
+ ):
724
+ path = Path(args[0])
725
+ selection_path = Path(args[2])
726
+ else:
727
+ print(usage, file=sys.stderr)
659
728
  return 2
660
- path = Path(argv[1])
661
- violations = validate(path)
729
+ violations = validate(path, selection_path)
662
730
  if not violations:
663
731
  print(f"OK: {path} conforms to okstra-schedule-gen Section Contract")
664
732
  return 0