okstra 0.177.0 → 0.178.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 (41) hide show
  1. package/dist/commands/execute/team.mjs +14 -4
  2. package/dist/commands/execute/team.mjs.map +1 -1
  3. package/dist/commands/lifecycle/install.mjs +0 -1
  4. package/dist/commands/lifecycle/install.mjs.map +1 -1
  5. package/docs/architecture.md +3 -3
  6. package/docs/cli.md +1 -1
  7. package/docs/project-structure-overview.md +2 -2
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/report-writer-worker.md +1 -1
  11. package/runtime/bin/okstra-compact-reminder.sh +2 -2
  12. package/runtime/bin/okstra-render-report-views.py +13 -10
  13. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  14. package/runtime/prompts/lead/report-writer.md +5 -1
  15. package/runtime/prompts/profiles/_common-contract.md +1 -1
  16. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
  17. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
  18. package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
  19. package/runtime/python/okstra_ctl/agent_prompt_cli.py +13 -0
  20. package/runtime/python/okstra_ctl/dispatch_core.py +96 -17
  21. package/runtime/python/okstra_ctl/dispatch_state.py +57 -0
  22. package/runtime/python/okstra_ctl/model_cli.py +11 -2
  23. package/runtime/python/okstra_ctl/model_discovery.py +12 -0
  24. package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
  25. package/runtime/python/okstra_ctl/render.py +53 -0
  26. package/runtime/python/okstra_ctl/report_html/render.py +7 -4
  27. package/runtime/python/okstra_ctl/report_views.py +35 -0
  28. package/runtime/python/okstra_ctl/run.py +88 -2
  29. package/runtime/python/okstra_ctl/team.py +84 -14
  30. package/runtime/python/okstra_ctl/tmux.py +2 -3
  31. package/runtime/python/okstra_ctl/user_response.py +20 -4
  32. package/runtime/python/okstra_ctl/worker_runner.py +2 -2
  33. package/runtime/python/okstra_ctl/write_policy.py +9 -1
  34. package/runtime/schemas/final-report-v2.0.schema.json +24 -1
  35. package/runtime/skills/okstra-run/SKILL.md +3 -3
  36. package/runtime/templates/reports/final-report-v2.template.md +2 -1
  37. package/runtime/templates/reports/html/base.template.html +1 -2
  38. package/runtime/validators/validate-report-views.py +30 -17
  39. package/runtime/validators/validate-run.py +256 -78
  40. package/runtime/validators/validate_session_conformance.py +1 -1
  41. package/runtime/bin/okstra-trace-cleanup.sh +0 -185
@@ -44,12 +44,34 @@ ANTIGRAVITY = {
44
44
  }
45
45
 
46
46
 
47
+ def _catalog_model_id(observed: str) -> str:
48
+ """Undo the dispatch-time tier suffix so the id is catalog-comparable.
49
+
50
+ Dispatch sends `gemini-3.1-pro-low`; agy serves that identity back
51
+ verbatim. The catalog holds the bare `gemini-3.1-pro`, so reading the
52
+ observation as-is made the served-model gate reject a model okstra itself
53
+ had asked for. Only suffixes dispatch can append are stripped, and only
54
+ when what remains is a catalog entry — anything else passes through for
55
+ the gate to judge.
56
+ """
57
+ if observed in ANTIGRAVITY:
58
+ return observed # identity already carries its tier (agy's Claude models)
59
+ for suffix in model_discovery.dispatch_tier_suffixes():
60
+ bare = observed.removesuffix(f"-{suffix}")
61
+ if bare != observed and bare in ANTIGRAVITY:
62
+ return bare
63
+ return observed
64
+
65
+
47
66
  def normalise_served_model(raw_model: str | None) -> ServedModelAttestation:
48
67
  if not raw_model or not raw_model.strip():
49
68
  return ServedModelAttestation.unknown()
50
69
  model_id = raw_model.strip().lower().removeprefix("antigravity/")
51
70
  return ServedModelAttestation(
52
- raw_model, f"antigravity/{model_id}", "exact", "provider-output"
71
+ raw_model,
72
+ f"antigravity/{_catalog_model_id(model_id)}",
73
+ "exact",
74
+ "provider-output",
53
75
  )
