okstra 0.155.0 → 0.157.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 (36) hide show
  1. package/docs/architecture.md +3 -1
  2. package/docs/for-ai/skills/okstra-schedule-gen.md +5 -4
  3. package/docs/project-structure-overview.md +7 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/prompts/profiles/_common-contract.md +1 -1
  7. package/runtime/python/okstra_ctl/clarification_items.py +3 -3
  8. package/runtime/python/okstra_ctl/render_final_report.py +31 -44
  9. package/runtime/python/okstra_ctl/report_contract.py +15 -0
  10. package/runtime/python/okstra_ctl/report_finalize.py +22 -3
  11. package/runtime/python/okstra_ctl/report_markdown.py +441 -0
  12. package/runtime/python/okstra_ctl/schedule_semantics.py +186 -91
  13. package/runtime/python/okstra_ctl/stage_map.py +203 -1
  14. package/runtime/python/okstra_ctl/wizard.py +1 -11
  15. package/runtime/python/okstra_project/state.py +14 -2
  16. package/runtime/skills/okstra-schedule-gen/SKILL.md +43 -18
  17. package/runtime/templates/reports/final-report-v2.template.md +74 -10
  18. package/runtime/templates/reports/md/macros/sections.md +19 -0
  19. package/runtime/templates/reports/md/tasks/change-impact-analysis.template.md +18 -0
  20. package/runtime/templates/reports/md/tasks/error-analysis.template.md +13 -0
  21. package/runtime/templates/reports/md/tasks/feature-analysis.template.md +13 -0
  22. package/runtime/templates/reports/md/tasks/final-verification.template.md +13 -0
  23. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +15 -0
  24. package/runtime/templates/reports/md/tasks/implementation.template.md +15 -0
  25. package/runtime/templates/reports/md/tasks/improvement-discovery.template.md +10 -0
  26. package/runtime/templates/reports/md/tasks/project-analysis.template.md +15 -0
  27. package/runtime/templates/reports/md/tasks/release-handoff.template.md +13 -0
  28. package/runtime/templates/reports/md/tasks/requirements-discovery.template.md +15 -0
  29. package/runtime/templates/reports/schedule.template.md +166 -63
  30. package/runtime/validators/validate-run.py +19 -6
  31. package/runtime/validators/validate-schedule.py +94 -65
  32. package/src/commands/inspect/stage-map.mjs +6 -1
  33. package/src/commands/inspect/worker-liveness.mjs +15 -3
  34. package/src/commands/lifecycle/install.mjs +69 -4
  35. package/src/commands/lifecycle/uninstall.mjs +21 -35
  36. package/src/lib/install-assets.mjs +37 -0
@@ -22,20 +22,27 @@ _TASK_FIELDS = {
22
22
  "stages",
23
23
  }
24
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
- )
25
+ # `### <n>. <human title>` — the heading is a title, not an identifier. The
26
+ # task-id is a machine key; printing it as the reader's heading makes them parse
27
+ # `nestjs-migration-nlpvibe-to-nestjs-and-org-standard-structure` to learn the
28
+ # work is a NestJS migration. Blocks bind to At a Glance by their number.
29
+ _TASK_HEADING_RE = re.compile(r"^###\s+(\d+)\.\s+(\S.*?)\s*$", re.MULTILINE)
28
30
  _WORK_BREAKDOWN_HEADER = ["Stage", "Title", "Steps", "Depends On", "Days"]
29
31
  _WORK_BREAKDOWN_SEPARATOR = ["---:", "---", "---:", "---", "---:"]
32
+ # No `taskType`: it printed okstra's own phase name at a reader who has no
33
+ # phases, and the per-task Status field already states the same thing in words.
30
34
  _AT_A_GLANCE_HEADER = [
31
- "#", "Task ID", "Title", "Category", "Priority", "Effort", "Days",
32
- "taskType", "Risk", "Phase",
35
+ "#", "Task ID", "Title", "Category", "Priority", "Effort", "Days", "Risk",
33
36
  ]
34
37
  _EFFORT_HEADER = ["Size", "Criteria", "Day(s)"]
35
38
  _EFFORT_SIZES = {"S", "M", "L", "XL", "XXL"}
