okstra 0.102.3 → 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.
Files changed (37) hide show
  1. package/README.kr.md +3 -2
  2. package/README.md +3 -2
  3. package/docs/for-ai/skills/okstra-brief.md +18 -2
  4. package/docs/for-ai/skills/okstra-run.md +9 -1
  5. package/docs/kr/architecture/storage-model.md +7 -2
  6. package/docs/kr/architecture.md +5 -2
  7. package/docs/kr/cli.md +1 -0
  8. package/docs/project-structure-overview.md +9 -4
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/lib/okstra/interactive.sh +12 -0
  12. package/runtime/prompts/profiles/_common-contract.md +4 -0
  13. package/runtime/prompts/profiles/error-analysis.md +2 -0
  14. package/runtime/prompts/profiles/improvement-discovery.md +2 -0
  15. package/runtime/prompts/profiles/requirements-discovery.md +4 -0
  16. package/runtime/prompts/wizard/prompts.ko.json +1 -1
  17. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  18. package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
  19. package/runtime/python/okstra_ctl/implementation_outcome.py +271 -0
  20. package/runtime/python/okstra_ctl/manager_cli.py +231 -0
  21. package/runtime/python/okstra_ctl/manager_launch.py +198 -0
  22. package/runtime/python/okstra_ctl/manager_paths.py +98 -0
  23. package/runtime/python/okstra_ctl/manager_store.py +318 -0
  24. package/runtime/python/okstra_ctl/manager_sync.py +144 -0
  25. package/runtime/python/okstra_ctl/path_hints.py +665 -0
  26. package/runtime/python/okstra_ctl/render.py +4 -3
  27. package/runtime/python/okstra_ctl/run_context.py +4 -2
  28. package/runtime/python/okstra_ctl/wizard.py +118 -16
  29. package/runtime/python/okstra_project/state.py +40 -2
  30. package/runtime/skills/okstra-brief/SKILL.md +32 -7
  31. package/runtime/skills/okstra-manager/SKILL.md +44 -0
  32. package/runtime/skills/okstra-run/SKILL.md +2 -2
  33. package/runtime/templates/reports/brief.template.md +25 -0
  34. package/runtime/validators/validate-brief.py +128 -0
  35. package/src/cli-registry.mjs +7 -0
  36. package/src/commands/manager.mjs +57 -0
  37. package/src/lib/skill-catalog.mjs +1 -0
@@ -31,6 +31,7 @@ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
31
31
  from . import fix_cycles
32
32
  from .paths import okstra_home
33
33
  from .lead_runtime import lead_runtime_info
34
+ from .path_hints import compact_active_run_context, hydrate_run_context
34
35
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
35
36
 
36
37
 
