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