okstra 0.202.0 → 0.205.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 (263) hide show
  1. package/README.md +7 -6
  2. package/dist/cli-registry.mjs +7 -7
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +50 -124
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/dist/commands/memory/memory.mjs +41 -8
  7. package/dist/commands/memory/memory.mjs.map +1 -1
  8. package/dist/lib/install-assets.mjs +3 -0
  9. package/dist/lib/install-assets.mjs.map +1 -1
  10. package/dist/lib/runtime-manifest.mjs +2 -1
  11. package/dist/lib/runtime-manifest.mjs.map +1 -1
  12. package/dist/lib/types.d.mts +2 -1
  13. package/docs/architecture/storage-model.md +14 -11
  14. package/docs/architecture.md +26 -20
  15. package/docs/cli.md +15 -12
  16. package/docs/contributor-change-matrix.md +3 -2
  17. package/docs/performance-improvement-plan-v2.md +3 -9
  18. package/docs/project-structure-overview.md +39 -11
  19. package/docs/task-process/README.md +1 -1
  20. package/docs/task-process/common-flow.md +1 -1
  21. package/docs/task-process/final-verification.md +3 -1
  22. package/docs/task-process/implementation-option-selection.md +1 -1
  23. package/docs/task-process/implementation.md +1 -1
  24. package/docs/task-process/release-handoff.md +36 -39
  25. package/package.json +1 -2
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/agents/common.json +28 -0
  28. package/runtime/agents/operations/code-review.json +6 -0
  29. package/runtime/agents/operations/report-translation.json +6 -0
  30. package/runtime/agents/operations/schedule-verification.json +6 -0
  31. package/runtime/agents/roles/analyser.json +18 -0
  32. package/runtime/agents/roles/critic.json +18 -0
  33. package/runtime/agents/roles/designer.json +18 -0
  34. package/runtime/agents/roles/implementer.json +20 -0
  35. package/runtime/agents/roles/leader.json +20 -0
  36. package/runtime/agents/roles/planner.json +18 -0
  37. package/runtime/agents/roles/report-writer.json +19 -0
  38. package/runtime/agents/roles/translator.json +19 -0
  39. package/runtime/agents/roles/verifier.json +18 -0
  40. package/runtime/bin/lib/okstra/usage.sh +5 -5
  41. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  42. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  43. package/runtime/prompts/duties/analysis-worker.json +32 -0
  44. package/runtime/prompts/duties/code-reviewer.json +32 -0
  45. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  46. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  47. package/runtime/prompts/duties/discovery-worker.json +32 -0
  48. package/runtime/prompts/duties/implementation-executor.json +32 -0
  49. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  50. package/runtime/prompts/duties/lead.json +32 -0
  51. package/runtime/prompts/duties/planning-worker.json +36 -0
  52. package/runtime/prompts/duties/report-writer.json +32 -0
  53. package/runtime/prompts/duties/reverification-worker.json +32 -0
  54. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  55. package/runtime/prompts/duties/scope-critic.json +32 -0
  56. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  57. package/runtime/prompts/duties/translator.json +32 -0
  58. package/runtime/prompts/launch.template.md +2 -1
  59. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  60. package/runtime/prompts/lead/convergence.md +4 -4
  61. package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
  62. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  63. package/runtime/prompts/lead/report-writer.md +3 -3
  64. package/runtime/prompts/profiles/_common-contract.md +2 -2
  65. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  66. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  67. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  68. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  69. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  70. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  71. package/runtime/prompts/profiles/error-analysis.json +39 -0
  72. package/runtime/prompts/profiles/error-analysis.md +0 -25
  73. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  74. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  75. package/runtime/prompts/profiles/final-verification.json +30 -0
  76. package/runtime/prompts/profiles/final-verification.md +4 -23
  77. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  78. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  79. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  80. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  81. package/runtime/prompts/profiles/implementation-planning.md +4 -29
  82. package/runtime/prompts/profiles/implementation.json +30 -0
  83. package/runtime/prompts/profiles/implementation.md +1 -20
  84. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  85. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  86. package/runtime/prompts/profiles/project-analysis.json +31 -0
  87. package/runtime/prompts/profiles/project-analysis.md +0 -20
  88. package/runtime/prompts/profiles/release-handoff.json +5 -0
  89. package/runtime/prompts/profiles/release-handoff.md +74 -74
  90. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  91. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  92. package/runtime/prompts/profiles/technical-verification.json +39 -0
  93. package/runtime/prompts/profiles/technical-verification.md +0 -25
  94. package/runtime/prompts/wizard/prompts.ko.json +14 -18
  95. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  96. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  97. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  100. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  101. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  102. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  103. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  104. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  105. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  107. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  108. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  109. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  110. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  111. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  112. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  113. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  114. package/runtime/python/okstra_ctl/cmux.py +69 -20
  115. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  116. package/runtime/python/okstra_ctl/conformance.py +43 -0
  117. package/runtime/python/okstra_ctl/consumers.py +23 -8
  118. package/runtime/python/okstra_ctl/container.py +31 -8
  119. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  120. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  121. package/runtime/python/okstra_ctl/convergence_engine.py +38 -0
  122. package/runtime/python/okstra_ctl/convergence_provenance.py +7 -1
  123. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  124. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  125. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  126. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  127. package/runtime/python/okstra_ctl/error_report.py +4 -3
  128. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  129. package/runtime/python/okstra_ctl/handoff.py +384 -286
  130. package/runtime/python/okstra_ctl/handoff_verification.py +25 -6
  131. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  132. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  133. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  134. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  135. package/runtime/python/okstra_ctl/manager_cli.py +92 -4
  136. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  137. package/runtime/python/okstra_ctl/manager_paths.py +14 -3
  138. package/runtime/python/okstra_ctl/manager_store.py +210 -3
  139. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  140. package/runtime/python/okstra_ctl/manager_view.py +2 -1
  141. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  142. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  143. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  144. package/runtime/python/okstra_ctl/models.py +1 -1
  145. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  146. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  147. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  148. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  149. package/runtime/python/okstra_ctl/paths.py +3 -0
  150. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  151. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  152. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  153. package/runtime/python/okstra_ctl/render.py +79 -0
  154. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  155. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  156. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  157. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  158. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  159. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  160. package/runtime/python/okstra_ctl/rollup.py +3 -1
  161. package/runtime/python/okstra_ctl/run.py +76 -29
  162. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  163. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  164. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  165. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  166. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  167. package/runtime/python/okstra_ctl/team.py +22 -13
  168. package/runtime/python/okstra_ctl/time_report.py +2 -1
  169. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  170. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  171. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  172. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  173. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  174. package/runtime/python/okstra_ctl/wizard/steps_plan.py +12 -21
  175. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  176. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  177. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  178. package/runtime/python/okstra_ctl/workflow.py +1 -1
  179. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  180. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  181. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  182. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  183. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  184. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  185. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  186. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  187. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  188. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  189. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  190. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  191. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  192. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  193. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  194. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  195. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  196. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  197. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  198. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  199. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  200. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  201. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  202. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  203. package/runtime/skills/okstra-manager/SKILL.md +18 -2
  204. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  205. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  206. package/runtime/skills/okstra-run/SKILL.md +31 -12
  207. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  208. package/runtime/skills/okstra-setup/SKILL.md +12 -10
  209. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  210. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  211. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  212. package/runtime/templates/manager/view.template.html +1 -0
  213. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  214. package/runtime/templates/reports/brief.template.md +14 -4
  215. package/runtime/templates/reports/html/i18n/en.json +5 -4
  216. package/runtime/templates/reports/html/i18n/ko.json +5 -4
  217. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  218. package/runtime/templates/reports/i18n/en.json +1 -1
  219. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  220. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  221. package/runtime/templates/translator-prompt-preamble.md +36 -0
  222. package/runtime/validators/checks/validate-assets-01.py +7 -8
  223. package/runtime/validators/validate-brief.py +70 -0
  224. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  225. package/runtime/validators/validate-run.py +72 -15
  226. package/runtime/validators/validate-schedule.py +9 -0
  227. package/docs/for-ai/README.md +0 -68
  228. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  229. package/docs/for-ai/skills/okstra-chat.md +0 -34
  230. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  231. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  232. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  233. package/docs/for-ai/skills/okstra-manager.md +0 -86
  234. package/docs/for-ai/skills/okstra-memory.md +0 -126
  235. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  236. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  237. package/docs/for-ai/skills/okstra-run.md +0 -250
  238. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  239. package/docs/for-ai/skills/okstra-setup.md +0 -167
  240. package/docs/for-ai/skills/okstra-usage.md +0 -29
  241. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  242. package/runtime/agents/workers/claude-worker.md +0 -128
  243. package/runtime/agents/workers/report-writer-worker.md +0 -37
  244. package/runtime/agents/workers/translator-worker.md +0 -63
  245. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  246. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  247. package/runtime/prompts/duties/analysis-worker.md +0 -44
  248. package/runtime/prompts/duties/code-reviewer.md +0 -44
  249. package/runtime/prompts/duties/common.md +0 -39
  250. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  251. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  252. package/runtime/prompts/duties/discovery-worker.md +0 -44
  253. package/runtime/prompts/duties/implementation-executor.md +0 -44
  254. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  255. package/runtime/prompts/duties/lead.md +0 -44
  256. package/runtime/prompts/duties/planning-worker.md +0 -52
  257. package/runtime/prompts/duties/report-writer.md +0 -44
  258. package/runtime/prompts/duties/reverification-worker.md +0 -44
  259. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  260. package/runtime/prompts/duties/scope-critic.md +0 -44
  261. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  262. package/runtime/prompts/duties/translator.md +0 -44
  263. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -1,10 +1,17 @@
