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.
- package/docs/architecture.md +3 -1
- package/docs/for-ai/skills/okstra-schedule-gen.md +5 -4
- package/docs/project-structure-overview.md +7 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/python/okstra_ctl/clarification_items.py +3 -3
- package/runtime/python/okstra_ctl/render_final_report.py +31 -44
- package/runtime/python/okstra_ctl/report_contract.py +15 -0
- package/runtime/python/okstra_ctl/report_finalize.py +22 -3
- package/runtime/python/okstra_ctl/report_markdown.py +441 -0
- package/runtime/python/okstra_ctl/schedule_semantics.py +186 -91
- package/runtime/python/okstra_ctl/stage_map.py +203 -1
- package/runtime/python/okstra_ctl/wizard.py +1 -11
- package/runtime/python/okstra_project/state.py +14 -2
- package/runtime/skills/okstra-schedule-gen/SKILL.md +43 -18
- package/runtime/templates/reports/final-report-v2.template.md +74 -10
- package/runtime/templates/reports/md/macros/sections.md +19 -0
- package/runtime/templates/reports/md/tasks/change-impact-analysis.template.md +18 -0
- package/runtime/templates/reports/md/tasks/error-analysis.template.md +13 -0
- package/runtime/templates/reports/md/tasks/feature-analysis.template.md +13 -0
- package/runtime/templates/reports/md/tasks/final-verification.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +15 -0
- package/runtime/templates/reports/md/tasks/implementation.template.md +15 -0
- package/runtime/templates/reports/md/tasks/improvement-discovery.template.md +10 -0
- package/runtime/templates/reports/md/tasks/project-analysis.template.md +15 -0
- package/runtime/templates/reports/md/tasks/release-handoff.template.md +13 -0
- package/runtime/templates/reports/md/tasks/requirements-discovery.template.md +15 -0
- package/runtime/templates/reports/schedule.template.md +166 -63
- package/runtime/validators/validate-run.py +19 -6
- package/runtime/validators/validate-schedule.py +94 -65
- package/src/commands/inspect/stage-map.mjs +6 -1
- package/src/commands/inspect/worker-liveness.mjs +15 -3
- package/src/commands/lifecycle/install.mjs +69 -4
- package/src/commands/lifecycle/uninstall.mjs +21 -35
- 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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
60
|
+
r"^\s*(?:(\S+)\s+)?Stage (\d+)\s+([█]*[░]*)\s*$"
|
|
50
61
|
)
|
|
51
|
-
_GANTT_STAGE_CANDIDATE_RE = re.compile(r"^\s*(\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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1083
|
-
required = _sum_day_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(
|
|
1167
|
-
"
|
|
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
|
-
|
|
1176
|
-
|
|
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(
|
|
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
|
|
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
|
{
|