54
76
 
55
77
 
@@ -1209,6 +1209,23 @@ def _render_prompt(
1209
1209
  f"**Runner:** {assignment.runner}",
1210
1210
  f"**Host runtime:** {assignment.host_runtime}",
1211
1211
  ]
1212
+ # The dispatch prompt contract requires exactly one of these on every prompt
1213
+ # it checks, and a critic pass is checked under the same contract as the
1214
+ # initial analysis it audits — but only the initial-prompt path emitted one,
1215
+ # so a dynamically materialized critic was refused. A dynamic prompt carries
1216
+ # its instruction inline, which is what `eager-include` states; the initial
1217
+ # path states the host's `initialPromptDeliveryMode` instead and puts it in
1218
+ # `anchor_lines`, so defer to it when it is already there.
1219
+ delivery_mode = "**Prompt Delivery Mode:**"
1220
+ already_declared = any(
1221
+ line.strip().startswith(delivery_mode)
1222
+ for line in (
1223
+ *request.instruction.anchor_lines,
1224
+ *request.instruction.body.splitlines(),
1225
+ )
1226
+ )
1227
+ if not already_declared:
1228
+ header.append(f"{delivery_mode} eager-include")
1212
1229
  if assignment.host_model_value is not None:
1213
1230
  header.append(f"**Host model value:** {assignment.host_model_value}")
1214
1231
  duty_body = f"{common.body.rstrip()}\n\n{duty.body.rstrip()}"
@@ -304,6 +304,19 @@ def _materialize_run(
304
304
  "result",
305
305
  must_exist=False,
306
306
  )
