okstra 0.151.1 → 0.152.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.151.1",
3
+ "version": "0.152.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.151.1",
3
- "builtAt": "2026-08-05T07:39:18.187Z",
2
+ "package": "0.152.0",
3
+ "builtAt": "2026-08-05T09:28:14.647Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -84,8 +84,12 @@
84
84
  - The plan lives in `data.json` under `implementationPlanning`, and `schemas/final-report-v2.0.schema.json` requires every one of these keys: `optionCandidates`, `tradeoffMatrix`, `recommendedOption`, `stageMap`, `stages`, `dependencyMigrationRisk`, `validationChecklist`, `rollbackStrategy`, `requirementCoverage`, `planBodyVerification`, `crossProjectDependencies`, `decisionDrafts`, `skippedAdrCandidates`, `variationPointAnalysis`, `userNarrative`. A missing block fails schema validation; there is nothing to satisfy by naming a heading. (Approval is not a body section — it is the YAML frontmatter `approved` field.)
85
85
  - Each `stages[]` entry requires `stage`, `title`, `sliceValue`, `acceptance`, `carryIn`, `stepwiseExecution` (1–6 rows), `exitContract`, and `stageValidation`. Each `stageMap[]` row requires `stage`, `title`, `dependsOn`, `stepCount`, `exitContractSummary`.
86
86
  - Beyond the schema, `validators/validate-run.py` reads the same data.json for `_validate_planning_conformance_declared`, `_validate_end_state_coverage`, `_validate_requirement_provenance`, `_validate_stage_has_requirement`, and `_validate_plan_body_state_file`. These run for every planning report regardless of schema version.
87
- - **Do not chase English heading substrings.** `PLANNING_REQUIRED_SECTIONS` and the structural Stage Map scan (`validators/validate-implementation-plan-stages.py`) live inside `validate_phase_boundary`, which returns immediately when `schemaVersion == "2.0"` — they gate historical v1 Markdown only. The v2 AI-handoff template renders nine headings and serialises the plan as JSON beneath them, so those substrings cannot appear, and a report is not defective for lacking them.
88
- - *Guideline, not gated:* write each `stepwiseExecution` row's `action` with a `RED:` / `GREEN:` prefix (or state a TDD exemption in `stageValidation`), and name the success / boundary / failure cases in `acceptance`. The v1 scan checked those tokens literally; v2 requires only that the fields be non-empty, so this is now review-enforced rather than validator-enforced.
87
+ - **Do not chase English heading substrings.** `PLANNING_REQUIRED_SECTIONS` and the Markdown scan in `collect_validation_errors` live inside `validate_phase_boundary`, which returns immediately when `schemaVersion == "2.0"` — they gate historical v1 Markdown only. The v2 AI-handoff template renders nine headings and serialises the plan as JSON beneath them, so those substrings cannot appear, and a report is not defective for lacking them.
88
+ - Per-stage vertical slice and TDD contract (BLOCKING enforced on the data, not on heading tokens):
89
+ - Every stage declares `sliceValue`, `acceptance`, and the three cases `testCaseSuccess` / `testCaseBoundary` / `testCaseFailure` — happy path, edge/boundary input, failure input. **Enforced:** the v2 schema's `if not tddExemption then require` conditional on `ImplementationPlanStage`.
90
+ - The first `stepwiseExecution` row's `action` starts with `RED:` and its `expected` reads FAIL; some later row's `action` starts with `GREEN:` and its `expected` reads PASS. **Enforced (S10c):** `collect_data_validation_errors` in `validators/validate-implementation-plan-stages.py`, run from `validate-run.py` `_append_stage_data_failures`.
91
+ - `tddExemption` waives both rules above, and only for `doc-only`, `config-only`, or `pure-rename` work. An empty or arbitrary reason waives nothing. **Enforced (S10e):** same function — the schema alone cannot reject it, because it types the field as a plain string and keys its conditional on the property merely being present.
92
+ - `stageMap[].dependsOn` must form a DAG (no self-dependency, no unknown stage, no cycle), each row's `stepCount` must equal its stage's actual `stepwiseExecution` row count, and two `(none)`-dependency stages must not name the same file in their `exitContract` — they run as concurrent implementation runs in separate worktrees. **Enforced (S8/S4/S9):** same function.
89
93
  - Required deliverable shape (final report, in addition to the standard sections):
