okstra 0.165.2 → 0.165.4

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 (34) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  4. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -2
  5. package/runtime/prompts/profiles/_implementation-diff-review.md +2 -2
  6. package/runtime/prompts/profiles/_implementation-executor.md +22 -15
  7. package/runtime/prompts/profiles/_implementation-verifier.md +4 -4
  8. package/runtime/prompts/profiles/_stage-discipline.md +4 -3
  9. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  10. package/runtime/python/okstra_ctl/cmux.py +9 -7
  11. package/runtime/python/okstra_ctl/consumers.py +73 -24
  12. package/runtime/python/okstra_ctl/dispatch_core.py +3 -3
  13. package/runtime/python/okstra_ctl/dispatch_state.py +21 -1
  14. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +17 -5
  15. package/runtime/python/okstra_ctl/implementation_stage.py +2 -1
  16. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +104 -12
  17. package/runtime/python/okstra_ctl/path_hints.py +10 -2
  18. package/runtime/python/okstra_ctl/render.py +5 -0
  19. package/runtime/python/okstra_ctl/stage_map.py +10 -4
  20. package/runtime/python/okstra_ctl/team.py +30 -6
  21. package/runtime/python/okstra_ctl/wizard.py +19 -2
  22. package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
  23. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -1
  24. package/runtime/python/okstra_ctl/worker_prompt_headers.py +18 -1
  25. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -2
  26. package/runtime/python/okstra_ctl/worktree.py +17 -1
  27. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  28. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  29. package/runtime/templates/implementation-worker-preamble.md +10 -3
  30. package/runtime/templates/reports/final-report.template.md +3 -0
  31. package/runtime/templates/worker-prompt-preamble.md +4 -3
  32. package/runtime/validators/validate-implementation-plan-stages.py +19 -2
  33. package/runtime/validators/validate-run.py +7 -0
  34. package/src/commands/execute/wizard.mjs +20 -8
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import tempfile
7
8
  from dataclasses import dataclass
8
9
  from enum import Enum
@@ -20,7 +21,12 @@ from .worker_prompt_body import (
20
21
  )
21
22
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
22
23
  from .worker_prompt_headers import worker_prompt_headers
23
- from .worker_prompt_policy import PromptPlan, resolve_prompt_plan_for_manifest
24
+ from .worker_prompt_policy import (
25
+ APPROVED_PLAN_HEADER,
26
+ IMPLEMENTATION_STAGE_HEADER,
27
+ PromptPlan,
28
+ resolve_prompt_plan_for_manifest,
29
+ )
24
30
 
25
31
 
26
32
  class PromptDeliveryMode(str, Enum):
@@ -94,6 +100,7 @@ _CLARIFICATION_AUTHORITY_INTRO = (
94
100
  )
95
101
  _CLARIFICATION_SOURCE_PREFIX = "Source:"
96
102
  _REQUIRED_PROMPT_RESOURCES_HEADING = "## Required prompt resources"
103
+ _HTML_COMMENTS = re.compile(r"<!--.*?-->\n?", re.DOTALL)
97
104
 
98
105
 
99
106
  @dataclass(frozen=True)
