okstra 0.136.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.
- package/docs/architecture/storage-model.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +8 -3
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
- 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 +54 -175
- 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/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 +9 -15
- 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 +222 -239
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +98 -55
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +45 -47
- 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 +0 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- 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/validate-run.py +3 -21
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -49,6 +49,23 @@ class StageLifecycle:
|
|
|
49
49
|
pr_covered: bool
|
|
50
50
|
head_commit: str
|
|
51
51
|
report_path: str
|
|
52
|
+
step_count: int
|
|
53
|
+
deps_satisfied: bool
|
|
54
|
+
blocked_by: list[int]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def status(self) -> str:
|
|
58
|
+
"""Lifecycle state, in precedence order: done > active > ready > blocked.
|
|
59
|
+
|
|
60
|
+
``started`` and ``reserved`` collapse into ``active`` because every
|
|
61
|
+
caller that distinguishes them already words the two the same way (see
|
|
62
|
+
the numeric-request error in ``resolve_effective_stages``).
|
|
63
|
+
"""
|
|
64
|
+
if self.done:
|
|
65
|
+
return "done"
|
|
66
|
+
if self.started or self.reserved:
|
|
67
|
+
return "active"
|
|
68
|
+
return "ready" if self.deps_satisfied else "blocked"
|
|
52
69
|
|
|
53
70
|
def handoff_eligibility_record(self) -> dict[str, Any]:
|
|
54
71
|
reasons: list[str] = []
|
|
@@ -92,14 +109,67 @@ class StageLifecycleSnapshot:
|
|
|
92
109
|
requested: str,
|
|
93
110
|
budget: int = RUN_STEP_BUDGET,
|
|
94
111
|
) -> list[int]:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
"""Return ordered stage numbers this run should execute.
|
|
113
|
+
|
|
114
|
+
``requested`` is ``"auto"`` or a decimal string. Auto selection returns
|
|
115
|
+
every stage reporting ``ready`` within the step budget; numeric
|
|
116
|
+
selection returns one forced stage. Readiness itself is not decided
|
|
117
|
+
here — ``StageLifecycle.status`` is the single judgment point.
|
|
118
|
+
"""
|
|
119
|
+
if requested != "auto":
|
|
120
|
+
return [self._forced_stage(requested)]
|
|
121
|
+
|
|
122
|
+
ready = [lc for lc in self.lifecycles if lc.status == "ready"]
|
|
123
|
+
if not ready:
|
|
124
|
+
raise StageTargetError(self._no_ready_stage_reason())
|
|
125
|
+
|
|
126
|
+
batch: list[int] = []
|
|
127
|
+
total = 0
|
|
128
|
+
for lifecycle in ready:
|
|
129
|
+
if batch and total + lifecycle.step_count > budget:
|
|
130
|
+
break
|
|
131
|
+
batch.append(lifecycle.stage)
|
|
132
|
+
total += lifecycle.step_count
|
|
133
|
+
return batch
|
|
134
|
+
|
|
135
|
+
def _forced_stage(self, requested: str) -> int:
|
|
136
|
+
try:
|
|
137
|
+
n = int(requested)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise StageTargetError(
|
|
140
|
+
f"--stage must be 'auto' or an integer, got {requested!r}"
|
|
141
|
+
) from exc
|
|
142
|
+
by_number = {lc.stage: lc for lc in self.lifecycles}
|
|
143
|
+
lifecycle = by_number.get(n)
|
|
144
|
+
if lifecycle is None:
|
|
145
|
+
raise StageTargetError(
|
|
146
|
+
f"--stage {n} not in Stage Map (have {list(by_number)})"
|
|
147
|
+
)
|
|
148
|
+
if lifecycle.status == "done":
|
|
149
|
+
raise StageTargetError(
|
|
150
|
+
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
151
|
+
)
|
|
152
|
+
if lifecycle.status == "active":
|
|
153
|
+
# Wording is load-bearing: okstra-run's unattended chain matches it
|
|
154
|
+
# to terminate normally instead of raising an exception gate.
|
|
155
|
+
raise StageTargetError(
|
|
156
|
+
f"--stage {n} already in progress or reserved by another run"
|
|
157
|
+
)
|
|
158
|
+
return n
|
|
159
|
+
|
|
160
|
+
def _no_ready_stage_reason(self) -> str:
|
|
161
|
+
blocked = [lc for lc in self.lifecycles if lc.status == "blocked"]
|
|
162
|
+
if not blocked:
|
|
163
|
+
return (
|
|
164
|
+
"no stage is ready: every stage is done or occupied by "
|
|
165
|
+
"another run"
|
|
166
|
+
)
|
|
167
|
+
detail = "; ".join(
|
|
168
|
+
f"stage {lc.stage} blocked by "
|
|
169
|
+
+ ", ".join(str(d) for d in lc.blocked_by)
|
|
170
|
+
for lc in blocked
|
|
102
171
|
)
|
|
172
|
+
return f"no stage is ready: {detail}"
|
|
103
173
|
|
|
104
174
|
def resolve_implementation_stage(self, requested: str) -> int:
|
|
105
175
|
return self.resolve_effective_stages(requested)[0]
|
|
@@ -131,9 +201,16 @@ def stage_lifecycle_snapshot_from_state(
|
|
|
131
201
|
for stage in stage_map:
|
|
132
202
|
stage_number = stage["stage_number"]
|
|
133
203
|
done_row = consumer_state.done_by_stage.get(stage_number) or {}
|
|
204
|
+
depends_on = list(stage.get("depends_on") or [])
|
|
205
|
+
blocked_by = [
|
|
206
|
+
d for d in depends_on if d not in consumer_state.done_stages
|
|
207
|
+
]
|
|
134
208
|
lifecycles.append(StageLifecycle(
|
|
135
209
|
stage=stage_number,
|
|
136
|
-
depends_on=
|
|
210
|
+
depends_on=depends_on,
|
|
211
|
+
step_count=int(stage.get("step_count") or 0),
|
|
212
|
+
deps_satisfied=not blocked_by,
|
|
213
|
+
blocked_by=blocked_by,
|
|
137
214
|
done=stage_number in consumer_state.done_stages,
|
|
138
215
|
started=stage_number in consumer_state.started_stages,
|
|
139
216
|
reserved=stage_number in reserved,
|
|
@@ -198,56 +275,22 @@ def resolve_effective_stages(
|
|
|
198
275
|
started_stages: set[int] | None = None,
|
|
199
276
|
reserved_stages: set[int] | None = None,
|
|
200
277
|
) -> list[int]:
|
|
201
|
-
"""
|
|
278
|
+
"""Adapter over ``StageLifecycleSnapshot.resolve_effective_stages``.
|
|
202
279
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
280
|
+
Entry point for callers holding raw stage-map and consumer sets rather than
|
|
281
|
+
a snapshot. It only assembles the snapshot; the readiness judgment lives on
|
|
282
|
+
``StageLifecycle.status``.
|
|
206
283
|
"""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
occupied = done_stages | started_stages | reserved_stages
|
|
210
|
-
if requested != "auto":
|
|
211
|
-
try:
|
|
212
|
-
n = int(requested)
|
|
213
|
-
except ValueError as exc:
|
|
214
|
-
raise StageTargetError(
|
|
215
|
-
f"--stage must be 'auto' or an integer, got {requested!r}"
|
|
216
|
-
) from exc
|
|
217
|
-
target = next((s for s in stages if s["stage_number"] == n), None)
|
|
218
|
-
if target is None:
|
|
219
|
-
raise StageTargetError(
|
|
220
|
-
f"--stage {n} not in Stage Map "
|
|
221
|
-
f"(have {[s['stage_number'] for s in stages]})"
|
|
222
|
-
)
|
|
223
|
-
if n in done_stages:
|
|
224
|
-
raise StageTargetError(
|
|
225
|
-
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
226
|
-
)
|
|
227
|
-
if n in started_stages or n in reserved_stages:
|
|
228
|
-
raise StageTargetError(
|
|
229
|
-
f"--stage {n} already in progress or reserved by another run"
|
|
230
|
-
)
|
|
231
|
-
return [n]
|
|
232
|
-
|
|
233
|
-
ready = [
|
|
234
|
-
s for s in stages
|
|
235
|
-
if s["stage_number"] not in occupied
|
|
236
|
-
and all(d in done_stages for d in s["depends_on"])
|
|
284
|
+
rows: list[dict[str, Any]] = [
|
|
285
|
+
{"stage": n, "status": "done"} for n in sorted(done_stages)
|
|
237
286
|
]
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
step_count = stage.get("step_count", 0) or 0
|
|
246
|
-
if batch and total + step_count > budget:
|
|
247
|
-
break
|
|
248
|
-
batch.append(stage["stage_number"])
|
|
249
|
-
total += step_count
|
|
250
|
-
return batch
|
|
287
|
+
rows += [
|
|
288
|
+
{"stage": n, "status": "started"} for n in sorted(started_stages or set())
|
|
289
|
+
]
|
|
290
|
+
snapshot = stage_lifecycle_snapshot_from_rows(
|
|
291
|
+
stages, rows, reserved_stages=reserved_stages,
|
|
292
|
+
)
|
|
293
|
+
return snapshot.resolve_effective_stages(requested, budget=budget)
|
|
251
294
|
|
|
252
295
|
|
|
253
296
|
def resolve_implementation_stage(
|
|
@@ -14,12 +14,13 @@ import json
|
|
|
14
14
|
import sys
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from okstra_ctl.paths import resolve_under_root
|
|
17
|
+
from okstra_ctl.paths import resolve_under_root, task_timeline_file
|
|
18
|
+
from okstra_project import read_task_key
|
|
18
19
|
from okstra_ctl.task_target import resolve_task_root
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def load_runs(task_root: Path) -> tuple[list[dict], bool]:
|
|
22
|
-
path = task_root
|
|
23
|
+
path = task_timeline_file(task_root)
|
|
23
24
|
try:
|
|
24
25
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
25
26
|
except (OSError, ValueError):
|
|
@@ -30,12 +31,6 @@ def load_runs(task_root: Path) -> tuple[list[dict], bool]:
|
|
|
30
31
|
return runs, False
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
def _task_key(task_root: Path) -> str:
|
|
34
|
-
try:
|
|
35
|
-
return json.loads((task_root / "task-manifest.json").read_text(encoding="utf-8")).get("taskKey", "")
|
|
36
|
-
except (OSError, ValueError):
|
|
37
|
-
return ""
|
|
38
|
-
|
|
39
34
|
|
|
40
35
|
def _duration_ms(block) -> int:
|
|
41
36
|
"""usage / leadUsage block 의 durationMs — na_block·missing·None 은 0."""
|
|
@@ -194,7 +189,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
194
189
|
|
|
195
190
|
task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
|
|
196
191
|
runs, _ = load_runs(task_root)
|
|
197
|
-
result = {"ok": True, "taskKey":
|
|
192
|
+
result = {"ok": True, "taskKey": read_task_key(task_root)}
|
|
198
193
|
result.update(aggregate_time(runs, project_root))
|
|
199
194
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
200
195
|
return 0
|
|
@@ -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)
|
|
@@ -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."]
|
|
@@ -170,19 +170,6 @@ def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
|
|
|
170
170
|
return [f"normalized initial analysis prompts differ across workers: {workers}"]
|
|
171
171
|
|
|
172
172
|
|
|
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
173
|
|
|
187
174
|
def validate_initial_prompt_records(
|
|
188
175
|
*,
|
|
@@ -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)}",
|
|
@@ -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"
|