90
94
  - at least two implementation options. **Each option must include**:
91
95
  - **File Structure**: an explicit list of files to create / modify / delete with each file's responsibility (one-line each). Use the form `Create: path — responsibility` / `Modify: path:line-range — change summary` / `Delete: path — reason`. Write every `path` in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...` / a trailing `/…`); an abbreviated path does not resolve and is rejected by plan-body verification as a kind-b path mismatch.
@@ -59,6 +59,30 @@ def _check_stage_map_present(text: str) -> List[ValidationError]:
59
59
  return []
60
60
 
61
61
 
62
+ def _parse_depends_on_cell(raw: str) -> List[int] | None:
63
+ """Stage numbers in a `depends-on` cell; None when the cell is unparseable.
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
+ """
68
+ value = raw.strip()
69
+ if value in ("(none)", ""):
70
+ return []
71
+ try:
72
+ return [int(x.strip()) for x in value.split(",") if x.strip()]
73
+ except ValueError:
74
+ return None
75
+
76
+
77
+ def _stage_numbers_monotonic(stages: List[StageMeta]) -> List[ValidationError]:
78
+ return [
79
+ ValidationError("S2", r.stage_number,
80
+ f"stage numbers must be 1..N monotonic, got {r.stage_number} at row {i}")
81
+ for i, r in enumerate(stages, start=1)
82
+ if r.stage_number != i
83
+ ]
84
+
85
+
62
86
  def _parse_stage_map(text: str) -> Tuple[List[StageMeta], List[ValidationError]]:
63
87
  m = STAGE_MAP_HEADING.search(text)
64
88
  if not m:
@@ -80,29 +104,19 @@ def _parse_stage_map(text: str) -> Tuple[List[StageMeta], List[ValidationError]]
80
104
  n = int(cells[0])
81
105
  except ValueError:
82
106
  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
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
95
114
  try:
96
115
  step_count = int(cells[3])
97
116
  except ValueError:
98
117
  step_count = -1
99
118
  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
119
+ return rows, _stage_numbers_monotonic(rows)
106
120
 
107
121
 
108
122
  def _slice_stage_section(text: str, stage_number: int) -> str:
@@ -358,16 +372,8 @@ def _extract_exit_contract_files(section: str) -> set:
358
372
  return set(PATH_TOKEN.findall(body))
359
373
 
360
374
 
361
- def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[ValidationError]:
362
- """S9: two `depends-on (none)` stages must not predict the same file —
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
- }
375
+ def _report_shared_parallel_files(files: dict) -> List[ValidationError]:
376
+ """S9 over an already-extracted {stage_number: {path}} map."""
371
377
  errs: List[ValidationError] = []
372
378
  nums = sorted(files)
373
379
  for i in range(len(nums)):
@@ -381,6 +387,18 @@ def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[Validatio
381
387
  return errs
382
388
 
383
389
 
390
+ def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[ValidationError]:
391
+ """S9: two `depends-on (none)` stages must not predict the same file —
392
+ otherwise two parallel implementation runs would edit it concurrently."""
393
+ return _report_shared_parallel_files({
394
+ s.stage_number: _extract_exit_contract_files(
395
+ _slice_stage_section(text, s.stage_number)
396
+ )
397
+ for s in stages
398
+ if not s.depends_on
399
+ })
400
+
401
+
384
402
  def collect_validation_errors(text: str) -> List[ValidationError]:
385
403
  """All S1–S11 checks against the report text; empty list means valid.
386
404
 
@@ -404,6 +422,130 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
404
422
  return errors
405
423
 
406
424
 
