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
@@ -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"),
@@ -125,6 +127,34 @@ 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": []})
@@ -135,21 +165,9 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
135
165
  for child in children.get("children", []):
136
166
  if not isinstance(child, dict):
137
167
  continue
138
- key = child.get("taskKey")
139
- snapshot_row = by_key.get(key)
168
+ snapshot_row = by_key.get(child.get("taskKey"))
140
169
  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
170
+ row["summaryBucket"] = "planned" if snapshot_row is None else summary_bucket(row)
171
+ summary[row["summaryBucket"]] += 1
154
172
  rows.append(row)
155
173
  return {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot}
@@ -0,0 +1,216 @@
1
+ """manager 상태를 한 장짜리 HTML 로 그린다.
2
+
3
+ manager 가 이미 저장한 파일(projects / task manifest / children / snapshots /
4
+ directives / events)만 읽는다. 하위 프로젝트는 읽지 않는다 — 최신 상태가 필요하면
5
+ 먼저 `okstra manager task sync` 를 돌리고, 페이지는 각 task 의 마지막 sync 시각을
6
+ 보여 준다.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import re
12
+ import shlex
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from okstra_ctl.jsonl import read_jsonl
17
+
18
+ from .manager_paths import directives_jsonl_path, events_jsonl_path, view_html_path
19
+ from .manager_store import ManagerError, list_projects, list_tasks, load_manager
20
+ from .manager_sync import status_task
21
+ from .paths import find_asset_root
22
+
23
+ TEMPLATE_RELATIVE = ("templates", "manager", "view.template.html")
24
+ _MARKER = re.compile(r"\{\{([A-Z]+)\}\}")
25
+ BUCKETS = ("planned", "missing", "running", "done", "blocked")
26
+
27
+
28
+ def _now_iso() -> str:
29
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
30
+
31
+
32
+ def _e(value: object) -> str:
33
+ return html.escape("" if value is None else str(value))
34
+
35
+
36
+ def _chip(bucket: str) -> str:
37
+ return f'<span class="chip {_e(bucket)}">{_e(bucket)}</span>'
38
+
39
+
40
+ def _table(headers: tuple[str, ...], rows: list[list[str]], empty: str) -> str:
41
+ if not rows:
42
+ return f'<p class="empty">{_e(empty)}</p>'
43
+ head = "".join(f"<th>{_e(header)}</th>" for header in headers)
44
+ body = "".join("<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>" for row in rows)
45
+ return f'<div class="scroll"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>'
46
+
47
+
48
+ def _file_link(project_root: str, path: str) -> str:
49
+ """하위 프로젝트 기준 상대 경로를 절대 file 링크로 바꾼다. 빈 값은 `-`."""
50
+ if not path:
51
+ return "-"
52
+ target = Path(path)
53
+ if not target.is_absolute() and project_root:
54
+ target = Path(project_root) / target
55
+ if not target.is_absolute():
56
+ return f"<code>{_e(path)}</code>"
57
+ return f'<a href="{_e(target.as_uri())}"><code>{_e(path)}</code></a>'
58
+
59
+
60
+ def _command(*args: str) -> str:
61
+ return shlex.join(["okstra", "manager", *args])
62
+
63
+
64
+ def _summary_tiles(totals: dict[str, int]) -> str:
65
+ tiles = "".join(
66
+ f'<div class="tile"><div class="count">{totals[bucket]}</div>'
67
+ f'<div class="label">{_e(bucket)}</div></div>'
68
+ for bucket in BUCKETS
69
+ )
70
+ return f'<div class="tiles">{tiles}</div>'
71
+
72
+
73
+ def _projects_table(projects: list[dict]) -> str:
74
+ rows = [
75
+ [
76
+ f"<code class=\"id\">{_e(project.get('projectId'))}</code>",
77
+ f"<code>{_e(project.get('projectRoot'))}</code>",
78
+ _e(project.get("role") or "-"),
79
+ _e(", ".join(project.get("tags") or []) or "-"),
80
+ _e(project.get("linkedAt") or "-"),
81
+ ]
82
+ for project in projects
83
+ ]
84
+ return _table(("Project", "Root", "Role", "Tags", "Linked at"), rows, "No projects registered.")
85
+
86
+
87
+ def _child_rows(children: list[dict], roots: dict[str, str]) -> list[list[str]]:
88
+ rows = []
89
+ for child in children:
90
+ launch = child.get("launch") if isinstance(child.get("launch"), dict) else {}
91
+ phase = child.get("currentPhase") or ""
92
+ phase_state = child.get("currentPhaseState") or ""
93
+ assignment = " — ".join(part for part in (child.get("role"), child.get("assignment")) if part)
94
+ # `task split` 하위 태스크는 assignment 대신 범위 목록을 가진다.
95
+ assignment = assignment or "; ".join(child.get("scope") or [])
96
+ rows.append(
97
+ [
98
+ f"<code>{_e(child.get('taskKey'))}</code>",
99
+ _e(child.get("ticketId") or "-"),
100
+ _chip(str(child.get("summaryBucket") or "planned")),
101
+ _e(child.get("workStatus") or "-"),
102
+ _e(f"{phase} ({phase_state})" if phase and phase_state else phase or "-"),
103
+ _e(child.get("finalVerdict") or "-"),
104
+ _e(launch.get("status") or "-"),
105
+ f'<span class="wrap">{_e(assignment or "-")}</span>',
106
+ _e(child.get("error") or "-"),
107
+ _file_link("", str(child.get("briefPath") or "")),
108
+ _file_link(
109
+ roots.get(str(child.get("projectId") or ""), ""),
110
+ str(child.get("latestReportRecordPath") or ""),
111
+ ),
112
+ ]
113
+ )
114
+ return rows
115
+
116
+
117
+ def _directive_list(directives: list[dict]) -> str:
118
+ if not directives:
119
+ return '<p class="empty">No directives.</p>'
120
+ items = "".join(
121
+ f"<li><strong>{_e(row.get('scope'))}"
122
+ f"{' · ' + _e(row.get('projectId')) if row.get('projectId') else ''}</strong>: "
123
+ f"{_e(row.get('body'))} <span class=\"meta\">{_e(row.get('createdAt'))}</span></li>"
124
+ for row in directives
125
+ )
126
+ return f'<ul class="plain">{items}</ul>'
127
+
128
+
129
+ def _event_list(events: list[dict]) -> str:
130
+ if not events:
131
+ return '<p class="empty">No events.</p>'
132
+ items = "".join(
133
+ f"<li><code>{_e(row.get('createdAt'))}</code> {_e(row.get('event'))}"
134
+ f"{' · ' + _e(row.get('taskKey')) if row.get('taskKey') else ''}</li>"
135
+ for row in events
136
+ )
137
+ return f'<ul class="plain">{items}</ul>'
138
+
139
+
140
+ def _task_panel(home: Path, manager_id: str, task: dict, status: dict, roots: dict[str, str]) -> str:
141
+ group, task_id = task["taskGroup"], task["taskId"]
142
+ synced_at = status["lastSnapshot"].get("syncedAt") or ""
143
+ sync_command = _command(
144
+ "task", "sync", "--manager-id", manager_id, "--task-group", group, "--task-id", task_id
145
+ )
146
+ counts = "".join(
147
+ f"{_chip(bucket)} {status['summary'][bucket]}"
148
+ for bucket in BUCKETS
149
+ if status["summary"][bucket]
150
+ )
151
+ head = (
152
+ f'<div class="task-head"><h3><code>{_e(group)} / {_e(task_id)}</code></h3>'
153
+ f'<div class="counts">{counts or "<span class=empty>no children</span>"}</div></div>'
154
+ )
155
+ meta = (
156
+ f'<div class="meta">Objective: {_e(task["objective"] or "-")} · '
157
+ f'Common brief: {_e(status["manifest"].get("commonBriefPath") or "-")} · '
158
+ f'Progress mode: {_e(task["progressMode"] or "-")}</div>'
159
+ f'<p class="hint">Last sync: {_e(synced_at or "never")} — refresh with '
160
+ f"<code>{_e(sync_command)}</code>, then re-run the view command.</p>"
161
+ )
162
+ children = _table(
163
+ ("Child", "Ticket", "Summary", "Work status", "Phase", "Verdict", "Launch", "Assignment", "Error",
164
+ "Brief", "Latest report"),
165
+ _child_rows(status["children"], roots),
166
+ "No child tasks.",
167
+ )
168
+ directives = read_jsonl(directives_jsonl_path(home, manager_id, group, task_id))
169
+ events = read_jsonl(events_jsonl_path(home, manager_id, group, task_id))
170
+ details = (
171
+ f"<details><summary>Directives ({len(directives)})</summary>{_directive_list(directives)}</details>"
172
+ f"<details><summary>Events ({len(events)})</summary>{_event_list(events)}</details>"
173
+ )
174
+ return f'<div class="panel">{head}{meta}{children}{details}</div>'
175
+
176
+
177
+ def render_view(home: Path, manager_id: str, *, now: str | None = None) -> str:
178
+ """manager 한 개의 HTML 페이지 본문을 돌려준다."""
179
+ manager = load_manager(home, manager_id)
180
+ root = find_asset_root(TEMPLATE_RELATIVE)
181
+ if root is None:
182
+ raise ManagerError("manager view template not found: " + "/".join(TEMPLATE_RELATIVE))
183
+ template = root.joinpath(*TEMPLATE_RELATIVE).read_text(encoding="utf-8")
184
+ projects = list_projects(home, manager_id)
185
+ roots = {str(project.get("projectId")): str(project.get("projectRoot") or "") for project in projects}
186
+ tasks = list_tasks(home, manager_id)
187
+ totals = dict.fromkeys(BUCKETS, 0)
188
+ panels = []
189
+ for task in tasks:
190
+ status = status_task(home, manager_id, task["taskGroup"], task["taskId"])
191
+ for bucket in BUCKETS:
192
+ totals[bucket] += status["summary"][bucket]
193
+ panels.append(_task_panel(home, manager_id, task, status, roots))
194
+ meta = (
195
+ f"Created {_e(manager.get('createdAt') or '-')} · Generated {_e(now or _now_iso())} · "
196
+ f"{len(projects)} projects · {len(tasks)} manager tasks"
197
+ )
198
+ replacements = {
199
+ "TITLE": _e(f"Okstra manager · {manager.get('managerId') or manager_id}"),
200
+ "META": meta,
201
+ "SUMMARY": _summary_tiles(totals),
202
+ "PROJECTS": _projects_table(projects),
203
+ "TASKS": "".join(panels) or '<p class="empty">No manager tasks.</p>',
204
+ }
205
+ # 한 번에 치환한다. 차례로 바꾸면 앞에서 넣은 사용자 값 속 `{{TASKS}}` 같은
206
+ # 문자열이 뒤 치환에 다시 걸린다.
207
+ return _MARKER.sub(lambda match: replacements[match.group(1)], template)
208
+
209
+
210
+ def write_view(home: Path, manager_id: str, *, now: str | None = None) -> dict:
211
+ """페이지를 `managers/<id>/view/index.html` 에 쓰고 경로를 돌려준다."""
212
+ page = render_view(home, manager_id, now=now)
213
+ path = view_html_path(home, manager_id)
214
+ path.parent.mkdir(parents=True, exist_ok=True)
215
+ path.write_text(page, encoding="utf-8")
216
+ return {"managerId": manager_id, "viewPath": str(path), "viewUrl": path.as_uri()}
@@ -26,6 +26,7 @@ from .convergence_store import write_json_atomic
26
26
  from .incremental_carry import sync_prepared_dispatch_queue
27
27
  from .json_boundary import JsonBoundaryError, load_owned_object
28
28
  from .report_finalize import task_manifest_path
29
+ from .qa_commands import split_verification_command
29
30
  from .plan_derivations import extract_tokens, find_derivations
30
31
  from .plan_items import (
31
32
  NextDispatch,
@@ -577,7 +578,11 @@ def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[st
577
578
  if isinstance(value, Mapping):
578
579
  raise PlanItemContractError(f"plan item {key} must be scalar")
579
580
  if key in {"command", "commandOrObservation"} and isinstance(value, str):
580
- return _render_literal(_label(key), value, indent)
581
+ command, annotation = split_verification_command(value)
582
+ rows = _render_literal(_label(key), command, indent)
583
+ if annotation is not None:
584
+ rows.append(indent + line("Command annotation (not executable)", annotation))
585
+ return rows
581
586
  return [indent + line(_label(key), value)]
582
587
 
583
588
 
@@ -98,6 +98,21 @@ def find_unfrozen_installs(cmd: str) -> list[str]:
98
98
  return found
99
99
 
100
100
 
101
+ def split_verification_command(command: str) -> tuple[str, str | None]:
102
+ """기존 계획의 파일 인용 설명만 분리하며 일반 셸 구문은 보존한다."""
103
+ match = re.search(r";\s*(existing helper:\s*[^;&|<>$`\n]+:\d+(?:-\d+)?)\s*$", command)
104
+ if match is None:
105
+ return command, None
106
+ executable = command[:match.start()].rstrip()
107
+ try:
108
+ words = shlex.split(executable)
109
+ except ValueError:
110
+ return command, None
111
+ if not words:
112
+ return command, None
113
+ return executable, match.group(1).strip()
114
+
115
+
101
116
  def verification_command_defects(command: str) -> list[str]:
102
117
  """선언된 작업 폴더를 셸에서 다른 체크아웃으로 바꾸는 명령을 거부한다."""
103
118
  try:
@@ -737,6 +737,8 @@ def _closeout_report_paths(ctx: FinalizeContext) -> dict[str, Any]:
737
737
  답장을 마크다운으로 그리므로 괄호 안에 경로가 들어간 형태만 클릭되고,
738
738
  백틱으로 감싼 경로는 글자로만 남아 사용자가 직접 옮겨 적어야 한다. 리드가
739
739
  그 형태를 매번 다시 만들면 어긋날 자리가 생기므로 완성된 문자열을 준다.
740
+ 링크 목적지는 절대 경로다 — 프로젝트 기준 상대 경로는 호스트 cwd 가 다르거나
741
+ 외부 프로그램으로 열 때 해석되지 않는다. 공백이 든 경로는 `<...>` 로 감싼다.
740
742
  """
