okstra 0.191.0 → 0.191.2

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.
@@ -938,7 +938,7 @@ Tokens used in each run are collected from lead/worker session transcripts and w
938
938
  - Collection sources:
939
939
  - Claude lead/workers: per-message `message.usage` in `~/.claude/projects/<cwd-as-dashes>/<sessionId>.jsonl` or `~/.claude/projects/<cwd-as-dashes>/<lead-session>/subagents/agent-a<worker-name>-<hash>.jsonl`. Worker names are recovered from nested-subagent filenames, and only the directory for the current run's `team-state.lead.sessionId` is counted.
940
940
  - Codex CLI: final `total_token_usage.total_tokens` in `~/.codex/sessions/Y/M/D/rollout-*.jsonl` (`$CODEX_HOME/sessions` first, then `~/.agent/sessions`). An in-session codex lead (`entryMode: current-session`) opened its rollout before the run and keeps it after, so its cumulative snapshot is the whole session; the lead is counted as the sum of `last_token_usage` over the `token_count` events inside the run window (`codex_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_codex_sessions(active_before_start=True)`).
941
- - Grok CLI: the last `usage` snapshot in `~/.grok/sessions/<percent-encoded cwd>/<sessionId>/updates.jsonl`. grok (and kimi) run *inside* the stage worktree, so the directory is encoded from the worktree path, not the project root.
941
+ - Grok CLI: the sum of the `usage` records in `~/.grok/sessions/<percent-encoded cwd>/<sessionId>/updates.jsonl` — one record is one prompt's usage (`numTurns` model calls, input re-sent each call), not a running total, so the last record alone is only the last prompt. grok (and kimi) run *inside* the stage worktree, so the directory is encoded from the worktree path, not the project root. An in-session grok lead is counted over the records inside the run window (`grok_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_grok_sessions(active_before_start=True)`).
942
942
  - Claude workers on a non-Claude host: the wrapper runs `claude -p --session-id <id>`, and the transcript is `~/.claude/projects/<cwd-as-dashes>/<id>.jsonl`, found by the id `workerDispatches[].sessionId` recorded at dispatch.
943
943
  - **Attribution key is the dispatch ledger, not the roster row.** `workers[].promptPath` names one prompt — the first dispatch — and v2 lead events carry no `workerId`. Every re-dispatch (reverify, critic-gap, plan-verify, report-writer re-author) is a new wrapper run with its own prompt, `.status.json`, and transcript, and only `team-state.workerDispatches[]` lists them all (`dispatch_state.worker_dispatch_records`, keyed by `workerId` or the last segment of `assignmentRef`). The collector takes one wrapper window per record, unions the transcripts found under each record's `worktreePath` and the project root, and sums them; wall-clock is the sum of the wrapper windows. Observed before this rule (2026-09-08, a codex-host planning run): codex counted 1 of 5 sessions, grok 0 of 1, claude 0 of 7.
944
944
  - Antigravity CLI: the `usage` snapshot in the wrapper `<prompt>.status.json` — the runner records the last `usage` the `agy` stream reported (`result.usage` on a run that closed). The CLI writes no transcript under the home directory, and the worker `.log` is the stream rewritten as readable lines, not stream-json.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.191.0",
3
+ "version": "0.191.2",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.191.0",
3
- "builtAt": "2026-09-08T21:03:17.927Z",
2
+ "package": "0.191.2",
3
+ "builtAt": "2026-09-09T06:51:26.651Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  import argparse
26
26
  import hashlib
27
27
  import json
28
+ import re
28
29
  import os
29
30
  import sys
30
31
  from pathlib import Path
@@ -199,17 +200,31 @@ def _translation_block(rows: list[str]) -> str:
199
200
  return "\n".join(rows).strip().replace("\\`", "`")
200
201
 
201
202
 
203
+ _T_HEADING_RE = re.compile(r"^#{1,6}\s+T-\d{3}\b")
204
+
205
+
202
206
  def _translation_blocks(path: Path) -> list[str]:
203
207
  text = path.read_text(encoding="utf-8")
204
208
  blocks: list[str] = []
205
209
  current: list[str] | None = None
206
- for row in text.splitlines():
210
+ for number, row in enumerate(text.splitlines(), start=1):
211
+ # 헤딩 수준이 다른 `### T-NNN` 은 블록 경계로 안 보여 본문에 묻히고, 그러면
212
+ # 블록 수 불일치라는 엉뚱한 메시지가 났다(실측 2026-09-08: id 164개 전부
213
+ # 일치, 틀린 것은 `#` 개수뿐). 경계처럼 생긴 줄은 여기서 이름을 대고 거절한다.
214
+ if _T_HEADING_RE.match(row) and not row.startswith("## T-"):
215
+ raise SystemExit(
216
+ f"error: translation heading must be level 2 — line {number} is "
217
+ f"{row.split()[0]!r}, expected '## T-NNN' ({path.name})"
218
+ )
207
219
  if row.startswith("## T-"):
208
220
  if current is not None:
209
221
  blocks.append(_translation_block(current))
210
222
  expected = f"## T-{len(blocks) + 1:03d}"
211
223
  if row != expected:
212
- raise SystemExit(f"error: expected translation heading {expected}")
224
+ raise SystemExit(
225
+ f"error: expected translation heading {expected} at line {number}, "
226
+ f"found {row!r} ({path.name})"
227
+ )
213
228
  current = []
214
229
  elif current is not None:
215
230
  current.append(row)
@@ -232,8 +247,18 @@ def cmd_write(args: argparse.Namespace) -> int:
232
247
  _validate_source_payload(source, expected, args.source_digest)
233
248
  sources = expected["strings"]
234
249
  translated = _translation_blocks(Path(args.translations))
