okstra 0.201.3 → 0.204.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 (273) hide show
  1. package/README.md +3 -3
  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/lifecycle/setup.mjs +15 -0
  7. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  8. package/dist/commands/memory/memory.mjs +41 -8
  9. package/dist/commands/memory/memory.mjs.map +1 -1
  10. package/dist/lib/citation-guidance.d.mts +21 -0
  11. package/dist/lib/citation-guidance.mjs +79 -0
  12. package/dist/lib/citation-guidance.mjs.map +1 -0
  13. package/dist/lib/install-assets.mjs +3 -0
  14. package/dist/lib/install-assets.mjs.map +1 -1
  15. package/dist/lib/runtime-manifest.mjs +2 -1
  16. package/dist/lib/runtime-manifest.mjs.map +1 -1
  17. package/dist/lib/types.d.mts +2 -1
  18. package/docs/architecture/storage-model.md +17 -10
  19. package/docs/architecture.md +26 -20
  20. package/docs/cli.md +16 -13
  21. package/docs/contributor-change-matrix.md +3 -2
  22. package/docs/performance-improvement-plan-v2.md +2 -3
  23. package/docs/project-structure-overview.md +38 -9
  24. package/docs/task-process/README.md +1 -1
  25. package/docs/task-process/common-flow.md +1 -1
  26. package/docs/task-process/final-verification.md +3 -1
  27. package/docs/task-process/implementation.md +1 -1
  28. package/docs/task-process/release-handoff.md +36 -39
  29. package/package.json +1 -2
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/common.json +28 -0
  32. package/runtime/agents/operations/code-review.json +6 -0
  33. package/runtime/agents/operations/report-translation.json +6 -0
  34. package/runtime/agents/operations/schedule-verification.json +6 -0
  35. package/runtime/agents/roles/analyser.json +18 -0
  36. package/runtime/agents/roles/critic.json +18 -0
  37. package/runtime/agents/roles/designer.json +18 -0
  38. package/runtime/agents/roles/implementer.json +20 -0
  39. package/runtime/agents/roles/leader.json +20 -0
  40. package/runtime/agents/roles/planner.json +18 -0
  41. package/runtime/agents/roles/report-writer.json +19 -0
  42. package/runtime/agents/roles/translator.json +19 -0
  43. package/runtime/agents/roles/verifier.json +18 -0
  44. package/runtime/bin/lib/okstra/usage.sh +5 -5
  45. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  46. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  47. package/runtime/prompts/duties/analysis-worker.json +32 -0
  48. package/runtime/prompts/duties/code-reviewer.json +32 -0
  49. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  50. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  51. package/runtime/prompts/duties/discovery-worker.json +32 -0
  52. package/runtime/prompts/duties/implementation-executor.json +32 -0
  53. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  54. package/runtime/prompts/duties/lead.json +32 -0
  55. package/runtime/prompts/duties/planning-worker.json +36 -0
  56. package/runtime/prompts/duties/report-writer.json +32 -0
  57. package/runtime/prompts/duties/reverification-worker.json +32 -0
  58. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  59. package/runtime/prompts/duties/scope-critic.json +32 -0
  60. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  61. package/runtime/prompts/duties/translator.json +32 -0
  62. package/runtime/prompts/launch.template.md +3 -2
  63. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  64. package/runtime/prompts/lead/convergence.md +4 -4
  65. package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
  66. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  67. package/runtime/prompts/lead/report-writer.md +3 -3
  68. package/runtime/prompts/profiles/_common-contract.md +2 -2
  69. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  70. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  71. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  72. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  73. package/runtime/prompts/profiles/error-analysis.json +39 -0
  74. package/runtime/prompts/profiles/error-analysis.md +0 -25
  75. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  76. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  77. package/runtime/prompts/profiles/final-verification.json +30 -0
  78. package/runtime/prompts/profiles/final-verification.md +3 -22
  79. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  80. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  81. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  82. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  83. package/runtime/prompts/profiles/implementation-planning.md +6 -29
  84. package/runtime/prompts/profiles/implementation.json +30 -0
  85. package/runtime/prompts/profiles/implementation.md +1 -20
  86. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  87. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  88. package/runtime/prompts/profiles/project-analysis.json +31 -0
  89. package/runtime/prompts/profiles/project-analysis.md +0 -20
  90. package/runtime/prompts/profiles/release-handoff.json +5 -0
  91. package/runtime/prompts/profiles/release-handoff.md +71 -73
  92. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  93. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  94. package/runtime/prompts/profiles/technical-verification.json +39 -0
  95. package/runtime/prompts/profiles/technical-verification.md +0 -25
  96. package/runtime/prompts/wizard/prompts.ko.json +12 -17
  97. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  98. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  101. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  103. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  104. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  105. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  106. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  107. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  108. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  109. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  110. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  111. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  112. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  113. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  114. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  115. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  116. package/runtime/python/okstra_ctl/cmux.py +69 -20
  117. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  118. package/runtime/python/okstra_ctl/conformance.py +43 -0
  119. package/runtime/python/okstra_ctl/consumers.py +6 -3
  120. package/runtime/python/okstra_ctl/container.py +31 -8
  121. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  122. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  123. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  124. package/runtime/python/okstra_ctl/convergence_provenance.py +81 -18
  125. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  126. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  127. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  128. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  129. package/runtime/python/okstra_ctl/error_report.py +4 -3
  130. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  131. package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
  132. package/runtime/python/okstra_ctl/handoff.py +167 -277
  133. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  134. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  135. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  136. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +175 -14
  138. package/runtime/python/okstra_ctl/manager_launch.py +41 -19
  139. package/runtime/python/okstra_ctl/manager_paths.py +22 -3
  140. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  141. package/runtime/python/okstra_ctl/manager_store.py +331 -21
  142. package/runtime/python/okstra_ctl/manager_sync.py +37 -16
  143. package/runtime/python/okstra_ctl/manager_view.py +217 -0
  144. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  145. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  146. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  147. package/runtime/python/okstra_ctl/models.py +1 -1
  148. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  149. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  150. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  151. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  152. package/runtime/python/okstra_ctl/paths.py +3 -0
  153. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  154. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  155. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  156. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  157. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  158. package/runtime/python/okstra_ctl/render.py +50 -0
  159. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  160. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  161. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  162. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  163. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  164. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  165. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  166. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  167. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  168. package/runtime/python/okstra_ctl/rollup.py +3 -1
  169. package/runtime/python/okstra_ctl/run.py +76 -29
  170. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  171. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  172. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  173. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  174. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  175. package/runtime/python/okstra_ctl/team.py +22 -13
  176. package/runtime/python/okstra_ctl/time_report.py +2 -1
  177. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  178. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  179. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  180. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  181. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  182. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  183. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -23
  184. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  185. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  186. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  187. package/runtime/python/okstra_ctl/workflow.py +1 -1
  188. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  189. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  190. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  191. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  192. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  193. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  194. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  195. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  196. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  197. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  198. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  199. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  200. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  201. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  202. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  203. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  204. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  205. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  206. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  207. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  208. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  209. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  210. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  211. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  212. package/runtime/skills/okstra-manager/SKILL.md +70 -5
  213. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  214. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  215. package/runtime/skills/okstra-run/SKILL.md +32 -13
  216. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  217. package/runtime/skills/okstra-setup/SKILL.md +21 -10
  218. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  219. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  220. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  221. package/runtime/templates/manager/view.template.html +109 -0
  222. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  223. package/runtime/templates/reports/brief.template.md +14 -4
  224. package/runtime/templates/reports/html/i18n/en.json +7 -4
  225. package/runtime/templates/reports/html/i18n/ko.json +7 -4
  226. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  227. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  228. package/runtime/templates/reports/i18n/en.json +1 -1
  229. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  230. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  231. package/runtime/templates/translator-prompt-preamble.md +36 -0
  232. package/runtime/validators/checks/validate-assets-01.py +7 -8
  233. package/runtime/validators/validate-brief.py +77 -2
  234. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  235. package/runtime/validators/validate-run.py +59 -9
  236. package/runtime/validators/validate-schedule.py +9 -0
  237. package/docs/for-ai/README.md +0 -68
  238. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  239. package/docs/for-ai/skills/okstra-chat.md +0 -34
  240. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  241. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  242. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  243. package/docs/for-ai/skills/okstra-manager.md +0 -69
  244. package/docs/for-ai/skills/okstra-memory.md +0 -126
  245. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  246. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  247. package/docs/for-ai/skills/okstra-run.md +0 -250
  248. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  249. package/docs/for-ai/skills/okstra-setup.md +0 -158
  250. package/docs/for-ai/skills/okstra-usage.md +0 -29
  251. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  252. package/runtime/agents/workers/claude-worker.md +0 -128
  253. package/runtime/agents/workers/report-writer-worker.md +0 -37
  254. package/runtime/agents/workers/translator-worker.md +0 -63
  255. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  256. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  257. package/runtime/prompts/duties/analysis-worker.md +0 -44
  258. package/runtime/prompts/duties/code-reviewer.md +0 -44
  259. package/runtime/prompts/duties/common.md +0 -39
  260. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  261. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  262. package/runtime/prompts/duties/discovery-worker.md +0 -44
  263. package/runtime/prompts/duties/implementation-executor.md +0 -44
  264. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  265. package/runtime/prompts/duties/lead.md +0 -44
  266. package/runtime/prompts/duties/planning-worker.md +0 -52
  267. package/runtime/prompts/duties/report-writer.md +0 -44
  268. package/runtime/prompts/duties/reverification-worker.md +0 -44
  269. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  270. package/runtime/prompts/duties/scope-critic.md +0 -44
  271. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  272. package/runtime/prompts/duties/translator.md +0 -44
  273. 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
 
