okstra 0.152.0 → 0.154.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 (30) hide show
  1. package/README.md +1 -1
  2. package/bin/okstra +7 -0
  3. package/docs/cli.md +5 -1
  4. package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
  5. package/docs/project-structure-overview.md +2 -2
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/bin/okstra-antigravity-exec.sh +11 -6
  9. package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
  10. package/runtime/prompts/lead/convergence.md +3 -2
  11. package/runtime/prompts/lead/plan-body-verification.md +30 -1
  12. package/runtime/python/okstra_ctl/container.py +9 -10
  13. package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
  14. package/runtime/python/okstra_ctl/handoff.py +4 -8
  15. package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
  16. package/runtime/python/okstra_ctl/model_discovery.py +22 -1
  17. package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
  18. package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
  19. package/runtime/python/okstra_ctl/run.py +8 -54
  20. package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
  21. package/runtime/python/okstra_ctl/stage_map.py +288 -0
  22. package/runtime/python/okstra_ctl/wizard.py +24 -35
  23. package/runtime/python/okstra_project/state.py +19 -5
  24. package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
  25. package/runtime/templates/reports/schedule.template.md +9 -9
  26. package/runtime/validators/detect_self_mock.py +27 -2
  27. package/runtime/validators/validate-implementation-plan-stages.py +24 -63
  28. package/runtime/validators/validate-run.py +110 -0
  29. package/runtime/validators/validate-schedule.py +78 -10
  30. package/src/commands/inspect/stage-map.mjs +1 -1
@@ -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
-
@@ -41,8 +41,10 @@ redirecting ``--waivers`` at a self-authored file blocks instead of passing.
41
41
  from __future__ import annotations
42
42
 
43
43
  import argparse
44
+ import io
44
45
  import json
45
46
  import sys
47
+ import tokenize
46
48
  from datetime import datetime, timezone
47
49
  from pathlib import Path
48
50
 
@@ -91,16 +93,39 @@ def scannable_files(paths: list[Path]) -> list[Path]:
91
93
  return [p for p in paths if is_scannable(p)]
92
94
 
93
95
 
96
+ def _mask_python_non_code(text: str) -> str:
97
+ """Blank Python strings and comments without moving any match positions."""
98
+ chars = list(text)
99
+ line_offsets = [0]
100
+ for line in text.splitlines(keepends=True):
101
+ line_offsets.append(line_offsets[-1] + len(line))
102
+ try:
103
+ tokens = tokenize.generate_tokens(io.StringIO(text).readline)
104
+ for token in tokens:
105
+ if token.type not in {tokenize.STRING, tokenize.COMMENT}:
106
+ continue
107
+ start = line_offsets[token.start[0] - 1] + token.start[1]
108
+ end = line_offsets[token.end[0] - 1] + token.end[1]
109
+ for index in range(start, end):
110
+ if chars[index] not in "\r\n":
111
+ chars[index] = " "
112
+ except (tokenize.TokenError, IndentationError, SyntaxError):
113
+ # Broken source is scanned conservatively so a syntax error cannot hide a hit.
114
+ return text
115
+ return "".join(chars)
116
+
117
+
94
118
  def scan_files(paths: list[Path]) -> list[dict]:
95
119
  """Return one hit dict ``{file, line, signal}`` per signal match."""
96
120
  hits: list[dict] = []
97
121
  for p in scannable_files(paths):
98
122
  lang = EXT_TO_LANG[p.suffix]
99
123
  text = p.read_text(encoding="utf-8", errors="replace")
124
+ scan_text = _mask_python_non_code(text) if lang == "python" else text
100
125
  file_hits: list[dict] = []
101
126
  for sig in SIGNALS.get(lang, []):
102
- for m in sig.pattern.finditer(text):
103
- line = text[: m.start()].count("\n") + 1
127
+ for m in sig.pattern.finditer(scan_text):
128
+ line = scan_text[: m.start()].count("\n") + 1
104
129
  file_hits.append({"file": str(p), "line": line, "signal": sig.name})
105
130
  hits.extend(sorted(file_hits, key=lambda h: (h["line"], h["signal"])))
106
131
  return hits
@@ -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
@@ -60,11 +56,7 @@ def _check_stage_map_present(text: str) -> List[ValidationError]:
60
56
 
61
57
 
62
58
  def _parse_depends_on_cell(raw: str) -> List[int] | None:
63
- """Stage numbers in a `depends-on` cell; None when the cell is unparseable.
64
-
65
- Schema v2 keeps the same literal cell text in `stageMap[].dependsOn`, so
66
- both the Markdown scan and the data scan read it through here.
67
- """
59
+ """Stage numbers from schema-v2 `stageMap[].dependsOn`."""
68
60
  value = raw.strip()
69
61
  if value in ("(none)", ""):
70
62
  return []
@@ -74,7 +66,9 @@ def _parse_depends_on_cell(raw: str) -> List[int] | None:
74
66
  return None
75
67
 
76
68
 
77
- def _stage_numbers_monotonic(stages: List[StageMeta]) -> List[ValidationError]:
69
+ def _stage_numbers_monotonic(
70
+ stages: List[StageMapStage],
71
+ ) -> List[ValidationError]:
78
72
  return [
79
73
  ValidationError("S2", r.stage_number,
80
74
  f"stage numbers must be 1..N monotonic, got {r.stage_number} at row {i}")
@@ -82,43 +76,6 @@ def _stage_numbers_monotonic(stages: List[StageMeta]) -> List[ValidationError]:
82
76
  if r.stage_number != i
83
77
  ]
84
78
 