@@ -323,7 +330,7 @@ def _render_prompt(
323
330
  lines.append(
324
331
  f"**Prompt Delivery Mode:** {context.request.delivery_mode.value}"
325
332
  )
326
- lines.extend(_worktree_lines(context, item.plan))
333
+ lines.extend(_implementation_anchor_lines(context, item.plan))
327
334
  lines.extend(_prompt_body(context, state, worker, item.plan))
328
335
  lines.extend(_resource_lines(context, item))
329
336
  except InitialPromptMaterializationError:
@@ -467,12 +474,27 @@ def _resolve_prompt_resources(
467
474
  return tuple(
468
475
  _PromptResource(
469
476
  path=path.resolve(),
470
- text=_read_required_nonempty_text(path, "required prompt resource"),
477
+ text=_worker_visible_text(
478
+ _read_required_nonempty_text(path, "required prompt resource")
479
+ ),
471
480
  )
472
481
  for path in paths
473
482
  )
474
483
 
475
484
 
485
+ def _worker_visible_text(text: str) -> str:
486
+ """Strip lead-and-maintainer-only passages before a body reaches a worker.
487
+
488
+ These sidecars have two readers: the lead lazy-reads the file to run the
489
+ phase, and the worker receives the same body inlined in its prompt. An
490
+ HTML comment is the seam between them — rendered markdown hides it, so it
491
+ holds the delivery plumbing (which path feeds this file, which heading the
492
+ CLI wrapper greps for) that the worker would otherwise pay for and read as
493
+ an instruction addressed to itself.
494
+ """
495
+ return _HTML_COMMENTS.sub("", text).lstrip("\n")
496
+
497
+
476
498
  def _required_resource_path_candidates(
477
499
  context: _MaterializationContext,
478
500
  plan: PromptPlan,
@@ -481,15 +503,17 @@ def _required_resource_path_candidates(
481
503
  if plan.audience == "implementation-executor":
482
504
  paths = (
483
505
  _executor_profile_path(context),
506
+ profiles / "_stage-discipline.md",
484
507
  profiles / "_coding-conventions-preflight.md",
485
508
  profiles / "_implementation-diff-review.md",
486
509
  profiles / "_implementation-self-check.md",
487
510
  )
488
511
  elif plan.audience == "implementation-verifier":
489
- paths = (
490
- profiles / "_implementation-verifier.md",
491
- profiles / "_implementation-self-check.md",
492
- )
512
+ # The self-check body is executor-only: it is written to the worker that
513
+ # owns the diff ("fix it or surface the violation", break-and-restore
514
+ # mutation checks), and a verifier is barred from every edit it asks
515
+ # for. Its own blocking taxonomy is inlined in the verifier sidecar.
516
+ paths = (profiles / "_implementation-verifier.md",)
493
517
  else:
494
518
  return ()
495
519
  return paths
@@ -873,15 +897,35 @@ def _result_paths(
873
897
  return str(final_report_data_path(Path(expected_report))), worker_result
874
898
 
875
899
 
876
- def _worktree_lines(
900
+ def _implementation_anchor_lines(
877
901
  context: _MaterializationContext,
878
902
  plan: PromptPlan,
879
903
  ) -> list[str]:
904
+ """Render the anchors only an implementation audience carries.
905
+
906
+ The approved plan and the stage number are absolute here because a CLI
907
+ worker's cwd is the stage worktree, where a `.okstra/...` relative path
908
+ resolves against the wrong root.
909
+ """
880
910
  if plan.audience not in {
881
911
  "implementation-executor",
882
912
  "implementation-verifier",
883
913
  }:
884
914
  return []
915
+ worktree = _required_worktree_path(context)
916
+ lines = [f"**Worktree:** {worktree}"]
917
+ if plan.audience == "implementation-executor":
918
+ lines.append(f"cwd for every mutating command: {worktree}")
919
+ lines.append(
920
+ f"{APPROVED_PLAN_HEADER} {_required_approved_plan_path(context)}"
921
+ )
922
+ lines.append(
923
+ f"{IMPLEMENTATION_STAGE_HEADER} {_required_stage_number(context)}"
924
+ )
925
+ return lines
926
+
927
+
928
+ def _required_worktree_path(context: _MaterializationContext) -> str:
885
929
  executor_worktree = context.active_context.get("executorWorktree")
886
930
  value = (
887
931
  _string_value(executor_worktree.get("path"))
@@ -893,10 +937,58 @@ def _worktree_lines(
893
937
  "required_input_missing",
894
938
  "implementation prompt generation requires a worktree path",
895
939
  )
896
- lines = [f"**Worktree:** {value}"]
897
- if plan.audience == "implementation-executor":
898
- lines.append(f"cwd for every mutating command: {value}")
899
- return lines
940
+ return value
941
+
942
+
943
+ def _required_approved_plan_path(context: _MaterializationContext) -> str:
944
+ """Read the approved plan path from this run's user-input snapshot.
945
+
946
+ run-inputs owns it because it is a user input, not a rendered path — and
947
+ reading it there also keeps runs prepared before this anchor existed
948
+ materializable, since every run has always recorded it.
949
+ """
950
+ source_artifacts = context.active_context.get("sourceArtifacts")
951
+ run_inputs_rel = (
952
+ _string_value(source_artifacts.get("runInputsPath"))
953
+ if isinstance(source_artifacts, Mapping)
954
+ else ""
955
+ )
956
+ if not run_inputs_rel:
957
+ raise InitialPromptMaterializationError(
958
+ "required_input_missing",
959
+ "implementation prompt generation requires a run inputs path",
960
+ )
961
+ payload = _load_json_object(
962
+ _resolve_input_path(context.project_root, Path(run_inputs_rel)),
963
+ "run inputs",
964
+ )
965
+ inputs = payload.get("inputs")
966
+ value = (
967
+ _string_value(inputs.get("approvedPlanPath"))
968
+ if isinstance(inputs, Mapping)
969
+ else ""
970
+ )
971
+ if not value:
972
+ raise InitialPromptMaterializationError(
973
+ "required_input_missing",
974
+ "implementation prompt generation requires an approved plan path",
975
+ )
976
+ return str(_resolve_input_path(context.project_root, Path(value)))
977
+
978
+
979
+ def _required_stage_number(context: _MaterializationContext) -> str:
980
+ value = _string_value(_active_run_field(context, "stage"))
981
+ if not value:
982
+ raise InitialPromptMaterializationError(
983
+ "required_input_missing",
984
+ "implementation prompt generation requires a stage number",
985
+ )
986
+ return value
987
+
988
+
989
+ def _active_run_field(context: _MaterializationContext, key: str) -> Any:
990
+ run = context.active_context.get("run")
991
+ return run.get(key) if isinstance(run, Mapping) else ""
900
992
 
901
993
 
902
994
  def _prompt_plan(
@@ -95,7 +95,7 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
95
95
  "kind": ACTIVE_CONTEXT_KIND,
96
96
  "task": _hydrate_active_task(payload, ctx),
97
97
  "workflow": dict(_mapping(payload.get("workflow"))),
98
- "run": _hydrate_active_run(ctx),
98
+ "run": _hydrate_active_run(payload, ctx),
99
99
  "instructionSet": _hydrate_active_instruction_set(payload, ctx),
100
100
  "workers": _hydrate_active_workers(payload, ctx),
101
101
  "errorLogs": _hydrate_active_error_logs(ctx),
@@ -182,8 +182,16 @@ def _hydrate_active_task(payload: Mapping[str, Any], ctx: Mapping[str, str]) ->
182
182
  return task
183
183
 
184
184
 
185
- def _hydrate_active_run(ctx: Mapping[str, str]) -> dict[str, str]:
185
+ def _hydrate_active_run(
186
+ payload: Mapping[str, Any],
187
+ ctx: Mapping[str, str],
188
+ ) -> dict[str, str]:
189
+ # `stage` is a run input, not a derived path, so pathHints cannot rebuild
190
+ # it — it survives the round trip only by being read back off the compact
191
+ # payload.
192
+ run = _mapping(payload.get("run"))
186
193
  return {
194
+ "stage": str(run.get("stage", "") or ""),
187
195
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
188
196
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
189
197
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
@@ -506,6 +506,11 @@ def _active_workflow(ctx: dict) -> dict:
506
506
 
507
507
  def _active_run(ctx: dict) -> dict:
508
508
  return {
509
+ # `implementation` binds one run to one Stage Map stage, and the
510
+ # executor prompt has to name it: its sidecar forbids recomputing the
511
+ # stage from `consumers.jsonl`. Feeds the implementation prompt anchor
512
+ # in `initial_prompt_materialization`.
513
+ "stage": ctx.get("RUN_STAGE", ""),
509
514
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
510
515
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
511
516
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
@@ -367,8 +367,14 @@ def parse_stage_map_file(markdown_path: Path) -> list[StageMapStage]:
367
367
  return _parse_schema_v2_stage_map(data, str(data_path))
368
368
 
369
369
 
370
- def _schema_v2_report(markdown_path: Path) -> dict[str, Any]:
371
- """The schema-v2 sidecar as a whole, `{}` for a v1 report."""
370
+ def schema_v2_report(markdown_path: Path) -> dict[str, Any]:
371
+ """The schema-v2 sidecar as a whole, `{}` for a v1 report.
372
+
373
+ Public because every caller that must branch on report schema needs it —
374
+ including `validators/validate-implementation-plan-stages.py`, which is a
375
+ separate process and cannot reach a private helper without copying the
376
+ sidecar-detection rule and letting the two drift.
377
+ """
372
378
  data_path = Path(markdown_path).resolve().with_suffix(".data.json")
373
379
  if not data_path.exists():
374
380
  return {}
@@ -383,7 +389,7 @@ def _schema_v2_report(markdown_path: Path) -> dict[str, Any]:
383
389
 
384
390
  def _planning_section(markdown_path: Path) -> dict[str, Any]:
385
391
  """The report's `implementationPlanning` block, `{}` for v1."""
386
- planning = _schema_v2_report(markdown_path).get("implementationPlanning")
392
+ planning = schema_v2_report(markdown_path).get("implementationPlanning")
387
393
  return planning if isinstance(planning, dict) else {}
388
394
 
389
395
 
@@ -407,7 +413,7 @@ def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
407
413
 
408
414
  def load_planning_detail(markdown_path: Path) -> PlanningDetail:
409
415
  """Read one report's narrative rows; empty for a schema-v1 report."""
410
- report = _schema_v2_report(markdown_path)
416
+ report = schema_v2_report(markdown_path)
411
417
  planning = report.get("implementationPlanning")
412
418
  if not isinstance(planning, dict) or not planning:
413
419
  return PlanningDetail({}, {})
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import subprocess
12
13
  import sys
13
14
  from pathlib import Path
14
15
  from typing import Any, Mapping, Sequence
@@ -202,11 +203,11 @@ def _reclaimable_panes(
202
203
  ) -> list[dict[str, str]]:
203
204
  """Everything this run owns and may close.
204
205
 
205
- Under cmux the recorded ids are the whole list. There is no per-pane tag API
206
- to sweep with, and scanning by title would be worse than nothing: cmux labels
207
- its own agent surfaces with the same glyph okstra's tmux cleanup treats as a
208
- teammate marker, so a sweep could close the lead. Only surfaces okstra
209
- created are recorded, so only those can be closed.
206
+ Under cmux the recorded ids are the only candidates. There is no per-pane tag
207
+ API to sweep with, and scanning by title would be worse than nothing: cmux
208
+ labels its own agent surfaces with the same glyph okstra's tmux cleanup
209
+ treats as a teammate marker, so a sweep could close the lead. Only surfaces
210
+ okstra created are recorded, so only those can be closed.
210
211
  """
211
212
  seen: set[str] = set()
212
213
  panes: list[dict[str, str]] = []
@@ -214,13 +215,36 @@ def _reclaimable_panes(
214
215
  if isinstance(record, dict):
215
216
  _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
216
217
  if _is_cmux_run(manifest):
217
- return panes
218
+ return _still_open_surfaces(panes)
218
219
  lead_pane = tmux.resolve_caller_pane()
219
220
  for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
220
221
  _append_pane(panes, seen, pane.pane_id, pane.kind)
221
222
  return panes
222
223
 
223
224
 
225
+ def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
226
+ """The recorded surfaces cmux still shows.
227
+
228
+ `workerDispatches` is append-only and nothing prunes it, so a surface closed
229
+ at an earlier round boundary stays recorded for the rest of the run. Taking
230
+ the ledger as the residual set makes the run-end cleanup gate offer to close
231
+ panes that left the screen rounds ago, on a workspace holding none.
232
+
233
+ An unreachable cmux keeps the ledger rather than reporting an empty set: a
234
+ wedged app is not evidence that the surfaces are gone, closing one that
235
+ already went away is a silent no-op, and skipping a live one strands it on
236
+ the user's screen for the rest of the session.
237
+ """
238
+ workspace = cmux.resolve_lead_workspace()
239
+ if not workspace:
240
+ return panes
241
+ try:
242
+ open_ids = cmux.open_surface_ids(workspace)
243
+ except (RuntimeError, OSError, subprocess.SubprocessError):
244
+ return panes
245
+ return [pane for pane in panes if pane["paneId"] in open_ids]
246
+
247
+
224
248
  def _append_pane(panes: list[dict[str, str]], seen: set[str], pane_id: str, kind: str) -> None:
225
249
  if pane_id and pane_id not in seen:
226
250
  panes.append({"paneId": pane_id, "kind": kind})
@@ -4484,11 +4484,28 @@ def _reset_from(state: WizardState, target_step: str) -> None:
4484
4484
  idx = next((i for i, s in enumerate(STEPS) if s.id == target_step), -1)
4485
4485
  if idx < 0:
4486
4486
  return
4487
+ # A later step may own a field an earlier answered step also owns —
4488
+ # `handoff_stage_pick` owns `approved_plan_path` because it resolves the
4489
+ # plan on its own. Clearing it while rewinding to a step in front of it
4490
+ # drops an answer the user never revisited; the rewound step then fails its
4491
+ # own `applies` guard, so no step is left to ask and the wizard reports
4492
+ # done while `outcome` still refuses it as incomplete.
4493
+ owned_earlier = {
4494
+ fname
4495
+ for step in STEPS[:idx]
4496
+ if step.id in state.answered
4497
+ for fname in step.owns
4498
+ }
4487
4499
  cleared_ids: set[str] = set()
4488
- for step in STEPS[idx:]:
4500
+ for position, step in enumerate(STEPS[idx:]):
4489
4501
  cleared_ids.add(step.id)
4502
+ is_rewind_target = position == 0
4490
4503
  for fname in step.owns:
4491
- _reset_field(state, fname)
4504
+ # The target's own fields always clear — that is the answer the
4505
+ # user came back to replace, even when an earlier step declares it
4506
+ # too (`task_pick` derives `task_type` for an existing task).
4507
+ if is_rewind_target or fname not in owned_earlier:
4508
+ _reset_field(state, fname)
4492
4509
  state.answered = [a for a in state.answered if a not in cleared_ids]
4493
4510
  direct_input_pending = {
4494
4511
  S_FEATURE_EVIDENCE: "feature_evidence_pending_text",
@@ -11,6 +11,21 @@ ANALYSIS_WORKER_LABELS = {
11
11
  "codex": "Codex worker",
12
12
  "antigravity": "Antigravity worker",
13
13
  }
14
+ # An implementation audience shares this body but not its premise: the executor
15
+ # owns the diff and the verifier grades it, so neither is producing one of the
16
+ # independent findings that cross-verification triangulates.
17
+ ROLE_STATEMENTS = {
18
+ "executor": (
19
+ "You are the Executor for this implementation stage — the only worker "
20
+ "permitted to mutate project files. Carry the stage end to end and "
21
+ "produce the worker result."
22
+ ),
23
+ "verifier": (
24
+ "You are a verifier for this implementation stage. Reproduce its QA "
25
+ "yourself, stay read-only on project files, and return an independent "
26
+ "verdict."
27
+ ),
28
+ }
14
29
 
15
30
 
16
31
  def analysis_prompt_body(
@@ -30,10 +45,7 @@ def analysis_prompt_body(
30
45
  f"# {label} Dispatch",
31
46
  "",
32
47
  "## Role",
33
- (
34
- f"You are the {label} for okstra cross-verification. "
35
- "Produce an independent worker result."
36
- ),
48
+ _role_statement(label, role),
37
49
  "",
38
50
  "## Task",
39
51
  f"- Task key: `{_require_string(manifest, 'taskKey')}`",
@@ -52,6 +64,14 @@ def analysis_prompt_body(
52
64
  ]
53
65
 
54
66
 
67
+ def _role_statement(label: str, role: str) -> str:
68
+ return ROLE_STATEMENTS.get(
69
+ role,
70
+ f"You are the {label} for okstra cross-verification. "
71
+ "Produce an independent worker result.",
72
+ )
73
+
74
+
55
75
  def analysis_input_lines(
56
76
  manifest: Mapping[str, Any],
57
77
  active_context: Mapping[str, Any],
@@ -8,6 +8,7 @@ from typing import Any, Mapping, Sequence
8
8
 
9
9
  from .worker_prompt_policy import (
10
10
  ERRORS_PATH_HEADERS,
11
+ IMPLEMENTATION_HEADERS,
11
12
  PromptPlan,
12
13
  resolve_prompt_plan_for_manifest,
13
14
  )
@@ -48,10 +49,11 @@ _NON_BODY_PREFIXES = (
48
49
  "Assigned worker prompt history path:",
49
50
  "**Worker Preamble Path:**",
50
51
  _EVIDENCE_LEDGER_HEADER_PREFIX,
52
+ "**Evidence citations:**",
51
53
  *ERRORS_PATH_HEADERS,
52
54
  "**Read scope:**",
53
55
  "**File write mode:**",
54
- "**Worktree:**",
56
+ *IMPLEMENTATION_HEADERS,
55
57
  "**Verification scope:**",
56
58
  "**Verification base ref:**",
57
59
  "**Verification head ref:**",
@@ -38,6 +38,23 @@ READ_SCOPE_HEADER = (
38
38
  )
39
39
  EVIDENCE_LEDGER_HEADER = "**Evidence ledger:** required-v1"
40
40
 
41
+ # `required-v1` is a mode name; what it demands lives in the Worker Preamble's
42
+ # "Evidence read ledger". But the preamble reaches the worker as a *path* —
43
+ # eager-include inlines only the role sidecars — so a worker that never opens it
44
+ # gets the switch without the rule, and Phase 7 then fails its result over a
45
+ # citation form the prompt never stated. Restating it inline is what
46
+ # READ_SCOPE_HEADER does, for the same reason: the rule has to be in front of the
47
+ # worker before its first citation, not in a file it may never read.
48
+ EVIDENCE_CITATION_HEADER = (
49
+ "**Evidence citations:** Append one `- Evidence read: <project-relative "
50
+ "path, no line suffix>` row to the audit sidecar for every file you open as "
51
+ "claim evidence, and cite that file in your result with backticks, a line "
52
+ "suffix, and the identical project-relative path — `src/config/env.ts:1-22`, "
53
+ "never the bare filename `env.ts:1-22`. A bare filename does not match its "
54
+ "ledger row and fails exactly like a file you never opened, however many "
55
+ "times you cited the full path earlier."
56
+ )
57
+
41
58
  # `agy`'s write tool validates the target against the Gemini artifact store
42
59
  # whenever the model attaches ArtifactMetadata, and rejects every path outside
43
60
  # `~/.gemini/antigravity-cli/brain/<uuid>/`. All okstra worker outputs live under
@@ -103,7 +120,7 @@ def worker_prompt_headers(
103
120
  f"**Coding preflight pack:** {_coding_preflight_pack_path(active_context)}"
104
121
  )
105
122
  if dispatch_kind == "initial" and plan.audience != "report-writer":
106
- headers.append(EVIDENCE_LEDGER_HEADER)
123
+ headers += [EVIDENCE_LEDGER_HEADER, EVIDENCE_CITATION_HEADER]
107
124
  headers.extend([
108
125
  f"**Errors log path:** {errors_log_path}",
109
126
  f"**Errors sidecar path:** {errors_sidecar_path}",
@@ -27,6 +27,17 @@ ERRORS_PATH_HEADERS = (
27
27
  "**Errors log path:**",
28
28
  "**Errors sidecar path:**",
29
29
  )
30
+ # The executor sidecar tells the worker to execute exactly one Stage Map stage
31
+ # against the approved plan, and forbids recomputing that stage from
32
+ # `consumers.jsonl`. Both facts therefore have to reach the worker prompt
33
+ # itself: the lead's launch prompt carries them, and no worker reads that.
34
+ APPROVED_PLAN_HEADER = "**Approved plan:**"
35
+ IMPLEMENTATION_STAGE_HEADER = "**Stage for this implementation run:**"
36
+ IMPLEMENTATION_HEADERS = (
37
+ "**Worktree:**",
38
+ APPROVED_PLAN_HEADER,
39
+ IMPLEMENTATION_STAGE_HEADER,
40
+ )
30
41
  FINAL_VERIFICATION_HEADERS = (
31
42
  "**Worktree:**",
32
43
  "**Verification scope:**",
@@ -96,14 +107,14 @@ def resolve_prompt_plan(
96
107
  return _plan(
97
108
  "implementation-executor",
98
109
  allow_coding_preflight=True,
99
- required_headers=("**Worktree:**",),
110
+ required_headers=IMPLEMENTATION_HEADERS,
100
111
  )
101
112
  if task_type == "implementation":
102
113
  return _plan(
103
114
  "implementation-verifier",
104
115
  equality_group="implementation-verifier-core",
105
116
  allow_coding_preflight=True,
106
- required_headers=("**Worktree:**",),
117
+ required_headers=IMPLEMENTATION_HEADERS,
107
118
  )
108
119
  if task_type == "final-verification":
109
120
  return _plan(
@@ -219,7 +219,7 @@ def resolve_stage_worktree_decision(
219
219
  safe_task = _safe_segment(task_id_segment)
220
220
  existing = worktree_registry.lookup(
221
221
  safe_project, safe_group, safe_task, stage_number=stage_number)
222
- if existing is not None and existing.status == "active":
222
+ if existing is not None and _stage_entry_is_reusable(existing):
223
223
  return StageWorktreeDecision(
224
224
  status="reused",
225
225
  path=existing.worktree_path,
@@ -237,6 +237,22 @@ def resolve_stage_worktree_decision(
237
237
  )
238
238
 
239
239
 
240
+ def _stage_entry_is_reusable(entry: worktree_registry.WorktreeEntry) -> bool:
241
+ """Whether a registered stage worktree can be entered by this run.
242
+
243
+ `active` is the live-run case. `released` with the directory still on disk is
244
+ the fix-run case: a stage whose verifier returned FAIL records a `failed`
245
+ consumers row, which frees the occupancy but deliberately keeps the worktree
246
+ and its branch as the reviewable stack. Re-entry MUST reuse that tree —
247
+ provisioning anew refuses on the existing path and branch. After whole-task
248
+ final-verification removes the directory the entry stops being reusable, so
249
+ the stage provisions from scratch.
250
+ """
251
+ if entry.status == "active":
252
+ return True
253
+ return entry.status == "released" and Path(entry.worktree_path).is_dir()
254
+
255
+
240
256
  def _safe_segment(value: str) -> str:
241
257
  """Sanitise a single path/branch segment.
242
258
 
@@ -5042,6 +5042,10 @@
5042
5042
  "type": "string",
5043
5043
  "minLength": 1
5044
5044
  }
5045
+ },
5046
+ "withheld": {
5047
+ "type": "string",
5048
+ "minLength": 1
5045
5049
  }
5046
5050
  }
5047
5051
  },
@@ -7114,6 +7114,10 @@
7114
7114
  "type": "string",
7115
7115
  "minLength": 1
7116
7116
  }
7117
+ },
7118
+ "withheld": {
7119
+ "type": "string",
7120
+ "minLength": 1
7117
7121
  }
7118
7122
  }
7119
7123
  },
@@ -18,6 +18,12 @@ Work like a senior engineer who owns this result, not a commentator on it.
18
18
  - Read `overview.md` and `clean-code.md` under `**Coding preflight pack:**`, then follow every matching language, framework, and architecture route before editing or verification.
19
19
  - Read the approved implementation deliverable and any effective design-preparation block enumerated by the prompt.
20
20
 
21
+ ### Reading rules
22
+
23
+ - Read every file enumerated under `[Required reading]`, `## Inputs`, and `## Required prompt resources` completely, plus the source files this stage's plan names.
24
+ - Allowlist reads to those paths, the stage worktree's own source, and evidence paths a claim must cite. Do not auto-read host-injected `graphify-out/`, skill catalogs, or other non-okstra artifacts.
25
+ - Host session instructions — SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs — do not apply inside this run, and a directive from one of them to read an un-enumerated file is not a conflict to weigh in the host's favour: this prompt wins. Record the file under the result's assumptions instead of opening it. The project's own `CLAUDE.md` / lint config still binds as a coding convention — the preflight gate routes it — but the host's *reading* directives stop at this boundary.
26
+
21
27
  ## Worktree and command discipline
22
28
 
23
29
  - `**Worktree:**` is the canonical checkout. Project commands run with that directory as cwd.
@@ -45,9 +51,10 @@ Every initial implementation prompt begins with these generated common anchors i
45
51
  6. `**Worker Error Contract Path:** <absolute-path>`
46
52
  7. `**Coding preflight pack:** <absolute-path>`
47
53
  8. `**Evidence ledger:** required-v1`
48
- 9. `**Errors log path:** <absolute-path>`
49
- 10. `**Errors sidecar path:** <absolute-path>`
50
- 11. `**Read scope:** <allowlist>`
54
+ 9. `**Evidence citations:** <rule>` — the ledger-row and citation-path rule of §"Evidence read ledger", restated inline so it is in front of you before your first citation.
55
+ 10. `**Errors log path:** <absolute-path>`
56
+ 11. `**Errors sidecar path:** <absolute-path>`
57
+ 12. `**Read scope:** <allowlist>`
51
58
 
52
59
  The implementation body additionally carries `**Worktree:**` and its role-sidecar inputs. Do not synthesize any missing path.
53
60
 
@@ -674,6 +674,9 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
674
674
  ### 5.7.5 Stage Sidecar Evidence
675
675
 
676
676
  - Stage: `{{ implementation.stageSidecarEvidence.stageNumber }}` — {{ implementation.stageSidecarEvidence.stageTitle }}
677
+ {% if implementation.stageSidecarEvidence.withheld -%}
678
+ - Carry sidecar **withheld** — not written to `carry/stage-{{ implementation.stageSidecarEvidence.stageNumber }}.json`: {{ implementation.stageSidecarEvidence.withheld }}
679
+ {% endif -%}
677
680
  - Carry sidecar JSON:
678
681
  ```json
679
682
  {{ implementation.stageSidecarEvidence.carryJson | indent(2) }}
@@ -43,9 +43,10 @@ Every initial analysis prompt begins with these generated anchors in this exact
43
43
  6. `**Worker Preamble Path:** <absolute-path>` — selects this analysis preamble.
44
44
  7. `**Worker Error Contract Path:** <absolute-path>` — shared by every initial audience.
45
45
  8. `**Evidence ledger:** required-v1`
46
- 9. `**Errors log path:** <absolute-path>`
47
- 10. `**Errors sidecar path:** <absolute-path>`
48
- 11. `**Read scope:** <allowlist>`
46
+ 9. `**Evidence citations:** <rule>` — the ledger-row and citation-path rule of §"Evidence read ledger", restated inline so it is in front of you before your first citation.
47
+ 10. `**Errors log path:** <absolute-path>`
48
+ 11. `**Errors sidecar path:** <absolute-path>`
49
+ 12. `**Read scope:** <allowlist>`
49
50
 
50
51
  `final-verification` additionally carries its six verification-target anchors. `improvement-discovery` carries `**Phase 1.5 Grilling Log:**`. Reverify prompts are lightweight and do not use this preamble.
51
52
 
@@ -25,6 +25,7 @@ from okstra_ctl.stage_map import ( # noqa: E402
25
25
  StageMapError,
26
26
  StageMapStage,
27
27
  parse_stage_map_text,
28
+ schema_v2_report,
28
29
  )
29
30
 
30
31
  HARD_STEP_CAP = 8
@@ -502,13 +503,29 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
502
503
  return errors
503
504
 
504
505
 
506
+ def collect_plan_errors(plan_path: Path) -> List[ValidationError]:
507
+ """The S-checks for one approved plan, whichever schema wrote it.
508
+
509
+ A schema-v2 report keeps its stage map in the `.data.json` sidecar and
510
+ renders no `## 5.5 Stage Map` section, so scanning its markdown reports the
511
+ section as missing and blocks every run that approved such a plan.
512
+ """
513
+ planning = schema_v2_report(plan_path).get("implementationPlanning")
514
+ if isinstance(planning, dict) and planning:
515
+ return collect_data_validation_errors(planning)
516
+ return collect_validation_errors(plan_path.read_text(encoding="utf-8"))
517
+
518
+
505
519
  def main(argv: List[str]) -> int:
506
520
  p = argparse.ArgumentParser()
507
521
  p.add_argument("--plan", required=True)
508
522
  args = p.parse_args(argv)
509
- text = Path(args.plan).read_text(encoding="utf-8")
510
523
 
511
- errors = collect_validation_errors(text)
524
+ try:
525
+ errors = collect_plan_errors(Path(args.plan))
526
+ except StageMapError as exc:
527
+ print(f"S0 stage=0: {exc.reason}", file=sys.stderr)
528
+ return 1
512
529
  if errors:
513
530
  for e in errors:
514
531
  print(f"{e.code} stage={e.stage}: {e.message}", file=sys.stderr)
@@ -5061,6 +5061,13 @@ def _validate_stage_carry_sidecar_exists(
5061
5061
  stage = evidence.get("stageNumber")
5062
5062
  if not isinstance(stage, int):
5063
5063
  return
5064
+ # A stage whose verifier returned FAIL must NOT persist its carry: the carry
5065
+ # file is what marks the stage `done`, and doing that would stack the next
5066
+ # stage on a confirmed regression. Such a run states the reason in
5067
+ # `withheld` and records a `failed` consumers row instead, so the absent
5068
+ # file is the correct outcome, not a gap.
5069
+ if str(evidence.get("withheld") or "").strip():
5070
+ return
5064
5071
  # Carry sidecars are stage-SHARED: the next stage's carry-in and
5065
5072
  # `consumers.backfill_done_from_carry` glob them without knowing the
5066
5073
  # producing run's layout. `RunRef.carry()` owns that flat-vs-staged rule;