@@ -96,6 +102,63 @@ def load_manager(home: Path, manager_id: str) -> dict:
96
102
  return _read_json(path)
97
103
 
98
104
 
105
+ def _task_manifest_paths(home: Path, manager_id: str) -> list[Path]:
106
+ return sorted(manager_root(home, manager_id).glob("task-groups/*/*/manifest.json"))
107
+
108
+
109
+ def list_managers(home: Path) -> list[dict]:
110
+ """이 홈의 manager 를 id 순으로, 소속 프로젝트 수와 manager task 수와 함께 돌려준다."""
111
+ base = Path(home) / "managers"
112
+ if not base.is_dir():
113
+ return []
114
+ items: list[dict] = []
115
+ for path in sorted(base.glob("*/manager.json")):
116
+ manager = _read_json(path)
117
+ manager_id = str(manager.get("managerId") or path.parent.name)
118
+ items.append(
119
+ {
120
+ "managerId": manager_id,
121
+ "createdAt": str(manager.get("createdAt") or ""),
122
+ "projectCount": len(_load_projects(home, manager_id).get("projects", [])),
123
+ "taskCount": len(_task_manifest_paths(home, manager_id)),
124
+ }
125
+ )
126
+ return items
127
+
128
+
129
+ def list_projects(home: Path, manager_id: str) -> list[dict]:
130
+ """manager 에 등록된 프로젝트를 등록 파일(projects.json) 순서 그대로 돌려준다."""
131
+ load_manager(home, manager_id)
132
+ return [
133
+ project
134
+ for project in _load_projects(home, manager_id).get("projects", [])
135
+ if isinstance(project, dict)
136
+ ]
137
+
138
+
139
+ def list_tasks(home: Path, manager_id: str) -> list[dict]:
140
+ """manager task 를 task-group, task-id 순으로 하위 task 수와 함께 돌려준다.
141
+
142
+ 디렉터리 이름은 slug 라 원래 입력값과 다를 수 있으므로 식별자는 manifest 에서 읽는다.
143
+ """
144
+ load_manager(home, manager_id)
145
+ items: list[dict] = []
146
+ for path in _task_manifest_paths(home, manager_id):
147
+ manifest = _read_json(path)
148
+ children = _read_json_default(path.parent / "children.json", {"children": []})
149
+ items.append(
150
+ {
151
+ "taskGroup": str(manifest.get("taskGroup") or ""),
152
+ "taskId": str(manifest.get("taskId") or ""),
153
+ "objective": str(manifest.get("objective") or ""),
154
+ "progressMode": str(manifest.get("progressMode") or ""),
155
+ "createdAt": str(manifest.get("createdAt") or ""),
156
+ "childCount": len(children.get("children", [])),
157
+ }
158
+ )
159
+ return sorted(items, key=lambda item: (item["taskGroup"], item["taskId"]))
160
+
161
+
99
162
  def discover_projects(home: Path) -> list[dict]:
