okstra 0.220.0 → 0.221.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.
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/lib/paths.mjs +1 -1
- package/dist/lib/paths.mjs.map +1 -1
- package/docs/architecture.md +2 -0
- package/docs/cli.md +7 -3
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +10 -2
- package/runtime/python/okstra_ctl/correction_cli.py +44 -5
- package/runtime/python/okstra_ctl/correction_round.py +40 -11
- package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
- package/runtime/python/okstra_ctl/domain/provider.py +2 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
- package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
- package/runtime/python/okstra_ctl/lead_progress.py +56 -11
- package/runtime/python/okstra_ctl/manager_cli.py +13 -1
- package/runtime/python/okstra_ctl/manager_launch.py +23 -2
- package/runtime/python/okstra_ctl/manager_sync.py +38 -2
- package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
- package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +4 -3
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +2 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +2 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
- package/runtime/python/okstra_ctl/render.py +7 -48
- package/runtime/python/okstra_ctl/report_assembly.py +5 -5
- package/runtime/python/okstra_ctl/report_inputs.py +10 -1
- package/runtime/python/okstra_ctl/report_language.py +4 -5
- package/runtime/python/okstra_ctl/run.py +29 -0
- package/runtime/python/okstra_ctl/stage_done.py +37 -3
- package/runtime/python/okstra_ctl/wizard/engine.py +1 -2
- package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
- package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
- package/runtime/skills/okstra-discussion/SKILL.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +3 -2
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/skills/okstra-setup/SKILL.md +2 -0
- package/runtime/skills/okstra-setup/references/project-config.md +34 -0
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/templates/reports/task-brief.template.md +3 -3
- package/runtime/validators/validate-run.py +13 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""프로젝트 MCP 서버 선언과 provider 별 도달성 판정.
|
|
2
|
+
|
|
3
|
+
선언은 논리 서버 이름과, provider CLI 가 그 서버를 나열하는 이름(`aliases`)만
|
|
4
|
+
갖는다. 어느 provider 가 볼 수 있는지는 각 어댑터의 `mcp_inventory` 가 답하며,
|
|
5
|
+
이 모듈은 provider 이름을 하나도 모른다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
|
|
18
|
+
|
|
19
|
+
from .domain.mcp import McpInventory, McpInventoryProbe
|
|
20
|
+
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
21
|
+
|
|
22
|
+
PROBE_TIMEOUT_SECONDS = 20.0
|
|
23
|
+
|
|
24
|
+
Reachability = Literal["reachable", "disabled", "missing", "unknown"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class McpDeclarationError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class McpServerDeclaration:
|
|
33
|
+
name: str
|
|
34
|
+
description: str
|
|
35
|
+
tools: tuple[str, ...]
|
|
36
|
+
aliases: tuple[str, ...] = ()
|
|
37
|
+
notes: str = ""
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict[str, Any]:
|
|
40
|
+
payload: dict[str, Any] = {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"tools": list(self.tools),
|
|
44
|
+
}
|
|
45
|
+
if self.aliases:
|
|
46
|
+
payload["aliases"] = list(self.aliases)
|
|
47
|
+
if self.notes:
|
|
48
|
+
payload["notes"] = self.notes
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ProviderCell:
|
|
54
|
+
provider_id: str
|
|
55
|
+
status: Reachability
|
|
56
|
+
visible_name: str = ""
|
|
57
|
+
note: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ServerReachability:
|
|
62
|
+
server: McpServerDeclaration
|
|
63
|
+
cells: tuple[ProviderCell, ...]
|
|
64
|
+
|
|
65
|
+
def reachable_from(self) -> tuple[str, ...]:
|
|
66
|
+
return tuple(c.provider_id for c in self.cells if c.status == "reachable")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _string_list(value: object, field: str, errors: list[str]) -> tuple[str, ...]:
|
|
70
|
+
if not isinstance(value, list) or not all(
|
|
71
|
+
isinstance(item, str) and item.strip() for item in value
|
|
72
|
+
):
|
|
73
|
+
errors.append(f"`{field}` must be a list of non-empty strings")
|
|
74
|
+
return ()
|
|
75
|
+
return tuple(item.strip() for item in value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_declaration(raw: object) -> tuple[McpServerDeclaration | None, list[str]]:
|
|
79
|
+
if not isinstance(raw, dict):
|
|
80
|
+
return None, ["entry must be an object"]
|
|
81
|
+
errors: list[str] = []
|
|
82
|
+
name = raw.get("name")
|
|
83
|
+
if not isinstance(name, str) or not name.strip():
|
|
84
|
+
errors.append("`name` must be a non-empty string")
|
|
85
|
+
description = raw.get("description")
|
|
86
|
+
if not isinstance(description, str) or not description.strip():
|
|
87
|
+
errors.append("`description` must be a non-empty string")
|
|
88
|
+
tools = _string_list(raw.get("tools"), "tools", errors)
|
|
89
|
+
if raw.get("tools") == []:
|
|
90
|
+
errors.append("`tools` must not be empty")
|
|
91
|
+
aliases = _string_list(raw["aliases"], "aliases", errors) if "aliases" in raw else ()
|
|
92
|
+
notes = raw.get("notes", "")
|
|
93
|
+
if not isinstance(notes, str):
|
|
94
|
+
errors.append("`notes` must be a string")
|
|
95
|
+
if errors:
|
|
96
|
+
return None, errors
|
|
97
|
+
return (
|
|
98
|
+
McpServerDeclaration(
|
|
99
|
+
name=name.strip(),
|
|
100
|
+
description=description.strip(),
|
|
101
|
+
tools=tools,
|
|
102
|
+
aliases=aliases,
|
|
103
|
+
notes=notes.strip(),
|
|
104
|
+
),
|
|
105
|
+
[],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_declarations(
|
|
110
|
+
project_root: Path,
|
|
111
|
+
) -> tuple[tuple[McpServerDeclaration, ...], tuple[str, ...]]:
|
|
112
|
+
path = project_json_path(project_root)
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return (), ()
|
|
115
|
+
try:
|
|
116
|
+
raw = load_owned_object(path, artifact="project config")
|
|
117
|
+
except JsonBoundaryError as exc:
|
|
118
|
+
return (), (str(exc),)
|
|
119
|
+
servers = raw.get("mcpServers")
|
|
120
|
+
if servers is None:
|
|
121
|
+
return (), ()
|
|
122
|
+
if not isinstance(servers, list):
|
|
123
|
+
return (), (f"`mcpServers` in {path} must be a list",)
|
|
124
|
+
declarations: list[McpServerDeclaration] = []
|
|
125
|
+
problems: list[str] = []
|
|
126
|
+
seen: set[str] = set()
|
|
127
|
+
for index, entry in enumerate(servers):
|
|
128
|
+
declaration, errors = parse_declaration(entry)
|
|
129
|
+
if declaration is None:
|
|
130
|
+
problems.append(f"mcpServers[{index}]: " + "; ".join(errors))
|
|
131
|
+
continue
|
|
132
|
+
if declaration.name in seen:
|
|
133
|
+
problems.append(f"mcpServers[{index}]: duplicate name `{declaration.name}`")
|
|
134
|
+
continue
|
|
135
|
+
seen.add(declaration.name)
|
|
136
|
+
declarations.append(declaration)
|
|
137
|
+
return tuple(declarations), tuple(problems)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_project_config(project_root: Path) -> tuple[Path, dict[str, Any]]:
|
|
141
|
+
path = project_json_path(project_root)
|
|
142
|
+
if not path.is_file():
|
|
143
|
+
raise McpDeclarationError(
|
|
144
|
+
f"{path} not found; register the project with `okstra setup` first"
|
|
145
|
+
)
|
|
146
|
+
return path, load_owned_object(path, artifact="project config")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def upsert_declaration(project_root: Path, declaration: McpServerDeclaration) -> bool:
|
|
150
|
+
path, data = _read_project_config(project_root)
|
|
151
|
+
servers = data.get("mcpServers") or []
|
|
152
|
+
if not isinstance(servers, list):
|
|
153
|
+
raise McpDeclarationError(f"`mcpServers` in {path} must be a list")
|
|
154
|
+
kept = [
|
|
155
|
+
entry for entry in servers
|
|
156
|
+
if not (isinstance(entry, dict) and entry.get("name") == declaration.name)
|
|
157
|
+
]
|
|
158
|
+
replaced = len(kept) != len(servers)
|
|
159
|
+
data["mcpServers"] = [*kept, declaration.to_json()]
|
|
160
|
+
write_owned_object_atomic(path, data, artifact="project config")
|
|
161
|
+
return replaced
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def remove_declaration(project_root: Path, name: str) -> bool:
|
|
165
|
+
path, data = _read_project_config(project_root)
|
|
166
|
+
servers = data.get("mcpServers") or []
|
|
167
|
+
if not isinstance(servers, list):
|
|
168
|
+
raise McpDeclarationError(f"`mcpServers` in {path} must be a list")
|
|
169
|
+
kept = [
|
|
170
|
+
entry for entry in servers
|
|
171
|
+
if not (isinstance(entry, dict) and entry.get("name") == name)
|
|
172
|
+
]
|
|
173
|
+
if len(kept) == len(servers):
|
|
174
|
+
return False
|
|
175
|
+
data["mcpServers"] = kept
|
|
176
|
+
write_owned_object_atomic(path, data, artifact="project config")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def normalize_server_name(name: str) -> str:
|
|
181
|
+
return re.sub(r"[\s._-]+", "", name).lower()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _match(
|
|
185
|
+
declaration: McpServerDeclaration, inventory: McpInventory,
|
|
186
|
+
) -> tuple[Reachability, str]:
|
|
187
|
+
if not inventory.listed:
|
|
188
|
+
return "unknown", ""
|
|
189
|
+
wanted = {normalize_server_name(n) for n in (declaration.name, *declaration.aliases)}
|
|
190
|
+
found = [s for s in inventory.servers if normalize_server_name(s.name) in wanted]
|
|
191
|
+
for server in found:
|
|
192
|
+
if server.state == "enabled":
|
|
193
|
+
return "reachable", server.name
|
|
194
|
+
if found:
|
|
195
|
+
return "disabled", found[0].name
|
|
196
|
+
return "missing", ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class CommandOutcome:
|
|
201
|
+
stdout: str | None
|
|
202
|
+
failure: str = ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
CommandRunner = Callable[[tuple[str, ...], Path], CommandOutcome]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def run_command(argv: tuple[str, ...], project_root: Path) -> CommandOutcome:
|
|
209
|
+
command = " ".join(argv)
|
|
210
|
+
try:
|
|
211
|
+
completed = subprocess.run(
|
|
212
|
+
list(argv),
|
|
213
|
+
cwd=project_root,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
timeout=PROBE_TIMEOUT_SECONDS,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
return CommandOutcome(None, f"`{argv[0]}` is not installed")
|
|
221
|
+
except subprocess.TimeoutExpired:
|
|
222
|
+
return CommandOutcome(None, f"`{command}` timed out after {PROBE_TIMEOUT_SECONDS:.0f}s")
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
return CommandOutcome(None, f"`{command}` failed to start ({exc})")
|
|
225
|
+
if completed.returncode != 0:
|
|
226
|
+
return CommandOutcome(None, f"`{command}` exited {completed.returncode}")
|
|
227
|
+
return CommandOutcome(completed.stdout)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def probe_inventories(
|
|
231
|
+
probes: Mapping[str, McpInventoryProbe | None],
|
|
232
|
+
project_root: Path,
|
|
233
|
+
runner: CommandRunner | None = None,
|
|
234
|
+
) -> dict[str, McpInventory]:
|
|
235
|
+
"""같은 명령은 한 번만 실행한다. claude 와 zai 처럼 CLI 를 공유하는 어댑터가
|
|
236
|
+
목록 비용(실측 7.5초)을 두 번 치르지 않게 하려는 것이다."""
|
|
237
|
+
commands = {probe.argv for probe in probes.values() if probe is not None}
|
|
238
|
+
outcomes: dict[tuple[str, ...], CommandOutcome] = {}
|
|
239
|
+
if commands:
|
|
240
|
+
with ThreadPoolExecutor(max_workers=len(commands)) as pool:
|
|
241
|
+
futures = {
|
|
242
|
+
argv: pool.submit(runner or run_command, argv, project_root)
|
|
243
|
+
for argv in commands
|
|
244
|
+
}
|
|
245
|
+
outcomes = {argv: future.result() for argv, future in futures.items()}
|
|
246
|
+
inventories: dict[str, McpInventory] = {}
|
|
247
|
+
for provider_id, probe in probes.items():
|
|
248
|
+
if probe is None:
|
|
249
|
+
inventories[provider_id] = McpInventory.unavailable(
|
|
250
|
+
"adapter declares no MCP inventory probe"
|
|
251
|
+
)
|
|
252
|
+
continue
|
|
253
|
+
outcome = outcomes[probe.argv]
|
|
254
|
+
inventories[provider_id] = (
|
|
255
|
+
probe.parse(outcome.stdout)
|
|
256
|
+
if outcome.stdout is not None
|
|
257
|
+
else McpInventory.unavailable(outcome.failure)
|
|
258
|
+
)
|
|
259
|
+
return inventories
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def assess(
|
|
263
|
+
declarations: Sequence[McpServerDeclaration],
|
|
264
|
+
inventories: Mapping[str, McpInventory],
|
|
265
|
+
) -> tuple[ServerReachability, ...]:
|
|
266
|
+
rows: list[ServerReachability] = []
|
|
267
|
+
for declaration in declarations:
|
|
268
|
+
cells = []
|
|
269
|
+
for provider_id, inventory in inventories.items():
|
|
270
|
+
status, visible = _match(declaration, inventory)
|
|
271
|
+
cells.append(ProviderCell(provider_id, status, visible, inventory.note))
|
|
272
|
+
rows.append(ServerReachability(declaration, tuple(cells)))
|
|
273
|
+
return tuple(rows)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def provider_probes(provider_ids: Iterable[str]) -> dict[str, McpInventoryProbe | None]:
|
|
277
|
+
from .registry.provider_registry import default_provider_registry
|
|
278
|
+
|
|
279
|
+
registry = default_provider_registry()
|
|
280
|
+
return {
|
|
281
|
+
provider_id: registry.resolve(provider_id).mcp_inventory
|
|
282
|
+
for provider_id in dict.fromkeys(provider_ids)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cell_text(cell: ProviderCell) -> str:
|
|
287
|
+
if cell.status == "reachable":
|
|
288
|
+
return f"{cell.provider_id} (as `{cell.visible_name}`)"
|
|
289
|
+
if cell.status == "disabled":
|
|
290
|
+
return f"{cell.provider_id} (disabled as `{cell.visible_name}`)"
|
|
291
|
+
return f"{cell.provider_id} ({cell.status})"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def reachability_sentence(row: ServerReachability) -> str:
|
|
295
|
+
reachable = [c for c in row.cells if c.status == "reachable"]
|
|
296
|
+
other = [c for c in row.cells if c.status != "reachable"]
|
|
297
|
+
parts = []
|
|
298
|
+
if reachable:
|
|
299
|
+
parts.append("Reachable from: " + ", ".join(_cell_text(c) for c in reachable))
|
|
300
|
+
if other:
|
|
301
|
+
parts.append("Not reachable: " + ", ".join(_cell_text(c) for c in other))
|
|
302
|
+
return ". ".join(parts)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def reachability_warnings(rows: Sequence[ServerReachability]) -> tuple[str, ...]:
|
|
306
|
+
warnings = []
|
|
307
|
+
for row in rows:
|
|
308
|
+
for cell in row.cells:
|
|
309
|
+
if cell.status == "reachable":
|
|
310
|
+
continue
|
|
311
|
+
detail = _cell_text(cell)
|
|
312
|
+
if cell.note:
|
|
313
|
+
detail += f": {cell.note}"
|
|
314
|
+
warnings.append(
|
|
315
|
+
f"mcp: server `{row.server.name}` is not reachable from provider {detail}"
|
|
316
|
+
)
|
|
317
|
+
return tuple(warnings)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
NO_MCP_SERVERS_LINE = (
|
|
321
|
+
f"- No MCP servers are declared in `{OKSTRA_DIR_NAME}/project.json`'s "
|
|
322
|
+
"`mcpServers` array. Treat MCP tools as unavailable for this run. To enable "
|
|
323
|
+
"them, register each server with `okstra mcp add` and re-render the bundle."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def render_block(
|
|
328
|
+
declarations: Sequence[McpServerDeclaration],
|
|
329
|
+
rows: Sequence[ServerReachability] = (),
|
|
330
|
+
) -> str:
|
|
331
|
+
"""툴 이름은 논리 이름으로만 싣는다. 실제 호출 이름은 CLI 마다 규칙이 달라
|
|
332
|
+
워커가 자기 CLI 의 툴 목록에서 찾는다."""
|
|
333
|
+
if not declarations:
|
|
334
|
+
return NO_MCP_SERVERS_LINE
|
|
335
|
+
by_name = {row.server.name: row for row in rows}
|
|
336
|
+
lines = []
|
|
337
|
+
for declaration in declarations:
|
|
338
|
+
parts = [f"`{declaration.name}`"]
|
|
339
|
+
if declaration.aliases:
|
|
340
|
+
parts[0] += " (also listed as " + ", ".join(
|
|
341
|
+
f"`{alias}`" for alias in declaration.aliases
|
|
342
|
+
) + ")"
|
|
343
|
+
parts.append(declaration.description)
|
|
344
|
+
parts.append("Tools: " + ", ".join(f"`{tool}`" for tool in declaration.tools))
|
|
345
|
+
if declaration.notes:
|
|
346
|
+
parts.append(declaration.notes)
|
|
347
|
+
row = by_name.get(declaration.name)
|
|
348
|
+
if row is not None and row.cells:
|
|
349
|
+
parts.append(reachability_sentence(row))
|
|
350
|
+
lines.append("- " + ". ".join(p.rstrip(".") for p in parts) + ".")
|
|
351
|
+
return "\n".join(lines)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True)
|
|
355
|
+
class BriefSource:
|
|
356
|
+
label: str
|
|
357
|
+
pattern: re.Pattern[str]
|
|
358
|
+
keyword: str
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
BRIEF_SOURCES = (
|
|
362
|
+
BriefSource(
|
|
363
|
+
"Figma",
|
|
364
|
+
re.compile(r"figma\.com/(?:file|design|proto|board)/", re.IGNORECASE),
|
|
365
|
+
"figma",
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def brief_source_warnings(
|
|
371
|
+
brief_text: str,
|
|
372
|
+
declarations: Sequence[McpServerDeclaration],
|
|
373
|
+
rows: Sequence[ServerReachability] = (),
|
|
374
|
+
) -> tuple[str, ...]:
|
|
375
|
+
warnings = []
|
|
376
|
+
for source in BRIEF_SOURCES:
|
|
377
|
+
if not source.pattern.search(brief_text):
|
|
378
|
+
continue
|
|
379
|
+
matching = [
|
|
380
|
+
d for d in declarations
|
|
381
|
+
if any(source.keyword in normalize_server_name(n) for n in (d.name, *d.aliases))
|
|
382
|
+
]
|
|
383
|
+
if not matching:
|
|
384
|
+
warnings.append(
|
|
385
|
+
f"mcp: brief cites a {source.label} URL but no mcpServers entry matches "
|
|
386
|
+
f"'{source.keyword}'; register it with `okstra mcp add`"
|
|
387
|
+
)
|
|
388
|
+
continue
|
|
389
|
+
names = {d.name for d in matching}
|
|
390
|
+
checked = [row for row in rows if row.server.name in names]
|
|
391
|
+
if checked and not any(row.reachable_from() for row in checked):
|
|
392
|
+
warnings.append(
|
|
393
|
+
f"mcp: brief cites a {source.label} URL but no selected provider can "
|
|
394
|
+
f"reach {', '.join(sorted(names))}; add it to a provider CLI's MCP "
|
|
395
|
+
"configuration or select a provider that lists it"
|
|
396
|
+
)
|
|
397
|
+
return tuple(warnings)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass(frozen=True)
|
|
401
|
+
class PreparedMcp:
|
|
402
|
+
block: str
|
|
403
|
+
warnings: tuple[str, ...]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def prepare_mcp(
|
|
407
|
+
project_root: Path,
|
|
408
|
+
provider_ids: Iterable[str],
|
|
409
|
+
brief_text: str,
|
|
410
|
+
runner: CommandRunner | None = None,
|
|
411
|
+
) -> PreparedMcp:
|
|
412
|
+
"""판정 결과는 경고와 렌더에만 쓰고 run 을 거절하지 않는다."""
|
|
413
|
+
declarations, problems = load_declarations(project_root)
|
|
414
|
+
warnings = [f"mcp: {problem}" for problem in problems]
|
|
415
|
+
rows: tuple[ServerReachability, ...] = ()
|
|
416
|
+
if declarations:
|
|
417
|
+
inventories = probe_inventories(provider_probes(provider_ids), project_root, runner)
|
|
418
|
+
rows = assess(declarations, inventories)
|
|
419
|
+
warnings.extend(reachability_warnings(rows))
|
|
420
|
+
warnings.extend(brief_source_warnings(brief_text, declarations, rows))
|
|
421
|
+
return PreparedMcp(render_block(declarations, rows), tuple(warnings))
|
package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md
CHANGED
|
@@ -77,7 +77,7 @@ When the synthesised verdict is `FAIL` and the lead minor fix does not apply, th
|
|
|
77
77
|
**Entry.** Every blocking finding of the round meets all of these, or the run ends `FAIL` as before:
|
|
78
78
|
|
|
79
79
|
1. A verifier cited where it is (`path:line`) and what the correct result is.
|
|
80
|
-
2. The fix stays inside this stage's `plannedPaths` or files the executor already changed.
|
|
80
|
+
2. The fix stays inside this stage's `plannedPaths` or files the executor already changed. A `FILE-SIZE:` finding also allows the new files the split creates beside the oversized one, so it always meets this rule.
|
|
81
81
|
3. It needs no new plan step, dependency, schema, migration, or config of record — a finding that does is a plan change: end the run `FAIL` and route by `prompts/lead/phase-routing.md`.
|
|
82
82
|
|
|
83
83
|
Changing a test's expected value is allowed here when the finding shows the oracle is wrong, because the verifiers re-verify the fix.
|
|
@@ -85,12 +85,13 @@ Changing a test's expected value is allowed here when the finding shows the orac
|
|
|
85
85
|
**Procedure for round `<N>`.**
|
|
86
86
|
|
|
87
87
|
1. Record the stage HEAD as `<base>` (`git rev-parse HEAD` in the stage worktree).
|
|
88
|
-
2. Write the executor instruction with `okstra correction prompt --run-manifest <path> --round <N> --role executor --base <base> --finding <F-id> [--finding <F-id> ...]` into a file under the run's `state/`, then materialize it with `okstra agent-prompt materialize` — `--audience implementation-executor`, `--assignment-ref initial/<executor worker id>`, `--dispatch-kind correction-r<N>`, a new `--invocation-id` ending `-correction-r<N>-<executor worker id>`, and a result path `<executor worker id>-worker-correction-r<N>-implementation-<seq>.md`. Build a jobs file with `okstra agent-prompt jobs --dispatch-kind correction-r<N>`, dispatch it alone with `okstra team dispatch --jobs-file <path> --dispatch-kind correction-r<N>`, and settle it with `okstra team await`.
|
|
88
|
+
2. Write the executor instruction with `okstra correction prompt --run-manifest <path> --round <N> --role executor --base <base> --stage-base <stage base> --finding <F-id> [--finding <F-id> ...]` into a file under the run's `state/`, then materialize it with `okstra agent-prompt materialize` — `--audience implementation-executor`, `--assignment-ref initial/<executor worker id>`, `--dispatch-kind correction-r<N>`, a new `--invocation-id` ending `-correction-r<N>-<executor worker id>`, and a result path `<executor worker id>-worker-correction-r<N>-implementation-<seq>.md`. Build a jobs file with `okstra agent-prompt jobs --dispatch-kind correction-r<N>`, dispatch it alone with `okstra team dispatch --jobs-file <path> --dispatch-kind correction-r<N>`, and settle it with `okstra team await`.
|
|
89
|
+
`--stage-base` (the carry's `stageCommitRange.base`) adds a `FILE-SIZE:<path>` finding for each file the stage diff leaves over the 400-line limit, computed by `okstra correction file-size`; a `FILE-SIZE-ADVISORY:` line (the 300-line target) never opens or joins a round; a round may hold only those findings, and `findingIds` records them by that id. Leave out a convergence finding id that restates a `FILE-SIZE:` line, or the executor reads it twice.
|
|
89
90
|
3. Do the same for every verifier with `--role verifier`, `--audience implementation-verifier`, its own `initial/<verifier worker id>` assignment, and result `<verifier worker id>-worker-correction-r<N>-implementation-<seq>.md`; dispatch the verifiers in one batch without the executor.
|
|
90
91
|
4. Converge the re-verification results the way the initial round was converged, restarting Phase 5.5 with `okstra convergence seed ... --restart-from-round0`.
|
|
91
92
|
5. No re-verification verdict is `FAIL`: the round `result` is `pass` and the run continues to persistence with the post-fix head. Otherwise `result: fail`; start round `<N+1>` if the cap allows and the entry rules still hold, or end the run `FAIL`.
|
|
92
93
|
|
|
93
|
-
**Enforced:** `scripts/okstra_ctl/dispatch_core.py` `_validate_correction_rounds` (via `correction_round.validate_correction_dispatch`) refuses a round past the cap, a verifier dispatch before that round's executor returned an `ok` result, a second executor fix in the same round, and round `<N+1>` before round `<N>` was re-verified. `validators/validate-run.py` `_validate_correction_rounds` (blocking) fails a report whose `correctionRounds` differ from the correction rounds the run dispatched, whose rounds are not 1, 2 in order, whose `result` contradicts its verdicts, or whose `verifierResults` are not the last round's verdicts. The entry rules are instructions only.
|
|
94
|
+
**Enforced:** `scripts/okstra_ctl/dispatch_core.py` `_validate_correction_rounds` (via `correction_round.validate_correction_dispatch`) refuses a round past the cap, a verifier dispatch before that round's executor returned an `ok` result, a second executor fix in the same round, and round `<N+1>` before round `<N>` was re-verified. `validators/validate-run.py` `_validate_correction_rounds` (blocking) fails a report whose `correctionRounds` differ from the correction rounds the run dispatched, whose rounds are not 1, 2 in order, whose `result` contradicts its verdicts, or whose `verifierResults` are not the last round's verdicts. The entry rules are instructions only. `okstra stage-done` (`scripts/okstra_ctl/stage_done.py` `_check_file_size`) refuses to settle a stage whose carry range breaks the 400-line file-size limit and names the `--stage-base` to pass, so a size violation no verifier reported still reaches a correction round, or ends the run `FAIL` when no round is left.
|
|
94
95
|
|
|
95
96
|
## Self-review pass before finalising the report (the Okstra lead runs this; do not delegate it)
|
|
96
97
|
|
package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md
CHANGED
|
@@ -92,6 +92,7 @@ template's check; that template is gone.
|
|
|
92
92
|
The file MUST NOT exist before the run starts (overwrite is refused — see `--force-stage` non-goal). **Enforced:** `validators/validate-run.py` `_validate_stage_carry_sidecar_exists` fails a run that declares `stageSidecarEvidence` without the file on disk. Transcribing the JSON into the report is not the same as writing it: `consumers` treats the carry file as the source of truth for marking the stage `done`, so a missing file leaves the stage permanently incomplete and blocks every dependent stage with a `PrepareError` — while this run reports success.
|
|
93
93
|
- **Verifier gates are not yours to run (BLOCKING).** The self-mock detector (`validators/detect_self_mock.py`) belongs to the implementation verifier and is never delegated to you (`_implementation-verifier.md` §"Self-mock detection"). The coding-conventions preflight names it as the enforcement behind the no-self-mocking principle — that names who will check your diff, not a command for you to run. You MUST NOT invoke it and MUST NOT write `<task_root>/qa/self-mock-*.json`; running it early does not pre-satisfy the gate, because the verifier runs it again under its own duty. **Enforced:** that sidecar is not among the paths your attempt's `writePolicy.artifactPolicy.allowedPaths` carries, so the write audit closes the attempt as `error` with `artifact-root change exceeds batch policy union` (`scripts/okstra_ctl/execution_mutation_audit.py`) — a stage whose every gate passed still lands as a failed run. The same holds for the conformance results `<task_root>/qa/result-*.json`: you may run a conformance script to check your work — a baseline capture or `--compare` target goes under `<task_root>/qa/baseline/` and anything else it writes (build logs, measurements) under `<task_root>/qa/output/`, the only qa directories besides `qa/scripts/` your attempt may write — but the verifier records every result, including an earlier stage's result that your rewrite of its script made stale. When the approved plan tells you to write one, leave it and name it in your result as a plan step the verifier owns.
|
|
94
94
|
- **An external Tier 3 non-PASS does NOT withhold the carry evidence.** A Tier 3 entry whose `requires` include `http`, `external`, or `db` is advisory. Its FAIL, MISSING, no result, startup failure, or credential / network / service absence gets recorded honestly — exact command, exit code, output tail, marked `ADVISORY` in `Validation evidence` — and you emit the carry evidence anyway. Only Tier 1 and Tier 2 failures withhold it. Withholding on an external result is what actually blocks the stage: the carry file is the only thing that can mark a stage `done`, the verifier re-runs that same command from the host (where a call your sandbox could not complete often passes), and a stage the verifier then PASSes can never be closed because its evidence was never written.
|
|
95
|
+
- **File size: aim for 300 lines, never exceed 400 (BLOCKING at 400).** Each threshold is broken the same three ways — a new file over it, an existing file taken past it, a file already over it that grows by any net line — counted in physical lines. Breaking 400 is a blocking finding; breaking only 300 is an advisory recommendation the verifiers record. Put a new responsibility — a group of functions, a class, a `describe` block — in a new module or test file beside the existing one instead of appending, and when a file would pass 400, split it by responsibility; never compress code or drop comments to meet the number. Lockfiles, data (JSON / YAML), Markdown, fixtures, snapshots, generated and vendored files are exempt, as are languages okstra has no coding preflight for. Check with `okstra correction file-size --base <stage-base>` before you emit the carry evidence; it writes nothing, so you may run it. **Enforced:** every verifier runs the same command and reports each `FILE-SIZE:` line (400) as a blocking finding and each `FILE-SIZE-ADVISORY:` line (300) as a recommendation, and `okstra stage-done` (`scripts/okstra_ctl/stage_done.py` `_check_file_size`) refuses to settle a stage whose carry range still breaks 400, so the stage goes to a correction round.
|
|
95
96
|
- **Reverse link (BLOCKING).** The runtime already appended a `status:"started"` row for this stage before the run began. The terminal row belongs to the lead's post-stage persistence and is verdict-gated — `status:"done"` with `carry_path` on a non-`FAIL` verdict, `status:"failed"` on `FAIL` (`_implementation-deliverable.md` §"Lead post-stage persistence").
|
|
96
97
|
- **No PR / push in this phase.** This run produces local commits, carry sidecar evidence, verifier results, and the implementation final report only. Push and PR creation belong exclusively to the later `release-handoff` phase after `final-verification` returns `accepted`.
|
|
97
98
|
|
package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md
CHANGED
|
@@ -17,6 +17,7 @@ fails, fix it or surface the violation — do not claim done on a failing item.
|
|
|
17
17
|
|
|
18
18
|
- diff-review done: the `Pre-commit diff review sweep` ran over the full diff and its `Coverage:` footer is in your audit sidecar — every changed file named, with each applied rule set carrying its outcome (`clean` or `fixed <n>`). No footer, or entries with no outcome → the sweep is unfinished; do not claim done.
|
|
19
19
|
- functions: every new/edited function ≤50 effective lines, single purpose — cite the longest function's actual line count, not "all under 50"
|
|
20
|
+
- file size: no source or test file this diff adds or grows ends over 400 physical lines, and no file already over 400 grew — paste the output of `okstra correction file-size --base <stage-base>`; it must end `QA-RESULT: PASS`. Aim for 300: for each `FILE-SIZE-ADVISORY:` line it prints, split the file or say why not. A responsibility that would push a file over goes into its own new module or test file
|
|
20
21
|
- conventions: applied the routed pack + project patterns (name which ones)
|
|
21
22
|
- names & comments: names say what, comments say why; no obvious-restatement comments; no truthful-name violations
|
|
22
23
|
- verification: ran the actual build/test — paste the exact command line and its real exit code / output tail, never a paraphrased "tests pass"
|
package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md
CHANGED
|
@@ -214,13 +214,14 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
|
|
|
214
214
|
- **Non-separating test data:** two scenarios whose setup values and assertions are identical, so a wrong implementation passes both; or new test tooling added in this diff (mock, state setter, repository branch) that no test calls.
|
|
215
215
|
- **Effect claimed under its own mock:** a test presented as covering an effect whose producing path is replaced by a mock inside that same test. The mock's presence in the harness is not evidence the branch behind it works.
|
|
216
216
|
- **Single-caller abstraction (KISS):** an abstraction layer this diff introduces — a helper module, a strategy / factory / builder, an indirection or wrapper layer, an interface or abstract base — that has exactly one caller after the change, where inlining it at that call site would be simpler. The rule is `overview.md` core principle 2: name the second caller now, or inline. Four exits are legitimate and each must **cite its evidence**: the approved plan declares it (a `testSeams[]` boundary or a variation-point extraction — cite the plan step); the project declares `architecture.style = hexagonal` and this is a port at the domain boundary (a port with one adapter is the normal shape, not a violation); it breaks a real import cycle (name both modules); or its single caller is the public API of a published package. Absent one of those, cite `path:line` and recommend the inlined shape. Do NOT raise this on a function extracted purely to bring a body under the 50-line cap — that is principle 5 doing its job — nor on a pre-existing abstraction this diff merely edits.
|
|
217
|
+
- **File-size limit (every verifier, not only the stage QA owner):** run `okstra correction file-size --base <base>` from the worktree cwd and log it in the `Read-only command log`. Each `FILE-SIZE:<path>:401 <before>-><after>` line it prints is a blocking finding cited exactly as printed — a new file over 400 lines, a file the diff took past 400, or a file already over 400 the diff grew. Recommended fix: move whole responsibilities into new modules or test files beside it. The command writes nothing and its result is the same for every verifier, so the finding converges and the lead sends it to a correction round. Each `FILE-SIZE-ADVISORY:<path>:301` line (the 300-line target, same three conditions) goes under the advisory findings below, not here. Do not judge size yourself: a file it does not print is not a finding (lockfiles, data, Markdown, fixtures, snapshots, generated files and languages without a coding preflight are exempt by `scripts/okstra_ctl/file_size_cap.py` `capped_path`).
|
|
217
218
|
- **Caller-less identifier (YAGNI / orphan):** an identifier this diff leaves with zero callers — either newly added and never called (a speculative parameter, an optional config object, a "future-proof" hook, an exported helper whose only caller is hypothetical), or pre-existing code orphaned because this diff removed its last caller. Grep the identifier across the whole repository rather than the diff alone; a hit inside its own declaration, its own test, or a commented-out line is not a caller. Two exits are legitimate and must be **stated with their evidence**, never assumed: an identifier the approved plan reserves for a named later stage (cite the plan step), and a contract point something other than project code calls (a framework entrypoint, an implemented interface method, a migration hook, the public API of a published package — cite the caller or the registration). Anything else is work no requirement asked for: recommend deleting it, or folding an added parameter back into its single call site. A caller-less identifier is the one YAGNI violation that survives a green pipeline unchanged, which is why it grades here rather than as a recommendation.
|
|
218
|
-
- **Advisory findings (recorded as recommendations; verdict MAY still PASS):** function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, weak-but-not-misleading names, priority-between-inputs policy inlined in a service condition instead of a named domain function, an error message asserting a cause the code never observed, a memory / concurrency / batching change with no test pinning the bound it claims, or (hexagonal overlay only) a service dependency this diff adds or modifies that injects a concrete adapter instead of a port — record it with the port sketch; advisory only while the project has not declared `architecture.style = hexagonal`, since that declaration — and only that one — promotes exactly this item to blocking per the declared-style bullet above, while a declared `layered` binds dependency direction instead and leaves this item advisory; an existing convention of concrete injections does not convert this one to `clean`, it is the debt the rule pays down. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
|
|
219
|
+
- **Advisory findings (recorded as recommendations; verdict MAY still PASS):** each `FILE-SIZE-ADVISORY:` line from the file-size command (a file over the 300-line target but within 400), function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, weak-but-not-misleading names, priority-between-inputs policy inlined in a service condition instead of a named domain function, an error message asserting a cause the code never observed, a memory / concurrency / batching change with no test pinning the bound it claims, or (hexagonal overlay only) a service dependency this diff adds or modifies that injects a concrete adapter instead of a port — record it with the port sketch; advisory only while the project has not declared `architecture.style = hexagonal`, since that declaration — and only that one — promotes exactly this item to blocking per the declared-style bullet above, while a declared `layered` binds dependency direction instead and leaves this item advisory; an existing convention of concrete injections does not convert this one to `clean`, it is the debt the rule pays down. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
|
|
219
220
|
- **Output.** Every finding — blocking or advisory — is a structured item in the verifier's worker result (`path:line`, rule, severity, suggested fix) so it carries into Phase 5.5 convergence and the final report. A blocking hit sets the verifier verdict to `FAIL` with the rule cited, using the same verdict machinery as the Discrepancy rule above. The Okstra lead MUST NOT silently downgrade a cited blocking finding to advisory during synthesis; an override requires a concrete cited reason, exactly as for the Discrepancy rule.
|
|
220
221
|
|
|
221
222
|
### Fix-run incremental scope (applies when the profile carries a "Fix-Run Carry" block)
|
|
222
223
|
|
|
223
|
-
A correction-round re-verification (`--dispatch-kind correction-r<N>`, a "Correction Round" block in the task instructions) takes the same scope: the block's findings and the correction diff it names, with every validation command re-run.
|
|
224
|
+
A correction-round re-verification (`--dispatch-kind correction-r<N>`, a "Correction Round" block in the task instructions) takes the same scope: the block's findings and the correction diff it names, with every validation command re-run. The file-size command is the exception: run it against the stage base, never the round's base, because a file the round did not touch keeps the violation the initial diff made.
|
|
224
225
|
|
|
225
226
|
A fix run re-verifies a stage whose previous run already passed a full static
|
|
226
227
|
sweep and failed only on cited blocking findings. Re-sweeping the whole stage
|
|
@@ -39,4 +39,4 @@
|
|
|
39
39
|
|
|
40
40
|
Record candidate validity, unresolved user decisions, and explicitly classified technical facts. The lead chooses the next phase under `## implementation-option-selection` in `prompts/lead/phase-routing.md`.
|
|
41
41
|
|
|
42
|
-
When `--clarification-response` carries a technical-verification report, read its source comparison, experiment plans, results and logs. Check that the tested environment matches current code, then author fresh feasibility votes. Do not directly convert a supported result into a feasible candidate, remove unrelated uncertainties or treat a failed or unrun probe as compatibility proof. Preserve the user's pilot scope and any deferred production rollout.
|
|
42
|
+
When `--clarification-response` carries a technical-verification report, read its source comparison, experiment plans, results and logs. The analysis packet's `## Technical Verification Evidence` section names the result record and the experiment directory (rendered by `okstra_ctl.phases.technical_verification.entry.technical_evidence_packet_block`). Check that the tested environment matches current code, then author fresh feasibility votes. Do not directly convert a supported result into a feasible candidate, remove unrelated uncertainties or treat a failed or unrun probe as compatibility proof. Preserve the user's pilot scope and any deferred production rollout.
|
|
@@ -733,6 +733,23 @@ _DIR_SNAPSHOT_OWNED_NOTE = (
|
|
|
733
733
|
"planner-fixable DISAGREE that asks to edit these four fields.\n"
|
|
734
734
|
)
|
|
735
735
|
|
|
736
|
+
# 구현 executor 는 소스 트리의 실 IO 테스트를 첫 편집 전에 거부한다. 계획이 그런
|
|
737
|
+
# step 을 승인받으면 stage 가 변경 0 으로 멈춘다(2026-10-18 dev-11374 실측).
|
|
738
|
+
_REAL_IO_STEP_NOTE = (
|
|
739
|
+
"\nReal-IO placement: this step must not create, edit, or run a test that "
|
|
740
|
+
"opens a real database connection, makes a real network call, or talks to "
|
|
741
|
+
"a real external service inside the project source test tree (`src/**`, "
|
|
742
|
+
"`test/**`, `tests/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, "
|
|
743
|
+
"`*.e2e-spec.*`), and its PASS must not depend on a reachable database or "
|
|
744
|
+
"service. The implementation executor refuses such a step before any edit. "
|
|
745
|
+
"Real-backend checks belong only in the stage's `Conformance tests:` "
|
|
746
|
+
"declaration, which the implementation run writes under "
|
|
747
|
+
"`<task_root>/qa/scripts/`, never commits, and treats as advisory. If this "
|
|
748
|
+
"step breaks the rule, vote DISAGREE(b) with Fixability planner-fixable and "
|
|
749
|
+
"name the real-IO test path or command. A test whose collaborator is "
|
|
750
|
+
"stubbed is not real IO.\n"
|
|
751
|
+
)
|
|
752
|
+
|
|
736
753
|
|
|
737
754
|
def _step_coordinate(payload: object, field: str) -> int | None:
|
|
738
755
|
if not isinstance(payload, Mapping):
|
|
@@ -1022,6 +1039,8 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
1022
1039
|
rows.append(_render_prior_steps(item, all_items))
|
|
1023
1040
|
if item_id.startswith("P-Prep-"):
|
|
1024
1041
|
rows.append(_PREP_OWNERSHIP_NOTE)
|
|
1042
|
+
if item_id.startswith("P-Step-"):
|
|
1043
|
+
rows.append(_REAL_IO_STEP_NOTE)
|
|
1025
1044
|
if item_id == "P-Dir-1":
|
|
1026
1045
|
rows.append(_DIR_SNAPSHOT_OWNED_NOTE)
|
|
1027
1046
|
split = _render_analyser_split(
|
|
@@ -316,6 +316,20 @@ from both numerator and denominator, and never converted to `DISAGREE`. Voting `
|
|
|
316
316
|
dependency-missing command instead manufactures a defect the planner cannot fix by editing the plan,
|
|
317
317
|
which is what sends the item into a self-fix round that can only paper over it.
|
|
318
318
|
|
|
319
|
+
## Real-IO test in a committed step
|
|
320
|
+
|
|
321
|
+
A `P-Step-*` item that creates, edits, or runs a test opening a real database connection, a real
|
|
322
|
+
network call, or a real external service inside the project source test tree (`src/**`, `test/**`,
|
|
323
|
+
`tests/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, `*.e2e-spec.*`), or whose `PASS` depends on a
|
|
324
|
+
reachable database or service, is `DISAGREE(b)` with `planner-fixable`. The implementation executor
|
|
325
|
+
refuses that step before its first edit (`_implementation-executor.md` "Real-IO test isolation"), so
|
|
326
|
+
the stage would end with zero changes. The planner's fix is a stubbed-collaborator unit test in the
|
|
327
|
+
step plus the stage's `Conformance tests:` declaration for the real-backend check. A test whose
|
|
328
|
+
collaborator is stubbed is not real IO. **Enforced:** the rule reaches each verifier on every
|
|
329
|
+
`P-Step-*` item through `okstra plan-items prompt`
|
|
330
|
+
(`okstra_ctl.phases.implementation_planning.authoring._REAL_IO_STEP_NOTE`); the vote itself is
|
|
331
|
+
verifier judgement.
|
|
332
|
+
|
|
319
333
|
## Mode constraint
|
|
320
334
|
|
|
321
335
|
Plan-body verification only supports **lightweight mode** (defined in `prompts/lead/convergence.md` §"Verification Mode"). `full-reanalysis` is not meaningful here because the "original source materials" for a plan item are the worker's own analysis plus the lead-mediated synthesis — there is no independent ground truth to re-read. The manifest's top-level `verificationMode` is ignored for this round; lightweight is always used.
|
|
@@ -147,7 +147,8 @@ Plan for the actual worktree layout before approval. Shared documentation direct
|
|
|
147
147
|
**Pick the cases by distinct outcome, not by line coverage.** When the stage writes or reconciles state, its meaningfully different outcomes are usually more than three — normal success, target already in the desired state (resume), existing data reused rather than created, a conflicting concurrent state, target absent, and mid-way failure with rollback. Enumerate the ones this stage actually implements and route them across the three lines (the `boundary` line is where resume / already-done / reuse belongs; `failure` carries conflict, absence, and rollback), naming each in the cell rather than collapsing them into "edge input". An implemented outcome with no declared case is a coverage gap the executor will not backfill.
|
|
148
148
|
- **Per-stage subsections** (`## 5.5.<i> Stage <i>: <title>` for each `i`), each containing the four required subsections:
|
|
149
149
|
- `### Carry-In` — for `depends-on (none)`: task-brief only. Otherwise: each depended-on stage's static exit contract + runtime sidecar path `runs/<impl-key>/carry/stage-<i>.json` placeholder.
|
|
150
|
-
- `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | outcome | expected`. `outcome` is one word — `PASS` or `FAIL` — and `expected` is the sentence saying what that looks like here; a verdict written inside the sentence is not read as one. The `files` cell lists each touched path in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...`), which does not resolve and is rejected by plan-body verification as a kind-b path mismatch. **The narrative row additionally carries `plannedPaths`: the paths this step may write as an array, one repository-relative path per entry, with no globs, exclusions, counts or commentary. Read-only checks use `plannedPaths: []`; their working directory belongs in the command, not the write ledger. Never declare the repository or worktree root. Planning assembly and correction preflight enforce this through `write_policy.planned_path_declaration_errors`.** `files` is the sentence a reader sees; `plannedPaths` is the ledger report assembly preserves and the implementer write policy enforces. When a step legitimately covers a set too large to enumerate, split it or name the directory the set lives under. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`outcome` = `FAIL`); at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`outcome` = `PASS`); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section. A stage that is truthfully none of those three declares `TDD exemption: user-bypass — <the user's words>`, which holds only while the user has granted it for that stage with `okstra prepare --tdd-bypass "<stage>:<reason>"` — never file the nearest of the three instead. Validator S10c enforces RED-first + GREEN; the `outcome` cell agreeing with its prefix is a schema conditional. S10e rejects an unsupported exemption reason and a `user-bypass` with no user grant (`validators/validate-implementation-plan-stages.py`).
|
|
150
|
+
- `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | outcome | expected`. `outcome` is one word — `PASS` or `FAIL` — and `expected` is the sentence saying what that looks like here; a verdict written inside the sentence is not read as one. The `files` cell lists each touched path in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...`), which does not resolve and is rejected by plan-body verification as a kind-b path mismatch. **The narrative row additionally carries `plannedPaths`: the paths this step may write as an array, one repository-relative path per entry, with no globs, exclusions, counts or commentary. Read-only checks use `plannedPaths: []`; their working directory belongs in the command, not the write ledger. Never declare the repository or worktree root. Planning assembly and correction preflight enforce this through `write_policy.planned_path_declaration_errors`.** **File size:** the implementation run aims for 300 physical lines per source and test file and enforces 400 — a new file over 400, an existing file taken past 400, and a file already over 400 that grows are blocking findings the executor must split in a correction round; the same conditions at 300 are advisory. When a step would add code or tests to a file near or over 300 lines (`wc -l`), plan a new module or test file for that responsibility in `plannedPaths` instead of appending to the existing one. **Enforced at implementation, not here:** `okstra correction file-size` (`scripts/okstra_ctl/file_size_cap.py`), re-run by `okstra stage-done`. `files` is the sentence a reader sees; `plannedPaths` is the ledger report assembly preserves and the implementer write policy enforces. When a step legitimately covers a set too large to enumerate, split it or name the directory the set lives under. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`outcome` = `FAIL`); at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`outcome` = `PASS`); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section. A stage that is truthfully none of those three declares `TDD exemption: user-bypass — <the user's words>`, which holds only while the user has granted it for that stage with `okstra prepare --tdd-bypass "<stage>:<reason>"` — never file the nearest of the three instead. Validator S10c enforces RED-first + GREEN; the `outcome` cell agreeing with its prefix is a schema conditional. S10e rejects an unsupported exemption reason and a `user-bypass` with no user grant (`validators/validate-implementation-plan-stages.py`).
|
|
151
|
+
- **Real-IO tests stay out of the plan's committed steps (BLOCKING).** A step never creates, edits, or runs a test that opens a real database connection, makes a real network call, or talks to a real external service inside the project source test tree (`src/**`, `test/**`, `tests/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, `*.e2e-spec.*`), and no step's `PASS` depends on a reachable database or service. The implementation executor refuses such a step before its first edit (`_implementation-executor.md` "Real-IO test isolation"), so a plan that contains one stops the stage with zero changes (observed 2026-10-18, dev-11374: a live-MySQL `test/font-pairing.e2e-spec.ts` planned in steps 1, 4 and 5). The committed RED/GREEN steps test the adapter with its injected collaborator stubbed — the exact query text, its replacements, and the returned rows — and run without any backend. Real-backend verification is declared only as this stage's `Conformance tests:` line below (`requires=[db|http|external]`): the implementation run writes that script under `<task_root>/qa/scripts/`, runs it against a local or replica backend when one is reachable, never commits it, and records a non-PASS or unreachable backend as advisory. Read a brief line such as `verify: integration test against a test database` as that conformance declaration, not as a committed test. Do not plan a shared real-DB guard, a test DDL fixture, or an edit to an existing real-IO suite in source to support it. **Enforced:** every `P-Step-*` item of the §5.5.9 plan-body round carries this rule to its verifiers (`okstra_ctl.phases.implementation_planning.authoring._REAL_IO_STEP_NOTE`), which vote `DISAGREE(b)` on a step that breaks it; no path pattern tells a mocked test from a live one, so no validator checks it.
|
|
151
152
|
- **The `command` cell runs inside an okstra task worktree, not a bare checkout (BLOCKING).** okstra provisions `.okstra`, the configured sync entries (`.project-docs`, `.claude`, …), and — for `implementation` — a nested `stage-<N>/` worktree into the tree the step executes in. Two consequences bind every command you write:
|
|
152
153
|
- **Clean-tree assertions use `okstra worktree-status --check-clean`.** A bare `git status --porcelain` is never empty there, so an assertion built on one fails on okstra's scaffolding rather than on the stage's work. The okstra command asks the same question over source paths only and exits 1 when dirty, so it stands alone as a step's assertion: `okstra worktree-status --check-clean`. Validator S13 rejects the bare form. Do not add a `git tag stage-<N>-exit` to the step. Stage completion records the commit in the consumer ledger without creating or moving git tags.
|
|
153
154
|
- **Never read an `.okstra/` artifact back out of a git object.** `.okstra/**` is gitignored and never committed — the executor aborts a commit that stages an ignored path and the verifier reports a committed `.okstra` path as a branch defect — so `git cat-file -e <tag>:.okstra/…`, `git show <tag>:.okstra/…`, and every variant of that read can never resolve, at any tag, in any stage. A later stage that needs a QA artifact reads it from the working tree or receives it through the carry sidecar / verifier result; do not design a stage contract around one being reachable from a tag. Validator S12 rejects the read.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"allowed": " - entering this phase only when the cited final-verification report's verdict is release-ready — `Verdict Token` exactly `accepted`, or exactly `conditional-accept` with every Conditional Acceptance Condition row declaring `blocksReleaseHandoff: false` (`okstra_ctl.release_gate.release_handoff_allowed`)\n - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `local checkout`, `push + PR`, or `skip` (end the run)\n - asking the user to pick a PR base branch from `staging` | `preprod` | `prod` | `main` | `dev` | a user-supplied branch name\n - drafting PR title and PR body **inline as the Okstra lead** (no drafter worker and no delegated dispatch); the lead reviews its own draft with the user before any mutating git / gh command runs\n - read-only git inspection: `git status`, `git diff`, `git log`, `git rev-parse`\n - pushing the current feature branch to its origin remote via `git push -u origin <current-branch>` (the feature branch only — NEVER the base branch)\n - creating a pull request via `gh pr create --base <chosen-base> --head <current-branch>`; if a PR with the same head already exists, surface its URL and skip creation\n - the lead writes the
|
|
2
|
+
"allowed": " - entering this phase only when the cited final-verification report's verdict is release-ready — `Verdict Token` exactly `accepted`, or exactly `conditional-accept` with every Conditional Acceptance Condition row declaring `blocksReleaseHandoff: false` (`okstra_ctl.release_gate.release_handoff_allowed`)\n - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `local checkout`, `push + PR`, or `skip` (end the run)\n - asking the user to pick a PR base branch from `staging` | `preprod` | `prod` | `main` | `dev` | a user-supplied branch name\n - drafting PR title and PR body **inline as the Okstra lead** (no drafter worker and no delegated dispatch); the lead reviews its own draft with the user before any mutating git / gh command runs\n - read-only git inspection: `git status`, `git diff`, `git log`, `git rev-parse`\n - pushing the current feature branch to its origin remote via `git push -u origin <current-branch>` (the feature branch only — NEVER the base branch)\n - creating a pull request via `gh pr create --base <chosen-base> --head <current-branch>`; if a PR with the same head already exists, surface its URL and skip creation\n - the lead writes the report narrative at the run manifest's `reportNarrativePath` directly (no `Report writer worker` dispatch); `okstra report-finalize` assembles the final report from it",
|
|
3
3
|
"forbidden": [
|
|
4
|
-
"entering this phase when
|
|
4
|
+
"entering this phase when a cited final-verification verdict is not release-ready — `Verdict Token` `blocked` or any token other than `accepted` / `conditional-accept`, a `conditional-accept` with no Conditional Acceptance Condition row or with any row whose `blocksReleaseHandoff` is not `false` (`okstra_ctl.release_gate.release_handoff_allowed`) — or when no final-verification report is cited",
|
|
5
5
|
"local commit commands of any kind (`git add`, `git commit`, `git restore --staged`, `git stash`), and any direct `git merge` / `git rebase` / `git rebase --onto` / `git cherry-pick` / `git commit --amend` run by the lead. Runtime exceptions are dependency-ordered rebases by `okstra_ctl.stage_rebase` (which invalidate verification and stop PR delivery), and merge commits on a dedicated multi-predecessor `merge-base` branch. Never merge a moving predecessor or origin branch into a downstream stage to update its base.",
|
|
6
6
|
"any git push variant that rewrites remote history, regardless of intent or whether the user said \"force it\": `git push --force`, `git push --force-with-lease`, `git push -f`, `git push +<refspec>`, or any other history-rewriting invocation",
|
|
7
7
|
"pushing directly to a release base branch — i.e. `git push origin <branch>` where `<branch>` is `main`, `master`, `prod`, `preprod`, `staging`, `dev`, or the branch the user chose as the release base in this run. The only permitted push targets are the branches `okstra handoff pr-plan` listed for this run (each stage `head_branch`, and any `merge-base` branch).",
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
the `releaseBase` the user picked. The report is the only place a reader learns which stages
|
|
5
5
|
shipped and in what order their PRs must be merged — the branch names do not say.
|
|
6
6
|
- Purpose: take a release-ready single-stage final-verification verdict for each already-committed implementation stage and turn it into a delivered push and/or pull request, with explicit user selection at every delivery step. **One stage is one PR.** The PR head is that stage's stack branch (`<prefix>-<task-id>-s<N>`); nothing is squashed or collected into a bundle branch. Runtime dependency rebases happen before delivery and invalidate the prior verification.
|
|
7
|
-
- **Execution model: single-lead, no worker dispatch.** This phase is a thin orchestrator over `git` / `gh`; it does NOT dispatch teammates, does NOT dispatch analysis or drafter sub-agents, and does NOT run convergence. The host-native Okstra lead performs every step inline (drafting PR text, asking the user, running git / gh, writing the
|
|
7
|
+
- **Execution model: single-lead, no worker dispatch.** This phase is a thin orchestrator over `git` / `gh`; it does NOT dispatch teammates, does NOT dispatch analysis or drafter sub-agents, and does NOT run convergence. The host-native Okstra lead performs every step inline (drafting PR text, asking the user, running git / gh, writing the report narrative) — see "Lead-only contract" below.
|
|
8
8
|
- Worker roster: none — `profile.json` declares no roles; the run is executed entirely by the Okstra lead.
|
|
9
9
|
- Lead-only contract (replaces the shared team contract for this phase):
|
|
10
10
|
- The host-native Okstra lead is the sole agent for this run. No worker dispatch, no teammates, no parallel sub-agents, no convergence loop. Do NOT run `okstra convergence` in any form: prepare already wrote this run's convergence state as "not run — fewer than two analysers", which is the record report assembly reads. **Enforced:** `okstra_ctl.render._initialize_lead_only_convergence` writes it when the run has no analysers, and leaves an existing state alone.
|
|
11
11
|
- The lead drafts each stage's PR title and PR body **inline** by reading the run brief, that stage's cited final-verification report, `git log --oneline <stage base>..<stage head>`, and `git diff <stage base>..<stage head> --stat`. No drafter worker is dispatched.
|
|
12
|
-
- The lead authors the
|
|
12
|
+
- The lead authors the report narrative directly (no `Report writer worker` dispatch) and never writes `final-report-*.data.json`. Write the narrative to the path the run manifest names in `reportNarrativePath`. It carries only the writer-owned fields (`humanSummary`, `summary`, `verdictCard`, `finalVerdict`, `rationale`, `recommendedNextSteps`, `followUpTasks`, and `releaseHandoff` with its `userNarrative`) in the narrative line grammar of `report_narrative.NARRATIVE_GRAMMAR_INSTRUCTIONS`: the first line is `# OKSTRA Report Narrative`, followed only by `- **Field Name**`, `- Item <N>` and `> value` lines, each value indented two spaces deeper than its label. `okstra report-finalize` assembles `data.json` from this narrative plus the approval, activity, team-state and convergence ledgers prepare already created. A missing or unparseable narrative fails the `project-activity` step with `owner=lead`. **Enforced:** `report_inputs._LEAD_AUTHORED_NARRATIVE_TASK_TYPES`, read by `report_assembly.assemble_report`.
|
|
13
13
|
- The shared anti-escalation rule from the common contract still applies: do not start any other lifecycle phase from inside this run.
|
|
14
14
|
- The shared "authority & permissions assumption" rule from the common contract still applies: assume the user holds every permission needed; do not block on hypothetical approvals.
|
|
15
15
|
- Pre-handoff entry gate (mandatory — refuse to start if any item fails):
|