1
- """Parse canonical role requirements declared in phase profiles."""
1
+ """Load the role structure a phase profile declares.
2
+
3
+ The structure lives in `prompts/profiles/<task-type>.json` beside the profile's
4
+ Markdown task instructions. The Markdown owns procedure and per-task inputs and
5
+ outputs; it declares no machine-readable roster. See ADR-0017.
6
+ """
2
7
  from __future__ import annotations
3
8
 
4
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, field
5
10
  from pathlib import Path
11
+ from typing import Union
6
12
 
7
13
  from .domain.role import RoleCatalogError, normalize_role, role_for_duty
14
+ from .json_boundary import JsonBoundaryError, load_owned_object
8
15
 
9
16
 
10
17
  class RoleProfileError(ValueError):
@@ -12,13 +19,16 @@ class RoleProfileError(ValueError):
12
19
 
13
20
 
14
21
  @dataclass(frozen=True)
15
- class RoleRequirement:
22
+ class StaticRoleRequirement:
23
+ """A role slot whose count the run fixes before dispatch."""
24
+
16
25
  role: str
17
26
  min_count: int
18
27
  recommended_count: int
19
28
  max_count: int
20
29
  duty: str
21
- dynamic: bool = False
30
+
31
+ dynamic: bool = field(default=False, init=False)
22
32
 