741
743
  def rel(path: Path) -> str:
742
744
  try:
@@ -744,17 +746,22 @@ def _closeout_report_paths(ctx: FinalizeContext) -> dict[str, Any]:
744
746
  except ValueError:
745
747
  return str(path)
746
748
 
747
- paths = {
748
- "humanReport": rel(html_view_path(ctx.data_path)),
749
- "reportRecord": rel(ctx.data_path),
750
- "teamState": rel(ctx.team_state_path),
749
+ def link_target(path: Path) -> str:
750
+ target = path.resolve().as_posix()
751
+ return f"<{target}>" if " " in target else target
752
+
753
+ sources = {
754
+ "humanReport": html_view_path(ctx.data_path),
755
+ "reportRecord": ctx.data_path,
756
+ "teamState": ctx.team_state_path,
751
757
  }
758
+ paths = {key: rel(path) for key, path in sources.items()}
752
759
  return {
753
760
  **paths,
754
761
  "renderFullCopy": f"okstra render-final-report {paths['reportRecord']}",
755
762
  "markdown": {
756
- key: f"[{Path(value).name}]({value})"
757
- for key, value in paths.items()
763
+ key: f"[{path.name}]({link_target(path)})"
764
+ for key, path in sources.items()
758
765
  },
759
766
  }
760
767
 
@@ -3,8 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from ...release_gate import release_handoff_allowed
5
5
  from ..common import evidence_index
6
- from ..models import HumanReportView, VisualNode
7
- from ..visualizations import coverage_figure
6
+ from ..models import HumanReportView
8
7
 
9
8
  # Record fields this template anchors as `id-<row id>` (see
10
9
  # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
@@ -17,25 +16,8 @@ ANCHORED_FIELDS = (
17
16
  )
18
17
 
19
18
 
20
- def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
21
- return tuple(
22
- VisualNode(
23
- row["id"],
24
- row["requirement"],
25
- "requirement",
26
- row["status"],
27
- row["artifact"],
28
- note=row["status"],
29
- )
30
- for row in final["validationEvidence"]
31
- )
32
-
33
-
34
19
  def build_final_verification_view(data: dict) -> HumanReportView:
35
20
  final = data["finalVerification"]
36
- figure = coverage_figure(
37
- rows=_coverage_nodes(final), title="Requirement verification coverage"
38
- )
39
21
  verdict_token = data["finalVerdict"]["verdictToken"]
40
22
  context = {
41
23
  "humanSummary": data["humanSummary"],
@@ -44,7 +26,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
44
26
  "finalVerdict": data["finalVerdict"],
45
27
  "final": final,
46
28
  "narrative": final["userNarrative"],
47
- "coverageFigure": figure,
48
29
  "releaseAllowed": release_handoff_allowed(data),
49
30
  "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
50
31
  }
@@ -52,6 +33,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
52
33
  "final-verification",
53
34
  "html/tasks/final-verification.template.html",
54
35
  context,
55
- (figure,),
36
+ (),
56
37
  anchored_fields=ANCHORED_FIELDS,
57
38
  )
