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.
- package/bin/okstra +1 -1
- package/dist/commands/lifecycle/setup.mjs +15 -0
- package/dist/commands/lifecycle/setup.mjs.map +1 -1
- package/dist/lib/citation-guidance.d.mts +21 -0
- package/dist/lib/citation-guidance.mjs +79 -0
- package/dist/lib/citation-guidance.mjs.map +1 -0
- package/docs/architecture/storage-model.md +4 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/for-ai/skills/okstra-manager.md +21 -4
- package/docs/for-ai/skills/okstra-setup.md +9 -0
- package/docs/project-structure-overview.md +11 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +15 -3
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/python/okstra_ctl/agent/evidence_recovery.py +78 -0
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
- package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
- package/runtime/python/okstra_ctl/dispatch_core.py +91 -17
- package/runtime/python/okstra_ctl/execution_identity.py +9 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +6 -2
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +134 -65
- package/runtime/python/okstra_ctl/manager_cli.py +85 -12
- package/runtime/python/okstra_ctl/manager_launch.py +40 -18
- package/runtime/python/okstra_ctl/manager_paths.py +8 -0
- package/runtime/python/okstra_ctl/manager_split.py +474 -0
- package/runtime/python/okstra_ctl/manager_store.py +121 -18
- package/runtime/python/okstra_ctl/manager_sync.py +33 -15
- package/runtime/python/okstra_ctl/manager_view.py +216 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
- package/runtime/python/okstra_ctl/qa_commands.py +15 -0
- package/runtime/python/okstra_ctl/report_finalize.py +13 -6
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
- package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
- package/runtime/python/okstra_ctl/verification_target.py +13 -2
- package/runtime/skills/okstra-manager/SKILL.md +53 -4
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +9 -0
- package/runtime/templates/manager/view.template.html +108 -0
- package/runtime/templates/reports/html/i18n/en.json +2 -0
- package/runtime/templates/reports/html/i18n/ko.json +2 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
- package/runtime/validators/validate-brief.py +7 -2
- 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 =
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
139
|
-
snapshot_row = by_key.get(key)
|
|
168
|
+
snapshot_row = by_key.get(child.get("taskKey"))
|
|
140
169
|
row = {**child, **(snapshot_row or {})}
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
"
|
|
750
|
-
|
|
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"[{
|
|
757
|
-
for key,
|
|
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
|
|
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
|
-
(
|
|
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))
|