okstra 0.201.3 → 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 (41) hide show
  1. package/dist/commands/lifecycle/setup.mjs +15 -0
  2. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  3. package/dist/lib/citation-guidance.d.mts +21 -0
  4. package/dist/lib/citation-guidance.mjs +79 -0
  5. package/dist/lib/citation-guidance.mjs.map +1 -0
  6. package/docs/architecture/storage-model.md +4 -0
  7. package/docs/architecture.md +1 -1
  8. package/docs/cli.md +2 -2
  9. package/docs/for-ai/skills/okstra-manager.md +21 -4
  10. package/docs/for-ai/skills/okstra-setup.md +9 -0
  11. package/docs/project-structure-overview.md +4 -1
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  16. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  17. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  18. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  19. package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
  20. package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
  21. package/runtime/python/okstra_ctl/manager_cli.py +85 -12
  22. package/runtime/python/okstra_ctl/manager_launch.py +40 -18
  23. package/runtime/python/okstra_ctl/manager_paths.py +8 -0
  24. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  25. package/runtime/python/okstra_ctl/manager_store.py +121 -18
  26. package/runtime/python/okstra_ctl/manager_sync.py +33 -15
  27. package/runtime/python/okstra_ctl/manager_view.py +216 -0
  28. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  29. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  30. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  31. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  32. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  33. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  34. package/runtime/skills/okstra-manager/SKILL.md +53 -4
  35. package/runtime/skills/okstra-run/SKILL.md +1 -1
  36. package/runtime/skills/okstra-setup/SKILL.md +9 -0
  37. package/runtime/templates/manager/view.template.html +108 -0
  38. package/runtime/templates/reports/html/i18n/en.json +2 -0
  39. package/runtime/templates/reports/html/i18n/ko.json +2 -0
  40. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  41. package/runtime/validators/validate-brief.py +7 -2
@@ -122,6 +122,7 @@ class MutationAuditResult:
122
122
  changed_artifact_paths: tuple[str, ...] = ()
123
123
  declared_out_of_plan_paths: tuple[str, ...] = ()
124
124
  unobserved_artifact_paths: tuple[str, ...] = ()
125
+ external_workspace_changes: tuple[str, ...] = ()
125
126
 
126
127
  def change_summary(self) -> dict[str, Any]:
127
128
  return {
@@ -138,6 +139,7 @@ class MutationAuditResult:
138
139
  "changedArtifactPaths": list(self.changed_artifact_paths),
139
140
  "declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
140
141
  "unobservedArtifactPaths": list(self.unobserved_artifact_paths),
142
+ "externalWorkspaceChanges": list(self.external_workspace_changes),
141
143
  "beforeDigest": self.before_digest,
142
144
  "afterDigest": self.after_digest,
143
145
  }
@@ -240,13 +242,13 @@ class ExecutionMutationAudit:
240
242
  rows,
241
243
  source_changes,
242
244
  )
243
- artifact_failures, untracked_artifact_changes, switched = (
245
+ artifact_failures, untracked_artifact_changes, switched, external = (
244
246
  _artifact_policy_failures(before, rows, artifact_changed, after=after)
245
247
  )
246
248
  violations.extend(artifact_failures)
247
249
  worker_artifact_changes = {
248
250
  path for path in artifact_changed
249
- if path not in untracked_artifact_changes and path not in switched
251
+ if path not in untracked_artifact_changes and path not in switched and path not in external
250
252
  and not any(_is_relative_to(before.artifact_root / path, Path(item))
251
253
  for item in before.orchestrator_paths)
252
254
  }
@@ -276,11 +278,14 @@ class ExecutionMutationAudit:
276
278
  untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
277
279
  warnings=_audit_warnings(untracked_artifact_changes)
278
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))
279
283
  + _plan_change_warnings(rows, source_changes | untracked_changes,
280
284
  artifact_changed, out_of_plan_edits, unobserved),
281
- changed_artifact_paths=tuple(sorted(artifact_changed)),
285
+ changed_artifact_paths=tuple(sorted(artifact_changed - external)),
282
286
  declared_out_of_plan_paths=tuple(out_of_plan_edits),
