okstra 0.152.0 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/docs/project-structure-overview.md +2 -2
- 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/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/mutation_probe.py +425 -2
- 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/detect_self_mock.py +27 -2
- package/runtime/validators/validate-implementation-plan-stages.py +24 -63
- package/runtime/validators/validate-run.py +110 -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
|
-
|
|
@@ -41,8 +41,10 @@ redirecting ``--waivers`` at a self-authored file blocks instead of passing.
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
43
|
import argparse
|
|
44
|
+
import io
|
|
44
45
|
import json
|
|
45
46
|
import sys
|
|
47
|
+
import tokenize
|
|
46
48
|
from datetime import datetime, timezone
|
|
47
49
|
from pathlib import Path
|
|
48
50
|
|
|
@@ -91,16 +93,39 @@ def scannable_files(paths: list[Path]) -> list[Path]:
|
|
|
91
93
|
return [p for p in paths if is_scannable(p)]
|
|
92
94
|
|
|
93
95
|
|
|
96
|
+
def _mask_python_non_code(text: str) -> str:
|
|
97
|
+
"""Blank Python strings and comments without moving any match positions."""
|
|
98
|
+
chars = list(text)
|
|
99
|
+
line_offsets = [0]
|
|
100
|
+
for line in text.splitlines(keepends=True):
|
|
101
|
+
line_offsets.append(line_offsets[-1] + len(line))
|
|
102
|
+
try:
|
|
103
|
+
tokens = tokenize.generate_tokens(io.StringIO(text).readline)
|
|
104
|
+
for token in tokens:
|
|
105
|
+
if token.type not in {tokenize.STRING, tokenize.COMMENT}:
|
|
106
|
+
continue
|
|
107
|
+
start = line_offsets[token.start[0] - 1] + token.start[1]
|
|
108
|
+
end = line_offsets[token.end[0] - 1] + token.end[1]
|
|
109
|
+
for index in range(start, end):
|
|
110
|
+
if chars[index] not in "\r\n":
|
|
111
|
+
chars[index] = " "
|
|
112
|
+
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
113
|
+
# Broken source is scanned conservatively so a syntax error cannot hide a hit.
|
|
114
|
+
return text
|
|
115
|
+
return "".join(chars)
|
|
116
|
+
|
|
117
|
+
|
|
94
118
|
def scan_files(paths: list[Path]) -> list[dict]:
|
|
95
119
|
"""Return one hit dict ``{file, line, signal}`` per signal match."""
|
|
96
120
|
hits: list[dict] = []
|
|
97
121
|
for p in scannable_files(paths):
|
|
98
122
|
lang = EXT_TO_LANG[p.suffix]
|
|
99
123
|
text = p.read_text(encoding="utf-8", errors="replace")
|
|
124
|
+
scan_text = _mask_python_non_code(text) if lang == "python" else text
|
|
100
125
|
file_hits: list[dict] = []
|
|
101
126
|
for sig in SIGNALS.get(lang, []):
|
|
102
|
-
for m in sig.pattern.finditer(
|
|
103
|
-
line =
|
|
127
|
+
for m in sig.pattern.finditer(scan_text):
|
|
128
|
+
line = scan_text[: m.start()].count("\n") + 1
|
|
104
129
|
file_hits.append({"file": str(p), "line": line, "signal": sig.name})
|
|
105
130
|
hits.extend(sorted(file_hits, key=lambda h: (h["line"], h["signal"])))
|
|
106
131
|
return hits
|
|
@@ -20,8 +20,13 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
|
|
|
20
20
|
sys.path.insert(0, str(_ssot_dir))
|
|
21
21
|
|
|
22
22
|
from okstra_ctl.md_table import split_pipe_row # noqa: E402
|
|
23
|
+
from okstra_ctl.stage_map import ( # noqa: E402
|
|
24
|
+
STAGE_MAP_HEADING,
|
|
25
|
+
StageMapError,
|
|
26
|
+
StageMapStage,
|
|
27
|
+
parse_stage_map_text,
|
|
28
|
+
)
|
|
23
29
|
|
|
24
|
-
STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.M)
|
|
25
30
|
HARD_STEP_CAP = 8
|
|
26
31
|
REQUIRED_SUBSECTIONS = (
|
|
27
32
|
"Carry-In",
|
|
@@ -36,15 +41,6 @@ EXIT_CONTRACT_HEADING = re.compile(r"^###\s+Stage Exit Contract\b", re.M)
|
|
|
36
41
|
PATH_TOKEN = re.compile(r"(?:[\w.@-]+/)+[\w.@-]+")
|
|
37
42
|
|
|
38
43
|
|
|
39
|
-
@dataclass
|
|
40
|
-
class StageMeta:
|
|
41
|
-
stage_number: int
|
|
42
|
-
title: str
|
|
43
|
-
depends_on: List[int]
|
|
44
|
-
step_count: int
|
|
45
|
-
exit_contract_summary: str
|
|
46
|
-
|
|
47
|
-
|
|
48
44
|
@dataclass
|
|
49
45
|
class ValidationError:
|
|
50
46
|
code: str # S1..S11
|
|
@@ -60,11 +56,7 @@ def _check_stage_map_present(text: str) -> List[ValidationError]:
|
|
|
60
56
|
|
|
61
57
|
|
|
62
58
|
def _parse_depends_on_cell(raw: str) -> List[int] | None:
|
|
63
|
-
"""Stage numbers
|
|
64
|
-
|
|
65
|
-
Schema v2 keeps the same literal cell text in `stageMap[].dependsOn`, so
|
|
66
|
-
both the Markdown scan and the data scan read it through here.
|
|
67
|
-
"""
|
|
59
|
+
"""Stage numbers from schema-v2 `stageMap[].dependsOn`."""
|
|
68
60
|
value = raw.strip()
|
|
69
61
|
if value in ("(none)", ""):
|
|
70
62
|
return []
|
|
@@ -74,7 +66,9 @@ def _parse_depends_on_cell(raw: str) -> List[int] | None:
|
|
|
74
66
|
return None
|
|
75
67
|
|
|
76
68
|
|
|
77
|
-
def _stage_numbers_monotonic(
|
|
69
|
+
def _stage_numbers_monotonic(
|
|
70
|
+
stages: List[StageMapStage],
|
|
71
|
+
) -> List[ValidationError]:
|
|
78
72
|
return [
|
|
79
73
|
ValidationError("S2", r.stage_number,
|
|
80
74
|
f"stage numbers must be 1..N monotonic, got {r.stage_number} at row {i}")
|
|
@@ -82,43 +76,6 @@ def _stage_numbers_monotonic(stages: List[StageMeta]) -> List[ValidationError]:
|
|
|
82
76
|
if r.stage_number != i
|
|
83
77
|
]
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
def _parse_stage_map(text: str) -> Tuple[List[StageMeta], List[ValidationError]]:
|
|
87
|
-
m = STAGE_MAP_HEADING.search(text)
|
|
88
|
-
if not m:
|
|
89
|
-
return [], [] # S1 already reported
|
|
90
|
-
body = text[m.end():]
|
|
91
|
-
rows = []
|
|
92
|
-
for line in body.splitlines():
|
|
93
|
-
if line.startswith("##"):
|
|
94
|
-
break
|
|
95
|
-
if not line.strip().startswith("|"):
|
|
96
|
-
continue
|
|
97
|
-
cells = split_pipe_row(line)
|
|
98
|
-
if len(cells) != 5:
|
|
99
|
-
continue
|
|
100
|
-
# skip header and separator rows (all-dash of any length is covered by set check)
|
|
101
|
-
if cells[0] == "stage" or set(cells[0]) <= set("-"):
|
|
102
|
-
continue
|
|
103
|
-
try:
|
|
104
|
-
n = int(cells[0])
|
|
105
|
-
except ValueError:
|
|
106
|
-
continue
|
|
107
|
-
depends = _parse_depends_on_cell(cells[2])
|
|
108
|
-
if depends is None:
|
|
109
|
-
# 비정수 depends_on 셀 → 행 skip (비정수 stage_number 와 동일). raw
|
|
110
|
-
# ValueError 를 흘리면 _stage_map_reject_detail·handoff 의 except
|
|
111
|
-
# PrepareError 를 우회해 traceback 으로 죽으므로, 누락된 행을 하류
|
|
112
|
-
# S2(비단조) 검사가 잡게 한다.
|
|
113
|
-
continue
|
|
114
|
-
try:
|
|
115
|
-
step_count = int(cells[3])
|
|
116
|
-
except ValueError:
|
|
117
|
-
step_count = -1
|
|
118
|
-
rows.append(StageMeta(n, cells[1], depends, step_count, cells[4]))
|
|
119
|
-
return rows, _stage_numbers_monotonic(rows)
|
|
120
|
-
|
|
121
|
-
|
|
122
79
|
def _slice_stage_section(text: str, stage_number: int) -> str:
|
|
123
80
|
"""Return the body of `## 5.5.<n> Stage <n>:` up to the next stage heading."""
|
|
124
81
|
start_m = re.search(
|
|
@@ -165,7 +122,7 @@ def _count_effective_steps(section: str) -> int:
|
|
|
165
122
|
return len(_effective_step_rows(section))
|
|
166
123
|
|
|
167
124
|
|
|
168
|
-
def _check_each_stage_section(text: str, stages: List[
|
|
125
|
+
def _check_each_stage_section(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
|
|
169
126
|
errs: List[ValidationError] = []
|
|
170
127
|
for s in stages:
|
|
171
128
|
if not re.search(
|
|
@@ -227,7 +184,7 @@ def _exemption_reason_allowed(section: str) -> bool:
|
|
|
227
184
|
return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)
|
|
228
185
|
|
|
229
186
|
|
|
230
|
-
def _check_slice_tdd(text: str, stages: List[
|
|
187
|
+
def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
|
|
231
188
|
"""S10: each stage declares a vertical slice and follows RED→GREEN ordering.
|
|
232
189
|
|
|
233
190
|
S10a — `Slice value:` line with a non-empty value.
|
|
@@ -305,7 +262,7 @@ def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationEr
|
|
|
305
262
|
|
|
306
263
|
|
|
307
264
|
def _check_conformance_declaration(
|
|
308
|
-
text: str, stages: List[
|
|
265
|
+
text: str, stages: List[StageMapStage]
|
|
309
266
|
) -> List[ValidationError]:
|
|
310
267
|
"""S11: 각 stage 는 conformance 검증을 선언하거나 명시적으로 면제한다.
|
|
311
268
|
|
|
@@ -326,7 +283,7 @@ def _check_conformance_declaration(
|
|
|
326
283
|
return errs
|
|
327
284
|
|
|
328
285
|
|
|
329
|
-
def _check_depends_on(stages: List[
|
|
286
|
+
def _check_depends_on(stages: List[StageMapStage]) -> List[ValidationError]:
|
|
330
287
|
errs: List[ValidationError] = []
|
|
331
288
|
valid = {s.stage_number for s in stages}
|
|
332
289
|
for s in stages:
|
|
@@ -387,7 +344,9 @@ def _report_shared_parallel_files(files: dict) -> List[ValidationError]:
|
|
|
387
344
|
return errs
|
|
388
345
|
|
|
389
346
|
|
|
390
|
-
def _check_parallel_safety(
|
|
347
|
+
def _check_parallel_safety(
|
|
348
|
+
text: str, stages: List[StageMapStage],
|
|
349
|
+
) -> List[ValidationError]:
|
|
391
350
|
"""S9: two `depends-on (none)` stages must not predict the same file —
|
|
392
351
|
otherwise two parallel implementation runs would edit it concurrently."""
|
|
393
352
|
return _report_shared_parallel_files({
|
|
@@ -411,8 +370,10 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
|
|
|
411
370
|
return present
|
|
412
371
|
|
|
413
372
|
errors: List[ValidationError] = []
|
|
414
|
-
|
|
415
|
-
|
|
373
|
+
try:
|
|
374
|
+
stages = parse_stage_map_text(text)
|
|
375
|
+
except StageMapError as exc:
|
|
376
|
+
return [ValidationError("S2", 0, exc.reason)]
|
|
416
377
|
if stages:
|
|
417
378
|
errors.extend(_check_each_stage_section(text, stages))
|
|
418
379
|
errors.extend(_check_slice_tdd(text, stages))
|
|
@@ -424,7 +385,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
|
|
|
424
385
|
|
|
425
386
|
def _data_stage_metas(
|
|
426
387
|
stage_map: List[dict],
|
|
427
|
-
) -> Tuple[List[
|
|
388
|
+
) -> Tuple[List[StageMapStage], List[ValidationError]]:
|
|
428
389
|
rows = []
|
|
429
390
|
for row in stage_map:
|
|
430
391
|
if not isinstance(row, dict) or not isinstance(row.get("stage"), int):
|
|
@@ -432,7 +393,7 @@ def _data_stage_metas(
|
|
|
432
393
|
depends = _parse_depends_on_cell(str(row.get("dependsOn") or ""))
|
|
433
394
|
if depends is None:
|
|
434
395
|
continue
|
|
435
|
-
rows.append(
|
|
396
|
+
rows.append(StageMapStage(
|
|
436
397
|
row["stage"],
|
|
437
398
|
str(row.get("title") or ""),
|
|
438
399
|
depends,
|
|
@@ -486,7 +447,7 @@ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
|
|
|
486
447
|
|
|
487
448
|
|
|
488
449
|
def _check_data_step_counts(
|
|
489
|
-
stage_map: List[
|
|
450
|
+
stage_map: List[StageMapStage], stages: List[dict]
|
|
490
451
|
) -> List[ValidationError]:
|
|
491
452
|
"""The Stage Map row is the plan's own index of its stage body. A row
|
|
492
453
|
claiming a step count the body does not have makes the map unusable for
|
|
@@ -3249,6 +3249,10 @@ def validate_final_report_data(
|
|
|
3249
3249
|
(data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
3250
3250
|
):
|
|
3251
3251
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3252
|
+
for warning in _detect_uniform_verifier(
|
|
3253
|
+
(data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
3254
|
+
):
|
|
3255
|
+
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3252
3256
|
for warning in _detect_unmapped_incremental_fallback(data, report_path):
|
|
3253
3257
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3254
3258
|
for warning in _detect_missing_dependency_precondition(
|
|
@@ -3259,6 +3263,7 @@ def validate_final_report_data(
|
|
|
3259
3263
|
_validate_self_fix_grouping(data, failures)
|
|
3260
3264
|
_validate_clarification_evidence_note(data, failures)
|
|
3261
3265
|
_validate_plan_body_verdict_provenance(data, report_path, failures)
|
|
3266
|
+
_validate_reverify_result_addresses_prior_dissent(data, report_path, failures)
|
|
3262
3267
|
_validate_aborted_gate_has_clarification(data, failures)
|
|
3263
3268
|
_validate_round_recorded_verdicts(data, failures)
|
|
3264
3269
|
_validate_verdicts_match_current_subjects(data, failures)
|
|
@@ -5048,6 +5053,111 @@ def _validate_plan_body_verdict_provenance(
|
|
|
5048
5053
|
)
|
|
5049
5054
|
|
|
5050
5055
|
|
|
5056
|
+
_UNIFORM_VERIFIER_MIN_ITEMS = 5
|
|
5057
|
+
|
|
5058
|
+
|
|
5059
|
+
def _detect_uniform_verifier(pbv: dict) -> list[str]:
|
|
5060
|
+
"""Verifiers whose every vote in the round was the same verdict.
|
|
5061
|
+
|
|
5062
|
+
`participatingAnalysers` counts whether a worker voted, not whether the
|
|
5063
|
+
votes carried information. A verifier that answers AGREE to every item is
|
|
5064
|
+
counted as a third opinion while contributing no refutation signal, so the
|
|
5065
|
+
report reads as a three-way cross-check backed by two. (fontsninja-nlpvibe
|
|
5066
|
+
`nlpvibe-vs-fontradar-baseline` seq 001: 63/63 AGREE off six inspected
|
|
5067
|
+
evidence paths, on a round where the two other analysers jointly refuted a
|
|
5068
|
+
real defect.)
|
|
5069
|
+
|
|
5070
|
+
Advisory only. A unanimous round is a legitimate outcome, and any ratio
|
|
5071
|
+
strict enough to catch a rubber stamp also fails honest agreement, so this
|
|
5072
|
+
reports the counts and leaves the judgement to the reader.
|
|
5073
|
+
"""
|
|
5074
|
+
items = pbv.get("planItems") if isinstance(pbv, dict) else None
|
|
5075
|
+
if not isinstance(items, list):
|
|
5076
|
+
return []
|
|
5077
|
+
verdicts_by_worker: dict[str, set[str]] = {}
|
|
5078
|
+
counts: dict[str, int] = {}
|
|
5079
|
+
for item in items:
|
|
5080
|
+
if not isinstance(item, dict):
|
|
5081
|
+
continue
|
|
5082
|
+
for verdict in item.get("verdicts") or []:
|
|
5083
|
+
if not isinstance(verdict, dict):
|
|
5084
|
+
continue
|
|
5085
|
+
worker = str(verdict.get("worker") or "").strip()
|
|
5086
|
+
value = str(verdict.get("verdict") or "").strip()
|
|
5087
|
+
if not worker or not value or value == "verification-error":
|
|
5088
|
+
continue
|
|
5089
|
+
verdicts_by_worker.setdefault(worker, set()).add(value)
|
|
5090
|
+
counts[worker] = counts.get(worker, 0) + 1
|
|
5091
|
+
warnings = []
|
|
5092
|
+
for worker in sorted(verdicts_by_worker):
|
|
5093
|
+
distinct = verdicts_by_worker[worker]
|
|
5094
|
+
total = counts[worker]
|
|
5095
|
+
if len(distinct) != 1 or total < _UNIFORM_VERIFIER_MIN_ITEMS:
|
|
5096
|
+
continue
|
|
5097
|
+
warnings.append(
|
|
5098
|
+
f"plan-body verification: {worker} returned `{next(iter(distinct))}` "
|
|
5099
|
+
f"for all {total} items it voted on, so this round's refutation "
|
|
5100
|
+
"signal came from its peers alone. Confirm the worker actually "
|
|
5101
|
+
"opened the cited evidence (its `-audit-` sidecar lists what it "
|
|
5102
|
+
"read) before reading the gate as a full cross-check."
|
|
5103
|
+
)
|
|
5104
|
+
return warnings
|
|
5105
|
+
|
|
5106
|
+
|
|
5107
|
+
_PRIOR_DISSENT_ANCHOR = "**Prior dissent**"
|
|
5108
|
+
_PLAN_VERIFY_ROUND_RE = re.compile(r"-plan-verify-r(?P<round>\d+)-")
|
|
5109
|
+
|
|
5110
|
+
|
|
5111
|
+
def _plan_verify_round_number(file_name: str) -> int | None:
|
|
5112
|
+
match = _PLAN_VERIFY_ROUND_RE.search(file_name)
|
|
5113
|
+
return int(match.group("round")) if match else None
|
|
5114
|
+
|
|
5115
|
+
|
|
5116
|
+
def _validate_reverify_result_addresses_prior_dissent(
|
|
5117
|
+
data: dict,
|
|
5118
|
+
report_path: Path,
|
|
5119
|
+
failures: list[str],
|
|
5120
|
+
) -> None:
|
|
5121
|
+
"""A round 2+ plan-body verdict must engage the dissent it re-verifies.
|
|
5122
|
+
|
|
5123
|
+
The self-fix loop re-dispatches the same workers against a corrected plan,
|
|
5124
|
+
but nothing carried the previous round's objection into the new prompt. The
|
|
5125
|
+
worker that objected then restates its verdict unchanged and its peers
|
|
5126
|
+
re-judge from nothing, so the loop spends its whole `selfFixMaxRounds`
|
|
5127
|
+
budget re-deriving one split instead of settling it. (fontsninja-nlpvibe
|
|
5128
|
+
`nlpvibe-vs-fontradar-baseline` seq 001: three self-fix rounds to
|
|
5129
|
+
`max-rounds-reached`, gate still `blocked-by-disagreement`, codex holding
|
|
5130
|
+
every DISAGREE it opened.)
|
|
5131
|
+
"""
|
|
5132
|
+
worker_results_dir = report_path.parent.parent / "worker-results"
|
|
5133
|
+
if not worker_results_dir.is_dir():
|
|
5134
|
+
return
|
|
5135
|
+
task_type = str((data.get("header") or {}).get("taskType") or "")
|
|
5136
|
+
seq = _report_run_seq(report_path)
|
|
5137
|
+
pattern = f"*-plan-verify-r*-{task_type}-{seq or '*'}.md"
|
|
5138
|
+
silent = []
|
|
5139
|
+
for path in sorted(worker_results_dir.glob(pattern)):
|
|
5140
|
+
if "-audit-plan-verify-r" in path.name:
|
|
5141
|
+
continue
|
|
5142
|
+
round_number = _plan_verify_round_number(path.name)
|
|
5143
|
+
if round_number is None or round_number < 2:
|
|
5144
|
+
continue
|
|
5145
|
+
body = path.read_text(encoding="utf-8", errors="replace")
|
|
5146
|
+
if _PRIOR_DISSENT_ANCHOR not in body:
|
|
5147
|
+
silent.append(path.name)
|
|
5148
|
+
if silent:
|
|
5149
|
+
failures.append(
|
|
5150
|
+
"plan-body re-verification: "
|
|
5151
|
+
f"{silent} carry no `{_PRIOR_DISSENT_ANCHOR}` line. A round 2+ "
|
|
5152
|
+
"verdict exists to settle the previous round's objection, so the "
|
|
5153
|
+
"prompt MUST carry that dissent forward and the worker MUST answer "
|
|
5154
|
+
"whether the correction resolved it. Without it the objecting "
|
|
5155
|
+
"worker repeats its verdict and its peers judge from nothing, and "
|
|
5156
|
+
"the self-fix budget drains on the same split "
|
|
5157
|
+
'(plan-body-verification.md §"Re-verification rounds (round 2+)").'
|
|
5158
|
+
)
|
|
5159
|
+
|
|
5160
|
+
|
|
5051
5161
|
def _validate_plan_item_extraction_completeness(
|
|
5052
5162
|
data: dict,
|
|
5053
5163
|
failures: list[str],
|
|
@@ -5,10 +5,12 @@ defined in skills/okstra-schedule-gen/SKILL.md.
|
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
7
|
python3 validators/validate-schedule.py <path-to-schedule.md>
|
|
8
|
+
python3 validators/validate-schedule.py <path-to-schedule.md> \
|
|
9
|
+
--selection-json <selection.json>
|
|
8
10
|
|
|
9
|
-
Exits 0 if compliant, 1 with a list of violations
|
|
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
|
|
@@ -7,7 +7,7 @@ Usage:
|
|
|
7
7
|
okstra stage-map <task-key> --cwd <dir> Resolve PROJECT_ROOT from <dir>
|
|
8
8
|
okstra stage-map <task-key> --project <dir> Use <dir> directly as PROJECT_ROOT
|
|
9
9
|
|
|
10
|
-
Output: JSON { ok, taskKey, taskRoot, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }.
|
|
10
|
+
Output: JSON { ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }.
|
|
11
11
|
stages is [] when no implementation-planning Stage Map exists.
|
|
12
12
|
`;
|
|
13
13
|
|