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
@@ -96,6 +96,63 @@ def load_manager(home: Path, manager_id: str) -> dict:
96
96
  return _read_json(path)
97
97
 
98
98
 
99
+ def _task_manifest_paths(home: Path, manager_id: str) -> list[Path]:
100
+ return sorted(manager_root(home, manager_id).glob("task-groups/*/*/manifest.json"))
101
+
102
+
103
+ def list_managers(home: Path) -> list[dict]:
104
+ """이 홈의 manager 를 id 순으로, 소속 프로젝트 수와 manager task 수와 함께 돌려준다."""
105
+ base = Path(home) / "managers"
106
+ if not base.is_dir():
107
+ return []
108
+ items: list[dict] = []
109
+ for path in sorted(base.glob("*/manager.json")):
110
+ manager = _read_json(path)
111
+ manager_id = str(manager.get("managerId") or path.parent.name)
112
+ items.append(
113
+ {
114
+ "managerId": manager_id,
115
+ "createdAt": str(manager.get("createdAt") or ""),
116
+ "projectCount": len(_load_projects(home, manager_id).get("projects", [])),
117
+ "taskCount": len(_task_manifest_paths(home, manager_id)),
118
+ }
119
+ )
120
+ return items
121
+
122
+
123
+ def list_projects(home: Path, manager_id: str) -> list[dict]:
124
+ """manager 에 등록된 프로젝트를 등록 파일(projects.json) 순서 그대로 돌려준다."""
125
+ load_manager(home, manager_id)
126
+ return [
127
+ project
128
+ for project in _load_projects(home, manager_id).get("projects", [])
129
+ if isinstance(project, dict)
130
+ ]
131
+
132
+
133
+ def list_tasks(home: Path, manager_id: str) -> list[dict]:
134
+ """manager task 를 task-group, task-id 순으로 하위 task 수와 함께 돌려준다.
135
+
136
+ 디렉터리 이름은 slug 라 원래 입력값과 다를 수 있으므로 식별자는 manifest 에서 읽는다.
137
+ """
138
+ load_manager(home, manager_id)
139
+ items: list[dict] = []
140
+ for path in _task_manifest_paths(home, manager_id):
141
+ manifest = _read_json(path)
142
+ children = _read_json_default(path.parent / "children.json", {"children": []})
143
+ items.append(
144
+ {
145
+ "taskGroup": str(manifest.get("taskGroup") or ""),
146
+ "taskId": str(manifest.get("taskId") or ""),
147
+ "objective": str(manifest.get("objective") or ""),
148
+ "progressMode": str(manifest.get("progressMode") or ""),
149
+ "createdAt": str(manifest.get("createdAt") or ""),
150
+ "childCount": len(children.get("children", [])),
151
+ }
152
+ )
153
+ return sorted(items, key=lambda item: (item["taskGroup"], item["taskId"]))
154
+
155
+
99
156
  def discover_projects(home: Path) -> list[dict]:
100
157
  base = Path(home) / "projects"
101
158
  if not base.is_dir():
