okstra 0.199.1 → 0.199.3

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 (36) hide show
  1. package/docs/cli.md +4 -4
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/prompts/launch.template.md +3 -2
  5. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  6. package/runtime/prompts/lead/okstra-lead-contract.md +3 -2
  7. package/runtime/prompts/lead/plan-body-verification.md +9 -5
  8. package/runtime/prompts/lead/report-writer.md +12 -9
  9. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  10. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +5 -1
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +8 -0
  13. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +10 -0
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +5 -1
  15. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +7 -1
  16. package/runtime/python/okstra_ctl/blocking_checks.py +1 -1
  17. package/runtime/python/okstra_ctl/conformance.py +7 -0
  18. package/runtime/python/okstra_ctl/convergence_store.py +16 -1
  19. package/runtime/python/okstra_ctl/error_log_core.py +2 -1
  20. package/runtime/python/okstra_ctl/error_log_write.py +36 -0
  21. package/runtime/python/okstra_ctl/error_report.py +20 -1
  22. package/runtime/python/okstra_ctl/implementation_direction.py +22 -4
  23. package/runtime/python/okstra_ctl/plan_items.py +5 -2
  24. package/runtime/python/okstra_ctl/plan_items_cli.py +94 -37
  25. package/runtime/python/okstra_ctl/qa_commands.py +26 -2
  26. package/runtime/python/okstra_ctl/report_assembly.py +13 -2
  27. package/runtime/python/okstra_ctl/report_finalize.py +108 -20
  28. package/runtime/python/okstra_ctl/report_synthesis_packet.py +7 -0
  29. package/runtime/python/okstra_ctl/run.py +10 -0
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +13 -0
  31. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -0
  32. package/runtime/skills/okstra-inspect/SKILL.md +4 -2
  33. package/runtime/skills/okstra-run/SKILL.md +1 -1
  34. package/runtime/validators/forbidden_actions.py +3 -0
  35. package/runtime/validators/validate-run.py +105 -27
  36. package/runtime/validators/validate_session_conformance.py +5 -0
@@ -246,6 +246,13 @@ class ReportSynthesisPacket:
246
246
  "that exact order.",
247
247
  "`endStateCoverage` must contain exactly one row per original "
248
248
  "requirement id; an `addressed` row names its `coveredBy` anchor.",
249
+ "Coverage references use existing identifiers: `stageRefs` contains stage numbers, "
250
+ "`stepRefs` uses `<stage>.<step>` (for example `1.2`), `validationRefs` uses "
251
+ "validation checklist ids, and `fileRefs` uses exact changed file paths without annotations. "
252
+ "Map QA script changes to their requirements too. A future follow-up is not a stage or validation id.",
253
+ "Derive `coverageSummary` from the coverage rows; do not declare exact 100% "
254
+ "or `plan-ready` while requirements are uncovered or file changes are unmapped. "
255
+ "Preserve recorded user decisions when describing deferred work.",
249
256
  ]
250
257
 
251
258
  def to_dict(self) -> dict[str, Any]:
@@ -394,6 +394,16 @@ def _validate_approved_plan_conformance(path: Path) -> None:
394
394
  if loaded is None:
395
395
  return
396
396
  data_path, data = loaded
397
+ from .implementation_direction import stage_validation_executability_errors
398
+
399
+ command_errors = stage_validation_executability_errors(
400
+ data.get("implementationPlanning") or {}
401
+ )
402
+ if command_errors:
403
+ raise PrepareError(
404
+ f"approved plan command preflight failed: {data_path}\n"
405
+ + "\n".join(command_errors)
406
+ )
397
407
  bad = malformed_conformance_stages(data)
398
408
  if not bad:
399
409
  return