235
- if len(translated) != len(sources) or any(not value for value in translated):
236
- raise SystemExit("error: translation blocks must match every T-NNN item")
250
+ if len(translated) != len(sources):
251
+ raise SystemExit(
252
+ f"error: translation blocks must match every T-NNN item — "
253
+ f"{len(translated)} blocks in {Path(args.translations).name}, "
254
+ f"{len(sources)} items in the translation source"
255
+ )
256
+ empty = [f"T-{index + 1:03d}" for index, value in enumerate(translated) if not value]
257
+ if empty:
258
+ raise SystemExit(
259
+ "error: translation blocks must match every T-NNN item — empty: "
260
+ + ", ".join(empty[:10])
261
+ )
237
262
  lang = expected["lang"]
238
263
  sidecar = translation_sidecar_path(data_path, lang)
239
264
  if sidecar.is_symlink():
@@ -89,7 +89,7 @@ Read the worker result files generated in Phase 4/5 and extract individual findi
89
89
  - Same semantics but disjoint ticket sets → separate groups (do NOT over-merge across tickets).
90
90
  - Only one worker confirms a finding → one single-source group.
91
91
  4. When grouping is ambiguous, prefer splitting over merging (avoid over-merging). Semantic matching, ticket-set equality, and evidence interpretation remain lead judgments; the engine does not perform fuzzy matching or decide whether evidence is credible.
92
- 5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. Never infer live evidence or functional scope from wording, provider, model, or execution label.
92
+ 5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. For `implementation` runs the convergence sources are the verifier-role results only — the executor's result is deliverable evidence, not a convergence source (**Enforced:** `_validate_worker_execution_identity` in `scripts/okstra_ctl/convergence_engine.py` rejects an `implementer` source with `analysis audience source role is not allowed`). Never infer live evidence or functional scope from wording, provider, model, or execution label.
93
93
 
94
94
  The command sets each worker's paired `participantRef` and `sourceRoleExecutionRef` from the run manifest's canonical role state. It sets `sourceRoleExecutionRef` to the selected source `RoleExecution` row's `roleExecutionRef`, not that row's `sourceRoleExecutionRef` field.
95
95
  6. Do not write a queue or classification in this grouped-input artifact. `okstra convergence seed` classifies Round 0 the same way in both modes: a group whose sources are **two or more distinct role executions** becomes `full-consensus` immediately, and only single-source groups enter the working queue. Independent co-derivation is already cross-verification — the adversarial burden of proof targets single-source claims, not a finding two roles reached on their own. A source is counted once per analysis worker, and one analysis worker is exactly one `sourceRoleExecutionRef` — the same identity the reverify roster uses for independence — so two roles held by one provider count as two and no role can count twice. **Enforced:** `_parse_workers` rejects a duplicate `workerId` and `_validate_worker_execution_identity` rejects a duplicate `sourceRoleExecutionRef`, both in `scripts/okstra_ctl/convergence_engine.py`. Semantic grouping merges provenance only; it does not decide a single-source finding is reliable. Section 6 never enters the grouped input.
@@ -146,7 +146,7 @@ Do NOT replace them with prose ("Now I'm starting Phase 2..."), do NOT skip a ch
146
146
 
147
147
  `okstra-run` surfaces these lines to the user directly; `okstra lead-progress append` persists them in the selected adapter's declared conformance evidence/event source for post-hoc retrieval.
148
148
 
149
- **Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds. For `implementation`, `_check_progress_checkpoints` additionally requires the `phase-5-stage` announcement once any worker is dispatched, and the `phase-5-stage-complete` line once an implementer worker completed. For activity-contract-v1 planning, `_check_activity_contract` validates the structured worker pairs, verification and self-fix counts, user-decision references, and `A-NNN` ordering. `phase-7-teardown` and `complete` fire after validation and are not checked.
149
+ **Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds. For `implementation`, `_check_progress_checkpoints` additionally requires the `phase-5-stage` announcement once any worker is dispatched, and the `phase-5-stage-complete` line once an implementer worker completed **and** this stage's carry sidecar exists (`_stage_carry_persisted`) — a stage whose carry was withheld (`FAIL` / non-result) is not asked for the line. For activity-contract-v1 planning, `_check_activity_contract` validates the structured worker pairs, verification and self-fix counts, user-decision references, and `A-NNN` ordering. `phase-7-teardown` and `complete` fire after validation and are not checked.
150
150
 
151
151
  ## Asking the user (BLOCKING)
152
152
 