23
33
  def signature(self) -> tuple[str, int, int, int, str, bool]:
24
34
  return (
@@ -27,138 +37,129 @@ class RoleRequirement:
27
37
  self.recommended_count,
28
38
  self.max_count,
29
39
  self.duty,
30
- self.dynamic,
40
+ False,
31
41
  )
32
42
 
33
43
 
44
+ @dataclass(frozen=True)
45
+ class DynamicRoleRequirement:
46
+ """A role slot the runtime adds once per activated source slot.
47
+
48
+ It carries no counts. Expressing it as 0/0/0 made a dynamic role look like a
49
+ static role nobody selected, which is a different thing: the upper bound is
50
+ the number of activated source slots, not zero.
51
+ """
52
+
53
+ role: str
54
+ duty: str
55
+ source_role_ids: tuple[str, ...]
56
+ activation: str
57
+
58
+ dynamic: bool = field(default=True, init=False)
59
+
60
+ def signature(self) -> tuple[str, str, tuple[str, ...], str, bool]:
61
+ return (self.role, self.duty, self.source_role_ids, self.activation, True)
62
+
63
+
64
+ RoleRequirement = Union[StaticRoleRequirement, DynamicRoleRequirement]
65
+
66
+
34
67
  @dataclass(frozen=True)
35
68
  class RoleProfile:
36
69
  roles: tuple[RoleRequirement, ...]
37
70
 
38
- def role_signature(self) -> tuple[tuple[str, int, int, int, str, bool], ...]:
71
+ def role_signature(self) -> tuple[tuple[object, ...], ...]:
39
72
  return tuple(requirement.signature() for requirement in self.roles)
40
73
 
41
74
 
42
- _REQUIRED_FIELDS = frozenset({"role", "min", "recommended", "max", "duty"})
43
- _ALLOWED_FIELDS = _REQUIRED_FIELDS | {"dynamic"}
44
-
45
-
46
75
  def load_role_profile(path: Path) -> RoleProfile:
47
- """Load and validate the one fenced YAML ``roles:`` declaration in a profile."""
48
- profile_path = Path(path)
76
+ """Load the structure contract for the profile at *path*.
77
+
78
+ Callers hold the profile's Markdown path because that is what the rest of
79
+ the prepare step reads, so this resolves the `.json` sibling itself. Passing
80
+ the `.json` path directly is the same call.
81
+ """
82
+ profile_path = Path(path).with_suffix(".json")
49
83
  try:
50
- text = profile_path.read_text(encoding="utf-8")
84
+ payload = load_owned_object(profile_path, artifact="role profile")
85
+ except JsonBoundaryError as exc:
86
+ raise RoleProfileError(f"invalid role profile: {profile_path}: {exc}") from exc
51
87
  except OSError as exc:
52
88
  raise RoleProfileError(f"cannot read role profile: {profile_path}") from exc
53
- raw_roles, errors = _parse_roles_block(text)
89
+
90
+ errors: list[str] = []
91
+ if payload.get("schemaVersion") != "1.0":
92
+ errors.append("role profile schemaVersion must be 1.0")
93
+ raw_roles = payload.get("roles")
94
+ if not isinstance(raw_roles, list):
95
+ raise RoleProfileError("invalid role profile:\n- roles must be a list")
96
+
54
97
  requirements = tuple(
55
- _validate_requirement(raw, index, errors)
98
+ _requirement(raw, index, errors)
56
99
  for index, raw in enumerate(raw_roles, 1)
57
100
  )
58
101
  _validate_static_role_duplicates(requirements, errors)
102
+ _validate_dynamic_sources(requirements, errors)
59
103
  if errors:
60
104
  raise RoleProfileError("invalid role profile:\n- " + "\n- ".join(errors))
61
105
  return RoleProfile(requirements)
62
106
 
63
107
 
