okstra 0.202.0 → 0.205.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 (263) hide show
  1. package/README.md +7 -6
  2. package/dist/cli-registry.mjs +7 -7
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +50 -124
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/dist/commands/memory/memory.mjs +41 -8
  7. package/dist/commands/memory/memory.mjs.map +1 -1
  8. package/dist/lib/install-assets.mjs +3 -0
  9. package/dist/lib/install-assets.mjs.map +1 -1
  10. package/dist/lib/runtime-manifest.mjs +2 -1
  11. package/dist/lib/runtime-manifest.mjs.map +1 -1
  12. package/dist/lib/types.d.mts +2 -1
  13. package/docs/architecture/storage-model.md +14 -11
  14. package/docs/architecture.md +26 -20
  15. package/docs/cli.md +15 -12
  16. package/docs/contributor-change-matrix.md +3 -2
  17. package/docs/performance-improvement-plan-v2.md +3 -9
  18. package/docs/project-structure-overview.md +39 -11
  19. package/docs/task-process/README.md +1 -1
  20. package/docs/task-process/common-flow.md +1 -1
  21. package/docs/task-process/final-verification.md +3 -1
  22. package/docs/task-process/implementation-option-selection.md +1 -1
  23. package/docs/task-process/implementation.md +1 -1
  24. package/docs/task-process/release-handoff.md +36 -39
  25. package/package.json +1 -2
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/agents/common.json +28 -0
  28. package/runtime/agents/operations/code-review.json +6 -0
  29. package/runtime/agents/operations/report-translation.json +6 -0
  30. package/runtime/agents/operations/schedule-verification.json +6 -0
  31. package/runtime/agents/roles/analyser.json +18 -0
  32. package/runtime/agents/roles/critic.json +18 -0
  33. package/runtime/agents/roles/designer.json +18 -0
  34. package/runtime/agents/roles/implementer.json +20 -0
  35. package/runtime/agents/roles/leader.json +20 -0
  36. package/runtime/agents/roles/planner.json +18 -0
  37. package/runtime/agents/roles/report-writer.json +19 -0
  38. package/runtime/agents/roles/translator.json +19 -0
  39. package/runtime/agents/roles/verifier.json +18 -0
  40. package/runtime/bin/lib/okstra/usage.sh +5 -5
  41. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  42. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  43. package/runtime/prompts/duties/analysis-worker.json +32 -0
  44. package/runtime/prompts/duties/code-reviewer.json +32 -0
  45. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  46. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  47. package/runtime/prompts/duties/discovery-worker.json +32 -0
  48. package/runtime/prompts/duties/implementation-executor.json +32 -0
  49. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  50. package/runtime/prompts/duties/lead.json +32 -0
  51. package/runtime/prompts/duties/planning-worker.json +36 -0
  52. package/runtime/prompts/duties/report-writer.json +32 -0
  53. package/runtime/prompts/duties/reverification-worker.json +32 -0
  54. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  55. package/runtime/prompts/duties/scope-critic.json +32 -0
  56. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  57. package/runtime/prompts/duties/translator.json +32 -0
  58. package/runtime/prompts/launch.template.md +2 -1
  59. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  60. package/runtime/prompts/lead/convergence.md +4 -4
  61. package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
  62. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  63. package/runtime/prompts/lead/report-writer.md +3 -3
  64. package/runtime/prompts/profiles/_common-contract.md +2 -2
  65. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  66. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  67. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  68. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  69. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  70. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  71. package/runtime/prompts/profiles/error-analysis.json +39 -0
  72. package/runtime/prompts/profiles/error-analysis.md +0 -25
  73. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  74. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  75. package/runtime/prompts/profiles/final-verification.json +30 -0
  76. package/runtime/prompts/profiles/final-verification.md +4 -23
  77. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  78. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  79. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  80. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  81. package/runtime/prompts/profiles/implementation-planning.md +4 -29
  82. package/runtime/prompts/profiles/implementation.json +30 -0
  83. package/runtime/prompts/profiles/implementation.md +1 -20
  84. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  85. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  86. package/runtime/prompts/profiles/project-analysis.json +31 -0
  87. package/runtime/prompts/profiles/project-analysis.md +0 -20
  88. package/runtime/prompts/profiles/release-handoff.json +5 -0
  89. package/runtime/prompts/profiles/release-handoff.md +74 -74
  90. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  91. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  92. package/runtime/prompts/profiles/technical-verification.json +39 -0
  93. package/runtime/prompts/profiles/technical-verification.md +0 -25
  94. package/runtime/prompts/wizard/prompts.ko.json +14 -18
  95. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  96. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  97. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  100. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  101. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  102. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  103. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  104. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  105. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  107. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  108. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  109. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  110. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  111. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  112. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  113. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  114. package/runtime/python/okstra_ctl/cmux.py +69 -20
  115. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  116. package/runtime/python/okstra_ctl/conformance.py +43 -0
  117. package/runtime/python/okstra_ctl/consumers.py +23 -8
  118. package/runtime/python/okstra_ctl/container.py +31 -8
  119. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  120. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  121. package/runtime/python/okstra_ctl/convergence_engine.py +38 -0
  122. package/runtime/python/okstra_ctl/convergence_provenance.py +7 -1
  123. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  124. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  125. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  126. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  127. package/runtime/python/okstra_ctl/error_report.py +4 -3
  128. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  129. package/runtime/python/okstra_ctl/handoff.py +384 -286
  130. package/runtime/python/okstra_ctl/handoff_verification.py +25 -6
  131. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  132. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  133. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  134. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  135. package/runtime/python/okstra_ctl/manager_cli.py +92 -4
  136. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  137. package/runtime/python/okstra_ctl/manager_paths.py +14 -3
  138. package/runtime/python/okstra_ctl/manager_store.py +210 -3
  139. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  140. package/runtime/python/okstra_ctl/manager_view.py +2 -1
  141. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  142. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  143. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  144. package/runtime/python/okstra_ctl/models.py +1 -1
  145. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  146. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  147. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  148. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  149. package/runtime/python/okstra_ctl/paths.py +3 -0
  150. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  151. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  152. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  153. package/runtime/python/okstra_ctl/render.py +79 -0
  154. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  155. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  156. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  157. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  158. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  159. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  160. package/runtime/python/okstra_ctl/rollup.py +3 -1
  161. package/runtime/python/okstra_ctl/run.py +76 -29
  162. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  163. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  164. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  165. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  166. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  167. package/runtime/python/okstra_ctl/team.py +22 -13
  168. package/runtime/python/okstra_ctl/time_report.py +2 -1
  169. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  170. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  171. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  172. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  173. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  174. package/runtime/python/okstra_ctl/wizard/steps_plan.py +12 -21
  175. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  176. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  177. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  178. package/runtime/python/okstra_ctl/workflow.py +1 -1
  179. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  180. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  181. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  182. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  183. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  184. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  185. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  186. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  187. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  188. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  189. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  190. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  191. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  192. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  193. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  194. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  195. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  196. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  197. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  198. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  199. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  200. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  201. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  202. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  203. package/runtime/skills/okstra-manager/SKILL.md +18 -2
  204. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  205. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  206. package/runtime/skills/okstra-run/SKILL.md +31 -12
  207. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  208. package/runtime/skills/okstra-setup/SKILL.md +12 -10
  209. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  210. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  211. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  212. package/runtime/templates/manager/view.template.html +1 -0
  213. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  214. package/runtime/templates/reports/brief.template.md +14 -4
  215. package/runtime/templates/reports/html/i18n/en.json +5 -4
  216. package/runtime/templates/reports/html/i18n/ko.json +5 -4
  217. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  218. package/runtime/templates/reports/i18n/en.json +1 -1
  219. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  220. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  221. package/runtime/templates/translator-prompt-preamble.md +36 -0
  222. package/runtime/validators/checks/validate-assets-01.py +7 -8
  223. package/runtime/validators/validate-brief.py +70 -0
  224. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  225. package/runtime/validators/validate-run.py +72 -15
  226. package/runtime/validators/validate-schedule.py +9 -0
  227. package/docs/for-ai/README.md +0 -68
  228. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  229. package/docs/for-ai/skills/okstra-chat.md +0 -34
  230. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  231. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  232. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  233. package/docs/for-ai/skills/okstra-manager.md +0 -86
  234. package/docs/for-ai/skills/okstra-memory.md +0 -126
  235. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  236. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  237. package/docs/for-ai/skills/okstra-run.md +0 -250
  238. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  239. package/docs/for-ai/skills/okstra-setup.md +0 -167
  240. package/docs/for-ai/skills/okstra-usage.md +0 -29
  241. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  242. package/runtime/agents/workers/claude-worker.md +0 -128
  243. package/runtime/agents/workers/report-writer-worker.md +0 -37
  244. package/runtime/agents/workers/translator-worker.md +0 -63
  245. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  246. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  247. package/runtime/prompts/duties/analysis-worker.md +0 -44
  248. package/runtime/prompts/duties/code-reviewer.md +0 -44
  249. package/runtime/prompts/duties/common.md +0 -39
  250. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  251. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  252. package/runtime/prompts/duties/discovery-worker.md +0 -44
  253. package/runtime/prompts/duties/implementation-executor.md +0 -44
  254. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  255. package/runtime/prompts/duties/lead.md +0 -44
  256. package/runtime/prompts/duties/planning-worker.md +0 -52
  257. package/runtime/prompts/duties/report-writer.md +0 -44
  258. package/runtime/prompts/duties/reverification-worker.md +0 -44
  259. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  260. package/runtime/prompts/duties/scope-critic.md +0 -44
  261. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  262. package/runtime/prompts/duties/translator.md +0 -44
  263. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import shutil