@@ -89,7 +89,7 @@ This section adds report-specific checks to [okstra-lead-contract](./okstra-lead
89
89
  4. When the check reports `mechanical: true` (every entry is a `replace` or `remove`), run `okstra agent-prompt apply-corrections` with the same arguments: okstra writes the corrected narrative to `reportNarrativePath` and records a `lead-correction-applied` activity row naming the ledger and its correction ids. No writer dispatch, `record-dispatch`, or `link-result` follows; the roster row's result already exists.
90
90
  5. Otherwise materialize the writer prompt with the same `--corrections <ledger>` under a new invocation id and prompt path (retire the first attempt's link with `reject-result` as [plan-body-verification](./plan-body-verification.md) describes). okstra renders `## Corrections` (each entry with its label path, current value, replacement or rule, schema constraint, and reason) and `## Output`; the instruction body carries only context.
91
91
 
92
- A report-writer materialization without `--corrections` whose narrative already exists and parses is refused before any prompt is written — free-form corrections cannot be checked before the writer runs, and four of six re-runs in the 2026-09-03 measurement were lead instructions that contradicted the authoring contract. Only a narrative whose structure does not parse (line grammar, an unknown top-level field) is re-authored, not corrected: that dispatch needs no ledger, and its body quotes the parser's message. Value defects — an id outside its pattern, a value outside its enum, a missing required field — leave the structure readable and are exactly what the ledger fixes; the a3 attempt of the 2026-09-03 run carried twenty `SC-` ids that assembly refused and was still a corrective base.
92
+ A report-writer materialization without `--corrections` whose narrative already exists and parses is refused before any prompt is written — free-form corrections cannot be checked before the writer runs, and four of six re-runs in the 2026-09-03 measurement were lead instructions that contradicted the authoring contract. Only a narrative whose structure does not parse (line grammar, an unknown top-level field) is re-authored, not corrected: that dispatch needs no ledger, and its body quotes the parser's message. A narrative that breaks the line grammar is not a produced artifact: the dispatcher settles that attempt as `required worker artifact is unusable: narrative does not parse: …` and retries it inside the same batch, so you see the parser's message at collection, not at Phase 7 assembly (**Enforced:** `okstra_ctl.dispatch_state.unusable_result_defect`, read by `missing_completion_paths` and the `team await` record path). The synthesis packet's Authoring Contract carries the line grammar itself (`report_narrative.NARRATIVE_GRAMMAR_INSTRUCTIONS`), so a writer that reads only the packet still sees it. Value defects — an id outside its pattern, a value outside its enum, a missing required field — leave the structure readable and are exactly what the ledger fixes; the a3 attempt of the 2026-09-03 run carried twenty `SC-` ids that assembly refused and was still a corrective base.
93
93
 
94
94
  **Enforced:** `_with_report_writer_sections` / `_refuse_free_form_correction` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`, `report_corrections.check_corrections`, `agent/prompt_cli/corrections.run_corrections_apply`; `tests/run/test_agent_prompt_corrections.py` and `tests/contract/test_report_writer_v3_contract.py` keep this procedure in the lead contract.
95
95
 
@@ -133,6 +133,8 @@ For `AskUserQuestion`, map each wizard option to the tool's `{label, description
133
133
 
134
134
  For a `host-text` mapping, render each numbered item as its option label followed by its description verbatim; preserve every item and its order. The next user message is the raw answer. Do not translate numbers, CSV members, labels, or values before `okstra wizard step`. A sequential group wraps each raw reply in one compact JSON object keyed by `questions[].step`; the wizard owns normalization.
135
135
 
136
+ The `confirm` prompt's `label` is the selection summary (one line per resolved input, then the question). Pass it verbatim as the `AskUserQuestion` question text — every line, including `(none)` values — with its three options. Do not replace the summary with a table or a prose digest of your own: a line you drop is a setting the user never saw.
137
+
136
138
 
137
139
  ## Semantic operation mapping
138
140
 
@@ -115,6 +115,10 @@ For `request_user_input`, send one to three questions. Each question carries `id
115
115
  For a `host-text` mapping, render each numbered item as its option label followed by its description verbatim; preserve every item and its order. The next user message is the raw answer: do not translate a number such as `1`, a CSV reply such as `1, 3`, an option label, or an option value before `okstra wizard step`. For `sequential-group`, collect one raw reply per question in order and build one compact JSON object keyed by the corresponding `questions[].step`; the wizard owns all normalization.
116
116
 
117
117
 
118
+ ### Confirm step
119
+
120
+ The `confirm` prompt's `label` is the selection summary (one line per resolved input, then the question). Send it verbatim as that question's text — every line, including `(none)` values — and offer its three options. Do not replace the summary with a table or a prose digest of your own; the user is confirming exactly what the wizard resolved, and a line you drop is a setting the user never saw.
121
+
118
122
  ### Runtime-generated selectable screens
119
123
 
120
124
  `wizard/engine.py` adapts choice screens before returning `next` when the session declares `native_single_select`. `wizard/picker_navigation.py` preserves all original choices while paging long lists, collecting multi-selection through toggle/complete choices, and disambiguating duplicate labels. Oversized or unsupported groups are presented one member at a time. These paths are exercised by `tests/domain/wizard/test_picker_navigation.py` and `test_role_model_selection.py`.
@@ -2420,9 +2420,15 @@ def _validate_worker_execution_identity(
2420
2420
  )
2421
2421
  allowed_roles = _AUDIENCE_SOURCE_ROLES[worker["audience"]]
2422
2422
  if source.role not in allowed_roles:
2423
+ hint = ""
2424
+ if source.role == "implementer":
2425
+ hint = (
2426
+ " — the executor's result is deliverable evidence, not a "
2427
+ "convergence source; group the verifier results instead"
2428
+ )
2423
2429
  raise ConvergenceContractError(
2424
2430
  f"{worker['audience']} audience source role is not allowed: "
2425
- f"{source.role}"
2431
+ f"{source.role} (allowed: {', '.join(sorted(allowed_roles))}){hint}"
2426
2432
  )
2427
2433
  source_refs = [worker["sourceRoleExecutionRef"] for worker in workers]
2428
2434
  if len(source_refs) != len(set(source_refs)):
@@ -31,6 +31,8 @@ from .dispatch_state import (
31
31
  load_json_object as _load_json_object,
32
32
  link_agent_dispatch_result as _link_agent_dispatch_result,
33
33
  missing_completion_paths as _missing_completion_paths,
34
+ unusable_result_defect,
35
+ _dispatch_worker_key,
34
36
  _plan_verify_result_aliases,
35
37
  mutate_team_state as _mutate_team_state,
36
38
  require_string as _require_string,
@@ -276,6 +278,9 @@ class WorkerOutcome:
276
278
  timeout: bool
277
279
  terminal_stage: str
278
280
  degraded_from: str
281
+ # 파일은 있는데 읽을 수 없는 산출물의 사유 — `missing_completion_paths` 에
282
+ # 그 경로가 함께 들어가고, 실패 사유는 이 문장을 인용한다.
283
+ artifact_defects: tuple[str, ...] = ()
279
284
 
280
285
 
281
286
  @dataclass(frozen=True)
@@ -631,7 +636,17 @@ def _dispatch_cli_wrapper_batch(
631
636
  contract is not None and contract[1].mutation_audit == "batch"
632
637
  for contract in contracts
633
638
  )
634
- if needs_batch and baseline_snapshot is None:
639
+ # 재시도 라운드는 baseline 다시 찍는다. 쓰기 계약은 attempt 소유라
640
+ # (`_persisted_write_contract`) attempt 2 의 정책 digest 는 attempt 1 과
641
+ # 다른데, 첫 라운드의 baseline 을 그대로 쓰면 마감의
642
+ # `_validate_snapshot_authority` 가 `policyDigests` 불일치로 죽고 attempt-N
643
+ # 사이드카는 끝내 안 써진다(실측 2026-09-08, translator attempt 2:
644
+ # 산출물 완전·exit 0 인데 dispatcher 예외). 앞 라운드에서 완주한 동료의
645
+ # 변경은 그 attempt 의 감사가 이미 판정했으므로 새 baseline 에 흡수돼도
646
+ # 맞다 — 재시도는 `failed-no-mutation` 뒤에만 오므로(`retry_allowed`)
647
+ # 미해결 변경이 baseline 으로 세탁되지는 않는다.
648
+ retry_round = any(attempt > 1 for _job, attempt in prepared)
649
+ if needs_batch and (baseline_snapshot is None or retry_round):
635
650
  baseline_snapshot = _mutation_snapshot(
636
651
  plan, policies, tuple(job for job, _ in prepared)
637
652
  )
@@ -1943,9 +1958,15 @@ def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
1943
1958
  timeout=False,
1944
1959
  terminal_stage="exited",
1945
1960
  degraded_from=handle.degraded_from,
1961
+ artifact_defects=_artifact_defects(handle.job),
1946
1962
  )
1947
1963
 
1948
1964
 
1965
+ def _artifact_defects(job: WorkerJob) -> tuple[str, ...]:
1966
+ defect = unusable_result_defect(job.worker_id, job.result_path)
1967
+ return (defect,) if defect else ()
1968
+
1969
+
1949
1970
  def _correct_teardown_marked_dispatches(plan: DispatchPlan) -> None:
1950
1971
  """Let the wrapper's own exit settle a record teardown wrote off.
1951
1972
 
@@ -3113,6 +3134,7 @@ def _outcome_from_status(record: Mapping[str, Any], status) -> WorkerOutcome:
3113
3134
  timeout=status.timeout,
3114
3135
  terminal_stage=status.stage,
3115
3136
  degraded_from=_string_value(record.get("degradedFrom")),
3137
+ artifact_defects=_record_artifact_defects(record),
3116
3138
  )
3117
3139
 
3118
3140
 
@@ -3180,8 +3202,11 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
3180
3202
  result_path = Path(_string_value(record.get("resultPath")))
3181
3203
  worker_result = Path(_string_value(record.get("workerResultPath")))
3182
3204
  missing: list[Path] = []
3205
+ worker_id = _dispatch_worker_key(record)
3183
3206
  for path in _record_completion_paths(record):
3184
3207
  if path.is_file():
3208
+ if path == result_path and unusable_result_defect(worker_id, path):
3209
+ missing.append(path)
3185
3210
  continue
3186
3211
  if path in {result_path, worker_result} and any(alias.is_file() for alias in aliases):
3187
3212
  continue
@@ -3189,6 +3214,13 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
3189
3214
  return tuple(missing)
3190
3215
 
3191
3216
 
3217
+ def _record_artifact_defects(record: Mapping[str, Any]) -> tuple[str, ...]:
3218
+ defect = unusable_result_defect(
3219
+ _dispatch_worker_key(record), Path(_string_value(record.get("resultPath")))
3220
+ )
3221
+ return (defect,) if defect else ()
3222
+
3223
+
3192
3224
  def _should_retry(outcome: WorkerOutcome, attempt: int) -> bool:
3193
3225
  """결과가 없으면 재시도한다 — 기준은 종료 코드가 아니라 산출물이다.
3194
3226
 
@@ -3235,6 +3267,8 @@ def _failure_reason(outcome: WorkerOutcome) -> str:
3235
3267
  return "CLI ended cleanly without emitting a result event"
3236
3268
  if outcome.returncode != 0:
3237
3269
  return f"wrapper exited with code {outcome.returncode}"
3270
+ if outcome.artifact_defects:
3271
+ return "required worker artifact is unusable: " + "; ".join(outcome.artifact_defects)
3238
3272
  if outcome.missing_completion_paths:
3239
3273
  missing = ", ".join(str(path) for path in outcome.missing_completion_paths)
3240
3274
  return f"required worker artifact was not produced: {missing}"
@@ -53,6 +53,7 @@ from .execution_manifest import (
53
53
  from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
54
54
  from .final_report_paths import final_report_data_path
55
55
  from .report_inputs import report_narrative_path, uses_report_contract_v3
56
+ from .report_narrative import narrative_structure_defect
56
57
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
57
58
  from .worker_prompt_contract import (
58
59
  PromptRecord,
@@ -1636,10 +1637,33 @@ def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
1636
1637
  return BACKEND_MIXED
1637
1638
 
1638
1639
 
1640
+ def unusable_result_defect(worker_id: str, result_path: Path) -> str | None:
1641
+ """산출물이 있어도 소비자가 읽을 수 없으면 없는 것이다 — 지금은 서사 한 종류.
1642
+
1643
+ report-writer 의 서사가 줄 문법을 어기면(frontmatter·헤딩으로 된 보통
1644
+ 보고서) 조립이 Phase 7 에서 거절하고, 그때는 배치의 재시도가 이미 지나
1645
+ 리드가 손으로 재저작을 띄워야 한다 — 실측(2026-09-09, jobs implementation
1646
+ stage-2)에서 리드는 그것을 하지 않고 run 을 닫았다. 수집 시점에 "없는
1647
+ 산출물" 로 세면 `_should_retry` 가 같은 배치 안에서 다시 띄운다.
1648
+ """
1649
+ if worker_id != REPORT_WRITER_WORKER_ID or not result_path.is_file():
1650
+ return None
1651
+ try:
1652
+ text = result_path.read_text(encoding="utf-8")
1653
+ except (OSError, UnicodeDecodeError) as exc:
1654
+ return f"narrative is unreadable: {exc}"
1655
+ defect = narrative_structure_defect(text)
1656
+ if defect is None:
1657
+ return None
1658
+ return f"narrative does not parse: {defect}"
1659
+
1660
+
1639
1661
  def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
1640
1662
  missing: list[Path] = []
1641
1663
  for path in job.completion_paths:
1642
1664
  if path.is_file():
1665
+ if path == job.result_path and unusable_result_defect(job.worker_id, path):
1666
+ missing.append(path)
1643
1667
  continue
1644
1668
  # reports seq 와 workerResults seq 가 갈라지면 워커는 다른 쪽
1645
1669
  # 파일명으로 쓴다. 둘 중 하나가 있으면 산출물은 있는 것이다.
@@ -281,12 +281,25 @@ def _validate_snapshot_authority(
281
281
  policies: tuple[WritePolicy, ...],
282
282
  ) -> None:
283
283
  expected = tuple(policy.digest for policy in policies)
284
- if (
285
- before.root != after.root
286
- or before.artifact_root != after.artifact_root
287
- or before.policy_digests != expected
288
- ):
289
- raise MutationAuditError("mutation snapshot does not match batch policies")
284
+ mismatches: list[str] = []
285
+ if before.root != after.root:
286
+ mismatches.append(f"root {before.root} != {after.root}")
287
+ if before.artifact_root != after.artifact_root:
288
+ mismatches.append(
289
+ f"artifactRoot {before.artifact_root} != {after.artifact_root}"
290
+ )
291
+ if before.policy_digests != expected:
292
+ mismatches.append(
293
+ "policyDigests snapshot=" + ",".join(before.policy_digests)
294
+ + " policies=" + ",".join(expected)
295
+ )
296
+ if mismatches:
297
+ # 어느 비교가 어긋났는지 없는 예외는 원인 특정이 불가능했다(실측
298
+ # 2026-09-08: 배치 안 재시도가 attempt 1 의 스냅샷을 attempt 2 의 계약과
299
+ # 대조해 죽었는데, 메시지는 "does not match batch policies" 뿐이었다).
300
+ raise MutationAuditError(
301
+ "mutation snapshot does not match batch policies: " + "; ".join(mismatches)
302
+ )
290
303
 
291
304
 
292
305
  def _maximum_precision(policy: WritePolicy) -> str:
@@ -29,6 +29,39 @@ class NarrativeContractError(ValueError):
29
29
  """서사 입력이 보고서 작성자 소유권이나 Markdown 문법을 위반했다."""
30
30
 
31
31
 
32
+ # 작성자에게 도달해야 하는 줄 문법 — 합성 패킷의 Authoring Contract 가 이것을
33
+ # 그대로 싣는다. 문법이 preamble 템플릿에만 있던 동안 작성자는 read-scope
34
+ # 규칙대로 패킷만 읽고 frontmatter + 헤딩으로 된 보통 보고서를 냈다(실측
35
+ # 2026-09-09, jobs implementation stage-2: `# OKSTRA Report Narrative` 0회,
36
+ # 조립 거부, 최종 리포트 미발행).
37
+ NARRATIVE_GRAMMAR_INSTRUCTIONS: tuple[str, ...] = (
38
+ f"Narrative line grammar: the file starts with the line `{TITLE}` and then "
39
+ "contains only three line shapes — `- **Humanised Field Name**` (one field; "
40
+ "nest a child by indenting two more spaces), `- Item <N>` (one array entry, "
41
+ "numbered 1..N without gaps), and `> value` (one scalar; repeat the line for a "
42
+ "multi-line value; `> _none_` for null, an empty object, or an empty array). "
43
+ "Blank lines are ignored.",
44
+ "Every other line is rejected — YAML frontmatter (`---` blocks), Markdown "
45
+ "headings (`#`, `##`, `###`), pipe tables at column 0, code fences, bare "
46
+ "paragraphs, JSON. Put such text inside a `> ` value instead. Report assembly "
47
+ "refuses the file otherwise and the run publishes no report.",
48
+ )
49
+
50
+
51
+ def narrative_structure_defect(markdown: str) -> str | None:
52
+ """줄 문법 위반 메시지, 없으면 None — 수집 시점의 산출물 검사용.
53
+
54
+ 값 결함(enum 밖 값 등)은 보지 않는다; 그것은 교정 원장이 고친다. 구조가
55
+ 깨진 파일은 원장이 해소될 자료가 없어 재저작 대상이고, 그것을 산출물이
56
+ 있는 것으로 세면 결함이 Phase 7 조립까지 숨어 있다 재저작 없이 run 이 닫힌다.
57
+ """
58
+ try:
59
+ _parse_tree(markdown)
60
+ except NarrativeContractError as exc:
61
+ return str(exc)
62
+ return None
63
+
64
+
32
65
  class _Node:
33
66
  def __init__(self, kind: str, label: str, level: int) -> None:
34
67
  self.kind = kind
@@ -30,6 +30,7 @@ from .final_report_schema import task_block_rules, verdict_token_rule
30
30
  from .report_contract import TASK_TYPE_DATA_PROPERTY
31
31
  from .report_markdown import humanise
32
32
  from .report_narrative import NarrativeContractError, allowed_top_level_fields
33
+ from .report_narrative import NARRATIVE_GRAMMAR_INSTRUCTIONS
33
34
 
34
35
 
35
36
  @dataclass(frozen=True)
@@ -250,6 +251,7 @@ class ReportSynthesisPacket:
250
251
  "format": "report-narrative-v3.0",
251
252
  "sourcePolicy": "read-only-synthesis-packet",
252
253
  "instructions": [
254
+ *NARRATIVE_GRAMMAR_INSTRUCTIONS,
253
255
  "Write the complete human-readable report narrative.",
254
256
  "Preserve settled source values, identities, dissent, and user responses.",
255
257
  "Do not invent a value when a source is missing or contradictory.",
@@ -285,6 +287,7 @@ class ReportSynthesisPacket:
285
287
  f"- Task type: `{self.task_type}`",
286
288
  f"- Result path: `{self.result_path}`",
287
289
  "- Output format: `report-narrative-v3.0`",
290
+ *(f"- {text}" for text in NARRATIVE_GRAMMAR_INSTRUCTIONS),
288
291
  "- Input policy: read this synthesis packet as the dispatched source set",
289
292
  "- Responsibility: write the complete human-readable narrative while preserving settled values",
290
293
  "- Runtime-owned values: session identifiers, token usage, estimated cost, "
@@ -96,10 +96,20 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
96
96
 
97
97
 
98
98
  def _build_confirm(state: WizardState) -> Prompt:
99
+ """확인 질문의 본문이 곧 선택 요약이다.
100
+
101
+ 요약을 `okstra wizard confirmation` 의 별도 텍스트로만 두면 리드가 그것을
102
+ 자기 표로 다시 쓰면서 줄을 빠뜨린다(실측 2026-09-08, jobs implementation:
103
+ 리드가 "추가 지시·관련 작업·추가 응답 문서는 모두 없음" 한 줄로 뭉개고
104
+ directive·base-ref·brief 줄을 뺀 표를 냈다). 네이티브 질문 카드는 질문
105
+ 텍스트를 반드시 그리므로 요약을 질문 텍스트에 싣는다 — 리드가 빼놓을
106
+ 자리가 없다. `okstra wizard confirmation` 은 텍스트 호스트와 재표시용으로
107
+ 같은 블록을 낸다.
108
+ """
99
109
  t = _p(state.workspace_root, "confirm")
100
110
  return Prompt(
101
111
  step=S_CONFIRM, kind="pick",
102
- label=t["label"],
112
+ label=f"{confirmation_block(state)}\n\n{t['label']}",
103
113
  options=[_opt(k, v) for k, v in t["options"].items()],
104
114
  echo_template=t["echo_template"],
105
115
  )
@@ -328,4 +338,6 @@ def confirmation_block(state: WizardState) -> str:
328
338
  lines.append(f" handoff scope : {scope}")
329
339
  if state.task_type == "release-handoff" and state.pr_template_path:
330
340
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
341
+ if state.fix_cycle:
342
+ lines.append(f" fix-cycle : {state.fix_cycle}")
331
343
  return "\n".join(lines)
@@ -32,6 +32,7 @@ from .grok import (
32
32
  find_grok_sessions,
33
33
  grok_session_is_non_interactive,
34
34
  grok_session_total,
35
+ grok_session_window_total,
35
36
  )
36
37
  from .paths import claude_project_dir, find_session_jsonl, utc_now
37
38
  from .pricing import antigravity_cost_usd, provider_cost_usd
@@ -644,13 +645,15 @@ def _cli_session_totals(
644
645
  *,
645
646
  window: tuple[str, str] | None = None,
646
647
  ) -> list[dict]:
647
- """세션별 합계. `window` 는 codex 리드 전용 — run 보다 먼저 열린 세션을 창으로 자른다."""
648
+ """세션별 합계. `window` 는 리드 전용 — run 보다 먼저 열린 세션을 창으로 자른다."""
648
649
  totals = []
649
650
  for session_path in session_paths:
650
651
  if provider == "codex" and window is not None:
651
652
  total = codex_session_window_total(session_path, *window)
652
653
  elif provider == "codex":
653
654
  total = codex_session_total(session_path)
655
+ elif provider == "grok" and window is not None:
656
+ total = grok_session_window_total(session_path, *window)
654
657
  elif provider == "antigravity":
655
658
  total = (
656
659
  antigravity_status_total(session_path)
@@ -1100,17 +1103,17 @@ def _cli_lead_usage(
1100
1103
  if isinstance(worker, dict)
1101
1104
  for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
1102
1105
  }
1103
- # in-session codex 리드는 run 보다 먼저 열린 세션이다 — 창 안에서 시작한
1104
- # 세션만 보면 없다고 나오고, 세션 전체를 더하면 다른 task 의 턴이 섞인다.
1105
- # 창 안에서 활동한 세션을 후보에 넣고 토큰은 창으로 잘라 센다.
1106
- window = (run_since, run_until) if provider == "codex" else None
1106
+ # in-session 리드(codex·grok)는 run 보다 먼저 열린 세션이다 — 창 안에서
1107
+ # 시작한 세션만 보면 없다고 나오고, 세션 전체를 더하면 다른 task 의 턴이
1108
+ # 섞인다. 창 안에서 활동한 세션을 후보에 넣고 토큰은 창으로 잘라 센다.
1109
+ window = (run_since, run_until)
1107
1110
  if provider == "codex":
1108
1111
  candidates = find_codex_sessions(
1109
1112
  project_root, run_since, run_until, active_before_start=True,
1110
1113
  )
1111
1114
  else:
1112
- candidates = _cli_sessions_for_windows(
1113
- provider, project_root, [(run_since, run_until)],
1115
+ candidates = find_grok_sessions(
1116
+ project_root, run_since, run_until, active_before_start=True,
1114
1117
  )
1115
1118
  sessions = _select_cli_lead_sessions(
1116
1119
  provider,
@@ -1,8 +1,12 @@
1
1
  """Grok Build session collectors.
2
2
 
3
3
  공식 세션 문서는 대화를 ``~/.grok/sessions/`` 에 둔다. cwd 는 퍼센트 인코딩된
4
- 디렉터리 이름이다. 누적 사용량은 ``updates.jsonl`` 의
5
- ``params.update.usage.modelUsage`` 마지막 스냅샷이다.
4
+ 디렉터리 이름이다. ``updates.jsonl`` 의 ``params.update.usage`` 행 하나는
5
+ **프롬프트 한 번**의 사용량이다 — ``numTurns`` 그 프롬프트 안의 모델 호출
6
+ 수이고 ``inputTokens`` 는 매번 컨텍스트 전체라 값이 오르내린다(실측
7
+ 2026-09-08, 대화형 세션 3개 39행: 83k → 798k → 406k → 187k …). 세션 합계는
8
+ 행의 합이고, 마지막 행은 마지막 프롬프트일 뿐이다. exec 래퍼 워커는 프롬프트가
9
+ 하나라 행도 하나다.
6
10
  """
7
11
  from __future__ import annotations
8
12
 
@@ -53,39 +57,55 @@ def _model_snapshot(usage: dict) -> tuple[dict, str | None]:
53
57
  return usage, None
54
58
 
55
59
 
56
- def grok_session_total(updates_path: Path) -> dict:
57
- """마지막 modelUsage 스냅샷. 세션 누적이라 더하지 않는다."""
58
- last: dict | None = None
60
+ _SUM_KEYS = (
61
+ ("totalTokens", "totalTokens"),
62
+ ("inputTokens", "inputTokens"),
63
+ ("outputTokens", "outputTokens"),
64
+ ("cachedInputTokens", "cachedReadTokens"),
65
+ ("reasoningOutputTokens", "reasoningTokens"),
66
+ ("durationMs", "apiDurationMs"),
67
+ )
68
+
69
+
70
+ def grok_session_window_total(
71
+ updates_path: Path, since: str | None = None, until: str | None = None,
72
+ ) -> dict:
73
+ """창 안 usage 행의 합. 창이 없으면 세션 전체.
74
+
75
+ 한 행이 프롬프트 한 번의 사용량이므로 더한다 — 마지막 행만 읽으면 여러
76
+ 프롬프트를 돌린 세션(in-session 리드)은 마지막 프롬프트만 남는다. 창은
77
+ run 보다 먼저 열린 리드 세션에서 다른 task 의 프롬프트를 걸러 낸다.
78
+ """
79
+ sums = {name: 0 for name, _raw in _SUM_KEYS}
59
80
  model: str | None = None
60
81
  started: str | None = None
61
82
  ended: str | None = None
83
+ counted = 0
62
84
  for record in iter_jsonl(updates_path):
63
85
  usage = _usage_payload(record)
64
86
  if usage is None:
65
87
  continue
66
88
  iso = _iso_from_unix(record.get("timestamp"))
89
+ if iso and not ts_in_window(iso, since, until):
90
+ continue
91
+ snapshot, snapshot_model = _model_snapshot(usage)
92
+ for name, raw in _SUM_KEYS:
93
+ sums[name] += snapshot.get(raw, 0) or 0
94
+ if snapshot_model:
95
+ model = snapshot_model
67
96
  if iso and started is None:
68
97
  started = iso
69
98
  if iso:
70
99
  ended = iso
71
- snapshot, snapshot_model = _model_snapshot(usage)
72
- last = snapshot
73
- if snapshot_model:
74
- model = snapshot_model
75
- if last is None:
100
+ counted += 1
101
+ if not counted:
76
102
  return {"totalTokens": 0, "available": False}
77
- return {
78
- "totalTokens": last.get("totalTokens", 0) or 0,
79
- "inputTokens": last.get("inputTokens", 0) or 0,
80
- "outputTokens": last.get("outputTokens", 0) or 0,
81
- "cachedInputTokens": last.get("cachedReadTokens", 0) or 0,
82
- "reasoningOutputTokens": last.get("reasoningTokens", 0) or 0,
83
- "durationMs": last.get("apiDurationMs", 0) or 0,
84
- "model": model,
85
- "startedAt": started,
86
- "endedAt": ended,
87
- "available": True,
88
- }
103
+ return {**sums, "model": model, "startedAt": started, "endedAt": ended, "available": True}
104
+
105
+
106
+ def grok_session_total(updates_path: Path) -> dict:
107
+ """세션 전체 모든 usage 행의 합."""
108
+ return grok_session_window_total(updates_path)
89
109
 
90
110
 
91
111
  def _session_started_in_window(
@@ -102,14 +122,38 @@ def _session_started_in_window(
102
122
  return False
103
123
 
104
124
 
125
+ def _session_active_in_window(
126
+ updates_path: Path, started_at: str, ended_at: str,
127
+ ) -> bool:
128
+ """창보다 먼저 열렸지만 창 안에도 usage 행이 있는 세션 — in-session 리드."""
129
+ first: str | None = None
130
+ for record in iter_jsonl(updates_path):
131
+ iso = _iso_from_unix(record.get("timestamp"))
132
+ if not iso:
133
+ continue
134
+ if first is None:
135
+ first = iso
136
+ if not ts_in_window(first, None, started_at):
137
+ return False
138
+ if ts_in_window(iso, started_at, ended_at):
139
+ return True
140
+ return False
141
+
142
+
105
143
  def find_grok_sessions(
106
144
  cwd: Path,
107
145
  started_at: str,
108
146
  ended_at: str,
109
147
  *,
110
148
  session_root: Path | None = None,
149
+ active_before_start: bool = False,
111
150
  ) -> list[Path]:
112
- """cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl."""
151
+ """cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl.
152
+
153
+ `active_before_start=True` 는 창보다 먼저 시작했지만 창 안에서도 프롬프트를
154
+ 돌린 세션을 더한다 — in-session 리드의 모양이고, 토큰은
155
+ `grok_session_window_total` 이 창으로 잘라 센다.
156
+ """
113
157
  if not started_at or not ended_at:
114
158
  return []
115
159
  root = session_root or grok_sessions_root()
@@ -125,8 +169,11 @@ def find_grok_sessions(
125
169
  continue
126
170
  for session in child.iterdir():
127
171
  updates = session / "updates.jsonl"
128
- if updates.is_file() and _session_started_in_window(
129
- updates, started_at, ended_at
172
+ if not updates.is_file():
173
+ continue
174
+ if _session_started_in_window(updates, started_at, ended_at) or (
175
+ active_before_start
176
+ and _session_active_in_window(updates, started_at, ended_at)
130
177
  ):
131
178
  matches.append(updates)
132
179
  return sorted(matches)
@@ -215,15 +215,11 @@ When the selected task type is `project-analysis`, `feature-analysis`, or `chang
215
215
 
216
216
  Do not second-guess the wizard. If the next prompt seems out of place, the bug is in `okstra_ctl.wizard`, not in your interpretation of the user's input.
217
217
 
218
- ## Step 4: Show the confirmation block before the final Proceed
218
+ ## Step 4: The confirm step's question text is the selection summary
219
219
 
220
- When `next.step == "confirm"`, before relaying the picker, fetch the human-readable selection summary:
220
+ When `next.step == "confirm"`, the prompt's `label` already carries the full selection summary (`선택 확인:` followed by one line per resolved input — task-type, task-key, brief, base-ref, worktree, every role slot, directive, related-tasks, clarification, stage, …) and ends with the question. Render that `label` **verbatim, every line, as the question text** of the `confirm` picker (Proceed / Edit / Abort) — the same way you render any other pick step's label. Do not re-author it as a table, do not summarise it into prose, do not drop a line because its value is `(none)`: the user confirms what is on screen, and a line you left out is a setting they never saw (observed: a lead's own table omitted `directive`, `base-ref` and `brief`). Render the picker **as the final output of that turn** — emit nothing after the picker call.
221
221
 
222
- ```bash
223
- okstra wizard confirmation --state-file /var/folders/.../okstra-wizard.AbCd.json
224
- ```
225
-
226
- Output: `{ok: true, text: "Selection summary:\n task-type : ...\n ..."}`. Print `text` to the user, then render the `confirm` picker (Proceed / Edit) **as the final output of that turn** — emit nothing after the picker call.
222
+ `okstra wizard confirmation --state-file <path>` returns the same block as `{ok: true, text: ...}` for a text-only host or when the user asks to see the summary again; it is not a substitute for the label.
227
223
 
228
224
  ## Step 5: Render the task bundle
229
225
 
@@ -569,7 +569,12 @@ def _validate_agent_dispatch_contract(
569
569
  dispatch_id = str(link.get("dispatchId") or "").strip()
570
570
  result_path = str(link.get("resultPath") or "").strip()
571
571
  if dispatch_id not in ids or not result_path:
572
- failures.append("agent result link has no matching dispatch record")
572
+ # 같은 문장이 링크 수만큼 반복되면 어느 링크인지 알 수 없다 — 대상을 이름한다.
573
+ failures.append(
574
+ "agent result link has no matching dispatch record: "
575
+ f"dispatchId={dispatch_id or '<empty>'} "
576
+ f"resultPath={Path(result_path).name if result_path else '<empty>'}"
577
+ )
573
578
  continue
574
579
  if uses_v2_identity:
575
580
  dispatch = ids[dispatch_id]
@@ -35,6 +35,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
35
35
  if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
36
36
  sys.path.insert(0, str(_ssot_dir))
37
37
 
38
+ from okstra_ctl.paths import RunRef # noqa: E402
38
39
  from okstra_ctl.worker_heartbeat import ( # noqa: E402
39
40
  HEARTBEAT_LINE_RE,
40
41
  IN_STAGE_PREFIX,
@@ -1521,6 +1522,42 @@ def _check_plan_verify_cleanup_checkpoints(
1521
1522
  )
1522
1523
 
1523
1524
 
1525
+ _STAGE_ANNOUNCE_RE = re.compile(r"\bstage=(\d+)\b")
1526
+
1527
+
1528
+ def _announced_stage(by_phase: Mapping[str, list[tuple[str, str]]]) -> int | None:
1529
+ for _ts, line in by_phase.get("phase-5-stage", []):
1530
+ matched = _STAGE_ANNOUNCE_RE.search(line)
1531
+ if matched:
1532
+ return int(matched.group(1))
1533
+ return None
1534
+
1535
+
1536
+ def _stage_carry_persisted(
1537
+ run_dir: Path, by_phase: Mapping[str, list[tuple[str, str]]]
1538
+ ) -> bool:
1539
+ """이 stage 의 carry 사이드카가 디스크에 있는가 — 완료 라인의 전제.
1540
+
1541
+ `phase-5-stage-complete` 는 executor 의 `### Stage Carry Evidence` 를 파싱한
1542
+ 뒤에만 낼 수 있고, 계약은 executor 가 carry 증거 없이 끝나면(FAIL 또는
1543
+ non-result) 그 라인을 생략하라고 한다. 그런데 검사는 명부의 `completed`
1544
+ 만 봐서, 계약대로 생략한 리드가 권고를 받았다(실측 2026-09-08,
1545
+ fontsninja-v3-site dev-10627-2 implementation stage-1: executor 가 상위
1546
+ 게이트 부재로 carry 를 의도적으로 보류). FAIL 이면 사이드카를 쓰지 않는다는
1547
+ 같은 계약을 `validate-run.py` `_validate_stage_carry_sidecar_exists` 가
1548
+ 집행하므로, 그 파일의 존재를 완료 라인의 조건으로 쓴다. stage 번호는 run
1549
+ 디렉터리(`stage-<N>`)에서, 없으면 `phase-5-stage stage=<N>` 공지에서 읽는다.
1550
+ """
1551
+ try:
1552
+ ref = RunRef.from_run_dir(run_dir)
1553
+ except ValueError:
1554
+ return True
1555
+ stage = ref.stage if ref.stage is not None else _announced_stage(by_phase)
1556
+ if stage is None:
1557
+ return any(ref.carry_dir.glob("stage-*.json"))
1558
+ return ref.carry(stage).exists()
1559
+
1560
+
1524
1561
  def _check_progress_checkpoints(
1525
1562
  evidence: _LeadEvidence,
1526
1563
  team_state: dict,
@@ -1605,7 +1642,7 @@ def _check_progress_checkpoints(
1605
1642
  )
1606
1643
  require(
1607
1644
  "phase-5-stage-complete",
1608
- implementer_completed,
1645
+ implementer_completed and _stage_carry_persisted(run_dir, by_phase),
1609
1646
  "immediately after parsing the Executor's `### Stage Carry Evidence` "
1610
1647
  "block — `stage=<N> steps=<done>/<count>` from its stepResults",
1611
1648
  )