okstra 0.102.4 → 0.104.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.
@@ -0,0 +1,152 @@
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, StateError, 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 _base_child_snapshot(child: dict) -> dict:
56
+ task_key = str(child.get("taskKey") or "")
57
+ return {
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
+
66
+
67
+ def _snapshot_child(project_root: Path, child: dict) -> dict:
68
+ base = _base_child_snapshot(child)
69
+ task_key = base["taskKey"]
70
+ try:
71
+ task_dir = _resolve_task_root_read_only(project_root, task_key)
72
+ if task_dir is None:
73
+ return base
74
+ manifest = read_task_manifest(task_dir) or {}
75
+ except StateError as exc:
76
+ return {**base, "error": str(exc)}
77
+ workflow = _workflow(manifest)
78
+ outcome = _phase_outcome(manifest)
79
+ return {
80
+ **base,
81
+ "exists": True,
82
+ "taskRoot": str(task_dir),
83
+ "taskType": str(manifest.get("taskType") or ""),
84
+ "currentPhase": str(workflow.get("currentPhase") or ""),
85
+ "currentPhaseState": str(workflow.get("currentPhaseState") or ""),
86
+ "lastCompletedPhase": str(workflow.get("lastCompletedPhase") or ""),
87
+ "nextRecommendedPhase": str(workflow.get("nextRecommendedPhase") or ""),
88
+ "latestRunStatus": str(manifest.get("latestRunStatus") or manifest.get("currentStatus") or ""),
89
+ "latestReportPath": str(manifest.get("latestReportPath") or ""),
90
+ "finalVerdict": str(outcome.get("finalVerdict") or ""),
91
+ "crossProjectDependencies": _list_field(outcome, "crossProjectDependencies"),
92
+ "recommendedNextSteps": _list_field(outcome, "recommendedNextSteps"),
93
+ "openClarifications": _list_field(outcome, "openClarifications"),
94
+ }
95
+
96
+
97
+ def sync_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now: str | None = None) -> dict:
98
+ _read_json(task_manifest_path(home, manager_id, task_group, task_id))
99
+ children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
100
+ projects = _project_map(home, manager_id)
101
+ rows = []
102
+ for child in children.get("children", []):
103
+ if not isinstance(child, dict):
104
+ continue
105
+ project = projects.get(str(child.get("projectId") or ""))
106
+ if project is None:
107
+ rows.append({**child, "exists": False, "error": "unknown project membership"})
108
+ continue
109
+ root = Path(str(project.get("projectRoot") or ""))
110
+ if not root.is_dir():
111
+ rows.append({**child, "exists": False, "error": f"missing project root: {root}"})
112
+ continue
113
+ rows.append(_snapshot_child(root, child))
114
+ payload = {
115
+ "managerId": manager_id,
116
+ "taskGroup": task_group,
117
+ "taskId": task_id,
118
+ "syncedAt": now or _now_iso(),
119
+ "children": rows,
120
+ }
121
+ _write_json(snapshots_json_path(home, manager_id, task_group, task_id), payload)
122
+ return payload
123
+
124
+
125
+ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
126
+ manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
127
+ children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
128
+ snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
129
+ by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
130
+ rows = []
131
+ summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0}
132
+ for child in children.get("children", []):
133
+ if not isinstance(child, dict):
134
+ continue
135
+ key = child.get("taskKey")
136
+ snapshot_row = by_key.get(key)
137
+ row = {**child, **(snapshot_row or {})}
138
+ status = str(row.get("latestRunStatus") or row.get("launch", {}).get("status") or "planned")
139
+ if snapshot_row is None:
140
+ summary["planned"] += 1
141
+ elif not row.get("exists", False):
142
+ summary["missing"] += 1
143
+ elif status == "done":
144
+ summary["done"] += 1
145
+ elif status in {"running", "started", "in-progress"}:
146
+ summary["running"] += 1
147
+ elif status == "blocked":
148
+ summary["blocked"] += 1
149
+ else:
150
+ summary["planned"] += 1
151
+ rows.append(row)
152
+ 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
- """프로젝트 catalog 에서 최근 task-group 후보를 limit 개까지 반환."""
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(Path(state.project_root))
1168
+ tasks = list_project_tasks(project_root)
1136
1169
  except (OSError, StateError):
1137
- return []
1138
- seen: list[str] = []
1139
- for entry in tasks:
1170
+ tasks = []
1171
+ for index, entry in enumerate(tasks):
1140
1172
  tg = entry.get("taskGroup") or ""
1141
- if tg and tg not in seen:
1142
- seen.append(tg)
1143
- if len(seen) >= limit:
1144
- break
1145
- return seen
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
- try:
1196
- mtime = path.stat().st_mtime
1197
- except OSError:
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`, and surface the
256
- GraphQL failure to the user in one line so they can decide whether
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, and the codebase-scan
962
- `scope`/`Scan Scope`/`Priority Lenses` rules.
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 (각각 최근 후보 3개 추천 + 직접 입력),
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:
@@ -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",