@@ -391,6 +391,19 @@ def complete_reverify_instruction(
391
391
  if not body.lstrip().startswith(("## ", "**")):
392
392
  body = "## Instructions\n\n" + body
393
393
  body = "\n".join(prefix) + "\n\n" + body
394
+ if task_type == "implementation-planning":
395
+ # 저장된 금지 목록은 감사 대조용으로 유지하되 폐기된 작성 의무를 정정한다.
396
+ body = body.rstrip() + (
397
+ "\n\n## Planning conformance ownership\n\n"
398
+ "Planning declares conformance commands and required dependencies; "
399
+ "implementation writes the QA scripts, manifest, and tsconfig. "
400
+ "Do not create those files during planning. Their absence before "
401
+ "implementation is not a planning defect by itself. This phase "
402
+ "ownership supersedes any legacy requirement in the frozen Forbidden "
403
+ "actions block saying this phase MUST write those artifacts. "
404
+ "Verify that the plan assigns their creation to implementation and "
405
+ "provides executable commands and required dependencies.\n"
406
+ )
394
407
  if _OUTPUT_CONTRACT_HEADING not in body:
395
408
  body = body.rstrip() + "\n\n" + output_contract.strip() + "\n"
396
409
  errors = _validate_output_contract_block(body)
@@ -121,6 +121,16 @@ def is_plan_verify_dispatch_kind(dispatch_kind: str) -> bool:
121
121
  return _numbered_round(dispatch_kind, PLAN_VERIFY_DISPATCH_KIND_PREFIX) is not None
122
122
 
123
123
 
124
+ def is_plan_critic_verification(
125
+ *, task_type: str, assignment_ref: str, dispatch_kind: str,
126
+ ) -> bool:
127
+ return (
128
+ task_type == "implementation-planning"
129
+ and assignment_ref == "critic/scope"
130
+ and is_plan_verify_dispatch_kind(dispatch_kind)
131
+ )
132
+
133
+
124
134
  def verification_dispatch_round(dispatch_kind: str) -> int | None:
125
135
  """검증 kind 가 적는 라운드 번호. 번호 없는 kind(`critic-verify`)는 1,
126
136
  검증 kind 가 아니거나 번호가 깨졌으면 None.
@@ -28,19 +28,21 @@ Single read-side entry point for okstra runtime inspection plus the one status m
28
28
 
29
29
  ## Step 0: Preflight (shared)
30
30
 
31
+ Resolve `<host-runtime>` from the launcher's `OKSTRA_RUNTIME_HOST` when present; otherwise use the registered host ID declared by the current harness (`codex` in Codex, `claude-code` in Claude Code). This follows `okstra-run`'s host selection rule. Do not infer the host from worker models, installed executables, or `PATH`. If neither source identifies the host, report that it is unknown instead of substituting Claude Code. Keep the same resolved host when retrying against another project directory.
32
+
31
33
  <!-- BEGIN FRAGMENT: bash-invocation-rule -->
32
34
  Run one Bash tool call, starting with the literal token `okstra` (never wrapped in `if`/`eval`/`export`/`$(...)`/`VAR=...`/`||`/`&&`/`npx` — a non-literal leading token defeats the `Bash(okstra:*)` permission match):
33
35
  <!-- END FRAGMENT: bash-invocation-rule -->
34
36
 
35
37
  ```bash
36
- okstra preflight --runtime claude-code
38
+ okstra preflight --runtime <host-runtime>
37
39
  ```
38
40
 
39
41
  The project check only sees the cwd of the Bash call. When the user is asking about a project that is **not** the cwd (a sibling repo, a monorepo subdir, or a project named explicitly in the request), the bare form can report `Okstra preflight: failed` — a false negative, not a missing setup; do not hard-stop on it.
40
42
 
41
43
  Branch on the fixed first line:
42
44
  - `Okstra preflight: ready` → carry `Project root` as a literal string; it is the base for every sub-command step below.
43
- - `Okstra preflight: failed` → before concluding "no setup", ask whether the user pointed at a specific project directory. If they did, re-run targeting it: `okstra preflight --runtime claude-code --cwd <that-dir>` (`--cwd` is the sanctioned way to target a project — a leading `cd` would break the permission match). Only if this also fails do you show `Reason` and `Recovery`, then stop.
45
+ - `Okstra preflight: failed` → before concluding "no setup", ask whether the user pointed at a specific project directory. If they did, re-run targeting it: `okstra preflight --runtime <host-runtime> --cwd <that-dir>` (`--cwd` is the sanctioned way to target a project — a leading `cd` would break the permission match). Only if this also fails do you show `Reason` and `Recovery`, then stop.
44
46
 
45
47
  <!-- BEGIN FRAGMENT: preflight-outdated-cli -->
46
48
  If the call fails with `unknown command: preflight`, the `okstra` binary on PATH predates this skill — tell the user to update it (`npm i -g okstra@latest`), then stop (`/okstra-setup` does not update the binary).
@@ -475,4 +475,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
475
475
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
476
476
  - Name every file you show the user as a markdown link — `[<what it is>](<path>)`, with the path inside the parentheses. That is the only form the host renders as clickable; a path in backticks is text the user has to copy out. The `report-finalize` result's `reportPaths.markdown` carries the run's report, report record, and team state already in that form. Commands stay in backticks — a link is for a file, not for something to run.
477
477
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
478
- - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed one-line cause then `/okstra-run`; otherwise `/okstra-inspect status`.
478
+ - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When `report-finalize` returns `recovery.mode: same-run`, continue the authorized corrections in this run and execute `recovery.resumeCommand` before closeout; preserve approvals and model choices without reopening the wizard. The command and owner issues are supplied by `report_finalize._finalize_recovery`. When the lead (or this skill, after the lead returns) reports a successfully finalized run over, close with the user's next action — one command they can run now. A prohibition is not a next action. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed with `recovery.mode: phase-reentry` → name the cause and use `nextCommand` for the recorded earlier phase; otherwise `/okstra-inspect status`.
@@ -108,11 +108,14 @@ def verifier_mutation_hits(command_log: str) -> list[tuple[str, str]]:
108
108
  로그는 자유 문자열이라 줄 단위로 본다 — 어느 명령이 문제인지 그대로 보고해야
109
109
  작성자가 고칠 자리를 안다. heredoc 본문은 기록되는 데이터이므로 걷어낸다.
110
110
  """
111
+ from okstra_ctl.qa_commands import find_unfrozen_installs
112
+
111
113
  hits: list[tuple[str, str]] = []
112
114
  for raw in strip_heredoc_bodies(command_log or "").splitlines():
113
115
  line = raw.strip()
114
116
  if not line or line.startswith("#"):
115
117
  continue
118
+ hits.extend((label, line) for label in find_unfrozen_installs(line))
116
119
  for label, pattern in (*_VERIFIER_MUTATION, *_VERIFIER_SOURCE_MUTATION):
117
120
  if pattern.search(line):
118
121
  hits.append((label, line))
@@ -902,11 +902,22 @@ def update_workflow_metadata(
902
902
  if current_phase:
903
903
  phase_states[current_phase] = current_phase_state
904
904
  last_completed_phase = workflow.get("lastCompletedPhase", "")
905
+ recovery = next_phase.project(report_data or {})
906
+ recovery_phase = recovery["phase"]
907
+ can_backtrack = (
908
+ current_phase in PHASE_SEQUENCE
909
+ and recovery_phase in PHASE_SEQUENCE
910
+ and PHASE_SEQUENCE.index(recovery_phase)
911
+ < PHASE_SEQUENCE.index(current_phase)
912
+ )
905
913
  next_recommended_phase = next_phase.make(
914
+ phase=recovery_phase if can_backtrack else "",
906
915
  status=next_phase.STATUS_BLOCKED,
907
916
  rationale=_blocked_rationale(
908
- current_phase,
909
- inherited_pointer["rationale"],
917
+ recovery_phase if can_backtrack else current_phase,
918
+ recovery["rationale"]
919
+ if can_backtrack
920
+ else inherited_pointer["rationale"],
910
921
  failures,
911
922
  run_manifest_path=str(run_manifest.get("runManifestPath") or ""),
912
923
  ),
@@ -2197,6 +2208,12 @@ def _validate_planning_conformance_declared(
2197
2208
  if not isinstance(ip, dict):
2198
2209
  return
2199
2210
  _planning_conformance_declarations(ip.get("stages"), failures)
2211
+ if ip.get("planningContract") != "selected-direction":
2212
+ from okstra_ctl.implementation_direction import (
2213
+ stage_validation_executability_errors,
2214
+ )
2215
+
2216
+ failures.extend(stage_validation_executability_errors(ip))
2200
2217
  for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
2201
2218
  failures.append(
2202
2219
  "conformance gate BLOCKING: stage "
@@ -3688,31 +3705,30 @@ def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
3688
3705
 
3689
3706
 
3690
3707
  def _critic_non_error_verdicts(item: dict) -> list[dict]:
3691
- return [
3692
- row
3693
- for row in (item.get("verdicts") or [])
3694
- if isinstance(row, dict)
3695
- and is_critic_worker(str(row.get("worker") or ""))
3696
- and str(row.get("verdict") or "").strip().upper()
3697
- not in ("", "VERIFICATION-ERROR")
3698
- ]
3708
+ """현재 기록된 비판 검토자의 최신 유효 판정."""
3709
+ rows = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
3710
+ critic = [row for row in rows
3711
+ if is_critic_worker(row.get("worker", ""))
3712
+ and str(row.get("verdict", "")).upper() in {"AGREE", "SUPPLEMENT", "DISAGREE"}]
3713
+ latest = max((row.get("round", 1) for row in critic), default=0)
3714
+ return [row for row in critic if row.get("round", 1) == latest]
3699
3715
 
3700
3716
 
3701
- def _tie_gate_class(item: dict, agree: list, disagree: list) -> str | None:
3702
- """분석자 동수면 critic 가르고, 없으면 재검증. 동수가 아니면 None."""
3703
- if not (len(disagree) == len(agree) and disagree):
3704
- return None
3717
+ def _critic_gate_class(item: dict) -> str | None:
3718
+ """비판 검토자의 교정 권한은 분석자의 표수나 동수 여부에 의존하지 않는다."""
3705
3719
  critic = _critic_non_error_verdicts(item)
3706
3720
  if not critic:
3707
- return "needs-reverify"
3708
- if any(
3709
- str(row.get("verdict") or "").strip().upper() == "DISAGREE"
3710
- and str(row.get("breakageKind") or "").strip().lower()
3711
- not in _ADVISORY_ONLY_KINDS
3712
- for row in critic
3713
- ):
3714
- return "majority-disagree"
3715
- return "has-dissent"
3721
+ return None
3722
+ critic_dissent = [row for row in critic if str(row.get("verdict", "")).upper() == "DISAGREE"]
3723
+ if critic_dissent:
3724
+ if str(item.get("id", "")).upper().startswith("P-RB"):
3725
+ return "has-dissent"
3726
+ return "majority-disagree" if any(
3727
+ str(row.get("breakageKind", "")).lower() not in _ADVISORY_ONLY_KINDS
3728
+ for row in critic_dissent
3729
+ ) else "has-dissent"
3730
+ dissent = any(str(row.get("verdict", "")).upper() == "DISAGREE" for row in item.get("verdicts", []))
3731
+ return "has-dissent" if dissent else "full-consensus"
3716
3732
 
3717
3733
 
3718
3734
  def _classify_plan_item_gate(item: dict) -> str:
@@ -3726,6 +3742,9 @@ def _classify_plan_item_gate(item: dict) -> str:
3726
3742
  reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
3727
3743
  settles it.
3728
3744
  """
3745
+ corrected = _critic_gate_class(item)
3746
+ if corrected is not None:
3747
+ return corrected
3729
3748
  tokens = [
3730
3749
  (
3731
3750
  str(v.get("verdict") or "").strip().upper(),
@@ -3783,9 +3802,8 @@ def _classify_plan_item_gate(item: dict) -> str:
3783
3802
  # made the gate stricter than a healthy roster would.
3784
3803
  if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
3785
3804
  return "majority-disagree"
3786
- settled = _tie_gate_class(item, agree, blocking_disagree)
3787
- if settled is not None and len(non_error) >= 2:
3788
- return settled
3805
+ if len(blocking_disagree) == len(agree) and len(non_error) >= 2:
3806
+ return "needs-reverify"
3789
3807
  if (
3790
3808
  len(non_error) >= 2
3791
3809
  and blocking_disagree
@@ -9363,13 +9381,66 @@ def _data_schema_failures(data: dict) -> list[str]:
9363
9381
  ]
9364
9382
 
9365
9383
 
9384
+ def run_preflight(report_path: Path, run_manifest_path: Path) -> int:
9385
+ """번역·표시·상태 갱신 전에 정본의 구조와 적합성만 검사한다."""
9386
+ try:
9387
+ data = load_json(_data_path_for(report_path))
9388
+ manifest = load_json(run_manifest_path)
9389
+ if not isinstance(data, dict) or not isinstance(manifest, dict):
9390
+ raise TypeError("preflight requires report and run-manifest JSON objects")
9391
+ except (OSError, ValueError, TypeError) as exc:
9392
+ print(json.dumps({"ok": False, "failures": [str(exc)]}))
9393
+ return 2
9394
+ failures = _data_schema_failures(data)
9395
+ task_type = manifest.get("taskType")
9396
+ warnings: list[str] = []
9397
+ if task_type in ("implementation", "final-verification"):
9398
+ project_root = Path(
9399
+ str(manifest.get("projectRoot") or run_manifest_path.parent)
9400
+ )
9401
+ warnings = _validate_conformance(
9402
+ report_path,
9403
+ failures,
9404
+ surface_patterns=_project_surface_patterns(project_root),
9405
+ approved_plan_path=_approved_plan_path_from_run_inputs(
9406
+ run_manifest_path, failures
9407
+ ),
9408
+ )
9409
+ elif task_type == "implementation-planning":
9410
+ from okstra_ctl.report_assembly import selected_direction_plan_errors
9411
+ from okstra_ctl.implementation_direction import (
9412
+ stage_validation_executability_errors,
9413
+ )
9414
+
9415
+ failures.extend(
9416
+ stage_validation_executability_errors(
9417
+ data.get("implementationPlanning") or {}
9418
+ )
9419
+ )
9420
+ failures.extend(selected_direction_plan_errors(
9421
+ data, Path(str(manifest.get("projectRoot") or run_manifest_path.parent)), manifest
9422
+ ))
9423
+ _validate_planning_conformance_declared(report_path, failures)
9424
+ print(
9425
+ json.dumps(
9426
+ {
9427
+ "ok": not failures,
9428
+ "failures": list(dict.fromkeys(failures)),
9429
+ "warnings": warnings,
9430
+ },
9431
+ ensure_ascii=False,
9432
+ )
9433
+ )
9434
+ return 2 if failures else 0
9435
+
9436
+
9366
9437
  def main() -> int:
9367
9438
  parser = argparse.ArgumentParser(
9368
9439
  description="Validate okstra run contract artifacts."
9369
9440
  )
9370
9441
  parser.add_argument(
9371
9442
  "--section",
9372
- choices=(SECTION_FULL, SECTION_PLAN_BODY),
9443
+ choices=(SECTION_FULL, SECTION_PLAN_BODY, "preflight"),
9373
9444
  default=SECTION_FULL,
9374
9445
  help=(
9375
9446
  "Which contract surface to validate. `full` (default) validates the "
@@ -9415,6 +9486,13 @@ def main() -> int:
9415
9486
  )
9416
9487
  args = parser.parse_args()
9417
9488
 
9489
+ if args.section == "preflight":
9490
+ if not args.report or not args.run_manifest:
9491
+ parser.error("--section preflight requires --report and --run-manifest")
9492
+ return run_preflight(
9493
+ Path(args.report).resolve(), Path(args.run_manifest).resolve()
9494
+ )
9495
+
9418
9496
  if args.section == SECTION_PLAN_BODY:
9419
9497
  if args.narrative and args.state and not args.report:
9420
9498
  return run_plan_body_inputs(
@@ -561,6 +561,11 @@ def _progress_line_from_event(event) -> tuple[str, str, str] | None:
561
561
  message = details.get("message")
562
562
  tail = f" {message}" if isinstance(message, str) and message else ""
563
563
  line = f"PROGRESS: {phase}{tail}"
564
+ worker = details.get("worker")
565
+ if isinstance(worker, str) and worker.strip():
566
+ from okstra_ctl.lead_progress import render_progress_line
567
+
568
+ line = render_progress_line(phase, [("worker", worker), ("detail", line)])
564
569
  return (event.timestamp, phase, line)
565
570
 
566
571