okstra 0.102.4 → 0.103.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/README.kr.md +3 -2
- package/README.md +3 -2
- package/docs/for-ai/skills/okstra-brief.md +18 -2
- package/docs/for-ai/skills/okstra-run.md +9 -1
- package/docs/kr/cli.md +1 -0
- package/docs/project-structure-overview.md +9 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -0
- package/runtime/prompts/profiles/error-analysis.md +2 -0
- package/runtime/prompts/profiles/improvement-discovery.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +4 -0
- package/runtime/prompts/wizard/prompts.ko.json +1 -1
- package/runtime/python/okstra_ctl/manager_cli.py +231 -0
- package/runtime/python/okstra_ctl/manager_launch.py +198 -0
- package/runtime/python/okstra_ctl/manager_paths.py +98 -0
- package/runtime/python/okstra_ctl/manager_store.py +318 -0
- package/runtime/python/okstra_ctl/manager_sync.py +144 -0
- package/runtime/python/okstra_ctl/wizard.py +105 -15
- package/runtime/skills/okstra-brief/SKILL.md +32 -7
- package/runtime/skills/okstra-manager/SKILL.md +44 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/reports/brief.template.md +25 -0
- package/runtime/validators/validate-brief.py +128 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/manager.mjs +57 -0
- package/src/lib/skill-catalog.mjs +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Read project-local okstra state into manager-owned snapshots."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from okstra_project import TASKS_RELATIVE, parse_task_key, read_task_catalog, read_task_manifest, slugify
|
|
7
|
+
|
|
8
|
+
from .manager_paths import children_json_path, projects_json_path, snapshots_json_path, task_manifest_path
|
|
9
|
+
from .manager_store import _now_iso, _read_json, _read_json_default, _write_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _project_map(home: Path, manager_id: str) -> dict[str, dict]:
|
|
13
|
+
projects = _read_json_default(projects_json_path(home, manager_id), {"projects": []})
|
|
14
|
+
return {
|
|
15
|
+
str(project.get("projectId")): project
|
|
16
|
+
for project in projects.get("projects", [])
|
|
17
|
+
if isinstance(project, dict) and project.get("projectId")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _phase_outcome(manifest: dict) -> dict:
|
|
22
|
+
value = manifest.get("phaseOutcome")
|
|
23
|
+
return value if isinstance(value, dict) else {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _workflow(manifest: dict) -> dict:
|
|
27
|
+
value = manifest.get("workflow")
|
|
28
|
+
return value if isinstance(value, dict) else {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _list_field(payload: dict, field: str) -> list:
|
|
32
|
+
value = payload.get(field)
|
|
33
|
+
return list(value) if isinstance(value, list) else []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_task_root_read_only(project_root: Path, task_key: str) -> Path | None:
|
|
37
|
+
_, task_group, task_id = parse_task_key(task_key)
|
|
38
|
+
requested_key = task_key.lower()
|
|
39
|
+
for entry in read_task_catalog(project_root):
|
|
40
|
+
entry_key = entry.get("taskKey") or ""
|
|
41
|
+
if not isinstance(entry_key, str) or entry_key.lower() != requested_key:
|
|
42
|
+
continue
|
|
43
|
+
relative_path = entry.get("taskRootPath") or entry.get("taskRoot") or ""
|
|
44
|
+
if not isinstance(relative_path, str) or not relative_path:
|
|
45
|
+
continue
|
|
46
|
+
candidate = project_root / relative_path if not Path(relative_path).is_absolute() else Path(relative_path)
|
|
47
|
+
if candidate.is_dir():
|
|
48
|
+
return candidate
|
|
49
|
+
slug_path = project_root / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
|
|
50
|
+
if slug_path.is_dir():
|
|
51
|
+
return slug_path
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _snapshot_child(project_root: Path, child: dict) -> dict:
|
|
56
|
+
task_key = str(child.get("taskKey") or "")
|
|
57
|
+
base = {
|
|
58
|
+
"projectId": str(child.get("projectId") or ""),
|
|
59
|
+
"taskGroup": str(child.get("taskGroup") or ""),
|
|
60
|
+
"taskId": str(child.get("taskId") or ""),
|
|
61
|
+
"taskKey": task_key,
|
|
62
|
+
"exists": False,
|
|
63
|
+
"taskRoot": "",
|
|
64
|
+
}
|
|
65
|
+
task_dir = _resolve_task_root_read_only(project_root, task_key)
|
|
66
|
+
if task_dir is None:
|
|
67
|
+
return base
|
|
68
|
+
manifest = read_task_manifest(task_dir) or {}
|
|
69
|
+
workflow = _workflow(manifest)
|
|
70
|
+
outcome = _phase_outcome(manifest)
|
|
71
|
+
return {
|
|
72
|
+
**base,
|
|
73
|
+
"exists": True,
|
|
74
|
+
"taskRoot": str(task_dir),
|
|
75
|
+
"taskType": str(manifest.get("taskType") or ""),
|
|
76
|
+
"currentPhase": str(workflow.get("currentPhase") or ""),
|
|
77
|
+
"currentPhaseState": str(workflow.get("currentPhaseState") or ""),
|
|
78
|
+
"lastCompletedPhase": str(workflow.get("lastCompletedPhase") or ""),
|
|
79
|
+
"nextRecommendedPhase": str(workflow.get("nextRecommendedPhase") or ""),
|
|
80
|
+
"latestRunStatus": str(manifest.get("latestRunStatus") or manifest.get("currentStatus") or ""),
|
|
81
|
+
"latestReportPath": str(manifest.get("latestReportPath") or ""),
|
|
82
|
+
"finalVerdict": str(outcome.get("finalVerdict") or ""),
|
|
83
|
+
"crossProjectDependencies": _list_field(outcome, "crossProjectDependencies"),
|
|
84
|
+
"recommendedNextSteps": _list_field(outcome, "recommendedNextSteps"),
|
|
85
|
+
"openClarifications": _list_field(outcome, "openClarifications"),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sync_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now: str | None = None) -> dict:
|
|
90
|
+
_read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
91
|
+
children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
92
|
+
projects = _project_map(home, manager_id)
|
|
93
|
+
rows = []
|
|
94
|
+
for child in children.get("children", []):
|
|
95
|
+
if not isinstance(child, dict):
|
|
96
|
+
continue
|
|
97
|
+
project = projects.get(str(child.get("projectId") or ""))
|
|
98
|
+
if project is None:
|
|
99
|
+
rows.append({**child, "exists": False, "error": "unknown project membership"})
|
|
100
|
+
continue
|
|
101
|
+
root = Path(str(project.get("projectRoot") or ""))
|
|
102
|
+
if not root.is_dir():
|
|
103
|
+
rows.append({**child, "exists": False, "error": f"missing project root: {root}"})
|
|
104
|
+
continue
|
|
105
|
+
rows.append(_snapshot_child(root, child))
|
|
106
|
+
payload = {
|
|
107
|
+
"managerId": manager_id,
|
|
108
|
+
"taskGroup": task_group,
|
|
109
|
+
"taskId": task_id,
|
|
110
|
+
"syncedAt": now or _now_iso(),
|
|
111
|
+
"children": rows,
|
|
112
|
+
}
|
|
113
|
+
_write_json(snapshots_json_path(home, manager_id, task_group, task_id), payload)
|
|
114
|
+
return payload
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
|
|
118
|
+
manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
119
|
+
children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
120
|
+
snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
121
|
+
by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
|
|
122
|
+
rows = []
|
|
123
|
+
summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0}
|
|
124
|
+
for child in children.get("children", []):
|
|
125
|
+
if not isinstance(child, dict):
|
|
126
|
+
continue
|
|
127
|
+
key = child.get("taskKey")
|
|
128
|
+
snapshot_row = by_key.get(key)
|
|
129
|
+
row = {**child, **(snapshot_row or {})}
|
|
130
|
+
status = str(row.get("latestRunStatus") or row.get("launch", {}).get("status") or "planned")
|
|
131
|
+
if snapshot_row is None:
|
|
132
|
+
summary["planned"] += 1
|
|
133
|
+
elif not row.get("exists", False):
|
|
134
|
+
summary["missing"] += 1
|
|
135
|
+
elif status == "done":
|
|
136
|
+
summary["done"] += 1
|
|
137
|
+
elif status in {"running", "started", "in-progress"}:
|
|
138
|
+
summary["running"] += 1
|
|
139
|
+
elif status == "blocked":
|
|
140
|
+
summary["blocked"] += 1
|
|
141
|
+
else:
|
|
142
|
+
summary["planned"] += 1
|
|
143
|
+
rows.append(row)
|
|
144
|
+
return {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot}
|
|
@@ -24,6 +24,7 @@ import math
|
|
|
24
24
|
import re
|
|
25
25
|
import subprocess
|
|
26
26
|
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
from typing import Any, Callable, Optional
|
|
29
30
|
|
|
@@ -228,6 +229,35 @@ def _project_relative_path(path: Path, project_root: Path) -> str:
|
|
|
228
229
|
return str(path)
|
|
229
230
|
|
|
230
231
|
|
|
232
|
+
def _project_relative_value(path_value: str, project_root: Path) -> str:
|
|
233
|
+
p = Path(path_value)
|
|
234
|
+
if p.is_absolute():
|
|
235
|
+
return _project_relative_path(p, project_root)
|
|
236
|
+
return str(p)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _parse_iso_timestamp(value: Any) -> float:
|
|
240
|
+
if not isinstance(value, str):
|
|
241
|
+
return 0.0
|
|
242
|
+
text = value.strip()
|
|
243
|
+
if not text:
|
|
244
|
+
return 0.0
|
|
245
|
+
if text.endswith("Z"):
|
|
246
|
+
text = f"{text[:-1]}+00:00"
|
|
247
|
+
try:
|
|
248
|
+
return datetime.fromisoformat(text).timestamp()
|
|
249
|
+
except ValueError:
|
|
250
|
+
return 0.0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _file_recency(path: Path) -> float:
|
|
254
|
+
try:
|
|
255
|
+
stat = path.stat()
|
|
256
|
+
except OSError:
|
|
257
|
+
return 0.0
|
|
258
|
+
return max(stat.st_mtime, float(getattr(stat, "st_birthtime", 0.0) or 0.0))
|
|
259
|
+
|
|
260
|
+
|
|
231
261
|
def _accept_brief_path(state: WizardState, path: Path) -> None:
|
|
232
262
|
"""Record a validated brief and derive safe identity suggestions.
|
|
233
263
|
|
|
@@ -1128,21 +1158,51 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
1128
1158
|
def _suggest_recent_task_groups(
|
|
1129
1159
|
state: WizardState, limit: int = _RECOMMENDATION_CAP
|
|
1130
1160
|
) -> list[str]:
|
|
1131
|
-
"""
|
|
1161
|
+
"""최근 task/brief 활동에서 task-group 후보를 limit 개까지 반환."""
|
|
1132
1162
|
if not state.project_root:
|
|
1133
1163
|
return []
|
|
1164
|
+
project_root = Path(state.project_root)
|
|
1165
|
+
scores: dict[str, float] = {}
|
|
1166
|
+
orders: dict[str, int] = {}
|
|
1134
1167
|
try:
|
|
1135
|
-
tasks = list_project_tasks(
|
|
1168
|
+
tasks = list_project_tasks(project_root)
|
|
1136
1169
|
except (OSError, StateError):
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
for entry in tasks:
|
|
1170
|
+
tasks = []
|
|
1171
|
+
for index, entry in enumerate(tasks):
|
|
1140
1172
|
tg = entry.get("taskGroup") or ""
|
|
1141
|
-
if
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1173
|
+
if not tg:
|
|
1174
|
+
continue
|
|
1175
|
+
scores[tg] = max(
|
|
1176
|
+
scores.get(tg, 0.0),
|
|
1177
|
+
_parse_iso_timestamp(entry.get("updatedAt")),
|
|
1178
|
+
)
|
|
1179
|
+
orders[tg] = min(orders.get(tg, index), index)
|
|
1180
|
+
|
|
1181
|
+
brief_root = project_root / ".okstra" / "briefs"
|
|
1182
|
+
if brief_root.is_dir():
|
|
1183
|
+
next_order = len(orders)
|
|
1184
|
+
for group_dir in brief_root.iterdir():
|
|
1185
|
+
if not group_dir.is_dir():
|
|
1186
|
+
continue
|
|
1187
|
+
latest_brief = max(
|
|
1188
|
+
(
|
|
1189
|
+
_file_recency(path)
|
|
1190
|
+
for path in group_dir.rglob("*.md")
|
|
1191
|
+
if path.is_file()
|
|
1192
|
+
),
|
|
1193
|
+
default=0.0,
|
|
1194
|
+
)
|
|
1195
|
+
if latest_brief <= 0.0:
|
|
1196
|
+
continue
|
|
1197
|
+
group = group_dir.name
|
|
1198
|
+
scores[group] = max(scores.get(group, 0.0), latest_brief)
|
|
1199
|
+
orders.setdefault(group, next_order)
|
|
1200
|
+
next_order += 1
|
|
1201
|
+
|
|
1202
|
+
return sorted(
|
|
1203
|
+
scores,
|
|
1204
|
+
key=lambda group: (-scores[group], orders[group], group),
|
|
1205
|
+
)[:limit]
|
|
1146
1206
|
|
|
1147
1207
|
|
|
1148
1208
|
def _suggest_recent_task_ids(
|
|
@@ -1168,6 +1228,37 @@ def _suggest_recent_task_ids(
|
|
|
1168
1228
|
return seen
|
|
1169
1229
|
|
|
1170
1230
|
|
|
1231
|
+
def _recently_used_brief_times(state: WizardState) -> dict[str, float]:
|
|
1232
|
+
if not state.project_root or not state.task_group:
|
|
1233
|
+
return {}
|
|
1234
|
+
project_root = Path(state.project_root)
|
|
1235
|
+
try:
|
|
1236
|
+
tasks = list_project_tasks(project_root)
|
|
1237
|
+
except (OSError, StateError):
|
|
1238
|
+
return {}
|
|
1239
|
+
out: dict[str, float] = {}
|
|
1240
|
+
for entry in tasks:
|
|
1241
|
+
if entry.get("taskGroup") != state.task_group:
|
|
1242
|
+
continue
|
|
1243
|
+
task_root = entry.get("_resolvedTaskRoot") or ""
|
|
1244
|
+
manifest = read_task_manifest(Path(task_root)) if task_root else None
|
|
1245
|
+
brief_path = ""
|
|
1246
|
+
if isinstance(manifest, dict):
|
|
1247
|
+
value = manifest.get("taskBriefPath")
|
|
1248
|
+
brief_path = value if isinstance(value, str) else ""
|
|
1249
|
+
if not brief_path:
|
|
1250
|
+
value = entry.get("taskBriefPath")
|
|
1251
|
+
brief_path = value if isinstance(value, str) else ""
|
|
1252
|
+
if not brief_path:
|
|
1253
|
+
continue
|
|
1254
|
+
relpath = _project_relative_value(brief_path, project_root)
|
|
1255
|
+
out[relpath] = max(
|
|
1256
|
+
out.get(relpath, 0.0),
|
|
1257
|
+
_parse_iso_timestamp(entry.get("updatedAt")),
|
|
1258
|
+
)
|
|
1259
|
+
return out
|
|
1260
|
+
|
|
1261
|
+
|
|
1171
1262
|
def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
|
|
1172
1263
|
"""Return recent okstra-brief outputs for the selected task-group.
|
|
1173
1264
|
|
|
@@ -1181,6 +1272,7 @@ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
|
|
|
1181
1272
|
root = project_root / ".okstra" / "briefs" / state.task_group
|
|
1182
1273
|
if not root.is_dir():
|
|
1183
1274
|
return []
|
|
1275
|
+
used_at_by_relpath = _recently_used_brief_times(state)
|
|
1184
1276
|
candidates: list[tuple[float, str]] = []
|
|
1185
1277
|
for path in root.rglob("*.md"):
|
|
1186
1278
|
if not path.is_file():
|
|
@@ -1192,11 +1284,9 @@ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
|
|
|
1192
1284
|
continue
|
|
1193
1285
|
except WizardError:
|
|
1194
1286
|
continue
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
mtime = 0.0
|
|
1199
|
-
candidates.append((mtime, _project_relative_path(path, project_root)))
|
|
1287
|
+
relpath = _project_relative_path(path, project_root)
|
|
1288
|
+
recency = max(_file_recency(path), used_at_by_relpath.get(relpath, 0.0))
|
|
1289
|
+
candidates.append((recency, relpath))
|
|
1200
1290
|
candidates.sort(key=lambda item: (-item[0], item[1]))
|
|
1201
1291
|
return [rel for _, rel in candidates[:limit]]
|
|
1202
1292
|
|
|
@@ -252,9 +252,9 @@ Order of operations:
|
|
|
252
252
|
unavailable on this repo, network error), **do NOT silently
|
|
253
253
|
continue with full recursion**. Disable the "Generate the full
|
|
254
254
|
tree recursively" option for this branch, default to
|
|
255
|
-
`Parent only; list children in Related Artifacts
|
|
256
|
-
GraphQL failure to the user in one line so
|
|
257
|
-
to paste child bodies manually.
|
|
255
|
+
`Parent only; list children in Related Artifacts and Related Task
|
|
256
|
+
Graph`, and surface the GraphQL failure to the user in one line so
|
|
257
|
+
they can decide whether to paste child bodies manually.
|
|
258
258
|
- Notion: child pages / sub-pages
|
|
259
259
|
- If there is **one or more** child, ask **once at the top parent**:
|
|
260
260
|
- `AskUserQuestion` (single-select)
|
|
@@ -263,7 +263,8 @@ Order of operations:
|
|
|
263
263
|
1. `Full tree` (recommended) — walk children, grandchildren, …
|
|
264
264
|
via BFS/DFS and emit one brief per node.
|
|
265
265
|
2. `Parent only` — single brief. Children are not fetched; their
|
|
266
|
-
keys/URLs are recorded under Related Artifacts
|
|
266
|
+
keys/URLs are recorded under Related Artifacts and as `parent-of`
|
|
267
|
+
rows in Related Task Graph.
|
|
267
268
|
3. `Selected` — multi-select the direct children; for each chosen
|
|
268
269
|
child, apply option-1 policy recursively to that branch.
|
|
269
270
|
- For options 1 / 3, **recurse into Step 1b sub-steps 3–5** for every
|
|
@@ -294,6 +295,25 @@ Order of operations:
|
|
|
294
295
|
(`sub/<ticket-id>-<file-title>.md`)
|
|
295
296
|
bidirectionally. (Non-direct ancestors/descendants are tracked via the
|
|
296
297
|
frontmatter `parent-id` chain.)
|
|
298
|
+
- Each generated brief's `Related Task Graph` should contain the same full
|
|
299
|
+
split topology, not only the current node's direct edges, so downstream
|
|
300
|
+
phases can start from any child brief without losing order or dependency
|
|
301
|
+
context. Use one row per source-backed edge with these columns:
|
|
302
|
+
`From | Relation | To | Direction | Source | Impact`.
|
|
303
|
+
- `From` / `To` use task key, brief id, tracker id, or URL. The edge
|
|
304
|
+
direction is `From` → `To`.
|
|
305
|
+
- `Relation` is one of `parent-of`, `child-of`, `depends-on`, `blocks`,
|
|
306
|
+
`blocked-by`, `follow-up-of`, `split-from`, `duplicates`, `related-to`.
|
|
307
|
+
- `Direction` is `directed` for parentage/ordering relations and
|
|
308
|
+
`undirected` for `duplicates` / `related-to`.
|
|
309
|
+
- `Source` names the evidence for the edge: tracker linked issue,
|
|
310
|
+
markdown task-list checkbox, reporter statement, manual split, or prior
|
|
311
|
+
okstra task.
|
|
312
|
+
- `Impact` states what the next phase must preserve, such as "run after
|
|
313
|
+
API contract" or "avoid duplicate implementation".
|
|
314
|
+
Do not invent graph edges from filename similarity or topic overlap; if a
|
|
315
|
+
relation is unclear, use `related-to` + `undirected` only when the source
|
|
316
|
+
explicitly says the items are related.
|
|
297
317
|
- If there are no children, proceed with a single brief (depth 0).
|
|
298
318
|
|
|
299
319
|
### 1c. `Link URL`
|
|
@@ -680,6 +700,9 @@ Required sections:
|
|
|
680
700
|
- **Desired Outcome** — success shape, not a solution prescription.
|
|
681
701
|
- **Constraints** — deadlines, compatibility, technical/operational limits.
|
|
682
702
|
- **Related Artifacts** — files, URLs, issues, prior task-keys.
|
|
703
|
+
- **Related Task Graph** — structured task-to-task edges when the work was
|
|
704
|
+
split, linked, or ordered by the source. Use `_(none)_` when no source-backed
|
|
705
|
+
edge exists.
|
|
683
706
|
- **Open Questions** — anything the user already flagged as undecided
|
|
684
707
|
(becomes raw material for `requirements-discovery`).
|
|
685
708
|
- **Task Continuity Notes** — if the target task's
|
|
@@ -763,7 +786,7 @@ Material).
|
|
|
763
786
|
|
|
764
787
|
The installed template `~/.okstra/templates/reports/brief.template.md` is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, and section ordering. The template's inline `<!-- author guidance -->` HTML comments are fill-in scaffolding for the author, NOT part of the artifact: render the file with per-brief values substituted and **strip every `<!-- ... -->` comment** so the final brief carries none. Leave any section's body as `_(none)_` rather than fabricating content.
|
|
765
788
|
|
|
766
|
-
**Variant note:** The template file is the canonical reporter-input shape. For the codebase-scan variant (`scope: codebase` in frontmatter), OMIT the `## Source Material` and `## Problem / Symptom` headings entirely — do NOT include them with `_(none)_` bodies. Instead, KEEP the `## Scan Scope` and `## Priority Lenses` sections (already present in the template file, marked with HTML comments). For the error-feedback variant (`scope` omitted ⇒ `reporter-input`), use the reporter-input shape: KEEP `## Source Material` (the chosen cluster's anonymized error records) and `## Problem / Symptom`, and OMIT the codebase-only `## Scan Scope` / `## Priority Lenses` sections. The remaining sections (Context / Desired Outcome / Constraints / Related Artifacts / Open Questions / Reporter Confirmations / Augmentation) apply to all variants.
|
|
789
|
+
**Variant note:** The template file is the canonical reporter-input shape. For the codebase-scan variant (`scope: codebase` in frontmatter), OMIT the `## Source Material` and `## Problem / Symptom` headings entirely — do NOT include them with `_(none)_` bodies. Instead, KEEP the `## Scan Scope` and `## Priority Lenses` sections (already present in the template file, marked with HTML comments). For the error-feedback variant (`scope` omitted ⇒ `reporter-input`), use the reporter-input shape: KEEP `## Source Material` (the chosen cluster's anonymized error records) and `## Problem / Symptom`, and OMIT the codebase-only `## Scan Scope` / `## Priority Lenses` sections. The remaining sections (Context / Desired Outcome / Constraints / Related Artifacts / Related Task Graph / Open Questions / Reporter Confirmations / Augmentation) apply to all variants.
|
|
767
790
|
|
|
768
791
|
### Required sections by variant
|
|
769
792
|
|
|
@@ -775,6 +798,7 @@ The installed template `~/.okstra/templates/reports/brief.template.md` is the by
|
|
|
775
798
|
| `## Desired Outcome` | Required | Required | Required |
|
|
776
799
|
| `## Constraints` | Required | Required | Required |
|
|
777
800
|
| `## Related Artifacts` | Required | Required | Required |
|
|
801
|
+
| `## Related Task Graph` | Required — `_(none)_` or canonical table | Required — `_(none)_` or canonical table | Required — `_(none)_` or canonical table |
|
|
778
802
|
| `## Open Questions` | Required | Required | Required |
|
|
779
803
|
| `## Scan Scope` | Not applicable — omit | Required — one bullet per `scan-scope` path with a short description of what lives there | Not applicable — omit |
|
|
780
804
|
| `## Priority Lenses` | Not applicable — omit | Required — one bullet per priority lens with a short rationale for why that lens applies to this scope | Not applicable — omit |
|
|
@@ -958,8 +982,9 @@ is set (Step 6.5), and **before** printing the Step 7 hand-off message, you
|
|
|
958
982
|
MUST validate every brief produced this run. The validator enforces the
|
|
959
983
|
labelled-handoff contract — required frontmatter keys, Augmentation labels,
|
|
960
984
|
`intent-inference ↔ intent-check:` auto-mirroring, `parent-id`/`depth`
|
|
961
|
-
consistency, `reporter-confirmations` markers,
|
|
962
|
-
`scope`/`Scan Scope
|
|
985
|
+
consistency, `reporter-confirmations` markers, `Related Task Graph`
|
|
986
|
+
table shape/relation values, and the codebase-scan `scope`/`Scan Scope`/
|
|
987
|
+
`Priority Lenses` rules.
|
|
963
988
|
|
|
964
989
|
1. Run the validator over the briefs directory:
|
|
965
990
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
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", "여러 프로젝트", "cross-project", "프로젝트 묶어서", "manager task".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OKSTRA Manager
|
|
7
|
+
|
|
8
|
+
Thin conversational wrapper over `okstra manager`. Manager state and child launch decisions belong to the CLI JSON/output, not to this skill.
|
|
9
|
+
|
|
10
|
+
## Bash Invocation Rule
|
|
11
|
+
|
|
12
|
+
Every command in this skill must begin with the literal token `okstra`. Do not introduce shell variables, command substitution, leading environment assignments, or alternate wrappers around `okstra manager`.
|
|
13
|
+
|
|
14
|
+
## Command Surface
|
|
15
|
+
|
|
16
|
+
Use the CLI output as the source of truth.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
okstra manager init --manager-id <manager-id> --json
|
|
20
|
+
okstra manager discover-projects --json
|
|
21
|
+
okstra manager new project --manager-id <manager-id> --project-id <project-id> --project-root <abs-path> [--role <role>] [--tag <tag>] --json
|
|
22
|
+
okstra manager new task-group --manager-id <manager-id> --task-group <task-group> --json
|
|
23
|
+
okstra manager new task --manager-id <manager-id> --task-group <task-group> --task-id <task-id> --task <project-id:task-group:task-id> --json
|
|
24
|
+
okstra manager task status --manager-id <manager-id> --task-group <task-group> --task-id <task-id> --json
|
|
25
|
+
okstra manager task sync --manager-id <manager-id> --task-group <task-group> --task-id <task-id> --json
|
|
26
|
+
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>] --json
|
|
27
|
+
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> --json
|
|
28
|
+
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>] --json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- Public child task identity is `project-id:task-group:task-id`.
|
|
32
|
+
- `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.
|
|
33
|
+
- 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`.
|
|
34
|
+
|
|
35
|
+
## Child Launch Rule
|
|
36
|
+
|
|
37
|
+
For launching child work:
|
|
38
|
+
|
|
39
|
+
1. Run `okstra manager task sync ... --json` if the manager snapshot must be refreshed first.
|
|
40
|
+
2. Run `okstra manager task run ... --json`.
|
|
41
|
+
3. Read the returned launch packet fields such as `backend`, `projectRoot`, `contextPath`, and `runArgs`.
|
|
42
|
+
4. Use that launch packet for the host-native child lead handoff.
|
|
43
|
+
|
|
44
|
+
The launch packet remains the source of truth. Do not rebuild child args by hand.
|
|
@@ -120,9 +120,9 @@ Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How
|
|
|
120
120
|
|
|
121
121
|
That is the entire interactive flow. The wizard handles:
|
|
122
122
|
|
|
123
|
-
- new-vs-existing task split (남은 작업 — `workStatus != done` — 최신순 3개 추천 + 직접 입력), task-group / task-id slug validation (
|
|
123
|
+
- new-vs-existing task split (남은 작업 — `workStatus != done` — 최신순 3개 추천 + 직접 입력), task-group / task-id slug validation (task-group 은 최근 task 사용 + 최근 `.okstra/briefs/<group>/` 생성 활동을 합산한 최신 후보 3개 추천 + 직접 입력, task-id 는 같은 group 의 최근 후보 3개 추천 + 직접 입력),
|
|
124
124
|
- task-type pick (추천 3개 — `nextRecommendedPhase` recommended / 현재 phase 재실행 / 라이프사이클 다음 단계 — + 직접 입력; 직접 입력은 후속 `text` 단계에서 전체 task-type 화이트리스트로 검증),
|
|
125
|
-
- brief path — **entry task-type(requirements-discovery / error-analysis / improvement-discovery)에서만 질문** (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing entry tasks). downstream task-type 은 manifest 의 brief 를 자동 carry-in 하고, 등록 brief 가 없으면 `brief_carry` 3-옵션(entry 전환 추천 / 직접 입력 / 중단)이 뜬다. `release-handoff` 는 brief 단계가 아예 없다 — prepare 가 검증 보고서 인용 input 문서를 생성한다,
|
|
125
|
+
- brief path — **entry task-type(requirements-discovery / error-analysis / improvement-discovery)에서만 질문** (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, sorted by the newer of file-created/modified time and latest task-catalog use; direct input last; `유지 / 변경` for existing entry tasks). downstream task-type 은 manifest 의 brief 를 자동 carry-in 하고, 등록 brief 가 없으면 `brief_carry` 3-옵션(entry 전환 추천 / 직접 입력 / 중단)이 뜬다. `release-handoff` 는 brief 단계가 아예 없다 — prepare 가 검증 보고서 인용 input 문서를 생성한다,
|
|
126
126
|
- base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
|
|
127
127
|
- `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick. approved-plan 선택 시 그 run 의 sibling `user-responses/` 에서 plan 과 source-report·seq 가 일치하는, 보고서에서 내보낸 `## APPROVAL` sidecar 를 감지하면 approve-confirm 단계가 3-옵션(`yes_apply` 내보낸 기록대로 승인+옵션 적용 추천 / `yes` 승인만 / `no` 중단)으로 확장된다 — `yes_apply` 는 옵션을 plan 의 `optionCandidates` 에 대해 검증한 뒤 기존 승인·옵션 경로로 적용한다,
|
|
128
128
|
- `release-handoff`-only sub-flow: approved plan 자동 해소 후 `handoff_stage_pick` 멀티선택 — eligible stage 묶음(stage-group) 또는 전체 task(accepted whole-task 검증 보고서 존재 시) 선택; 결과는 render-args 의 `stages` 키(csv, whole-task 면 빈 값)로 나간다,
|
|
@@ -96,6 +96,31 @@ none.>
|
|
|
96
96
|
|
|
97
97
|
- <file path / URL / issue / prior task-key>
|
|
98
98
|
|
|
99
|
+
## Related Task Graph
|
|
100
|
+
|
|
101
|
+
<!-- author guidance — strip out at fill-in time:
|
|
102
|
+
Use this section when the source ticket / reporter / split operation exposes
|
|
103
|
+
relationships between this task and other tasks. The table is copied into
|
|
104
|
+
every brief generated from the same split so each downstream phase can see
|
|
105
|
+
the whole topology from a single brief.
|
|
106
|
+
|
|
107
|
+
Columns:
|
|
108
|
+
- From / To: task key, brief id, tracker id, or URL. Direction is encoded by
|
|
109
|
+
`From` → `To`.
|
|
110
|
+
- Relation: one of `parent-of`, `child-of`, `depends-on`, `blocks`,
|
|
111
|
+
`blocked-by`, `follow-up-of`, `split-from`, `duplicates`, `related-to`.
|
|
112
|
+
- Direction: `directed` for ordering/parentage relations, `undirected` for
|
|
113
|
+
`duplicates` / `related-to`.
|
|
114
|
+
- Source: where the edge came from (tracker linked issue, task-list checkbox,
|
|
115
|
+
reporter statement, manual split, prior okstra task).
|
|
116
|
+
- Impact: what the next phase must preserve about this edge.
|
|
117
|
+
|
|
118
|
+
Use `_(none)_` when there are no known task relationships. Do not invent
|
|
119
|
+
edges from filename similarity or topic overlap.
|
|
120
|
+
-->
|
|
121
|
+
|
|
122
|
+
_(none)_
|
|
123
|
+
|
|
99
124
|
## Open Questions
|
|
100
125
|
|
|
101
126
|
<!-- author guidance — strip out at fill-in time:
|
|
@@ -33,6 +33,8 @@ Checks performed per brief file:
|
|
|
33
33
|
11. codebase-scan variant (`scope: codebase`): the Scan Scope section is
|
|
34
34
|
non-empty and Priority Lenses lists 1–4 values from the lens whitelist
|
|
35
35
|
(`scripts/okstra_ctl/improvement_lenses.py` SSOT).
|
|
36
|
+
12. When present, `Related Task Graph` is a markdown table with the canonical
|
|
37
|
+
columns and relation/direction values from the okstra-brief contract.
|
|
36
38
|
|
|
37
39
|
Exit code 0 on PASS, 1 on FAIL.
|
|
38
40
|
"""
|
|
@@ -91,6 +93,24 @@ REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}
|
|
|
91
93
|
|
|
92
94
|
SCOPE_VALUES = {"reporter-input", "codebase"}
|
|
93
95
|
|
|
96
|
+
TASK_GRAPH_HEADER = ["From", "Relation", "To", "Direction", "Source", "Impact"]
|
|
97
|
+
|
|
98
|
+
DIRECTED_TASK_RELATIONS = {
|
|
99
|
+
"parent-of",
|
|
100
|
+
"child-of",
|
|
101
|
+
"depends-on",
|
|
102
|
+
"blocks",
|
|
103
|
+
"blocked-by",
|
|
104
|
+
"follow-up-of",
|
|
105
|
+
"split-from",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
UNDIRECTED_TASK_RELATIONS = {"duplicates", "related-to"}
|
|
109
|
+
|
|
110
|
+
TASK_RELATIONS = DIRECTED_TASK_RELATIONS | UNDIRECTED_TASK_RELATIONS
|
|
111
|
+
|
|
112
|
+
TASK_RELATION_DIRECTIONS = {"directed", "undirected"}
|
|
113
|
+
|
|
94
114
|
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
95
115
|
|
|
96
116
|
|
|
@@ -265,6 +285,112 @@ def check_codebase_scope(text: str, errors: list[str]) -> None:
|
|
|
265
285
|
)
|
|
266
286
|
|
|
267
287
|
|
|
288
|
+
def parse_markdown_table_row(line: str) -> list[str]:
|
|
289
|
+
"""Return markdown table cells without the outer pipes."""
|
|
290
|
+
stripped = line.strip()
|
|
291
|
+
if not stripped.startswith("|") or not stripped.endswith("|"):
|
|
292
|
+
return []
|
|
293
|
+
return [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def is_markdown_table_separator(cells: list[str]) -> bool:
|
|
297
|
+
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_related_task_graph(text: str, errors: list[str]) -> None:
|
|
301
|
+
body = section_body(text, "Related Task Graph")
|
|
302
|
+
if not body.strip():
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
|
306
|
+
if all(is_placeholder(line) or is_template_example(line) for line in lines):
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
table_lines = [line for line in lines if line.startswith("|")]
|
|
310
|
+
if not table_lines:
|
|
311
|
+
errors.append(
|
|
312
|
+
"Related Task Graph must be a markdown table or the literal _(none)_"
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
if len(table_lines) < 2:
|
|
317
|
+
errors.append("Related Task Graph table must include a header and separator")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
header = parse_markdown_table_row(table_lines[0])
|
|
321
|
+
if header != TASK_GRAPH_HEADER:
|
|
322
|
+
errors.append(
|
|
323
|
+
"Related Task Graph header must be "
|
|
324
|
+
f"{TASK_GRAPH_HEADER}, got {header}"
|
|
325
|
+
)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
separator = parse_markdown_table_row(table_lines[1])
|
|
329
|
+
if len(separator) != len(TASK_GRAPH_HEADER) or not is_markdown_table_separator(
|
|
330
|
+
separator
|
|
331
|
+
):
|
|
332
|
+
errors.append("Related Task Graph separator row is malformed")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
for row_number, line in enumerate(table_lines[2:], start=1):
|
|
336
|
+
cells = parse_markdown_table_row(line)
|
|
337
|
+
if len(cells) != len(TASK_GRAPH_HEADER):
|
|
338
|
+
errors.append(
|
|
339
|
+
f"Related Task Graph row {row_number} has {len(cells)} cells; "
|
|
340
|
+
f"expected {len(TASK_GRAPH_HEADER)}"
|
|
341
|
+
)
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
row = dict(zip(TASK_GRAPH_HEADER, cells))
|
|
345
|
+
relation = row["Relation"]
|
|
346
|
+
direction = row["Direction"]
|
|
347
|
+
source = row["Source"]
|
|
348
|
+
impact = row["Impact"]
|
|
349
|
+
from_ref = row["From"]
|
|
350
|
+
to_ref = row["To"]
|
|
351
|
+
|
|
352
|
+
for key in TASK_GRAPH_HEADER:
|
|
353
|
+
if not row[key]:
|
|
354
|
+
errors.append(f"Related Task Graph row {row_number} has empty {key}")
|
|
355
|
+
|
|
356
|
+
if from_ref and to_ref and from_ref == to_ref:
|
|
357
|
+
errors.append(
|
|
358
|
+
f"Related Task Graph row {row_number} links {from_ref!r} to itself"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if relation not in TASK_RELATIONS:
|
|
362
|
+
errors.append(
|
|
363
|
+
f"Related Task Graph row {row_number} has unknown relation "
|
|
364
|
+
f"{relation!r}; allowed: {sorted(TASK_RELATIONS)}"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if direction not in TASK_RELATION_DIRECTIONS:
|
|
368
|
+
errors.append(
|
|
369
|
+
f"Related Task Graph row {row_number} has unknown Direction "
|
|
370
|
+
f"{direction!r}; allowed: {sorted(TASK_RELATION_DIRECTIONS)}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if relation in DIRECTED_TASK_RELATIONS and direction != "directed":
|
|
374
|
+
errors.append(
|
|
375
|
+
f"Related Task Graph row {row_number} relation {relation!r} "
|
|
376
|
+
"must use Direction=directed"
|
|
377
|
+
)
|
|
378
|
+
elif relation in UNDIRECTED_TASK_RELATIONS and direction != "undirected":
|
|
379
|
+
errors.append(
|
|
380
|
+
f"Related Task Graph row {row_number} relation {relation!r} "
|
|
381
|
+
"must use Direction=undirected"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if source in {"-", "_(none)_", "none"}:
|
|
385
|
+
errors.append(
|
|
386
|
+
f"Related Task Graph row {row_number} must cite a source for the edge"
|
|
387
|
+
)
|
|
388
|
+
if impact in {"-", "_(none)_", "none"}:
|
|
389
|
+
errors.append(
|
|
390
|
+
f"Related Task Graph row {row_number} must state the edge impact"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
268
394
|
def check_reporter_confirmations(
|
|
269
395
|
rc_status: str | None, reporter_rows: list[str], errors: list[str]
|
|
270
396
|
) -> None:
|
|
@@ -324,6 +450,8 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
|
|
|
324
450
|
if scope == "codebase":
|
|
325
451
|
check_codebase_scope(text, errors)
|
|
326
452
|
|
|
453
|
+
check_related_task_graph(text, errors)
|
|
454
|
+
|
|
327
455
|
# 2. brief-id matches filename stem
|
|
328
456
|
stem = path.stem
|
|
329
457
|
if fm.get("brief-id") and fm["brief-id"] != stem:
|
package/src/cli-registry.mjs
CHANGED
|
@@ -159,6 +159,13 @@ export const COMMAND_REGISTRY = [
|
|
|
159
159
|
category: "introspection",
|
|
160
160
|
summary: ["Manage the okstra container runtime (up/status/logs/stop-watcher/down)"],
|
|
161
161
|
},
|
|
162
|
+
{
|
|
163
|
+
name: "manager",
|
|
164
|
+
module: "./commands/manager.mjs",
|
|
165
|
+
export: "run",
|
|
166
|
+
category: "introspection",
|
|
167
|
+
summary: ["Manage cross-project okstra tasks"],
|
|
168
|
+
},
|
|
162
169
|
{
|
|
163
170
|
name: "worktree-lookup",
|
|
164
171
|
module: "./commands/execute/worktree-lookup.mjs",
|