okstra 0.136.0 → 0.138.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 (50) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +3 -2
  3. package/docs/contributor-change-matrix.md +1 -1
  4. package/docs/project-structure-overview.md +32 -5
  5. package/package.json +2 -3
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  10. package/runtime/bin/okstra-spawn-followups.py +4 -9
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  15. package/runtime/prompts/lead/report-writer.md +11 -10
  16. package/runtime/prompts/lead/team-contract.md +4 -2
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
  21. package/runtime/python/okstra_ctl/consumers.py +7 -5
  22. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  23. package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
  24. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  25. package/runtime/python/okstra_ctl/error_report.py +2 -7
  26. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  27. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
  29. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  30. package/runtime/python/okstra_ctl/paths.py +17 -2
  31. package/runtime/python/okstra_ctl/recap.py +5 -12
  32. package/runtime/python/okstra_ctl/render.py +18 -19
  33. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  34. package/runtime/python/okstra_ctl/report_language.py +83 -0
  35. package/runtime/python/okstra_ctl/run.py +262 -328
  36. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  37. package/runtime/python/okstra_ctl/stage_targets.py +330 -101
  38. package/runtime/python/okstra_ctl/time_report.py +4 -9
  39. package/runtime/python/okstra_ctl/wizard.py +47 -49
  40. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
  43. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  44. package/runtime/python/okstra_ctl/worktree.py +37 -29
  45. package/runtime/python/okstra_project/__init__.py +4 -0
  46. package/runtime/python/okstra_project/dirs.py +7 -0
  47. package/runtime/python/okstra_project/state.py +78 -8
  48. package/runtime/validators/lib/fixtures.sh +2 -0
  49. package/runtime/validators/validate-run.py +3 -21
  50. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -12,7 +12,7 @@ from okstra_project.dirs import (
12
12
  okstra_home,
13
13
  )
14
14
 
15
- from .paths import runs_dir_of
15
+ from .paths import runs_dir_of, task_manifest_file, task_timeline_file
16
16
 
17
17
  RUN_CONTEXT_KIND = "run-context"
18
18
  RUN_CONTEXT_SCHEMA_VERSION = "2.0"
@@ -356,14 +356,14 @@ def _task_path_set(task_root: Path) -> dict[str, Path]:
356
356
  history_dir = task_root / "history"
357
357
  recap_dir = task_root / "recap"
