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.
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +3 -2
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +32 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/adapters/codex.md +1 -0
- package/runtime/prompts/lead/adapters/external.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +4 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +4 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
- package/runtime/python/okstra_ctl/path_hints.py +3 -3
- package/runtime/python/okstra_ctl/paths.py +17 -2
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +18 -19
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +262 -328
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +330 -101
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +47 -49
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_ctl/worktree.py +37 -29
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/lib/fixtures.sh +2 -0
- package/runtime/validators/validate-run.py +3 -21
- 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
|
|
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":
|
|
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
|
|
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 =
|
|
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
|
|
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":
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
605
|
+
timeline_file = (
|
|
605
606
|
(project_root / timeline_relative)
|
|
606
607
|
if timeline_relative
|
|
607
|
-
else (task_root
|
|
608
|
+
else task_timeline_file(task_root)
|
|
608
609
|
)
|
|
609
610
|
latest_run = {}
|
|
610
|
-
if
|
|
611
|
+
if timeline_file.is_file():
|
|
611
612
|
try:
|
|
612
|
-
payload = json.loads(
|
|
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(
|
|
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
|
-
"-
|
|
1958
|
-
"
|
|
1959
|
-
"
|
|
1960
|
-
"
|
|
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)
|
|
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"
|