307
+ if args.audience == "report-writer" and not args.audit_source:
308
+ # The report writer is the one audience whose result path is not its own
309
+ # worker result: it writes the report `data.json`, while the audit
310
+ # sidecar is derived from its `.md`. With both collapsed into one value
311
+ # the prompt loses its `**Worker Result Path:**` anchor and the writer
312
+ # puts the report where the audit file belongs — silently, because every
313
+ # header is still present and well-formed. The roster path derives both
314
+ # from the manifest; a dynamic call has to name them.
315
+ raise AgentPromptCliError(
316
+ "report-writer materialization requires --audit-source: --result is "
317
+ "the report data.json, --audit-source the worker-result .md the "
318
+ "audit sidecar is derived from"
319
+ )
307
320
  audit_source_path = (
308
321
  _authorized_path(
309
322
  project_root,
@@ -65,6 +65,7 @@ from .domain.worker_runtime import (
65
65
  from .ports.worker_runtime import WorkerRuntimePort
66
66
  from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
67
67
  from .execution_manifest import (
68
+ ExecutionManifestError,
68
69
  finish_attempt_mutation,
69
70
  read_execution_manifest,
70
71
  record_invocation_attempt,
@@ -208,11 +209,23 @@ def verify_served_model(
208
209
  return attestation
209
210
  if role_execution.model_ref is None or not attestation.normalized_model_ref:
210
211
  raise DispatchError("served model differs from selected model")
212
+ # An unregistered ref and a genuine substitution are different failures.
213
+ # Folding both into "differs from selected" hid which one happened, and a
214
+ # catalog gap reads as a provider swapping the model out from under us.
211
215
  try:
212
216
  selected = pool.resolve(role_execution.model_ref)
217
+ except ValueError as exc:
218
+ raise DispatchError(
219
+ f"selected model is not in the catalog: {role_execution.model_ref}"
220
+ ) from exc
221
+ try:
213
222
  observed = pool.resolve(attestation.normalized_model_ref)
214
223
  except ValueError as exc:
215
- raise DispatchError("served model differs from selected model") from exc
224
+ raise DispatchError(
225
+ "served model is not in the catalog: "
226
+ f"{attestation.normalized_model_ref} "
227
+ f"(provider reported {attestation.observed_model!r})"
228
+ ) from exc
216
229
  expected_level = "channel" if observed.version_kind == "channel" else "exact"
217
230
  if attestation.level != expected_level:
218
231
  raise DispatchError("served model attestation level is inconsistent")
@@ -1302,22 +1315,32 @@ def _spawn_job(
1302
1315
  batch_artifact_paths: Sequence[Path] = (),
1303
1316
  ) -> WorkerHandle:
1304
1317
  job = _prepare_job_attempt(plan, job, attempt)
1305
- contract = _persisted_write_contract(plan, job)
1306
- policies = (contract[0],) if contract else ()
1307
- snapshot = None
1308
- if contract is not None and contract[1].mutation_audit == "batch":
1309
- snapshot_path = _mutation_snapshot_path(job)
1310
- if snapshot_path.is_file():
1311
- snapshot = MutationSnapshot.from_payload(
1312
- _load_json_object(snapshot_path, "mutation audit snapshot")
1313
- )
1314
- else:
1315
- snapshot = _mutation_snapshot(
1316
- plan,
1317
- policies,
1318
- (job,),
1319
- round_artifact_paths=batch_artifact_paths,
1320
- )
1318
+ # Everything between recording the attempt and starting the worker runs
1319
+ # before any worker process exists. A failure here used to leave the attempt
1320
+ # `started` forever: the manifest then refused attempt 1 again ("next
1321
+ # attempt must be 2") and refused attempt 2 as well, because the prompt
1322
+ # metadata still said attempt 1. The invocation had no way forward and the
1323
+ # lead had to mint a new invocation id and prompt path to escape.
1324
+ try:
1325
+ contract = _persisted_write_contract(plan, job)
1326
+ policies = (contract[0],) if contract else ()
1327
+ snapshot = None
1328
+ if contract is not None and contract[1].mutation_audit == "batch":
1329
+ snapshot_path = _mutation_snapshot_path(job)
1330
+ if snapshot_path.is_file():
1331
+ snapshot = MutationSnapshot.from_payload(
1332
+ _load_json_object(snapshot_path, "mutation audit snapshot")
1333
+ )
1334
+ else:
1335
+ snapshot = _mutation_snapshot(
1336
+ plan,
1337
+ policies,
1338
+ (job,),
1339
+ round_artifact_paths=batch_artifact_paths,
1340
+ )
1341
+ except Exception:
1342
+ _abandon_unstarted_attempt(plan, job, attempt)
1343
+ raise
1321
1344
  handle = replace(
1322
1345
  _start_job(plan, job),
1323
1346
  mutation_snapshot=snapshot,
@@ -1422,6 +1445,7 @@ def _mutation_snapshot(
1422
1445
  policies,
1423
1446
  orchestrator_paths=(
1424
1447
  *round_artifact_paths,
1448
+ *_run_errors_log_path(plan),
1425
1449
  plan.manifest_path,
1426
1450
  plan.team_state_path,
1427
1451
  Path(f"{plan.team_state_path}.lock"),
@@ -1523,6 +1547,12 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
1523
1547
  Path(audit_sidecar_rel(str(job.worker_result_path))),
1524
1548
  status_path_for_prompt(job.prompt_path),
1525
1549
  log_path_for_prompt(job.prompt_path),
1550
+ # The prompt's three derived files are written together and belong in
1551
+ # one list. The audit snapshot used to be listed only for the job whose
1552
+ # snapshot it was, so a sibling's snapshot — written by okstra as that
1553
+ # sibling started — landed inside this worker's window as an
1554
+ # unauthorized artifact-root change.
1555
+ _mutation_snapshot_path(job),
1526
1556
  }
1527
1557
  error_logs = active_context.get("errorLogs")
1528
1558
  if isinstance(error_logs, Mapping):
@@ -1534,6 +1564,26 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
1534
1564
  return tuple(sorted(paths, key=str))
1535
1565
 
1536
1566
 
1567
+ def _run_errors_log_path(plan: DispatchPlan) -> tuple[Path, ...]:
1568
+ """The run-level errors log, which the lead appends to while workers run.
1569
+
1570
+ The lead contract requires it to record an observed worker failure as soon
1571
+ as it happens, so a worker still running at that moment sees the write. It
1572
+ is a lead-owned run artifact like the manifest and the lead events log, and
1573
+ is listed for the same reason.
1574
+ """
1575
+ active_context = _load_optional_json(
1576
+ plan.project_root, plan.manifest.get("activeRunContextPath")
1577
+ )
1578
+ error_logs = active_context.get("errorLogs")
1579
+ if not isinstance(error_logs, Mapping):
1580
+ return ()
1581
+ value = _string_value(error_logs.get("runErrorsLogPath"))
1582
+ if not value:
1583
+ return ()
1584
+ return (_resolve_project_path(plan.project_root, value),)
1585
+
1586
+
1537
1587
 
1538
1588
 
1539
1589
  def _job_for_attempt(job: WorkerJob, attempt: int) -> WorkerJob:
@@ -1884,6 +1934,35 @@ def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
1884
1934
  )
1885
1935
 
1886
1936
 
1937
+ def _abandon_unstarted_attempt(
1938
+ plan: DispatchPlan, job: WorkerJob, attempt: int
1939
+ ) -> None:
1940
+ """Close an attempt whose worker never started, so a retry can follow it.
1941
+
1942
+ `failed-no-mutation` is the truthful status: the dispatch died before the
1943
+ worker process existed, so nothing wrote anything. It is also the only
1944
+ terminal status the manifest lets another attempt follow.
1945
+ """
1946
+ if not job.has_execution_identity:
1947
+ return
1948
+ try:
1949
+ finish_attempt_mutation(
1950
+ plan.manifest_path,
1951
+ invocation_ref=job.invocation_ref,
1952
+ attempt=attempt,
1953
+ finished_at=_utc_now(),
1954
+ status="failed-no-mutation",
1955
+ result_path=None,
1956
+ error_path=None,
1957
+ change_summary={},
1958
+ task_key=_require_string(plan.manifest, "taskKey"),
1959
+ )
1960
+ except (ExecutionManifestError, DispatchError, OSError):
1961
+ # The original dispatch failure is what the caller needs to see; a
1962
+ # manifest that cannot be closed here is reported by the next read.
1963
+ return
1964
+
1965
+
1887
1966
  def _finish_manifest_attempt(
1888
1967
  plan: DispatchPlan,
1889
1968
  job: WorkerJob,
@@ -31,6 +31,8 @@ from typing import Any, Callable, Mapping, Sequence
31
31
  from . import cmux
32
32
  from .agent_invocation import (
33
33
  AgentInvocationError,
34
+ AgentModelAssignment,
35
+ InvocationMetadataIdentity,
34
36
  agent_model_assignment_from_payload,
35
37
  invocation_metadata_identity,
36
38
  v2_role_assignment_authority_errors,
@@ -104,6 +106,14 @@ WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
104
106
  WORKER_STATUSES = frozenset(
105
107
  {"in-progress", "completed", "timeout", "error", "not-run"}
106
108
  )
109
+ # Which of those mean the dispatch is still expected to produce something. Two
110
+ # readers key off this split — `team reclaim` closes a finished dispatch's pane
111
+ # and must never touch a live one, and the compact-reminder hook calls a run
112
+ # in-flight when any dispatch is still here. Both restated the terminal four
113
+ # locally before, so a sixth status would have read as finished in one place and
114
+ # as live in the other.
115
+ NON_TERMINAL_WORKER_STATUSES = frozenset({"in-progress"})
116
+ TERMINAL_WORKER_STATUSES = WORKER_STATUSES - NON_TERMINAL_WORKER_STATUSES
107
117
  REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
108
118
 
109
119
 
@@ -564,6 +574,9 @@ def record_verified_agent_dispatch(
564
574
  if (
565
575
  enforcement_mode == "host-native-spec-link-gate"
566
576
  and assignment.runner != "native-session"
577
+ and not _is_current_session_lead(
578
+ run_manifest_path, execution_identity, assignment
579
+ )
567
580
  ):
568
581
  raise DispatchError(
569
582
  "host-native enforcement requires a native-session assignment"
@@ -718,6 +731,50 @@ def record_verified_agent_dispatch(
718
731
  return record
719
732
 
720
733
 
734
+ def _is_current_session_lead(
735
+ manifest_path: Path,
736
+ execution_identity: InvocationMetadataIdentity | None,
737
+ assignment: AgentModelAssignment,
738
+ ) -> bool:
739
+ """Report whether this dispatch is the lead attesting its own session.
740
+
741
+ A current-session lead has no model binding, so the run manifest projects
742
+ its assignment as ``cli-wrapper`` while the execution manifest records the
743
+ participant as ``current-session``. It runs through no dispatch boundary at
744
+ all: the spec link is the lead associating the already-running session with
745
+ its verified invocation specification, which is what the host-native gate
746
+ records. Keyed on the execution manifest's own participant row rather than
747
+ on the projected runner string, so a genuine cli-wrapper worker never
748
+ reaches the native gate.
749
+ """
750
+ if execution_identity is None or assignment.runner == "native-session":
751
+ return False
752
+ manifest = read_execution_manifest(manifest_path)
753
+ if manifest.legacy:
754
+ return False
755
+ participant = next(
756
+ (
757
+ row for row in manifest.participant_assignments
758
+ if row.participant_ref == execution_identity.participant_ref
759
+ ),
760
+ None,
761
+ )
762
+ execution = next(
763
+ (
764
+ row for row in manifest.role_executions
765
+ if row.role_execution_ref == execution_identity.role_execution_ref
766
+ ),
767
+ None,
768
+ )
769
+ return (
770
+ participant is not None
771
+ and execution is not None
772
+ and execution.role == "leader"
773
+ and participant.runner == "current-session"
774
+ and participant.entry_mode == "current-session"
775
+ )
776
+
777
+
721
778
  def _agent_write_contract(
722
779
  project_root: Path,
723
780
  manifest_path: Path,
@@ -158,8 +158,17 @@ def _catalog_row(model, adapter, role: str | None) -> dict[str, Any]:
158
158
  )
159
159
  )
160
160
  except HostModelBindingError as exc:
161
- row["selectable"] = False
162
- row["reason"] = str(exc)
161
+ # Not "unusable as a lead": the host cannot bind this model to a native
162
+ # session, so the lead runs through the provider CLI instead. Reporting
163
+ # it as unselectable made the listing disagree with what assignment
164
+ # resolution actually does — it accepts the model and falls back to
165
+ # `cli-wrapper` — leaving no way to find out why a lead was not native.
166
+ row["leaderRunner"] = "cli-wrapper"
167
+ row["reason"] = (
168
+ f"native-session unavailable ({exc}); the lead runs via cli-wrapper"
169
+ )
170
+ else:
171
+ row["leaderRunner"] = "native-session"
163
172
  return row
164
173
 
165
174
 
@@ -51,6 +51,18 @@ def _dispatch_effort(execution: str, role: str) -> str:
51
51
  return effort
52
52
 
53
53
 
54
+ def dispatch_tier_suffixes() -> frozenset[str]:
55
+ """Every tier suffix dispatch can append to an agy execution value.
56
+
57
+ The provider serves back the suffixed identity it was given, so reading
58
+ that identity means undoing exactly this set — not guessing at whatever
59
+ trails the last hyphen. `low` is here because `_dispatch_effort` demotes
60
+ the untrusted high tier to it.
61
+ """
62
+ efforts = {value.lower() for value in (*ROLE_EFFORT.values(), _DEFAULT_EFFORT)}
63
+ return frozenset(efforts | {"low"})
64
+
65
+
54
66
  @lru_cache(maxsize=1)
55
67
  def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
56
68
  """Live `agy models` ids, or () when agy is unavailable (non-blocking).
@@ -1,12 +1,15 @@
1
- """활성 run 스코프 조회 — pane 정리 의무를 어느 run 에 걸지 고르는 데 쓴다.
1
+ """진행 중 run 조회 — pane 회수 의무를 어느 run 에 걸지 고르는 데 쓴다.
2
2
 
3
3
  호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
4
- 직후 리드에게 "이 run 의 완료 teammate pane 을 라운드 경계마다 회수하라"는 의무를
4
+ 직후 리드에게 "이 run 의 끝난 워커 pane 을 라운드 경계마다 회수하라"는 의무를
5
5
  재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
6
6
 
7
- 완료 판정(`is_completed_status`)과 프로젝트 무관 전체 조회(`active_run_dirs`)도
8
- 여기 있었으나, 그것을 쓰던 `okstra-trace-cleanup.sh --reclaim-completed` 와
9
- `okstra-subagent-reclaim.sh` 가 제거되면서 함께 사라졌다.
7
+ 정본은 run 디렉터리다(ADR-0011). 신호는 그 run 의 가장 최근 team-state 에
8
+ 비종결 배치가 남아 있는지다. 이전 구현은 `~/.okstra/active.jsonl` 을 읽었는데,
9
+ 그 원장은 `initial_status="running"` 으로 기록된 run 만 담고 in-session 경로는
10
+ `--render-only` 강제로 항상 `prepared` 가 되어 종결로 라우팅된다 — 실측에서 그
11
+ 파일은 0행이었고 이 훅은 한 번도 발화하지 않았다. `state/lead-pane.id` 도 신호가
12
+ 못 된다: 한 번 기록된 뒤 지워지지 않아 끝난 run 을 영구히 진행 중으로 읽는다.
10
13
  """
11
14
  from __future__ import annotations
12
15
 
@@ -14,54 +17,57 @@ import json
14
17
  import sys
15
18
  from pathlib import Path
16
19
 
17
- from .paths import resolve_under_root
18
- from .reconcile import NON_TERMINAL_RECENT_STATUSES
20
+ from .dispatch_state import NON_TERMINAL_WORKER_STATUSES
19
21
 
20
22
 
21
- def _iter_active_run_dirs(home: Path):
22
- """active.jsonl 의 진행 중 run 마다 (projectRoot_str, 절대 run_dir) 를 yield.
23
- runDirRel 의 base 는 projectRoot(paths.compute_run_paths 의 _rel 기준)."""
24
- path = home / "active.jsonl"
25
- if not path.is_file():
26
- return
27
- for line in path.read_text(encoding="utf-8").splitlines():
28
- line = line.strip()
29
- if not line:
30
- continue
31
- try:
32
- row = json.loads(line)
33
- except json.JSONDecodeError:
34
- continue
35
- # 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
36
- # SSOT. reserving 은 아직 run-dir 산출물이 없어 이 집합에 들어있지
37
- # 않으므로 자연히 제외된다(allowlist).
38
- if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
39
- continue
40
- run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
41
- if run_dir is not None:
42
- yield row.get("projectRoot"), run_dir
23
+ def _newest_team_state(state_dir: Path) -> Path | None:
24
+ """한 run 디렉터리의 최신 team-state.
25
+
26
+ seq 는 3자리 zero-pad 라 이름 정렬이 곧 순서다. run 하나에 seq 가 누적되고
27
+ 이전 라운드의 `in-progress` 행이 그 파일에 남으므로, 최신이 아닌 파일을 읽으면
28
+ 몇 시간 전에 끝난 run 을 진행 중으로 보고한다.
29
+ """
30
+ files = sorted(state_dir.glob("team-state-*.json"))
31
+ return files[-1] if files else None
43
32
 
44
33
 
45
- def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
46
- """진행 중 run 중 projectRoot 이 cwd(또는 그 상위)인 것의 절대 run-dir 목록.
47
- SessionStart(compact) 훅이 이 프로젝트의 run 에만 리마인더를 걸도록 스코프한다."""
48
- cwd_r = Path(cwd).resolve()
34
+ def _has_live_dispatch(team_state_path: Path) -> bool:
35
+ try:
36
+ payload = json.loads(team_state_path.read_text(encoding="utf-8"))
37
+ except (OSError, json.JSONDecodeError):
38
+ return False
39
+ if not isinstance(payload, dict):
40
+ return False
41
+ return any(
42
+ isinstance(record, dict)
43
+ and record.get("status") in NON_TERMINAL_WORKER_STATUSES
44
+ for record in payload.get("workerDispatches", [])
45
+ )
46
+
47
+
48
+ def in_flight_run_dirs(project_root: Path) -> list[Path]:
49
+ """비종결 배치를 아직 들고 있는 이 프로젝트의 run 디렉터리 목록.
50
+
51
+ `runs/<task-type>/` 와 stage 격리 run 의 `runs/<task-type>/stage-<N>/` 을 모두
52
+ 훑는다. 후자를 빼면 `implementation` 과 단일 stage `final-verification` 이
53
+ 통째로 누락된다.
54
+ """
55
+ tasks_root = Path(project_root) / ".okstra" / "tasks"
56
+ if not tasks_root.is_dir():
57
+ return []
49
58
  out: list[Path] = []
50
- for project_root, run_dir in _iter_active_run_dirs(home):
51
- if not project_root:
52
- continue
53
- try:
54
- project_root_r = Path(project_root).resolve()
55
- except OSError:
59
+ for state_dir in sorted(tasks_root.glob("*/*/runs/**/state")):
60
+ if not state_dir.is_dir():
56
61
  continue
57
- if cwd_r == project_root_r or cwd_r.is_relative_to(project_root_r):
58
- out.append(run_dir)
62
+ newest = _newest_team_state(state_dir)
63
+ if newest is not None and _has_live_dispatch(newest):
64
+ out.append(state_dir.parent)
59
65
  return out
60
66
 
61
67
 
62
68
  def main(argv: list[str]) -> int:
63
- if len(argv) == 3 and argv[0] == "--active-dirs-for":
64
- for run_dir in active_run_dirs_for_project(Path(argv[1]), Path(argv[2])):
69
+ if len(argv) == 2 and argv[0] == "--in-flight-run-dirs-for":
70
+ for run_dir in in_flight_run_dirs(Path(argv[1])):
65
71
  print(run_dir)
66
72
  return 0
67
73
  return 1
@@ -735,6 +735,15 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
735
735
  catalog = _worker_catalog(ctx)
736
736
  worker_dispatch_plan = _worker_dispatch_plan(ctx)
737
737
  workers = []
738
+ for row in _optional_worker_roles(ctx):
739
+ workers.append({
740
+ **{key: row[key] for key in (
741
+ "workerId", "role", "agent", "provider", "runner",
742
+ "model", "modelExecutionValue", "resultPath", "promptPath",
743
+ )},
744
+ "status": "not-run",
745
+ "reason": "",
746
+ })
738
747
  for w in selected:
739
748
  m = catalog[w]
740
749
  workers.append(
@@ -1133,6 +1142,44 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
1133
1142
  ]
1134
1143
 
1135
1144
 
1145
+ def _optional_worker_roles(ctx: dict) -> list[dict]:
1146
+ """Roles this run may dispatch but does not require — today, the critics.
1147
+
1148
+ A critic is opt-in, so it never belonged in `requiredWorkerRoles`. But it
1149
+ was absent from the roster entirely, and both `okstra team dispatch` and
1150
+ the liveness reader locate a worker by its team-state row: dispatching a
1151
+ critic failed with `team-state has no workerId=acceptance`, while adding
1152
+ the row by hand failed validation as an `unexpected worker role`. Declaring
1153
+ it here makes the roster say what the run may run, so both sides agree.
1154
+
1155
+ Keyed off `invocationAssignments`, which is where the run records the
1156
+ critic it actually resolved — an absent `critic/*` entry means no critic.
1157
+ """
1158
+ assignments = _invocation_assignments(ctx)
1159
+ roles: list[dict] = []
1160
+ for assignment_ref, assignment in sorted(assignments.items()):
1161
+ if not assignment_ref.startswith("critic/"):
1162
+ continue
1163
+ provider = str(assignment.get("provider", ""))
1164
+ roles.append({
1165
+ # `okstra team dispatch` projects a v2 worker's state key off the
1166
+ # assignment ref's last segment; the row it looks up must use it.
1167
+ "workerId": assignment_ref.rsplit("/", 1)[-1],
1168
+ "role": f"{provider_spec(provider).display_label} critic",
1169
+ "agent": provider,
1170
+ "provider": provider,
1171
+ "runner": str(assignment.get("runner", "")),
1172
+ "model": str(assignment.get("model", "")),
1173
+ "modelExecutionValue": str(assignment.get("modelExecutionValue", "")),
1174
+ # The lead names these when it materializes the invocation; a
1175
+ # critic has no prompt or result until it is dispatched.
1176
+ "resultPath": "",
1177
+ "promptPath": "",
1178
+ "attemptRequired": False,
1179
+ })
1180
+ return roles
1181
+
1182
+
1136
1183
  def _reporter_confirmation_status(brief_bytes: bytes) -> str:
1137
1184
  try:
1138
1185
  lines = brief_bytes.decode("utf-8").splitlines()
@@ -1448,6 +1495,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1448
1495
  "minimumPreferredWorkerResults": len(reviewers),
1449
1496
  "requiredWorkerAttempts": reviewers,
1450
1497
  "requiredWorkerRoles": required_worker_roles,
1498
+ "optionalWorkerRoles": _optional_worker_roles(ctx),
1451
1499
  "requiredAgentStatusEntries": required_agent_status_entries,
1452
1500
  "requireDistinctLeadFromWorkerSession": True,
1453
1501
  "requireAllRequiredWorkerAttempts": True,
@@ -1644,6 +1692,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1644
1692
  "taskId": ctx.get("TASK_ID", ""),
1645
1693
  "taskKey": ctx.get("TASK_KEY", ""),
1646
1694
  "taskType": ctx.get("TASK_TYPE", ""),
1695
+ # Every other path in this manifest is project-relative; this is the
1696
+ # anchor they resolve against. Convergence reads it to place run
1697
+ # artifacts, so a manifest without it cannot seed a round.
1698
+ "projectRoot": ctx.get("PROJECT_ROOT", ""),
1647
1699
  "hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
1648
1700
  "leadRuntime": _lead_runtime(ctx),
1649
1701
  "leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
@@ -1765,6 +1817,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1765
1817
  "finalSynthesisOwner": lead_role,
1766
1818
  "requiredWorkerAttempts": reviewers,
1767
1819
  "requiredWorkerRoles": required_worker_roles,
1820
+ "optionalWorkerRoles": _optional_worker_roles(ctx),
1768
1821
  "requiredAgentStatusEntries": [lead_role]
1769
1822
  + [catalog[item]["role"] for item in reviewers],
1770
1823
  "requireDistinctLeadFromWorkerSession": True,
@@ -109,17 +109,21 @@ def _html_path(data_path: Path) -> Path:
109
109
 
110
110
  def render_v2_html_view(
111
111
  data_path: Path,
112
- markdown_path: Path,
113
112
  *,
114
113
  run_meta: HtmlRunMeta,
115
114
  templates_root: Path | None = None,
116
115
  ) -> Path:
116
+ """Render the human HTML from the data.json alone.
117
+
118
+ The AI-handoff markdown sibling is a second rendering of this same record,
119
+ never an input here: it used to be read for a `source-md-sha256` stamp that
120
+ no reader ever compared, which made a derived artifact a precondition for
121
+ another derived artifact.
122
+ """
117
123
  data = json.loads(data_path.read_text(encoding="utf-8"))
118
124
  errors = validate(data, load_schema_for_data(data))
119
125
  if errors:
120
126
  raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
121
- if not markdown_path.is_file():
122
- raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
123
127
  # Validate the SSOT, then localize — the sidecar carries presentation and
124
128
  # has no say in whether the report is well-formed.
125
129
  data, lang = _localize(data, data_path)
@@ -150,7 +154,6 @@ def render_v2_html_view(
150
154
  "taskType": view.task_type,
151
155
  "sourceData": source_data,
152
156
  "dataSha256": _sha256(data_path),
153
- "markdownSha256": _sha256(markdown_path),
154
157
  "clarificationItems": data.get("clarificationItems", []),
155
158
  # Every task type ends with the same run-cost section, so it is bound
156
159
  # here rather than in ten view models that would each rebuild it.