okstra 0.178.0 → 0.179.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 (110) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/execute/plan-verify.mjs +1 -1
  3. package/dist/commands/execute/worktree-status.mjs +8 -2
  4. package/dist/commands/execute/worktree-status.mjs.map +1 -1
  5. package/dist/commands/lifecycle/install.mjs +1 -1
  6. package/dist/commands/lifecycle/install.mjs.map +1 -1
  7. package/dist/commands/report/render-final-report.mjs +3 -3
  8. package/docs/architecture/storage-model.md +3 -3
  9. package/docs/architecture.md +10 -9
  10. package/docs/cli.md +11 -13
  11. package/docs/for-ai/skills/okstra-inspect.md +3 -3
  12. package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
  13. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  14. package/docs/project-structure-overview.md +10 -11
  15. package/docs/task-process/implementation-planning.md +1 -1
  16. package/docs/task-process/implementation.md +1 -1
  17. package/package.json +1 -1
  18. package/runtime/BUILD.json +2 -2
  19. package/runtime/agents/workers/report-writer-worker.md +11 -12
  20. package/runtime/bin/lib/okstra/globals.sh +2 -2
  21. package/runtime/bin/lib/okstra/interactive.sh +1 -1
  22. package/runtime/bin/lib/okstra/usage.sh +11 -9
  23. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  24. package/runtime/bin/okstra-central.sh +2 -2
  25. package/runtime/bin/okstra-render-final-report.py +1 -1
  26. package/runtime/bin/okstra-token-usage.py +1 -1
  27. package/runtime/prompts/launch.template.md +1 -1
  28. package/runtime/prompts/lead/adapters/cmux.md +6 -1
  29. package/runtime/prompts/lead/context-loader.md +3 -2
  30. package/runtime/prompts/lead/convergence.md +1 -1
  31. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  32. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  33. package/runtime/prompts/lead/report-writer.md +21 -20
  34. package/runtime/prompts/lead/team-contract.md +1 -1
  35. package/runtime/prompts/profiles/_common-contract.md +5 -4
  36. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  37. package/runtime/prompts/profiles/_implementation-executor.md +2 -1
  38. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  39. package/runtime/prompts/profiles/implementation-planning.md +9 -5
  40. package/runtime/prompts/profiles/implementation.md +4 -4
  41. package/runtime/prompts/profiles/improvement-discovery.md +2 -2
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
  43. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
  44. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
  45. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
  46. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
  47. package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
  48. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
  49. package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
  50. package/runtime/python/okstra_ctl/backfill.py +12 -5
  51. package/runtime/python/okstra_ctl/consumers.py +70 -3
  52. package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
  53. package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
  54. package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
  55. package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
  56. package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
  57. package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
  58. package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
  59. package/runtime/python/okstra_ctl/handoff.py +1 -2
  60. package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
  61. package/runtime/python/okstra_ctl/index.py +4 -4
  62. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
  63. package/runtime/python/okstra_ctl/listing.py +4 -2
  64. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  65. package/runtime/python/okstra_ctl/manager_sync.py +1 -1
  66. package/runtime/python/okstra_ctl/path_hints.py +2 -2
  67. package/runtime/python/okstra_ctl/paths.py +13 -10
  68. package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
  69. package/runtime/python/okstra_ctl/recap.py +3 -2
  70. package/runtime/python/okstra_ctl/reconcile.py +3 -1
  71. package/runtime/python/okstra_ctl/render.py +22 -22
  72. package/runtime/python/okstra_ctl/report_finalize.py +4 -4
  73. package/runtime/python/okstra_ctl/rollup.py +1 -1
  74. package/runtime/python/okstra_ctl/run.py +139 -284
  75. package/runtime/python/okstra_ctl/run_audit.py +5 -5
  76. package/runtime/python/okstra_ctl/run_index_row.py +2 -2
  77. package/runtime/python/okstra_ctl/session_transcript.py +89 -0
  78. package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
  79. package/runtime/python/okstra_ctl/stage_map.py +28 -29
  80. package/runtime/python/okstra_ctl/stage_targets.py +61 -0
  81. package/runtime/python/okstra_ctl/user_response.py +97 -12
  82. package/runtime/python/okstra_ctl/wizard.py +43 -65
  83. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
  84. package/runtime/python/okstra_ctl/worker_runner.py +76 -213
  85. package/runtime/python/okstra_ctl/workflow.py +1 -1
  86. package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
  87. package/runtime/python/okstra_ctl/write_policy.py +45 -6
  88. package/runtime/python/okstra_project/state.py +2 -2
  89. package/runtime/python/okstra_token_usage/__init__.py +1 -1
  90. package/runtime/python/okstra_token_usage/cli.py +3 -3
  91. package/runtime/python/okstra_token_usage/report.py +7 -24
  92. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  93. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  94. package/runtime/schemas/final-report-v2.0.schema.json +11 -1
  95. package/runtime/skills/okstra-inspect/facets/history.md +3 -3
  96. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  97. package/runtime/skills/okstra-inspect/facets/report.md +5 -5
  98. package/runtime/skills/okstra-inspect/facets/status.md +2 -2
  99. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  100. package/runtime/skills/okstra-run/SKILL.md +1 -1
  101. package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
  102. package/runtime/skills/okstra-user-response/SKILL.md +3 -3
  103. package/runtime/templates/project-docs/task-index.template.md +1 -1
  104. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  105. package/runtime/validators/forbidden_actions.py +76 -5
  106. package/runtime/validators/lib/fixtures.sh +14 -10
  107. package/runtime/validators/lib/runners.sh +1 -1
  108. package/runtime/validators/validate-implementation-plan-stages.py +3 -0
  109. package/runtime/validators/validate-report-views.py +1 -1
  110. package/runtime/validators/validate-run.py +95 -37
