okstra 0.201.2 → 0.202.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 (49) hide show
  1. package/bin/okstra +1 -1
  2. package/dist/commands/lifecycle/setup.mjs +15 -0
  3. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  4. package/dist/lib/citation-guidance.d.mts +21 -0
  5. package/dist/lib/citation-guidance.mjs +79 -0
  6. package/dist/lib/citation-guidance.mjs.map +1 -0
  7. package/docs/architecture/storage-model.md +4 -0
  8. package/docs/architecture.md +1 -1
  9. package/docs/cli.md +2 -2
  10. package/docs/for-ai/skills/okstra-manager.md +21 -4
  11. package/docs/for-ai/skills/okstra-setup.md +9 -0
  12. package/docs/project-structure-overview.md +11 -5
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/prompts/launch.template.md +1 -1
  16. package/runtime/prompts/lead/okstra-lead-contract.md +15 -3
  17. package/runtime/prompts/lead/team-contract.md +2 -0
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  20. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  21. package/runtime/python/okstra_ctl/agent/evidence_recovery.py +78 -0
  22. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  23. package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
  24. package/runtime/python/okstra_ctl/dispatch_core.py +91 -17
  25. package/runtime/python/okstra_ctl/execution_identity.py +9 -2
  26. package/runtime/python/okstra_ctl/execution_manifest.py +6 -2
  27. package/runtime/python/okstra_ctl/execution_mutation_audit.py +134 -65
  28. package/runtime/python/okstra_ctl/manager_cli.py +85 -12
  29. package/runtime/python/okstra_ctl/manager_launch.py +40 -18
  30. package/runtime/python/okstra_ctl/manager_paths.py +8 -0
  31. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  32. package/runtime/python/okstra_ctl/manager_store.py +121 -18
  33. package/runtime/python/okstra_ctl/manager_sync.py +33 -15
  34. package/runtime/python/okstra_ctl/manager_view.py +216 -0
  35. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  36. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  37. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  38. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  39. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  40. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  41. package/runtime/skills/okstra-manager/SKILL.md +53 -4
  42. package/runtime/skills/okstra-run/SKILL.md +3 -1
  43. package/runtime/skills/okstra-setup/SKILL.md +9 -0
  44. package/runtime/templates/manager/view.template.html +108 -0
  45. package/runtime/templates/reports/html/i18n/en.json +2 -0
  46. package/runtime/templates/reports/html/i18n/ko.json +2 -0
  47. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  48. package/runtime/validators/validate-brief.py +7 -2
  49. package/runtime/validators/validate-run.py +7 -15
@@ -44,6 +44,7 @@ class MutationSnapshot:
44
44
  # 스냅샷이 아직 실행 중인 디스패치에 남아 있고, 그것을 못 읽으면 그 워커가
45
45
  # 통째로 error 가 된다. 두 루트가 같으면 None.
46
46
  artifact_git_head: str | None = None
47
+ artifact_observation_roots: tuple[str, ...] = ()
47
48
 
48
49
  def to_payload(self) -> dict[str, Any]:
49
50
  return {
@@ -57,6 +58,7 @@ class MutationSnapshot:
57
58
  "digest": self.digest,
58
59
  "orchestratorPaths": list(self.orchestrator_paths),
59
60
  "artifactGitHead": self.artifact_git_head,
61
+ "artifactObservationRoots": list(self.artifact_observation_roots),
60
62
  }
61
63
 
62
64
  @classmethod
@@ -75,6 +77,7 @@ class MutationSnapshot:
75
77
  str(payload["artifactGitHead"])
76
78
  if payload.get("artifactGitHead") else None
77
79
  ),
80
+ artifact_observation_roots=tuple(payload.get("artifactObservationRoots", ())),
78
81
  )
79
82
  expected = _snapshot_digest(
80
83
  snapshot.root,
@@ -85,6 +88,7 @@ class MutationSnapshot:
85
88
  snapshot.scratch_digests,
86
89
  snapshot.git_projection,
87
90
  snapshot.orchestrator_paths,
91
+ snapshot.artifact_observation_roots,
88
92
  )
89
93
  if snapshot.digest != expected:
90
94
  raise MutationAuditError("mutation snapshot digest does not match payload")
@@ -115,6 +119,10 @@ class MutationAuditResult:
115
119
  # 에러 원장(contract-violation)이 따로 잡는다.
116
120
  untracked_artifact_paths: tuple[str, ...] = ()
117
121
  warnings: tuple[str, ...] = ()
122
+ changed_artifact_paths: tuple[str, ...] = ()
123
+ declared_out_of_plan_paths: tuple[str, ...] = ()
124
+ unobserved_artifact_paths: tuple[str, ...] = ()
125
+ external_workspace_changes: tuple[str, ...] = ()
118
126
 