425
+ def _data_stage_metas(
426
+ stage_map: List[dict],
427
+ ) -> Tuple[List[StageMeta], List[ValidationError]]:
428
+ rows = []
429
+ for row in stage_map:
430
+ if not isinstance(row, dict) or not isinstance(row.get("stage"), int):
431
+ continue
432
+ depends = _parse_depends_on_cell(str(row.get("dependsOn") or ""))
433
+ if depends is None:
434
+ continue
435
+ rows.append(StageMeta(
436
+ row["stage"],
437
+ str(row.get("title") or ""),
438
+ depends,
439
+ row.get("stepCount") if isinstance(row.get("stepCount"), int) else -1,
440
+ str(row.get("exitContractSummary") or ""),
441
+ ))
442
+ return rows, _stage_numbers_monotonic(rows)
443
+
444
+
445
+ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
446
+ """S10c / S10e over one schema-v2 `stages[]` entry.
447
+
448
+ The schema already requires `sliceValue`, `acceptance`, and — through its
449
+ `if not tddExemption then testCase*` conditional — the three test cases, so
450
+ S10a/S10b/S10d are covered declaratively. Two rules a JSON Schema cannot
451
+ state are left: the RED→GREEN ordering across `stepwiseExecution` rows, and
452
+ that a `tddExemption` naming no allowed category cannot waive them. The
453
+ schema types `tddExemption` as a plain string, so `""` currently satisfies
454
+ the conditional and drops all three test cases with no reason given.
455
+ """
456
+ number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
457
+ if "tddExemption" in stage:
458
+ reason = str(stage.get("tddExemption") or "").lower()
459
+ if not any(cat in reason for cat in TDD_EXEMPTION_ALLOWED):
460
+ return [ValidationError("S10", number,
461
+ "S10e: 'tddExemption' reason must be one of "
462
+ + " / ".join(TDD_EXEMPTION_ALLOWED)
463
+ + " — an empty or arbitrary reason cannot waive RED/GREEN "
464
+ "and the three test cases")]
465
+ return []
466
+
467
+ steps = [s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)]
468
+ actions = [str(s.get("action") or "") for s in steps]
469
+ if not (actions and actions[0].startswith("RED:")
470
+ and any(a.startswith("GREEN:") for a in actions)):
471
+ return [ValidationError("S10", number,
472
+ "S10c: first stepwiseExecution action must start with 'RED:' and "
473
+ "some action with 'GREEN:', or declare a 'tddExemption'")]
474
+
475
+ errs: List[ValidationError] = []
476
+ for step in steps:
477
+ action = str(step.get("action") or "")
478
+ expected = str(step.get("expected") or "")
479
+ if action.startswith("RED:") and "FAIL" not in expected.upper():
480
+ errs.append(ValidationError("S10", number,
481
+ f"S10c: 'RED:' step's expected must read FAIL, got '{expected}'"))
482
+ elif action.startswith("GREEN:") and "PASS" not in expected.upper():
483
+ errs.append(ValidationError("S10", number,
484
+ f"S10c: 'GREEN:' step's expected must read PASS, got '{expected}'"))
485
+ return errs
486
+
487
+
488
+ def _check_data_step_counts(
489
+ stage_map: List[StageMeta], stages: List[dict]
490
+ ) -> List[ValidationError]:
491
+ """The Stage Map row is the plan's own index of its stage body. A row
492
+ claiming a step count the body does not have makes the map unusable for
493
+ sizing a stage, which is the only reason the cell exists."""
494
+ by_number = {
495
+ s.get("stage"): s for s in stages
496
+ if isinstance(s, dict) and isinstance(s.get("stage"), int)
497
+ }
498
+ errs: List[ValidationError] = []
499
+ for meta in stage_map:
500
+ stage = by_number.get(meta.stage_number)
501
+ if stage is None:
502
+ errs.append(ValidationError("S3", meta.stage_number,
503
+ "stageMap row has no matching stages[] entry"))
504
+ continue
505
+ actual = len([
506
+ s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)
507
+ ])
508
+ if meta.step_count != actual:
509
+ errs.append(ValidationError("S4", meta.stage_number,
510
+ f"stageMap stepCount {meta.step_count} != "
511
+ f"{actual} stepwiseExecution rows"))
512
+ for number in sorted(n for n in by_number if n not in {m.stage_number for m in stage_map}):
513
+ errs.append(ValidationError("S3", number,
514
+ "stages[] entry has no matching stageMap row"))
515
+ return errs
516
+
517
+
518
+ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
519
+ """The S-checks that schema v2 cannot express, over `implementationPlanning`.
520
+
521
+ `collect_validation_errors` scans rendered v1 Markdown, and
522
+ `validate_phase_boundary` returns before calling it for a v2 report — so on
523
+ the current schema nothing enforced the depends-on DAG, parallel-stage file
524
+ safety, RED→GREEN ordering, or the TDD-exemption vocabulary. The schema
525
+ covers presence and cardinality; this covers the relationships between
526
+ fields, which is what a JSON Schema has no way to say.
527
+ """
528
+ stage_map, errors = _data_stage_metas(planning.get("stageMap") or [])
529
+ stages = [s for s in (planning.get("stages") or []) if isinstance(s, dict)]
530
+ if not stage_map and not stages:
531
+ return errors
532
+
533
+ errors.extend(_check_data_step_counts(stage_map, stages))
534
+ errors.extend(_check_depends_on(stage_map))
535
+ errors.extend(_report_shared_parallel_files({
536
+ meta.stage_number: set(PATH_TOKEN.findall(
537
+ str((next(
538
+ (s for s in stages if s.get("stage") == meta.stage_number), {}
539
+ )).get("exitContract") or "")
540
+ ))
541
+ for meta in stage_map
542
+ if not meta.depends_on
543
+ }))
544
+ for stage in stages:
545
+ errors.extend(_check_data_slice_tdd(stage))
546
+ return errors
547
+
548
+
407
549
  def main(argv: List[str]) -> int:
