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.
- 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 +4 -1
- 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 +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -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/execution_mutation_audit.py +21 -21
- 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 +1 -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
|
@@ -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))
|
|
@@ -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
|
-
|
|
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`,
|
|
44
|
-
4.
|
|
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
|
-
|
|
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 — `[<
|
|
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
|