okstra 0.135.0 → 0.137.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 (44) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/contributor-change-matrix.md +1 -1
  3. package/docs/project-structure-overview.md +8 -3
  4. package/package.json +2 -3
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra/interactive.sh +24 -67
  7. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  8. package/runtime/bin/okstra-antigravity-exec.sh +10 -8
  9. package/runtime/bin/okstra-claude-exec.sh +10 -8
  10. package/runtime/bin/okstra-codex-exec.sh +10 -8
  11. package/runtime/bin/okstra-spawn-followups.py +4 -9
  12. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  13. package/runtime/python/okstra_ctl/backfill.py +2 -1
  14. package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
  15. package/runtime/python/okstra_ctl/consumers.py +7 -5
  16. package/runtime/python/okstra_ctl/context_cost.py +6 -5
  17. package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
  18. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  19. package/runtime/python/okstra_ctl/error_log_core.py +3 -1
  20. package/runtime/python/okstra_ctl/error_report.py +2 -7
  21. package/runtime/python/okstra_ctl/handoff.py +2 -1
  22. package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
  23. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  24. package/runtime/python/okstra_ctl/path_hints.py +5 -3
  25. package/runtime/python/okstra_ctl/paths.py +278 -4
  26. package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
  27. package/runtime/python/okstra_ctl/recap.py +5 -12
  28. package/runtime/python/okstra_ctl/render.py +9 -15
  29. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  30. package/runtime/python/okstra_ctl/report_language.py +83 -0
  31. package/runtime/python/okstra_ctl/run.py +222 -239
  32. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  33. package/runtime/python/okstra_ctl/stage_targets.py +98 -55
  34. package/runtime/python/okstra_ctl/time_report.py +4 -9
  35. package/runtime/python/okstra_ctl/wizard.py +50 -50
  36. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  37. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  38. package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
  39. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  40. package/runtime/python/okstra_project/__init__.py +4 -0
  41. package/runtime/python/okstra_project/dirs.py +7 -0
  42. package/runtime/python/okstra_project/state.py +78 -8
  43. package/runtime/validators/validate-run.py +23 -52
  44. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -30,6 +30,7 @@ from .dirs import (
30
30
  DISCOVERY_RELATIVE,
31
31
  LATEST_TASK_RELATIVE,
32
32
  TASK_CATALOG_RELATIVE,
33
+ TASK_MANIFEST_FILENAME,
33
34
  TASKS_RELATIVE,
34
35
  )
35
36
 
@@ -82,7 +83,24 @@ def read_latest_task(project_root: Path) -> Optional[dict]:
82
83
 
83
84
  def read_task_manifest(task_root: Path) -> Optional[dict]:
84
85
  """<task-root>/task-manifest.json 을 dict 로. 파일 없으면 None."""
85
- return _load_json(Path(task_root) / "task-manifest.json")
86
+ return _load_json(Path(task_root) / TASK_MANIFEST_FILENAME)
87
+
88
+
89
+ def read_task_key(task_root: Path) -> str:
90
+ """manifest 의 taskKey. 파일이 없거나 깨졌으면 "".
91
+
92
+ 리포트 렌더러(recap·time-report·error-report)는 taskKey 를 헤더 라벨로만 쓰므로
93
+ manifest 가 없어도 실패하지 않아야 한다 — 세 모듈이 각자 갖고 있던 관용 리더를
94
+ 여기로 모은다.
95
+ """
96
+ try:
97
+ manifest = read_task_manifest(Path(task_root))
98
+ except (StateError, OSError):
99
+ return ""
100
+ if not isinstance(manifest, dict):
101
+ return ""
102
+ value = manifest.get("taskKey")
103
+ return value if isinstance(value, str) else ""
86
104
 
87
105
 
88
106
  def _reconcile_task_root_best_effort(project_root: Path, task_root: Path) -> None:
@@ -94,6 +112,31 @@ def _reconcile_task_root_best_effort(project_root: Path, task_root: Path) -> Non
94
112
  return
95
113
 
96
114
 