408
550
  p = argparse.ArgumentParser()
409
551
  p.add_argument("--plan", required=True)
@@ -6177,6 +6177,31 @@ def _load_stage_validator():
6177
6177
  return mod
6178
6178
 
6179
6179
 
6180
+ def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) -> None:
6181
+ """Run the stage relationship checks that schema v2 cannot express.
6182
+
6183
+ `_append_stage_structure_failures` scans rendered Markdown and sits after
6184
+ the v2 early return in `validate_phase_boundary`, so for a v2 report the
6185
+ depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and the
6186
+ TDD-exemption vocabulary had nothing enforcing them. The same validator
6187
+ owns both modes so the rule vocabulary stays defined once.
6188
+ """
6189
+ if (data or {}).get("schemaVersion") != "2.0":
6190
+ return # v1 reports are covered by the Markdown scan.
6191
+ planning = (data or {}).get("implementationPlanning")
6192
+ if not isinstance(planning, Mapping):
6193
+ return # Schema validation already reported the missing block.
6194
+ mod = _load_stage_validator()
6195
+ if mod is None: # pragma: no cover — repo/runtime always ship the file
6196
+ failures.append(f"cannot load Stage Map validator at {_STAGE_VALIDATOR_PATH}")
6197
+ return
6198
+ for e in mod.collect_data_validation_errors(dict(planning)):
6199
+ failures.append(
6200
+ f"implementation-planning stage contract invalid "
6201
+ f"[{e.code} stage={e.stage}]: {e.message}"
6202
+ )
6203
+
6204
+
6180
6205
  def _append_stage_structure_failures(content: str, failures: list[str]) -> None:
6181
6206
  """Enforce the Stage Map structural contract at the implementation-planning
6182
6207
  boundary. Without this, a plan missing `## 5.5 Stage Map` passes the
@@ -7420,6 +7445,7 @@ def main() -> int:
7420
7445
  validation_data, brief_path, failures
7421
7446
  )
7422
7447
  _validate_stage_has_requirement(validation_data, failures)
7448
+ _append_stage_data_failures(validation_data, failures)
7423
7449
  if task_type == "improvement-discovery":
7424
7450
  run_dir = report_path.parent.parent
7425
7451
  _validate_improvement_discovery(report_path, run_dir, brief_path, failures)