@@ -174,6 +231,17 @@ def create_task_group(home: Path, manager_id: str, task_group: str, *, now: str
174
231
  return {"managerId": manager_id, "taskGroup": task_group, "createdAt": now or _now_iso()}
175
232
 
176
233
 
234
+ def _merge_manifest(existing: dict, requested: dict) -> None:
235
+ """이미 있는 manager task 의 manifest 값을 `new task` 가 바꾸지 못하게 한다."""
236
+ for key, value in requested.items():
237
+ if value is None or value == "" or existing.get(key) == value:
238
+ continue
239
+ raise ManagerError(
240
+ f"manager task already exists with {key}={existing.get(key)!r}; "
241
+ f"refusing to change it to {value!r}"
242
+ )
243
+
244
+
177
245
  def create_task(
178
246
  home: Path,
179
247
  manager_id: str,
@@ -183,32 +251,56 @@ def create_task(
183
251
  *,
184
252
  objective: str = "",
185
253
  common_brief_path: str = "",
186
- progress_mode: str = "manual",
254
+ progress_mode: str | None = None,
187
255
  now: str | None = None,
188
256
  ) -> dict:
257
+ """manager task 를 만들거나, 이미 있으면 새 하위 task 만 덧붙인다.
258
+
259
+ 다시 실행해도 기존 하위 task 의 assignment·launch 상태는 지우지 않는다.
260
+ """
261
+ load_manager(home, manager_id)
189
262
  projects = _load_projects(home, manager_id)
190
263
  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 = []
264
+ manifest_path = task_manifest_path(home, manager_id, task_group, task_id)
265
+ existing = manifest_path.is_file()
266
+ if existing:
267
+ manifest = _read_json(manifest_path)
268
+ _merge_manifest(
269
+ manifest,
270
+ {
271
+ "objective": objective,
272
+ "commonBriefPath": common_brief_path,
273
+ "progressMode": progress_mode,
274
+ },
275
+ )
276
+ else:
277
+ manifest = {
278
+ "managerId": manager_id,
279
+ "taskGroup": task_group,
280
+ "taskId": task_id,
281
+ "progressMode": progress_mode or "manual",
282
+ "commonBriefPath": common_brief_path,
283
+ "objective": objective,
284
+ "createdAt": when,
285
+ }
286
+ children = _load_children(home, manager_id, task_group, task_id)
287
+ known_keys = {child.get("taskKey") for child in children["children"] if isinstance(child, dict)}
288
+ added = []
201
289
  for spec in child_specs:
202
290
  project_id = str(spec.get("projectId") or "")
203
291
  child_task_id = str(spec.get("taskId") or task_id)
204
292
  if _project_entry(projects, project_id) is None:
205
293
  raise ManagerError(f"unknown project membership: {project_id}")
206
- children.append(
294
+ task_key = _child_task_key(project_id, task_group, child_task_id)
295
+ if task_key in known_keys:
296
+ continue
297
+ known_keys.add(task_key)
298
+ added.append(
207
299
  {
208
300
  "projectId": project_id,
209
301
  "taskGroup": task_group,
210
302
  "taskId": child_task_id,
211
- "taskKey": f"{project_id}:{task_group}:{child_task_id}",
303
+ "taskKey": task_key,
212
304
  "role": "",
213
305
  "tags": [],
214
306
  "assignment": "",
@@ -219,14 +311,19 @@ def create_task(
219
311
  },
220
312
  }
221
313
  )
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"])
314
+ children["children"].extend(added)
315
+ if not existing:
316
+ _write_json(manifest_path, manifest)
317
+ _write_json(children_json_path(home, manager_id, task_group, task_id), children)
225
318
  append_jsonl(
226
319
  events_jsonl_path(home, manager_id, task_group, task_id),
227
- {"event": "task-created", "createdAt": when},
320
+ {
321
+ "event": "task-children-added" if existing else "task-created",
322
+ "createdAt": when,
323
+ "taskKeys": [child["taskKey"] for child in added],
324
+ },
228
325
  )
229
- return payload
326
+ return {"manifest": manifest, "children": children}
230
327
 
231
328
 
232
329
  def _load_children(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
@@ -286,6 +383,7 @@ def assign_child(
286
383
  assignment: str,
287
384
  child_task_id: str | None = None,
288
385
  ) -> dict:
386
+ _read_json(task_manifest_path(home, manager_id, task_group, task_id))
289
387
  payload = _load_children(home, manager_id, task_group, task_id)
290
388
  child = _select_child_for_assignment(payload["children"], project_id, task_group, child_task_id)
291
389
  child["role"] = role
@@ -311,6 +409,11 @@ def append_directive(
311
409
  raise ManagerError("scope must be shared or project")
312
410
  if scope == "project" and not project_id:
313
411
  raise ManagerError("project directive requires project-id")
412
+ _read_json(task_manifest_path(home, manager_id, task_group, task_id))
413
+ if scope == "project":
414
+ children = _load_children(home, manager_id, task_group, task_id)["children"]
415
+ if not any(isinstance(child, dict) and child.get("projectId") == project_id for child in children):
416
+ raise ManagerError(f"project directive target has no child in this task: {project_id}")
314
417
  row = {
315
418
  "scope": scope,
316
419
  "projectId": project_id if scope == "project" else "",
@@ -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))