283
287
  unobserved_artifact_paths=tuple(sorted(unobserved)),
288
+ external_workspace_changes=tuple(sorted(external)),
284
289
  )
285
290
 
286
291
 
@@ -753,22 +758,12 @@ def _artifact_policy_failures(
753
758
  changed: set[str],
754
759
  *,
755
760
  after: MutationSnapshot | None = None,
756
- ) -> tuple[list[str], set[str], set[str]]:
757
- """``(위반 목록, 위반이 아닌 비추적 신규 경로, 브랜치 전환으로 설명되는 경로)``.
758
-
759
- artifact root 의 변경 중 위반으로 남는 것은 두 부류다 — okstra 산출물
760
- 서브트리(`.okstra/`) 안의 허용 밖 쓰기와, git 이 추적하는 파일의 변경.
761
- 그 밖의 비추적 신규 파일은 워커의 도구가 남긴 로그·캐시이므로 소스 root 의
762
- `_split_tracked` 와 같은 이유로 기록만 한다. artifact root 가 git 레포가
763
- 아니면 추적 여부를 알 수 없으므로 종전대로 전부 위반으로 본다.
764
-
765
- 추적 파일의 변경 중 **artifact root 의 HEAD 가 실행 창 안에서 옮겨졌고 그
766
- 두 커밋 사이에서 실제로 달라지는 경로**는 워커의 쓰기가 아니라 사람의 브랜치
767
- 전환이다. 실측(2026-09-09, `fontsninja-v3-site` dev-10627 reverify r1b): 워커가
768
- 워크트리에서 읽기만 하는 8분 동안 프로젝트 루트에서 `checkout preprod →
769
- rebase` 가 있었고, run 브랜치에만 있는 `CardHero.{tsx,styled.ts}` 가 사라져
770
- 완주한 결과가 `contract-failed-unattributed` 로 폐기됐다. 그 경로는 위반에서
771
- 빼고 경고로 남긴다. 전환으로 설명되지 않는 추적 파일 변경은 그대로 위반이다.
761
+ ) -> tuple[list[str], set[str], set[str], set[str]]:
762
+ """위반·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
763
+
764
+ 작업트리와 기본 체크아웃이 다르면 기본 체크아웃 소스의 변경 주체는
765
+ 전후 비교로 판단할 수 없다. 배정된 산출물 밖의 변경은 관측으로 남기고,
766
+ `.okstra` 내부의 허용 밖 쓰기와 배정된 소스 정책은 별도로 검사한다.
772
767
  """
773
768
  allowed = _allowed_artifact_paths(policies)
774
769
  orchestrator = {
@@ -799,9 +794,14 @@ def _artifact_policy_failures(
799
794
  if path in unauthorized and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
800
795
  }
801
796
  untracked_outside -= switched
802
- 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
803
803
  failures = ["artifact-root change exceeds batch policy union"] if violating else []
804
- return failures, untracked_outside, switched
804
+ return failures, untracked_outside, switched, external
805
805
 
806
806
 
807
807
  def _branch_switch_paths(
@@ -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(
@@ -84,6 +84,10 @@ def snapshots_json_path(home: Path, manager_id: str, task_group: str, task_id: s
84
84
  return task_root(home, manager_id, task_group, task_id) / "snapshots.json"
85
85
 
86
86
 
87
+ def split_plan_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
88
+ return task_root(home, manager_id, task_group, task_id) / "split-plan.json"
89
+
90
+
87
91
  def events_jsonl_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
88
92
  return task_root(home, manager_id, task_group, task_id) / "events.jsonl"
89
93
 
@@ -99,3 +103,7 @@ def child_context_path(
99
103
  safe_project = _slug_segment(project_id, "project-id")
100
104
  safe_task = _slug_segment(child_task_id, "task-id")
101
105
  return task_root(home, manager_id, task_group, task_id) / "child-context" / f"{safe_project}-{safe_task}.md"
106
+
107
+
108
+ def view_html_path(home: Path, manager_id: str) -> Path:
109
+ return manager_root(home, manager_id) / "view" / "index.html"