115
+ _DERIVED_STATUS_STRING_FIELDS = (
116
+ "workStatus",
117
+ "currentStatus",
118
+ "latestRunStatus",
119
+ "latestReportPath",
120
+ )
121
+
122
+
123
+ def derived_task_status(manifest: dict) -> dict:
124
+ """Task status a caller acts on, projected off the raw manifest.
125
+
126
+ Both the task list rows and the read-side snapshot project through here so
127
+ the two views cannot disagree about whether a task is finished.
128
+ """
129
+ out: dict = {}
130
+ for field in _DERIVED_STATUS_STRING_FIELDS:
131
+ value = manifest.get(field)
132
+ if isinstance(value, str):
133
+ out[field] = value
134
+ phase_outcome = manifest.get("phaseOutcome")
135
+ if isinstance(phase_outcome, dict):
136
+ out["phaseOutcome"] = phase_outcome
137
+ return out
138
+
139
+
97
140
  def _entry_with_manifest_state(entry: dict, task_root: Path) -> dict:
98
141
  out = {**entry}
99
142
  manifest = read_task_manifest(task_root) or {}
@@ -107,13 +150,7 @@ def _entry_with_manifest_state(entry: dict, task_root: Path) -> dict:
107
150
  value = workflow.get(source)
108
151
  if isinstance(value, str):
109
152
  out[target] = value
110
- for field in ("currentStatus", "latestRunStatus", "latestReportPath"):
111
- value = manifest.get(field)
112
- if isinstance(value, str):
113
- out[field] = value
114
- phase_outcome = manifest.get("phaseOutcome")
115
- if isinstance(phase_outcome, dict):
116
- out["phaseOutcome"] = phase_outcome
153
+ out.update(derived_task_status(manifest))
117
154
  return out
118
155
 
119
156
 
@@ -329,8 +366,41 @@ def task_read_side_snapshot(project_root: Path, task_key: str) -> dict:
329
366
  "routingStatus": workflow.get("routingStatus"),
330
367
  "phaseStates": workflow.get("phaseStates"),
331
368
  },
369
+ "status": derived_task_status(manifest),
332
370
  "resultContract": manifest.get("resultContract"),
333
371
  "artifacts": manifest.get("artifacts"),
334
372
  "modelAssignments": manifest.get("modelAssignments"),
335
373
  "latestRunPath": manifest.get("latestRunPath"),
336
374
  }
375
+
376
+
377
+ def stage_map_read_side_snapshot(project_root: Path, task_key: str) -> dict:
378
+ """stage-map inspect 용 read-side view: Stage Map + done 처리된 stage 번호.
379
+
380
+ Stage Map 파싱과 planning run-root 위치를 caller 에게 노출하지 않는다 —
381
+ Node inspect 가 okstra_ctl 의 private 심볼을 import 하거나 `runs/` 경로를
382
+ 직접 조립하지 않도록 하는 어댑터다.
383
+ """
384
+ from okstra_ctl.consumers import read_stage_consumer_state
385
+ from okstra_ctl.implementation_outcome import load_stage_map
386
+ from okstra_ctl.paths import RunRef
387
+
388
+ identity = resolve_task_identity(project_root, task_key)
389
+ task_root = Path(identity["taskRoot"])
390
+ stages = load_stage_map(task_root, identity["manifest"])
391
+ plan_run_root = RunRef.from_task_root(
392
+ task_root, "implementation-planning"
393
+ ).run_dir
394
+ done: list[int] = []
395
+ if plan_run_root.is_dir():
396
+ done = sorted(
397
+ read_stage_consumer_state(
398
+ plan_run_root, recover_from_carry=True
399
+ ).done_stages
400
+ )
401
+ return {
402
+ "taskKey": identity["taskKey"],
403
+ "taskRoot": identity["taskRoot"],
404
+ "stages": stages,
405
+ "doneStages": done,
406
+ }
@@ -43,6 +43,8 @@ from okstra_ctl.conformance import ( # noqa: E402
43
43
  qa_result_from_dict,
44
44
  validate_conformance_manifest,
45
45
  )
