okstra 0.220.1 → 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/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 +9 -5
- 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/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/manager_cli.py +11 -1
- 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/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_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/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 +1 -0
- 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
|
@@ -65,6 +65,7 @@ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
|
|
|
65
65
|
"Worker direction-selection procedure",
|
|
66
66
|
"Pre-selection context exploration",
|
|
67
67
|
"Option evaluation rules",
|
|
68
|
+
"Technical evidence loop",
|
|
68
69
|
),
|
|
69
70
|
"implementation-planning": (
|
|
70
71
|
"Brief consumption",
|
|
@@ -114,6 +115,8 @@ def build_analysis_packet(
|
|
|
114
115
|
business_knowledge_text: str = "",
|
|
115
116
|
coverage_census_text: str = "",
|
|
116
117
|
has_selected_direction: bool = False,
|
|
118
|
+
mcp_servers_text: str = "",
|
|
119
|
+
technical_evidence_text: str = "",
|
|
117
120
|
) -> str:
|
|
118
121
|
"""Return the primary compact input for Claude/Codex/Antigravity analysers.
|
|
119
122
|
|
|
@@ -130,6 +133,12 @@ def build_analysis_packet(
|
|
|
130
133
|
|
|
131
134
|
`coverage_census_text` is the `## Coverage Census` block
|
|
132
135
|
`coverage_census.render_census_section` rendered; empty means no census.
|
|
136
|
+
|
|
137
|
+
`mcp_servers_text` 는 prepare 가 provider 별 도달성까지 렌더한 선언 서버
|
|
138
|
+
목록이다. 빈 문자열이면 선언이 없는 것이고 절을 싣지 않는다.
|
|
139
|
+
|
|
140
|
+
`technical_evidence_text` 는 carry-in 이 technical-verification 결과일 때
|
|
141
|
+
`technical_evidence_packet_block` 이 렌더한 증거 위치 절이다.
|
|
133
142
|
"""
|
|
134
143
|
brief_text = task_brief_path.read_text(encoding="utf-8")
|
|
135
144
|
group_context_text = _read_optional(group_context_path) if group_context_path else ""
|
|
@@ -150,6 +159,9 @@ def build_analysis_packet(
|
|
|
150
159
|
has_selected_direction,
|
|
151
160
|
)
|
|
152
161
|
)
|
|
162
|
+
# 짧은 포인터 절이라 46KB 에서 잘리는 읽기에서도 도착하도록 브리프보다 앞에 둔다.
|
|
163
|
+
if technical_evidence_text:
|
|
164
|
+
parts.extend(["", technical_evidence_text])
|
|
153
165
|
# 그룹 문서는 사람 절과 okstra 메모리 영역으로 갈라 싣는다. 사람 절("왜")은
|
|
154
166
|
# 브리프 발췌보다 앞 — 짧고, 46KB 에서 잘리는 읽기(agy view_file)에서도
|
|
155
167
|
# 도착해야 한다. 형제 task 의 결론은 브리프 뒤 — 브리프를 밀어내면 안 된다.
|
|
@@ -158,6 +170,8 @@ def build_analysis_packet(
|
|
|
158
170
|
)
|
|
159
171
|
parts.extend(_group_context_block(group_human))
|
|
160
172
|
parts.extend(_brief_block(brief_text))
|
|
173
|
+
if mcp_servers_text:
|
|
174
|
+
parts.extend(["", "## Available MCP Servers", "", mcp_servers_text])
|
|
161
175
|
if direct_work_text:
|
|
162
176
|
parts.extend(["", direct_work_text, ""])
|
|
163
177
|
parts.extend(_group_memory_block(
|
|
@@ -2,23 +2,46 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from .convergence import canonical_run_state_artifact, validated_run_authority
|
|
9
10
|
from .correction_round import CorrectionRoundError, correction_instruction
|
|
11
|
+
from .file_size_cap import FILE_LINE_LIMIT, FILE_LINE_TARGET, file_size_violations
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
_CLI_EPILOG = r"""Usage:
|
|
13
15
|
okstra correction prompt --run-manifest <path> --round <N> --role <executor|verifier> \
|
|
14
|
-
--base <sha> --finding <F-id> [--
|
|
16
|
+
--base <sha> [--finding <F-id> ...] [--stage-base <sha>]
|
|
17
|
+
okstra correction file-size --base <stage base sha> [--head <rev>] [--repo <path>]
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
`prompt` prints the instruction body for one correction-round prompt; pass it to
|
|
17
20
|
`okstra agent-prompt materialize --instruction` with --dispatch-kind correction-r<N>.
|
|
18
|
-
Findings are read from the run's convergence record
|
|
21
|
+
Findings are read from the run's convergence record; --stage-base adds the file-size
|
|
22
|
+
findings of `<stage-base>..<base>` (the 400-line limit only). Exit 1 on a refusal.
|
|
23
|
+
|
|
24
|
+
`file-size` prints one `FILE-SIZE:<path>:401` line per file the stage diff leaves over
|
|
25
|
+
the 400-line limit (blocking) and one `FILE-SIZE-ADVISORY:<path>:301` line per file it
|
|
26
|
+
leaves over only the 300-line target, then `QA-RESULT: PASS|FAIL`; only a limit line
|
|
27
|
+
fails. Exit 1 on FAIL, 2 when git cannot read the range.
|
|
19
28
|
"""
|
|
20
29
|
|
|
21
30
|
|
|
31
|
+
def _file_size(args: argparse.Namespace) -> int:
|
|
32
|
+
try:
|
|
33
|
+
violations = file_size_violations(Path(args.repo), args.base, args.head)
|
|
34
|
+
except subprocess.CalledProcessError as exc:
|
|
35
|
+
print(f"error: git: {exc.stderr.decode(errors='replace').strip()}", file=sys.stderr)
|
|
36
|
+
return 2
|
|
37
|
+
for violation in violations:
|
|
38
|
+
print(violation.line())
|
|
39
|
+
failed = any(v.blocking for v in violations)
|
|
40
|
+
print(f"QA-RESULT: {'FAIL' if failed else 'PASS'} "
|
|
41
|
+
f"(file-size limit {FILE_LINE_LIMIT}, target {FILE_LINE_TARGET})")
|
|
42
|
+
return 1 if failed else 0
|
|
43
|
+
|
|
44
|
+
|
|
22
45
|
def main(argv: list[str] | None = None) -> int:
|
|
23
46
|
parser = argparse.ArgumentParser(
|
|
24
47
|
prog="okstra correction",
|
|
@@ -33,9 +56,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
33
56
|
prompt.add_argument("--role", choices=("executor", "verifier"), required=True)
|
|
34
57
|
prompt.add_argument("--base", required=True,
|
|
35
58
|
help="the stage HEAD before this round")
|
|
36
|
-
prompt.add_argument("--finding", action="append", default=[],
|
|
59
|
+
prompt.add_argument("--finding", action="append", default=[],
|
|
37
60
|
help="a convergence finding id to fix; repeat for several")
|
|
61
|
+
prompt.add_argument("--stage-base", default="",
|
|
62
|
+
help="the stage base commit; adds the stage diff's file-size limit findings")
|
|
63
|
+
size = commands.add_parser("file-size", help="check the stage diff against the file-size limit and target")
|
|
64
|
+
size.add_argument("--base", required=True, help="the stage base commit")
|
|
65
|
+
size.add_argument("--head", default="HEAD")
|
|
66
|
+
size.add_argument("--repo", default=".", help="the stage worktree (default: cwd)")
|
|
38
67
|
args = parser.parse_args(argv)
|
|
68
|
+
if args.command == "file-size":
|
|
69
|
+
return _file_size(args)
|
|
39
70
|
|
|
40
71
|
try:
|
|
41
72
|
authority = validated_run_authority(args.run_manifest)
|
|
@@ -45,10 +76,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
45
76
|
authority, manifest_field="convergenceStatePath", prefix="convergence",
|
|
46
77
|
label="convergence state",
|
|
47
78
|
)
|
|
79
|
+
size_violations = [
|
|
80
|
+
v for v in file_size_violations(Path.cwd(), args.stage_base, args.base)
|
|
81
|
+
if v.blocking
|
|
82
|
+
] if args.stage_base else []
|
|
48
83
|
print(correction_instruction(
|
|
49
84
|
convergence_path=convergence_path, round_number=args.round, role=args.role,
|
|
50
|
-
base=args.base, finding_ids=args.finding,
|
|
85
|
+
base=args.base, finding_ids=args.finding, stage_base=args.stage_base,
|
|
86
|
+
size_violations=size_violations,
|
|
51
87
|
))
|
|
88
|
+
except subprocess.CalledProcessError as exc:
|
|
89
|
+
print(f"error: git: {exc.stderr.decode(errors='replace').strip()}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
52
91
|
except (CorrectionRoundError, OSError, ValueError) as exc:
|
|
53
92
|
print(f"error: {exc}", file=sys.stderr)
|
|
54
93
|
return 1
|
|
@@ -14,6 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Mapping, Sequence
|
|
15
15
|
|
|
16
16
|
from .execution_identity import ExecutionManifest
|
|
17
|
+
from .file_size_cap import FILE_LINE_LIMIT, FileSizeViolation
|
|
17
18
|
from .json_boundary import load_owned_object
|
|
18
19
|
|
|
19
20
|
MAX_CORRECTION_ROUNDS = 2
|
|
@@ -101,17 +102,11 @@ def _finding_lines(findings: Sequence[Mapping[str, object]]) -> list[str]:
|
|
|
101
102
|
return lines
|
|
102
103
|
|
|
103
104
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
) -> str:
|
|
108
|
-
"""The instruction body one correction-round prompt carries, read from the convergence record."""
|
|
109
|
-
if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
|
|
110
|
-
raise CorrectionRoundError(f"--round must be 1..{MAX_CORRECTION_ROUNDS}")
|
|
105
|
+
def _convergence_findings(
|
|
106
|
+
convergence_path: Path, finding_ids: Sequence[str],
|
|
107
|
+
) -> list[Mapping[str, object]]:
|
|
111
108
|
if not finding_ids:
|
|
112
|
-
|
|
113
|
-
if not base.strip():
|
|
114
|
-
raise CorrectionRoundError("--base is required: the stage HEAD before this round")
|
|
109
|
+
return []
|
|
115
110
|
record = load_owned_object(convergence_path, artifact="convergence record")
|
|
116
111
|
by_id = {
|
|
117
112
|
str(row.get("findingId")): row for row in record.get("findings") or []
|
|
@@ -123,14 +118,40 @@ def correction_instruction(
|
|
|
123
118
|
f"findings not in {convergence_path.name}: {', '.join(missing)}; "
|
|
124
119
|
f"it holds {', '.join(sorted(by_id)) or 'none'}"
|
|
125
120
|
)
|
|
126
|
-
|
|
121
|
+
return [by_id[fid] for fid in finding_ids]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def correction_instruction(
|
|
125
|
+
*, convergence_path: Path, round_number: int, role: str, base: str,
|
|
126
|
+
finding_ids: Sequence[str], stage_base: str = "",
|
|
127
|
+
size_violations: Sequence[FileSizeViolation] = (),
|
|
128
|
+
) -> str:
|
|
129
|
+
"""The instruction body one correction-round prompt carries.
|
|
130
|
+
|
|
131
|
+
Verifier findings are read from the convergence record. File-size findings come from
|
|
132
|
+
`file_size_cap.file_size_violations` over `stage_base..base`, so they need no verifier
|
|
133
|
+
to have reported them.
|
|
134
|
+
"""
|
|
135
|
+
if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
|
|
136
|
+
raise CorrectionRoundError(f"--round must be 1..{MAX_CORRECTION_ROUNDS}")
|
|
137
|
+
if not finding_ids and not size_violations:
|
|
138
|
+
raise CorrectionRoundError(
|
|
139
|
+
"name at least one --finding, or pass --stage-base for a stage whose diff "
|
|
140
|
+
"breaks the 400-line file-size limit"
|
|
141
|
+
)
|
|
142
|
+
if not base.strip():
|
|
143
|
+
raise CorrectionRoundError("--base is required: the stage HEAD before this round")
|
|
144
|
+
findings = _convergence_findings(convergence_path, finding_ids)
|
|
127
145
|
results_dir = convergence_path.parent.parent / "worker-results"
|
|
146
|
+
stage_base_ref = stage_base.strip() or "<the stage base>"
|
|
128
147
|
header = [
|
|
129
148
|
f"- Convergence record: `{convergence_path}`",
|
|
130
149
|
f"- Verifier results (each finding's suggested fix): `{results_dir}`",
|
|
131
150
|
f"- Stage HEAD before this round: `{base.strip()}`",
|
|
151
|
+
*([f"- Stage base (file-size limit): `{stage_base.strip()}`"] if stage_base.strip() else []),
|
|
132
152
|
"- Findings:",
|
|
133
153
|
*(" " + line for line in _finding_lines(findings)),
|
|
154
|
+
*(f" - `{v.finding_id}` (file-size limit; okstra): {v.line()}" for v in size_violations),
|
|
134
155
|
"",
|
|
135
156
|
]
|
|
136
157
|
if role == "executor":
|
|
@@ -144,6 +165,11 @@ def correction_instruction(
|
|
|
144
165
|
"dependency, or a schema or migration change is not fixed here: stop and say so.",
|
|
145
166
|
"- Update a test's expected value only where the finding shows the oracle itself is "
|
|
146
167
|
"wrong; never weaken or delete an assertion to make a test pass.",
|
|
168
|
+
f"- A `FILE-SIZE:` finding is fixed by moving whole responsibilities (a group of "
|
|
169
|
+
f"functions, a class, a `describe` block) into new files beside the oversized one; "
|
|
170
|
+
f"those files count as inside this stage. Never compress code or drop comments to "
|
|
171
|
+
f"meet the number. Done when `okstra correction file-size --base {stage_base_ref}` "
|
|
172
|
+
f"prints `QA-RESULT: PASS` (limit {FILE_LINE_LIMIT} lines; aim for 300).",
|
|
147
173
|
"- Follow the executor sidecar's TDD, pre-commit diff review and self-check, and "
|
|
148
174
|
"commit with a Conventional Commit whose body names the finding ids.",
|
|
149
175
|
"- Re-run the plan validation commands that cover the fix, and end with a "
|
|
@@ -161,6 +187,9 @@ def correction_instruction(
|
|
|
161
187
|
"the stage was reviewed in the initial round.",
|
|
162
188
|
"- Re-run every plan validation command, as the verifier sidecar's Fix-run "
|
|
163
189
|
"incremental scope requires.",
|
|
190
|
+
f"- Run `okstra correction file-size --base {stage_base_ref}` against the stage base, "
|
|
191
|
+
"not this round's base: each `FILE-SIZE:` line it prints is a blocking finding, and each "
|
|
192
|
+
"`FILE-SIZE-ADVISORY:` line a recommendation.",
|
|
164
193
|
"- Verdict `PASS` when every finding is fixed and no blocking check hits the "
|
|
165
194
|
"correction diff; otherwise `FAIL`, with each remaining or new finding in the "
|
|
166
195
|
"usual format.",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""provider 어댑터와 핵심 판정이 함께 쓰는 벤더 중립 MCP 목록 값."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
McpServerState = Literal["enabled", "disabled", "needs-auth"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class VisibleMcpServer:
|
|
13
|
+
name: str
|
|
14
|
+
state: McpServerState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class McpInventory:
|
|
19
|
+
"""``listed`` 가 False 면 목록 자체를 못 얻은 것이라 선언 서버를 ``missing``
|
|
20
|
+
이 아니라 ``unknown`` 으로 판정한다."""
|
|
21
|
+
|
|
22
|
+
listed: bool
|
|
23
|
+
servers: tuple[VisibleMcpServer, ...] = ()
|
|
24
|
+
note: str = ""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def unavailable(cls, note: str) -> McpInventory:
|
|
28
|
+
return cls(listed=False, note=note)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
McpInventoryParser = Callable[[str], McpInventory]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class McpInventoryProbe:
|
|
36
|
+
"""추론 호출 없이 MCP 서버를 나열하는 명령과 그 출력 파서.
|
|
37
|
+
|
|
38
|
+
실행은 어댑터가 아니라 핵심 코드가 맡아 모든 provider 에 같은 타임아웃과
|
|
39
|
+
cwd(프로젝트 루트)를 적용한다.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
argv: tuple[str, ...]
|
|
43
|
+
parse: McpInventoryParser
|
|
@@ -5,6 +5,7 @@ from collections.abc import Callable
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from typing import TYPE_CHECKING, Literal, Mapping, Optional, Protocol
|
|
7
7
|
|
|
8
|
+
from .mcp import McpInventoryProbe
|
|
8
9
|
from .role import RoleCatalogError, normalize_role, ROLE_CATALOG
|
|
9
10
|
from .worker_exec import WorkerWriteCapability
|
|
10
11
|
|
|
@@ -195,6 +196,7 @@ class ProviderSpec:
|
|
|
195
196
|
exec_strategy: Optional["ExecutionStrategy"] = None
|
|
196
197
|
execution_normalizer: Optional[ExecutionNormalizer] = None
|
|
197
198
|
served_model_normalizer: ServedModelNormalizer = unavailable_served_model
|
|
199
|
+
mcp_inventory: Optional[McpInventoryProbe] = None
|
|
198
200
|
_legacy_role_restriction: frozenset[str] = field(
|
|
199
201
|
init=False,
|
|
200
202
|
repr=False,
|
|
@@ -33,7 +33,13 @@ class WizardPrompt:
|
|
|
33
33
|
|
|
34
34
|
@dataclass(frozen=True)
|
|
35
35
|
class AnswerProtocol:
|
|
36
|
-
kind: Literal[
|
|
36
|
+
kind: Literal[
|
|
37
|
+
"exact-value",
|
|
38
|
+
"numbered",
|
|
39
|
+
"selected-labels",
|
|
40
|
+
"group-json",
|
|
41
|
+
"numbered-group-json",
|
|
42
|
+
]
|
|
37
43
|
multi: bool = False
|
|
38
44
|
|
|
39
45
|
|
|
@@ -120,6 +126,76 @@ def _normalize_numbered_answer(
|
|
|
120
126
|
)
|
|
121
127
|
|
|
122
128
|
|
|
129
|
+
_LABEL_SEPARATOR = ", "
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def labels_decode_uniquely(labels: tuple[str, ...]) -> bool:
|
|
133
|
+
"""이 라벨들을 `", "` 로 이은 어떤 선택이든 한 가지로만 되쪼개지는가.
|
|
134
|
+
|
|
135
|
+
네이티브 다중 선택 호스트는 고른 라벨을 `", "` 로 이어 돌려주는데 라벨 자체도
|
|
136
|
+
`", "` 를 품을 수 있다. 선택 문자열은 `label + ", "` 낱말의 연접이므로 그
|
|
137
|
+
낱말 집합에 대한 Sardinas-Patterson 유일 복호 판정이다.
|
|
138
|
+
"""
|
|
139
|
+
words = {label + _LABEL_SEPARATOR for label in labels}
|
|
140
|
+
if len(words) != len(labels):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def dangling(prefixes: set[str], strings: set[str]) -> set[str]:
|
|
144
|
+
return {
|
|
145
|
+
string[len(prefix):]
|
|
146
|
+
for prefix in prefixes
|
|
147
|
+
for string in strings
|
|
148
|
+
if string != prefix and string.startswith(prefix)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
seen: set[str] = set()
|
|
152
|
+
suffixes = dangling(words, words)
|
|
153
|
+
while suffixes:
|
|
154
|
+
if suffixes & words:
|
|
155
|
+
return False
|
|
156
|
+
seen |= suffixes
|
|
157
|
+
suffixes = (dangling(words, suffixes) | dangling(suffixes, words)) - seen
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_selected_labels(prompt: WizardPrompt, answer: str) -> str:
|
|
162
|
+
text = answer or ""
|
|
163
|
+
if not text.strip():
|
|
164
|
+
return ""
|
|
165
|
+
target = text + _LABEL_SEPARATOR
|
|
166
|
+
words = tuple(option.label + _LABEL_SEPARATOR for option in prompt.options)
|
|
167
|
+
# ways[i] 는 target[i:] 를 쪼개는 방법 수(2 에서 멈춤), first[i] 는 방법이
|
|
168
|
+
# 하나일 때 그 첫 옵션 위치다.
|
|
169
|
+
ways = [0] * len(target) + [1]
|
|
170
|
+
first = [-1] * (len(target) + 1)
|
|
171
|
+
for start in range(len(target) - 1, -1, -1):
|
|
172
|
+
for index, word in enumerate(words):
|
|
173
|
+
if target.startswith(word, start) and ways[start + len(word)]:
|
|
174
|
+
ways[start] = min(2, ways[start] + ways[start + len(word)])
|
|
175
|
+
first[start] = index
|
|
176
|
+
if ways[0] == 0:
|
|
177
|
+
values = {option.value for option in prompt.options}
|
|
178
|
+
# 라벨 대신 값 CSV 를 낸 제출(직접 CLI 입력, 값으로 바꿔 내는 리드)도 받는다.
|
|
179
|
+
if all(item.strip() in values for item in text.split(",")):
|
|
180
|
+
return ",".join(item.strip() for item in text.split(","))
|
|
181
|
+
raise WizardAnswerError(
|
|
182
|
+
f"multi-select answer is not a {_LABEL_SEPARATOR!r}-joined list "
|
|
183
|
+
f"of option labels: {text}"
|
|
184
|
+
)
|
|
185
|
+
if ways[0] > 1:
|
|
186
|
+
raise WizardAnswerError(f"multi-select answer labels are ambiguous: {text}")
|
|
187
|
+
selected: set[int] = set()
|
|
188
|
+
position = 0
|
|
189
|
+
while position < len(target):
|
|
190
|
+
selected.add(first[position])
|
|
191
|
+
position += len(words[first[position]])
|
|
192
|
+
return ",".join(
|
|
193
|
+
option.value
|
|
194
|
+
for index, option in enumerate(prompt.options)
|
|
195
|
+
if index in selected
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
123
199
|
def _group_answers(answer: str) -> dict[str, object]:
|
|
124
200
|
try:
|
|
125
201
|
answers = json.loads(answer or "{}")
|
|
@@ -137,19 +213,22 @@ def _normalize_group_answer(
|
|
|
137
213
|
numbered: bool,
|
|
138
214
|
) -> str:
|
|
139
215
|
answers = _group_answers(answer)
|
|
140
|
-
if not numbered:
|
|
141
|
-
return json.dumps(answers, ensure_ascii=False, separators=(",", ":"))
|
|
142
216
|
questions = {question.step: question for question in prompt.questions}
|
|
143
217
|
normalized = dict(answers)
|
|
144
218
|
for step, raw_answer in answers.items():
|
|
145
219
|
question = questions.get(step)
|
|
146
220
|
if question is None:
|
|
147
221
|
continue
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
222
|
+
if numbered:
|
|
223
|
+
normalized[step] = _normalize_numbered_answer(
|
|
224
|
+
question,
|
|
225
|
+
str(raw_answer or ""),
|
|
226
|
+
multi=question.multi,
|
|
227
|
+
)
|
|
228
|
+
elif question.multi:
|
|
229
|
+
normalized[step] = _normalize_selected_labels(
|
|
230
|
+
question, str(raw_answer or "")
|
|
231
|
+
)
|
|
153
232
|
return json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))
|
|
154
233
|
|
|
155
234
|
|
|
@@ -162,6 +241,8 @@ def normalize_planned_answer(
|
|
|
162
241
|
return answer
|
|
163
242
|
if protocol.kind == "numbered":
|
|
164
243
|
return _normalize_numbered_answer(prompt, answer, multi=protocol.multi)
|
|
244
|
+
if protocol.kind == "selected-labels":
|
|
245
|
+
return _normalize_selected_labels(prompt, answer)
|
|
165
246
|
if protocol.kind in {"group-json", "numbered-group-json"}:
|
|
166
247
|
return _normalize_group_answer(
|
|
167
248
|
prompt,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""File-size thresholds for source and test files an implementation stage writes.
|
|
2
|
+
|
|
3
|
+
Every later agent that touches a file loads all of it into context, so a stage that
|
|
4
|
+
grows one file past a few hundred lines makes every following run pay for it. Both
|
|
5
|
+
thresholds are measured on the stage diff: 300 lines is the target, reported as an
|
|
6
|
+
advisory finding, and 400 lines is the limit, a blocking finding the correction round
|
|
7
|
+
fixes by splitting the file (`_implementation-deliverable.md` §"Correction round").
|
|
8
|
+
|
|
9
|
+
A line is a physical line, as `wc -l` and an editor's line numbers count it, plus an
|
|
10
|
+
unterminated last line. The cost being capped is what an agent reads, and comments and
|
|
11
|
+
blank lines are read too; it also lets the executor check the number with any tool.
|
|
12
|
+
|
|
13
|
+
Only source and test files are capped: the extensions in `self_mock_signals.EXT_TO_LANG`,
|
|
14
|
+
the languages okstra's coding preflight has rules for. Lockfiles, JSON / YAML data,
|
|
15
|
+
snapshots, Markdown, SQL and config fall outside by extension. Inside that set, vendored,
|
|
16
|
+
fixture, snapshot, generated and `.okstra` paths are exempt, and so is a file whose head
|
|
17
|
+
declares itself generated.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
|
|
25
|
+
from .self_mock_signals import EXT_TO_LANG
|
|
26
|
+
|
|
27
|
+
FILE_LINE_TARGET = 300
|
|
28
|
+
FILE_LINE_LIMIT = 400
|
|
29
|
+
|
|
30
|
+
_EXEMPT_SEGMENTS = frozenset({
|
|
31
|
+
".okstra", "node_modules", "vendor", "fixtures", "__fixtures__", "__snapshots__",
|
|
32
|
+
"generated", "__generated__",
|
|
33
|
+
})
|
|
34
|
+
_GENERATED_MARKERS = (b"@generated", b"do not edit")
|
|
35
|
+
_MARKER_HEAD_LINES = 5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class FileSizeViolation:
|
|
40
|
+
path: str
|
|
41
|
+
before: int | None
|
|
42
|
+
after: int
|
|
43
|
+
kind: str
|
|
44
|
+
threshold: int
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def blocking(self) -> bool:
|
|
48
|
+
return self.threshold == FILE_LINE_LIMIT
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def finding_id(self) -> str:
|
|
52
|
+
prefix = "FILE-SIZE" if self.blocking else "FILE-SIZE-ADVISORY"
|
|
53
|
+
return f"{prefix}:{self.path}"
|
|
54
|
+
|
|
55
|
+
def line(self) -> str:
|
|
56
|
+
before = "new" if self.before is None else str(self.before)
|
|
57
|
+
label = "limit" if self.blocking else "target"
|
|
58
|
+
return (
|
|
59
|
+
f"{self.finding_id}:{self.threshold + 1} {before}->{self.after} lines "
|
|
60
|
+
f"({self.kind}; {label} {self.threshold})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capped_path(path: str) -> bool:
|
|
65
|
+
"""True when the cap applies to this repository-relative path."""
|
|
66
|
+
pure = PurePosixPath(path)
|
|
67
|
+
if EXT_TO_LANG.get(pure.suffix) is None or ".min." in pure.name:
|
|
68
|
+
return False
|
|
69
|
+
return not any(part in _EXEMPT_SEGMENTS for part in pure.parts[:-1])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def physical_lines(data: bytes) -> int:
|
|
73
|
+
return data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _declares_generated(data: bytes) -> bool:
|
|
77
|
+
head = b"\n".join(data.split(b"\n", _MARKER_HEAD_LINES)[:_MARKER_HEAD_LINES]).lower()
|
|
78
|
+
return any(marker in head for marker in _GENERATED_MARKERS)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git(repo: Path, *args: str) -> bytes:
|
|
82
|
+
return subprocess.run(
|
|
83
|
+
["git", "-C", str(repo), *args], check=True, capture_output=True,
|
|
84
|
+
).stdout
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _blob(repo: Path, rev: str, path: str) -> bytes:
|
|
88
|
+
return _git(repo, "show", f"{rev}:{path}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _changed(repo: Path, base: str, head: str) -> list[tuple[str, str | None, str]]:
|
|
92
|
+
"""(status letter, path at base or None, path at head) for each added or modified file."""
|
|
93
|
+
fields = _git(repo, "diff", "--name-status", "-z", "-M", f"{base}..{head}").split(b"\0")
|
|
94
|
+
rows: list[tuple[str, str | None, str]] = []
|
|
95
|
+
index = 0
|
|
96
|
+
while index < len(fields) and fields[index]:
|
|
97
|
+
status = fields[index].decode()[0]
|
|
98
|
+
if status == "R":
|
|
99
|
+
rows.append((status, fields[index + 1].decode(), fields[index + 2].decode()))
|
|
100
|
+
index += 3
|
|
101
|
+
continue
|
|
102
|
+
path = fields[index + 1].decode()
|
|
103
|
+
if status in "AM":
|
|
104
|
+
rows.append((status, path if status == "M" else None, path))
|
|
105
|
+
index += 2
|
|
106
|
+
return rows
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _breach(before: int | None, after: int, threshold: int) -> str:
|
|
110
|
+
"""Which way the diff breaks `threshold`, or '' when it does not."""
|
|
111
|
+
if after <= threshold:
|
|
112
|
+
return ""
|
|
113
|
+
if before is None:
|
|
114
|
+
return f"new file over {threshold}"
|
|
115
|
+
if before <= threshold:
|
|
116
|
+
return f"crossed {threshold}"
|
|
117
|
+
return f"grew a file over {threshold}" if after > before else ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def file_size_violations(repo: Path, base: str, head: str = "HEAD") -> list[FileSizeViolation]:
|
|
121
|
+
"""Files the `base..head` diff leaves over a threshold, in diff order.
|
|
122
|
+
|
|
123
|
+
A new file over a threshold, an existing file the diff takes over it, and a file
|
|
124
|
+
already over it that the diff grows. A file already over it that the diff leaves the
|
|
125
|
+
same size or shrinks is not this stage's to split. A file that breaks the limit is
|
|
126
|
+
reported once, as blocking; one that breaks only the target is advisory.
|
|
127
|
+
"""
|
|
128
|
+
violations: list[FileSizeViolation] = []
|
|
129
|
+
for _status, old_path, path in _changed(repo, base, head):
|
|
130
|
+
if not capped_path(path):
|
|
131
|
+
continue
|
|
132
|
+
data = _blob(repo, head, path)
|
|
133
|
+
after = physical_lines(data)
|
|
134
|
+
if after <= FILE_LINE_TARGET or _declares_generated(data):
|
|
135
|
+
continue
|
|
136
|
+
before = None if old_path is None else physical_lines(_blob(repo, base, old_path))
|
|
137
|
+
for threshold in (FILE_LINE_LIMIT, FILE_LINE_TARGET):
|
|
138
|
+
kind = _breach(before, after, threshold)
|
|
139
|
+
if kind:
|
|
140
|
+
violations.append(FileSizeViolation(path, before, after, kind, threshold))
|
|
141
|
+
break
|
|
142
|
+
return violations
|
|
@@ -25,7 +25,6 @@ from .agent.invocation import (
|
|
|
25
25
|
verify_agent_invocation,
|
|
26
26
|
)
|
|
27
27
|
from .path_hints import hydrate_active_run_context
|
|
28
|
-
from .report_language import resolve_report_language
|
|
29
28
|
from .report_inputs import report_narrative_path, uses_report_contract_v3
|
|
30
29
|
from .report_synthesis_packet import (
|
|
31
30
|
ReportSynthesisPacketError,
|
|
@@ -708,11 +707,9 @@ def _prompt_body(
|
|
|
708
707
|
context.active_context,
|
|
709
708
|
context.team_state,
|
|
710
709
|
worker.model,
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
context.active_context,
|
|
715
|
-
),
|
|
710
|
+
# 조립이 `meta.reportLanguage` 로 옮기는 매니페스트 값. 설정을 다시 읽으면
|
|
711
|
+
# prepare 뒤의 설정 변경이 프롬프트와 리포트의 언어를 가른다.
|
|
712
|
+
str(context.manifest.get("reportLanguage") or "en"),
|
|
716
713
|
synthesis_packet_path,
|
|
717
714
|
)
|
|
718
715
|
return analysis_prompt_body(
|
|
@@ -139,6 +139,11 @@ def _render_list(command: str, items: list[dict]) -> list[str]:
|
|
|
139
139
|
return rows
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def _age_text(seconds: int) -> str:
|
|
143
|
+
hours, rest = divmod(int(seconds), 3600)
|
|
144
|
+
return f"{hours}h {rest // 60}m"
|
|
145
|
+
|
|
146
|
+
|
|
142
147
|
def _render_manager_status(payload: dict) -> list[str]:
|
|
143
148
|
rows: list[str] = []
|
|
144
149
|
manifest = payload.get("manifest") if isinstance(payload.get("manifest"), dict) else {}
|
|
@@ -148,6 +153,10 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
148
153
|
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
149
154
|
for key in ("planned", "missing", "running", "done", "blocked", "discussion"):
|
|
150
155
|
rows.append(line(f"Summary {key}", summary.get(key)))
|
|
156
|
+
rows.append(line("Synced at", payload.get("syncedAt") or "never"))
|
|
157
|
+
if "syncAgeSeconds" in payload:
|
|
158
|
+
rows.append(line("Sync age", _age_text(payload["syncAgeSeconds"])))
|
|
159
|
+
rows.append(line("Changed since sync count", payload.get("changedSinceSyncCount", 0)))
|
|
151
160
|
children = payload.get("children") if isinstance(payload.get("children"), list) else []
|
|
152
161
|
for index, child in enumerate(children, 1):
|
|
153
162
|
if not isinstance(child, dict):
|
|
@@ -156,7 +165,8 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
156
165
|
("current phase", "currentPhase"), ("work status", "workStatus"),
|
|
157
166
|
("summary", "summaryBucket"), ("project linked", "projectLinked"),
|
|
158
167
|
("ticket", "ticketId"), ("brief path", "briefPath"),
|
|
159
|
-
("report path", "latestReportRecordPath"),
|
|
168
|
+
("report path", "latestReportRecordPath"),
|
|
169
|
+
("changed since sync", "changedSinceSync"), ("error", "error")):
|
|
160
170
|
if key in child:
|
|
161
171
|
value = child.get(key)
|
|
162
172
|
rows.append(line(f"Child {index} {label}", ("yes" if value else "no") if isinstance(value, bool) else value))
|