119
127
  def change_summary(self) -> dict[str, Any]:
120
128
  return {
@@ -128,6 +136,10 @@ class MutationAuditResult:
128
136
  "retryAllowed": self.retry_allowed,
129
137
  "violations": list(self.violations),
130
138
  "warnings": list(self.warnings),
139
+ "changedArtifactPaths": list(self.changed_artifact_paths),
140
+ "declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
141
+ "unobservedArtifactPaths": list(self.unobserved_artifact_paths),
142
+ "externalWorkspaceChanges": list(self.external_workspace_changes),
131
143
  "beforeDigest": self.before_digest,
132
144
  "afterDigest": self.after_digest,
133
145
  }
@@ -147,12 +159,14 @@ class ExecutionMutationAudit:
147
159
  generated = _generated_paths(rows)
148
160
  file_digests = source_content_snapshot(root, generated)
149
161
  artifact_digests = (
150
- file_digests
162
+ dict(file_digests)
151
163
  if artifact_root == root
152
164
  else _content_snapshot(
153
165
  artifact_root, generated | _non_source_paths(artifact_root)
154
166
  )
155
167
  )
168
+ observation_roots = _artifact_observation_roots(rows)
169
+ artifact_digests.update(_observed_artifact_contents(artifact_root, observation_roots))
156
170
  scratch_digests = _scratch_snapshot(rows)
157
171
  git_projection = _git_projection(root)
158
172
  orchestrator = tuple(
@@ -168,6 +182,7 @@ class ExecutionMutationAudit:
168
182
  scratch_digests,
169
183
  git_projection,
170
184
  orchestrator,
185
+ observation_roots,
171
186
  )
172
187
  return MutationSnapshot(
173
188
  root=root,
@@ -182,6 +197,7 @@ class ExecutionMutationAudit:
182
197
  artifact_git_head=(
183
198
  None if artifact_root == root else _git_head(artifact_root)
184
199
  ),
200
+ artifact_observation_roots=observation_roots,
185
201
  )
186
202
 
187
203
  def compare(
@@ -203,6 +219,13 @@ class ExecutionMutationAudit:
203
219
  artifact_changed = _changed_keys(
204
220
  before.artifact_digests, after.artifact_digests
205
221
  )
222
+ unobserved = {
223
+ path for path in artifact_changed
224
+ if path not in before.artifact_digests
225
+ and any(_is_within(path, root) for root in after.artifact_observation_roots)
226
+ and not any(_is_within(path, root) for root in before.artifact_observation_roots)
227
+ }
228
+ artifact_changed -= unobserved
206
229
  scratch_changed = _changed_keys(
207
230
  before.scratch_digests, after.scratch_digests
208
231
  )
@@ -218,22 +241,27 @@ class ExecutionMutationAudit:
218
241
  after,
219
242
  rows,
220
243
  source_changes,
221
- out_of_plan_edits,
222
244
  )