64
- def _parse_roles_block(text: str) -> tuple[list[dict[str, str]], list[str]]:
65
- blocks = _yaml_blocks_with_roles(text)
66
- if len(blocks) != 1:
67
- return [], ["profile must declare exactly one fenced YAML roles block"]
68
- lines = blocks[0]
69
- if lines == ["roles: []"]:
70
- return [], []
71
- if not lines or lines[0] != "roles:":
72
- return [], ["roles block must start with roles:"]
73
- roles: list[dict[str, str]] = []
74
- errors: list[str] = []
75
- current: dict[str, str] | None = None
76
- for line_number, line in enumerate(lines[1:], 2):
77
- if not line.strip():
78
- continue
79
- if line.startswith(" - "):
80
- if current is not None:
81
- roles.append(current)
82
- current = {}
83
- _add_yaml_field(current, line[4:], line_number, errors)
84
- continue
85
- if line.startswith(" ") and current is not None:
86
- _add_yaml_field(current, line[4:], line_number, errors)
87
- continue
88
- errors.append(f"roles block line {line_number} is not a role field")
89
- if current is not None:
90
- roles.append(current)
91
- elif not errors:
92
- errors.append("roles block must contain a role or use roles: []")
93
- return roles, errors
94
-
95
-
96
- def _yaml_blocks_with_roles(text: str) -> list[list[str]]:
97
- blocks: list[list[str]] = []
98
- current: list[str] | None = None
99
- for line in text.splitlines():
100
- if line.strip() == "```yaml":
101
- current = []
102
- continue
103
- if line.strip() == "```" and current is not None:
104
- if any(item.strip().startswith("roles:") for item in current):
105
- blocks.append(current)
106
- current = None
107
- continue
108
- if current is not None:
109
- current.append(line)
110
- return blocks
111
-
112
-
113
- def _add_yaml_field(
114
- fields: dict[str, str], line: str, line_number: int, errors: list[str],
115
- ) -> None:
116
- if ":" not in line:
117
- errors.append(f"roles block line {line_number} must contain key: value")
118
- return
119
- key, value = (part.strip() for part in line.split(":", 1))
120
- if not key or not value:
121
- errors.append(f"roles block line {line_number} must contain key: value")
122
- return
123
- if key in fields:
124
- errors.append(f"roles block line {line_number} repeats {key}")
125
- return
126
- fields[key] = value
127
-
128
-
129
- def _validate_requirement(
130
- raw: dict[str, str], index: int, errors: list[str],
131
- ) -> RoleRequirement:
108
+ def _requirement(raw: object, index: int, errors: list[str]) -> RoleRequirement:
132
109
  prefix = f"role requirement {index}"