6
7
  from datetime import datetime, timezone
7
8
  from pathlib import Path
8
9
 
@@ -18,12 +19,17 @@ from okstra_ctl.json_boundary import (
18
19
  from .manager_paths import (
19
20
  ManagerPathError,
20
21
  canonical_manager_id,
22
+ child_context_path,
21
23
  children_json_path,
22
24
  directives_jsonl_path,
23
25
  events_jsonl_path,
24
26
  manager_json_path,
25
27
  manager_root,
26
28
  projects_json_path,
29
+ snapshots_json_path,
30
+ task_group_meta_path,
31
+ task_group_root,
32
+ task_groups_root,
27
33
  task_manifest_path,
28
34
  )
29
35
 
@@ -224,11 +230,20 @@ def link_project(
224
230
  return payload
225
231
 
226
232
 
233
+ def _ensure_group_meta(home: Path, manager_id: str, task_group: str, when: str) -> dict:
234
+ """그룹 폴더와 `group.json` 을 만든다. 이미 있으면 처음 기록을 그대로 둔다."""
235
+ path = task_group_meta_path(home, manager_id, task_group)
236
+ if path.is_file():
237
+ return _read_json(path)
238
+ meta = {"taskGroup": task_group, "createdAt": when}
239
+ _write_json(path, meta)
240
+ return meta
241
+
242
+
227
243
  def create_task_group(home: Path, manager_id: str, task_group: str, *, now: str | None = None) -> dict:
228
244
  load_manager(home, manager_id)
229
- group_dir = task_manifest_path(home, manager_id, task_group, "group-marker").parents[1]
230
- group_dir.mkdir(parents=True, exist_ok=True)
231
- return {"managerId": manager_id, "taskGroup": task_group, "createdAt": now or _now_iso()}
245
+ meta = _ensure_group_meta(home, manager_id, task_group, now or _now_iso())
246
+ return {"managerId": manager_id, **meta}
232
247
 
233
248
 
234
249
  def _merge_manifest(existing: dict, requested: dict) -> None:
@@ -313,6 +328,7 @@ def create_task(
313
328
  )
314
329
  children["children"].extend(added)
315
330
  if not existing:
331
+ _ensure_group_meta(home, manager_id, task_group, when)
316
332
  _write_json(manifest_path, manifest)
317
333
  _write_json(children_json_path(home, manager_id, task_group, task_id), children)
318
334
  append_jsonl(
@@ -423,3 +439,194 @@ def append_directive(
423
439
  }
424
440
  append_jsonl(directives_jsonl_path(home, manager_id, task_group, task_id), row)
425
441
  return row
442
+
443
+
444
+ def list_task_groups(home: Path, manager_id: str) -> list[dict]:
445
+ """태스크가 없는 그룹까지 포함해 그룹을 이름 순으로 돌려준다.
446
+
447
+ `group.json` 이 없는 기존 그룹은 소속 태스크 manifest 의 그룹 이름을, 태스크도 없으면
448
+ 폴더 이름을 쓴다.
449
+ """
450
+ load_manager(home, manager_id)
451
+ base = task_groups_root(home, manager_id)
452
+ if not base.is_dir():
453
+ return []
454
+ items: list[dict] = []
455
+ for group_dir in sorted(path for path in base.iterdir() if path.is_dir()):
456
+ manifests = sorted(group_dir.glob("*/manifest.json"))
457
+ meta_path = group_dir / "group.json"
458
+ meta = _read_json(meta_path) if meta_path.is_file() else {}
459
+ name = str(meta.get("taskGroup") or "")
460
+ if not name and manifests:
461
+ name = str(_read_json(manifests[0]).get("taskGroup") or "")
462
+ items.append(
463
+ {
464
+ "taskGroup": name or group_dir.name,
465
+ "createdAt": str(meta.get("createdAt") or ""),
466
+ "taskCount": len(manifests),
467
+ }
468
+ )
469
+ return sorted(items, key=lambda item: item["taskGroup"])
470
+
471
+
472
+ def _children_of_project(home: Path, manager_id: str, project_id: str) -> list[tuple[dict, list[str]]]:
473
+ """(task manifest, 그 task 에서 project_id 를 가리키는 하위 태스크 키) 목록."""
474
+ rows = []
475
+ for path in _task_manifest_paths(home, manager_id):
476
+ children = _read_json_default(path.parent / "children.json", {"children": []})
477
+ keys = [
478
+ str(child.get("taskKey"))
479
+ for child in children.get("children", [])
480
+ if isinstance(child, dict) and child.get("projectId") == project_id
481
+ ]
482
+ if keys:
483
+ rows.append((_read_json(path), keys))
484
+ return rows
485
+
486
+
487
+ def unlink_project(
488
+ home: Path, manager_id: str, project_id: str, *, confirm: bool, now: str | None = None
489
+ ) -> dict:
490
+ """프로젝트 등록을 해제한다. 하위 태스크는 남기고, 등록 목록에 없다는 사실로 해제 상태를 표시한다.
491
+
492
+ `confirm` 이 거짓이면 대상만 돌려주고 아무것도 바꾸지 않는다.
493
+ """
494
+ load_manager(home, manager_id)
495
+ payload = _load_projects(home, manager_id)
496
+ entry = _project_entry(payload, project_id)
497
+ if entry is None:
498
+ raise ManagerError(f"unknown project membership: {project_id}")
499
+ affected = _children_of_project(home, manager_id, project_id)
500
+ result = {
501
+ "managerId": manager_id,
502
+ "projectId": project_id,
503
+ "projectRoot": str(entry.get("projectRoot") or ""),
504
+ "keptChildren": [key for _, keys in affected for key in keys],
505
+ "removed": confirm,
506
+ }
507
+ if not confirm:
508
+ return result
509
+ payload["projects"] = [item for item in payload["projects"] if item is not entry]
510
+ _write_json(projects_json_path(home, manager_id), payload)
511
+ when = now or _now_iso()
512
+ for manifest, keys in affected:
513
+ append_jsonl(
514
+ events_jsonl_path(home, manager_id, str(manifest["taskGroup"]), str(manifest["taskId"])),
515
+ {"event": "project-unlinked", "createdAt": when, "projectId": project_id, "taskKeys": keys},
516
+ )
517
+ return result
518
+
519
+
520
+ def remove_child(
521
+ home: Path,
522
+ manager_id: str,
523
+ task_group: str,
524
+ task_id: str,
525
+ project_id: str,
526
+ *,
527
+ child_task_id: str | None = None,
528
+ confirm: bool,
529
+ now: str | None = None,
530
+ ) -> dict:
531
+ """하위 태스크 하나를 manager 에서 뺀다. 프로젝트의 `.okstra/` 는 건드리지 않는다."""
532
+ _read_json(task_manifest_path(home, manager_id, task_group, task_id))
533
+ payload = _load_children(home, manager_id, task_group, task_id)
534
+ child = _select_child_for_assignment(payload["children"], project_id, task_group, child_task_id)
535
+ task_key = str(child.get("taskKey") or "")
536
+ result = {"managerId": manager_id, "taskGroup": task_group, "taskId": task_id,
537
+ "taskKey": task_key, "removed": confirm}
538
+ if not confirm:
539
+ return result
540
+ payload["children"] = [item for item in payload["children"] if item is not child]
541
+ _write_json(children_json_path(home, manager_id, task_group, task_id), payload)
542
+ # 남은 형제의 context 에 지운 하위 태스크가 형제로 실리지 않게 스냅샷 행도 뺀다.
543
+ snapshot_path = snapshots_json_path(home, manager_id, task_group, task_id)
544
+ if snapshot_path.is_file():
545
+ snapshot = _read_json(snapshot_path)
546
+ snapshot["children"] = [
547
+ row for row in snapshot.get("children", []) if not isinstance(row, dict) or row.get("taskKey") != task_key
548
+ ]
549
+ _write_json(snapshot_path, snapshot)
550
+ child_context_path(
551
+ home, manager_id, task_group, task_id, project_id, str(child.get("taskId") or task_id)
552
+ ).unlink(missing_ok=True)
553
+ append_jsonl(
554
+ events_jsonl_path(home, manager_id, task_group, task_id),
555
+ {"event": "task-child-removed", "createdAt": now or _now_iso(), "taskKey": task_key},
556
+ )
557
+ return result
558
+
559
+
560
+ def update_task(
561
+ home: Path,
562
+ manager_id: str,
563
+ task_group: str,
564
+ task_id: str,
565
+ *,
566
+ objective: str | None = None,
567
+ common_brief_path: str | None = None,
568
+ progress_mode: str | None = None,
569
+ now: str | None = None,
570
+ ) -> dict:
571
+ """manager task 의 objective·공통 브리프·진행 방식 중 넘긴 값만 바꾼다."""
572
+ manifest_path = task_manifest_path(home, manager_id, task_group, task_id)
573
+ manifest = _read_json(manifest_path)
574
+ requested = {"objective": objective, "commonBriefPath": common_brief_path, "progressMode": progress_mode}
575
+ requested = {key: value for key, value in requested.items() if value is not None}
576
+ if not requested:
577
+ raise ManagerError("task update needs at least one of --objective, --common-brief, --progress-mode")
578
+ if requested.get("progressMode") not in (None, "manual", "auto"):
579
+ raise ManagerError("progress-mode must be manual or auto")
580
+ changes = {
581
+ key: {"from": manifest.get(key), "to": value}
582
+ for key, value in requested.items()
583
+ if manifest.get(key) != value
584
+ }
585
+ for key, change in changes.items():
586
+ manifest[key] = change["to"]
587
+ if changes:
588
+ _write_json(manifest_path, manifest)
589
+ append_jsonl(
590
+ events_jsonl_path(home, manager_id, task_group, task_id),
591
+ {"event": "task-updated", "createdAt": now or _now_iso(), "changes": changes},
592
+ )
593
+ return {"manifest": manifest, "changedFields": sorted(changes)}
594
+
595
+
596
+ def remove_task(
597
+ home: Path, manager_id: str, task_group: str, task_id: str, *, confirm: bool
598
+ ) -> dict:
599
+ """manager task 폴더를 지운다. 하위 프로젝트의 태스크와 브리프는 남는다."""
600
+ manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
601
+ root = task_manifest_path(home, manager_id, task_group, task_id).parent
602
+ children = _load_children(home, manager_id, task_group, task_id)["children"]
603
+ result = {
604
+ "managerId": manager_id,
605
+ "taskGroup": str(manifest.get("taskGroup") or task_group),
606
+ "taskId": str(manifest.get("taskId") or task_id),
607
+ "path": str(root),
608
+ "childCount": len(children),
609
+ "removed": confirm,
610
+ }
611
+ if confirm:
612
+ shutil.rmtree(root)
613
+ return result
614
+
615
+
616
+ def remove_task_group(home: Path, manager_id: str, task_group: str, *, confirm: bool) -> dict:
617
+ """그룹 폴더와 그 안의 manager task 전부를 지운다."""
618
+ load_manager(home, manager_id)
619
+ root = task_group_root(home, manager_id, task_group)
620
+ if not root.is_dir():
621
+ raise ManagerError(f"unknown task group: {task_group}")
622
+ task_ids = [str(_read_json(path).get("taskId") or path.parent.name) for path in sorted(root.glob("*/manifest.json"))]
623
+ result = {
624
+ "managerId": manager_id,
625
+ "taskGroup": task_group,
626
+ "path": str(root),
627
+ "taskIds": task_ids,
628
+ "removed": confirm,
629
+ }
630
+ if confirm:
631
+ shutil.rmtree(root)
632
+ return result
@@ -109,7 +109,7 @@ def sync_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now
109
109
  continue
110
110
  project = projects.get(str(child.get("projectId") or ""))
111
111
  if project is None:
112
- rows.append({**child, "exists": False, "error": "unknown project membership"})
112
+ rows.append({**child, "exists": False, "error": "project unlinked from this manager; re-register it with `okstra manager new project`"})
113
113
  continue
114
114
  root = Path(str(project.get("projectRoot") or ""))
115
115
  if not root.is_dir():
@@ -160,6 +160,7 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
160
160
  children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
161
161
  snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
162
162
  by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
163
+ linked = _project_map(home, manager_id)
163
164
  rows = []
164
165
  summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0}
165
166
  for child in children.get("children", []):
@@ -168,6 +169,8 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
168
169
  snapshot_row = by_key.get(child.get("taskKey"))
169
170
  row = {**child, **(snapshot_row or {})}
170
171
  row["summaryBucket"] = "planned" if snapshot_row is None else summary_bucket(row)
172
+ # `remove project` 는 하위 태스크를 남긴다. 등록 목록에 없으면 해제된 프로젝트의 것이다.
173
+ row["projectLinked"] = str(child.get("projectId") or "") in linked
171
174
  summary[row["summaryBucket"]] += 1
172
175
  rows.append(row)
173
176
  return {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot}
@@ -95,7 +95,8 @@ def _child_rows(children: list[dict], roots: dict[str, str]) -> list[list[str]]:
95
95
  assignment = assignment or "; ".join(child.get("scope") or [])
96
96
  rows.append(
97
97
  [
98
- f"<code>{_e(child.get('taskKey'))}</code>",
98
+ f"<code>{_e(child.get('taskKey'))}</code>"
99
+ + ("" if child.get("projectLinked", True) else " " + _chip("unlinked")),
99
100
  _e(child.get("ticketId") or "-"),
100
101
  _chip(str(child.get("summaryBucket") or "planned")),
101
102
  _e(child.get("workStatus") or "-"),
@@ -73,6 +73,25 @@ def _codex_home() -> Path:
73
73
  return Path.home() / ".codex"
74
74
 
75
75
 
76
+ @lru_cache(maxsize=1)
77
+ def codex_cli_version(codex_bin: str = "codex") -> str:
78
+ """디스패치에 쓸 codex CLI 의 버전, 알 수 없으면 "".
79
+
80
+ `codex --version` 은 `codex-cli 0.155.1` 처럼 마지막 마디에 버전을 싣는다.
81
+ """
82
+ try:
83
+ proc = subprocess.run(
84
+ [codex_bin, "--version"],
85
+ capture_output=True, text=True, timeout=20,
86
+ )
87
+ except (FileNotFoundError, OSError, subprocess.SubprocessError):
88
+ return ""
89
+ if proc.returncode != 0:
90
+ return ""
91
+ parts = proc.stdout.split()
92
+ return parts[-1].strip() if parts else ""
93
+
94
+
76
95
  def codex_availability(codex_home: Path | None = None) -> tuple[str, ...]:
77
96
  """Model slugs the codex CLI last fetched from the provider, or ().
78
97
 
@@ -100,6 +119,17 @@ def codex_availability(codex_home: Path | None = None) -> tuple[str, ...]:
100
119
  models = payload.get("models") if isinstance(payload, dict) else None
101
120
  if not isinstance(models, list):
102
121
  return ()
122
+ # 이 캐시는 codex 홈 하나를 쓰는 모든 클라이언트가 공유한다 — CLI 뿐 아니라
123
+ # ChatGPT 데스크톱 앱과 VS Code 확장도 자기 번들 버전으로 덮어쓴다. 그런데
124
+ # 카탈로그는 클라이언트 버전마다 다르다: 실측 2026-09-23, 같은 계정에서
125
+ # 0.155.1 은 gpt-6-sol·gpt-6-luna 를 내놓고 0.154.0·0.153.4 는 내놓지 않으며,
126
+ # 세 버전이 10분 사이 번갈아 이 파일을 썼다. 다른 클라이언트가 쓴 목록은
127
+ # okstra 가 실행할 CLI 의 카탈로그에 대한 근거가 아니므로 판정을 끈다 —
128
+ # 탐색 실패와 같은 처리이고, 정말 없는 모델이면 CLI 자신이 거절한다.
129
+ writer = payload.get("client_version")
130
+ running = codex_cli_version()
131
+ if running and isinstance(writer, str) and writer.strip() and writer.strip() != running:
132
+ return ()
103
133
  out: list[str] = []
104
134
  for entry in models:
105
135
  slug = entry.get("slug") if isinstance(entry, dict) else None
@@ -10,6 +10,8 @@ from __future__ import annotations
10
10
  from pathlib import Path
11
11
  from typing import Any, Mapping
12
12
 
13
+ from okstra_project.phase_pointer import promote
14
+
13
15
  from ..fixed_text import line as _line, scalar as _value
14
16
  from ..json_boundary import JsonBoundaryError
15
17
 
@@ -19,6 +21,17 @@ def _mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]:
19
21
  return value if isinstance(value, Mapping) else {}
20
22
 
21
23
 
24
+ def next_phase_pointer(payload: Mapping[str, Any]) -> Mapping[str, Any]:
25
+ """`nextRecommendedPhase` 를 포인터로 읽는다. 구형 문자열도 승격한다.
26
+
27
+ `_mapping` 으로 읽으면 구형 문자열 포인터가 빈 매핑이 되어 `Next phase: -`
28
+ 로 사라진다(ADR-0004 의 승격 예외가 이 읽기 경로에는 없었다). 값이 아예
29
+ 없으면 빈 매핑 그대로 둔다 — 없는 포인터를 `pending` 으로 지어내지 않는다.
30
+ """
31
+ value = payload.get("nextRecommendedPhase")
32
+ return {} if value is None else promote(value)
33
+
34
+
22
35
  def _project_policy_lines(project: Mapping[str, Any]) -> str:
23
36
  packs = project.get("reviewRulePacks")
24
37
  pack_lines = (
@@ -84,7 +97,7 @@ def _worker_prompt_lines(run: Mapping[str, Any]) -> str:
84
97
 
85
98
 
86
99
  def _next_phase_lines(workflow: Mapping[str, Any]) -> str:
87
- pointer = _mapping(workflow, "nextRecommendedPhase")
100
+ pointer = next_phase_pointer(workflow)
88
101
  return (
89
102
  _line("Next phase", pointer.get("phase"))
90
103
  + _line("Next phase status", pointer.get("status"))
@@ -29,6 +29,7 @@ from .lines import (
29
29
  _mapping,
30
30
  _next_phase_lines,
31
31
  _project_policy_lines,
32
+ next_phase_pointer,
32
33
  _provider_models,
33
34
  _status_overview_text,
34
35
  _worker_assignment_lines,
@@ -84,7 +85,7 @@ def _overview_rows(
84
85
  latest_run = _latest_timeline_run(project_root, manifest_path)
85
86
  _, group, _ = parse_task_key(task_key)
86
87
  workflow = _mapping(manifest, "workflow")
87
- next_phase = _mapping(workflow, "nextRecommendedPhase")
88
+ next_phase = next_phase_pointer(workflow)
88
89
  # task 단위 현재 사실은 task-manifest 가 권위다(validate-run 이 run 종료
89
90
  # 시 거기에 쓴다). timeline 의 마지막 run 은 task-manifest 에 그 필드가
90
91
  # 없을 때의 폴백이고, 그 값도 run-manifest 로 덮어쓴 현재 사실이다.
@@ -372,7 +373,7 @@ def render_recap_input(project_root: Path, task_ref: str) -> str:
372
373
  for transition in transitions if isinstance(transitions, list) else []:
373
374
  if not isinstance(transition, Mapping):
374
375
  continue
375
- pointer = _mapping(transition, "nextRecommendedPhase")
376
+ pointer = next_phase_pointer(transition)
376
377
  blocks.extend((
377
378
  "\n## Transition\n\n",
378
379
  _line("Run timestamp", transition.get("runTimestamp")),
@@ -420,7 +421,7 @@ def render_group_recap_input(project_root: Path, task_group: str) -> str:
420
421
  for task in tasks if isinstance(tasks, list) else []:
421
422
  if not isinstance(task, Mapping):
422
423
  continue
423
- pointer = _mapping(task, "nextRecommendedPhase")
424
+ pointer = next_phase_pointer(task)
424
425
  memory = _mapping(task, "memory")
425
426
  blocks.extend((
426
427
  "\n## Task\n\n",
@@ -35,7 +35,7 @@ IMPLEMENTATION_ROLES = frozenset({"executor", "verifier"})
35
35
  # Legacy role-keyed compatibility defaults. Canonical assignments resolve a
36
36
  # provider first and then use ProviderSpec.default_models for that role.
37
37
  ROLE_DEFAULTS = {
38
- "lead": "opus", "claude": "opus", "codex": "gpt-5.6-sol",
38
+ "lead": "opus", "claude": "opus", "codex": "gpt-6-sol",
39
39
  "antigravity": "gemini-3.1-pro", "report-writer": "sonnet",
40
40
  }
41
41
 
@@ -235,8 +235,9 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
235
235
  # release-handoff 는 `routingRecommendation`.
236
236
  return make(status=STATUS_BLOCKED, rationale=_selection_guidance(selection))
237
237
  if routing == "pending-direction-selection":
238
- # 이 상태는 **성공**이다 — 비교가 끝났고, 방향은 계획 단계의 위저드가
239
- # 고르게 한다(`selected_direction_pick`). 그러니 다음 phase 는 지금 바로
238
+ # 이 상태는 **성공**이다 — 비교가 끝났고, 방향은 HTML 열람본의
239
+ # `DIRECTION SELECTION` Export 가 고르며 계획 위저드의
240
+ # `selected_direction_pick` 은 그 답이 딸린 리포트를 고른다. 그러니 다음 phase 는 지금 바로
240
241
  # 시작할 수 있는 `implementation-planning` 이고 status 는 `ready` 다.
241
242
  # 종전에는 phase 없는 `pending` 이었다: task 선택 화면이 `next: --
242
243
  # (pending)` 을 찍어 목적지를 지웠고, task-type 화면은 추천 없이 방금
@@ -276,11 +277,20 @@ def _direction_selection_reason(selection: Mapping[str, Any]) -> str:
276
277
  recommended = _text(selection.get("recommendedOptionId"))
277
278
  head = f"비교가 끝났습니다. 후보 {listed} 중 하나를 방향으로 고르세요"
278
279
  head += f" (권장 `{recommended}`)." if recommended else "."
280
+ # 위저드의 `selected_direction_pick` 은 후보가 아니라 **리포트**를 고른다
281
+ # (`wizard/steps_plan.py` `_build_selected_direction_pick`). 방향은 그 리포트
282
+ # 옆 `user-responses/` 의 `DIRECTION SELECTION` 사이드카가 정한다
283
+ # (`implementation_direction.py` `_comparison_selection`). 종전 문장은
284
+ # "위저드에 후보가 선택지로 나온다" 고 해서 사이드카 없이 계획을 열게 했다.
279
285
  return (
280
- f"{head} 고르는 자리는 계획 단계의 위저드입니다 — `/okstra-run` 으로 "
281
- "`implementation-planning` 을 시작하면 이 후보들이 선택지로 나옵니다. "
282
- "이 phase 를 다시 돌리지 마세요: 비교는 이미 끝났고, 재실행은 같은 "
283
- "비교를 처음부터 다시 합니다."
286
+ f"{head} 고르는 자리는 이 리포트의 HTML 열람본입니다 — 방향 선택 칸에서 "
287
+ "후보를 고르고 Export 한 파일을 "
288
+ "`runs/implementation-option-selection/user-responses/"
289
+ "user-response-implementation-option-selection-<이 리포트 번호>.md` 로 "
290
+ "저장하세요. 그다음 `/okstra-run` 으로 `implementation-planning` 을 "
291
+ "시작하고 위저드의 방향 보고서 단계에서 이 리포트를 고르면 저장한 방향이 "
292
+ "계획에 전달됩니다. 이 phase 를 다시 돌리지 마세요: 비교는 이미 끝났고, "
293
+ "재실행은 같은 비교를 처음부터 다시 합니다."
284
294
  )
285
295
 
286
296
 
@@ -0,0 +1,86 @@
1
+ """okstra-run 밖에서 도는 Okstra 소유 LLM 작업의 준비.
2
+
3
+ 작업 계약(`agents/operations/<id>.json`)이 직무와 워커 수를 소유하고, 역할은 그
4
+ 직무의 `roleId` 에서 파생된다. 기술 문서는 작업 이름만 전달한다 — 역할·공급자·
5
+ 모델을 기술이 조립하면 같은 역할이 호출 표면마다 다른 규칙으로 배정된다
6
+ (ADR-0017).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from .contract_graph import (
15
+ ContractGraphError,
16
+ ContractGraphRoot,
17
+ ContractGraphValidator,
18
+ ContractSelection,
19
+ )
20
+ from .model_pool import ModelPool
21
+
22
+
23
+ class OperationPreparationError(ValueError):
24
+ """작업을 디스패치 전에 준비할 수 없다. 메시지가 그 자리를 이름한다."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class OperationSlot:
29
+ """이 작업이 열 워커 한 칸. 모델은 칸마다 다르다."""
30
+
31
+ operation_id: str
32
+ duty_id: str
33
+ role_id: str
34
+ ordinal: int
35
+ model_ref: str
36
+ provider_id: str
37
+ display_name: str
38
+
39
+
40
+ def resolve_operation_slots(
41
+ operation_id: str,
42
+ *,
43
+ contract_root: Path,
44
+ pool: ModelPool,
45
+ ) -> Sequence[OperationSlot]:
46
+ """작업 계약이 요구하는 칸과 각 칸의 모델.
47
+
48
+ 모델은 런과 같은 기본값 사슬에서 고른다. 서로 다른 모델 참조를 요구한
49
+ 수만큼 채우지 못하면 여기서 멈춘다 — 같은 모델을 반복하거나 워커 수를
50
+ 줄이면 그 작업이 요구한 교차검증이 조용히 사라진다.
51
+ """
52
+ graph_root = ContractGraphRoot.from_base(contract_root)
53
+ try:
54
+ graph = ContractGraphValidator().validate(
55
+ graph_root, selected=ContractSelection(operation_id=operation_id)
56
+ )
57
+ except ContractGraphError as exc:
58
+ raise OperationPreparationError(str(exc)) from exc
59
+ operation = graph.operations.get(operation_id)
60
+ if operation is None:
61
+ known = ", ".join(sorted(graph.operations)) or "(none)"
62
+ raise OperationPreparationError(
63
+ f"unknown operation: {operation_id} (known: {known})"
64
+ )
65
+ duty_id = str(operation.payload["dutyId"])
66
+ count = int(operation.payload["count"])
67
+ role_id = graph.duties[duty_id].role_id
68
+
69
+ candidates = pool.default_candidates(role_id)
70
+ if len(candidates) < count:
71
+ raise OperationPreparationError(
72
+ f"operation {operation_id!r} requires {count} distinct models for role "
73
+ f"{role_id!r}, available {len(candidates)}"
74
+ )
75
+ slots = []
76
+ for ordinal, model in enumerate(candidates[:count], start=1):
77
+ slots.append(OperationSlot(
78
+ operation_id=operation_id,
79
+ duty_id=duty_id,
80
+ role_id=role_id,
81
+ ordinal=ordinal,
82
+ model_ref=str(model.model_ref),
83
+ provider_id=model.provider_id,
84
+ display_name=model.display_name,
85
+ ))
86
+ return tuple(slots)