@@ -1,5 +1,9 @@
1
1
  """Bundled Grok provider catalog."""
2
+ import json
3
+ from pathlib import Path
2
4
  from typing import Any, Mapping
5
+ from urllib.parse import quote
6
+
3
7
  from okstra_ctl.domain.provider import (
4
8
  LeadLaunchSpec,
5
9
  ModelSpec,
@@ -8,12 +12,11 @@ from okstra_ctl.domain.provider import (
8
12
  )
9
13
  from okstra_ctl.domain.role import ALL_ROLE_TOKENS
10
14
  from okstra_ctl.domain.worker_exec import (
11
- STREAM_JSON,
12
15
  ExecCommand,
13
16
  PolicySupport,
14
17
  WorkerExecRequest,
15
18
  )
16
- from okstra_ctl.domain.worker_stream import content_block_events
19
+ from okstra_ctl.domain.worker_presentation import MergedText
17
20
 
18
21
 
19
22
  GROK = {
@@ -23,23 +26,86 @@ GROK = {
23
26
  }
24
27
 
25
28
 
29
+ def _catalog_model_id(observed: str) -> str:
30
+ """Undo the `-build` suffix grok reports its served model under.
31
+
32
+ Measured on grok 1.x: asking for `grok-4.6` closes with
33
+ `modelUsage: {"grok-4.6-build": ...}`, and `grok-4.5` with
34
+ `grok-4.5-build`. The catalog holds the requested ids, so reading the
35
+ reported one as-is would fail the served-model gate on the very model
36
+ okstra asked for. A catalog entry that already carries the suffix
37
+ (`grok-build-0.1`) is matched first and left alone.
38
+ """
39
+ if observed in GROK:
40
+ return observed
41
+ bare = observed.removesuffix("-build")
42
+ return bare if bare != observed and bare in GROK else observed
43
+
44
+
26
45
  def normalise_served_model(raw_model: str | None) -> ServedModelAttestation:
27
46
  if not raw_model or not raw_model.strip():
28
47
  return ServedModelAttestation.unknown()
29
48
  model_id = raw_model.strip().lower().removeprefix("grok/")
30
49
  return ServedModelAttestation(
31
- raw_model, f"grok/{model_id}", "exact", "provider-output"
50
+ raw_model,
51
+ f"grok/{_catalog_model_id(model_id)}",
52
+ "exact",
53
+ "provider-output",
32
54
  )
33
55
 
34
56
 
57
+ def served_model_from_session(
58
+ session_id: str, cwd: Path, *, home: Path | None = None
59
+ ) -> str | None:
60
+ """세션 기록에서 실제로 서빙된 모델을 읽는다.
61
+
62
+ grok 은 일하는 동안 어느 이벤트에도 모델을 적지 않고, 닫는 기록에만
63
+ 남긴다. 스트림을 해석하지 않는 경로에서는 여기가 유일한 관측 지점이다.
64
+ 디렉터리 이름은 cwd 를 퍼센트 인코딩한 것이다.
65
+ """
66
+ root = (home or Path.home() / ".grok") / "sessions"
67
+ encoded = quote(str(cwd), safe="")
68
+ history = root / encoded / session_id / "chat_history.jsonl"
69
+ if not history.is_file():
70
+ return None
71
+ try:
72
+ for line in history.read_text(encoding="utf-8", errors="replace").splitlines():
73
+ if not line.strip():
74
+ continue
75
+ record = json.loads(line)
76
+ model = record.get("model_id") if isinstance(record, Mapping) else None
77
+ if isinstance(model, str) and model.strip():
78
+ return model
79
+ except (OSError, ValueError):
80
+ return None
81
+ return None
82
+
83
+
35
84
  def observe_served_model(event: Mapping[str, Any]) -> str | None:
85
+ """Read the model grok actually served from its closing `end` event.
86
+
87
+ grok names no model on the events it streams as it works; the only place
88
+ it states one is `end.modelUsage`, keyed by model. Looking for a top-level
89
+ `model` field — Claude's shape — found nothing on every event, so the run
90
+ recorded `servedModelAttestation: unknown` and the served-model gate had
91
+ nothing to check.
92
+ """
36
93
  model = event.get("model")
37
94
  if isinstance(model, str) and model.strip():
38
95
  return model
39
96
  message = event.get("message")
40
97
  if isinstance(message, Mapping):
41
98
  model = message.get("model")
42
- return model if isinstance(model, str) and model.strip() else None
99
+ if isinstance(model, str) and model.strip():
100
+ return model
101
+ if event.get("type") == "end":
102
+ usage = event.get("modelUsage")
103
+ if isinstance(usage, Mapping):
104
+ names = [name for name in usage if isinstance(name, str) and name.strip()]
105
+ # More than one model in a single turn means the CLI switched
106
+ # mid-run; naming either one would be a guess, so report neither.
107
+ if len(names) == 1:
108
+ return names[0]
43
109
  return None
44
110
 
45
111
  GROK_LEAD_LAUNCH = LeadLaunchSpec(
@@ -66,16 +132,12 @@ class GrokExecution:
66
132
  request.prompt_text,
67
133
  "-m",
68
134
  request.model,
69
- "--output-format",
70
- "streaming-json",
71
135
  "--cwd",
72
136
  str(cwd),
73
137
  ),
74
138
  stdin_text=None,
75
- stream_format=STREAM_JSON,
76
- normalise=content_block_events,
77
- observe_served_model=observe_served_model,
78
139
  cwd=cwd,
140
+ presentation=MergedText(served_model_at_exit=served_model_from_session),
79
141
  )
80
142
 
81
143
  def policy_support(self) -> PolicySupport:
@@ -8,11 +8,11 @@ from okstra_ctl.domain.provider import (
8
8
  )
9
9
  from okstra_ctl.domain.role import ALL_ROLE_TOKENS
10
10
  from okstra_ctl.domain.worker_exec import (
11
- STREAM_JSON,
12
11
  ExecCommand,
13
12
  PolicySupport,
14
13
  WorkerExecRequest,
15
14
  )
15
+ from okstra_ctl.domain.worker_presentation import JsonEvents
16
16
  from okstra_ctl.domain.worker_stream import content_block_events
17
17
 
18
18
 
@@ -72,10 +72,11 @@ class KimiExecution:
72
72
  "stream-json",
73
73
  ),
