okstra 0.195.4 → 0.197.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/docs/architecture.md +1 -1
- package/docs/cli.md +14 -1
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
- package/runtime/prompts/lead/plan-body-verification.md +21 -3
- package/runtime/prompts/profiles/implementation-planning.md +3 -2
- package/runtime/prompts/wizard/prompts.ko.json +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
- package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
- package/runtime/python/okstra_ctl/cmux.py +33 -2
- package/runtime/python/okstra_ctl/conformance.py +10 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
- package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -1
- package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
- package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
- package/runtime/python/okstra_ctl/plan_items.py +14 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
- package/runtime/python/okstra_ctl/run.py +76 -0
- package/runtime/python/okstra_ctl/session_transcript.py +19 -11
- package/runtime/python/okstra_ctl/stage_close.py +244 -0
- package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
- package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
- package/runtime/skills/okstra-run/SKILL.md +63 -4
- package/runtime/validators/validate-implementation-plan-stages.py +109 -23
- package/runtime/validators/validate-run.py +30 -6
|
@@ -99,6 +99,10 @@ from .initial_prompt_materialization import (
|
|
|
99
99
|
)
|
|
100
100
|
from .agent.invocation import AgentInvocationError, materialize_retry_invocation
|
|
101
101
|
from .path_hints import hydrate_active_run_context
|
|
102
|
+
from .worker_prompt_policy import (
|
|
103
|
+
is_verification_dispatch_kind,
|
|
104
|
+
verification_dispatch_round,
|
|
105
|
+
)
|
|
102
106
|
from .schema_excerpt import (
|
|
103
107
|
bundle_excerpt_path,
|
|
104
108
|
describe_changed,
|
|
@@ -1856,13 +1860,14 @@ def _job_for_attempt(job: WorkerJob, attempt: int) -> WorkerJob:
|
|
|
1856
1860
|
|
|
1857
1861
|
|
|
1858
1862
|
def _dispatch_round(dispatch_kind: str) -> int:
|
|
1859
|
-
|
|
1860
|
-
|
|
1863
|
+
"""attempt 행의 라운드 번호. 예약과 같은 계산을 쓴다
|
|
1864
|
+
(`worker_prompt_policy.verification_dispatch_round`)."""
|
|
1865
|
+
if not is_verification_dispatch_kind(dispatch_kind):
|
|
1861
1866
|
return 1
|
|
1862
|
-
|
|
1863
|
-
if
|
|
1864
|
-
raise DispatchError(f"invalid
|
|
1865
|
-
return
|
|
1867
|
+
round_number = verification_dispatch_round(dispatch_kind)
|
|
1868
|
+
if round_number is None:
|
|
1869
|
+
raise DispatchError(f"invalid verification dispatch kind: {dispatch_kind}")
|
|
1870
|
+
return round_number
|
|
1866
1871
|
|
|
1867
1872
|
|
|
1868
1873
|
def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
@@ -61,7 +61,10 @@ from .worker_prompt_contract import (
|
|
|
61
61
|
validate_prompt_model_header,
|
|
62
62
|
validate_reverify_prompt,
|
|
63
63
|
)
|
|
64
|
-
from .worker_prompt_policy import
|
|
64
|
+
from .worker_prompt_policy import (
|
|
65
|
+
is_verification_dispatch_kind,
|
|
66
|
+
verification_dispatch_round,
|
|
67
|
+
)
|
|
65
68
|
from .worker_runner import LIVE, QUIET
|
|
66
69
|
from .worker_request import verifier_extra_dirs
|
|
67
70
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
@@ -1000,13 +1003,14 @@ def prompt_anchor_values(
|
|
|
1000
1003
|
|
|
1001
1004
|
|
|
1002
1005
|
def _execution_dispatch_round(dispatch_kind: str) -> int:
|
|
1003
|
-
|
|
1004
|
-
|
|
1006
|
+
"""attempt 행에 적는 라운드 번호. 예약(`agent-prompt materialize` 의
|
|
1007
|
+
`_reservation_round`)과 같은 계산을 써야 한다."""
|
|
1008
|
+
if not is_verification_dispatch_kind(dispatch_kind):
|
|
1005
1009
|
return 1
|
|
1006
|
-
|
|
1007
|
-
if
|
|
1008
|
-
raise DispatchError(f"invalid
|
|
1009
|
-
return
|
|
1010
|
+
round_number = verification_dispatch_round(dispatch_kind)
|
|
1011
|
+
if round_number is None:
|
|
1012
|
+
raise DispatchError(f"invalid verification dispatch kind: {dispatch_kind}")
|
|
1013
|
+
return round_number
|
|
1010
1014
|
|
|
1011
1015
|
|
|
1012
1016
|
def link_agent_dispatch_result(
|
|
@@ -13,6 +13,7 @@ CLI 는 갈라 읽어야 한다.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
+
import re
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
|
|
@@ -34,6 +35,11 @@ ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
|
34
35
|
ObserveUsage = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
|
|
35
36
|
|
|
36
37
|
WORKER = "worker"
|
|
38
|
+
_TERMINAL_COLORS = re.compile(r"\x1b\[[0-9;:]*m")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def strip_terminal_colors(text: str) -> str:
|
|
42
|
+
return _TERMINAL_COLORS.sub("", text)
|
|
37
43
|
|
|
38
44
|
|
|
39
45
|
class TranscriptWriter(Protocol):
|
|
@@ -93,7 +99,7 @@ class SplitText:
|
|
|
93
99
|
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
|
|
94
100
|
def result(line: str) -> str | None:
|
|
95
101
|
writer.write(WORKER, line)
|
|
96
|
-
return line
|
|
102
|
+
return strip_terminal_colors(line)
|
|
97
103
|
|
|
98
104
|
def progress(line: str) -> str | None:
|
|
99
105
|
writer.write(WORKER, line)
|
|
@@ -38,7 +38,6 @@ _MAX_SUMMARY = 120
|
|
|
38
38
|
# 결과 하나가 pane 을 다 차지하면 직전 호출이 위로 밀려 나가므로 앞부분만
|
|
39
39
|
# 보여주고, 나머지는 로그가 갖는다.
|
|
40
40
|
_LIVE_BODY_ROWS = 4
|
|
41
|
-
_BODY_INDENT = " "
|
|
42
41
|
|
|
43
42
|
|
|
44
43
|
@dataclass(frozen=True)
|
|
@@ -197,8 +196,8 @@ def _rows(event: StreamEvent, *, limit: int | None, body_rows: int | None) -> li
|
|
|
197
196
|
tail = (
|
|
198
197
|
f"the tool reported: {report}" if report else "the tool reported nothing"
|
|
199
198
|
)
|
|
200
|
-
return [_truncate(f"
|
|
201
|
-
head = f"
|
|
199
|
+
return [_truncate(f"← {_outcome(event.failed)} — no body returned; {tail}", limit)]
|
|
200
|
+
head = f"← {_outcome(event.failed)} ({event.size_bytes} bytes)"
|
|
202
201
|
return [head, *_result_body(event.body, limit=limit, keep=body_rows)]
|
|
203
202
|
if isinstance(event, Denial):
|
|
204
203
|
return [_truncate(f"!! PERMISSION DENIED — {event.tool}: {event.reason}", limit)]
|
|
@@ -217,10 +216,10 @@ def _result_body(body: str, *, limit: int | None, keep: int | None) -> list[str]
|
|
|
217
216
|
rows = _body_rows(body)
|
|
218
217
|
if keep is None:
|
|
219
218
|
return rows
|
|
220
|
-
shown = [_truncate(
|
|
219
|
+
shown = [_truncate(row, limit) for row in rows[:keep]]
|
|
221
220
|
dropped = len(rows) - keep
|
|
222
221
|
if dropped > 0:
|
|
223
|
-
shown.append(f"
|
|
222
|
+
shown.append(f"… +{dropped} more line(s) — full body in the log")
|
|
224
223
|
return shown
|
|
225
224
|
|
|
226
225
|
|
|
@@ -202,7 +202,68 @@ class _Validator:
|
|
|
202
202
|
elif schema.get("additionalProperties") is False:
|
|
203
203
|
# Don't fire for keys we know are part of the conditional
|
|
204
204
|
# branches (we still validate values when they appear).
|
|
205
|
-
self._err(
|
|
205
|
+
self._err(
|
|
206
|
+
path,
|
|
207
|
+
f"additional property '{name}' is not allowed"
|
|
208
|
+
+ self._misplaced_property_hint(name, path),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _misplaced_property_hint(
|
|
212
|
+
self, name: str, path: tuple[str | int, ...],
|
|
213
|
+
) -> str:
|
|
214
|
+
"""이 키가 스키마의 다른 자리에서 정의된 필드면 "옮겨라" 를 덧붙인다.
|
|
215
|
+
|
|
216
|
+
`additional property 'X' is not allowed` 만 보면 "스키마에 그런 칸이
|
|
217
|
+
없다" 로 읽힌다. 실제로는 X 가 다른 객체의 필드이고 작성자가 자리를
|
|
218
|
+
틀린 경우가 있고, 그때 지우면 다음 라운드에서 `required property 'X'
|
|
219
|
+
is missing` 이 나온다 — 라운드 하나를 왕복에 쓴다(2026-09-09 실측,
|
|
220
|
+
dev-10642 implementation-planning: 최상위 필수 `summary` 를
|
|
221
|
+
`rationale` 안에 써서 지웠다가 되살렸다).
|
|
222
|
+
"""
|
|
223
|
+
found = self._required_property_owners(name) - {_format_path(path)}
|
|
224
|
+
if not found:
|
|
225
|
+
return ""
|
|
226
|
+
# 최상위를 앞에 둔다. 잘못 놓인 키는 대개 최상위 필드가 한 단계 안으로
|
|
227
|
+
# 들어간 것이고, 나머지 소유자는 같은 이름을 쓰는 다른 행 타입이다.
|
|
228
|
+
owners = (
|
|
229
|
+
["<root>"] if "<root>" in found else []
|
|
230
|
+
) + sorted(found - {"<root>"})
|
|
231
|
+
return (
|
|
232
|
+
f" — '{name}' is a REQUIRED property of {', '.join(owners[:3])}, not "
|
|
233
|
+
f"of {_format_path(path)}; move it there rather than removing it"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _required_property_owners(self, name: str) -> set[str]:
|
|
237
|
+
"""`name` 을 **필수**로 요구하는 객체들의 이름.
|
|
238
|
+
|
|
239
|
+
정의만 가진 자리까지 세면(예: `summary` 는 8곳에서 정의된다) 힌트가
|
|
240
|
+
이름 목록이 돼 아무것도 가리키지 못한다. 지우면 다음 라운드에서
|
|
241
|
+
`required property ... is missing` 이 나오는 자리, 즉 필수인 곳만 센다.
|
|
242
|
+
"""
|
|
243
|
+
owners: set[str] = set()
|
|
244
|
+
|
|
245
|
+
def walk(node: Any, label: str) -> None:
|
|
246
|
+
if isinstance(node, list):
|
|
247
|
+
for item in node:
|
|
248
|
+
walk(item, label)
|
|
249
|
+
return
|
|
250
|
+
if not isinstance(node, dict):
|
|
251
|
+
return
|
|
252
|
+
required = node.get("required")
|
|
253
|
+
if isinstance(required, list) and name in required:
|
|
254
|
+
owners.add(label)
|
|
255
|
+
for key, value in node.items():
|
|
256
|
+
if key == "properties" and isinstance(value, dict):
|
|
257
|
+
for child, sub in value.items():
|
|
258
|
+
walk(sub, f"{label}.{child}" if label else child)
|
|
259
|
+
elif key in ("definitions", "$defs") and isinstance(value, dict):
|
|
260
|
+
for child, sub in value.items():
|
|
261
|
+
walk(sub, child)
|
|
262
|
+
elif key in ("items", "allOf", "oneOf", "anyOf", "then", "else"):
|
|
263
|
+
walk(value, label)
|
|
264
|
+
|
|
265
|
+
walk(self.root, "<root>")
|
|
266
|
+
return owners
|
|
206
267
|
|
|
207
268
|
def _validate_array(self, instance: list, schema: dict, path: tuple[str | int, ...]) -> None:
|
|
208
269
|
min_items = schema.get("minItems")
|
|
@@ -790,6 +790,20 @@ PLAN_VERIFY_RESPONSE_FORMAT = (
|
|
|
790
790
|
)
|
|
791
791
|
|
|
792
792
|
|
|
793
|
+
# 계획 항목 큐의 렌더러 서명. 검증 계약(`worker_prompt_contract.
|
|
794
|
+
# validate_reverify_prompt`)이 `plan-verify-r<N>` 디스패치에 이 줄을 요구한다 —
|
|
795
|
+
# 번호 reverify 가 `okstra convergence reverify-prompt` 서명을 요구하는 것과
|
|
796
|
+
# 같은 이유다. 손으로 쓴 큐는 항목 id·직전 반대 의견·응답 형식이 임의로 빠지고,
|
|
797
|
+
# 그렇게 버려진 라운드가 실재한다(run 003 실측, `with_response_format` 주석).
|
|
798
|
+
RENDERED_BY_LINE = "**Rendered by:** okstra plan-items prompt"
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def with_rendered_by(queue_markdown: str) -> str:
|
|
802
|
+
"""렌더러 서명을 프롬프트 맨 앞에 붙인다. 디스패치는 이 출력을 그대로
|
|
803
|
+
`--instruction` 으로 받으므로 서명은 `## Task Instructions` 뒤에 놓인다."""
|
|
804
|
+
return f"{RENDERED_BY_LINE}\n\n{queue_markdown.lstrip()}"
|
|
805
|
+
|
|
806
|
+
|
|
793
807
|
def with_response_format(queue_markdown: str) -> str:
|
|
794
808
|
"""큐 본문 뒤에 응답 형식 블록을 덧붙인다. run 003 실측: 리드가 손 조립
|
|
795
809
|
중 이 블록을 빼먹어 검증자 2명의 판정 32건 전부가 형식 불일치로 버려졌다."""
|
|
@@ -43,6 +43,7 @@ from .plan_items import (
|
|
|
43
43
|
reverify_prompt_text,
|
|
44
44
|
tie_vote_item_ids,
|
|
45
45
|
voting_analyser_keys,
|
|
46
|
+
with_rendered_by,
|
|
46
47
|
with_response_format,
|
|
47
48
|
)
|
|
48
49
|
from .paths import RunRef
|
|
@@ -242,6 +243,14 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
242
243
|
metavar="<worker-id>=<path>",
|
|
243
244
|
help="one worker's plan-verify Markdown result (repeatable)",
|
|
244
245
|
)
|
|
246
|
+
apply_verdicts.add_argument(
|
|
247
|
+
"--items", type=Path,
|
|
248
|
+
metavar="<plan-items artifact>",
|
|
249
|
+
help="the plan-items artifact this round dispatched, when it dispatched "
|
|
250
|
+
"part of the queue (the `--tie-vote` artifact). Its dispatchQueue "
|
|
251
|
+
"is what each --result must answer; without it a result is checked "
|
|
252
|
+
"against the whole persisted queue",
|
|
253
|
+
)
|
|
245
254
|
apply_verdicts.add_argument(
|
|
246
255
|
"--run-manifest", type=Path,
|
|
247
256
|
help="resolve the project a `fact` claim's probe runs against; without "
|
|
@@ -831,10 +840,10 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
831
840
|
rows.append(split)
|
|
832
841
|
body = with_response_format("".join(rows))
|
|
833
842
|
if envelope.get("dispatchKind") == "critic-tie":
|
|
834
|
-
return critic_tie_prompt_text(body)
|
|
843
|
+
return with_rendered_by(critic_tie_prompt_text(body))
|
|
835
844
|
if envelope.get("dispatchKind") == "reverify":
|
|
836
|
-
return reverify_prompt_text(body)
|
|
837
|
-
return body
|
|
845
|
+
return with_rendered_by(reverify_prompt_text(body))
|
|
846
|
+
return with_rendered_by(body)
|
|
838
847
|
|
|
839
848
|
|
|
840
849
|
def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
|
|
@@ -1377,6 +1386,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1377
1386
|
{item_id for item_id in queue if isinstance(item_id, str)}
|
|
1378
1387
|
if isinstance(queue, list) else known
|
|
1379
1388
|
)
|
|
1389
|
+
assigned = _narrow_assignment(args, assigned)
|
|
1380
1390
|
rows = _incoming_verdict_rows(args, assigned)
|
|
1381
1391
|
missing = sorted(item_id for item_id in rows if item_id not in known)
|
|
1382
1392
|
if missing:
|
|
@@ -1872,6 +1882,38 @@ def _correction_prompt(args: argparse.Namespace) -> str:
|
|
|
1872
1882
|
return correction_prompt_text(_prompt(args))
|
|
1873
1883
|
|
|
1874
1884
|
|
|
1885
|
+
def _narrow_assignment(
|
|
1886
|
+
args: argparse.Namespace, assigned: set[object],
|
|
1887
|
+
) -> set[object]:
|
|
1888
|
+
"""이 라운드가 실제로 배정한 항목들.
|
|
1889
|
+
|
|
1890
|
+
tie 라운드는 큐의 일부(7항목)만 critic 에게 보낸다. 그런데 `--result` 는
|
|
1891
|
+
지금까지 state 에 남은 라운드 큐(44항목) 전체를 배정으로 보고 답 없는
|
|
1892
|
+
37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
|
|
1893
|
+
tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
|
|
1894
|
+
`--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
|
|
1895
|
+
`--items` 로 그 라운드의 artifact 를 주면 그 `dispatchQueue` 가 배정이
|
|
1896
|
+
된다. 미응답 검사 자체는 그대로다 — 좁힌 배정 안에서 여전히 전건을
|
|
1897
|
+
요구하므로, 워커가 자기 몫을 조용히 빠뜨리는 것은 계속 잡힌다.
|
|
1898
|
+
"""
|
|
1899
|
+
items_path = getattr(args, "items", None)
|
|
1900
|
+
if items_path is None:
|
|
1901
|
+
return assigned
|
|
1902
|
+
narrowed = {item_id for item_id in _assigned_item_ids(items_path)}
|
|
1903
|
+
if not narrowed:
|
|
1904
|
+
raise PlanItemContractError(
|
|
1905
|
+
f"items artifact dispatches nothing: {items_path}"
|
|
1906
|
+
)
|
|
1907
|
+
unknown = sorted(narrowed - {i for i in assigned if isinstance(i, str)})
|
|
1908
|
+
if unknown:
|
|
1909
|
+
raise PlanItemContractError(
|
|
1910
|
+
f"items artifact dispatches {unknown}, which this round's persisted "
|
|
1911
|
+
f"queue does not contain — pass the artifact this round dispatched, "
|
|
1912
|
+
f"not another round's"
|
|
1913
|
+
)
|
|
1914
|
+
return narrowed
|
|
1915
|
+
|
|
1916
|
+
|
|
1875
1917
|
def _incoming_verdict_rows(
|
|
1876
1918
|
args: argparse.Namespace, known: set[object],
|
|
1877
1919
|
) -> dict[str, list[dict[str, Any]]]:
|
|
@@ -551,6 +551,11 @@ class PrepareInputs:
|
|
|
551
551
|
# implementation 전용: `--qa-waiver "<stageKey>:<reason>"` 사용자 확인형 우회.
|
|
552
552
|
# prepare-time 에 task-level conformance 매니페스트 entry.waiver 를 채운다.
|
|
553
553
|
qa_waiver: str = ""
|
|
554
|
+
# implementation-planning 전용: `--tdd-bypass "<stage>:<reason>"` 사용자
|
|
555
|
+
# 확인형 TDD 우회. prepare-time 에 `<task-root>/qa/tdd-bypass.json` 에
|
|
556
|
+
# 사유를 원문 그대로 남기고, S10e 는 그 원장이 있을 때만 계획서의
|
|
557
|
+
# `tddExemption: user-bypass` 를 인정한다.
|
|
558
|
+
tdd_bypass: str = ""
|
|
554
559
|
stage: str = "auto"
|
|
555
560
|
# release-handoff 전용: PR 로 내보낼 stage 묶음 (csv, 예: "2,3"). 빈 값 =
|
|
556
561
|
# whole-task 모드. `--stage`(impl/fv 의 Stage Map 실행/검증 선택)와는
|
|
@@ -1915,6 +1920,40 @@ def validate_project_qa_commands(task_type: str, project_root: Path) -> None:
|
|
|
1915
1920
|
raise PrepareError(_format_qa_errors(qa_errors))
|
|
1916
1921
|
|
|
1917
1922
|
|
|
1923
|
+
def _apply_tdd_bypass_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
|
|
1924
|
+
"""`--tdd-bypass` 가 있으면 task-level TDD 우회 원장에 사유를 남긴다.
|
|
1925
|
+
|
|
1926
|
+
같은 task-key 의 다른 prepare 와 같은 파일을 read-modify-write 하므로
|
|
1927
|
+
`_apply_qa_waiver_if_requested` 와 동일한 per-task-key 락 안에서 쓴다.
|
|
1928
|
+
원장이 없으면 만든다 — 사용자가 빈 파일을 손으로 만들 이유가 없다.
|
|
1929
|
+
"""
|
|
1930
|
+
if not inp.tdd_bypass:
|
|
1931
|
+
return
|
|
1932
|
+
from .paths import task_dir
|
|
1933
|
+
from .tdd_bypass import (
|
|
1934
|
+
TddBypassError,
|
|
1935
|
+
bypass_file,
|
|
1936
|
+
parse_bypass_arg,
|
|
1937
|
+
record_bypass,
|
|
1938
|
+
)
|
|
1939
|
+
parsed = parse_bypass_arg(inp.tdd_bypass)
|
|
1940
|
+
if parsed is None:
|
|
1941
|
+
raise PrepareError(
|
|
1942
|
+
'--tdd-bypass must be "<stage>:<reason>" with a positive stage '
|
|
1943
|
+
f"number, got {inp.tdd_bypass!r}"
|
|
1944
|
+
)
|
|
1945
|
+
stage, reason = parsed
|
|
1946
|
+
path = bypass_file(task_dir(project_root, inp.task_group, inp.task_id))
|
|
1947
|
+
when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1948
|
+
with worktree_provision_mutex(
|
|
1949
|
+
okstra_home(), inp.project_id, slugify(inp.task_group), slugify(inp.task_id),
|
|
1950
|
+
):
|
|
1951
|
+
try:
|
|
1952
|
+
record_bypass(path, stage, reason, at=when)
|
|
1953
|
+
except TddBypassError as exc:
|
|
1954
|
+
raise PrepareError(f"--tdd-bypass: {exc}") from exc
|
|
1955
|
+
|
|
1956
|
+
|
|
1918
1957
|
def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
|
|
1919
1958
|
"""`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다.
|
|
1920
1959
|
|
|
@@ -2008,6 +2047,9 @@ def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
|
|
|
2008
2047
|
# waiver 는 stage 단위 Tier 3 면제라 implementation 진입에서만 적용한다.
|
|
2009
2048
|
if inp.task_type == "implementation":
|
|
2010
2049
|
_apply_qa_waiver_if_requested(inp, project_root)
|
|
2050
|
+
# TDD 우회는 계획서의 stage 를 면제하므로 계획 진입에서만 적용한다.
|
|
2051
|
+
if inp.task_type == "implementation-planning":
|
|
2052
|
+
_apply_tdd_bypass_if_requested(inp, project_root)
|
|
2011
2053
|
|
|
2012
2054
|
|
|
2013
2055
|
def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str], str]:
|
|
@@ -4563,6 +4605,25 @@ def _resolve_terminal_backend(project_root: Path, inp: PrepareInputs) -> str:
|
|
|
4563
4605
|
) if part))
|
|
4564
4606
|
|
|
4565
4607
|
|
|
4608
|
+
def lead_pane_title(task_group: str, task_id: str) -> str:
|
|
4609
|
+
"""cmux 에서 리드 pane 에 붙는 제목. 사용자가 여러 task 의 pane 을 구분하는
|
|
4610
|
+
이름이므로 slug 가 아니라 입력한 task-group / task-id 그대로 쓴다."""
|
|
4611
|
+
return f"{task_group}/{task_id}"
|
|
4612
|
+
|
|
4613
|
+
|
|
4614
|
+
def _title_lead_pane(inp: PrepareInputs) -> None:
|
|
4615
|
+
"""리드 pane 제목을 `<task-group>/<task-id>` 로 바꾼다 (cmux 백엔드 전용).
|
|
4616
|
+
|
|
4617
|
+
prepare 는 리드 세션(또는 리드를 띄울 pane)에서 실행되므로 호출 surface 가
|
|
4618
|
+
곧 리드 pane 이다. 제목은 화면 편의라 실패해도 run 을 막지 않지만, 무엇이
|
|
4619
|
+
막았는지는 stderr 에 남긴다 — codex 샌드박스가 cmux 소켓을 EPERM 으로 막는
|
|
4620
|
+
경우가 실제로 있다(`_resolve_terminal_backend` 참조).
|
|
4621
|
+
"""
|
|
4622
|
+
reason = cmux.rename_lead_surface(lead_pane_title(inp.task_group, inp.task_id))
|
|
4623
|
+
if reason:
|
|
4624
|
+
print(f"okstra: lead pane title not applied — {reason}", file=sys.stderr)
|
|
4625
|
+
|
|
4626
|
+
|
|
4566
4627
|
def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
4567
4628
|
"""Produce a complete okstra task bundle on disk. See module docstring."""
|
|
4568
4629
|
workspace_root = Path(inp.workspace_root)
|
|
@@ -4872,6 +4933,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4872
4933
|
|
|
4873
4934
|
_record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
|
|
4874
4935
|
|
|
4936
|
+
if terminal_backend == BACKEND_CMUX_PANE:
|
|
4937
|
+
_title_lead_pane(inp)
|
|
4938
|
+
|
|
4875
4939
|
if not inp.render_only:
|
|
4876
4940
|
_provision_settings_symlink(inp)
|
|
4877
4941
|
|
|
@@ -5036,6 +5100,17 @@ def build_prepare_argument_parser():
|
|
|
5036
5100
|
p.add_argument("--critic", default="")
|
|
5037
5101
|
p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
|
|
5038
5102
|
p.add_argument("--approved-plan", default="", dest="approved_plan_path")
|
|
5103
|
+
p.add_argument(
|
|
5104
|
+
"--tdd-bypass",
|
|
5105
|
+
default="",
|
|
5106
|
+
dest="tdd_bypass",
|
|
5107
|
+
help=(
|
|
5108
|
+
'User-recorded TDD bypass for one plan stage: "<stage>:<reason>" '
|
|
5109
|
+
"(implementation-planning only). Records the reason verbatim into "
|
|
5110
|
+
"<task-root>/qa/tdd-bypass.json; S10e accepts a stage declaring "
|
|
5111
|
+
"tddExemption `user-bypass` only while that record exists."
|
|
5112
|
+
),
|
|
5113
|
+
)
|
|
5039
5114
|
p.add_argument(
|
|
5040
5115
|
"--qa-waiver",
|
|
5041
5116
|
default="",
|
|
@@ -5301,6 +5376,7 @@ def main(argv: list[str]) -> int:
|
|
|
5301
5376
|
base_ref=args.base_ref,
|
|
5302
5377
|
approved_plan_path=args.approved_plan_path,
|
|
5303
5378
|
qa_waiver=args.qa_waiver,
|
|
5379
|
+
tdd_bypass=args.tdd_bypass,
|
|
5304
5380
|
stage=args.stage,
|
|
5305
5381
|
stages=args.stages,
|
|
5306
5382
|
clarification_response_path=clarification_abs,
|
|
@@ -11,16 +11,17 @@ from datetime import datetime
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Callable
|
|
13
13
|
|
|
14
|
+
from .domain.worker_presentation import strip_terminal_colors
|
|
15
|
+
|
|
14
16
|
OKSTRA = "okstra"
|
|
15
|
-
_SPEAKER_WIDTH = 14
|
|
16
17
|
_RESET = "\x1b[0m"
|
|
17
18
|
_MUTED = "\x1b[90m"
|
|
18
19
|
_LIVE_COLORS = (
|
|
19
20
|
("→ ", "\x1b[36m"),
|
|
20
|
-
("
|
|
21
|
-
("
|
|
21
|
+
("← ok", "\x1b[32m"),
|
|
22
|
+
("← error", "\x1b[31m"),
|
|
22
23
|
("!! PERMISSION DENIED", "\x1b[1;31m"),
|
|
23
|
-
("
|
|
24
|
+
("← done", "\x1b[33m"),
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
# 파일 사본이 담는 워커 진행 줄의 상한. 진행은 워커 출력의 부피가 몰리는
|
|
@@ -50,11 +51,20 @@ class SessionTranscript:
|
|
|
50
51
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
52
|
self._file = path.open("w", encoding="utf-8")
|
|
52
53
|
self._live = live
|
|
54
|
+
# cmux 워커는 색상 사용을 명시한다. 리드에서 상속한 비대화형 출력
|
|
55
|
+
# 설정이 워커 터미널의 색상까지 끄지 않도록 명시적 요청을 우선한다.
|
|
56
|
+
force_color = os.environ.get("FORCE_COLOR", "")
|
|
53
57
|
self._color = (
|
|
54
58
|
live
|
|
55
59
|
and sys.stdout.isatty()
|
|
56
|
-
and
|
|
57
|
-
|
|
60
|
+
and (
|
|
61
|
+
force_color not in ("", "0")
|
|
62
|
+
or (
|
|
63
|
+
force_color != "0"
|
|
64
|
+
and not os.environ.get("NO_COLOR")
|
|
65
|
+
and os.environ.get("TERM") != "dumb"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
58
68
|
)
|
|
59
69
|
self._clock = clock
|
|
60
70
|
self._archived = 0
|
|
@@ -89,9 +99,7 @@ class SessionTranscript:
|
|
|
89
99
|
self._keep(self._row(speaker, line), capped=True)
|
|
90
100
|
|
|
91
101
|
def _row(self, speaker: str, line: str) -> str:
|
|
92
|
-
|
|
93
|
-
# `[worker:grok]`(13칸) 줄이 두 칸이 되고 `[okstra]` 정렬이 깨진다.
|
|
94
|
-
label = f"[{speaker}]".ljust(_SPEAKER_WIDTH)
|
|
102
|
+
label = f"[{speaker}]"
|
|
95
103
|
return f"{self._clock()} {label}{line}".rstrip()
|
|
96
104
|
|
|
97
105
|
def _show(self, row: str, line: str) -> None:
|
|
@@ -110,7 +118,7 @@ class SessionTranscript:
|
|
|
110
118
|
f"{_MUTED}{row[:prefix_size]}{_RESET}"
|
|
111
119
|
f"{color}{row[prefix_size:]}{_RESET}"
|
|
112
120
|
)
|
|
113
|
-
print(row, flush=True)
|
|
121
|
+
print(row if self._color else strip_terminal_colors(row), flush=True)
|
|
114
122
|
|
|
115
123
|
def _keep(self, row: str, *, capped: bool) -> None:
|
|
116
124
|
if not capped:
|
|
@@ -136,7 +144,7 @@ class SessionTranscript:
|
|
|
136
144
|
self._file.close()
|
|
137
145
|
|
|
138
146
|
def _append(self, row: str) -> None:
|
|
139
|
-
self._file.write(row + "\n")
|
|
147
|
+
self._file.write(strip_terminal_colors(row) + "\n")
|
|
140
148
|
self._file.flush()
|
|
141
149
|
|
|
142
150
|
def _note_elision(self) -> None:
|