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.
Files changed (67) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/lib/paths.mjs +1 -1
  4. package/dist/lib/paths.mjs.map +1 -1
  5. package/docs/architecture.md +2 -0
  6. package/docs/cli.md +7 -3
  7. package/docs/project-structure-overview.md +1 -1
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/prompts/launch.template.md +1 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
  12. package/runtime/prompts/lead/team-contract.md +1 -1
  13. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
  14. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  15. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
  16. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
  17. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
  18. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
  19. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
  20. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
  21. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
  22. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
  23. package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
  24. package/runtime/python/okstra_ctl/business_flow/engine.py +10 -2
  25. package/runtime/python/okstra_ctl/correction_cli.py +44 -5
  26. package/runtime/python/okstra_ctl/correction_round.py +40 -11
  27. package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
  28. package/runtime/python/okstra_ctl/domain/provider.py +2 -0
  29. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
  30. package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
  31. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
  32. package/runtime/python/okstra_ctl/lead_progress.py +56 -11
  33. package/runtime/python/okstra_ctl/manager_cli.py +13 -1
  34. package/runtime/python/okstra_ctl/manager_launch.py +23 -2
  35. package/runtime/python/okstra_ctl/manager_sync.py +38 -2
  36. package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
  37. package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
  38. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +4 -3
  39. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  40. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
  41. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
  42. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  43. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
  44. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
  45. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  46. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +2 -2
  47. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +2 -2
  48. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  49. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
  50. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
  51. package/runtime/python/okstra_ctl/render.py +7 -48
  52. package/runtime/python/okstra_ctl/report_assembly.py +5 -5
  53. package/runtime/python/okstra_ctl/report_inputs.py +10 -1
  54. package/runtime/python/okstra_ctl/report_language.py +4 -5
  55. package/runtime/python/okstra_ctl/run.py +29 -0
  56. package/runtime/python/okstra_ctl/stage_done.py +37 -3
  57. package/runtime/python/okstra_ctl/wizard/engine.py +1 -2
  58. package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
  59. package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
  60. package/runtime/skills/okstra-discussion/SKILL.md +3 -3
  61. package/runtime/skills/okstra-manager/SKILL.md +3 -2
  62. package/runtime/skills/okstra-run/SKILL.md +3 -3
  63. package/runtime/skills/okstra-setup/SKILL.md +2 -0
  64. package/runtime/skills/okstra-setup/references/project-config.md +34 -0
  65. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  66. package/runtime/templates/reports/task-brief.template.md +3 -3
  67. 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
- resolve_report_language(
712
- context.project_root,
713
- context.manifest,
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
- line = render_progress_line(phase, ordered, detail)
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"), ("error", "error")):
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 _resolve_task_root_read_only
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 _resolve_task_root_read_only(Path(project_root), target_task_key) is None:
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 status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
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
- return {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot}
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())