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,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(
|
|
@@ -18,12 +18,14 @@ import argparse
|
|
|
18
18
|
import json
|
|
19
19
|
import sys
|
|
20
20
|
from collections.abc import Mapping, Sequence
|
|
21
|
+
from dataclasses import replace
|
|
21
22
|
from datetime import datetime, timezone
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Any
|
|
24
25
|
|
|
25
26
|
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
26
|
-
from okstra_ctl.lead_events import LeadEvent, append_lead_event
|
|
27
|
+
from okstra_ctl.lead_events import LeadEvent, append_lead_event, read_lead_events
|
|
28
|
+
from okstra_ctl.phases import catalog
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
class ProgressRecordError(ValueError):
|
|
@@ -64,6 +66,11 @@ _DEFAULT_DETAIL: Mapping[str, str] = {
|
|
|
64
66
|
"phase-7-teardown": "shutting-down-workers",
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
# phase 하나에 문구가 둘인 체크포인트. 고정 표만으로는 두 번째 호출이 첫 문구를
|
|
70
|
+
# 다시 내므로, 원장과 task type 을 보고 고른다.
|
|
71
|
+
_INTAKE_COMPLETE_DETAIL = "complete"
|
|
72
|
+
_LEAD_ONLY_SYNTHESIS_DETAIL = "authoring final report"
|
|
73
|
+
|
|
67
74
|
|
|
68
75
|
def _utc_now() -> str:
|
|
69
76
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -165,6 +172,39 @@ def _parse_field(raw: str) -> tuple[str, str]:
|
|
|
165
172
|
return name.strip(), field_value.strip()
|
|
166
173
|
|
|
167
174
|
|
|
175
|
+
def _intake_already_started(events_path: Path, event: LeadEvent) -> bool:
|
|
176
|
+
return any(
|
|
177
|
+
prior.event_type == "progress"
|
|
178
|
+
and prior.details.get("phase") == "phase-1-intake"
|
|
179
|
+
and (prior.task_key, prior.task_type, prior.run_seq)
|
|
180
|
+
== (event.task_key, event.task_type, event.run_seq)
|
|
181
|
+
for prior in read_lead_events(events_path)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _task_type_dispatches_report_writer(task_type: str) -> bool:
|
|
186
|
+
if task_type not in catalog.task_types():
|
|
187
|
+
return True
|
|
188
|
+
package = catalog.phase_record(task_type).package_name
|
|
189
|
+
profile = _read_json_object(
|
|
190
|
+
Path(catalog.__file__).parent / package / "profile.json", "phase profile"
|
|
191
|
+
)
|
|
192
|
+
return any(
|
|
193
|
+
isinstance(row, Mapping) and row.get("roleId") == "report-writer"
|
|
194
|
+
for row in profile.get("roles") or []
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _default_detail(phase: str, events_path: Path, event: LeadEvent) -> str | None:
|
|
199
|
+
if phase == "phase-1-intake" and _intake_already_started(events_path, event):
|
|
200
|
+
return _INTAKE_COMPLETE_DETAIL
|
|
201
|
+
if phase == "phase-6-synthesis" and not _task_type_dispatches_report_writer(
|
|
202
|
+
event.task_type
|
|
203
|
+
):
|
|
204
|
+
return _LEAD_ONLY_SYNTHESIS_DETAIL
|
|
205
|
+
return _DEFAULT_DETAIL.get(phase)
|
|
206
|
+
|
|
207
|
+
|
|
168
208
|
def render_progress_line(
|
|
169
209
|
phase: str,
|
|
170
210
|
fields: Sequence[tuple[str, str]] = (),
|
|
@@ -199,7 +239,9 @@ def record_progress(
|
|
|
199
239
|
)
|
|
200
240
|
ordered.append(("worker", role))
|
|
201
241
|
ordered.extend(fields)
|
|
202
|
-
|
|
242
|
+
events_path = _resolve_project_path(
|
|
243
|
+
project_root, _require_string(manifest, "leadEventsPath")
|
|
244
|
+
)
|
|
203
245
|
event = LeadEvent(
|
|
204
246
|
event_type="progress",
|
|
205
247
|
lead_runtime=_require_string(manifest, "leadRuntime"),
|
|
@@ -207,15 +249,15 @@ def record_progress(
|
|
|
207
249
|
task_type=_require_string(manifest, "taskType"),
|
|
208
250
|
run_seq=_manifest_run_seq(manifest),
|
|
209
251
|
timestamp=timestamp or _utc_now(),
|
|
210
|
-
details={
|
|
211
|
-
"phase": phase,
|
|
212
|
-
"line": line,
|
|
213
|
-
**({name: field_value for name, field_value in ordered}),
|
|
214
|
-
},
|
|
215
|
-
)
|
|
216
|
-
events_path = _resolve_project_path(
|
|
217
|
-
project_root, _require_string(manifest, "leadEventsPath")
|
|
218
252
|
)
|
|
253
|
+
if detail is None:
|
|
254
|
+
detail = _default_detail(phase, events_path, event)
|
|
255
|
+
line = render_progress_line(phase, ordered, detail)
|
|
256
|
+
event = replace(event, details={
|
|
257
|
+
"phase": phase,
|
|
258
|
+
"line": line,
|
|
259
|
+
**({name: field_value for name, field_value in ordered}),
|
|
260
|
+
})
|
|
219
261
|
append_lead_event(events_path, event)
|
|
220
262
|
return event, warning
|
|
221
263
|
|
|
@@ -306,7 +348,10 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
306
348
|
help="extra `NAME=VALUE` token, in the order given")
|
|
307
349
|
append_parser.add_argument(
|
|
308
350
|
"--detail",
|
|
309
|
-
help="verb phrase after the fields; contract wording is the default"
|
|
351
|
+
help="verb phrase after the fields; contract wording is the default "
|
|
352
|
+
"(a second phase-1-intake renders `complete`, and phase-6-synthesis "
|
|
353
|
+
"in a task type without a report writer renders "
|
|
354
|
+
"`authoring final report`)")
|
|
310
355
|
return parser
|
|
311
356
|
|
|
312
357
|
|
|
@@ -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))
|
|
@@ -332,6 +342,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
332
342
|
run.add_argument("--task-group", required=True)
|
|
333
343
|
run.add_argument("--task-id", required=True)
|
|
334
344
|
run.add_argument("--child-task-id", default=None)
|
|
345
|
+
run.add_argument("--allow-completed", action="store_true")
|
|
335
346
|
|
|
336
347
|
split = task_sub.add_parser("split")
|
|
337
348
|
split.add_argument("--manager-id", required=True)
|
|
@@ -466,6 +477,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
466
477
|
args.task_id,
|
|
467
478
|
args.project_id,
|
|
468
479
|
child_task_id=args.child_task_id,
|
|
480
|
+
allow_completed=args.allow_completed,
|
|
469
481
|
),
|
|
470
482
|
args.json,
|
|
471
483
|
)
|
|
@@ -20,7 +20,7 @@ from .manager_paths import (
|
|
|
20
20
|
task_manifest_path,
|
|
21
21
|
)
|
|
22
22
|
from .manager_store import ManagerError, _now_iso, _read_json, _read_json_default, _write_json
|
|
23
|
-
from .manager_sync import
|
|
23
|
+
from .manager_sync import _snapshot_child, summary_bucket
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
WORKER_DISPATCH_BACKEND = "subagent"
|
|
@@ -222,6 +222,25 @@ def _record_launch_prepared(
|
|
|
222
222
|
)
|
|
223
223
|
|
|
224
224
|
|
|
225
|
+
def _current_child_state(project_root: str, child: dict, target_task_key: str, *, allow_completed: bool) -> dict:
|
|
226
|
+
"""대상 하위 task 를 마지막 sync 가 아니라 지금 프로젝트에서 다시 읽는다.
|
|
227
|
+
|
|
228
|
+
스냅샷은 sync 시점 값이라, 그 뒤 프로젝트에서 끝난 task 에 런치 패킷을 만들면 같은 일을 두 번 시킨다.
|
|
229
|
+
"""
|
|
230
|
+
current = _snapshot_child(Path(project_root), {**child, "taskKey": target_task_key})
|
|
231
|
+
if current.get("error"):
|
|
232
|
+
raise ManagerError(f"{target_task_key}: cannot read child task state: {current['error']}")
|
|
233
|
+
if summary_bucket(current) == "done" and not allow_completed:
|
|
234
|
+
report = current.get("latestReportRecordPath") or ""
|
|
235
|
+
raise ManagerError(
|
|
236
|
+
f"{target_task_key}: already done in its project "
|
|
237
|
+
f"(last phase {current.get('lastCompletedPhase') or current.get('currentPhase') or 'unknown'}, "
|
|
238
|
+
f"report {Path(project_root) / report if report else 'none'}); nothing to launch. "
|
|
239
|
+
"Run `okstra manager task sync` to refresh the manager, or pass --allow-completed to prepare a launch anyway"
|
|
240
|
+
)
|
|
241
|
+
return current
|
|
242
|
+
|
|
243
|
+
|
|
225
244
|
def build_launch_packet(
|
|
226
245
|
home: Path,
|
|
227
246
|
manager_id: str,
|
|
@@ -230,6 +249,7 @@ def build_launch_packet(
|
|
|
230
249
|
project_id: str,
|
|
231
250
|
*,
|
|
232
251
|
child_task_id: str | None = None,
|
|
252
|
+
allow_completed: bool = False,
|
|
233
253
|
now: str | None = None,
|
|
234
254
|
) -> dict:
|
|
235
255
|
manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
@@ -244,6 +264,7 @@ def build_launch_packet(
|
|
|
244
264
|
target_task_key = _target_task_key(project_id, task_group, child_task_id_value)
|
|
245
265
|
_validate_child_task_key(child, target_task_key)
|
|
246
266
|
project_root = _validated_project_root(project)
|
|
267
|
+
current = _current_child_state(project_root, child, target_task_key, allow_completed=allow_completed)
|
|
247
268
|
created_at = now or _now_iso()
|
|
248
269
|
brief_path = (Path(str(child["briefPath"])) if child.get("briefPath")
|
|
249
270
|
else _discussion_follow_up_brief(snapshot, target_task_key))
|
|
@@ -277,7 +298,7 @@ def build_launch_packet(
|
|
|
277
298
|
# phase 를 채워야 하는데, 두 값이 다 주어지면 그 채우기를 건너뛴다(autofill_from_manifest).
|
|
278
299
|
# 토론 후속 브리프는 권장 phase 를 따로 적지 않아도 requirements-discovery 에서 시작한다.
|
|
279
300
|
recommended = child.get("recommendedPhase") or (DISCUSSION_FOLLOW_UP_PHASE if discussion_ref else "")
|
|
280
|
-
if recommended and
|
|
301
|
+
if recommended and not current["exists"]:
|
|
281
302
|
run_args.extend(["--task-type", str(recommended)])
|
|
282
303
|
if brief_path is not None:
|
|
283
304
|
run_args.extend(["--task-brief", str(brief_path)])
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Read project-local okstra state into manager-owned snapshots."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from okstra_project import TASKS_RELATIVE, StateError, parse_task_key, read_task_catalog, read_task_manifest, slugify
|
|
8
|
+
from okstra_project.dirs import TASK_MANIFEST_FILENAME
|
|
7
9
|
|
|
8
10
|
from . import next_phase
|
|
9
11
|
from .next_phase import STATUS_BLOCKED, STATUS_TERMINAL
|
|
@@ -168,12 +170,35 @@ def summary_bucket(row: dict) -> str:
|
|
|
168
170
|
return _POINTER_STATUS_BUCKETS.get(pointer_status, "running")
|
|
169
171
|
|
|
170
172
|
|
|
171
|
-
def
|
|
173
|
+
def _epoch(value: str) -> int | None:
|
|
174
|
+
if not value:
|
|
175
|
+
return None
|
|
176
|
+
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _manifest_changed_since(project: dict | None, task_key: str, synced: int | None) -> bool:
|
|
180
|
+
"""프로젝트의 task-manifest 가 마지막 sync 뒤에 바뀌었는지 — 파일 시각만 본다."""
|
|
181
|
+
if project is None or not task_key:
|
|
182
|
+
return False
|
|
183
|
+
root = Path(str(project.get("projectRoot") or ""))
|
|
184
|
+
if not root.is_dir():
|
|
185
|
+
return False
|
|
186
|
+
task_dir = _resolve_task_root_read_only(root, task_key)
|
|
187
|
+
manifest_path = task_dir / TASK_MANIFEST_FILENAME if task_dir is not None else None
|
|
188
|
+
if manifest_path is None or not manifest_path.is_file():
|
|
189
|
+
return False
|
|
190
|
+
# syncedAt 은 초 단위라 mtime 도 초로 내려 같은 초에 쓴 파일을 변경으로 세지 않는다.
|
|
191
|
+
return synced is None or int(manifest_path.stat().st_mtime) > synced
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def status_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now: str | None = None) -> dict:
|
|
172
195
|
manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
173
196
|
children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
174
197
|
snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
175
198
|
by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
|
|
176
199
|
linked = _project_map(home, manager_id)
|
|
200
|
+
synced_at = str(snapshot.get("syncedAt") or "")
|
|
201
|
+
synced = _epoch(synced_at)
|
|
177
202
|
rows = []
|
|
178
203
|
summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0, "discussion": 0}
|
|
179
204
|
for child in children.get("children", []):
|
|
@@ -184,6 +209,17 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
|
|
|
184
209
|
row["summaryBucket"] = "planned" if snapshot_row is None else summary_bucket(row)
|
|
185
210
|
# `remove project` 는 하위 태스크를 남긴다. 등록 목록에 없으면 해제된 프로젝트의 것이다.
|
|
186
211
|
row["projectLinked"] = str(child.get("projectId") or "") in linked
|
|
212
|
+
try:
|
|
213
|
+
row["changedSinceSync"] = _manifest_changed_since(
|
|
214
|
+
linked.get(str(child.get("projectId") or "")), str(child.get("taskKey") or ""), synced)
|
|
215
|
+
except StateError as exc:
|
|
216
|
+
row["changedSinceSync"] = True
|
|
217
|
+
row["error"] = str(exc)
|
|
187
218
|
summary[row["summaryBucket"]] += 1
|
|
188
219
|
rows.append(row)
|
|
189
|
-
|
|
220
|
+
payload = {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot,
|
|
221
|
+
"syncedAt": synced_at,
|
|
222
|
+
"changedSinceSyncCount": sum(1 for row in rows if row["changedSinceSync"])}
|
|
223
|
+
if synced is not None:
|
|
224
|
+
payload["syncAgeSeconds"] = max(0, (_epoch(now or _now_iso()) or 0) - synced)
|
|
225
|
+
return payload
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""`okstra mcp` — declare project MCP servers and check provider reachability."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from okstra_project import resolve_project_root
|
|
10
|
+
from okstra_project.resolver import ResolverError
|
|
11
|
+
|
|
12
|
+
from .json_boundary import JsonBoundaryError
|
|
13
|
+
from .mcp_servers import (
|
|
14
|
+
McpDeclarationError,
|
|
15
|
+
assess,
|
|
16
|
+
brief_source_warnings,
|
|
17
|
+
load_declarations,
|
|
18
|
+
parse_declaration,
|
|
19
|
+
probe_inventories,
|
|
20
|
+
provider_probes,
|
|
21
|
+
reachability_sentence,
|
|
22
|
+
remove_declaration,
|
|
23
|
+
upsert_declaration,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_CLI_EPILOG = r"""Usage:
|
|
27
|
+
okstra mcp list [--json] [--cwd <dir>]
|
|
28
|
+
okstra mcp add --name <name> --description <text> --tool <tool>... [--alias <name>...] [--notes <text>] [--cwd <dir>]
|
|
29
|
+
okstra mcp remove --name <name> [--cwd <dir>]
|
|
30
|
+
okstra mcp check [--provider <id>...] [--brief <path>] [--json] [--cwd <dir>]
|
|
31
|
+
|
|
32
|
+
Declarations live in .okstra/project.json `mcpServers`. `--alias` names the
|
|
33
|
+
server as a provider CLI lists it (for example `figma-desktop`). `check` runs
|
|
34
|
+
each provider adapter's MCP listing command; it never sends an inference call.
|
|
35
|
+
Add a missing server through that provider CLI's own MCP configuration.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _project_root(cwd: str) -> Path:
|
|
40
|
+
return resolve_project_root(cwd=str(Path(cwd).resolve()))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _list(root: Path, as_json: bool) -> int:
|
|
44
|
+
declarations, problems = load_declarations(root)
|
|
45
|
+
if as_json:
|
|
46
|
+
print(json.dumps({
|
|
47
|
+
"servers": [d.to_json() for d in declarations],
|
|
48
|
+
"problems": list(problems),
|
|
49
|
+
}, ensure_ascii=False, indent=2))
|
|
50
|
+
return 1 if problems else 0
|
|
51
|
+
if not declarations:
|
|
52
|
+
print("No MCP servers declared.")
|
|
53
|
+
for declaration in declarations:
|
|
54
|
+
aliases = f" (aliases: {', '.join(declaration.aliases)})" if declaration.aliases else ""
|
|
55
|
+
print(f"{declaration.name}{aliases}: {declaration.description}")
|
|
56
|
+
print(f" tools: {', '.join(declaration.tools)}")
|
|
57
|
+
if declaration.notes:
|
|
58
|
+
print(f" notes: {declaration.notes}")
|
|
59
|
+
for problem in problems:
|
|
60
|
+
print(f"invalid: {problem}")
|
|
61
|
+
return 1 if problems else 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _add(root: Path, args: argparse.Namespace) -> int:
|
|
65
|
+
raw = {
|
|
66
|
+
"name": args.name,
|
|
67
|
+
"description": args.description,
|
|
68
|
+
"tools": args.tool or [],
|
|
69
|
+
}
|
|
70
|
+
if args.alias:
|
|
71
|
+
raw["aliases"] = args.alias
|
|
72
|
+
if args.notes:
|
|
73
|
+
raw["notes"] = args.notes
|
|
74
|
+
declaration, errors = parse_declaration(raw)
|
|
75
|
+
if declaration is None:
|
|
76
|
+
print("error: " + "; ".join(errors))
|
|
77
|
+
return 1
|
|
78
|
+
replaced = upsert_declaration(root, declaration)
|
|
79
|
+
print(f"{'Replaced' if replaced else 'Added'} MCP server `{declaration.name}`.")
|
|
80
|
+
print("Run `okstra mcp check` to see which providers can reach it.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _remove(root: Path, name: str) -> int:
|
|
85
|
+
if not remove_declaration(root, name):
|
|
86
|
+
print(f"error: no MCP server named `{name}` is declared")
|
|
87
|
+
return 1
|
|
88
|
+
print(f"Removed MCP server `{name}`.")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check(root: Path, providers: list[str], brief: str, as_json: bool) -> int:
|
|
93
|
+
from .registry.provider_registry import default_provider_registry
|
|
94
|
+
|
|
95
|
+
declarations, problems = load_declarations(root)
|
|
96
|
+
provider_ids = providers or list(default_provider_registry().ids())
|
|
97
|
+
rows = assess(declarations, probe_inventories(provider_probes(provider_ids), root))
|
|
98
|
+
brief_warnings = (
|
|
99
|
+
brief_source_warnings(Path(brief).read_text(encoding="utf-8"), declarations, rows)
|
|
100
|
+
if brief else ()
|
|
101
|
+
)
|
|
102
|
+
if as_json:
|
|
103
|
+
print(json.dumps({
|
|
104
|
+
"servers": [
|
|
105
|
+
{
|
|
106
|
+
"name": row.server.name,
|
|
107
|
+
"providers": [
|
|
108
|
+
{
|
|
109
|
+
"provider": cell.provider_id,
|
|
110
|
+
"status": cell.status,
|
|
111
|
+
"visibleName": cell.visible_name,
|
|
112
|
+
"note": cell.note,
|
|
113
|
+
}
|
|
114
|
+
for cell in row.cells
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
for row in rows
|
|
118
|
+
],
|
|
119
|
+
"problems": list(problems),
|
|
120
|
+
"briefWarnings": list(brief_warnings),
|
|
121
|
+
}, ensure_ascii=False, indent=2))
|
|
122
|
+
return 0
|
|
123
|
+
if not declarations:
|
|
124
|
+
print("No MCP servers declared. Register one with `okstra mcp add`.")
|
|
125
|
+
for row in rows:
|
|
126
|
+
print(f"{row.server.name}: {reachability_sentence(row)}")
|
|
127
|
+
for cell in row.cells:
|
|
128
|
+
if cell.note and cell.status != "missing":
|
|
129
|
+
print(f" {cell.provider_id}: {cell.note}")
|
|
130
|
+
for problem in problems:
|
|
131
|
+
print(f"invalid: {problem}")
|
|
132
|
+
for warning in brief_warnings:
|
|
133
|
+
print(warning)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
parser = argparse.ArgumentParser(
|
|
139
|
+
description="Declare project MCP servers and check provider reachability.",
|
|
140
|
+
epilog=_CLI_EPILOG,
|
|
141
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
142
|
+
prog="okstra mcp",
|
|
143
|
+
)
|
|
144
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
145
|
+
listed = sub.add_parser("list")
|
|
146
|
+
listed.add_argument("--json", action="store_true")
|
|
147
|
+
listed.add_argument("--cwd", default=".")
|
|
148
|
+
add = sub.add_parser("add")
|
|
149
|
+
add.add_argument("--name", required=True)
|
|
150
|
+
add.add_argument("--description", required=True)
|
|
151
|
+
add.add_argument("--tool", action="append")
|
|
152
|
+
add.add_argument("--alias", action="append")
|
|
153
|
+
add.add_argument("--notes", default="")
|
|
154
|
+
add.add_argument("--cwd", default=".")
|
|
155
|
+
remove = sub.add_parser("remove")
|
|
156
|
+
remove.add_argument("--name", required=True)
|
|
157
|
+
remove.add_argument("--cwd", default=".")
|
|
158
|
+
check = sub.add_parser("check")
|
|
159
|
+
check.add_argument("--provider", action="append", default=[])
|
|
160
|
+
check.add_argument("--brief", default="")
|
|
161
|
+
check.add_argument("--json", action="store_true")
|
|
162
|
+
check.add_argument("--cwd", default=".")
|
|
163
|
+
args = parser.parse_args(argv)
|
|
164
|
+
try:
|
|
165
|
+
root = _project_root(args.cwd)
|
|
166
|
+
if args.command == "list":
|
|
167
|
+
return _list(root, args.json)
|
|
168
|
+
if args.command == "add":
|
|
169
|
+
return _add(root, args)
|
|
170
|
+
if args.command == "remove":
|
|
171
|
+
return _remove(root, args.name)
|
|
172
|
+
return _check(root, args.provider, args.brief, args.json)
|
|
173
|
+
except (McpDeclarationError, JsonBoundaryError, ResolverError, OSError, ValueError) as exc:
|
|
174
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
raise SystemExit(main())
|