46
+ from okstra_ctl.paths import RunRef # noqa: E402
47
+ from okstra_ctl.workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE # noqa: E402
46
48
  from okstra_ctl.md_table import ( # noqa: E402
47
49
  is_separator_row as _is_markdown_separator,
48
50
  split_pipe_row as _split_pipe_row,
@@ -157,19 +159,7 @@ def write_json(path: Path, payload: dict) -> None:
157
159
 
158
160
 
159
161
  def default_next_phase(task_type: str) -> str:
160
- mapping = {
161
- "requirements-discovery": "pending-routing-decision",
162
- "improvement-discovery": "pending-routing-decision",
163
- "error-analysis": "implementation-planning",
164
- "implementation-planning": "implementation",
165
- "implementation": "final-verification",
166
- # final-verification 의 다음 phase 는 verdict 에 따라 갈리므로
167
- # 정적 매핑은 `pending-release-handoff` 로 두고, 실제 진입은
168
- # release-handoff profile 의 entry gate (`accepted` 확인) 에서 강제한다.
169
- "final-verification": "pending-release-handoff",
170
- "release-handoff": "done-or-follow-up",
171
- }
172
- return mapping.get(task_type, "unknown")
162
+ return DEFAULT_NEXT_PHASE.get(task_type, "unknown")
173
163
 
174
164
 
175
165
  def advance_next_phase(
@@ -208,14 +198,7 @@ def update_workflow_metadata(
208
198
  )
209
199
  phase_sequence = workflow.get("phaseSequence", [])
210
200
  if not isinstance(phase_sequence, list) or not phase_sequence:
211
- phase_sequence = [
212
- "requirements-discovery",
213
- "error-analysis",
214
- "implementation-planning",
215
- "implementation",
216
- "final-verification",
217
- "release-handoff",
218
- ]
201
+ phase_sequence = list(PHASE_SEQUENCE)
219
202
 
220
203
  phase_states = workflow.get("phaseStates", {})
221
204
  if not isinstance(phase_states, dict):
@@ -1143,17 +1126,15 @@ def _scope_manifest_entries(manifest: dict, stage_name: str | None) -> dict:
1143
1126
 
1144
1127
 
1145
1128
  def _task_root_from_run_dir(run_dir: Path) -> Path:
1146
- """run_dir 에서 `runs` 디렉터리를 앵커로 task_root 를 복원한다.
1129
+ """run_dir 에서 task_root 를 복원한다.
1147
1130
 
1148
- 일반 task-type: run_dir = task_root/runs/<task-type> task_root.
1149
- implementation(stage 격리): run_dir = task_root/runs/implementation/stage-<N>
1150
- → 같은 task_root. `runs` 를 위로 탐색하므로 stage-<N> 레벨이 추가돼도
1151
- 안전하다. `runs` 가 조상에 없으면 기존 동작(두 단계 위)로 폴백한다.
1131
+ 레이아웃 해석은 `RunRef.from_run_dir` 소유한다. run 디렉터리가 아닌
1132
+ 입력에는 기존 동작( 단계 위)으로 폴백해 검증기가 죽지 않게 한다.
1152
1133
  """
1153
- for parent in run_dir.parents:
1154
- if parent.name == "runs":
1155
- return parent.parent
1156
- return run_dir.parent.parent
1134
+ try:
1135
+ return RunRef.from_run_dir(run_dir).task_root
1136
+ except ValueError:
1137
+ return run_dir.parent.parent
1157
1138
 
1158
1139
 
1159
1140
  def _read_run_inputs_payload(
@@ -3129,19 +3110,15 @@ _PASSING_VERDICT_TOKENS = frozenset({"accepted", "conditional-accept"})
3129
3110
  def _consumers_rows(report_path: Path) -> list[dict] | None:
3130
3111
  """`runs/implementation-planning/consumers.jsonl` rows for this task.
3131
3112
 
3132
- The final-verification report sits at `runs/final-verification/[stage-N/]
3133
- reports/...`, so walk up to the `runs/` directory rather than guessing a
3134
- fixed depth. ``None`` when the file is absent, so the caller does not turn
3135
- a missing artifact into a false accusation.
3113
+ ``None`` when the report path is unreadable as a run reference or the file
3114
+ is absent, so the caller does not turn a missing artifact into a false
3115
+ accusation.
3136
3116
  """
3137
- runs_dir = None
3138
- for parent in report_path.parents:
3139
- if parent.name == "runs":
3140
- runs_dir = parent
3141
- break
3142
- if runs_dir is None:
3117
+ try:
3118
+ ref = RunRef.from_report_path(report_path)
3119
+ except ValueError:
3143
3120
  return None
3144
- path = runs_dir / "implementation-planning" / "consumers.jsonl"
3121
+ path = ref.sibling("implementation-planning").run_dir / "consumers.jsonl"
3145
3122
  if not path.is_file():
3146
3123
  return None
3147
3124
  rows = []
@@ -3497,17 +3474,11 @@ def _validate_stage_carry_sidecar_exists(
3497
3474
  stage = evidence.get("stageNumber")
3498
3475
  if not isinstance(stage, int):
3499
3476
  return
3500
- # Carry sidecars are stage-SHARED: they live flat at
3501
- # `runs/implementation/carry/stage-<N>.json`, NOT under the stage-<N>/ run
3502
- # dir, because the next stage's carry-in and `consumers.backfill_done_from_carry`
3503
- # glob them without knowing the producing run's layout (storage-model.md
3504
- # §"representative files"). `report_path` is
3505
- # `runs/implementation/stage-<N>/reports/final-report-...md`, so drop the
3506
- # stage-<N>/ segment to reach the flat carry dir that `consumers` reads —
3507
- # resolving it under the stage run dir forced the lead to write the file twice.
3508
- run_dir = report_path.parent.parent
3509
- carry_root = run_dir.parent if _stage_isolated_name(run_dir) else run_dir
3510
- carry_path = carry_root / "carry" / f"stage-{stage}.json"
3477
+ # Carry sidecars are stage-SHARED: the next stage's carry-in and
3478
+ # `consumers.backfill_done_from_carry` glob them without knowing the
3479
+ # producing run's layout. `RunRef.carry()` owns that flat-vs-staged rule;
3480
+ # resolving it under the stage run dir forced the lead to write it twice.
3481
+ carry_path = RunRef.from_report_path(report_path).carry(stage)
3511
3482
  if not carry_path.exists():
3512
3483
  failures.append(
3513
3484
  f"implementation run declares stage-{stage} sidecar evidence but "
@@ -40,9 +40,9 @@ function parseArgs(args) {
40
40
  const SCRIPT = `
41
41
  import json, sys
42
42
  from pathlib import Path
43
- from okstra_project import resolve_project_root, find_task_root, read_task_manifest, ResolverError
44
- from okstra_ctl.implementation_outcome import _load_stage_map
45
- from okstra_ctl.consumers import read_stage_consumer_state
43
+ from okstra_project import (
44
+ resolve_project_root, stage_map_read_side_snapshot, ResolverError, StateError,
45
+ )
46
46
 
47
47
  explicit, cwd, task_key = sys.argv[1], sys.argv[2], sys.argv[3]
48
48
 
@@ -52,24 +52,17 @@ except ResolverError as e:
52
52
  print(json.dumps({"ok": False, "stage": "resolve", "reason": str(e)}))
53
53
  sys.exit(2)
54
54
 
55
- task_root = find_task_root(Path(pr), task_key)
56
- if task_root is None:
57
- print(json.dumps({"ok": False, "stage": "task_root", "reason": f"task root not found for {task_key}"}))
55
+ try:
56
+ snapshot = stage_map_read_side_snapshot(Path(pr), task_key)
57
+ except StateError as e:
58
+ print(json.dumps({
59
+ "ok": False,
60
+ "stage": getattr(e, "stage", None) or "stage_map",
61
+ "reason": str(e),
62
+ }))
58
63
  sys.exit(1)
59
64
 
60
- manifest = read_task_manifest(task_root) or {}
61
- stages = _load_stage_map(task_root, manifest)
62
-
63
- plan_run_root = task_root / "runs" / "implementation-planning"
64
- done = sorted(read_stage_consumer_state(plan_run_root, recover_from_carry=True).done_stages) if plan_run_root.is_dir() else []
65
-
66
- print(json.dumps({
67
- "ok": True,
68
- "taskKey": task_key,
69
- "taskRoot": str(task_root),
70
- "stages": stages,
71
- "doneStages": done,
72
- }, ensure_ascii=False, indent=2))
65
+ print(json.dumps({"ok": True, **snapshot}, ensure_ascii=False, indent=2))
73
66
  `;
74
67
 
75
68
  export async function run(args) {