358
358
  return {
359
- "task_manifest": task_root / "task-manifest.json",
359
+ "task_manifest": task_manifest_file(task_root),
360
360
  "task_index": task_root / "task-index.md",
361
361
  "instruction_set": instruction_set,
362
362
  "analysis_packet": instruction_set / "analysis-packet.md",
363
363
  "task_qa": task_root / "qa",
364
364
  "runs_dir": runs_dir_of(task_root),
365
365
  "history_dir": history_dir,
366
- "timeline_file": history_dir / "timeline.json",
366
+ "timeline_file": task_timeline_file(task_root),
367
367
  "recap_dir": recap_dir,
368
368
  "recap_log": recap_dir / "recap-log.jsonl",
369
369
  "final_report_template": instruction_set / "final-report-template.md",
@@ -21,6 +21,7 @@ from typing import Optional
21
21
 
22
22
  from okstra_project.dirs import (
23
23
  DISCOVERY_RELATIVE,
24
+ TASK_MANIFEST_FILENAME,
24
25
  OKSTRA_RELATIVE,
25
26
  TASKS_RELATIVE,
26
27
  okstra_home,
@@ -313,6 +314,20 @@ def runs_dir_of(task_root: Path) -> Path:
313
314
  return Path(task_root) / "runs"
314
315
 
315
316
 
317
+ def task_manifest_file(task_root: Path) -> Path:
318
+ """task 의 lifecycle manifest 경로.
319
+
320
+ task-type 을 모르는 호출자(recap·time-report·error-report·work-categories)가
321
+ 쓰는 진입점이다. `RunRef.manifest` 는 한 *run* 의 run-manifest 이고, `report_finalize.task_manifest_path`
322
+ 는 run manifest 의 fallback 체인을 타는 해소기다 — 셋 다 다른 것이므로 이름을 나눈다."""
323
+ return Path(task_root) / TASK_MANIFEST_FILENAME
324
+
325
+
326
+ def task_timeline_file(task_root: Path) -> Path:
327
+ """task 의 history timeline 경로."""
328
+ return Path(task_root) / "history" / "timeline.json"
329
+
330
+
316
331
  def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
317
332
  """container 산출물 경로 묶음(순수 계산, mkdir 금지).
318
333
 
@@ -415,14 +430,14 @@ def compute_run_paths(
415
430
  discovery_dir = project_root / DISCOVERY_RELATIVE
416
431
 
417
432
  task_root = tasks_root / task_group_segment / task_id_segment
418
- task_manifest = task_root / "task-manifest.json"
433
+ task_manifest = task_manifest_file(task_root)
419
434
  task_index = task_root / "task-index.md"
420
435
  instruction_set = task_root / "instruction-set"
421
436
  analysis_packet = instruction_set / "analysis-packet.md"
422
437
  task_qa = task_root / "qa"
423
438
  runs_dir = runs_dir_of(task_root)
424
439
  history_dir = task_root / "history"
425
- timeline_file = history_dir / "timeline.json"
440
+ timeline_file = task_timeline_file(task_root)
426
441
  recap_dir = task_root / "recap"
427
442
  recap_log = recap_dir / "recap-log.jsonl"
428
443
 
@@ -12,6 +12,8 @@ import sys
12
12
  from pathlib import Path
13
13
 
14
14
  from okstra_ctl.ids import slugify_task_segment
15
+ from okstra_ctl.paths import task_timeline_file
16
+ from okstra_project import read_task_key
15
17
  from okstra_ctl.run_context import dir_flock
16
18
  from okstra_ctl.task_target import resolve_task_root, project_rel
17
19
 
@@ -19,7 +21,7 @@ NOTE_KINDS = ("verification-evidence", "decision-draft", "analysis-note")
19
21
 
20
22
 
21
23
  def _load_timeline(task_root: Path) -> list[dict]:
22
- path = task_root / "history" / "timeline.json"
24
+ path = task_timeline_file(task_root)
23
25
  if not path.is_file():
24
26
  return []
25
27
  try:
@@ -30,15 +32,6 @@ def _load_timeline(task_root: Path) -> list[dict]:
30
32
  return runs if isinstance(runs, list) else []
31
33
 
32
34
 
33
- def _task_key(task_root: Path) -> str:
34
- path = task_root / "task-manifest.json"
35
- if not path.is_file():
36
- return ""
37
- try:
38
- return json.loads(path.read_text(encoding="utf-8")).get("taskKey", "")
39
- except Exception:
40
- return ""
41
-
42
35
 
43
36
  def assemble_recap(task_root: Path, project_root: Path) -> dict:
44
37
  runs = _load_timeline(task_root)
@@ -65,7 +58,7 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
65
58
  # 최신 상태인 양 보고하게 된다.
66
59
  latest_states = snap.get("phaseStates", {}) or {}
67
60
  return {
68
- "taskKey": _task_key(task_root),
61
+ "taskKey": read_task_key(task_root),
69
62
  "runCount": len(runs),
70
63
  "transitions": transitions,
71
64
  "latestPhaseStates": latest_states,
@@ -128,7 +121,7 @@ def write_note(task_root, project_root, *, kind, slug, purpose, scope_note,
128
121
  if not slug_seg:
129
122
  raise ValueError("slug must contain at least one alphanumeric character")
130
123
  path = _note_path(notes_dir, slug_seg, created_at)
131
- task_key = _task_key(task_root)
124
+ task_key = read_task_key(task_root)
132
125
  frontmatter = _note_frontmatter(
133
126
  task_key=task_key, kind=kind, created_at=created_at,
134
127
  purpose=purpose, scope_note=scope_note)
@@ -23,7 +23,7 @@ import re
23
23
  import sys
24
24
  from pathlib import Path
25
25
 
26
- from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
26
+ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project_json_path
27
27
 
28
28
  # phase 시퀀스 / 기본 next-phase 매핑의 SSOT 는 workflow 모듈이다. 과거
29
29
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
@@ -32,6 +32,7 @@ from . import fix_cycles
32
32
  from .paths import okstra_home
33
33
  from .lead_runtime import lead_runtime_info
34
34
  from .path_hints import compact_active_run_context, hydrate_run_context
35
+ from .paths import task_timeline_file
35
36
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
36
37
 
37
38
 
@@ -589,7 +590,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
589
590
  return str(p)
590
591
 
591
592
  entries = []
592
- for manifest_path in sorted(tasks_root.rglob("task-manifest.json")):
593
+ for manifest_path in sorted(tasks_root.rglob(TASK_MANIFEST_FILENAME)):
593
594
  try:
594
595
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
595
596
  except Exception:
@@ -601,15 +602,15 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
601
602
  continue
602
603
  task_root = manifest_path.parent
603
604
  timeline_relative = s(manifest, "historyTimelinePath").strip()
604
- timeline_path = (
605
+ timeline_file = (
605
606
  (project_root / timeline_relative)
606
607
  if timeline_relative
607
- else (task_root / "history" / "timeline.json")
608
+ else task_timeline_file(task_root)
608
609
  )
609
610
  latest_run = {}
610
- if timeline_path.is_file():
611
+ if timeline_file.is_file():
611
612
  try:
612
- payload = json.loads(timeline_path.read_text(encoding="utf-8"))
613
+ payload = json.loads(timeline_file.read_text(encoding="utf-8"))
613
614
  except Exception:
614
615
  payload = {}
615
616
  runs = payload.get("runs", []) if isinstance(payload, dict) else []
@@ -675,7 +676,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
675
676
  or s(latest_run, "reportPath"),
676
677
  "latestResumeCommandPath": s(manifest, "latestResumeCommandPath")
677
678
  or s(latest_run, "resumeCommandPath"),
678
- "historyTimelinePath": timeline_relative or rel(timeline_path),
679
+ "historyTimelinePath": timeline_relative or rel(timeline_file),
679
680
  "fixCycles": manifest.get("fixCycles")
680
681
  or {"count": 0, "openCycleId": None, "latest": None},
681
682
  }
@@ -1525,14 +1526,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1525
1526
  )
1526
1527
  phase_order = workflow.get("phaseSequence", [])
1527
1528
  if not isinstance(phase_order, list) or not phase_order:
1528
- phase_order = [
1529
- "requirements-discovery",
1530
- "error-analysis",
1531
- "implementation-planning",
1532
- "implementation",
1533
- "final-verification",
1534
- "release-handoff",
1535
- ]
1529
+ phase_order = list(PHASE_SEQUENCE)
1536
1530
  phase_state_lines = [
1537
1531
  f"- `{phase}`: `{phase_states.get(phase, 'not-started')}`"
1538
1532
  for phase in phase_order
@@ -1954,10 +1948,15 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1954
1948
  "- Worker prompt paths in `task-manifest.json` are assigned prompt-history "
1955
1949
  "locations, not pre-rendered completion markers. An absent file means its "
1956
1950
  "dispatch history has not been materialized yet.\n"
1957
- "- Construct each required worker prompt and follow the lifecycle core plus "
1958
- "selected adapter to persist its assigned history through `write_artifact` "
1959
- "and start it through `dispatch_worker`. Existing-history handling must stay "
1960
- "idempotent under those contracts.\n"
1951
+ "- Code-backed `dispatch_worker` mappings leave missing roster prompts to "
1952
+ "`okstra_ctl.initial_prompt_materialization` and its canonical "
1953
+ "`materialize_initial_prompts()` entry point. The selected adapter supplies "
1954
+ "its declared delivery mode; do not construct or overwrite those prompts "
1955
+ "manually.\n"
1956
+ "- For native in-process dispatch, use the same canonical initial-prompt headers, "
1957
+ "persist the assigned prompt history through `write_artifact`, and apply "
1958
+ "the adapter-declared `lazy-path-reference` delivery mode before starting the "
1959
+ "worker. This native path does not apply to reverify or critic dispatches.\n"
1961
1960
  "- Use `await_workers` through the selected adapter and confirm terminal state "
1962
1961
  "plus every required Result Path. File presence alone is never a signal to "
1963
1962
  "skip; worker selection and skipped status come from `team-state`.\n"
@@ -21,7 +21,7 @@ from pathlib import Path
21
21
  from typing import Any, Callable, Mapping, Sequence
22
22
 
23
23
  from .final_report_paths import final_report_data_path, final_report_markdown_path
24
- from .paths import task_dir
24
+ from .paths import task_dir, task_manifest_file
25
25
 
26
26
 
27
27
  STEP_TOKEN_USAGE = "token-usage"
@@ -108,15 +108,19 @@ def run_seq(manifest: Mapping[str, Any]) -> str:
108
108
 
109
109
 
110
110
  def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
111
+ """Resolve a run manifest's task manifest, falling back through the identity.
112
+
113
+ Distinct from `paths.task_manifest_file`, which only names the file inside a
114
+ known task root — this one decides *which* task root applies.
115
+ """
111
116
  value = string_value(manifest.get("taskManifestPath"))
112
117
  if value:
113
118
  return resolve_project_path(project_root, value)
114
119
  task_root = string_value(manifest.get("taskRootPath"))
115
120
  if task_root:
116
- return resolve_project_path(project_root, task_root) / "task-manifest.json"
117
- return (
121
+ return task_manifest_file(resolve_project_path(project_root, task_root))
122
+ return task_manifest_file(
118
123
  task_dir(project_root, task_group(manifest), task_id(manifest))
119
- / "task-manifest.json"
120
124
  )
121
125
 
122
126
 
@@ -0,0 +1,83 @@
1
+ """Report-language resolution for the report-writer prompt.
2
+
3
+ Both dispatchers stamp `**Report Language:**` into the report-writer prompt, so
4
+ the precedence (project config → global config → inferred from the task brief)
5
+ lives here rather than in whichever dispatcher happened to need it first.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, Mapping
13
+
14
+ from .worker_prompt_body import instruction_path
15
+
16
+ REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
17
+
18
+ # Below this many Hangul syllables a brief reads as incidental Korean in an
19
+ # otherwise English document.
20
+ _HANGUL_THRESHOLD = 10
21
+ _BRIEF_SCAN_CHARS = 16000
22
+
23
+
24
+ class ReportLanguageError(ValueError):
25
+ """A config file declares a reportLanguage outside the allowed values."""
26
+
27
+
28
+ def resolve_report_language(
29
+ project_root: Path,
30
+ manifest: Mapping[str, Any],
31
+ active_context: Mapping[str, Any],
32
+ ) -> str:
33
+ for value in _report_language_candidates(project_root):
34
+ if value == "auto":
35
+ return _infer_report_language(project_root, manifest, active_context)
36
+ if value:
37
+ return value
38
+ return _infer_report_language(project_root, manifest, active_context)
39
+
40
+
41
+ def _report_language_candidates(project_root: Path) -> list[str]:
42
+ okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
43
+ return [
44
+ _read_report_language(project_root / ".okstra" / "project.json", "project"),
45
+ _read_report_language(okstra_home / "config.json", "global"),
46
+ ]
47
+
48
+
49
+ def _read_report_language(path: Path, label: str) -> str:
50
+ if not path.is_file():
51
+ return ""
52
+ try:
53
+ payload = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError) as exc:
55
+ raise ReportLanguageError(f"{label} config is unreadable: {path}") from exc
56
+ if not isinstance(payload, Mapping):
57
+ raise ReportLanguageError(f"{label} config must be a JSON object: {path}")
58
+ value = payload.get("reportLanguage")
59
+ if value in (None, ""):
60
+ return ""
61
+ if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
62
+ return value
63
+ raise ReportLanguageError(
64
+ f"{label} config reportLanguage must be en, ko, or auto: {path}"
65
+ )
66
+
67
+
68
+ def _infer_report_language(
69
+ project_root: Path,
70
+ manifest: Mapping[str, Any],
71
+ active_context: Mapping[str, Any],
72
+ ) -> str:
73
+ task_brief = instruction_path(manifest, active_context, "taskBriefPath")
74
+ if not task_brief:
75
+ return "en"
76
+ path = Path(task_brief)
77
+ if not path.is_absolute():
78
+ path = project_root / path
79
+ if not path.is_file():
80
+ return "en"
81
+ text = path.read_text(encoding="utf-8", errors="replace")[:_BRIEF_SCAN_CHARS]
82
+ hangul = sum(1 for char in text if "가" <= char <= "힣")
83
+ return "ko" if hangul >= _HANGUL_THRESHOLD else "en"