223
- artifact_failures, untracked_artifact_changes, switched = (
245
+ artifact_failures, untracked_artifact_changes, switched, external = (
224
246
  _artifact_policy_failures(before, rows, artifact_changed, after=after)
225
247
  )
226
248
  violations.extend(artifact_failures)
249
+ worker_artifact_changes = {
250
+ path for path in artifact_changed
251
+ if path not in untracked_artifact_changes and path not in switched and path not in external
252
+ and not any(_is_relative_to(before.artifact_root / path, Path(item))
253
+ for item in before.orchestrator_paths)
254
+ }
227
255
  status = _terminal_status(
228
256
  rows,
229
- source_changed=bool(source_changes),
257
+ source_changed=bool(source_changes or worker_artifact_changes),
230
258
  git_changed=git_changed,
231
259
  attempt_succeeded=attempt_succeeded,
232
260
  result_present=result_present,
233
261
  violations=violations,
234
262
  )
235
263
  retry_allowed = _retry_allowed(
236
- status, bool(source_changes), git_changed, bool(scratch_changed)
264
+ status, bool(source_changes or worker_artifact_changes), git_changed, bool(scratch_changed)
237
265
  )
238
266
  return MutationAuditResult(
239
267
  status=status,
@@ -249,10 +277,87 @@ class ExecutionMutationAudit:
249
277
  git_projection=after.git_projection,
250
278
  untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
251
279
  warnings=_audit_warnings(untracked_artifact_changes)
252
- + _branch_switch_warnings(before, after, switched),
280
+ + _branch_switch_warnings(before, after, switched)
281
+ + tuple(f"unattributed change outside assigned worktree and artifact paths: {path}"
282
+ for path in sorted(external))
283
+ + _plan_change_warnings(rows, source_changes | untracked_changes,
284
+ artifact_changed, out_of_plan_edits, unobserved),
285
+ changed_artifact_paths=tuple(sorted(artifact_changed - external)),
286
+ declared_out_of_plan_paths=tuple(out_of_plan_edits),
287
+ unobserved_artifact_paths=tuple(sorted(unobserved)),
288
+ external_workspace_changes=tuple(sorted(external)),
253
289
  )
254
290
 
255
291
 
292
+ def _observed_artifact_contents(root: Path, observation_roots: Sequence[str]) -> dict[str, str]:
293
+ """관측 경계의 링크 자체를 기록하고 링크 목적지는 탐색하지 않는다."""
294
+ artifact_digests: dict[str, str] = {}
295
+ for relative in observation_roots:
296
+ target = root / relative
297
+ link = next((parent for parent in (*target.parents, target)
298
+ if parent != root and _is_relative_to(parent, root)
299
+ and parent.is_symlink()), None)
300
+ if link is not None:
301
+ artifact_digests[link.relative_to(root).as_posix()] = _path_digest(link)
302
+ continue
303
+ if target.is_symlink() or target.is_file():
304
+ artifact_digests[relative] = _path_digest(target)
305
+ else:
306
+ artifact_digests.update({
307
+ f"{relative}/{key}": value
308
+ for key, value in _content_snapshot(target, frozenset()).items()
309
+ })
310
+ return artifact_digests
311
+
312
+
313
+ def _artifact_observation_roots(policies: Sequence[WritePolicy]) -> tuple[str, ...]:
314
+ """현재 배정의 QA와 실행 산출물은 Git 무시 규칙과 무관하게 관측한다."""
315
+ roots: set[str] = set()
316
+ for path in _allowed_artifact_paths(policies):
317
+ parts = Path(path).parts
318
+ if len(parts) < 5 or parts[:2] != (".okstra", "tasks"):
319
+ continue
320
+ if parts[4] == "qa":
321
+ roots.add(Path(*parts[:5]).as_posix())
322
+ elif parts[4] == "runs":
323
+ boundary = next((i for i, part in enumerate(parts)
324
+ if part in {"worker-results", "prompts"}), len(parts) - 1)
325
+ roots.add(Path(*parts[:boundary]).as_posix())
326
+ else:
327
+ roots.add(path)
328
+ return tuple(sorted(roots))
329
+
330
+
331
+ def _plan_change_warnings(
332
+ policies: Sequence[WritePolicy], source_changes: set[str],
333
+ artifact_changes: set[str], declared: Sequence[str], unobserved: set[str],
334
+ ) -> tuple[str, ...]:
335
+ """예상 목록과의 차이는 검토 자료이며 쓰기 권한 위반이 아니다."""
336
+ warnings: list[str] = []
337
+ for policy in policies:
338
+ if policy.source_mode != "project-mutation":
339
+ continue
340
+ planned = policy.source_policy.get("plannedPaths", ())
341
+ extra = sorted(path for path in source_changes
342
+ if not any(_is_within(path, item) for item in planned))
343
+ if extra:
344
+ warnings.append("source changes outside expected plan; review scope: " + ", ".join(extra))
345
+ observed = {Path(str(policy.source_policy["allowedRoot"])) / path
346
+ for policy in policies for path in source_changes}
347
+ observed.update(Path(str(policy.artifact_policy["allowedRoot"])) / path
348
+ for policy in policies for path in artifact_changes)
349
+ roots = {Path(str(policy.source_policy["allowedRoot"])) for policy in policies}
350
+ roots.update(Path(str(policy.artifact_policy["allowedRoot"])) for policy in policies)
351
+ missing = sorted(path for path in declared
352
+ if not any(root / path in observed for root in roots))
353
+ if missing:
354
+ warnings.append("declared changes not observed; reconcile evidence: " + ", ".join(missing))
355
+ if unobserved:
356
+ warnings.append(f"artifact baseline coverage unavailable ({len(unobserved)} paths); "
357
+ "independently verify current artifacts; see unobservedArtifactPaths")
358
+ return tuple(warnings)
359
+
360
+
256
361
  def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
257
362
  if not untracked_artifact_changes:
258
363
  return ()
@@ -595,6 +700,7 @@ def _snapshot_digest(
595
700
  scratch: Mapping[str, str],
596
701
  git_projection: Mapping[str, Any],
597
702
  orchestrator_paths: Sequence[str],
703
+ observation_roots: Sequence[str] = (),
598
704
  ) -> str:
599
705
  encoded = json.dumps(
600
706
  {
@@ -606,6 +712,7 @@ def _snapshot_digest(
606
712
  "scratch": scratch,
607
713
  "gitProjection": git_projection,
608
714
  "orchestratorPaths": list(orchestrator_paths),
715
+ **({"artifactObservationRoots": list(observation_roots)} if observation_roots else {}),
609
716
  },
610
717
  sort_keys=True,
611
718
  separators=(",", ":"),
@@ -651,22 +758,12 @@ def _artifact_policy_failures(
651
758
  changed: set[str],
652
759
  *,
653
760
  after: MutationSnapshot | None = None,
654
- ) -> tuple[list[str], set[str], set[str]]:
655
- """``(위반 목록, 위반이 아닌 비추적 신규 경로, 브랜치 전환으로 설명되는 경로)``.
656
-
657
- artifact root 의 변경 중 위반으로 남는 것은 두 부류다 — okstra 산출물
658
- 서브트리(`.okstra/`) 안의 허용 밖 쓰기와, git 이 추적하는 파일의 변경.
659
- 그 밖의 비추적 신규 파일은 워커의 도구가 남긴 로그·캐시이므로 소스 root 의
660
- `_split_tracked` 와 같은 이유로 기록만 한다. artifact root 가 git 레포가
661
- 아니면 추적 여부를 알 수 없으므로 종전대로 전부 위반으로 본다.
662
-
663
- 추적 파일의 변경 중 **artifact root 의 HEAD 가 실행 창 안에서 옮겨졌고 그
664
- 두 커밋 사이에서 실제로 달라지는 경로**는 워커의 쓰기가 아니라 사람의 브랜치
665
- 전환이다. 실측(2026-09-09, `fontsninja-v3-site` dev-10627 reverify r1b): 워커가
666
- 워크트리에서 읽기만 하는 8분 동안 프로젝트 루트에서 `checkout preprod →
667
- rebase` 가 있었고, run 브랜치에만 있는 `CardHero.{tsx,styled.ts}` 가 사라져
668
- 완주한 결과가 `contract-failed-unattributed` 로 폐기됐다. 그 경로는 위반에서
669
- 빼고 경고로 남긴다. 전환으로 설명되지 않는 추적 파일 변경은 그대로 위반이다.
761
+ ) -> tuple[list[str], set[str], set[str], set[str]]:
762
+ """위반·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
763
+
764
+ 작업트리와 기본 체크아웃이 다르면 기본 체크아웃 소스의 변경 주체는
765
+ 전후 비교로 판단할 수 없다. 배정된 산출물 밖의 변경은 관측으로 남기고,
766
+ `.okstra` 내부의 허용 밖 쓰기와 배정된 소스 정책은 별도로 검사한다.
670
767
  """
671
768
  allowed = _allowed_artifact_paths(policies)
672
769
  orchestrator = {
@@ -680,7 +777,7 @@ def _artifact_policy_failures(
680
777
  and not any(_is_within(path, item) for item in orchestrator)
681
778
  }
682
779
  if snapshot.artifact_root == snapshot.root:
683
- return [], set(), set()
780
+ unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
684
781
  tracked = _tracked_paths(snapshot.artifact_root)
685
782
  if tracked is None:
686
783
  untracked_outside: set[str] = set()
@@ -697,9 +794,14 @@ def _artifact_policy_failures(
697
794
  if path in unauthorized and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
698
795
  }
699
796
  untracked_outside -= switched
700
- violating = unauthorized - untracked_outside - switched
797
+ external = {
798
+ path for path in unauthorized - untracked_outside - switched
799
+ if snapshot.artifact_root != snapshot.root
800
+ and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
801
+ }
802
+ violating = unauthorized - untracked_outside - switched - external
701
803
  failures = ["artifact-root change exceeds batch policy union"] if violating else []
702
- return failures, untracked_outside, switched
804
+ return failures, untracked_outside, switched, external
703
805
 
704
806
 
705
807
  def _branch_switch_paths(
@@ -761,7 +863,6 @@ def _policy_violations(
761
863
  after: MutationSnapshot,
762
864
  policies: tuple[WritePolicy, ...],
763
865
  source_changes: set[str],
764
- out_of_plan_edits: Sequence[str],
765
866
  ) -> list[str]:
766
867
  if all(policy.source_mode == "source-readonly" for policy in policies):
767
868
  failures = ["readonly source changed"] if source_changes else []
@@ -769,8 +870,8 @@ def _policy_violations(
769
870
  failures.append("gitPolicy disabled but Git projection changed")
770
871
  return failures
771
872
  policy = next(row for row in policies if row.source_mode == "project-mutation")
772
- failures = _source_policy_failures(policy, source_changes, out_of_plan_edits)
773
- failures.extend(_git_policy_failures(before, after, policy, source_changes, out_of_plan_edits))
873
+ failures = _source_policy_failures(policy, source_changes)
874
+ failures.extend(_git_policy_failures(before, after, policy))
774
875
  return failures
775
876
 
776
877
 
@@ -797,37 +898,14 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
797
898
  }
798
899
 
799
900
 
800
- def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
801
- """옛 계획의 미선언 목록만 면제하고, 산출물 전용 계획의 빈 소스 목록은 집행한다.
802
-
803
- 선언 여부가 저장되기 전 정책은 기존의 빈 목록 해석을 유지한다.
804
- 새 정책은 경로 분류 후에도 선언 여부를 보존하므로 산출물만 남은 계획이
805
- 임의의 소스 변경이나 커밋을 허용하지 않는다.
806
- """
807
- source = policy.source_policy
808
- return (
809
- source.get("mode") == "project-mutation"
810
- and not source.get("plannedPaths")
811
- and not source.get("plannedPathsDeclared", False)
812
- )
813
-
814
-
815
901
  def _source_policy_failures(
816
902
  policy: WritePolicy,
817
903
  changed: set[str],
818
- out_of_plan_edits: Sequence[str],
819
904
  ) -> list[str]:
820
- planned = set(policy.source_policy.get("plannedPaths", ()))
821
- declared = set(out_of_plan_edits)
822
905
  protected = set(policy.source_policy.get("protectedPaths", ()))
823
906
  failures: list[str] = []
824
- if (
825
- not _path_ledger_is_unenforceable(policy)
826
- and not changed <= planned | declared
827
- ):
828
- failures.append("source changes exceed planned and declared out-of-plan paths")
829
- if not declared <= changed:
830
- failures.append("declared out-of-plan path did not change")
907
+ if policy.source_mode == "source-readonly" and changed:
908
+ failures.append("readonly source changed")
831
909
  if any(_is_within(path, item) for path in changed for item in protected):
832
910
  failures.append("protected path changed")
833
911
  return failures
@@ -837,8 +915,6 @@ def _git_policy_failures(
837
915
  before: MutationSnapshot,
838
916
  after: MutationSnapshot,
839
917
  policy: WritePolicy,
840
- changed: set[str],
841
- out_of_plan_edits: Sequence[str],
842
918
  ) -> list[str]:
843
919
  git = policy.git_policy
844
920
  root = before.root
@@ -857,23 +933,16 @@ def _git_policy_failures(
857
933
  failures.append("final Git index contains staged changes")
858
934
  if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
859
935
  failures.append("final HEAD is not a fast-forward descendant")
860
- allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
861
- if _path_ledger_is_unenforceable(policy):
862
- # The plan predates the declared path column, so there is no ledger
863
- # anyone can be held to. Every other check above still applies; only
864
- # the path comparison stands down.
865
- return failures
936
+ protected = policy.source_policy.get("protectedPaths", ())
866
937
  if any(
867
- not paths <= allowed
938
+ any(_is_within(path, item) for path in paths for item in protected)
868
939
  for paths in _commit_paths_by_commit(
869
940
  root,
870
941
  str(git.get("expectedBaseCommit")),
871
942
  str(after.git_projection.get("head")),
872
943
  )
873
944
  ):
874
- failures.append("commit chain changed paths outside source policy")
875
- if not changed <= allowed:
876
- failures.append("working tree changed paths outside source policy")
945
+ failures.append("commit chain changed protected paths")
877
946
  return failures
878
947
 
879
948
 
@@ -10,6 +10,7 @@ from okstra_project.dirs import okstra_home
10
10
 
11
11
  from .manager_launch import build_launch_packet
12
12
  from .fixed_text import line, value_lines
13
+ from .manager_paths import ManagerPathError
13
14
  from .manager_store import (
14
15
  ManagerError,
15
16
  append_directive,
@@ -19,20 +20,40 @@ from .manager_store import (
19
20
  discover_projects,
20
21
  init_manager,
21
22
  link_project,
23
+ list_managers,
24
+ list_projects,
25
+ list_tasks,
22
26
  )
27
+ from .manager_split import split_task
23
28
  from .manager_sync import status_task, sync_task
29
+ from .manager_view import write_view
30
+
31
+ # list 명령마다 목록 한 줄에 싣는 (라벨, 키). 라벨은 "<접두어> <번호> <라벨>" 로 찍힌다.
32
+ _LIST_ROWS = {
33
+ "discover-projects": ("Project", (("ID", "projectId"), ("root", "projectRoot"),
34
+ ("run count", "runCount"), ("active count", "activeCount"),
35
+ ("last run at", "lastRunAt"))),
36
+ "list-managers": ("Manager", (("ID", "managerId"), ("created at", "createdAt"),
37
+ ("project count", "projectCount"), ("task count", "taskCount"))),
38
+ "list-projects": ("Project", (("ID", "projectId"), ("root", "projectRoot"), ("role", "role"),
39
+ ("tags", "tags"), ("linked at", "linkedAt"))),
40
+ "list-tasks": ("Task", (("group", "taskGroup"), ("ID", "taskId"), ("objective", "objective"),
41
+ ("progress mode", "progressMode"), ("child count", "childCount"),
42
+ ("created at", "createdAt"))),
43
+ }
24
44
 
25
45
 
26
46
  def render_manager_text(command: str, payload: dict | list[dict]) -> str:
27
47
  """manager 모델 표면에 승인된 식별자와 상태만 투영한다."""
28
48
  allowed = {"init", "discover-projects", "new-project", "new-task-group", "new-task",
29
- "task-assign", "task-note", "task-sync", "task-status", "task-run"}
49
+ "task-assign", "task-note", "task-sync", "task-status", "task-run", "task-split",
50
+ "list-managers", "list-projects", "list-tasks", "view"}
30
51
  if command not in allowed:
31
52
  raise ValueError("unknown manager text purpose")
32
53
  rows = [f"Okstra manager {command}\n", line("Status", "ready")]
33
54
  if isinstance(payload, list):
34
55
  rows.append(line("Result count", len(payload)))
35
- rows.extend(_render_manager_projects(payload))
56
+ rows.extend(_render_list(command, payload))
36
57
  return "".join(rows)
37
58
  if command == "task-status":
38
59
  rows.extend(_render_manager_status(payload))
@@ -44,7 +65,9 @@ def render_manager_text(command: str, payload: dict | list[dict]) -> str:
44
65
  ("Work status", "workStatus"), ("Run command", "command"),
45
66
  ("Backend", "backend"),
46
67
  ("Worker dispatch backend", "workerDispatchBackend"),
47
- ("Project root", "projectRoot"), ("Context path", "contextPath")):
68
+ ("Project root", "projectRoot"), ("Context path", "contextPath"),
69
+ ("Shell command", "shellCommand"), ("View path", "viewPath"),
70
+ ("View URL", "viewUrl")):
48
71
  if key in payload:
49
72
  rows.append(line(label, payload.get(key)))
50
73
  run_args = payload.get("runArgs")
@@ -64,20 +87,27 @@ def render_manager_text(command: str, payload: dict | list[dict]) -> str:
64
87
  ("Directive body", "body"), ("Created at", "createdAt"),
65
88
  ("Source", "source")):
66
89
  rows.append(line(label, payload.get(key)))
90
+ if command == "task-split":
91
+ briefs = payload.get("briefs") if isinstance(payload.get("briefs"), list) else []
92
+ rows.append(line("Brief count", len(briefs)))
93
+ for index, brief in enumerate(briefs, 1):
94
+ for label, key in (("task key", "taskKey"), ("project ID", "projectId"), ("ticket", "ticketId"),
95
+ ("path", "briefPath"), ("status", "briefStatus")):
96
+ rows.append(line(f"Brief {index} {label}", brief.get(key)))
67
97
  if command == "task-sync" and "syncedAt" in payload:
68
98
  rows.append(line("Synced at", payload.get("syncedAt")))
69
99
  return "".join(rows)
70
100
 
71
101
 
72
- def _render_manager_projects(projects: list[dict]) -> list[str]:
102
+ def _render_list(command: str, items: list[dict]) -> list[str]:
103
+ prefix, fields = _LIST_ROWS[command]
73
104
  rows: list[str] = []
74
- for index, project in enumerate(projects, 1):
75
- if not isinstance(project, dict):
105
+ for index, item in enumerate(items, 1):
106
+ if not isinstance(item, dict):
76
107
  continue
77
- for label, key in (("ID", "projectId"), ("root", "projectRoot"),
78
- ("run count", "runCount"), ("active count", "activeCount"),
79
- ("last run at", "lastRunAt")):
80
- rows.append(line(f"Project {index} {label}", project.get(key)))
108
+ for label, key in fields:
109
+ value = item.get(key)
110
+ rows.append(line(f"{prefix} {index} {label}", ", ".join(value) if isinstance(value, list) else value))
81
111
  return rows
82
112
 
83
113
 
@@ -96,6 +126,7 @@ def _render_manager_status(payload: dict) -> list[str]:
96
126
  continue
97
127
  for label, key in (("task key", "taskKey"), ("run status", "latestRunStatus"),
98
128
  ("current phase", "currentPhase"), ("work status", "workStatus"),
129
+ ("summary", "summaryBucket"), ("ticket", "ticketId"), ("brief path", "briefPath"),
99
130
  ("report path", "latestReportRecordPath"), ("error", "error")):
100
131
  if key in child:
101
132
  rows.append(line(f"Child {index} {label}", child.get(key)))
@@ -156,6 +187,11 @@ _CLI_EPILOG = r"""Usage:
156
187
  okstra manager task assign --manager-id <id> --task-group <task-group> --task-id <task-id> --project-id <project-id>
157
188
  okstra manager task note --manager-id <id> --task-group <task-group> --task-id <task-id> --scope <shared|project> --body <text>
158
189
  okstra manager task run --manager-id <id> --project-id <project-id> --task-group <task-group> --task-id <task-id>
190
+ okstra manager task split --manager-id <id> --task-group <task-group> --task-id <task-id> --plan <split-plan.json> [--overwrite]
191
+ okstra manager list managers
192
+ okstra manager list projects --manager-id <id>
193
+ okstra manager list tasks --manager-id <id>
194
+ okstra manager view --manager-id <id>
159
195
 
160
196
  --workspace-root is owned by this command.
161
197
  """
@@ -198,7 +234,7 @@ def _parser() -> argparse.ArgumentParser:
198
234
  task_new.add_argument("--task", action="append", default=[])
199
235
  task_new.add_argument("--objective", default="")
200
236
  task_new.add_argument("--common-brief", default="")
201
- task_new.add_argument("--progress-mode", choices=["manual", "auto"], default="manual")
237
+ task_new.add_argument("--progress-mode", choices=["manual", "auto"], default=None)
202
238
 
203
239
  task = sub.add_parser("task")
204
240
  task_sub = task.add_subparsers(dest="task_command", required=True)
@@ -233,6 +269,22 @@ def _parser() -> argparse.ArgumentParser:
233
269
  run.add_argument("--task-group", required=True)
234
270
  run.add_argument("--task-id", required=True)
235
271
  run.add_argument("--child-task-id", default=None)
272
+
273
+ split = task_sub.add_parser("split")
274
+ split.add_argument("--manager-id", required=True)
275
+ split.add_argument("--task-group", required=True)
276
+ split.add_argument("--task-id", required=True)
277
+ split.add_argument("--plan", required=True)
278
+ split.add_argument("--overwrite", action="store_true")
279
+
280
+ listing = sub.add_parser("list")
281
+ list_sub = listing.add_subparsers(dest="list_command", required=True)
282
+ list_sub.add_parser("managers")
283
+ for name in ("projects", "tasks"):
284
+ list_sub.add_parser(name).add_argument("--manager-id", required=True)
285
+
286
+ view = sub.add_parser("view")
287
+ view.add_argument("--manager-id", required=True)
236
288
  return parser
237
289
 
238
290
 
@@ -322,11 +374,32 @@ def main(argv: list[str] | None = None) -> int:
322
374
  ),
323
375
  args.json,
324
376
  )
377
+ elif args.command == "task" and args.task_command == "split":
378
+ _emit(
379
+ "task-split",
380
+ split_task(
381
+ home,
382
+ args.manager_id,
383
+ args.task_group,
384
+ args.task_id,
385
+ Path(args.plan),
386
+ overwrite=args.overwrite,
387
+ ),
388
+ args.json,
389
+ )
390
+ elif args.command == "list" and args.list_command == "managers":
391
+ _emit("list-managers", list_managers(home), args.json)
392
+ elif args.command == "list" and args.list_command == "projects":
393
+ _emit("list-projects", list_projects(home, args.manager_id), args.json)
394
+ elif args.command == "list" and args.list_command == "tasks":
395
+ _emit("list-tasks", list_tasks(home, args.manager_id), args.json)
396
+ elif args.command == "view":
397
+ _emit("view", write_view(home, args.manager_id), args.json)
325
398
  else:
326
399
  parser.print_help()
327
400
  return 2
328
401
  return 0
329
- except ManagerError as exc:
402
+ except (ManagerError, ManagerPathError) as exc:
330
403
  print(f"okstra manager: {exc}", file=sys.stderr)
331
404
  return 2
332
405
 
@@ -1,6 +1,7 @@
1
1
  """Build manager child launch packets and context documents."""
2
2
  from __future__ import annotations
3
3
 
4
+ import shlex
4
5
  from pathlib import Path
5
6
 
6
7
  from okstra_ctl.jsonl import append_jsonl, read_jsonl
@@ -15,12 +16,16 @@ from .manager_paths import (
15
16
  task_manifest_path,
16
17
  )
17
18
  from .manager_store import ManagerError, _now_iso, _read_json, _read_json_default, _write_json
19
+ from .manager_sync import _resolve_task_root_read_only
18
20
 
19
21
 
20
22
  WORKER_DISPATCH_BACKEND = "subagent"
21
- # child lead 는 언제나 subagent 로 뜬다. 예전에는 `$TMUX` 가 잡히면
22
- # `tmux-child-lead` 를 골랐지만 tmux 백엔드가 사라져 분기가 남지 않았다.
23
- CHILD_LEAD_BACKEND = "subagent-child-lead"
23
+ # child lead 는 설치본 런처(`okstra.sh`)가 새 host 프로세스로 띄운다. `okstra run`
24
+ # 은 lead host 에 `--launch-only` 를 붙여 task 인자와 `--directive` 를 버리므로
25
+ # child 맥락이 전달되지 않는다(run.mts 의 launch-lead 분기). 런처는 prepare 까지
26
+ # 거친 뒤 `launch-plan --entry-mode spawn-process` 로 lead 를 띄운다.
27
+ CHILD_LEAD_BACKEND = "spawn-process"
28
+ LAUNCHER_RELATIVE = Path("bin") / "okstra.sh"
24
29
  LAUNCH_STATUS_PREPARED = "prepared"
25
30
 
26
31
 
@@ -110,9 +115,14 @@ def _render_context(
110
115
  "",
111
116
  child.get("assignment") or "",
112
117
  "",
113
- "## Manager Directives",
114
- "",
115
118
  ]
119
+ if child.get("scope"):
120
+ # `task split` 이 만든 하위 태스크. 이 프로젝트에서 할 범위만 적는다.
121
+ lines.extend(["## Project Scope", "", f"- ticket: {child.get('ticketId') or ''}",
122
+ f"- brief: {child.get('briefPath') or ''}", ""])
123
+ lines.extend(f"- in scope: {item}" for item in child["scope"])
124
+ lines.append("")
125
+ lines.extend(["## Manager Directives", ""])
116
126
  for row in directives:
117
127
  lines.append(f"- [{row.get('scope')}] {row.get('body')}")
118
128
  if not directives:
@@ -214,6 +224,28 @@ def build_launch_packet(
214
224
  ),
215
225
  encoding="utf-8",
216
226
  )
227
+ command = str(Path(home) / LAUNCHER_RELATIVE)
228
+ run_args = [
229
+ "--project-root",
230
+ project_root,
231
+ "--project-id",
232
+ project_id,
233
+ "--task-group",
234
+ str(child.get("taskGroup") or task_group),
235
+ "--task-id",
236
+ child_task_id_value,
237
+ ]
238
+ # `task split` 이 기록한 브리프·권장 phase 를 넘겨 런처가 첫 실행에서 다시 묻지 않게 한다.
239
+ # phase 는 하위 태스크가 아직 없을 때만 넘긴다. 이미 있으면 런처가 manifest 에서 다음
240
+ # phase 를 채워야 하는데, 두 값이 다 주어지면 그 채우기를 건너뛴다(autofill_from_manifest).
241
+ if child.get("recommendedPhase") and _resolve_task_root_read_only(Path(project_root), target_task_key) is None:
242
+ run_args.extend(["--task-type", str(child["recommendedPhase"])])
243
+ if child.get("briefPath"):
244
+ run_args.extend(["--task-brief", str(child["briefPath"])])
245
+ run_args += [
246
+ "--directive",
247
+ f"Read manager child context: {context_path}",
248
+ ]
217
249
  packet = {
218
250
  "managerId": manager_id,
219
251
  "taskGroup": task_group,
@@ -224,19 +256,9 @@ def build_launch_packet(
224
256
  "workerDispatchBackend": WORKER_DISPATCH_BACKEND,
225
257
  "projectRoot": project_root,
226
258
  "contextPath": str(context_path),
227
- "runArgs": [
228
- "run",
229
- "--project-root",
230
- project_root,
231
- "--project-id",
232
- project_id,
233
- "--task-group",
234
- str(child.get("taskGroup") or task_group),
235
- "--task-id",
236
- child_task_id_value,
237
- "--directive",
238
- f"Read manager child context: {context_path}",
239
- ],
259
+ "command": command,
260
+ "runArgs": run_args,
261
+ "shellCommand": shlex.join([command, *run_args]),
240
262
  "createdAt": created_at,
241
263
  }
242
264
  _record_launch_prepared(