36
39
  _DAY_NUMBER = r"\d+(?:\.\d+)?"
40
+ # `(est)` marks a range the schedule derived from `step_count` rather than one
41
+ # the plan stated. implementation-planning does not estimate duration, so every
42
+ # range carries it today; the marker is what keeps that visible in the table
43
+ # instead of in a footnote.
37
44
  _DAY_RANGE_RE = re.compile(
38
- rf"^\s*({_DAY_NUMBER})\s*(?:~|-)\s*({_DAY_NUMBER})\s*$"
45
+ rf"^\s*({_DAY_NUMBER})\s*(?:~|-)\s*({_DAY_NUMBER})\s*(?:\(est\))?\s*$"
39
46
  )
40
47
  _EFFORT_TOTAL_RE = re.compile(
41
48
  rf"\*\*\d+\s+tasks\s+total\s*/\s*estimated\s+effort:\s*"
@@ -45,10 +52,15 @@ _EFFORT_TOTAL_RE = re.compile(
45
52
  _PLAIN_FENCE_RE = re.compile(
46
53
  r"^```[ \t]*$\n(.*?)^```[ \t]*$", re.MULTILINE | re.DOTALL
47
54
  )
55
+ # A row is `[<TASK-ID> ]Stage <n> <bar>` and nothing else. The bar's width IS
56
+ # the duration — a trailing `days=` annotation restated the Work Breakdown's Days
57
+ # column, and per-row `! crit` / `est` markers that were identical on every row
58
+ # carried no information at all.
48
59
  _GANTT_ROW_RE = re.compile(
49
- rf"^\s*(\S+)/S(\d+)\s+.*\bdays=({_DAY_NUMBER})~({_DAY_NUMBER})\s*$"
60
+ r"^\s*(?:(\S+)\s+)?Stage (\d+)\s+([█]*[░]*)\s*$"
50
61
  )
51
- _GANTT_STAGE_CANDIDATE_RE = re.compile(r"^\s*(\S+)/S(\d+)(?:\s|$)")
62
+ _GANTT_STAGE_CANDIDATE_RE = re.compile(r"^\s*(?:(\S+)\s+)?Stage (\d+)(?:\s|$)")
63
+ _GANTT_AXIS_RE = re.compile(r"^\s*Day:\s*(.+)$")
52
64
  _XXL_DAY_RANGE_RE = re.compile(rf"^\s*{_DAY_NUMBER}\s*-\s*$")
53
65
  _TASK_SUBSECTION_PREFIXES = (
54
66
  "**Problem**:",
@@ -58,6 +70,12 @@ _TASK_SUBSECTION_PREFIXES = (
58
70
  "**Rollback**:",
59
71
  )
60
72
  HALF_DAY = Decimal("0.5")
73
+ # One column is half a day. A whole-day column cannot draw a 2.5-day stage, which
74
+ # is how bars silently rounded away from the Work Breakdown they annotate.
75
+ GANTT_COLUMN_DAYS = HALF_DAY
76
+ # The axis may overshoot the work by less than one tick interval; more than that
77
+ # is dead space that misreads as schedule length.
78
+ _GANTT_AXIS_TICK_DAYS = Decimal("5")
61
79
 
62
80
 
63
81
  class ScheduleSemanticError(ValueError):
@@ -167,7 +185,8 @@ class _AtAGlanceRow:
167
185
  class _GanttRow:
168
186
  task_id: str
169
187
  stage_number: int
170
- days: tuple[Decimal, Decimal]
188
+ filled_cells: int
189
+ open_cells: int
171
190
 
172
191
 
173
192
  class StageMapSelectionError(ValueError):
@@ -404,12 +423,12 @@ def load_schedule_selection(path: Path) -> tuple[SelectionTask, ...]:
404
423
  def _parse_dependencies(
405
424
  value: str, task_id: str, stage_number: int,
406
425
  ) -> tuple[tuple[tuple[int, bool], ...], str | None]:
407
- if value == "":
426
+ if value == "None":
408
427
  return (), None
409
428
  dependencies: list[tuple[int, bool]] = []
410
429
  for token in value.split(","):
411
430
  normalized = token.strip()
412
- match = re.fullmatch(r"(\d+)( \(done\))?", normalized)
431
+ match = re.fullmatch(r"Stage (\d+)( \(done\))?", normalized)
413
432
  if match is None:
414
433
  return (), (
415
434
  f"work breakdown: stage {task_id}/S{stage_number} has invalid "
@@ -577,7 +596,7 @@ def _parse_at_a_glance_table(
577
596
  task_id = cells[1] if len(cells) > 1 else "<unknown>"
578
597
  violations.append(
579
598
  f"At a Glance: noncanonical row for task {task_id} "
580
- "requires 10 columns"
599
+ "requires 8 columns"
581
600
  )
582
601
  continue
583
602
  effort_tokens = cells[5].split()
@@ -602,7 +621,7 @@ def _parse_at_a_glance_rows(
602
621
  if split_pipe_row(line) == _AT_A_GLANCE_HEADER
603
622
  ]
604
623
  if not header_indexes:
605
- return [], ["At a Glance: missing canonical 10-column table"]
624
+ return [], ["At a Glance: missing canonical 8-column table"]
606
625
  rows: list[_AtAGlanceRow] = []
607
626
  violations: list[str] = []
608
627
  for header_index in header_indexes:
@@ -662,18 +681,26 @@ def _validate_at_a_glance_effort(
662
681
  missing_task_ids = {
663
682
  task.task_id for task in tasks if task.state == "missing"
664
683
  }
684
+ scheduled_task_ids = {
685
+ task.task_id for task in tasks if task.selected_stages
686
+ }
665
687
  violations: list[str] = []
666
688
  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:
689
+ if row.task_id in missing_task_ids:
671
690
  if row.days_text != "[NEEDS-PLANNING]":
672
691
  violations.append(
673
- f"effort: {row.task_id} effort {row.effort} "
692
+ f"effort: {row.task_id} has no planning report and "
674
693
  "requires [NEEDS-PLANNING]"
675
694
  )
676
695
  continue
696
+ if row.task_id not in scheduled_task_ids:
697
+ continue
698
+ if row.days is None:
699
+ violations.append(
700
+ f"effort: {row.task_id} requires a day range in Days — "
701
+ f"got {row.days_text!r}"
702
+ )
703
+ continue
677
704
  if row.effort not in effort_ranges:
678
705
  violations.append(
679
706
  f"effort: {row.task_id} effort {row.effort} is absent from "
@@ -769,19 +796,23 @@ def _duplicate_heading_violations(
769
796
  headings: list[re.Match[str]],
770
797
  ) -> list[str]:
771
798
  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
- )
799
+ values = [heading.group(1) for heading in headings]
800
+ duplicates = sorted({value for value in values if values.count(value) > 1})
801
+ for value in duplicates:
802
+ violations.append(
803
+ f"work breakdown: duplicate task-index heading {value}"
804
+ )
779
805
  return violations
780
806
 
781
807
 
782
808
  def _task_section_blocks(
783
- text: str,
809
+ text: str, ordered_task_ids: list[str],
784
810
  ) -> tuple[list[tuple[str, str]], list[str]]:
811
+ """Bind each `### <n>.` block to the n-th At a Glance row.
812
+
813
+ The heading carries a human title, so the task it belongs to comes from its
814
+ position rather than from an identifier printed at the reader.
815
+ """
785
816
  visible_text = _mask_fenced_text(text)
786
817
  headings = list(_TASK_HEADING_RE.finditer(visible_text))
787
818
  blocks: list[tuple[str, str]] = []
@@ -796,7 +827,13 @@ def _task_section_blocks(
796
827
  )
797
828
  if next_section is not None:
798
829
  end = heading.end() + next_section.start()
799
- blocks.append((heading.group(2), visible_text[heading.end():end]))
830
+ position = int(heading.group(1))
831
+ task_id = (
832
+ ordered_task_ids[position - 1]
833
+ if 1 <= position <= len(ordered_task_ids)
834
+ else f"<no At a Glance row {position}>"
835
+ )
836
+ blocks.append((task_id, visible_text[heading.end():end]))
800
837
  return blocks, _duplicate_heading_violations(headings)
801
838
 
802
839
 
@@ -848,7 +885,10 @@ def _validate_task_section_coverage(
848
885
  def _extract_breakdown_rows(
849
886
  text: str,
850
887
  ) -> tuple[list[_BreakdownRow], list[str], list[tuple[str, str]]]:
851
- blocks, violations = _task_section_blocks(text)
888
+ glance_rows, _ = _parse_at_a_glance_rows(text)
889
+ blocks, violations = _task_section_blocks(
890
+ text, [row.task_id for row in glance_rows]
891
+ )
852
892
  if violations:
853
893
  return [], violations, blocks
854
894
  rows: list[_BreakdownRow] = []
@@ -986,7 +1026,10 @@ def _expected_selected_stage_days(
986
1026
  glance_row = glance_rows.get(task.task_id)
987
1027
  if glance_row is None or not task.selected_stages:
988
1028
  continue
989
- total = effort_ranges.get(glance_row.effort)
1029
+ # XXL has no finite upper bound in the sizing table, so an XXL task is
1030
+ # sized by its own decomposition: the row's own range is the total the
1031
+ # stages divide up.
1032
+ total = effort_ranges.get(glance_row.effort) or glance_row.days
990
1033
  if total is None:
991
1034
  continue
992
1035
  stages = {stage.stage_number: stage for stage in task.stages}
@@ -1053,11 +1096,17 @@ def _parse_effort_total(text: str) -> tuple[Decimal, Decimal] | None:
1053
1096
  )
1054
1097
 
1055
1098
 
1056
- def _finite_task_ranges(
1099
+ def _scheduled_task_ranges(
1057
1100
  effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
1058
1101
  tasks: tuple[SelectionTask, ...],
1059
1102
  glance_rows: dict[str, _AtAGlanceRow],
1060
1103
  ) -> list[tuple[Decimal, Decimal]]:
1104
+ """Every in-scope task's day range, including XXL.
1105
+
1106
+ An XXL row has no finite range in the sizing table; it carries its own,
1107
+ summed from its stages. Dropping those rows is what produced a `0.0 ~ 0.0`
1108
+ Effort sum on a schedule whose stages added up to weeks of work.
1109
+ """
1061
1110
  ranges: list[tuple[Decimal, Decimal]] = []
1062
1111
  for task in tasks:
1063
1112
  if task.state == "missing" or not task.selected_stages:
@@ -1065,7 +1114,7 @@ def _finite_task_ranges(
1065
1114
  glance_row = glance_rows.get(task.task_id)
1066
1115
  if glance_row is None:
1067
1116
  continue
1068
- day_range = effort_ranges.get(glance_row.effort)
1117
+ day_range = effort_ranges.get(glance_row.effort) or glance_row.days
1069
1118
  if day_range is not None:
1070
1119
  ranges.append(day_range)
1071
1120
  return ranges
@@ -1079,8 +1128,8 @@ def _validate_effort_total(
1079
1128
  ) -> list[str]:
1080
1129
  actual = _parse_effort_total(text)
1081
1130
  if actual is None:
1082
- return ["effort: missing finite Effort sum"]
1083
- required = _sum_day_ranges(_finite_task_ranges(
1131
+ return ["effort: missing Effort sum"]
1132
+ required = _sum_day_ranges(_scheduled_task_ranges(
1084
1133
  effort_ranges, tasks, glance_rows
1085
1134
  ))
1086
1135
  if actual == required:
@@ -1090,27 +1139,6 @@ def _validate_effort_total(
1090
1139
  ]
1091
1140
 
1092
1141
 
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
1142
  def _validate_effort_semantics(
1115
1143
  text: str,
1116
1144
  tasks: tuple[SelectionTask, ...],
@@ -1131,14 +1159,17 @@ def _validate_effort_semantics(
1131
1159
  violations.extend(_validate_effort_total(
1132
1160
  text, effort_ranges, tasks, glance_rows
1133
1161
  ))
1134
- violations.extend(_validate_decomposition_notice(
1135
- text, tasks, glance_rows
1136
- ))
1137
1162
  return violations
1138
1163
 
1139
1164
 
1165
+ def _sole_scheduled_task_id(tasks: tuple[SelectionTask, ...]) -> str:
1166
+ """The one task an unqualified `S<n>` row can only mean; '' when ambiguous."""
1167
+ scheduled = [task.task_id for task in tasks if task.selected_stages]
1168
+ return scheduled[0] if len(scheduled) == 1 else ""
1169
+
1170
+
1140
1171
  def _parse_gantt_rows(
1141
- text: str,
1172
+ text: str, tasks: tuple[SelectionTask, ...],
1142
1173
  ) -> tuple[list[_GanttRow] | None, list[str]]:
1143
1174
  visible_text = _mask_fenced_text(text)
1144
1175
  headings = list(re.finditer(
@@ -1153,6 +1184,7 @@ def _parse_gantt_rows(
1153
1184
  section = _raw_section_body(text, "## Gantt Chart", 2)
1154
1185
  if section is None:
1155
1186
  return None, []
1187
+ sole_task_id = _sole_scheduled_task_id(tasks)
1156
1188
  rows: list[_GanttRow] = []
1157
1189
  violations: list[str] = []
1158
1190
  for fence in _PLAIN_FENCE_RE.finditer(section):
@@ -1161,26 +1193,110 @@ def _parse_gantt_rows(
1161
1193
  if match is None:
1162
1194
  candidate = _GANTT_STAGE_CANDIDATE_RE.match(line)
1163
1195
  if candidate is not None:
1196
+ label = candidate.group(1) or sole_task_id or "<task>"
1164
1197
  violations.append(
1165
- "Gantt: malformed Gantt stage row "
1166
- f"{candidate.group(1)}/S{candidate.group(2)} requires "
1167
- "canonical days=<lower>~<upper> syntax"
1198
+ f"Gantt: malformed Gantt stage row {label} Stage "
1199
+ f"{candidate.group(2)} a row is a label and a bar, "
1200
+ "with nothing after it"
1168
1201
  )
1169
1202
  continue
1170
- task_id = match.group(1)
1171
1203
  stage_number = int(match.group(2))
1204
+ task_id = match.group(1) or sole_task_id
1205
+ if not task_id:
1206
+ violations.append(
1207
+ f"Gantt: row S{stage_number} omits its task-id, which is "
1208
+ "only allowed when exactly one task is scheduled"
1209
+ )
1210
+ continue
1211
+ bar = match.group(3)
1172
1212
  rows.append(_GanttRow(
1173
1213
  task_id=task_id,
1174
1214
  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
- ),
1215
+ filled_cells=bar.count("█"),
1216
+ open_cells=bar.count("░"),
1180
1217
  ))
1181
1218
  return rows, violations
1182
1219
 
1183
1220
 
1221
+ def _cells_for_days(days: Decimal) -> Decimal:
1222
+ return days / GANTT_COLUMN_DAYS
1223
+
1224
+
1225
+ def _breakdown_days(
1226
+ breakdown_rows: list[_BreakdownRow],
1227
+ ) -> dict[tuple[str, int], tuple[Decimal, Decimal]]:
1228
+ days: dict[tuple[str, int], tuple[Decimal, Decimal]] = {}
1229
+ counts: dict[tuple[str, int], int] = {}
1230
+ for row in breakdown_rows:
1231
+ key = (row.task_id, row.stage_number)
1232
+ counts[key] = counts.get(key, 0) + 1
1233
+ if row.days is not None:
1234
+ days[key] = row.days
1235
+ return {key: value for key, value in days.items() if counts[key] == 1}
1236
+
1237
+
1238
+ def _validate_gantt_geometry(
1239
+ rows: list[_GanttRow], breakdown_rows: list[_BreakdownRow],
1240
+ ) -> list[str]:
1241
+ """A bar's width must be the duration the Work Breakdown gives that stage.
1242
+
1243
+ The bar is compared against the Days column itself rather than a `days=`
1244
+ label beside it: an annotation restating the table can agree with the label
1245
+ while disagreeing with the plan.
1246
+ """
1247
+ days = _breakdown_days(breakdown_rows)
1248
+ violations: list[str] = []
1249
+ for row in rows:
1250
+ day_range = days.get((row.task_id, row.stage_number))
1251
+ if day_range is None:
1252
+ continue
1253
+ lower, upper = day_range
1254
+ expected_filled = _cells_for_days(lower)
1255
+ expected_open = _cells_for_days(upper - lower)
1256
+ if (
1257
+ expected_filled != row.filled_cells
1258
+ or expected_open != row.open_cells
1259
+ ):
1260
+ violations.append(
1261
+ f"Gantt: bar for {row.task_id} Stage {row.stage_number} draws "
1262
+ f"{row.filled_cells}█+{row.open_cells}░ but Work Breakdown says "
1263
+ f"{_format_day_range(day_range)} days, which is "
1264
+ f"{expected_filled:.0f}█+{expected_open:.0f}░ at "
1265
+ f"{GANTT_COLUMN_DAYS} day per column"
1266
+ )
1267
+ return violations
1268
+
1269
+
1270
+ def _validate_gantt_axis(
1271
+ text: str, rows: list[_GanttRow], breakdown_rows: list[_BreakdownRow],
1272
+ ) -> list[str]:
1273
+ section = _raw_section_body(text, "## Gantt Chart", 2)
1274
+ if section is None or not rows:
1275
+ return []
1276
+ ticks: list[Decimal] = []
1277
+ for fence in _PLAIN_FENCE_RE.finditer(section):
1278
+ for line in fence.group(1).splitlines():
1279
+ axis = _GANTT_AXIS_RE.match(line)
1280
+ if axis is not None:
1281
+ ticks = [
1282
+ Decimal(token) for token in re.findall(r"\d+", axis.group(1))
1283
+ ]
1284
+ if not ticks:
1285
+ return []
1286
+ days = _breakdown_days(breakdown_rows)
1287
+ span = sum(
1288
+ (days[(row.task_id, row.stage_number)][1]
1289
+ for row in rows if (row.task_id, row.stage_number) in days),
1290
+ Decimal("0"),
1291
+ )
1292
+ if span and ticks[-1] > span + _GANTT_AXIS_TICK_DAYS:
1293
+ return [
1294
+ f"Gantt: axis runs to day {ticks[-1]} but the schedule spans at "
1295
+ f"most {span} days — trim the axis to the work"
1296
+ ]
1297
+ return []
1298
+
1299
+
1184
1300
  def _validate_gantt_coverage(
1185
1301
  rows: list[_GanttRow], tasks: tuple[SelectionTask, ...],
1186
1302
  ) -> list[str]:
@@ -1207,28 +1323,6 @@ def _validate_gantt_coverage(
1207
1323
  return violations
1208
1324
 
1209
1325
 
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
1326
  def validate_schedule_semantics(
1233
1327
  text: str, selection_path: Path,
1234
1328
  ) -> list[str]:
@@ -1239,11 +1333,12 @@ def validate_schedule_semantics(
1239
1333
  try:
1240
1334
  rows, violations = _validate_work_breakdowns(text, tasks)
1241
1335
  violations.extend(_validate_effort_semantics(text, tasks, rows))
1242
- gantt_rows, gantt_violations = _parse_gantt_rows(text)
1336
+ gantt_rows, gantt_violations = _parse_gantt_rows(text, tasks)
1243
1337
  violations.extend(gantt_violations)
1244
1338
  if gantt_rows is not None:
1245
1339
  violations.extend(_validate_gantt_coverage(gantt_rows, tasks))
1246
- violations.extend(_validate_gantt_days(gantt_rows, rows))
1340
+ violations.extend(_validate_gantt_geometry(gantt_rows, rows))
1341
+ violations.extend(_validate_gantt_axis(text, gantt_rows, rows))
1247
1342
  return violations
1248
1343
  except ScheduleSemanticError as exc:
1249
1344
  return [str(exc)]
@@ -47,6 +47,38 @@ class StageMapSnapshot:
47
47
  stages: list[dict[str, Any]]
48
48
 
49
49
 
50
+ # `stepwiseExecution` is what the stage DOES; the other three are what becomes
51
+ # true when it is finished. A schedule carrying only the latter reads as an
52
+ # analysis of a plan rather than the plan itself.
53
+ _PLANNING_STAGE_NARRATIVE_FIELDS = (
54
+ "sliceValue",
55
+ "stepwiseExecution",
56
+ "acceptance",
57
+ "exitContract",
58
+ )
59
+ _PLANNING_TASK_NARRATIVE_FIELDS = (
60
+ "rollbackStrategy",
61
+ "validationChecklist",
62
+ "crossProjectDependencies",
63
+ "dependencyMigrationRisk",
64
+ "recommendedOption",
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class PlanningDetail:
70
+ """The planning report's narrative rows, carried to a schedule verbatim.
71
+
72
+ The Stage Map says which stages exist; these rows say what each one accepts,
73
+ how it rolls back and what validates it. A consumer without them has to
74
+ re-summarise a half-megabyte report by hand, which is where a schedule's
75
+ rollback, verification and risk sections drift away from the plan.
76
+ """
77
+
78
+ stage_narratives: dict[int, dict[str, Any]]
79
+ task_narratives: dict[str, Any]
80
+
81
+
50
82
  class StageMapError(Exception):
51
83
  def __init__(
52
84
  self,
@@ -209,7 +241,14 @@ def parse_stage_map_text(
209
241
  raise StageMapError("stage_map", str(exc), source_plan_path) from exc
210
242
 
211
243
 
212
- def parse_stage_map_file(path: Path) -> list[StageMapStage]:
244
+ def _parse_stage_map_markdown(path: Path) -> list[StageMapStage]:
245
+ """Parse the `## 5.5 Stage Map` table out of a schema-v1 report body.
246
+
247
+ Private on purpose: a v2 report has no such section, so a caller reaching
248
+ for this directly gets `section '## 5.5 Stage Map' is missing` on every
249
+ modern report. `parse_stage_map_file` is the entry point — it reads the
250
+ structured sidecar when there is one and falls back here when there is not.
251
+ """
213
252
  resolved = Path(path).resolve()
214
253
  try:
215
254
  text = resolved.read_text(encoding="utf-8")
@@ -218,6 +257,169 @@ def parse_stage_map_file(path: Path) -> list[StageMapStage]:
218
257
  return parse_stage_map_text(text, source_plan_path=str(resolved))
219
258
 
220
259
 
260
+ def _require_data_positive_int(
261
+ value: Any, field: str, row_number: int, source_plan_path: str,
262
+ ) -> int:
263
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
264
+ raise StageMapError(
265
+ "stage_map",
266
+ f"structured Stage Map row {row_number} has invalid {field} {value!r}",
267
+ source_plan_path,
268
+ )
269
+ return value
270
+
271
+
272
+ def _require_data_text(
273
+ value: Any, field: str, row_number: int, source_plan_path: str,
274
+ ) -> str:
275
+ if not isinstance(value, str) or not value.strip():
276
+ raise StageMapError(
277
+ "stage_map",
278
+ f"structured Stage Map row {row_number} has invalid {field} {value!r}",
279
+ source_plan_path,
280
+ )
281
+ return value
282
+
283
+
284
+ def _parse_data_stage_map_row(
285
+ value: Any, row_number: int, source_plan_path: str,
286
+ ) -> StageMapStage:
287
+ if not isinstance(value, dict):
288
+ raise StageMapError(
289
+ "stage_map",
290
+ f"structured Stage Map row {row_number} must be an object",
291
+ source_plan_path,
292
+ )
293
+ stage_number = _require_data_positive_int(
294
+ value.get("stage"), "stage", row_number, source_plan_path
295
+ )
296
+ step_count = _require_data_positive_int(
297
+ value.get("stepCount"), "stepCount", row_number, source_plan_path
298
+ )
299
+ title = _require_data_text(
300
+ value.get("title"), "title", row_number, source_plan_path
301
+ )
302
+ depends_on = _require_data_text(
303
+ value.get("dependsOn"), "dependsOn", row_number, source_plan_path
304
+ )
305
+ exit_summary = _require_data_text(
306
+ value.get("exitContractSummary"),
307
+ "exitContractSummary",
308
+ row_number,
309
+ source_plan_path,
310
+ )
311
+ return StageMapStage(
312
+ stage_number,
313
+ title,
314
+ _parse_depends_on(depends_on.strip(), row_number, source_plan_path),
315
+ step_count,
316
+ exit_summary,
317
+ )
318
+
319
+
320
+ def _parse_schema_v2_stage_map(
321
+ data: dict[str, Any], source_plan_path: str,
322
+ ) -> list[StageMapStage]:
323
+ planning = data.get("implementationPlanning")
324
+ stage_map = planning.get("stageMap") if isinstance(planning, dict) else None
325
+ if not isinstance(stage_map, list) or not stage_map:
326
+ raise StageMapError(
327
+ "stage_map",
328
+ "structured report requires a non-empty implementationPlanning.stageMap",
329
+ source_plan_path,
330
+ )
331
+ stages = [
332
+ _parse_data_stage_map_row(value, row_number, source_plan_path)
333
+ for row_number, value in enumerate(stage_map, start=1)
334
+ ]
335
+ _validate_stage_numbers(stages, source_plan_path)
336
+ return stages
337
+
338
+
339
+ def parse_stage_map_file(markdown_path: Path) -> list[StageMapStage]:
340
+ """Read one report's Stage Map, whichever schema wrote it.
341
+
342
+ THE entry point for every caller. A schema-v2 report carries the stage map
343
+ in its `.data.json` sidecar and has no `## 5.5 Stage Map` section at all, so
344
+ a caller that parses the markdown directly works only against v1 reports —
345
+ which is how v2 support landed on one call site and left five reading a
346
+ section that modern reports do not have.
347
+ """
348
+ resolved = Path(markdown_path).resolve()
349
+ data_path = resolved.with_suffix(".data.json")
350
+ if not data_path.exists():
351
+ return _parse_stage_map_markdown(resolved)
352
+ try:
353
+ data = json.loads(data_path.read_text(encoding="utf-8"))
354
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
355
+ raise StageMapError("stage_map", str(exc), str(data_path)) from exc
356
+ if not isinstance(data, dict):
357
+ raise StageMapError(
358
+ "stage_map", "structured report must be an object", str(data_path)
359
+ )
360
+ if data.get("schemaVersion") != "2.0":
361
+ return _parse_stage_map_markdown(resolved)
362
+ return _parse_schema_v2_stage_map(data, str(data_path))
363
+
364
+
365
+ def _planning_section(markdown_path: Path) -> dict[str, Any]:
366
+ """The schema-v2 sidecar's `implementationPlanning` block, `{}` for v1."""
367
+ data_path = Path(markdown_path).resolve().with_suffix(".data.json")
368
+ if not data_path.exists():
369
+ return {}
370
+ try:
371
+ data = json.loads(data_path.read_text(encoding="utf-8"))
372
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
373
+ raise StageMapError("stage_map", str(exc), str(data_path)) from exc
374
+ if not isinstance(data, dict) or data.get("schemaVersion") != "2.0":
375
+ return {}
376
+ planning = data.get("implementationPlanning")
377
+ return planning if isinstance(planning, dict) else {}
378
+
379
+
380
+ def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
381
+ narratives: dict[int, dict[str, Any]] = {}
382
+ if not isinstance(value, list):
383
+ return narratives
384
+ for row in value:
385
+ if not isinstance(row, dict):
386
+ continue
387
+ number = row.get("stage")
388
+ if not isinstance(number, int) or isinstance(number, bool):
389
+ continue
390
+ narratives[number] = {
391
+ field: row[field]
392
+ for field in _PLANNING_STAGE_NARRATIVE_FIELDS
393
+ if field in row
394
+ }
395
+ return narratives
396
+
397
+
398
+ def load_planning_detail(markdown_path: Path) -> PlanningDetail:
399
+ """Read one report's narrative rows; empty for a schema-v1 report."""
400
+ planning = _planning_section(markdown_path)
401
+ if not planning:
402
+ return PlanningDetail({}, {})
403
+ return PlanningDetail(
404
+ _stage_narratives(planning.get("stages")),
405
+ {
406
+ field: planning[field]
407
+ for field in _PLANNING_TASK_NARRATIVE_FIELDS
408
+ if field in planning
409
+ },
410
+ )
411
+
412
+
413
+ def merge_planning_detail(
414
+ records: list[dict[str, Any]], detail: PlanningDetail,
415
+ ) -> list[dict[str, Any]]:
416
+ """Join each stage's narrative onto its `stage_map_records` row."""
417
+ return [
418
+ {**record, **detail.stage_narratives.get(record["stage_number"], {})}
419
+ for record in records
420
+ ]
421
+
422
+
221
423
  def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
222
424
  return [
223
425
  {