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
@@ -0,0 +1,1249 @@
1
+ """Selection-backed schedule effort, Work Breakdown, and Gantt validation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from decimal import Decimal
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .md_table import is_separator_row, split_pipe_row
12
+
13
+
14
+ _ROOT_FIELDS = {"schemaVersion", "tasks"}
15
+ _TASK_FIELDS = {
16
+ "taskKey",
17
+ "taskId",
18
+ "state",
19
+ "sourcePlanPath",
20
+ "selectedStages",
21
+ "doneStages",
22
+ "stages",
23
+ }
24
+ _STAGE_FIELDS = {"stageNumber", "title", "dependsOn", "stepCount"}
25
+ _TASK_HEADING_RE = re.compile(
26
+ r"^###\s+(\d+-\d+)\.\s+(\S+)\s+—(?:\s+.*)?$", re.MULTILINE
27
+ )
28
+ _WORK_BREAKDOWN_HEADER = ["Stage", "Title", "Steps", "Depends On", "Days"]
29
+ _WORK_BREAKDOWN_SEPARATOR = ["---:", "---", "---:", "---", "---:"]
30
+ _AT_A_GLANCE_HEADER = [
31
+ "#", "Task ID", "Title", "Category", "Priority", "Effort", "Days",
32
+ "taskType", "Risk", "Phase",
33
+ ]
34
+ _EFFORT_HEADER = ["Size", "Criteria", "Day(s)"]
35
+ _EFFORT_SIZES = {"S", "M", "L", "XL", "XXL"}
36
+ _DAY_NUMBER = r"\d+(?:\.\d+)?"
37
+ _DAY_RANGE_RE = re.compile(
38
+ rf"^\s*({_DAY_NUMBER})\s*(?:~|-)\s*({_DAY_NUMBER})\s*$"
39
+ )
40
+ _EFFORT_TOTAL_RE = re.compile(
41
+ rf"\*\*\d+\s+tasks\s+total\s*/\s*estimated\s+effort:\s*"
42
+ rf"({_DAY_NUMBER})\s*~\s*({_DAY_NUMBER})\s+days?\s*"
43
+ r"\(Effort\s+sum\)\*\*"
44
+ )
45
+ _PLAIN_FENCE_RE = re.compile(
46
+ r"^```[ \t]*$\n(.*?)^```[ \t]*$", re.MULTILINE | re.DOTALL
47
+ )
48
+ _GANTT_ROW_RE = re.compile(
49
+ rf"^\s*(\S+)/S(\d+)\s+.*\bdays=({_DAY_NUMBER})~({_DAY_NUMBER})\s*$"
50
+ )
51
+ _GANTT_STAGE_CANDIDATE_RE = re.compile(r"^\s*(\S+)/S(\d+)(?:\s|$)")
52
+ _XXL_DAY_RANGE_RE = re.compile(rf"^\s*{_DAY_NUMBER}\s*-\s*$")
53
+ _TASK_SUBSECTION_PREFIXES = (
54
+ "**Problem**:",
55
+ "**Solution**:",
56
+ "**Work Breakdown**:",
57
+ "**Verification Commands**:",
58
+ "**Rollback**:",
59
+ )
60
+ HALF_DAY = Decimal("0.5")
61
+
62
+
63
+ class ScheduleSemanticError(ValueError):
64
+ pass
65
+
66
+
67
+ def _round_half_day(value: Decimal) -> Decimal:
68
+ return (value / HALF_DAY).quantize(Decimal("1")) * HALF_DAY
69
+
70
+
71
+ def _half_day_units(value: Decimal) -> int:
72
+ units = value / HALF_DAY
73
+ if units != units.to_integral_value():
74
+ raise ScheduleSemanticError(
75
+ "effort: stage totals must use 0.5-day increments"
76
+ )
77
+ return int(units)
78
+
79
+
80
+ def _validate_stage_day_allocation(
81
+ total: tuple[Decimal, Decimal], step_counts: tuple[int, ...],
82
+ allocated: list[tuple[Decimal, Decimal]],
83
+ ) -> None:
84
+ invalid_range = any(
85
+ lower < 0 or upper < 0 or lower > upper
86
+ for lower, upper in allocated
87
+ )
88
+ lower_total = sum((lower for lower, _ in allocated), Decimal("0"))
89
+ upper_total = sum((upper for _, upper in allocated), Decimal("0"))
90
+ if invalid_range or (lower_total, upper_total) != total:
91
+ raise ScheduleSemanticError(
92
+ "effort: cannot represent proportional half-day allocation "
93
+ f"for step counts {step_counts} within {_format_day_range(total)}"
94
+ )
95
+ for lower, upper in allocated:
96
+ _half_day_units(lower)
97
+ _half_day_units(upper)
98
+
99
+
100
+ def allocate_stage_days(
101
+ total: tuple[Decimal, Decimal], step_counts: tuple[int, ...],
102
+ ) -> tuple[tuple[Decimal, Decimal], ...]:
103
+ if not step_counts or any(count <= 0 for count in step_counts):
104
+ raise ValueError("selected stage step counts must be positive")
105
+ if total[0] < 0 or total[0] > total[1]:
106
+ raise ScheduleSemanticError(
107
+ "effort: stage total must satisfy 0 <= lower <= upper"
108
+ )
109
+ _half_day_units(total[0])
110
+ _half_day_units(total[1])
111
+ total_steps = Decimal(sum(step_counts))
112
+ allocated: list[tuple[Decimal, Decimal]] = []
113
+ used_lower = Decimal("0")
114
+ used_upper = Decimal("0")
115
+ for count in step_counts[:-1]:
116
+ ratio = Decimal(count) / total_steps
117
+ current = (
118
+ _round_half_day(total[0] * ratio),
119
+ _round_half_day(total[1] * ratio),
120
+ )
121
+ allocated.append(current)
122
+ used_lower += current[0]
123
+ used_upper += current[1]
124
+ allocated.append((total[0] - used_lower, total[1] - used_upper))
125
+ _validate_stage_day_allocation(total, step_counts, allocated)
126
+ return tuple(allocated)
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class SelectionStage:
131
+ stage_number: int
132
+ title: str
133
+ depends_on: tuple[int, ...]
134
+ step_count: int
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class SelectionTask:
139
+ task_key: str
140
+ task_id: str
141
+ state: str
142
+ source_plan_path: str
143
+ selected_stages: tuple[int, ...]
144
+ done_stages: tuple[int, ...]
145
+ stages: tuple[SelectionStage, ...]
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class _BreakdownRow:
150
+ task_id: str
151
+ stage_number: int
152
+ title: str
153
+ step_count: int
154
+ depends_on: tuple[tuple[int, bool], ...]
155
+ days: tuple[Decimal, Decimal] | None
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class _AtAGlanceRow:
160
+ task_id: str
161
+ effort: str
162
+ days_text: str
163
+ days: tuple[Decimal, Decimal] | None
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class _GanttRow:
168
+ task_id: str
169
+ stage_number: int
170
+ days: tuple[Decimal, Decimal]
171
+
172
+
173
+ class StageMapSelectionError(ValueError):
174
+ pass
175
+
176
+
177
+ def _require_object(value: Any, context: str) -> dict[str, Any]:
178
+ if not isinstance(value, dict):
179
+ raise StageMapSelectionError(f"{context} must be an object")
180
+ return value
181
+
182
+
183
+ def _require_fields(
184
+ value: dict[str, Any], expected: set[str], context: str,
185
+ ) -> None:
186
+ if set(value) == expected:
187
+ return
188
+ missing = sorted(expected - set(value))
189
+ extra = sorted(set(value) - expected)
190
+ raise StageMapSelectionError(
191
+ f"{context} fields mismatch: missing={missing}, extra={extra}"
192
+ )
193
+
194
+
195
+ def _require_string(
196
+ value: dict[str, Any], field: str, context: str,
197
+ ) -> str:
198
+ result = value.get(field)
199
+ if not isinstance(result, str):
200
+ raise StageMapSelectionError(f"{context}.{field} must be a string")
201
+ return result
202
+
203
+
204
+ def _require_positive_int(value: Any, context: str) -> int:
205
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
206
+ raise StageMapSelectionError(f"{context} must be a positive integer")
207
+ return value
208
+
209
+
210
+ def _require_int_list(
211
+ value: dict[str, Any], field: str, context: str,
212
+ ) -> tuple[int, ...]:
213
+ items = value.get(field)
214
+ if not isinstance(items, list):
215
+ raise StageMapSelectionError(f"{context}.{field} must be an array")
216
+ result = tuple(
217
+ _require_positive_int(item, f"{context}.{field}[{index}]")
218
+ for index, item in enumerate(items)
219
+ )
220
+ if len(result) != len(set(result)):
221
+ raise StageMapSelectionError(f"{context}.{field} must be unique")
222
+ return result
223
+
224
+
225
+ def _parse_stage(value: Any, context: str) -> SelectionStage:
226
+ record = _require_object(value, context)
227
+ _require_fields(record, _STAGE_FIELDS, context)
228
+ title = _require_string(record, "title", context)
229
+ if not title:
230
+ raise StageMapSelectionError(f"{context}.title must not be empty")
231
+ return SelectionStage(
232
+ stage_number=_require_positive_int(
233
+ record.get("stageNumber"), f"{context}.stageNumber"
234
+ ),
235
+ title=title,
236
+ depends_on=_require_int_list(record, "dependsOn", context),
237
+ step_count=_require_positive_int(
238
+ record.get("stepCount"), f"{context}.stepCount"
239
+ ),
240
+ )
241
+
242
+
243
+ def _parse_stages(value: Any, context: str) -> tuple[SelectionStage, ...]:
244
+ if not isinstance(value, list):
245
+ raise StageMapSelectionError(f"{context}.stages must be an array")
246
+ stages = tuple(
247
+ _parse_stage(stage, f"{context}.stages[{index}]")
248
+ for index, stage in enumerate(value)
249
+ )
250
+ stage_numbers = [stage.stage_number for stage in stages]
251
+ if len(stage_numbers) != len(set(stage_numbers)):
252
+ raise StageMapSelectionError(f"{context}: duplicate stageNumber")
253
+ return stages
254
+
255
+
256
+ def _validate_stage_graph(
257
+ task_id: str,
258
+ stages_by_number: dict[int, SelectionStage],
259
+ ) -> None:
260
+ for stage in stages_by_number.values():
261
+ for dependency in stage.depends_on:
262
+ if dependency not in stages_by_number:
263
+ raise StageMapSelectionError(
264
+ f"task {task_id} stage {stage.stage_number}: "
265
+ f"dependency {dependency} is absent from stages"
266
+ )
267
+
268
+ completed: set[int] = set()
269
+ current_path: set[int] = set()
270
+ path: list[int] = []
271
+
272
+ def visit(stage_number: int) -> None:
273
+ if stage_number in completed:
274
+ return
275
+ if stage_number in current_path:
276
+ cycle_start = path.index(stage_number)
277
+ cycle = path[cycle_start:] + [stage_number]
278
+ rendered = " -> ".join(f"S{item}" for item in cycle)
279
+ raise ScheduleSemanticError(
280
+ f"task {task_id}: stage dependency cycle detected: {rendered}"
281
+ )
282
+ current_path.add(stage_number)
283
+ path.append(stage_number)
284
+ for dependency in stages_by_number[stage_number].depends_on:
285
+ visit(dependency)
286
+ path.pop()
287
+ current_path.remove(stage_number)
288
+ completed.add(stage_number)
289
+
290
+ for stage_number in stages_by_number:
291
+ visit(stage_number)
292
+
293
+
294
+ def _validate_task_selection(task: SelectionTask) -> None:
295
+ selected = set(task.selected_stages)
296
+ done = set(task.done_stages)
297
+ overlap = sorted(selected & done)
298
+ if overlap:
299
+ raise StageMapSelectionError(
300
+ f"task {task.task_id}: selected/done stage overlap {overlap}"
301
+ )
302
+ stages_by_number = {stage.stage_number: stage for stage in task.stages}
303
+ for stage_number in task.selected_stages:
304
+ if stage_number not in stages_by_number:
305
+ raise StageMapSelectionError(
306
+ f"task {task.task_id}: selected stage {stage_number} is absent"
307
+ )
308
+ _validate_stage_graph(task.task_id, stages_by_number)
309
+ allowed = selected | done
310
+ pending = list(task.selected_stages)
311
+ visited: set[int] = set()
312
+ while pending:
313
+ stage_number = pending.pop()
314
+ if stage_number in visited:
315
+ continue
316
+ visited.add(stage_number)
317
+ for dependency in stages_by_number[stage_number].depends_on:
318
+ if dependency not in allowed:
319
+ raise StageMapSelectionError(
320
+ f"task {task.task_id} stage {stage_number}: "
321
+ f"dependency {dependency} is outside doneStages or selectedStages"
322
+ )
323
+ pending.append(dependency)
324
+
325
+
326
+ def _validate_task_state(task: SelectionTask) -> None:
327
+ if task.state != "missing":
328
+ return
329
+ if any((
330
+ task.source_plan_path,
331
+ task.selected_stages,
332
+ task.done_stages,
333
+ task.stages,
334
+ )):
335
+ raise StageMapSelectionError(
336
+ f"task {task.task_id}: missing task must have empty sourcePlanPath, "
337
+ "selectedStages, doneStages, and stages"
338
+ )
339
+
340
+
341
+ def _parse_task(value: Any, index: int) -> SelectionTask:
342
+ context = f"tasks[{index}]"
343
+ record = _require_object(value, context)
344
+ _require_fields(record, _TASK_FIELDS, context)
345
+ state = _require_string(record, "state", context)
346
+ if state not in {"ready", "missing"}:
347
+ raise StageMapSelectionError(
348
+ f"{context}.state must be 'ready' or 'missing'"
349
+ )
350
+ task = SelectionTask(
351
+ task_key=_require_string(record, "taskKey", context),
352
+ task_id=_require_string(record, "taskId", context),
353
+ state=state,
354
+ source_plan_path=_require_string(record, "sourcePlanPath", context),
355
+ selected_stages=_require_int_list(record, "selectedStages", context),
356
+ done_stages=_require_int_list(record, "doneStages", context),
357
+ stages=_parse_stages(record.get("stages"), context),
358
+ )
359
+ if not task.task_key or not task.task_id:
360
+ raise StageMapSelectionError(f"{context} taskKey/taskId must not be empty")
361
+ _validate_task_state(task)
362
+ _validate_task_selection(task)
363
+ return task
364
+
365
+
366
+ def _reject_duplicate_task_identities(tasks: tuple[SelectionTask, ...]) -> None:
367
+ for field in ("task_key", "task_id"):
368
+ values = [getattr(task, field) for task in tasks]
369
+ duplicates = {value for value in values if values.count(value) > 1}
370
+ if duplicates:
371
+ label = "taskKey" if field == "task_key" else "taskId"
372
+ raise StageMapSelectionError(
373
+ f"duplicate {label}: {sorted(duplicates)[0]}"
374
+ )
375
+
376
+
377
+ def _strict_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
378
+ result: dict[str, Any] = {}
379
+ for key, value in pairs:
380
+ if key in result:
381
+ raise StageMapSelectionError(f"duplicate JSON key {key!r}")
382
+ result[key] = value
383
+ return result
384
+
385
+
386
+ def load_schedule_selection(path: Path) -> tuple[SelectionTask, ...]:
387
+ payload = json.loads(
388
+ path.read_text(encoding="utf-8"),
389
+ object_pairs_hook=_strict_json_object,
390
+ )
391
+ root = _require_object(payload, "selection")
392
+ _require_fields(root, _ROOT_FIELDS, "selection")
393
+ version = root.get("schemaVersion")
394
+ if type(version) is not int or version != 1:
395
+ raise StageMapSelectionError("selection.schemaVersion must be exactly 1")
396
+ values = root.get("tasks")
397
+ if not isinstance(values, list):
398
+ raise StageMapSelectionError("selection.tasks must be an array")
399
+ tasks = tuple(_parse_task(value, index) for index, value in enumerate(values))
400
+ _reject_duplicate_task_identities(tasks)
401
+ return tasks
402
+
403
+
404
+ def _parse_dependencies(
405
+ value: str, task_id: str, stage_number: int,
406
+ ) -> tuple[tuple[tuple[int, bool], ...], str | None]:
407
+ if value == "—":
408
+ return (), None
409
+ dependencies: list[tuple[int, bool]] = []
410
+ for token in value.split(","):
411
+ normalized = token.strip()
412
+ match = re.fullmatch(r"(\d+)( \(done\))?", normalized)
413
+ if match is None:
414
+ return (), (
415
+ f"work breakdown: stage {task_id}/S{stage_number} has invalid "
416
+ f"Depends On value {value!r}"
417
+ )
418
+ dependencies.append((int(match.group(1)), match.group(2) is not None))
419
+ return tuple(dependencies), None
420
+
421
+
422
+ def _mask_fenced_lines(lines: list[str]) -> list[str | None]:
423
+ masked: list[str | None] = []
424
+ fence_marker = ""
425
+ for line in lines:
426
+ stripped = line.lstrip()
427
+ marker = stripped[:3] if stripped.startswith(("```", "~~~")) else ""
428
+ if fence_marker:
429
+ masked.append(None)
430
+ if marker == fence_marker:
431
+ fence_marker = ""
432
+ elif marker:
433
+ fence_marker = marker
434
+ masked.append(None)
435
+ else:
436
+ masked.append(line)
437
+ return masked
438
+
439
+
440
+ def _mask_fenced_text(text: str) -> str:
441
+ lines = text.splitlines(keepends=True)
442
+ visible_lines = _mask_fenced_lines(lines)
443
+ return "".join(
444
+ line if visible is not None else re.sub(r"[^\r\n]", " ", line)
445
+ for line, visible in zip(lines, visible_lines)
446
+ )
447
+
448
+
449
+ def _section_bounds(
450
+ text: str, heading: str, max_level: int,
451
+ ) -> tuple[int, int] | None:
452
+ visible_text = _mask_fenced_text(text)
453
+ match = re.search(
454
+ rf"^{re.escape(heading)}[ \t]*$", visible_text, re.MULTILINE
455
+ )
456
+ if match is None:
457
+ return None
458
+ body_start = match.end()
459
+ body = visible_text[body_start:]
460
+ next_heading = re.search(
461
+ rf"^#{{1,{max_level}}}\s", body, re.MULTILINE
462
+ )
463
+ body_end = (
464
+ body_start + next_heading.start() if next_heading else len(text)
465
+ )
466
+ return body_start, body_end
467
+
468
+
469
+ def _section_body(text: str, heading: str, max_level: int) -> str:
470
+ bounds = _section_bounds(text, heading, max_level)
471
+ if bounds is None:
472
+ return ""
473
+ return _mask_fenced_text(text[bounds[0]:bounds[1]])
474
+
475
+
476
+ def _raw_section_body(text: str, heading: str, max_level: int) -> str | None:
477
+ bounds = _section_bounds(text, heading, max_level)
478
+ if bounds is None:
479
+ return None
480
+ return text[bounds[0]:bounds[1]]
481
+
482
+
483
+ def _ordered_day_range(
484
+ lower: Decimal, upper: Decimal, source: str,
485
+ ) -> tuple[Decimal, Decimal]:
486
+ if lower > upper:
487
+ raise ScheduleSemanticError(
488
+ f"{source}: lower must not exceed upper"
489
+ )
490
+ return lower, upper
491
+
492
+
493
+ def _parse_day_range(
494
+ value: str, source: str,
495
+ ) -> tuple[Decimal, Decimal] | None:
496
+ match = _DAY_RANGE_RE.fullmatch(value)
497
+ if match is None:
498
+ return None
499
+ return _ordered_day_range(
500
+ Decimal(match.group(1)), Decimal(match.group(2)), source
501
+ )
502
+
503
+
504
+ def parse_effort_ranges(
505
+ text: str,
506
+ ) -> dict[str, tuple[Decimal, Decimal] | None]:
507
+ section = _section_body(text, "### Effort Sizing Criteria", 3)
508
+ lines = section.splitlines()
509
+ header_indexes = [
510
+ index for index, line in enumerate(lines)
511
+ if split_pipe_row(line) == _EFFORT_HEADER
512
+ ]
513
+ if len(header_indexes) != 1:
514
+ raise ScheduleSemanticError(
515
+ "Effort Sizing Criteria requires exactly one canonical table"
516
+ )
517
+ header_index = header_indexes[0]
518
+ separator_index = header_index + 1
519
+ if (
520
+ separator_index >= len(lines)
521
+ or not is_separator_row(lines[separator_index])
522
+ or len(split_pipe_row(lines[separator_index])) != len(_EFFORT_HEADER)
523
+ ):
524
+ raise ScheduleSemanticError(
525
+ "Effort Sizing Criteria requires a canonical separator row"
526
+ )
527
+ ranges: dict[str, tuple[Decimal, Decimal] | None] = {}
528
+ for line in lines[header_index + 2:]:
529
+ if not line.strip().startswith("|"):
530
+ break
531
+ cells = split_pipe_row(line)
532
+ size = cells[0].strip().strip("*") if cells else ""
533
+ if len(cells) != 3 or size not in _EFFORT_SIZES:
534
+ raise ScheduleSemanticError(
535
+ f"Effort Sizing Criteria has malformed row {line!r}"
536
+ )
537
+ if size in ranges:
538
+ raise ScheduleSemanticError(
539
+ f"Effort Sizing Criteria has duplicate {size} row"
540
+ )
541
+ day_range = _parse_day_range(
542
+ cells[2], f"Effort Sizing Criteria {size}"
543
+ )
544
+ if size == "XXL":
545
+ if day_range is not None or _XXL_DAY_RANGE_RE.fullmatch(cells[2]) is None:
546
+ raise ScheduleSemanticError(
547
+ "Effort Sizing Criteria XXL has invalid Day(s)"
548
+ )
549
+ ranges[size] = None
550
+ continue
551
+ if day_range is None:
552
+ raise ScheduleSemanticError(
553
+ f"Effort Sizing Criteria {size} has invalid Day(s)"
554
+ )
555
+ ranges[size] = day_range
556
+ missing = sorted(_EFFORT_SIZES - set(ranges))
557
+ if missing:
558
+ raise ScheduleSemanticError(
559
+ "Effort Sizing Criteria is missing size row(s): "
560
+ + ", ".join(missing)
561
+ )
562
+ return ranges
563
+
564
+
565
+ def _parse_at_a_glance_table(
566
+ lines: list[str], header_index: int,
567
+ ) -> tuple[list[_AtAGlanceRow], list[str]]:
568
+ rows: list[_AtAGlanceRow] = []
569
+ violations: list[str] = []
570
+ for line in lines[header_index + 2:]:
571
+ if not line.strip().startswith("|"):
572
+ break
573
+ cells = split_pipe_row(line)
574
+ if cells == _AT_A_GLANCE_HEADER:
575
+ break
576
+ if len(cells) != len(_AT_A_GLANCE_HEADER):
577
+ task_id = cells[1] if len(cells) > 1 else "<unknown>"
578
+ violations.append(
579
+ f"At a Glance: noncanonical row for task {task_id} "
580
+ "requires 10 columns"
581
+ )
582
+ continue
583
+ effort_tokens = cells[5].split()
584
+ rows.append(_AtAGlanceRow(
585
+ task_id=cells[1],
586
+ effort=effort_tokens[0].strip("*") if effort_tokens else "",
587
+ days_text=cells[6],
588
+ days=_parse_day_range(
589
+ cells[6], f"At a Glance {cells[1]}"
590
+ ),
591
+ ))
592
+ return rows, violations
593
+
594
+
595
+ def _parse_at_a_glance_rows(
596
+ text: str,
597
+ ) -> tuple[list[_AtAGlanceRow], list[str]]:
598
+ section = _section_body(text, "## At a Glance", 2)
599
+ lines = section.splitlines()
600
+ header_indexes = [
601
+ index for index, line in enumerate(lines)
602
+ if split_pipe_row(line) == _AT_A_GLANCE_HEADER
603
+ ]
604
+ if not header_indexes:
605
+ return [], ["At a Glance: missing canonical 10-column table"]
606
+ rows: list[_AtAGlanceRow] = []
607
+ violations: list[str] = []
608
+ for header_index in header_indexes:
609
+ table_rows, table_violations = _parse_at_a_glance_table(
610
+ lines, header_index
611
+ )
612
+ rows.extend(table_rows)
613
+ violations.extend(table_violations)
614
+ return rows, violations
615
+
616
+
617
+ def _validated_at_a_glance_rows(
618
+ text: str, tasks: tuple[SelectionTask, ...],
619
+ ) -> tuple[dict[str, _AtAGlanceRow], list[str]]:
620
+ rows, violations = _parse_at_a_glance_rows(text)
621
+ indexed: dict[str, list[_AtAGlanceRow]] = {}
622
+ for row in rows:
623
+ indexed.setdefault(row.task_id, []).append(row)
624
+ known_task_ids = {task.task_id for task in tasks}
625
+ for task_id, matches in indexed.items():
626
+ if len(matches) > 1:
627
+ violations.append(f"At a Glance: duplicate Task ID {task_id}")
628
+ if task_id not in known_task_ids:
629
+ violations.append(f"At a Glance: unknown Task ID {task_id}")
630
+ for task in tasks:
631
+ if len(indexed.get(task.task_id, [])) != 1:
632
+ violations.append(
633
+ f"At a Glance: task {task.task_id} "
634
+ "requires exactly one canonical row"
635
+ )
636
+ validated = {
637
+ task_id: matches[0]
638
+ for task_id, matches in indexed.items()
639
+ if task_id in known_task_ids and len(matches) == 1
640
+ }
641
+ return validated, violations
642
+
643
+
644
+ def _format_day_range(day_range: tuple[Decimal, Decimal]) -> str:
645
+ return f"{day_range[0]:.1f} ~ {day_range[1]:.1f}"
646
+
647
+
648
+ def _sum_day_ranges(
649
+ ranges: list[tuple[Decimal, Decimal]],
650
+ ) -> tuple[Decimal, Decimal]:
651
+ return (
652
+ sum((day_range[0] for day_range in ranges), Decimal("0")),
653
+ sum((day_range[1] for day_range in ranges), Decimal("0")),
654
+ )
655
+
656
+
657
+ def _validate_at_a_glance_effort(
658
+ effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
659
+ tasks: tuple[SelectionTask, ...],
660
+ glance_rows: dict[str, _AtAGlanceRow],
661
+ ) -> list[str]:
662
+ missing_task_ids = {
663
+ task.task_id for task in tasks if task.state == "missing"
664
+ }
665
+ violations: list[str] = []
666
+ for row in glance_rows.values():
667
+ needs_planning = (
668
+ row.effort == "XXL" or row.task_id in missing_task_ids
669
+ )
670
+ if needs_planning:
671
+ if row.days_text != "[NEEDS-PLANNING]":
672
+ violations.append(
673
+ f"effort: {row.task_id} effort {row.effort} "
674
+ "requires [NEEDS-PLANNING]"
675
+ )
676
+ continue
677
+ if row.effort not in effort_ranges:
678
+ violations.append(
679
+ f"effort: {row.task_id} effort {row.effort} is absent from "
680
+ "Effort Sizing Criteria"
681
+ )
682
+ continue
683
+ expected = effort_ranges[row.effort]
684
+ if expected is None or row.days == expected:
685
+ continue
686
+ violations.append(
687
+ f"effort: {row.task_id} effort {row.effort} requires "
688
+ f"{_format_day_range(expected)} days"
689
+ )
690
+ return violations
691
+
692
+
693
+ def _work_breakdown_lines(block: str) -> list[str | None] | None:
694
+ lines = _mask_fenced_lines(block.splitlines())
695
+ label_index = next(
696
+ (
697
+ index for index, line in enumerate(lines)
698
+ if line == "**Work Breakdown**:"
699
+ ),
700
+ None,
701
+ )
702
+ if label_index is None:
703
+ return None
704
+ end = next(
705
+ (
706
+ index for index in range(label_index + 1, len(lines))
707
+ if lines[index] is not None
708
+ and lines[index].startswith(_TASK_SUBSECTION_PREFIXES)
709
+ ),
710
+ len(lines),
711
+ )
712
+ return lines[label_index + 1:end]
713
+
714
+
715
+ def _parse_breakdown_table(
716
+ task_id: str, block: str,
717
+ ) -> tuple[list[_BreakdownRow], list[str]]:
718
+ lines = _work_breakdown_lines(block)
719
+ if lines is None:
720
+ return [], []
721
+ violations: list[str] = []
722
+ header_indices = [
723
+ index for index, line in enumerate(lines)
724
+ if line is not None and split_pipe_row(line) == _WORK_BREAKDOWN_HEADER
725
+ ]
726
+ if len(header_indices) != 1:
727
+ violations.append(
728
+ f"work breakdown: task {task_id} must contain exactly one canonical header"
729
+ )
730
+ return [], violations
731
+ header_index = header_indices[0]
732
+ if (
733
+ header_index + 1 >= len(lines)
734
+ or lines[header_index + 1] is None
735
+ or split_pipe_row(lines[header_index + 1]) != _WORK_BREAKDOWN_SEPARATOR
736
+ ):
737
+ violations.append(
738
+ f"work breakdown: task {task_id} must use the canonical separator"
739
+ )
740
+ return [], violations
741
+ rows: list[_BreakdownRow] = []
742
+ for line in lines[header_index + 2:]:
743
+ if line is None or not line.strip().startswith("|"):
744
+ break
745
+ cells = split_pipe_row(line)
746
+ if len(cells) != 5 or not cells[0].isdigit() or not cells[2].isdigit():
747
+ violations.append(
748
+ f"work breakdown: malformed stage row for task {task_id}: {line!r}"
749
+ )
750
+ continue
751
+ stage_number = int(cells[0])
752
+ depends_on, error = _parse_dependencies(cells[3], task_id, stage_number)
753
+ if error:
754
+ violations.append(error)
755
+ rows.append(_BreakdownRow(
756
+ task_id=task_id,
757
+ stage_number=stage_number,
758
+ title=cells[1],
759
+ step_count=int(cells[2]),
760
+ depends_on=depends_on,
761
+ days=_parse_day_range(
762
+ cells[4], f"work breakdown {task_id}/S{stage_number}"
763
+ ),
764
+ ))
765
+ return rows, violations
766
+
767
+
768
+ def _duplicate_heading_violations(
769
+ headings: list[re.Match[str]],
770
+ ) -> list[str]:
771
+ violations: list[str] = []
772
+ for group, label in ((1, "phase-index"), (2, "task")):
773
+ values = [heading.group(group) for heading in headings]
774
+ duplicates = sorted({value for value in values if values.count(value) > 1})
775
+ for value in duplicates:
776
+ violations.append(
777
+ f"work breakdown: duplicate {label} heading {value}"
778
+ )
779
+ return violations
780
+
781
+
782
+ def _task_section_blocks(
783
+ text: str,
784
+ ) -> tuple[list[tuple[str, str]], list[str]]:
785
+ visible_text = _mask_fenced_text(text)
786
+ headings = list(_TASK_HEADING_RE.finditer(visible_text))
787
+ blocks: list[tuple[str, str]] = []
788
+ for index, heading in enumerate(headings):
789
+ end = (
790
+ headings[index + 1].start()
791
+ if index + 1 < len(headings)
792
+ else len(visible_text)
793
+ )
794
+ next_section = re.search(
795
+ r"^##\s", visible_text[heading.end():end], re.MULTILINE
796
+ )
797
+ if next_section is not None:
798
+ end = heading.end() + next_section.start()
799
+ blocks.append((heading.group(2), visible_text[heading.end():end]))
800
+ return blocks, _duplicate_heading_violations(headings)
801
+
802
+
803
+ def _validate_nonforward_task_section(
804
+ task: SelectionTask, block: str,
805
+ ) -> list[str]:
806
+ if task.selected_stages:
807
+ return []
808
+ marker = (
809
+ "[NEEDS-PLANNING]"
810
+ if task.state == "missing"
811
+ else "_Complete — no remaining stage_"
812
+ )
813
+ marker_count = sum(line.strip() == marker for line in block.splitlines())
814
+ violations: list[str] = []
815
+ if marker_count != 1:
816
+ violations.append(
817
+ f"task section: task {task.task_id} requires exact marker {marker}"
818
+ )
819
+ if "**Work Breakdown**:" in block:
820
+ violations.append(
821
+ f"task section: task {task.task_id} with no selected stages "
822
+ "must not contain Work Breakdown"
823
+ )
824
+ return violations
825
+
826
+
827
+ def _validate_task_section_coverage(
828
+ blocks: list[tuple[str, str]], tasks: tuple[SelectionTask, ...],
829
+ ) -> list[str]:
830
+ indexed: dict[str, list[str]] = {}
831
+ for task_id, block in blocks:
832
+ indexed.setdefault(task_id, []).append(block)
833
+ known = {task.task_id for task in tasks}
834
+ violations: list[str] = []
835
+ for task in tasks:
836
+ matches = indexed.get(task.task_id, [])
837
+ if len(matches) != 1:
838
+ violations.append(
839
+ f"task section: task {task.task_id} requires exactly one task section"
840
+ )
841
+ continue
842
+ violations.extend(_validate_nonforward_task_section(task, matches[0]))
843
+ for task_id in sorted(set(indexed) - known):
844
+ violations.append(f"task section: unknown task {task_id}")
845
+ return violations
846
+
847
+
848
+ def _extract_breakdown_rows(
849
+ text: str,
850
+ ) -> tuple[list[_BreakdownRow], list[str], list[tuple[str, str]]]:
851
+ blocks, violations = _task_section_blocks(text)
852
+ if violations:
853
+ return [], violations, blocks
854
+ rows: list[_BreakdownRow] = []
855
+ for task_id, block in blocks:
856
+ parsed, errors = _parse_breakdown_table(
857
+ task_id, block
858
+ )
859
+ rows.extend(parsed)
860
+ violations.extend(errors)
861
+ return rows, violations, blocks
862
+
863
+
864
+ def _validate_row_fields(
865
+ row: _BreakdownRow, task: SelectionTask, stage: SelectionStage,
866
+ ) -> list[str]:
867
+ prefix = f"work breakdown: stage {task.task_id}/S{stage.stage_number}"
868
+ violations: list[str] = []
869
+ if row.title != stage.title:
870
+ violations.append(
871
+ f"{prefix} Title {row.title!r} does not match {stage.title!r}"
872
+ )
873
+ if row.step_count != stage.step_count:
874
+ violations.append(
875
+ f"{prefix} Steps {row.step_count} does not match {stage.step_count}"
876
+ )
877
+ done = set(task.done_stages)
878
+ expected_dependencies: list[tuple[int, bool]] = []
879
+ for dependency in stage.depends_on:
880
+ expected_dependencies.append((dependency, dependency in done))
881
+ expected = tuple(expected_dependencies)
882
+ if row.depends_on != expected:
883
+ violations.append(
884
+ f"{prefix} Depends On {row.depends_on!r} does not match {expected!r}"
885
+ )
886
+ return violations
887
+
888
+
889
+ def _validate_task_rows(
890
+ task: SelectionTask,
891
+ indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]],
892
+ ) -> list[str]:
893
+ violations: list[str] = []
894
+ stages = {stage.stage_number: stage for stage in task.stages}
895
+ selected = set(task.selected_stages)
896
+ done = set(task.done_stages)
897
+ for stage_number in task.selected_stages:
898
+ entries = indexed.get((task.task_id, stage_number), [])
899
+ if not entries:
900
+ violations.append(
901
+ f"work breakdown: missing selected stage {task.task_id}/S{stage_number}"
902
+ )
903
+ elif len(entries) > 1:
904
+ violations.append(
905
+ f"work breakdown: duplicate stage {task.task_id}/S{stage_number}"
906
+ )
907
+ for _, row in entries:
908
+ violations.extend(_validate_row_fields(row, task, stages[stage_number]))
909
+ for stage_number in done:
910
+ if indexed.get((task.task_id, stage_number)):
911
+ violations.append(
912
+ f"work breakdown: done stage {task.task_id}/S{stage_number} must be excluded"
913
+ )
914
+ task_rows = {
915
+ stage_number for task_id, stage_number in indexed if task_id == task.task_id
916
+ }
917
+ for stage_number in sorted(task_rows - selected - done):
918
+ violations.append(
919
+ f"work breakdown: stage {task.task_id}/S{stage_number} is not selected"
920
+ )
921
+ return violations
922
+
923
+
924
+ def _validate_extra_rows(
925
+ tasks: tuple[SelectionTask, ...],
926
+ indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]],
927
+ ) -> list[str]:
928
+ known_task_ids = {task.task_id for task in tasks}
929
+ violations: list[str] = []
930
+ for task_id, stage_number in sorted(indexed):
931
+ if task_id not in known_task_ids:
932
+ violations.append(
933
+ f"work breakdown: stage {task_id}/S{stage_number} is not selected"
934
+ )
935
+ return violations
936
+
937
+
938
+ def _validate_topological_order(
939
+ tasks: tuple[SelectionTask, ...],
940
+ indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]],
941
+ ) -> list[str]:
942
+ violations: list[str] = []
943
+ for task in tasks:
944
+ selected = set(task.selected_stages)
945
+ stages = {stage.stage_number: stage for stage in task.stages}
946
+ for stage_number in task.selected_stages:
947
+ positions = indexed.get((task.task_id, stage_number), [])
948
+ if not positions:
949
+ continue
950
+ position = positions[0][0]
951
+ for dependency in stages[stage_number].depends_on:
952
+ dependency_rows = indexed.get((task.task_id, dependency), [])
953
+ if dependency in selected and dependency_rows:
954
+ if dependency_rows[0][0] >= position:
955
+ violations.append(
956
+ f"work breakdown: dependency {task.task_id}/S{dependency} "
957
+ f"must precede {task.task_id}/S{stage_number}"
958
+ )
959
+ return violations
960
+
961
+
962
+ def _validate_work_breakdowns(
963
+ text: str, tasks: tuple[SelectionTask, ...],
964
+ ) -> tuple[list[_BreakdownRow], list[str]]:
965
+ rows, violations, blocks = _extract_breakdown_rows(text)
966
+ violations.extend(_validate_task_section_coverage(blocks, tasks))
967
+ indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]] = {}
968
+ for position, row in enumerate(rows):
969
+ indexed.setdefault((row.task_id, row.stage_number), []).append(
970
+ (position, row)
971
+ )
972
+ for task in tasks:
973
+ violations.extend(_validate_task_rows(task, indexed))
974
+ violations.extend(_validate_extra_rows(tasks, indexed))
975
+ violations.extend(_validate_topological_order(tasks, indexed))
976
+ return rows, violations
977
+
978
+
979
+ def _expected_selected_stage_days(
980
+ effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
981
+ tasks: tuple[SelectionTask, ...],
982
+ glance_rows: dict[str, _AtAGlanceRow],
983
+ ) -> dict[tuple[str, int], tuple[Decimal, Decimal]]:
984
+ expected: dict[tuple[str, int], tuple[Decimal, Decimal]] = {}
985
+ for task in tasks:
986
+ glance_row = glance_rows.get(task.task_id)
987
+ if glance_row is None or not task.selected_stages:
988
+ continue
989
+ total = effort_ranges.get(glance_row.effort)
990
+ if total is None:
991
+ continue
992
+ stages = {stage.stage_number: stage for stage in task.stages}
993
+ step_counts = tuple(
994
+ stages[number].step_count for number in task.selected_stages
995
+ )
996
+ allocated = allocate_stage_days(total, step_counts)
997
+ expected.update(zip(
998
+ ((task.task_id, number) for number in task.selected_stages),
999
+ allocated,
1000
+ ))
1001
+ return expected
1002
+
1003
+
1004
+ def _validate_stage_effort(
1005
+ expected: dict[tuple[str, int], tuple[Decimal, Decimal]],
1006
+ rows: list[_BreakdownRow],
1007
+ ) -> list[str]:
1008
+ violations: list[str] = []
1009
+ for row in rows:
1010
+ day_range = expected.get((row.task_id, row.stage_number))
1011
+ if day_range is None or row.days == day_range:
1012
+ continue
1013
+ violations.append(
1014
+ f"effort: {row.task_id}/S{row.stage_number} days require "
1015
+ f"{_format_day_range(day_range)}"
1016
+ )
1017
+ return violations
1018
+
1019
+
1020
+ def _validate_task_stage_sums(
1021
+ tasks: tuple[SelectionTask, ...],
1022
+ rows: list[_BreakdownRow],
1023
+ expected: dict[tuple[str, int], tuple[Decimal, Decimal]],
1024
+ ) -> list[str]:
1025
+ indexed: dict[tuple[str, int], list[_BreakdownRow]] = {}
1026
+ for row in rows:
1027
+ indexed.setdefault((row.task_id, row.stage_number), []).append(row)
1028
+ violations: list[str] = []
1029
+ for task in tasks:
1030
+ keys = [(task.task_id, number) for number in task.selected_stages]
1031
+ if not keys or any(key not in expected for key in keys):
1032
+ continue
1033
+ entries = [indexed.get(key, []) for key in keys]
1034
+ if any(len(items) != 1 or items[0].days is None for items in entries):
1035
+ continue
1036
+ actual = _sum_day_ranges([items[0].days for items in entries])
1037
+ required = _sum_day_ranges([expected[key] for key in keys])
1038
+ if actual != required:
1039
+ violations.append(
1040
+ f"effort: {task.task_id} stage days sum "
1041
+ f"{_format_day_range(actual)} requires "
1042
+ f"{_format_day_range(required)}"
1043
+ )
1044
+ return violations
1045
+
1046
+
1047
+ def _parse_effort_total(text: str) -> tuple[Decimal, Decimal] | None:
1048
+ match = _EFFORT_TOTAL_RE.search(_section_body(text, "## At a Glance", 2))
1049
+ if match is None:
1050
+ return None
1051
+ return _ordered_day_range(
1052
+ Decimal(match.group(1)), Decimal(match.group(2)), "Effort sum"
1053
+ )
1054
+
1055
+
1056
+ def _finite_task_ranges(
1057
+ effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
1058
+ tasks: tuple[SelectionTask, ...],
1059
+ glance_rows: dict[str, _AtAGlanceRow],
1060
+ ) -> list[tuple[Decimal, Decimal]]:
1061
+ ranges: list[tuple[Decimal, Decimal]] = []
1062
+ for task in tasks:
1063
+ if task.state == "missing" or not task.selected_stages:
1064
+ continue
1065
+ glance_row = glance_rows.get(task.task_id)
1066
+ if glance_row is None:
1067
+ continue
1068
+ day_range = effort_ranges.get(glance_row.effort)
1069
+ if day_range is not None:
1070
+ ranges.append(day_range)
1071
+ return ranges
1072
+
1073
+
1074
+ def _validate_effort_total(
1075
+ text: str,
1076
+ effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
1077
+ tasks: tuple[SelectionTask, ...],
1078
+ glance_rows: dict[str, _AtAGlanceRow],
1079
+ ) -> list[str]:
1080
+ actual = _parse_effort_total(text)
1081
+ if actual is None:
1082
+ return ["effort: missing finite Effort sum"]
1083
+ required = _sum_day_ranges(_finite_task_ranges(
1084
+ effort_ranges, tasks, glance_rows
1085
+ ))
1086
+ if actual == required:
1087
+ return []
1088
+ return [
1089
+ f"effort: Effort sum requires {_format_day_range(required)} days"
1090
+ ]
1091
+
1092
+
1093
+ def _validate_decomposition_notice(
1094
+ text: str,
1095
+ tasks: tuple[SelectionTask, ...],
1096
+ glance_rows: dict[str, _AtAGlanceRow],
1097
+ ) -> list[str]:
1098
+ has_unplanned_work = (
1099
+ any(task.state == "missing" for task in tasks)
1100
+ or any(row.effort == "XXL" for row in glance_rows.values())
1101
+ )
1102
+ if not has_unplanned_work:
1103
+ return []
1104
+ executive_summary = _section_body(
1105
+ text, "## Executive Summary", 2
1106
+ ).partition("### Effort Sizing Criteria")[0]
1107
+ if "requires further decomposition" in executive_summary.lower():
1108
+ return []
1109
+ return [
1110
+ "effort: Executive Summary requires further decomposition notice"
1111
+ ]
1112
+
1113
+
1114
+ def _validate_effort_semantics(
1115
+ text: str,
1116
+ tasks: tuple[SelectionTask, ...],
1117
+ breakdown_rows: list[_BreakdownRow],
1118
+ ) -> list[str]:
1119
+ effort_ranges = parse_effort_ranges(text)
1120
+ glance_rows, violations = _validated_at_a_glance_rows(text, tasks)
1121
+ violations.extend(_validate_at_a_glance_effort(
1122
+ effort_ranges, tasks, glance_rows
1123
+ ))
1124
+ expected = _expected_selected_stage_days(
1125
+ effort_ranges, tasks, glance_rows
1126
+ )
1127
+ violations.extend(_validate_stage_effort(expected, breakdown_rows))
1128
+ violations.extend(_validate_task_stage_sums(
1129
+ tasks, breakdown_rows, expected
1130
+ ))
1131
+ violations.extend(_validate_effort_total(
1132
+ text, effort_ranges, tasks, glance_rows
1133
+ ))
1134
+ violations.extend(_validate_decomposition_notice(
1135
+ text, tasks, glance_rows
1136
+ ))
1137
+ return violations
1138
+
1139
+
1140
+ def _parse_gantt_rows(
1141
+ text: str,
1142
+ ) -> tuple[list[_GanttRow] | None, list[str]]:
1143
+ visible_text = _mask_fenced_text(text)
1144
+ headings = list(re.finditer(
1145
+ r"^## Gantt Chart[ \t]*$", visible_text, re.MULTILINE
1146
+ ))
1147
+ if not headings:
1148
+ return None, []
1149
+ if len(headings) > 1:
1150
+ return None, [
1151
+ "Gantt: Gantt Chart section must appear at most once"
1152
+ ]
1153
+ section = _raw_section_body(text, "## Gantt Chart", 2)
1154
+ if section is None:
1155
+ return None, []
1156
+ rows: list[_GanttRow] = []
1157
+ violations: list[str] = []
1158
+ for fence in _PLAIN_FENCE_RE.finditer(section):
1159
+ for line in fence.group(1).splitlines():
1160
+ match = _GANTT_ROW_RE.fullmatch(line)
1161
+ if match is None:
1162
+ candidate = _GANTT_STAGE_CANDIDATE_RE.match(line)
1163
+ if candidate is not None:
1164
+ violations.append(
1165
+ "Gantt: malformed Gantt stage row "
1166
+ f"{candidate.group(1)}/S{candidate.group(2)} requires "
1167
+ "canonical days=<lower>~<upper> syntax"
1168
+ )
1169
+ continue
1170
+ task_id = match.group(1)
1171
+ stage_number = int(match.group(2))
1172
+ rows.append(_GanttRow(
1173
+ task_id=task_id,
1174
+ stage_number=stage_number,
1175
+ days=_ordered_day_range(
1176
+ Decimal(match.group(3)),
1177
+ Decimal(match.group(4)),
1178
+ f"Gantt {task_id}/S{stage_number}",
1179
+ ),
1180
+ ))
1181
+ return rows, violations
1182
+
1183
+
1184
+ def _validate_gantt_coverage(
1185
+ rows: list[_GanttRow], tasks: tuple[SelectionTask, ...],
1186
+ ) -> list[str]:
1187
+ expected = {
1188
+ (task.task_id, number)
1189
+ for task in tasks
1190
+ for number in task.selected_stages
1191
+ }
1192
+ counts: dict[tuple[str, int], int] = {}
1193
+ for row in rows:
1194
+ key = (row.task_id, row.stage_number)
1195
+ counts[key] = counts.get(key, 0) + 1
1196
+ violations: list[str] = []
1197
+ for task_id, stage_number in sorted(expected):
1198
+ count = counts.get((task_id, stage_number), 0)
1199
+ if count == 0:
1200
+ violations.append(f"Gantt: missing Gantt row {task_id}/S{stage_number}")
1201
+ elif count > 1:
1202
+ violations.append(f"Gantt: duplicate Gantt row {task_id}/S{stage_number}")
1203
+ for task_id, stage_number in sorted(set(counts) - expected):
1204
+ violations.append(
1205
+ f"Gantt: Gantt row {task_id}/S{stage_number} is not selected"
1206
+ )
1207
+ return violations
1208
+
1209
+
1210
+ def _validate_gantt_days(
1211
+ gantt_rows: list[_GanttRow],
1212
+ breakdown_rows: list[_BreakdownRow],
1213
+ ) -> list[str]:
1214
+ breakdown: dict[tuple[str, int], list[_BreakdownRow]] = {}
1215
+ for row in breakdown_rows:
1216
+ key = (row.task_id, row.stage_number)
1217
+ breakdown.setdefault(key, []).append(row)
1218
+ violations: list[str] = []
1219
+ for row in gantt_rows:
1220
+ key = (row.task_id, row.stage_number)
1221
+ matches = breakdown.get(key, [])
1222
+ if len(matches) != 1 or matches[0].days is None:
1223
+ continue
1224
+ if row.days != matches[0].days:
1225
+ violations.append(
1226
+ f"Gantt: Gantt days for {row.task_id}/S{row.stage_number} "
1227
+ f"require {_format_day_range(matches[0].days)}"
1228
+ )
1229
+ return violations
1230
+
1231
+
1232
+ def validate_schedule_semantics(
1233
+ text: str, selection_path: Path,
1234
+ ) -> list[str]:
1235
+ try:
1236
+ tasks = load_schedule_selection(selection_path)
1237
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1238
+ return [f"selection: {exc}"]
1239
+ try:
1240
+ rows, violations = _validate_work_breakdowns(text, tasks)
1241
+ violations.extend(_validate_effort_semantics(text, tasks, rows))
1242
+ gantt_rows, gantt_violations = _parse_gantt_rows(text)
1243
+ violations.extend(gantt_violations)
1244
+ if gantt_rows is not None:
1245
+ violations.extend(_validate_gantt_coverage(gantt_rows, tasks))
1246
+ violations.extend(_validate_gantt_days(gantt_rows, rows))
1247
+ return violations
1248
+ except ScheduleSemanticError as exc:
1249
+ return [str(exc)]