85
-
86
- def _parse_stage_map(text: str) -> Tuple[List[StageMeta], List[ValidationError]]:
87
- m = STAGE_MAP_HEADING.search(text)
88
- if not m:
89
- return [], [] # S1 already reported
90
- body = text[m.end():]
91
- rows = []
92
- for line in body.splitlines():
93
- if line.startswith("##"):
94
- break
95
- if not line.strip().startswith("|"):
96
- continue
97
- cells = split_pipe_row(line)
98
- if len(cells) != 5:
99
- continue
100
- # skip header and separator rows (all-dash of any length is covered by set check)
101
- if cells[0] == "stage" or set(cells[0]) <= set("-"):
102
- continue
103
- try:
104
- n = int(cells[0])
105
- except ValueError:
106
- continue
107
- depends = _parse_depends_on_cell(cells[2])
108
- if depends is None:
109
- # 비정수 depends_on 셀 → 행 skip (비정수 stage_number 와 동일). raw
110
- # ValueError 를 흘리면 _stage_map_reject_detail·handoff 의 except
111
- # PrepareError 를 우회해 traceback 으로 죽으므로, 누락된 행을 하류
112
- # S2(비단조) 검사가 잡게 한다.
113
- continue
114
- try:
115
- step_count = int(cells[3])
116
- except ValueError:
117
- step_count = -1
118
- rows.append(StageMeta(n, cells[1], depends, step_count, cells[4]))
119
- return rows, _stage_numbers_monotonic(rows)
120
-
121
-
122
79
  def _slice_stage_section(text: str, stage_number: int) -> str:
123
80
  """Return the body of `## 5.5.<n> Stage <n>:` up to the next stage heading."""
124
81
  start_m = re.search(
@@ -165,7 +122,7 @@ def _count_effective_steps(section: str) -> int:
165
122
  return len(_effective_step_rows(section))
166
123
 
167
124
 
168
- 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]:
169
126
  errs: List[ValidationError] = []
170
127
  for s in stages:
171
128
  if not re.search(
@@ -227,7 +184,7 @@ def _exemption_reason_allowed(section: str) -> bool:
227
184
  return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)
228
185
 
229
186
 
230
- def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError]:
187
+ def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
231
188
  """S10: each stage declares a vertical slice and follows RED→GREEN ordering.
232
189
 
233
190
  S10a — `Slice value:` line with a non-empty value.
@@ -305,7 +262,7 @@ def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationEr
305
262
 
306
263
 
307
264
  def _check_conformance_declaration(
308
- text: str, stages: List[StageMeta]
265
+ text: str, stages: List[StageMapStage]
309
266
  ) -> List[ValidationError]:
310
267
  """S11: 각 stage 는 conformance 검증을 선언하거나 명시적으로 면제한다.
311
268
 
@@ -326,7 +283,7 @@ def _check_conformance_declaration(
326
283
  return errs
327
284
 
328
285
 
329
- def _check_depends_on(stages: List[StageMeta]) -> List[ValidationError]:
286
+ def _check_depends_on(stages: List[StageMapStage]) -> List[ValidationError]:
330
287
  errs: List[ValidationError] = []
331
288
  valid = {s.stage_number for s in stages}
332
289
  for s in stages:
@@ -387,7 +344,9 @@ def _report_shared_parallel_files(files: dict) -> List[ValidationError]:
387
344
  return errs
388
345
 
389
346
 
390
- def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[ValidationError]:
347
+ def _check_parallel_safety(
348
+ text: str, stages: List[StageMapStage],
349
+ ) -> List[ValidationError]:
391
350
  """S9: two `depends-on (none)` stages must not predict the same file —
392
351
  otherwise two parallel implementation runs would edit it concurrently."""
393
352
  return _report_shared_parallel_files({
@@ -411,8 +370,10 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
411
370
  return present
412
371
 
413
372
  errors: List[ValidationError] = []
414
- stages, s2_errs = _parse_stage_map(text)
415
- 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)]
416
377
  if stages:
417
378
  errors.extend(_check_each_stage_section(text, stages))
418
379
  errors.extend(_check_slice_tdd(text, stages))
@@ -424,7 +385,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
424
385
 
425
386
  def _data_stage_metas(
426
387
  stage_map: List[dict],
427
- ) -> Tuple[List[StageMeta], List[ValidationError]]:
388
+ ) -> Tuple[List[StageMapStage], List[ValidationError]]:
428
389
  rows = []
429
390
  for row in stage_map:
430
391
  if not isinstance(row, dict) or not isinstance(row.get("stage"), int):
@@ -432,7 +393,7 @@ def _data_stage_metas(
432
393
  depends = _parse_depends_on_cell(str(row.get("dependsOn") or ""))
433
394
  if depends is None:
434
395
  continue
435
- rows.append(StageMeta(
396
+ rows.append(StageMapStage(
436
397
  row["stage"],
437
398
  str(row.get("title") or ""),
438
399
  depends,
@@ -486,7 +447,7 @@ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
486
447
 
487
448
 
488
449
  def _check_data_step_counts(
489
- stage_map: List[StageMeta], stages: List[dict]
450
+ stage_map: List[StageMapStage], stages: List[dict]
490
451
  ) -> List[ValidationError]:
491
452
  """The Stage Map row is the plan's own index of its stage body. A row
492
453
  claiming a step count the body does not have makes the map unusable for
@@ -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],
@@ -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
@@ -7,7 +7,7 @@ Usage:
7
7
  okstra stage-map <task-key> --cwd <dir> Resolve PROJECT_ROOT from <dir>
8
8
  okstra stage-map <task-key> --project <dir> Use <dir> directly as PROJECT_ROOT
9
9
 
10
- Output: JSON { ok, taskKey, taskRoot, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }.
10
+ Output: JSON { ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }.
11
11
  stages is [] when no implementation-planning Stage Map exists.
12
12
  `;
13
13