74
74
  stdin_text=None,
75
- stream_format=STREAM_JSON,
76
- normalise=content_block_events,
77
- observe_served_model=observe_served_model,
78
75
  cwd=cwd,
76
+ presentation=JsonEvents(
77
+ normalise=content_block_events,
78
+ observe=observe_served_model,
79
+ ),
79
80
  )
80
81
 
81
82
  def policy_support(self) -> PolicySupport:
@@ -43,7 +43,11 @@ from .assignment_resolver import AssignmentContext, resolve_dispatch_assignment
43
43
  from .path_hints import hydrate_active_run_context
44
44
  from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
45
45
  from .worker_artifact_paths import audit_sidecar_rel
46
- from .wrapper_status import log_path_for_prompt, status_path_for_prompt
46
+ from .wrapper_status import (
47
+ log_path_for_prompt,
48
+ prompt_derived_paths,
49
+ status_path_for_prompt,
50
+ )
47
51
  from .worker_prompt_policy import resolve_prompt_plan_for_manifest
48
52
  from .dispatch_state import (
49
53
  BACKEND_CLI_WRAPPER,
@@ -489,8 +493,7 @@ def _dynamic_verifier_artifact_paths(
489
493
  result_path,
490
494
  audit_source_path,
491
495
  project_root / audit_sidecar_rel(audit_rel),
492
- status_path_for_prompt(prompt_path),
493
- log_path_for_prompt(prompt_path),
496
+ *prompt_derived_paths(prompt_path),
494
497
  }
495
498
  error_logs = active_context.get("errorLogs")
496
499
  sidecars = error_logs.get("sidecarsByWorkerId") if isinstance(error_logs, Mapping) else None
@@ -628,12 +631,61 @@ def _validate_run_identity(
628
631
  ).duty_audience
629
632
  except ValueError as exc:
630
633
  raise AgentPromptCliError(str(exc)) from exc
634
+ if audience != expected and _manifest_issued_role_execution(
635
+ manifest, audience=audience, assignment_ref=assignment_ref
636
+ ):
637
+ # The prompt plan names a worker's role by worker id, so on an
638
+ # implementation run the executor's provider resolves to the
639
+ # executor role no matter which role execution is being targeted.
640
+ # But the manifest issues one role execution per role, and a
641
+ # provider serving as both executor and verifier gets two — a
642
+ # normal roster, since the verifier contract accepts reusing the
643
+ # executor's model behind its own session. Refusing the second
644
+ # audience made a role execution okstra had itself issued
645
+ # undispatchable, which costs the run an independent verifier.
646
+ expected = audience
631
647
  if audience != expected:
632
648
  raise AgentPromptCliError(
633
649
  f"audience {audience!r} does not match required audience {expected!r}"
634
650
  )
635
651
 
636
652
 
653
+ def _manifest_issued_role_execution(
654
+ manifest: Mapping[str, Any],
655
+ *,
656
+ audience: str,
657
+ assignment_ref: str,
658
+ ) -> bool:
659
+ """Whether this run issued a role execution for that audience's role.
660
+
661
+ Keyed on the manifest's own rows, not on what the prompt plan infers from
662
+ a worker id: the question is whether okstra created the execution being
663
+ targeted, and only the manifest answers that.
664
+ """
665
+ from .domain.role import RoleCatalogError, role_for_duty
666
+
667
+ try:
668
+ role = role_for_duty(audience)
669
+ except RoleCatalogError:
670
+ return False
671
+ assignments = manifest.get("invocationAssignments")
672
+ assignment = (
673
+ assignments.get(assignment_ref) if isinstance(assignments, Mapping) else None
674
+ )
675
+ provider = (
676
+ assignment.get("provider") if isinstance(assignment, Mapping) else None
677
+ )
678
+ if not provider:
679
+ return False
680
+ executions = manifest.get("roleExecutions")
681
+ return any(
682
+ isinstance(row, Mapping)
683
+ and row.get("role") == role
684
+ and row.get("provider") == provider
685
+ for row in (executions if isinstance(executions, list) else [])
686
+ )
687
+
688
+
637
689
  def _materialize_standalone(
638
690
  args: argparse.Namespace,
639
691
  project_root: Path,
@@ -69,7 +69,9 @@ _EVIDENCE_RELATION_BY_TYPES = {
69
69
  ("change-impact-analysis", "project-analysis"): "project-context",
70
70
  ("change-impact-analysis", "feature-analysis"): "feature-baseline",
71
71
  }
72
- _REPORT_NAME_RE = re.compile(r"^final-report-.+-(?P<run_seq>[^-]+)\.md$")
72
+ _REPORT_NAME_RE = re.compile(
73
+ r"^final-report-.+-(?P<run_seq>[^-]+)\.data\.json$"
74
+ )
73
75
  _FEATURE_ID_RE = re.compile(r"PF-\d{3}")
74
76
  _FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}")
75
77
  _RUN_SEQ_RE = re.compile(r"[0-9]{3}")
@@ -242,7 +244,7 @@ def load_analysis_report_candidate(
242
244
  raise AnalysisInputError("data.json header.taskKey does not match task manifest")
243
245
  if task_type != path_task_type:
244
246
  raise AnalysisInputError("data.json header.taskType does not match report path")
245
- if filename != f"final-report-{task_type}-{run_seq}.md":
247
+ if filename != f"final-report-{task_type}-{run_seq}.data.json":
246
248
  raise AnalysisInputError("data.json analysisCommon.runSeq does not match report filename")
247
249
  feature_index: Sequence[object] = ()
248
250
  if task_type == "project-analysis":
@@ -289,7 +291,7 @@ def list_evidence_candidates(
289
291
  return []
290
292
  reports_root = root / ".okstra" / "tasks"
291
293
  candidates: list[AnalysisReportCandidate] = []
292
- for report in reports_root.glob("*/*/runs/*/reports/*.md"):
294
+ for report in reports_root.glob("*/*/runs/*/reports/*.data.json"):
293
295
  try:
294
296
  candidate = load_analysis_report_candidate(root, report)
295
297
  except AnalysisInputError:
@@ -100,6 +100,7 @@ def build_analysis_packet(
100
100
  directive: str,
101
101
  instruction_set_relative_path: str,
102
102
  fix_history_text: str = "",
103
+ stage_ledger_json: str = "",
103
104
  ) -> str:
104
105
  """Return the primary compact input for Claude/Codex/Antigravity analysers."""
105
106
  brief_text = task_brief_path.read_text(encoding="utf-8")
@@ -122,6 +123,7 @@ def build_analysis_packet(
122
123
  parts.extend(_profile_block(task_type, profile_text))
123
124
  parts.extend(_reference_block(reference_text))
124
125
  parts.extend(_fix_history_block(fix_history_text))
126
+ parts.extend(_stage_ledger_block(stage_ledger_json))
125
127
  parts.extend(_clarification_block(clarification_text))
126
128
  parts.extend(_directive_block(directive))
127
129
  return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
@@ -229,6 +231,25 @@ def _fix_history_block(fix_history_text: str) -> list[str]:
229
231
  ]
230
232
 
231
233
 
234
+ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
235
+ if not stage_ledger_json.strip():
236
+ return []
237
+ return [
238
+ "",
239
+ "## Stage Ledger",
240
+ "",
241
+ "Facts about this task's stages as they stand on disk — not a plan.",
242
+ "A stage whose `status` is `done` is already implemented and will not",
243
+ "be executed again, so do not rewrite its plan body. Stage numbers",
244
+ "already listed here are taken; never reuse or renumber them.",
245
+ "",
246
+ "```json",
247
+ stage_ledger_json.strip(),
248
+ "```",
249
+ "",
250
+ ]
251
+
252
+
232
253
  def _clarification_block(clarification_text: str) -> list[str]:
233
254
  if not clarification_text.strip():
234
255
  return []
@@ -162,13 +162,20 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
162
162
  continue
163
163
  # 실제 okstra.sh 가 쓰는 manifest 키:
164
164
  # - runDirectoryPath (디렉터리)
165
- # - expectedReportPath (보고서, 호환: reportPath)
165
+ # - expectedReportRecordPath (보고서, 호환: reportPath)
166
166
  # - validation: { status, passed, ... } 중첩 dict
167
167
  run_dir_rel = (manifest.get("runDirectoryPath")
168
168
  or manifest.get("runDirPath", ""))
169
- final_rel = (manifest.get("expectedReportPath")
170
- or manifest.get("reportPath")
171
- or manifest.get("finalReportPath", ""))
169
+ from .final_report_paths import report_record_rel_from_legacy_pointer
170
+
171
+ final_rel = report_record_rel_from_legacy_pointer(
172
+ manifest.get("expectedReportRecordPath")
173
+ or manifest.get("expectedReportPath")
174
+ or manifest.get("reportPath")
175
+ or manifest.get("finalReportRecordPath")
176
+ or manifest.get("finalReportPath")
177
+ or ""
178
+ )
172
179
  # status 파일 경로는 RUN_STATUS_SEQ 기반이라 manifest seq
173
180
  # 와 어긋날 수 있다. tail 이 정확한 파일을 추적하도록
174
181
  # 매니페스트가 기록한 expectedStatusPath /
@@ -223,7 +230,7 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
223
230
  finished_at=None if is_non_terminal else finished_at,
224
231
  workers=raw_workers, lead_model=raw_lead_model,
225
232
  validation=validation_status, run_dir_rel=run_dir_rel,
226
- final_report_rel=final_rel,
233
+ final_report_record_rel=final_rel,
227
234
  final_status_rel=final_status_rel)
228
235
  if is_non_terminal:
229
236
  append_jsonl(home / "active.jsonl", row)
@@ -10,6 +10,8 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import re
13
+ import subprocess
14
+ import sys
13
15
  from dataclasses import dataclass
14
16
  from pathlib import Path
15
17
  from typing import Any, Dict, List, Optional
@@ -68,13 +70,25 @@ def read_consumers(plan_run_root: Path) -> List[Dict[str, Any]]:
68
70
 
69
71
 
70
72
  def latest_done_by_stage(rows: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
71
- """stage → 마지막 done row. 보정(reconciled) row 가 같은 stage 에
72
- 재-append 되므로 done 읽기의 유일한 의미는 last-wins 다."""
73
+ """stage → 마지막 done row. 리드가 `failed` 로 철회한 stage 는 제외한다.
74
+
75
+ 보정(reconciled) row 가 같은 stage 에 재-append 되므로 done 들 사이에서는
76
+ last-wins 다. done 행만 훑던 동안은 뒤에 붙은 `failed` 가 아무 효과가 없어
77
+ 한 번 done 이 기록된 stage 를 되열 수단이 없었다 — `--stage N` 은 계속
78
+ 거부되고, 되돌리기가 필요해진 stage 를 okstra 밖에서 처리한 뒤 원장을 손으로
79
+ 맞춰야 했다. 그 보정을 놓치면 다음 stage 가 되돌리기 이전 트리에서 갈라진다.
80
+
81
+ `started` 는 여기서 stage 를 내리지 않는다. fix run 이 done 인 stage 에
82
+ `started` 를 다시 기록하는 것은 정상 흐름이고(`_equivalent_row_exists`),
83
+ 그동안 의존 stage 는 종전대로 마지막 done head 에서 base 를 잡는다.
84
+ 철회는 리드의 명시적 판정인 `failed` 하나로만 일어난다.
85
+ """
86
+ last = last_lifecycle_status_by_stage(rows)
73
87
  out: Dict[int, Dict[str, Any]] = {}
74
88
  for r in rows:
75
89
  if r.get("status") == "done" and isinstance(r.get("stage"), int):
76
90
  out[r["stage"]] = r
77
- return out
91
+ return {stage: row for stage, row in out.items() if last.get(stage) != "failed"}
78
92
 
79
93
 
80
94
  def read_stage_consumer_state(
@@ -132,6 +146,8 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
132
146
  **fields,
133
147
  }
134
148
  _append_row(plan_run_root, record)
149
+ if status == "done":
150
+ _tag_stage_exit(plan_run_root, stage, fields.get("head_commit"))
135
151
  # 종결 status 는 점유 해제 이벤트이기도 하다 — 중복 append(no-op)에서도 풀어야
136
152
  # release 없이 done 만 기록된 과거 run 의 잔존 점유가 다음 호출에서 치유된다.
137
153
  if status == "done":
@@ -140,6 +156,57 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
140
156
  _release_stage_occupancy_keeping_branch(impl_task_key, stage)
141
157
 
142
158
 
159
+ STAGE_EXIT_TAG = "stage-{stage}-exit"
160
+
161
+
162
+ def _repo_root(start: Path) -> Optional[Path]:
163
+ for candidate in [start, *start.parents]:
164
+ if (candidate / ".git").exists():
165
+ return candidate
166
+ return None
167
+
168
+
169
+ def _tag_stage_exit(plan_run_root: Path, stage: int, head_commit: Any) -> None:
170
+ """Move `stage-<N>-exit` to the commit this ledger row records.
171
+
172
+ The tag used to be a plan step, so it was written while the stage was still
173
+ running — before the carry evidence the ledger reads. A stage then had three
174
+ exit points that could disagree: the tag, the recorded `head_commit`, and
175
+ the branch tip. okstra writes the tag at the moment it settles the stage, so
176
+ the tag and the ledger cannot drift apart.
177
+
178
+ Best effort by design: the ledger is the record and a tag is a convenience
179
+ for the reader. A tree that is not a git repository, or a commit git cannot
180
+ resolve, leaves the row written and says so on stderr.
181
+ """
182
+ if not isinstance(head_commit, str) or not head_commit.strip():
183
+ return
184
+ root = _repo_root(Path(plan_run_root).resolve())
185
+ if root is None:
186
+ return
187
+ commit = head_commit.strip()
188
+ tag = STAGE_EXIT_TAG.format(stage=stage)
189
+ try:
190
+ exists = subprocess.run(
191
+ ["git", "-C", str(root), "cat-file", "-e", f"{commit}^{{commit}}"],
192
+ capture_output=True,
193
+ )
194
+ if exists.returncode != 0:
195
+ print(
196
+ f"okstra: stage {stage} done recorded, but {commit[:12]} is not "
197
+ f"a commit in {root} — `{tag}` not moved",
198
+ file=sys.stderr,
199
+ )
200
+ return
201
+ subprocess.run(
202
+ ["git", "-C", str(root), "tag", "-f", tag, commit],
203
+ capture_output=True,
204
+ check=True,
205
+ )
206
+ except (OSError, subprocess.CalledProcessError) as exc:
207
+ print(f"okstra: could not move `{tag}` to {commit[:12]}: {exc}", file=sys.stderr)
208
+
209
+
143
210
  def _equivalent_row_exists(plan_run_root: Path, impl_task_key: str, stage: int,
144
211
  status: str, force_reappend: bool,
145
212
  head_commit: Any) -> bool:
@@ -13,7 +13,12 @@ WORKING_SCHEMA_VERSION = "1.0"
13
13
  V2_WORKING_SCHEMA_VERSION = "2.0"
14
14
  EXECUTION_IDENTITY_VERSION = 2
15
15
  FINAL_SCHEMA_VERSION = "1.3"
16
- _AUDIENCES = {"analysis", "report-writer"}
16
+ _AUDIENCES = {"analysis", "lead", "report-writer"}
17
+ # 발견을 낼 수 있는 audience. 리드는 계약상 Phase 6 자체 리뷰를 수행하므로
18
+ # 출처가 될 수 있지만, 교차검증 표는 아니다 — 합의 수는 아래에서 분석 워커만
19
+ # 센다. 리드를 분석 워커로 위장 선언하는 것을 계약이 금지하는 이상, 리드의
20
+ # 발견을 담을 자리가 없으면 그 리뷰 결과는 어디에도 못 간다.
21
+ _FINDING_SOURCE_AUDIENCES = {"analysis", "lead"}
17
22
  _AUDIENCE_SOURCE_ROLES = {
18
23
  "analysis": frozenset({"analyser", "designer", "planner", "verifier"}),
19
24
  "report-writer": frozenset({"report-writer"}),
@@ -79,6 +84,11 @@ def seed_working_state(
79
84
  analysis_workers = [
80
85
  worker["workerId"] for worker in workers if worker["audience"] == "analysis"
81
86
  ]
87
+ finding_sources = [
88
+ worker["workerId"]
89
+ for worker in workers
90
+ if worker["audience"] in _FINDING_SOURCE_AUDIENCES
91
+ ]
82
92
  state = {
83
93
  "schemaVersion": source["schemaVersion"],
84
94
  "taskKey": task_key,
@@ -104,7 +114,10 @@ def seed_working_state(
104
114
  return state
105
115
 
106
116
  findings, queue = _parse_groups(
107
- source.get("groups"), analysis_workers, adversarial=config["adversarial"]
117
+ source.get("groups"),
118
+ analysis_workers,
119
+ finding_sources,
120
+ adversarial=config["adversarial"],
108
121
  )
109
122
  state["findings"] = findings
110
123
  state["queueFindingIds"] = queue
@@ -602,7 +615,8 @@ def validate_working_state(state: Mapping[str, Any]) -> list[str]:
602
615
  errors.append(
603
616
  f"workers[{index}].audience is unsupported: {audience!r}. "
604
617
  "Finding-producing workers (implementation verifiers included) "
605
- "use 'analysis'; only the report author uses 'report-writer'. "
618
+ "use 'analysis'; the okstra lead's own review uses 'lead'; "
619
+ "only the report author uses 'report-writer'. "
606
620
  f"Allowed: {sorted(_AUDIENCES)}."
607
621
  )
608
622
  participant_ref = worker.get("participantRef")
@@ -2042,8 +2056,9 @@ def _parse_workers(value: Any, identity_version: int) -> list[dict[str, str]]:
2042
2056
  f"workers[{index}] has unsupported audience: {audience}. "
2043
2057
  "Convergence audience is a functional role, not a phase label: "
2044
2058
  "every finding-producing worker uses 'analysis' (an "
2045
- "implementation run's verifiers included), and only the report "
2046
- f"author uses 'report-writer'. Allowed: {sorted(_AUDIENCES)}."
2059
+ "implementation run's verifiers included), the okstra lead's own "
2060
+ "review uses 'lead', and only the report author uses "
2061
+ f"'report-writer'. Allowed: {sorted(_AUDIENCES)}."
2047
2062
  )
2048
2063
  if worker_id in seen:
2049
2064
  raise ConvergenceContractError(f"duplicate workerId: {worker_id}")
@@ -2125,6 +2140,7 @@ def _validate_worker_execution_identity(
2125
2140
  def _parse_groups(
2126
2141
  value: Any,
2127
2142
  analysis_workers: list[str],
2143
+ finding_sources: list[str],
2128
2144
  *,
2129
2145
  adversarial: bool,
2130
2146
  ) -> tuple[list[dict[str, Any]], list[str]]:
@@ -2139,7 +2155,9 @@ def _parse_groups(
2139
2155
  if finding_id in seen_ids:
2140
2156
  raise ConvergenceContractError(f"duplicate findingId: {finding_id}")
2141
2157
  seen_ids.add(finding_id)
2142
- finding, source_workers = _parse_group(group, index, analysis_workers)
2158
+ finding, source_workers = _parse_group(
2159
+ group, index, analysis_workers, finding_sources
2160
+ )
2143
2161
  if len(source_workers) >= 2 and not adversarial:
2144
2162
  finding["classification"] = "full-consensus"
2145
2163
  else:
@@ -2152,23 +2170,30 @@ def _parse_group(
2152
2170
  group: Mapping[str, Any],
2153
2171
  index: int,
2154
2172
  analysis_workers: list[str],
2173
+ finding_sources: list[str],
2155
2174
  ) -> tuple[dict[str, Any], list[str]]:
2175
+ """그룹 하나를 읽는다. 출처는 발견을 낼 수 있는 모두, 합의는 분석 워커만.
2176
+
2177
+ 두 목록을 따로 받는 이유가 이 함수의 전부다. 리드도 발견을 낼 수 있어야
2178
+ 하지만 그 발견이 교차검증 표로 세어지면, 독립 검증자 없이 해소된 것처럼
2179
+ 보이는 findings 가 생긴다.
2180
+ """
2156
2181
  label = f"groups[{index}]"
2157
2182
  origin = _required_string(group, "originWorker", label)
2158
- if origin not in analysis_workers:
2183
+ if origin not in finding_sources:
2159
2184
  raise ConvergenceContractError(
2160
- f"{label}.originWorker is outside the analysis roster: {origin}"
2185
+ f"{label}.originWorker cannot produce findings in this run: {origin}"
2161
2186
  )
2162
2187
  origin_evidence = _required_string(group, "originEvidence", label)
2163
2188
  discovered_by = _parse_discovered_by(
2164
- group.get("discoveredBy"), label, analysis_workers
2189
+ group.get("discoveredBy"), label, finding_sources
2165
2190
  )
2166
2191
  if origin not in discovered_by:
2167
2192
  raise ConvergenceContractError(
2168
2193
  f"{label}.originWorker must appear in discoveredBy"
2169
2194
  )
2170
2195
  source_items = _parse_source_items(
2171
- group.get("sourceItems"), label, analysis_workers
2196
+ group.get("sourceItems"), label, finding_sources
2172
2197
  )
2173
2198
  source_workers = [
2174
2199
  worker_id
@@ -2243,14 +2268,15 @@ def _is_normalized_okstra_path(path: str) -> bool:
2243
2268
  def _parse_discovered_by(
2244
2269
  value: Any,
2245
2270
  label: str,
2246
- analysis_workers: list[str],
2271
+ finding_sources: list[str],
2247
2272
  ) -> dict[str, dict[str, str]]:
2248
2273
  raw = _object(value, f"{label}.discoveredBy")
2249
2274
  parsed: dict[str, dict[str, str]] = {}
2250
2275
  for worker_id, details_value in raw.items():
2251
- if worker_id not in analysis_workers:
2276
+ if worker_id not in finding_sources:
2252
2277
  raise ConvergenceContractError(
2253
- f"{label}.discoveredBy contains non-analysis worker: {worker_id}"
2278
+ f"{label}.discoveredBy contains a worker that produces no "
2279
+ f"findings in this run: {worker_id}"
2254
2280
  )
2255
2281
  details = _object(details_value, f"{label}.discoveredBy.{worker_id}")
2256
2282
  parsed[worker_id] = {
@@ -2269,7 +2295,7 @@ def _parse_discovered_by(
2269
2295
  def _parse_source_items(
2270
2296
  value: Any,
2271
2297
  label: str,
2272
- analysis_workers: list[str],
2298
+ finding_sources: list[str],
2273
2299
  ) -> list[dict[str, str]]:
2274
2300
  if not isinstance(value, list) or not value:
2275
2301
  raise ConvergenceContractError(f"{label}.sourceItems must be a non-empty array")
@@ -2277,10 +2303,10 @@ def _parse_source_items(
2277
2303
  for index, raw in enumerate(value):
2278
2304
  item = _object(raw, f"{label}.sourceItems[{index}]")
2279
2305
  worker = _required_string(item, "worker", f"{label}.sourceItems[{index}]")
2280
- if worker not in analysis_workers:
2306
+ if worker not in finding_sources:
2281
2307
  raise ConvergenceContractError(
2282
- f"{label}.sourceItems contains non-analysis worker {worker}; "
2283
- "report-writer never votes"
2308
+ f"{label}.sourceItems contains {worker}, which produces no "
2309
+ "findings in this run; report-writer never votes"
2284
2310
  )
2285
2311
  items.append(
2286
2312
  {