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.
Files changed (36) 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-ctl/cmd-rerun.sh +1 -1
  7. package/runtime/bin/okstra-spawn-followups.py +4 -9
  8. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  9. package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
  10. package/runtime/python/okstra_ctl/consumers.py +7 -5
  11. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  12. package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
  13. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  14. package/runtime/python/okstra_ctl/error_report.py +2 -7
  15. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  16. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  17. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  18. package/runtime/python/okstra_ctl/paths.py +17 -2
  19. package/runtime/python/okstra_ctl/recap.py +5 -12
  20. package/runtime/python/okstra_ctl/render.py +9 -15
  21. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  22. package/runtime/python/okstra_ctl/report_language.py +83 -0
  23. package/runtime/python/okstra_ctl/run.py +222 -239
  24. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  25. package/runtime/python/okstra_ctl/stage_targets.py +98 -55
  26. package/runtime/python/okstra_ctl/time_report.py +4 -9
  27. package/runtime/python/okstra_ctl/wizard.py +45 -47
  28. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  29. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
  31. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  32. package/runtime/python/okstra_project/__init__.py +4 -0
  33. package/runtime/python/okstra_project/dirs.py +7 -0
  34. package/runtime/python/okstra_project/state.py +78 -8
  35. package/runtime/validators/validate-run.py +3 -21
  36. 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
- return resolve_effective_stages(
96
- self.stage_map,
97
- self.done_stages,
98
- requested,
99
- budget=budget,
100
- started_stages=self.started_stages,
101
- reserved_stages=self.reserved_stages,
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=list(stage.get("depends_on") or []),
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
- """Return ordered stage numbers this run should execute.
278
+ """Adapter over ``StageLifecycleSnapshot.resolve_effective_stages``.
202
279
 
203
- ``requested`` is ``"auto"`` or a decimal string. Auto selection returns all
204
- ready stages within the step budget, excluding done/started/reserved stages.
205
- Numeric selection returns one forced stage.
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
- started_stages = started_stages or set()
208
- reserved_stages = reserved_stages or set()
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
- if not ready:
239
- raise StageTargetError(
240
- "no stage is ready: every remaining stage has unsatisfied depends-on"
241
- )
242
- batch: list[int] = []
243
- total = 0
244
- for stage in ready:
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 / "history" / "timeline.json"
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": _task_key(task_root)}
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
- manifest = task_root / "task-manifest.json"
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 _done_stage_numbers(state: WizardState) -> set:
1013
- """approved plan 소비한 implementation run 들의 consumers.jsonl 에서
1014
- done 처리된 stage 번호 집합. 순수 읽기 — carry backfill 은 하지 않는다.
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 점유를 해제한다. 이 함수는 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
- def _started_stage_numbers(state: WizardState) -> set:
1030
- """approved plan 을 소비한 run 들의 consumers.jsonl 에서 status:started 인
1031
- stage 번호 집합. _done_stage_numbers 와 같은 순수 읽기 — carry backfill 금지."""
1032
- if not state.approved_plan_path:
1033
- return set()
1034
- from .consumers import read_consumers
1035
- plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
1036
- rows = read_consumers(plan_run_root)
1037
- return {r["stage"] for r in rows
1038
- if r.get("status") == "started" and isinstance(r.get("stage"), int)}
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 = _done_stage_numbers(state)
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
- done = _done_stage_numbers(state)
2196
- started = _started_stage_numbers(state) if is_impl else set()
2197
- reserved = _reserved_stage_numbers(state) if is_impl else set()
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(t, s, done, started, reserved)
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
- def _impl_stage_marker(t, stage, done, started, reserved) -> str:
2226
- n = stage.stage_number
2227
- if n in done:
2228
- return t["options"]["mark_done"]
2229
- if n in started or n in reserved:
2230
- return t["options"]["mark_active"]
2231
- if all(d in done for d in stage.depends_on):
2232
- return t["options"]["mark_ready"]
2233
- return t["options"]["mark_blocked"]
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
- done = _done_stage_numbers(state)
2268
- occupied = (done | _started_stage_numbers(state)
2269
- | _reserved_stage_numbers(state))
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) / "task-manifest.json"
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"