@@ -356,11 +356,6 @@ def matrix_figure(*, items: Iterable[VisualNode], title: str, x_label: str, y_la
356
356
  return replace(dependency_figure(nodes=nodes, edges=(), title=title), kind="matrix", figure_id="matrix", summary=summary)
357
357
 
358
358
 
359
- def coverage_figure(*, rows: Iterable[VisualNode], title: str) -> FigureModel:
360
- nodes = tuple(rows)
361
- return replace(dependency_figure(nodes=nodes, edges=(), title=title), kind="coverage", figure_id="coverage")
362
-
363
-
364
359
  def timeline_figure(*, events: Iterable[VisualNode], title: str) -> FigureModel:
365
360
  nodes = tuple(events)
366
361
  edges = tuple(VisualEdge(nodes[i].id, nodes[i + 1].id, "next", "sequence") for i in range(len(nodes) - 1))
@@ -13,13 +13,14 @@ import hashlib
13
13
  import argparse
14
14
  import json
15
15
  import re
16
+ import shlex
16
17
  import subprocess
17
18
  from pathlib import Path
18
19
 
19
20
  from .execution_mutation_audit import source_content_snapshot
20
21
  from .json_boundary import load_owned_object
21
22
  from .path_hints import hydrate_active_run_context
22
- from .qa_commands import verification_command_defects
23
+ from .qa_commands import split_verification_command, verification_command_defects
23
24
 
24
25
  TARGET_FIELD_RES = {
25
26
  "scope": re.compile(r"\*\*Verification scope:\*\*\s*`([^`]*)`"),
@@ -95,6 +96,13 @@ def capture_verification_target(
95
96
  if not target or not Path(target).is_absolute():
96
97
  raise ValueError("active run context has no absolute verification worktree")
97
98
  worktree = Path(target).resolve()
99
+ declared_command = command
100
+ command, annotation = split_verification_command(command)
101
+ prefix = re.match(r"^\s*cd\s+('[^']*'|\"[^\"]*\"|[^\s;&|]+)\s*&&\s*", command)
102
+ if prefix is not None:
103
+ directory = shlex.split(prefix.group(1))[0]
104
+ if Path(directory).is_absolute() and Path(directory).resolve() == worktree:
105
+ command = command[prefix.end():]
98
106
  defects = verification_command_defects(command)
99
107
  if defects:
100
108
  raise ValueError("; ".join(defects))
@@ -105,11 +113,14 @@ def capture_verification_target(
105
113
  raise ValueError(f"verification target mismatch: expected HEAD {expected_head}, actual {head}, root {actual_root}")
106
114
  files = source_content_snapshot(worktree, frozenset({".okstra"}))
107
115
  digest = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
108
- return {
116
+ result = {
109
117
  "schemaVersion": "1.0", "runManifest": str(manifest_path),
110
118
  "taskKey": manifest["taskKey"], "cwd": str(worktree),
111
119
  "head": head, "sourceDigest": digest, "command": command,
112
120
  }
121
+ if declared_command != command:
122
+ result.update(declaredCommand=declared_command, commandAnnotation=annotation)
123
+ return result
113
124
 
114
125
 
115
126
  def main(argv: list[str] | None = None) -> int:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: okstra-manager
3
- description: Use when the user wants to manage okstra work across multiple project roots, register or discover projects under a manager, create or update a shared manager task, sync project child-task status into manager state, or launch a child task from manager context. Trigger words include "okstra manager", "okstra-manager", "multiple projects", "cross-project", "group projects together", "manager task".
3
+ description: Use when the user wants to manage okstra work across multiple project roots, register or discover projects under a manager, create or update a shared manager task, sync project child-task status into manager state, split a Linear project or issue into per-project scoped briefs, or launch a child task from manager context. Trigger words include "okstra manager", "okstra-manager", "multiple projects", "cross-project", "group projects together", "manager task", "split this Linear project", "brief per project".
4
4
  ---
5
5
 
6
6
  # OKSTRA Manager
@@ -28,19 +28,68 @@ okstra manager task sync --manager-id <manager-id> --task-group <task-group> --t
28
28
  okstra manager task assign --manager-id <manager-id> --task-group <task-group> --task-id <task-id> --project-id <project-id> [--child-task-id <child-task-id>] [--role <role>] [--tag <tag>] [--assignment <text>]
29
29
  okstra manager task note --manager-id <manager-id> --task-group <task-group> --task-id <task-id> --scope <shared|project> [--project-id <project-id>] --body <text>
30
30
  okstra manager task run --manager-id <manager-id> --project-id <project-id> --task-group <task-group> --task-id <task-id> [--child-task-id <child-task-id>]
31
+ okstra manager task split --manager-id <manager-id> --task-group <task-group> --task-id <task-id> --plan <split-plan.json> [--overwrite]
32
+ okstra manager list managers
33
+ okstra manager list projects --manager-id <manager-id>
34
+ okstra manager list tasks --manager-id <manager-id>
35
+ okstra manager view --manager-id <manager-id>
31
36
  ```
32
37
 
33
38
  - Public child task identity is `project-id:task-group:task-id`.
34
39
  - `okstra manager new task --task ...` should prefer the full child key form above. The CLI also accepts shorthand under the command's `--task-group`, but the full key is the public form to show users.
35
40
  - When a manager task id differs from the actual child task id for a specific project, pass `--child-task-id <child-task-id>` to `task assign` and `task run`.
36
41
 
42
+ ## Tracker Split
43
+
44
+ Use this when one Linear project, or one parent issue, covers work in several registered projects and each project needs its own brief. A Linear project can point at several projects, and one issue can too; every (issue, project) pair gets its own brief and child task.
45
+
46
+ 1. Run `okstra manager list projects --manager-id <manager-id>`. These are the only valid targets. Register a missing repo with `okstra manager new project ...` first.
47
+ 2. Fetch from the Linear MCP: the project (`get_project`) and its issues (`list_issues` filtered by that project), then each issue with `get_issue`, including sub-issues and blocking or related links. If the Linear tools are missing, load them once with `ToolSearch`; if they are still missing, ask the user to paste the bodies. Never invent ticket content.
48
+ 3. For each issue, propose target projects from evidence: labels, team, repository names or paths in the body. Confirm with `AskUserQuestion` (multi-select, recommended projects first, reason in each description).
49
+ 4. For each (issue, project) pair, draft the scope: the part of the issue this project implements. Confirm it with `AskUserQuestion` offering one or two drafts plus a custom-input option. A pair without scope is rejected by the CLI.
50
+ 5. Author each pair's brief fields under the okstra-brief-gen brief contract: bodies verbatim, `EB-NNN` / `PB-NNN` / `EO-NNN` items as `<id> <observable condition> — verify: <how>`, anything without an observation method under `externalGates`, and `openQuestions` rows prefixed `general:`, `terminology:`, `intent-check:`, `conversion-block:` or `adr-candidate:`.
51
+ 6. Write the plan JSON below with the Write tool to a scratch path outside the project roots, then run `okstra manager task split ... --plan <path>`.
52
+ - `split plan rejected` or `rendered briefs failed validate-brief`: fix the named fields and run it again. Nothing was written.
53
+ - `briefs already exist with different content`: show the listed paths and ask before adding `--overwrite`, which replaces hand edits. Adding issues to an earlier split changes every brief's Related Task Graph, so existing briefs are listed too.
54
+ 7. Each `Brief N task key` is `<project-id>:<task-group>:<child-task-id>`. Start one with `okstra manager task run ... --project-id <project-id> --child-task-id <child-task-id>` and follow the Child Launch Rule. The packet already carries `--task-brief`, and `--task-type` until the child task exists.
55
+
56
+ Plan JSON (`schemaVersion` 1). `source.kind` is `project` or `issue`; `recommendedPhase` is `requirements-discovery` (default), `error-analysis` or `improvement-discovery`; `relations[].relation` uses the brief Related Task Graph values; list fields may be omitted.
57
+
58
+ ```json
59
+ {
60
+ "schemaVersion": 1,
61
+ "source": {"kind": "project", "ref": "<Linear URL>", "title": "<title>", "fetchedVia": "<tool>", "fetchedAt": "YYYY-MM-DD HH:MM", "body": "<verbatim>"},
62
+ "issues": [
63
+ {
64
+ "ticketId": "LIN-12", "title": "<title>", "url": "<Linear URL>", "fetchedVia": "<tool>", "fetchedAt": "YYYY-MM-DD HH:MM", "body": "<verbatim>",
65
+ "relations": [{"relation": "blocked-by", "to": "LIN-11", "source": "<where the link came from>", "impact": "<what the next phase must keep>"}],
66
+ "assignments": [
67
+ {
68
+ "projectId": "<registered project id>", "scope": ["<work in this project>"], "outOfScope": [],
69
+ "recommendedPhase": "requirements-discovery",
70
+ "context": "<text>", "problem": "<text>", "desiredOutcome": "<text>",
71
+ "expectedBehavior": [], "preservedBehavior": [], "expectedOutcome": [],
72
+ "externalGates": [], "constraints": [], "openQuestions": []
73
+ }
74
+ ]
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ The CLI writes each brief to `<projectRoot>/.okstra/briefs/<task-group>/<ticketId>-<file-title>.md` with a `## Project Scope` section: this project's scope, `outOfScope`, and the scope of every other project the same issue went to. It validates every brief with the brief validator before writing any file, registers the child tasks, and keeps the plan at `split-plan.json` in the manager task directory.
81
+
82
+ ## Overview Page
83
+
84
+ When the user wants to see a manager at a glance, run `okstra manager task sync ...` for each task listed by `okstra manager list tasks ...` whose snapshot must be current, then run `okstra manager view --manager-id <manager-id>` and give the user the returned `View URL`. The page reads manager files only, so each task shows the state of its last sync and prints its own sync command.
85
+
37
86
  ## Child Launch Rule
38
87
 
39
88
  For launching child work:
40
89
 
41
90
  1. Run `okstra manager task sync ...` if the manager snapshot must be refreshed first.
42
91
  2. Run `okstra manager task run ...`.
43
- 3. Read the returned fixed fields `Backend`, `Worker dispatch backend`, `Project root`, `Context path`, and every numbered `Run arg N`.
44
- 4. Use that launch packet for the host-native child lead handoff.
92
+ 3. Read the returned fixed fields `Backend`, `Worker dispatch backend`, `Project root`, `Context path`, `Run command`, every numbered `Run arg N`, and `Shell command`.
93
+ 4. Give the user the `Shell command` line verbatim and tell them to run it in a new terminal. It starts the installed launcher in the child project: the launcher fills the task type and brief from the child task manifest or asks for them, prepares the run with the manager context directive, and starts the child lead as a separate host process (`Backend` `spawn-process`).
45
94
 
46
- The launch packet remains the source of truth. Do not rebuild child args by hand.
95
+ Do not run the `Shell command` through this session's Bash tool: it opens an interactive host session. Do not rebuild it from `Run arg N` or swap in `okstra run`, which drops the task inputs and the directive for a lead host.
@@ -491,6 +491,6 @@ Follow the rendered launch prompt's "Progress, remaining work, and recommendatio
491
491
  ## Output Rules
492
492
 
493
493
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
494
- - Name every file you show the user as a markdown link — `[<what it is>](<path>)`, with the path inside the parentheses. That is the only form the host renders as clickable; a path in backticks is text the user has to copy out. The `report-finalize` result's `reportPaths.markdown` carries the run's report, report record, and team state already in that form. Commands stay in backticks — a link is for a file, not for something to run.
494
+ - Name every file you show the user as a markdown link — `[<short label>](<absolute path>)`, with the absolute path inside the parentheses and a short label naming what the file is. A relative destination does not open from an external program. That is the only form the host renders as clickable; a path in backticks is text the user has to copy out. The `report-finalize` result's `reportPaths.markdown` carries the run's report, report record, and team state already in that form. Commands stay in backticks — a link is for a file, not for something to run.
495
495
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
496
496
  - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, finish after Step 6 and any same-run recovery, unless the user has already authorized continuing the task through further phases. In an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When `report-finalize` returns `recovery.mode: same-run`, continue the authorized corrections in this run and execute `recovery.resumeCommand` before closeout; preserve approvals and model choices without reopening the wizard. The command and owner issues are supplied by `report_finalize._finalize_recovery`. When the lead (or this skill, after the lead returns) reports a successfully finalized run over, close with the user's next action — one command they can run now. A prohibition is not a next action. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed with `recovery.mode: phase-reentry` → name the cause and use `nextCommand` for the recorded earlier phase; otherwise `/okstra-inspect status`.
@@ -140,6 +140,15 @@ Then create the file — paste the literal `projectRoot` from Step 2 and the lit
140
140
  okstra setup --yes --project-root /abs/path/to/projectRoot --project-id my-project-id
141
141
  ```
142
142
 
143
+ `okstra setup` also refreshes the okstra-managed citation-guidance block in
144
+ `<PROJECT_ROOT>/CLAUDE.md` and `AGENTS.md` when those files already exist (it never
145
+ creates them). The block tells agents not to carry okstra-internal references — report
146
+ section numbers, `C-NNN` clarification ids, run/stage ids, `.okstra/...` paths — into
147
+ writing that is not an okstra report, where the reader cannot resolve them. The command
148
+ reports the files it touched in its JSON `citationGuidance` array; a failure there is a
149
+ `warning:` line, not a non-zero exit. Tell the user which guidance files were updated so
150
+ they can review the appended block.
151
+
143
152
  ## Step 3.5 (optional): project customisation
144
153
 
145
154
  The built-in defaults work for most projects — skip straight to Step 4