100
163
  base = Path(home) / "projects"
101
164
  if not base.is_dir():
@@ -167,11 +230,31 @@ def link_project(
167
230
  return payload
168
231
 
169
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
+
170
243
  def create_task_group(home: Path, manager_id: str, task_group: str, *, now: str | None = None) -> dict:
171
244
  load_manager(home, manager_id)
172
- group_dir = task_manifest_path(home, manager_id, task_group, "group-marker").parents[1]
173
- group_dir.mkdir(parents=True, exist_ok=True)
174
- 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}
247
+
248
+
249
+ def _merge_manifest(existing: dict, requested: dict) -> None:
250
+ """이미 있는 manager task 의 manifest 값을 `new task` 가 바꾸지 못하게 한다."""
251
+ for key, value in requested.items():
252
+ if value is None or value == "" or existing.get(key) == value:
253
+ continue
254
+ raise ManagerError(
255
+ f"manager task already exists with {key}={existing.get(key)!r}; "
256
+ f"refusing to change it to {value!r}"
257
+ )
175
258
 
176
259
 
177
260
  def create_task(
@@ -183,32 +266,56 @@ def create_task(
183
266
  *,
184
267
  objective: str = "",
185
268
  common_brief_path: str = "",
186
- progress_mode: str = "manual",
269
+ progress_mode: str | None = None,
187
270
  now: str | None = None,
188
271
  ) -> dict:
272
+ """manager task 를 만들거나, 이미 있으면 새 하위 task 만 덧붙인다.
273
+
274
+ 다시 실행해도 기존 하위 task 의 assignment·launch 상태는 지우지 않는다.
275
+ """
276
+ load_manager(home, manager_id)
189
277
  projects = _load_projects(home, manager_id)
190
278
  when = now or _now_iso()
191
- manifest = {
192
- "managerId": manager_id,
193
- "taskGroup": task_group,
194
- "taskId": task_id,
195
- "progressMode": progress_mode,
196
- "commonBriefPath": common_brief_path,
197
- "objective": objective,
198
- "createdAt": when,
199
- }
200
- children = []
279
+ manifest_path = task_manifest_path(home, manager_id, task_group, task_id)
280
+ existing = manifest_path.is_file()
281
+ if existing:
282
+ manifest = _read_json(manifest_path)
283
+ _merge_manifest(
284
+ manifest,
285
+ {
286
+ "objective": objective,
287
+ "commonBriefPath": common_brief_path,
288
+ "progressMode": progress_mode,
289
+ },
290
+ )
291
+ else:
292
+ manifest = {
293
+ "managerId": manager_id,
294
+ "taskGroup": task_group,
295
+ "taskId": task_id,
296
+ "progressMode": progress_mode or "manual",
297
+ "commonBriefPath": common_brief_path,
298
+ "objective": objective,
299
+ "createdAt": when,
300
+ }
301
+ children = _load_children(home, manager_id, task_group, task_id)
302
+ known_keys = {child.get("taskKey") for child in children["children"] if isinstance(child, dict)}
303
+ added = []
201
304
  for spec in child_specs:
202
305
  project_id = str(spec.get("projectId") or "")
203
306
  child_task_id = str(spec.get("taskId") or task_id)
204
307
  if _project_entry(projects, project_id) is None:
205
308
  raise ManagerError(f"unknown project membership: {project_id}")
206
- children.append(
309
+ task_key = _child_task_key(project_id, task_group, child_task_id)
310
+ if task_key in known_keys:
311
+ continue
312
+ known_keys.add(task_key)
313
+ added.append(
207
314
  {
208
315
  "projectId": project_id,
209
316
  "taskGroup": task_group,
210
317
  "taskId": child_task_id,
211
- "taskKey": f"{project_id}:{task_group}:{child_task_id}",
318
+ "taskKey": task_key,
212
319
  "role": "",
213
320
  "tags": [],
214
321
  "assignment": "",
@@ -219,14 +326,20 @@ def create_task(
219
326
  },
220
327
  }
221
328
  )
222
- payload = {"manifest": manifest, "children": {"children": children}}
223
- _write_json(task_manifest_path(home, manager_id, task_group, task_id), manifest)
224
- _write_json(children_json_path(home, manager_id, task_group, task_id), payload["children"])
329
+ children["children"].extend(added)
330
+ if not existing:
331
+ _ensure_group_meta(home, manager_id, task_group, when)
332
+ _write_json(manifest_path, manifest)
333
+ _write_json(children_json_path(home, manager_id, task_group, task_id), children)
225
334
  append_jsonl(
226
335
  events_jsonl_path(home, manager_id, task_group, task_id),
227
- {"event": "task-created", "createdAt": when},
336
+ {
337
+ "event": "task-children-added" if existing else "task-created",
338
+ "createdAt": when,
339
+ "taskKeys": [child["taskKey"] for child in added],
340
+ },
228
341
  )
229
- return payload
342
+ return {"manifest": manifest, "children": children}
230
343
 
231
344
 
232
345
  def _load_children(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
@@ -286,6 +399,7 @@ def assign_child(
286
399
  assignment: str,
287
400
  child_task_id: str | None = None,
288
401
  ) -> dict:
402
+ _read_json(task_manifest_path(home, manager_id, task_group, task_id))
289
403
  payload = _load_children(home, manager_id, task_group, task_id)
290
404
  child = _select_child_for_assignment(payload["children"], project_id, task_group, child_task_id)
291
405
  child["role"] = role
@@ -311,6 +425,11 @@ def append_directive(
311
425
  raise ManagerError("scope must be shared or project")
312
426
  if scope == "project" and not project_id:
313
427
  raise ManagerError("project directive requires project-id")
428
+ _read_json(task_manifest_path(home, manager_id, task_group, task_id))
429
+ if scope == "project":
430
+ children = _load_children(home, manager_id, task_group, task_id)["children"]
431
+ if not any(isinstance(child, dict) and child.get("projectId") == project_id for child in children):
432
+ raise ManagerError(f"project directive target has no child in this task: {project_id}")
314
433
  row = {
315
434
  "scope": scope,
316
435
  "projectId": project_id if scope == "project" else "",
@@ -320,3 +439,194 @@ def append_directive(
320
439
  }
321
440
  append_jsonl(directives_jsonl_path(home, manager_id, task_group, task_id), row)
322
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
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from okstra_project import TASKS_RELATIVE, StateError, parse_task_key, read_task_catalog, read_task_manifest, slugify
7
7
 
8
8
  from . import next_phase
9
+ from .next_phase import STATUS_BLOCKED, STATUS_TERMINAL
9
10
  from .manager_paths import children_json_path, projects_json_path, snapshots_json_path, task_manifest_path
10
11
  from .manager_store import _now_iso, _read_json, _read_json_default, _write_json
11
12
 
@@ -89,6 +90,7 @@ def _snapshot_child(project_root: Path, child: dict) -> dict:
89
90
  workflow.get("nextRecommendedPhase")
90
91
  ),
91
92
  "latestRunStatus": str(manifest.get("latestRunStatus") or manifest.get("currentStatus") or ""),
93
+ "workStatus": str(manifest.get("workStatus") or ""),
92
94
  "latestReportRecordPath": str(manifest.get("latestReportRecordPath") or ""),
93
95
  "finalVerdict": str(outcome.get("finalVerdict") or ""),
94
96
  "crossProjectDependencies": _list_field(outcome, "crossProjectDependencies"),
@@ -107,7 +109,7 @@ def sync_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now
107
109
  continue
108
110
  project = projects.get(str(child.get("projectId") or ""))
109
111
  if project is None:
110
- 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`"})
111
113
  continue
112
114
  root = Path(str(project.get("projectRoot") or ""))
113
115
  if not root.is_dir():
@@ -125,31 +127,50 @@ def sync_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now
125
127
  return payload
126
128
 
127
129
 
130
+ # 사용자가 `okstra set-work-status` 로 적은 값이 파생 포인터보다 우선한다.
131
+ # 그룹 큐(group_context.catalog_progress)와 같은 순서다. `latestRunStatus` 는
132
+ # phase 한 번의 실행 결과(`completed` / `contract-violated` / `in-progress` …)라
133
+ # task 가 끝났는지 말해 주지 않으므로 요약 분류에 쓰지 않는다.
134
+ _WORK_STATUS_BUCKETS = {
135
+ "done": "done",
136
+ "blocked": "blocked",
137
+ "in-progress": "running",
138
+ "todo": "planned",
139
+ }
140
+ _POINTER_STATUS_BUCKETS = {
141
+ STATUS_TERMINAL: "done",
142
+ STATUS_BLOCKED: "blocked",
143
+ }
144
+
145
+
146
+ def summary_bucket(row: dict) -> str:
147
+ """스냅샷이 있는 하위 task 한 줄을 요약 칸 하나로 분류한다."""
148
+ if not row.get("exists", False):
149
+ return "missing"
150
+ work_status = str(row.get("workStatus") or "")
151
+ if work_status in _WORK_STATUS_BUCKETS:
152
+ return _WORK_STATUS_BUCKETS[work_status]
153
+ pointer = row.get("nextRecommendedPhase")
154
+ pointer_status = pointer.get("status") if isinstance(pointer, dict) else ""
155
+ return _POINTER_STATUS_BUCKETS.get(pointer_status, "running")
156
+
157
+
128
158
  def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
129
159
  manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
130
160
  children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
131
161
  snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
132
162
  by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
163
+ linked = _project_map(home, manager_id)
133
164
  rows = []
134
165
  summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0}
135
166
  for child in children.get("children", []):
136
167
  if not isinstance(child, dict):
137
168
  continue
138
- key = child.get("taskKey")
139
- snapshot_row = by_key.get(key)
169
+ snapshot_row = by_key.get(child.get("taskKey"))
140
170
  row = {**child, **(snapshot_row or {})}
141
- status = str(row.get("latestRunStatus") or row.get("launch", {}).get("status") or "planned")
142
- if snapshot_row is None:
143
- summary["planned"] += 1
144
- elif not row.get("exists", False):
145
- summary["missing"] += 1
146
- elif status == "done":
147
- summary["done"] += 1
148
- elif status in {"running", "started", "in-progress"}:
149
- summary["running"] += 1
150
- elif status == "blocked":
151
- summary["blocked"] += 1
152
- else:
153
- summary["planned"] += 1
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
174
+ summary[row["summaryBucket"]] += 1
154
175
  rows.append(row)
155
176
  return {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot}