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
|
@@ -81,8 +81,8 @@ from okstra_ctl.worktree import (
|
|
|
81
81
|
compute_worktree_path,
|
|
82
82
|
is_git_work_tree,
|
|
83
83
|
main_worktree_path,
|
|
84
|
-
preview_stage_worktree_decision,
|
|
85
84
|
preview_worktree_decision,
|
|
85
|
+
resolve_stage_worktree_decision,
|
|
86
86
|
)
|
|
87
87
|
from okstra_ctl.paths import RunRef, task_dir, task_runs_dir
|
|
88
88
|
from okstra_ctl.work_categories import resolve_work_category
|
|
@@ -972,14 +972,7 @@ def _fix_cycle_confirm_required(state: WizardState) -> bool:
|
|
|
972
972
|
return False
|
|
973
973
|
task_root = task_dir(Path(state.project_root),
|
|
974
974
|
state.task_group, state.task_id)
|
|
975
|
-
|
|
976
|
-
if not manifest.is_file():
|
|
977
|
-
return False
|
|
978
|
-
try:
|
|
979
|
-
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
980
|
-
except (OSError, json.JSONDecodeError):
|
|
981
|
-
return False
|
|
982
|
-
workflow = data.get("workflow") or {}
|
|
975
|
+
workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
|
|
983
976
|
if workflow.get("lastCompletedPhase") != "release-handoff":
|
|
984
977
|
return False
|
|
985
978
|
return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
|
|
@@ -1009,33 +1002,29 @@ def _parse_stage_objects(state: WizardState) -> list:
|
|
|
1009
1002
|
return stages
|
|
1010
1003
|
|
|
1011
1004
|
|
|
1012
|
-
def
|
|
1013
|
-
|
|
1014
|
-
|
|
1005
|
+
def _stage_lifecycle_snapshot(
|
|
1006
|
+
state: WizardState, stages: list, *, reserved_stages: Optional[set] = None,
|
|
1007
|
+
):
|
|
1008
|
+
"""picker 가 보는 Stage Lifecycle Snapshot. 순수 읽기 — carry backfill 금지.
|
|
1015
1009
|
|
|
1016
1010
|
backfill_done_from_carry 는 consumers.jsonl 에 done 행을 append 하고
|
|
1017
|
-
그 부수효과로 worktree-registry stage 점유를 해제한다. 이
|
|
1011
|
+
그 부수효과로 worktree-registry stage 점유를 해제한다. 이 스냅샷은 stage
|
|
1018
1012
|
picker 표시와 progress 추정(_remaining_screens 가 매 step 반복 호출)에서만
|
|
1019
1013
|
쓰이므로, 화면을 그리는 것만으로 동시 실행 중인 run 의 점유를 풀면 안 된다.
|
|
1020
|
-
carry 기반 done 보정은 prepare 게이트(run.py)가 단일 진입점으로 수행한다.
|
|
1021
|
-
if not state.approved_plan_path:
|
|
1022
|
-
return set()
|
|
1023
|
-
from .consumers import read_consumers, latest_done_by_stage
|
|
1024
|
-
plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
|
|
1025
|
-
rows = read_consumers(plan_run_root)
|
|
1026
|
-
return set(latest_done_by_stage(rows).keys())
|
|
1014
|
+
carry 기반 done 보정은 prepare 게이트(run.py)가 단일 진입점으로 수행한다.
|
|
1027
1015
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
"""
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1016
|
+
One snapshot per call site: the two ledger reads this replaced ran once per
|
|
1017
|
+
helper, and `_build_stage_pick` sits inside `_remaining_screens`' simulation.
|
|
1018
|
+
"""
|
|
1019
|
+
from .stage_targets import read_stage_lifecycle_snapshot
|
|
1020
|
+
return read_stage_lifecycle_snapshot(
|
|
1021
|
+
[{"stage_number": s.stage_number,
|
|
1022
|
+
"depends_on": list(s.depends_on),
|
|
1023
|
+
"step_count": s.step_count} for s in stages],
|
|
1024
|
+
Path(state.approved_plan_path).resolve().parents[1],
|
|
1025
|
+
recover_from_carry=False,
|
|
1026
|
+
reserved_stages=reserved_stages,
|
|
1027
|
+
)
|
|
1039
1028
|
|
|
1040
1029
|
|
|
1041
1030
|
def _reserved_stage_numbers(state: WizardState) -> set:
|
|
@@ -1068,7 +1057,7 @@ def _whole_task_allowed(
|
|
|
1068
1057
|
if not stages:
|
|
1069
1058
|
return False
|
|
1070
1059
|
if done is None:
|
|
1071
|
-
done =
|
|
1060
|
+
done = _stage_lifecycle_snapshot(state, stages).done_stages
|
|
1072
1061
|
return all(s.stage_number in done for s in stages)
|
|
1073
1062
|
|
|
1074
1063
|
|
|
@@ -2192,9 +2181,11 @@ def _build_stage_pick(state: WizardState) -> Prompt:
|
|
|
2192
2181
|
is_fv = state.task_type == "final-verification"
|
|
2193
2182
|
is_impl = state.task_type == "implementation"
|
|
2194
2183
|
label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2184
|
+
snapshot = _stage_lifecycle_snapshot(
|
|
2185
|
+
state, stages,
|
|
2186
|
+
reserved_stages=_reserved_stage_numbers(state) if is_impl else None,
|
|
2187
|
+
)
|
|
2188
|
+
done = snapshot.done_stages
|
|
2198
2189
|
options = []
|
|
2199
2190
|
if _whole_task_allowed(state, stages=stages, done=done):
|
|
2200
2191
|
options.append(_opt(WHOLE_TASK_STAGE, t["options"]["whole_task"]))
|
|
@@ -2208,7 +2199,8 @@ def _build_stage_pick(state: WizardState) -> Prompt:
|
|
|
2208
2199
|
if s.stage_number in done
|
|
2209
2200
|
else t["options"]["undone_mark"])
|
|
2210
2201
|
elif is_impl:
|
|
2211
|
-
suffix = " " + _impl_stage_marker(
|
|
2202
|
+
suffix = " " + _impl_stage_marker(
|
|
2203
|
+
t, snapshot.lifecycle_for(s.stage_number))
|
|
2212
2204
|
options.append(_opt(
|
|
2213
2205
|
str(s.stage_number),
|
|
2214
2206
|
f"{s.stage_number}: {s.title} "
|
|
@@ -2222,15 +2214,19 @@ def _build_stage_pick(state: WizardState) -> Prompt:
|
|
|
2222
2214
|
)
|
|
2223
2215
|
|
|
2224
2216
|
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2217
|
+
# Presentation keys are mapped explicitly rather than interpolated from the
|
|
2218
|
+
# status, so renaming a Stage Lifecycle status is a compile-time-visible edit
|
|
2219
|
+
# here instead of a KeyError raised while drawing the picker.
|
|
2220
|
+
_STAGE_MARKER_KEYS = {
|
|
2221
|
+
"done": "mark_done",
|
|
2222
|
+
"active": "mark_active",
|
|
2223
|
+
"ready": "mark_ready",
|
|
2224
|
+
"blocked": "mark_blocked",
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
def _impl_stage_marker(t, lifecycle) -> str:
|
|
2229
|
+
return t["options"][_STAGE_MARKER_KEYS[lifecycle.status]]
|
|
2234
2230
|
|
|
2235
2231
|
|
|
2236
2232
|
def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
|
|
@@ -2264,9 +2260,11 @@ def _submit_impl_stage_pick(state: WizardState, answer: str) -> Optional[str]:
|
|
|
2264
2260
|
raise WizardError(t["errors"]["whole_task_impl"])
|
|
2265
2261
|
stages = _parse_stage_objects(state)
|
|
2266
2262
|
all_nums = {s.stage_number for s in stages}
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2263
|
+
snapshot = _stage_lifecycle_snapshot(
|
|
2264
|
+
state, stages, reserved_stages=_reserved_stage_numbers(state))
|
|
2265
|
+
done = snapshot.done_stages
|
|
2266
|
+
occupied = {lc.stage for lc in snapshot.lifecycles
|
|
2267
|
+
if lc.status in ("done", "active")}
|
|
2270
2268
|
chosen = _impl_chosen_stages(t, picks, answer, all_nums, occupied)
|
|
2271
2269
|
ordered = order_stage_closure(
|
|
2272
2270
|
[(s.stage_number, s.depends_on) for s in stages], chosen, done)
|
|
@@ -3177,7 +3175,7 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
|
|
|
3177
3175
|
)
|
|
3178
3176
|
return _msg(state.workspace_root, "confirmation", "worktree_impl_auto",
|
|
3179
3177
|
path=str(parent_dir))
|
|
3180
|
-
decision =
|
|
3178
|
+
decision = resolve_stage_worktree_decision(
|
|
3181
3179
|
project_id=state.project_id, task_group_segment=state.task_group,
|
|
3182
3180
|
task_id_segment=state.task_id,
|
|
3183
3181
|
work_category=_preview_work_category(state),
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import json
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
from .paths import task_dir
|
|
14
|
+
from .paths import task_dir, task_manifest_file
|
|
15
15
|
|
|
16
16
|
WORK_CATEGORIES: tuple[str, ...] = (
|
|
17
17
|
"bugfix",
|
|
@@ -30,7 +30,7 @@ def is_valid_category(value: str) -> bool:
|
|
|
30
30
|
|
|
31
31
|
def recorded_work_category(project_root: Path, task_group: str, task_id: str) -> str:
|
|
32
32
|
"""task-manifest.json 에 이미 기록된 work-category. 없거나 enum 밖이면 ""."""
|
|
33
|
-
manifest = task_dir(project_root, task_group, task_id)
|
|
33
|
+
manifest = task_manifest_file(task_dir(project_root, task_group, task_id))
|
|
34
34
|
try:
|
|
35
35
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
36
36
|
except (OSError, json.JSONDecodeError):
|
|
@@ -71,6 +71,88 @@ def analysis_input_lines(
|
|
|
71
71
|
return existing_input_lines(inputs)
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
REPORT_WRITER_WORKER_ID = "report-writer"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def report_writer_prompt_body(
|
|
78
|
+
manifest: Mapping[str, Any],
|
|
79
|
+
active_context: Mapping[str, Any],
|
|
80
|
+
team_state: Mapping[str, Any],
|
|
81
|
+
model: str,
|
|
82
|
+
report_language: str,
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
"""Render the provider-neutral body for the report-writer audience."""
|
|
85
|
+
return [
|
|
86
|
+
f"**Model:** Report writer worker, {model}",
|
|
87
|
+
f"**Report Language:** {report_language}",
|
|
88
|
+
"",
|
|
89
|
+
"# Report Writer Worker Dispatch",
|
|
90
|
+
"",
|
|
91
|
+
"## Role",
|
|
92
|
+
(
|
|
93
|
+
"You are the Report writer worker. Author the final-report data.json "
|
|
94
|
+
"and the worker-results audit file."
|
|
95
|
+
),
|
|
96
|
+
"",
|
|
97
|
+
"## Task",
|
|
98
|
+
f"- Task key: `{_require_string(manifest, 'taskKey')}`",
|
|
99
|
+
f"- Task type: `{_require_string(manifest, 'taskType')}`",
|
|
100
|
+
"",
|
|
101
|
+
"## Inputs",
|
|
102
|
+
*report_writer_input_lines(manifest, active_context, team_state),
|
|
103
|
+
"",
|
|
104
|
+
mcp_pointer_line(),
|
|
105
|
+
"",
|
|
106
|
+
"## Output Contract",
|
|
107
|
+
"You are the author of TWO files:",
|
|
108
|
+
"- The final-report data.json at Result Path.",
|
|
109
|
+
"- The worker-results audit file at Audit sidecar path.",
|
|
110
|
+
(
|
|
111
|
+
'After writing the data.json, invoke "okstra render-final-report '
|
|
112
|
+
'<Result Path>" so the markdown sibling is rendered before you return.'
|
|
113
|
+
),
|
|
114
|
+
"Do not return the report inline.",
|
|
115
|
+
"Copy Report Language verbatim into data.json.meta.reportLanguage.",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def report_writer_input_lines(
|
|
120
|
+
manifest: Mapping[str, Any],
|
|
121
|
+
active_context: Mapping[str, Any],
|
|
122
|
+
team_state: Mapping[str, Any],
|
|
123
|
+
) -> list[str]:
|
|
124
|
+
inputs = [
|
|
125
|
+
("Task brief", instruction_path(manifest, active_context, "taskBriefPath")),
|
|
126
|
+
("Analysis profile", instruction_path(manifest, active_context, "analysisProfilePath")),
|
|
127
|
+
("Analysis material", instruction_path(manifest, active_context, "analysisMaterialPath")),
|
|
128
|
+
("Reference expectations", instruction_path(manifest, active_context, "referenceExpectationsPath")),
|
|
129
|
+
("Clarification response", instruction_path(manifest, active_context, "clarificationResponsePath")),
|
|
130
|
+
("Final report template", instruction_path(manifest, active_context, "finalReportTemplatePath")),
|
|
131
|
+
("Final report schema", instruction_path(manifest, active_context, "finalReportSchemaPath")),
|
|
132
|
+
("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
|
|
133
|
+
]
|
|
134
|
+
lines = existing_input_lines(inputs)
|
|
135
|
+
lines.extend(analysis_worker_result_lines(team_state))
|
|
136
|
+
return lines or ["- No report-writer inputs were recorded in active-run-context."]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
|
|
140
|
+
lines = []
|
|
141
|
+
workers = team_state.get("workers")
|
|
142
|
+
if not isinstance(workers, list):
|
|
143
|
+
return lines
|
|
144
|
+
for worker in workers:
|
|
145
|
+
if not isinstance(worker, Mapping):
|
|
146
|
+
continue
|
|
147
|
+
worker_id = worker.get("workerId")
|
|
148
|
+
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
149
|
+
continue
|
|
150
|
+
result_path = _string_value(worker.get("resultPath"))
|
|
151
|
+
if result_path:
|
|
152
|
+
lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
|
|
153
|
+
return lines
|
|
154
|
+
|
|
155
|
+
|
|
74
156
|
def existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
|
|
75
157
|
lines = [f"- {label}: `{path}`" for label, path in inputs if path]
|
|
76
158
|
return lines or ["- No input paths were recorded in active-run-context."]
|
|
@@ -16,6 +16,10 @@ from .worker_prompt_policy import (
|
|
|
16
16
|
MAX_FINAL_VERIFICATION_DIRECTIVE_LINES = 40
|
|
17
17
|
MAX_FINAL_VERIFICATION_BODY_LINES = 96
|
|
18
18
|
|
|
19
|
+
PROMPT_DELIVERY_MODE_HEADER = "**Prompt Delivery Mode:**"
|
|
20
|
+
PROMPT_DELIVERY_MODES = frozenset({"eager-include", "lazy-path-reference"})
|
|
21
|
+
MODEL_HEADER = "**Model:**"
|
|
22
|
+
|
|
19
23
|
_DIRECTIVE_HEADING = "## Run-specific directive"
|
|
20
24
|
_WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
|
|
21
25
|
_PRIMARY_PACKET_RE = re.compile(
|
|
@@ -48,6 +52,7 @@ _NON_BODY_PREFIXES = (
|
|
|
48
52
|
"**Verification head ref:**",
|
|
49
53
|
"**Verification target path:**",
|
|
50
54
|
"**Verification target digest:**",
|
|
55
|
+
PROMPT_DELIVERY_MODE_HEADER,
|
|
51
56
|
)
|
|
52
57
|
_REQUIRED_TARGET_PREFIXES = (
|
|
53
58
|
"**Worktree:**",
|
|
@@ -81,6 +86,8 @@ class PromptRecord:
|
|
|
81
86
|
worker_id: str
|
|
82
87
|
dispatch_kind: str
|
|
83
88
|
path: Path
|
|
89
|
+
expected_model: str | None = None
|
|
90
|
+
expected_delivery_mode: str | None = None
|
|
84
91
|
|
|
85
92
|
|
|
86
93
|
def validate_final_verification_initial_prompt(text: str) -> list[str]:
|
|
@@ -170,19 +177,6 @@ def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
|
|
|
170
177
|
return [f"normalized initial analysis prompts differ across workers: {workers}"]
|
|
171
178
|
|
|
172
179
|
|
|
173
|
-
def validate_final_verification_prompt_paths(
|
|
174
|
-
prompt_paths: Mapping[str, Path],
|
|
175
|
-
) -> list[str]:
|
|
176
|
-
"""Validate persisted initial prompts and their normalized equality."""
|
|
177
|
-
records = [
|
|
178
|
-
PromptRecord(worker_id, "initial", path)
|
|
179
|
-
for worker_id, path in sorted(prompt_paths.items())
|
|
180
|
-
]
|
|
181
|
-
return validate_initial_prompt_records(
|
|
182
|
-
manifest={"taskType": "final-verification"},
|
|
183
|
-
records=records,
|
|
184
|
-
)
|
|
185
|
-
|
|
186
180
|
|
|
187
181
|
def validate_initial_prompt_records(
|
|
188
182
|
*,
|
|
@@ -207,6 +201,10 @@ def validate_initial_prompt_records(
|
|
|
207
201
|
f"{record.worker_id}: {error}"
|
|
208
202
|
for error in _validate_prompt_for_plan(text, plan, manifest)
|
|
209
203
|
)
|
|
204
|
+
errors.extend(
|
|
205
|
+
f"{record.worker_id}: {error}"
|
|
206
|
+
for error in _validate_record_metadata(text, record)
|
|
207
|
+
)
|
|
210
208
|
if plan.equality_group:
|
|
211
209
|
group = equality_groups.setdefault(plan.equality_group, {})
|
|
212
210
|
group[record.worker_id] = text
|
|
@@ -215,6 +213,68 @@ def validate_initial_prompt_records(
|
|
|
215
213
|
return errors
|
|
216
214
|
|
|
217
215
|
|
|
216
|
+
def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
|
|
217
|
+
errors = _validate_delivery_mode(
|
|
218
|
+
_header_values(text, PROMPT_DELIVERY_MODE_HEADER),
|
|
219
|
+
record.expected_delivery_mode,
|
|
220
|
+
)
|
|
221
|
+
if record.expected_model is None:
|
|
222
|
+
return errors
|
|
223
|
+
model = _model_value(_header_values(text, MODEL_HEADER))
|
|
224
|
+
if model is None:
|
|
225
|
+
errors.append(
|
|
226
|
+
"exactly one non-empty **Model:** <label>, <model> header is required"
|
|
227
|
+
)
|
|
228
|
+
elif model != record.expected_model:
|
|
229
|
+
errors.append(
|
|
230
|
+
f"prompt model does not match requested model: {record.expected_model}"
|
|
231
|
+
)
|
|
232
|
+
return errors
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_delivery_mode(
|
|
236
|
+
values: list[str],
|
|
237
|
+
expected: str | None,
|
|
238
|
+
) -> list[str]:
|
|
239
|
+
errors = []
|
|
240
|
+
if len(values) != 1 or not values[0]:
|
|
241
|
+
errors.append(
|
|
242
|
+
"exactly one non-empty **Prompt Delivery Mode:** header is required"
|
|
243
|
+
)
|
|
244
|
+
errors.extend(
|
|
245
|
+
f"unsupported Prompt Delivery Mode: {value}"
|
|
246
|
+
for value in values
|
|
247
|
+
if value and value not in PROMPT_DELIVERY_MODES
|
|
248
|
+
)
|
|
249
|
+
if (
|
|
250
|
+
expected is not None
|
|
251
|
+
and len(values) == 1
|
|
252
|
+
and values[0]
|
|
253
|
+
and values[0] != expected
|
|
254
|
+
):
|
|
255
|
+
errors.append(
|
|
256
|
+
f"Prompt Delivery Mode does not match requested mode: {expected}"
|
|
257
|
+
)
|
|
258
|
+
return errors
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _header_values(text: str, prefix: str) -> list[str]:
|
|
262
|
+
return [
|
|
263
|
+
line.strip()[len(prefix):].strip()
|
|
264
|
+
for line in text.splitlines()
|
|
265
|
+
if line.strip().startswith(prefix)
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _model_value(values: list[str]) -> str | None:
|
|
270
|
+
if len(values) != 1:
|
|
271
|
+
return None
|
|
272
|
+
parts = [part.strip() for part in values[0].rsplit(",", 1)]
|
|
273
|
+
if len(parts) != 2 or not all(parts):
|
|
274
|
+
return None
|
|
275
|
+
return parts[1]
|
|
276
|
+
|
|
277
|
+
|
|
218
278
|
def _resolve_record_plan(
|
|
219
279
|
manifest: Mapping[str, Any],
|
|
220
280
|
record: PromptRecord,
|
|
@@ -85,6 +85,13 @@ def worker_prompt_headers(
|
|
|
85
85
|
f"**Project Root:** {project_root}",
|
|
86
86
|
f"**Prompt History Path:** {prompt_rel}",
|
|
87
87
|
f"**Result Path:** {result_rel}",
|
|
88
|
+
]
|
|
89
|
+
if audit_source_rel and audit_source_rel != result_rel:
|
|
90
|
+
# The audit path is derived from this file, not from Result Path, so the
|
|
91
|
+
# worker has to be told which one it is (report-writer, whose Result Path
|
|
92
|
+
# is the report data.json rather than its own worker result).
|
|
93
|
+
headers.append(f"**Worker Result Path:** {audit_source_rel}")
|
|
94
|
+
headers += [
|
|
88
95
|
f"**Audit sidecar path:** {audit_sidecar_path}",
|
|
89
96
|
f"Assigned worker prompt history path: {prompt_path}",
|
|
90
97
|
f"**Worker Preamble Path:** {_worker_preamble_path(plan, active_context)}",
|
|
@@ -194,31 +194,38 @@ def preview_worktree_decision(
|
|
|
194
194
|
)
|
|
195
195
|
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
@dataclass
|
|
198
|
+
class StageWorktreeDecision:
|
|
199
|
+
"""Side-effect-free decision for one concrete implementation stage."""
|
|
200
|
+
|
|
201
|
+
status: str
|
|
202
|
+
path: str
|
|
203
|
+
branch: str = ""
|
|
204
|
+
base_ref: str = ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def resolve_stage_worktree_decision(
|
|
198
208
|
*,
|
|
199
209
|
project_id: str,
|
|
200
210
|
task_group_segment: str,
|
|
201
211
|
task_id_segment: str,
|
|
202
212
|
work_category: str,
|
|
203
213
|
stage_number: int,
|
|
204
|
-
) ->
|
|
205
|
-
"""
|
|
206
|
-
|
|
207
|
-
Mirrors provision_stage_worktree's registry-reuse branch exactly. A "new"
|
|
208
|
-
decision carries no base_ref — the stage base commit is resolved later by
|
|
209
|
-
the prepare flow from the stage's dependency anchors.
|
|
210
|
-
"""
|
|
214
|
+
) -> StageWorktreeDecision:
|
|
215
|
+
"""Resolve whether one concrete stage worktree is new or reusable."""
|
|
211
216
|
safe_project = _safe_segment(project_id)
|
|
212
217
|
safe_group = _safe_segment(task_group_segment)
|
|
213
218
|
safe_task = _safe_segment(task_id_segment)
|
|
214
219
|
existing = worktree_registry.lookup(
|
|
215
220
|
safe_project, safe_group, safe_task, stage_number=stage_number)
|
|
216
221
|
if existing is not None and existing.status == "active":
|
|
217
|
-
return
|
|
218
|
-
status="reused",
|
|
219
|
-
|
|
222
|
+
return StageWorktreeDecision(
|
|
223
|
+
status="reused",
|
|
224
|
+
path=existing.worktree_path,
|
|
225
|
+
branch=existing.branch,
|
|
226
|
+
base_ref=existing.base_ref,
|
|
220
227
|
)
|
|
221
|
-
return
|
|
228
|
+
return StageWorktreeDecision(
|
|
222
229
|
status="new",
|
|
223
230
|
path=str(compute_worktree_path(
|
|
224
231
|
project_id=safe_project, task_group_segment=safe_group,
|
|
@@ -923,31 +930,32 @@ def provision_stage_worktree(
|
|
|
923
930
|
if not base_commit:
|
|
924
931
|
raise RuntimeError("provision_stage_worktree requires a base_commit")
|
|
925
932
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
task_id_segment=safe_task, stage_number=stage_number,
|
|
932
|
-
)
|
|
933
|
-
branch = compute_branch_name(
|
|
934
|
-
work_category=work_category, task_id_segment=safe_task,
|
|
933
|
+
decision = resolve_stage_worktree_decision(
|
|
934
|
+
project_id=project_id,
|
|
935
|
+
task_group_segment=task_group_segment,
|
|
936
|
+
task_id_segment=task_id_segment,
|
|
937
|
+
work_category=work_category,
|
|
935
938
|
stage_number=stage_number,
|
|
936
939
|
)
|
|
937
|
-
|
|
938
|
-
existing = worktree_registry.lookup(
|
|
939
|
-
safe_project, safe_group, safe_task, stage_number=stage_number)
|
|
940
|
-
if existing is not None and existing.status == "active":
|
|
940
|
+
if decision.status == "reused":
|
|
941
941
|
return WorktreeProvision(
|
|
942
|
-
status="reused",
|
|
943
|
-
|
|
942
|
+
status="reused",
|
|
943
|
+
path=decision.path,
|
|
944
|
+
branch=decision.branch,
|
|
945
|
+
base_ref=decision.base_ref,
|
|
944
946
|
note=(
|
|
945
947
|
f"stage {stage_number} worktree reused at "
|
|
946
|
-
f"{
|
|
947
|
-
f"(base {
|
|
948
|
+
f"{decision.path} on branch {decision.branch} "
|
|
949
|
+
f"(base {decision.base_ref[:12]})"
|
|
948
950
|
),
|
|
949
951
|
)
|
|
950
952
|
|
|
953
|
+
safe_project = _safe_segment(project_id)
|
|
954
|
+
safe_group = _safe_segment(task_group_segment)
|
|
955
|
+
safe_task = _safe_segment(task_id_segment)
|
|
956
|
+
worktree_path = Path(decision.path)
|
|
957
|
+
branch = decision.branch
|
|
958
|
+
|
|
951
959
|
if worktree_path.exists():
|
|
952
960
|
raise RuntimeError(
|
|
953
961
|
f"stage worktree path already exists but is not in the registry: "
|
|
@@ -33,11 +33,13 @@ from .state import (
|
|
|
33
33
|
parse_task_key,
|
|
34
34
|
read_latest_task,
|
|
35
35
|
read_task_catalog,
|
|
36
|
+
read_task_key,
|
|
36
37
|
read_task_manifest,
|
|
37
38
|
resolve_task_id,
|
|
38
39
|
resolve_task_identity,
|
|
39
40
|
resolve_task_reference,
|
|
40
41
|
slugify,
|
|
42
|
+
stage_map_read_side_snapshot,
|
|
41
43
|
task_read_side_snapshot,
|
|
42
44
|
)
|
|
43
45
|
|
|
@@ -59,12 +61,14 @@ __all__ = [
|
|
|
59
61
|
"project_json_path",
|
|
60
62
|
"read_latest_task",
|
|
61
63
|
"read_task_catalog",
|
|
64
|
+
"read_task_key",
|
|
62
65
|
"read_task_manifest",
|
|
63
66
|
"resolve_project_root",
|
|
64
67
|
"resolve_task_id",
|
|
65
68
|
"resolve_task_identity",
|
|
66
69
|
"resolve_task_reference",
|
|
67
70
|
"slugify",
|
|
71
|
+
"stage_map_read_side_snapshot",
|
|
68
72
|
"task_read_side_snapshot",
|
|
69
73
|
"tasks_root",
|
|
70
74
|
"upsert_project_json",
|
|
@@ -37,6 +37,13 @@ OKSTRA_RELATIVE = Path(OKSTRA_DIR_NAME)
|
|
|
37
37
|
LEGACY_OKSTRA_RELATIVE = Path(LEGACY_OKSTRA_DIR_NAME)
|
|
38
38
|
PROJECT_JSON_RELATIVE = OKSTRA_RELATIVE / "project.json"
|
|
39
39
|
TASKS_RELATIVE = OKSTRA_RELATIVE / "tasks"
|
|
40
|
+
|
|
41
|
+
TASK_MANIFEST_FILENAME = "task-manifest.json"
|
|
42
|
+
"""task root 안의 lifecycle manifest 파일명.
|
|
43
|
+
|
|
44
|
+
`okstra_ctl.paths.task_manifest_path` 와 `okstra_project.state.read_task_manifest`
|
|
45
|
+
양쪽이 이 상수를 쓴다 — paths 가 state 를 import 하므로 역방향 import 는 순환이고,
|
|
46
|
+
두 계층이 공유할 수 있는 자리는 여기뿐이다."""
|
|
40
47
|
DISCOVERY_RELATIVE = OKSTRA_RELATIVE / "discovery"
|
|
41
48
|
TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
|
|
42
49
|
LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
|