okstra 0.196.0 → 0.197.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/cli.md +14 -1
  4. package/docs/project-structure-overview.md +1 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
  8. package/runtime/prompts/lead/plan-body-verification.md +21 -3
  9. package/runtime/prompts/profiles/implementation-planning.md +3 -2
  10. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  11. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
  13. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
  15. package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
  16. package/runtime/python/okstra_ctl/conformance.py +10 -0
  17. package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
  18. package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
  19. package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
  20. package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
  21. package/runtime/python/okstra_ctl/plan_items.py +14 -0
  22. package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
  23. package/runtime/python/okstra_ctl/run.py +54 -0
  24. package/runtime/python/okstra_ctl/session_transcript.py +4 -4
  25. package/runtime/python/okstra_ctl/stage_close.py +244 -0
  26. package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
  27. package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
  28. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
  29. package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
  31. package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
  32. package/runtime/skills/okstra-run/SKILL.md +63 -4
  33. package/runtime/validators/validate-implementation-plan-stages.py +109 -23
  34. package/runtime/validators/validate-run.py +30 -6
@@ -21,6 +21,11 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
21
21
  sys.path.insert(0, str(_ssot_dir))
22
22
 
23
23
  from okstra_ctl.md_table import split_pipe_row # noqa: E402
24
+ from okstra_ctl.tdd_bypass import ( # noqa: E402
25
+ REASON_TOKEN as TDD_USER_BYPASS_TOKEN,
26
+ bypass_file,
27
+ granted_stages,
28
+ )
24
29
  from okstra_ctl.stage_map import ( # noqa: E402
25
30
  STAGE_MAP_HEADING,
26
31
  StageMapError,
@@ -185,6 +190,15 @@ TDD_EXEMPTION = re.compile(_LABEL_PREFIX + r"TDD exemption\s*:\s*(?:\*\*)?\s*(.+
185
190
  # Profile implementation-planning.md:81 limits the exemption to these three
186
191
  # categories; any other reason (e.g. "refactor") must not waive RED/GREEN.
187
192
  TDD_EXEMPTION_ALLOWED = ("doc-only", "config-only", "pure-rename")
193
+ # The fourth reason is not a category the plan may assert on its own: it holds
194
+ # only while `<task-root>/qa/tdd-bypass.json` records the user's grant for that
195
+ # stage. Every caller therefore passes the granted stage numbers in, and a plan
196
+ # naming the token with no grant fails S10e like any arbitrary reason. Without
197
+ # it a stage that is genuinely none of the three has no passable value and the
198
+ # plan misdescribes itself to get through (2026-09-10, fontsninja-v3-site
199
+ # dev-10628-3: product work already committed and conformance-proved in a prior
200
+ # run of the same stage, filed as `config-only`).
201
+ TDD_EXEMPTION_USER_BYPASS = TDD_USER_BYPASS_TOKEN
188
202
  TEST_CASE_CATEGORIES = ("success", "boundary", "failure")
189
203
  TEST_CASE = {
190
204
  cat: re.compile(
@@ -196,18 +210,48 @@ CONFORMANCE_TESTS = re.compile(_LABEL_PREFIX + r"Conformance tests\s*:\s*(?:\*\*
196
210
  CONFORMANCE_EXEMPTION = re.compile(_LABEL_PREFIX + r"Conformance exemption\s*:\s*(?:\*\*)?\s*\S", re.M)
197
211
 
198
212
 
199
- def _exemption_reason_allowed(section: str) -> bool:
200
- """True when a `TDD exemption:` line is present AND its reason names an
201
- allowed category (doc-only / config-only / pure-rename, case-insensitive).
202
- A present-but-unlisted reason returns False so S10e can reject it."""
213
+ def _exemption_reason_message(stage_number: int) -> str:
214
+ """S10e 거절 문구. 통과할 있는 값을 전부 이름으로 말한다.
215
+
216
+ 사유 목록만 나열하면 카테고리 어느 것도 사실이 아닌 stage 는
217
+ 거짓 신고 외에 길이 없다. 네 번째 값과 그 값을 얻는 명령을 함께 적어
218
+ 다음 행동이 문구 안에 있게 한다.
219
+ """
220
+ return (
221
+ "S10e: 'tddExemption' reason must be one of "
222
+ + " / ".join(TDD_EXEMPTION_ALLOWED)
223
+ + f" — or `{TDD_EXEMPTION_USER_BYPASS}`, which holds only while the "
224
+ "user has granted it for this stage: re-run prepare with "
225
+ f'`--tdd-bypass "{stage_number or 1}:<reason>"` so the reason is '
226
+ "recorded verbatim in <task-root>/qa/tdd-bypass.json. An empty or "
227
+ "arbitrary reason cannot waive RED/GREEN and the three test cases"
228
+ )
229
+
230
+
231
+ def _exemption_reason_allowed(
232
+ section: str, *, stage_number: int = 0, user_bypassed: frozenset[int] = frozenset(),
233
+ ) -> bool:
234
+ """True when a `TDD exemption:` line is present AND its reason is allowed.
235
+
236
+ Allowed means one of the three categories (doc-only / config-only /
237
+ pure-rename, case-insensitive), or `user-bypass` for a stage the user
238
+ granted — that grant lives outside the plan, so it is passed in.
239
+ A present-but-unlisted reason returns False so S10e can reject it.
240
+ """
203
241
  m = TDD_EXEMPTION.search(section)
204
242
  if not m:
205
243
  return False
206
244
  reason = m.group(1).lower()
245
+ if TDD_EXEMPTION_USER_BYPASS in reason:
246
+ return stage_number in user_bypassed
207
247
  return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)
208
248
 
209
249
 
210
- def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
250
+ def _check_slice_tdd(
251
+ text: str,
252
+ stages: List[StageMapStage],
253
+ user_bypassed: frozenset[int] = frozenset(),
254
+ ) -> List[ValidationError]:
211
255
  """S10: each stage declares a vertical slice and follows RED→GREEN ordering.
212
256
 
213
257
  S10a — `Slice value:` line with a non-empty value.
@@ -237,11 +281,11 @@ def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationE
237
281
  "S10b: 'Acceptance:' line missing or empty"))
238
282
 
239
283
  if TDD_EXEMPTION.search(section):
240
- if not _exemption_reason_allowed(section):
284
+ if not _exemption_reason_allowed(
285
+ section, stage_number=s.stage_number, user_bypassed=user_bypassed,
286
+ ):
241
287
  errs.append(ValidationError("S10", s.stage_number,
242
- "S10e: 'TDD exemption:' reason must be one of "
243
- "doc-only / config-only / pure-rename — an arbitrary reason "
244
- "cannot waive RED/GREEN"))
288
+ _exemption_reason_message(s.stage_number)))
245
289
  continue
246
290
 
247
291
  missing_cases = [
@@ -419,7 +463,9 @@ def _check_parallel_safety(
419
463
  })
420
464
 
421
465
 
422
- def collect_validation_errors(text: str) -> List[ValidationError]:
466
+ def collect_validation_errors(
467
+ text: str, user_bypassed: frozenset[int] = frozenset(),
468
+ ) -> List[ValidationError]:
423
469
  """All S1–S11 checks against the report text; empty list means valid.
424
470
 
425
471
  S1 (missing `## 5.5 Stage Map` heading) makes the rest unparseable, so it
@@ -437,7 +483,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
437
483
  return [ValidationError("S2", 0, exc.reason)]
438
484
  if stages:
439
485
  errors.extend(_check_each_stage_section(text, stages))
440
- errors.extend(_check_slice_tdd(text, stages))
486
+ errors.extend(_check_slice_tdd(text, stages, user_bypassed))
441
487
  errors.extend(_check_markdown_step_commands(text, stages))
442
488
  errors.extend(_check_conformance_declaration(text, stages))
443
489
  errors.extend(_check_depends_on(stages))
@@ -465,7 +511,9 @@ def _data_stage_metas(
465
511
  return rows, _stage_numbers_monotonic(rows)
466
512
 
467
513
 
468
- def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
514
+ def _check_data_slice_tdd(
515
+ stage: dict, user_bypassed: frozenset[int] = frozenset(),
516
+ ) -> List[ValidationError]:
469
517
  """S10c / S10e over one schema-v2 `stages[]` entry.
470
518
 
471
519
  The schema already requires `sliceValue`, `acceptance`, and — through its
@@ -479,12 +527,12 @@ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
479
527
  number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
480
528
  if "tddExemption" in stage:
481
529
  reason = str(stage.get("tddExemption") or "").lower()
530
+ if TDD_EXEMPTION_USER_BYPASS in reason:
531
+ if number in user_bypassed:
532
+ return []
533
+ return [ValidationError("S10", number, _exemption_reason_message(number))]
482
534
  if not any(cat in reason for cat in TDD_EXEMPTION_ALLOWED):
483
- return [ValidationError("S10", number,
484
- "S10e: 'tddExemption' reason must be one of "
485
- + " / ".join(TDD_EXEMPTION_ALLOWED)
486
- + " — an empty or arbitrary reason cannot waive RED/GREEN "
487
- "and the three test cases")]
535
+ return [ValidationError("S10", number, _exemption_reason_message(number))]
488
536
  return []
489
537
 
490
538
  steps = [s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)]
@@ -625,7 +673,9 @@ def _check_data_stage_identities(
625
673
  return errs
626
674
 
627
675
 
628
- def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
676
+ def collect_data_validation_errors(
677
+ planning: dict, user_bypassed: frozenset[int] = frozenset(),
678
+ ) -> List[ValidationError]:
629
679
  """The S-checks that schema v2 cannot express, over `implementationPlanning`.
630
680
 
631
681
  `collect_validation_errors` scans rendered v1 Markdown, which a v2 report
@@ -663,7 +713,7 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
663
713
  if not meta.depends_on
664
714
  }))
665
715
  for stage in stages:
666
- errors.extend(_check_data_slice_tdd(stage))
716
+ errors.extend(_check_data_slice_tdd(stage, user_bypassed))
667
717
  number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
668
718
  for step in stage.get("stepwiseExecution") or []:
669
719
  if isinstance(step, dict):
@@ -671,26 +721,62 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
671
721
  return errors
672
722
 
673
723
 
674
- def collect_plan_errors(plan_path: Path) -> List[ValidationError]:
724
+ def collect_plan_errors(
725
+ plan_path: Path, user_bypassed: frozenset[int] | None = None,
726
+ ) -> List[ValidationError]:
675
727
  """The S-checks for one approved plan, whichever schema wrote it.
676
728
 
677
729
  A schema-v2 report keeps its stage map in the `.data.json` sidecar and
678
730
  renders no `## 5.5 Stage Map` section, so scanning its markdown reports the
679
731
  section as missing and blocks every run that approved such a plan.
680
732
  """
733
+ granted = (
734
+ user_bypassed if user_bypassed is not None
735
+ else user_bypassed_stages_for_plan(plan_path)
736
+ )
681
737
  planning = schema_v2_report(plan_path).get("implementationPlanning")
682
738
  if isinstance(planning, dict) and planning:
683
- return collect_data_validation_errors(planning)
684
- return collect_validation_errors(plan_path.read_text(encoding="utf-8"))
739
+ return collect_data_validation_errors(planning, granted)
740
+ return collect_validation_errors(plan_path.read_text(encoding="utf-8"), granted)
741
+
742
+
743
+ def user_bypassed_stages_for_plan(plan_path: Path) -> frozenset[int]:
744
+ """이 계획이 속한 task 의 우회 원장에서 부여된 stage 번호들.
745
+
746
+ 레이아웃 해석은 `RunRef.from_run_dir` 이 소유한다 — 부모를 세는 계산은
747
+ 배치가 하나만 바뀌어도 조용히 다른 디렉터리를 가리킨다. run 디렉터리로
748
+ 해석되지 않는 입력은 빈 집합이고, 그때는 `--tdd-bypass-ledger` 로 원장을
749
+ 직접 넘기는 것이 경로다. 원장이 없으면 빈 집합 — 우회 없음이다.
750
+ """
751
+ from okstra_ctl.paths import RunRef
752
+
753
+ try:
754
+ task_root = RunRef.from_run_dir(plan_path.resolve().parent.parent).task_root
755
+ except (ValueError, IndexError):
756
+ return frozenset()
757
+ return frozenset(granted_stages(bypass_file(task_root)))
685
758
 
686
759
 
687
760
  def main(argv: List[str]) -> int:
688
761
  p = argparse.ArgumentParser()
689
762
  p.add_argument("--plan", required=True)
763
+ p.add_argument(
764
+ "--tdd-bypass-ledger",
765
+ default="",
766
+ dest="tdd_bypass_ledger",
767
+ help=(
768
+ "path to <task-root>/qa/tdd-bypass.json; defaults to the ledger of "
769
+ "the task the --plan report lives in"
770
+ ),
771
+ )
690
772
  args = p.parse_args(argv)
691
773
 
774
+ granted = (
775
+ frozenset(granted_stages(Path(args.tdd_bypass_ledger)))
776
+ if args.tdd_bypass_ledger else None
777
+ )
692
778
  try:
693
- errors = collect_plan_errors(Path(args.plan))
779
+ errors = collect_plan_errors(Path(args.plan), granted)
694
780
  except StageMapError as exc:
695
781
  print(f"S0 stage=0: {exc.reason}", file=sys.stderr)
696
782
  return 1
@@ -41,6 +41,7 @@ from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
41
41
  from okstra_project.resolver import resolve_architecture # noqa: E402
42
42
 
43
43
  from okstra_ctl.conformance import ( # noqa: E402
44
+ conformance_result_file,
44
45
  detect_surfaces,
45
46
  exempt_stage_surface_conflicts,
46
47
  evaluate_conformance,
@@ -56,6 +57,10 @@ from okstra_ctl.dispatch_state import ( # noqa: E402
56
57
  v2_worker_state_key,
57
58
  )
58
59
  from okstra_ctl.paths import RunRef, okstra_home, project_rel # noqa: E402
60
+ from okstra_ctl.tdd_bypass import ( # noqa: E402
61
+ bypass_file as tdd_bypass_file,
62
+ granted_stages,
63
+ )
59
64
  from okstra_ctl.reconcile import settle_run_row # noqa: E402
60
65
  from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION # noqa: E402
61
66
  from okstra_ctl.release_gate import ( # noqa: E402
@@ -1700,7 +1705,7 @@ def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
1700
1705
  key = entry.get("stageKey") if isinstance(entry, dict) else None
1701
1706
  if not isinstance(key, str) or not key:
1702
1707
  continue
1703
- sidecar = qa_dir / f"result-{key}.json"
1708
+ sidecar = conformance_result_file(qa_dir, key)
1704
1709
  if not sidecar.is_file():
1705
1710
  continue
1706
1711
  try:
@@ -6352,9 +6357,14 @@ def _plan_verify_dispatched_results(
6352
6357
  # critic 동수 라운드는 `kind: "critic"` 으로 나간다 — 결과 파일명은
6353
6358
  # 같은 `-plan-verify-r<N>-` 꼴이다. reverify 계열만 세면 동수가 있던
6354
6359
  # run 마다 critic 표가 "디스패치 기록 없음" 으로 오탐된다(2026-09-09,
6355
- # fontsninja-v3-site dev-10627 planning 002).
6360
+ # fontsninja-v3-site dev-10627 planning 002). 계획 본문 라운드 자신의
6361
+ # kind 인 `plan-verify-r<N>` 도 같은 이유로 센다.
6356
6362
  kind = str(row.get("kind") or "")
6357
- if not (kind.startswith("reverify-r") or kind == "critic"):
6363
+ if not (
6364
+ kind.startswith("reverify-r")
6365
+ or kind.startswith("plan-verify-r")
6366
+ or kind == "critic"
6367
+ ):
6358
6368
  continue
6359
6369
  name = Path(str(row.get("workerResultPath") or "")).name
6360
6370
  if "-plan-verify-r" not in name:
@@ -8003,13 +8013,19 @@ def _load_stage_validator():
8003
8013
  return mod
8004
8014
 
8005
8015
 
8006
- def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) -> None:
8016
+ def _append_stage_data_failures(
8017
+ data: Mapping[str, Any], failures: list[str], task_root: Path | None = None,
8018
+ ) -> None:
8007
8019
  """Run the stage relationship checks that schema v2 cannot express.
8008
8020
 
8009
8021
  The depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and
8010
8022
  the TDD-exemption vocabulary are relationships between stages, which a
8011
8023
  JSON Schema cannot state. They are enforced here, against the data.json,
8012
8024
  by the same validator that owns the rule vocabulary.
8025
+
8026
+ `tddExemption: user-bypass` is the one reason the plan cannot assert by
8027
+ itself, so the user's grants are read from the task's own bypass ledger
8028
+ and passed in; without the task root no stage counts as granted.
8013
8029
  """
8014
8030
  if (data or {}).get("schemaVersion") != CURRENT_REPORT_SCHEMA_VERSION:
8015
8031
  return # Schema validation already rejected an unknown version.
@@ -8020,7 +8036,11 @@ def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) ->
8020
8036
  if mod is None: # pragma: no cover — repo/runtime always ship the file
8021
8037
  failures.append(f"cannot load Stage Map validator at {_STAGE_VALIDATOR_PATH}")
8022
8038
  return
8023
- for e in mod.collect_data_validation_errors(dict(planning)):
8039
+ granted = (
8040
+ frozenset(granted_stages(tdd_bypass_file(task_root)))
8041
+ if task_root is not None else frozenset()
8042
+ )
8043
+ for e in mod.collect_data_validation_errors(dict(planning), granted):
8024
8044
  failures.append(
8025
8045
  f"implementation-planning stage contract invalid "
8026
8046
  f"[{e.code} stage={e.stage}]: {e.message}"
@@ -9485,7 +9505,11 @@ def main() -> int:
9485
9505
  )
9486
9506
  _validate_stage_has_requirement(validation_data, failures)
9487
9507
  if task_type == "implementation-planning":
9488
- _append_stage_data_failures(validation_data, failures)
9508
+ _append_stage_data_failures(
9509
+ validation_data,
9510
+ failures,
9511
+ _task_root_from_run_dir(report_path.parent.parent),
9512
+ )
9489
9513
  if task_type == "improvement-discovery":
9490
9514
  run_dir = report_path.parent.parent
9491
9515
  _validate_improvement_discovery(report_path, run_dir, brief_path, failures)