@@ -56,8 +57,8 @@ def _load_ctx(ctx_arg: str) -> dict:
56
57
  """
57
58
  s = ctx_arg.lstrip()
58
59
  if s.startswith("{"):
59
- return json.loads(ctx_arg)
60
- return json.loads(Path(ctx_arg).read_text(encoding="utf-8"))
60
+ return hydrate_run_context(json.loads(ctx_arg))
61
+ return hydrate_run_context(json.loads(Path(ctx_arg).read_text(encoding="utf-8")))
61
62
 
62
63
 
63
64
  def _write_text(path: Path, text: str) -> None:
@@ -434,7 +435,7 @@ def render_active_run_context(active_context_path: str, ctx: dict) -> None:
434
435
  "sourceArtifacts": _active_source_artifacts(ctx),
435
436
  "lazyReadPlan": _active_lazy_read_plan(),
436
437
  }
437
- _write_json(Path(active_context_path), payload)
438
+ _write_json(Path(active_context_path), compact_active_run_context(ctx, payload))
438
439
 
439
440
 
440
441
  def render_team_state(team_state_path: str, ctx: dict) -> None:
@@ -25,6 +25,7 @@ from typing import Iterator, Optional
25
25
 
26
26
  from okstra_project.dirs import okstra_home
27
27
 
28
+ from .path_hints import compact_run_context, hydrate_run_context
28
29
  from .paths import compute_run_paths, task_runs_dir
29
30
 
30
31
 
@@ -188,7 +189,7 @@ def compute_and_write_run_context(
188
189
  str(ctx_path.relative_to(project_root.resolve()))
189
190
  if ctx_path.resolve().is_relative_to(project_root.resolve())
190
191
  else str(ctx_path))
191
- _atomic_write_json(ctx_path, ctx)
192
+ _atomic_write_json(ctx_path, compact_run_context(ctx))
192
193
  return ctx
193
194
 
194
195
 
@@ -225,7 +226,8 @@ def read_run_context(run_manifests_dir: Path, task_type_segment: str,
225
226
  path = Path(run_manifests_dir) / _run_context_filename(task_type_segment, seq)
226
227
  if not path.is_file():
227
228
  return None
228
- return json.loads(path.read_text(encoding="utf-8"))
229
+ payload = json.loads(path.read_text(encoding="utf-8"))
230
+ return hydrate_run_context(payload)
229
231
 
230
232
 
231
233
  def read_run_inputs(run_manifests_dir: Path, task_type_segment: str,
@@ -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
 
@@ -1053,6 +1083,18 @@ def _opt(value: str, label: str = "", description: str = "") -> Option:
1053
1083
 
1054
1084
  # --- builders ---
1055
1085
 
1086
+ def _contract_outcome_suffix(entry: dict) -> str:
1087
+ outcome = entry.get("phaseOutcome")
1088
+ implementation = outcome.get("implementation") if isinstance(outcome, dict) else {}
1089
+ if not isinstance(implementation, dict):
1090
+ return ""
1091
+ if implementation.get("state") != "completed":
1092
+ return ""
1093
+ if entry.get("currentStatus") == "contract-violated":
1094
+ return " · implementation complete; contract warnings"
1095
+ return ""
1096
+
1097
+
1056
1098
  def _build_task_pick(state: WizardState) -> Prompt:
1057
1099
  t = _p(state.workspace_root, "task_pick")
1058
1100
  project_root = Path(state.project_root)
@@ -1070,7 +1112,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
1070
1112
  phase = entry.get("currentPhase") or ttype
1071
1113
  nxt = entry.get("nextRecommendedPhase") or ""
1072
1114
  suffix = latest_suffix if key == latest_key else ""
1073
- label = f"{key} · {phase} · next: {nxt}{suffix}"
1115
+ label = f"{key} · {phase} · next: {nxt}{_contract_outcome_suffix(entry)}{suffix}"
1074
1116
  options.append(_opt(value=key, label=label))
1075
1117
  for value, label in _static_options(t):
1076
1118
  options.append(_opt(value=value, label=label))
@@ -1116,21 +1158,51 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
1116
1158
  def _suggest_recent_task_groups(
1117
1159
  state: WizardState, limit: int = _RECOMMENDATION_CAP
1118
1160
  ) -> list[str]:
1119
- """프로젝트 catalog 에서 최근 task-group 후보를 limit 개까지 반환."""
1161
+ """최근 task/brief 활동에서 task-group 후보를 limit 개까지 반환."""
1120
1162
  if not state.project_root:
1121
1163
  return []
1164
+ project_root = Path(state.project_root)
1165
+ scores: dict[str, float] = {}
1166
+ orders: dict[str, int] = {}
1122
1167
  try:
1123
- tasks = list_project_tasks(Path(state.project_root))
1168
+ tasks = list_project_tasks(project_root)
1124
1169
  except (OSError, StateError):
1125
- return []
1126
- seen: list[str] = []
1127
- for entry in tasks:
1170
+ tasks = []
1171
+ for index, entry in enumerate(tasks):
1128
1172
  tg = entry.get("taskGroup") or ""
1129
- if tg and tg not in seen:
1130
- seen.append(tg)
1131
- if len(seen) >= limit:
1132
- break
1133
- 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]
1134
1206
 
1135
1207
 
1136
1208
  def _suggest_recent_task_ids(
@@ -1156,6 +1228,37 @@ def _suggest_recent_task_ids(
1156
1228
  return seen
1157
1229
 
1158
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
+
1159
1262
  def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
1160
1263
  """Return recent okstra-brief outputs for the selected task-group.
1161
1264
 
@@ -1169,6 +1272,7 @@ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
1169
1272
  root = project_root / ".okstra" / "briefs" / state.task_group
1170
1273
  if not root.is_dir():
1171
1274
  return []
1275
+ used_at_by_relpath = _recently_used_brief_times(state)
1172
1276
  candidates: list[tuple[float, str]] = []
1173
1277
  for path in root.rglob("*.md"):