133
- for field in sorted(_REQUIRED_FIELDS - raw.keys()):
134
- errors.append(f"{prefix} is missing {field}")
135
- for field in sorted(raw.keys() - _ALLOWED_FIELDS):
136
- errors.append(f"{prefix} has unknown field {field}")
137
-
138
- role = _validate_role(raw.get("role", ""), prefix, errors)
139
- min_count = _validate_non_negative_integer(
140
- raw.get("min", ""), "min", prefix, errors,
110
+ if not isinstance(raw, dict):
111
+ errors.append(f"{prefix} must be an object")
112
+ return StaticRoleRequirement("", 0, 0, 0, "")
113
+ mode = raw.get("mode")
114
+ if mode == "dynamic":
115
+ return _dynamic_requirement(raw, prefix, errors)
116
+ if mode == "static":
117
+ return _static_requirement(raw, prefix, errors)
118
+ errors.append(f"{prefix} mode must be static or dynamic")
119
+ return StaticRoleRequirement("", 0, 0, 0, "")
120
+
121
+
122
+ def _static_requirement(
123
+ raw: dict[str, object], prefix: str, errors: list[str]
124
+ ) -> StaticRoleRequirement:
125
+ role = _validate_role(raw.get("roleId"), prefix, errors)
126
+ duty = _validate_duty(raw.get("dutyId"), role, prefix, errors)
127
+ counts = tuple(
128
+ _validate_count(raw.get(key), key, prefix, errors)
129
+ for key in ("min", "recommended", "max")
141
130
  )
142
- recommended_count = _validate_non_negative_integer(
143
- raw.get("recommended", ""), "recommended", prefix, errors,
144
- )
145
- max_count = _validate_non_negative_integer(
146
- raw.get("max", ""), "max", prefix, errors,
147
- )
148
- duty = _validate_duty(raw.get("duty", ""), role, prefix, errors)
149
- dynamic = _validate_dynamic(raw.get("dynamic", "false"), prefix, errors)
150
- if not (min_count <= recommended_count <= max_count):
131
+ if not (counts[0] <= counts[1] <= counts[2]):
151
132
  errors.append(f"{prefix} min must be <= recommended <= max")
152
- if dynamic and (min_count != 0 or recommended_count != 0 or max_count != 0):
153
- errors.append(
154
- f"{prefix} dynamic roles must have min, recommended, and max of zero"
155
- )
156
- return RoleRequirement(
157
- role, min_count, recommended_count, max_count, duty, dynamic,
158
- )
159
-
160
-
161
- def _validate_role(raw: str, prefix: str, errors: list[str]) -> str:
133
+ return StaticRoleRequirement(role, counts[0], counts[1], counts[2], duty)
134
+
135
+
136
+ def _dynamic_requirement(
137
+ raw: dict[str, object], prefix: str, errors: list[str]
138
+ ) -> DynamicRoleRequirement:
139
+ role = _validate_role(raw.get("roleId"), prefix, errors)
140
+ duty = _validate_duty(raw.get("dutyId"), role, prefix, errors)
141
+ sources = raw.get("sourceRoleIds")
142
+ if (
143
+ not isinstance(sources, list)
144
+ or not sources
145
+ or not all(isinstance(item, str) and item for item in sources)
146
+ ):
147
+ errors.append(f"{prefix} sourceRoleIds must be a non-empty list of role ids")
148
+ sources = []
149
+ activation = raw.get("activation")
150
+ if activation != "per-selected-source":
151
+ errors.append(f"{prefix} activation must be per-selected-source")
152
+ activation = "per-selected-source"
153
+ for key in ("min", "recommended", "max"):
154
+ if key in raw:
155
+ errors.append(f"{prefix} dynamic roles declare no {key}")
156
+ return DynamicRoleRequirement(role, duty, tuple(sources), str(activation))
157
+
158
+
159
+ def _validate_role(raw: object, prefix: str, errors: list[str]) -> str:
160
+ if not isinstance(raw, str) or not raw:
161
+ errors.append(f"{prefix} roleId must be a role id")
162
+ return ""
162
163
  try:
163
164
  return normalize_role(raw)
164
165
  except RoleCatalogError as exc:
@@ -166,7 +167,10 @@ def _validate_role(raw: str, prefix: str, errors: list[str]) -> str:
166
167
  return raw
167
168
 
168
169
 
169
- def _validate_duty(raw: str, role: str, prefix: str, errors: list[str]) -> str:
170
+ def _validate_duty(raw: object, role: str, prefix: str, errors: list[str]) -> str:
171
+ if not isinstance(raw, str) or not raw:
172
+ errors.append(f"{prefix} dutyId must be a duty id")
173
+ return ""
170
174
  try:
171
175
  duty_role = role_for_duty(raw)
172
176
  except RoleCatalogError as exc:
@@ -177,25 +181,11 @@ def _validate_duty(raw: str, role: str, prefix: str, errors: list[str]) -> str:
177
181
  return raw
178
182
 
179
183
 
180
- def _validate_non_negative_integer(raw: str, field: str, prefix: str, errors: list[str]) -> int:
181
- try:
182
- value = int(raw)
183
- except ValueError:
184
- errors.append(f"{prefix} {field} must be a non-negative integer")
185
- return 0
186
- if value < 0 or str(value) != raw:
187
- errors.append(f"{prefix} {field} must be a non-negative integer")
184
+ def _validate_count(raw: object, field_name: str, prefix: str, errors: list[str]) -> int:
185
+ if not isinstance(raw, int) or isinstance(raw, bool) or raw < 0:
186
+ errors.append(f"{prefix} {field_name} must be a non-negative integer")
188
187
  return 0
189
- return value
190
-
191
-
192
- def _validate_dynamic(raw: str, prefix: str, errors: list[str]) -> bool:
193
- if raw == "true":
194
- return True
195
- if raw == "false":
196
- return False
197
- errors.append(f"{prefix} dynamic must be true or false")
198
- return False
188
+ return raw
199
189
 
200
190
 
201
191
  def _validate_static_role_duplicates(
@@ -208,3 +198,26 @@ def _validate_static_role_duplicates(
208
198
  if requirement.role in static_roles:
209
199
  errors.append(f"duplicate static role: {requirement.role}")
210
200
  static_roles.add(requirement.role)
201
+
202
+
203
+ def _validate_dynamic_sources(
204
+ requirements: tuple[RoleRequirement, ...], errors: list[str],
205
+ ) -> None:
206
+ """A dynamic slot activates per source slot, so its sources must be declared.
207
+
208
+ A source the profile never declares can never activate, which makes the row
209
+ unreachable rather than invalid at dispatch time — the failure would surface
210
+ as a round that silently opens no verifier.
211
+ """
212
+ static_roles = {
213
+ requirement.role for requirement in requirements if not requirement.dynamic
214
+ }
215
+ for requirement in requirements:
216
+ if not requirement.dynamic:
217
+ continue
218
+ unknown = sorted(set(requirement.source_role_ids) - static_roles)
219
+ if unknown:
220
+ errors.append(
221
+ f"dynamic role {requirement.role} has unknown sourceRoleIds: "
222
+ + ", ".join(unknown)
223
+ )
@@ -142,9 +142,11 @@ def build_rollup(project_root: Path, task_group: str | None) -> dict:
142
142
  _CLI_EPILOG = r"""Usage:
143
143
  okstra rollup [--task-group <group>] [--project-root <dir>] [--cwd <dir>]
144
144
 
145
- Read-only. Prints a JSON roll-up of every catalog task (optionally scoped to one
145
+ Prints a JSON roll-up of every catalog task (optionally scoped to one
146
146
  task-group): per-task run count, elapsed time (raw ms), error count, and the
147
147
  latest report path, plus group-level totals and status/category/phase tallies.
148
+ Resolving a task may record a finished implementation outcome in its task
149
+ manifest, the task catalog, and consumers.jsonl; nothing else is written.
148
150
  With no --task-group it covers the whole project catalog. Time is raw ms; the
149
151
  caller formats HH:MM:SS and synthesizes the prose digest from the report files.
150
152
  """
@@ -1827,10 +1827,9 @@ def _materialize_release_handoff_input(
1827
1827
  brief 는 entry phase 의 입력물이라 release-handoff 에는 없다 — 이 run 의
1828
1828
  `--task-brief` 자리는 여기서 생성된 input 문서가 채운다. stage 자격은
1829
1829
  okstra_ctl.handoff 의 SSOT 판정을 prepare 에서도 그대로 강제한다.
1830
- 반환: ctx 에 올릴 {"HANDOFF_MODE": ..., "HANDOFF_STAGES": ...}."""
1830
+ 반환: ctx 에 올릴 {"HANDOFF_STAGES": ...}."""
1831
1831
  from .consumers import read_consumers
1832
- from .handoff import (HandoffError, _require_eligible,
1833
- latest_whole_task_fv_release_ready)
1832
+ from .handoff import HandoffError, _require_eligible, compute_eligibility
1834
1833
  from .paths import task_dir
1835
1834
  from .render import render_template_with_ctx
1836
1835
  from .run_context import _now_task_date
@@ -1840,8 +1839,8 @@ def _materialize_release_handoff_input(
1840
1839
  raise PrepareError(
1841
1840
  "--task-brief is not accepted for --task-type release-handoff — "
1842
1841
  "the input document is generated from the cited final-verification "
1843
- "reports (pick stages via --stages, or leave it empty for "
1844
- "whole-task)"
1842
+ "reports (pick stages via --stages, or leave it empty to take "
1843
+ "every eligible stage)"
1845
1844
  )
1846
1845
  if not inp.approved_plan_path:
1847
1846
  raise PrepareError(
@@ -1868,21 +1867,20 @@ def _materialize_release_handoff_input(
1868
1867
  _require_eligible(stage_map, rows, nums)
1869
1868
  except HandoffError as exc:
1870
1869
  raise PrepareError(str(exc)) from exc
1871
- mode = "stage-group"
1872
- stages_csv = ",".join(str(n) for n in nums)
1873
- report_rows = _collect_handoff_source_report_rows(rows, nums)
1874
1870
  else:
1875
- report = latest_whole_task_fv_release_ready(
1876
- project_root, inp.project_id, inp.task_group, inp.task_id)
1877
- if not report:
1871
+ eligibility = compute_eligibility(stage_map, rows)
1872
+ nums = [e["stage"] for e in eligibility if e["eligible"]]
1873
+ if not nums:
1874
+ blocked = "; ".join(
1875
+ f"stage {e['stage']}: {', '.join(e['reasons'])}"
1876
+ for e in eligibility) or "(no stage in the Stage Map)"
1878
1877
  raise PrepareError(
1879
- "whole-task release-handoff requires the latest completed, release-ready whole-task "
1880
- "final-verification report with matching target evidence — re-run final-verification, "
1881
- "or pass --stages <csv> for a stage-group PR"
1878
+ "release-handoff has no eligible stage — every stage must "
1879
+ "carry a release-ready single-stage final-verification of its "
1880
+ f"recorded implementation commit. {blocked}"
1882
1881
  )
1883
- mode = "whole-task"
1884
- stages_csv = ""
1885
- report_rows = [("(all)", Path(report))]
1882
+ stages_csv = ",".join(str(n) for n in nums)
1883
+ report_rows = _collect_handoff_source_report_rows(rows, nums)
1886
1884
 
1887
1885
  source_rows, source_conditions = _render_handoff_sources(report_rows, project_root)
1888
1886
 
@@ -1902,13 +1900,12 @@ def _materialize_release_handoff_input(
1902
1900
  "PROJECT_ID": inp.project_id,
1903
1901
  "TASK_TYPE": inp.task_type,
1904
1902
  "TASK_DATE": _now_task_date(),
1905
- "HANDOFF_MODE": mode,
1906
- "HANDOFF_STAGES": stages_csv or "(all)",
1903
+ "HANDOFF_STAGES": stages_csv,
1907
1904
  "HANDOFF_SOURCE_REPORTS": source_rows,
1908
1905
  "HANDOFF_CONDITIONS": source_conditions,
1909
1906
  })
1910
1907
  inp.brief_path = out
1911
- return {"HANDOFF_MODE": mode, "HANDOFF_STAGES": stages_csv}
1908
+ return {"HANDOFF_STAGES": stages_csv}
1912
1909
 
1913
1910
 
1914
1911
  QA_COMMAND_EXECUTING_TASK_TYPES = ("implementation", "final-verification")
@@ -2768,7 +2765,7 @@ def recommended_role_models(
2768
2765
  codex 이고 다른 provider 요청은 거부된다(`resolve_lead_provider`). 그래서
2769
2766
  `lead_provider` 를 받아 그 provider 의 기본값으로 해소한다. 받지 않으면
2770
2767
  role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
2771
- 화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-5.6-sol` 을 배정한다.
2768
+ 화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-6-sol` 을 배정한다.
2772
2769
  """
2773
2770
  lead_provider_id = lead_provider or "claude"
2774
2771
  report_writer_provider_id = report_writer_provider or "claude"
@@ -4250,6 +4247,9 @@ def _write_bundle_artifacts(
4250
4247
  ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
4251
4248
 
4252
4249
 
4250
+ CONTRACT_FORMAT_VERSION = 2
4251
+
4252
+
4253
4253
  def _prepare_agent_contract(
4254
4254
  inp: PrepareInputs,
4255
4255
  ctx: dict,
@@ -4258,11 +4258,14 @@ def _prepare_agent_contract(
4258
4258
  ) -> None:
4259
4259
  source = _duty_catalog_source(workspace_root)
4260
4260
  destination = Path(ctx["DUTY_CONTRACT_ROOT"])
4261
- _snapshot_duty_catalog(source, destination)
4261
+ _snapshot_duty_catalog(source, destination, _common_contract_source(workspace_root))
4262
4262
  digest = digest_duty_catalog(destination)
4263
4263
  allowed_audiences = _allowed_agent_audiences(inp, models)
4264
4264
  contract = {
4265
4265
  "schemaVersion": 1,
4266
+ # 이 run 이 동결한 계약의 형식. 2 = JSON 계약(공통·역할·직무). 1 이거나
4267
+ # 없는 run 은 Markdown 직무를 동결했고, 그 파서는 더 이상 없다.
4268
+ "contractFormatVersion": CONTRACT_FORMAT_VERSION,
4266
4269
  "dutyRootPath": ctx["DUTY_CONTRACT_ROOT_RELATIVE_PATH"],
4267
4270
  "catalogDigest": digest,
4268
4271
  "invocationReservationRootPath": (
@@ -4295,14 +4298,35 @@ def _duty_catalog_source(workspace_root: Path) -> Path:
4295
4298
  okstra_home() / "prompts" / "duties",
4296
4299
  )
4297
4300
  for candidate in candidates:
4298
- if (candidate / "common.md").is_file():
4301
+ if (candidate / "lead.json").is_file():
4299
4302
  return candidate
4300
4303
  raise PrepareError("agent duty catalog is missing from the runtime")
4301
4304
 
4302
4305
 
4303
- def _snapshot_duty_catalog(source: Path, destination: Path) -> None:
4306
+ def _common_contract_source(workspace_root: Path) -> Path:
4307
+ """공통 계약의 정본 위치. 직무가 아니므로 `agents/` 가 소유한다(ADR-0017)."""
4308
+ for candidate in (
4309
+ workspace_root / "agents" / "common.json",
4310
+ okstra_home() / "agents" / "common.json",
4311
+ ):
4312
+ if candidate.is_file():
4313
+ return candidate
4314
+ raise PrepareError("agent common contract is missing from the runtime")
4315
+
4316
+
4317
+ def _snapshot_duty_catalog(
4318
+ source: Path, destination: Path, common_source: Path,
4319
+ ) -> None:
4320
+ """이 run 이 쓸 계약을 한 폴더로 동결한다.
4321
+
4322
+ 공통 계약은 `agents/` 가 소유하지만 동결본은 한 뿌리여야 한다 — 두 곳에서
4323
+ 읽으면 run 이 시작된 뒤 둘 중 하나만 바뀌어도 그 run 의 다이제스트가
4324
+ 자기 입력과 맞지 않게 된다.
4325
+ """
4304
4326
  if destination.exists():
4305
- if digest_duty_catalog(destination) != digest_duty_catalog(source):
4327
+ if digest_duty_catalog(destination) != _runtime_catalog_digest(
4328
+ source, common_source
4329
+ ):
4306
4330
  raise PrepareError("existing run duty snapshot conflicts with runtime catalog")
4307
4331
  return
4308
4332
  destination.parent.mkdir(parents=True, exist_ok=True)
@@ -4313,15 +4337,38 @@ def _snapshot_duty_catalog(source: Path, destination: Path) -> None:
4313
4337
  ))
4314
4338
  try:
4315
4339
  shutil.copytree(source, temp_dir, dirs_exist_ok=True)
4340
+ shutil.copyfile(common_source, temp_dir / "common.json")
4341
+ shutil.copytree(
4342
+ common_source.parent / "roles", temp_dir / "roles", dirs_exist_ok=True
4343
+ )
4316
4344
  os.rename(temp_dir, destination)
4317
4345
  except FileExistsError as exc:
4318
- if digest_duty_catalog(destination) != digest_duty_catalog(source):
4346
+ if digest_duty_catalog(destination) != _runtime_catalog_digest(
4347
+ source, common_source
4348
+ ):
4319
4349
  raise PrepareError("run duty snapshot publication conflict") from exc
4320
4350
  finally:
4321
4351
  if temp_dir.exists():
4322
4352
  shutil.rmtree(temp_dir)
4323
4353
 
4324
4354
 
4355
+ def _runtime_catalog_digest(source: Path, common_source: Path) -> str:
4356
+ """동결본과 비교할 런타임 카탈로그의 다이제스트.
4357
+
4358
+ 동결본은 `common.json` 을 포함하므로 런타임 쪽도 같은 구성으로 세어야 한다.
4359
+ """
4360
+ staged = Path(tempfile.mkdtemp(prefix=".okstra-catalog-digest."))
4361
+ try:
4362
+ shutil.copytree(source, staged, dirs_exist_ok=True)
4363
+ shutil.copyfile(common_source, staged / "common.json")
4364
+ shutil.copytree(
4365
+ common_source.parent / "roles", staged / "roles", dirs_exist_ok=True
4366
+ )
4367
+ return digest_duty_catalog(staged)
4368
+ finally:
4369
+ shutil.rmtree(staged, ignore_errors=True)
4370
+
4371
+
4325
4372
  def _allowed_agent_audiences(
4326
4373
  inp: PrepareInputs,
4327
4374
  models: _ModelBindings,
@@ -4792,7 +4839,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4792
4839
 
4793
4840
  # release-handoff: 검증 보고서 인용 input 문서를 생성해 brief 자리에 채운다.
4794
4841
  # 이후의 모든 brief 소비 경로(material/instruction-set 복사)는 그대로 동작한다.
4795
- handoff_tokens = {"HANDOFF_MODE": "", "HANDOFF_STAGES": ""}
4842
+ handoff_tokens = {"HANDOFF_STAGES": ""}
4796
4843
  if inp.task_type == "release-handoff":
4797
4844
  handoff_tokens = _materialize_release_handoff_input(
4798
4845
  workspace_root, project_root, inp)
@@ -5250,8 +5297,8 @@ def build_prepare_argument_parser():
5250
5297
  p.add_argument(
5251
5298
  "--stages", default="", dest="stages",
5252
5299
  help=(
5253
- "release-handoff only. Comma-separated stage numbers to bundle "
5254
- "into one PR (stage-group mode); empty = whole-task mode. "
5300
+ "release-handoff only. Comma-separated stage numbers to open a "
5301
+ "PR for, one PR per stage; empty = every eligible stage. "
5255
5302
  "Distinct from --stage (implementation/final-verification "
5256
5303
  "Stage Map selection)."
5257
5304
  ),
@@ -33,9 +33,20 @@ _WORK_BREAKDOWN_SEPARATOR = ["---:", "---", "---:", "---", "---:"]
33
33
  # No `Task ID` and no `taskType`: the schedule is shared with people who do not
34
34
  # run okstra, so a task is named by its work and a row binds to the selection by
35
35
  # its `#` position — the same rule the `### <n>.` blocks follow.
36
- _AT_A_GLANCE_HEADER = [
37
- "#", "작업", "Category", "Priority", "Effort", "Days", "Risk",
38
- ]
36
+ # 두 번째 열은 작업 이름 열이고 그 제목은 문서 언어를 따른다(`작업` / `Travail`).
37
+ # 언어별 제목은 형식 게이트(`validate-schedule.py` 의 `glance_work_column`)가
38
+ # 검사한다. 여기서 한국어로 고정하면 번역본이 이 게이트를 통과할 수 없다.
39
+ _AT_A_GLANCE_FIXED_COLUMNS = ["Category", "Priority", "Effort", "Days", "Risk"]
40
+ _AT_A_GLANCE_WIDTH = 2 + len(_AT_A_GLANCE_FIXED_COLUMNS)
41
+
42
+
43
+ def _is_at_a_glance_header(cells: list[str]) -> bool:
44
+ return (
45
+ len(cells) == _AT_A_GLANCE_WIDTH
46
+ and cells[0] == "#"
47
+ and bool(cells[1])
48
+ and cells[2:] == _AT_A_GLANCE_FIXED_COLUMNS
49
+ )
39
50
  _EFFORT_HEADER = ["Size", "Criteria", "Day(s)"]
40
51
  _EFFORT_SIZES = {"S", "M", "L", "XL", "XXL"}
41
52
  _DAY_NUMBER = r"\d+(?:\.\d+)?"
@@ -594,9 +605,9 @@ def _parse_at_a_glance_table(
594
605
  if not line.strip().startswith("|"):
595
606
  break
596
607
  cells = split_pipe_row(line)
597
- if cells == _AT_A_GLANCE_HEADER:
608
+ if _is_at_a_glance_header(cells):
598
609
  break
599
- if len(cells) != len(_AT_A_GLANCE_HEADER):
610
+ if len(cells) != _AT_A_GLANCE_WIDTH:
600
611
  task_id = cells[1] if len(cells) > 1 else "<unknown>"
601
612
  violations.append(
602
613
  f"At a Glance: noncanonical row for task {task_id} "
@@ -621,7 +632,7 @@ def _parse_at_a_glance_rows(
621
632
  lines = section.splitlines()
622
633
  header_indexes = [
623
634
  index for index, line in enumerate(lines)
624
- if split_pipe_row(line) == _AT_A_GLANCE_HEADER
635
+ if _is_at_a_glance_header(split_pipe_row(line))
625
636
  ]
626
637
  if not header_indexes:
627
638
  return [], ["At a Glance: missing canonical 7-column table"]
@@ -82,14 +82,33 @@ def derive_stage_fix_carry(
82
82
  prev_report_seq=_REPORT_RE.search(report.name).group(1),
83
83
  prev_head_commit=head,
84
84
  failed_verifiers=[str(row.get("verifier", "")) for row in failed],
85
- blocking_findings=[
86
- str(row.get("discrepancy") or row.get("verifier") or "")
87
- for row in failed
88
- ],
85
+ blocking_findings=_blocking_findings(data, failed),
89
86
  routing_recommendation=_routing_line(impl.get("routingRecommendation")),
90
87
  )
91
88
 
92
89
 
90
+ def _blocking_findings(data: dict, failed: list[dict]) -> list[str]:
91
+ """이전 run 을 FAIL 로 만든 finding 목록.
92
+
93
+ 정본은 리포트의 `humanSummary.blockers` 다 — 스키마가 요구하는 배열이고,
94
+ FAIL 사유가 정적 검토 finding(F-NNN)이든 Discrepancy 규칙이든 그 문장이 여기
95
+ 있다. 검증자 행의 `discrepancy` 는 재실행 결과 차이를 적는 칸이라 FAIL 사유가
96
+ 아닐 수 있다. 실측(2026-09-22, jobs dev-10860 stage 1): Codex 의 FAIL 사유는
97
+ F-001~003 이었는데 캐리는 그 행의 VC-001 종료 코드 메모를 실었다.
98
+ blockers 가 비어 있는 옛 리포트만 검증자 행으로 대신한다.
99
+ """
100
+ summary = data.get("humanSummary")
101
+ blockers = summary.get("blockers") if isinstance(summary, dict) else None
102
+ if isinstance(blockers, list):
103
+ rows = [row.strip() for row in blockers if isinstance(row, str) and row.strip()]
104
+ if rows:
105
+ return rows
106
+ return [
107
+ str(row.get("discrepancy") or row.get("verifier") or "")
108
+ for row in failed
109
+ ]
110
+
111
+
93
112
  def _routing_line(value: object) -> str:
94
113
  """구조체 라우팅을 캐리 프롬프트에 실을 한 줄로 편다.
95
114