1174
1278
  if not path.is_file():
@@ -1180,11 +1284,9 @@ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
1180
1284
  continue
1181
1285
  except WizardError:
1182
1286
  continue
1183
- try:
1184
- mtime = path.stat().st_mtime
1185
- except OSError:
1186
- mtime = 0.0
1187
- 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))
1188
1290
  candidates.sort(key=lambda item: (-item[0], item[1]))
1189
1291
  return [rel for _, rel in candidates[:limit]]
1190
1292
 
@@ -1,10 +1,13 @@
1
- """Read-only state accessors over okstra's on-disk authority files.
1
+ """Read-side state accessors over okstra's on-disk authority files.
2
2
 
3
3
  Skills, okstra.sh, and okstra-ctl all read the same files through this module
4
4
  so identity / discovery values are derived from disk on every call instead of
5
5
  being passed via process environment. This keeps concurrent claude-code skill
6
6
  invocations isolated — each call reads the authoritative state at the moment
7
7
  it runs and never sees stale snapshots inherited from a parent process.
8
+ Some accessors also run best-effort reconciliation before returning derived
9
+ phase pointers, so stale catalog entries can be healed from authoritative
10
+ task artifacts.
8
11
 
9
12
  권위 파일 매핑 (okstra root = <PROJECT_ROOT>/<OKSTRA_DIR_NAME>):
10
13
  - <okstra root>/project.json
@@ -78,6 +81,38 @@ def read_task_manifest(task_root: Path) -> Optional[dict]:
78
81
  return _load_json(Path(task_root) / "task-manifest.json")
79
82
 
80
83
 
84
+ def _reconcile_task_root_best_effort(project_root: Path, task_root: Path) -> None:
85
+ try:
86
+ from okstra_ctl.implementation_outcome import reconcile_implementation_outcome
87
+
88
+ reconcile_implementation_outcome(project_root, task_root)
89
+ except Exception:
90
+ return
91
+
92
+
93
+ def _entry_with_manifest_state(entry: dict, task_root: Path) -> dict:
94
+ out = {**entry}
95
+ manifest = read_task_manifest(task_root) or {}
96
+ workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
97
+ for source, target in (
98
+ ("currentPhase", "currentPhase"),
99
+ ("currentPhaseState", "currentPhaseState"),
100
+ ("lastCompletedPhase", "lastCompletedPhase"),
101
+ ("nextRecommendedPhase", "nextRecommendedPhase"),
102
+ ):
103
+ value = workflow.get(source)
104
+ if isinstance(value, str):
105
+ out[target] = value
106
+ for field in ("currentStatus", "latestRunStatus", "latestReportPath"):
107
+ value = manifest.get(field)
108
+ if isinstance(value, str):
109
+ out[field] = value
110
+ phase_outcome = manifest.get("phaseOutcome")
111
+ if isinstance(phase_outcome, dict):
112
+ out["phaseOutcome"] = phase_outcome
113
+ return out
114
+
115
+
81
116
  def parse_task_key(task_key: str) -> tuple[str, str, str]:
82
117
  """`project-id:task-group:task-id` 를 3-tuple 로 분해.
83
118
 
@@ -111,10 +146,12 @@ def find_task_root(project_root: Path, task_key: str) -> Optional[Path]:
111
146
  if isinstance(rel, str) and rel:
112
147
  abs_path = project_root / rel if not Path(rel).is_absolute() else Path(rel)
113
148
  if abs_path.is_dir():
149
+ _reconcile_task_root_best_effort(project_root, abs_path)
114
150
  return abs_path
115
151
 
116
152
  slug_path = project_root / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
117
153
  if slug_path.is_dir():
154
+ _reconcile_task_root_best_effort(project_root, slug_path)
118
155
  return slug_path
119
156
  return None
120
157
 
@@ -180,7 +217,8 @@ def list_project_tasks(
180
217
  root = find_task_root(project_root, entry_key)
181
218
  if root is None:
182
219
  continue
183
- out.append({**entry, "_resolvedTaskRoot": str(root)})
220
+ reconciled_entry = _entry_with_manifest_state(entry, root)
221
+ out.append({**reconciled_entry, "_resolvedTaskRoot": str(root)})
184
222
  if limit is not None and limit >= 0:
185
223
  out = out[:limit]
186
224
  return out
@@ -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: