okstra 0.110.0 → 0.111.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 (31) hide show
  1. package/README.kr.md +1 -0
  2. package/README.md +1 -0
  3. package/docs/for-ai/skills/okstra-run.md +7 -5
  4. package/docs/kr/architecture.md +9 -10
  5. package/docs/kr/cli.md +2 -2
  6. package/docs/project-structure-overview.md +9 -9
  7. package/docs/task-process/README.md +4 -4
  8. package/docs/task-process/common-flow.md +8 -5
  9. package/docs/task-process/implementation.md +4 -5
  10. package/docs/task-process/release-handoff.md +3 -3
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/antigravity-worker.md +1 -0
  14. package/runtime/agents/workers/codex-worker.md +1 -0
  15. package/runtime/prompts/lead/convergence.md +7 -7
  16. package/runtime/prompts/profiles/_common-contract.md +1 -1
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +43 -0
  18. package/runtime/prompts/profiles/_implementation-executor.md +7 -9
  19. package/runtime/prompts/profiles/_implementation-self-check.md +11 -5
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +3 -0
  21. package/runtime/python/okstra_ctl/consumers.py +4 -0
  22. package/runtime/python/okstra_ctl/handoff.py +5 -23
  23. package/runtime/python/okstra_ctl/implementation_stage.py +11 -11
  24. package/runtime/python/okstra_ctl/report_views.py +76 -26
  25. package/runtime/python/okstra_ctl/stage_targets.py +152 -0
  26. package/runtime/python/okstra_ctl/wizard.py +47 -0
  27. package/runtime/python/okstra_project/__init__.py +2 -0
  28. package/runtime/python/okstra_project/state.py +44 -2
  29. package/runtime/skills/okstra-run/SKILL.md +21 -17
  30. package/src/commands/execute/wizard.mjs +3 -1
  31. package/src/commands/inspect/task-show.mjs +11 -31
@@ -9,12 +9,18 @@ appends this file's body into the persisted executor prompt at dispatch time
9
9
 
10
10
  # Implementation self-check (BLOCKING — before you claim done)
11
11
 
12
- Before declaring the change complete, write one confirming line per item to your
13
- audit sidecar. If any item fails, fix it or surface the violation do not claim
14
- done on a failing item.
12
+ Before declaring the change complete, confirm each item below against the ACTUAL
13
+ diff not from memory. This is a re-derivation, not a rubber stamp: enumerate the
14
+ changed files (`git diff --name-only <stage-base>..HEAD`) and, for the per-file
15
+ items, write the confirming evidence per file, not one global "✓". If any item
16
+ fails, fix it or surface the violation — do not claim done on a failing item.
15
17
 
16
- - functions: every new/edited function ≤50 effective lines, single purpose
18
+ - diff-review done: the `Pre-commit diff review sweep` ran over the full diff and its `Coverage:` footer is in your audit sidecar (every changed file named with the rule set applied). No footer → the sweep is unfinished; do not claim done.
19
+ - functions: every new/edited function ≤50 effective lines, single purpose — cite the longest function's actual line count, not "all under 50"
17
20
  - conventions: applied the routed pack + project patterns (name which ones)
18
21
  - names & comments: names say what, comments say why; no obvious-restatement comments; no truthful-name violations
19
- - verification: ran the actual build/test (paste the command + its result)
22
+ - verification: ran the actual build/test paste the exact command line and its real exit code / output tail, never a paraphrased "tests pass"
20
23
  - cleanup: no dead or commented-out code left behind
24
+
25
+ Close with a `Self-check coverage:` line naming the files you verified the
26
+ per-file items against, so the Coverage footer above and this gate reconcile.
@@ -818,6 +818,9 @@ def _implementation_executor_tail(
818
818
  parts.append(preflight_path.read_text(encoding="utf-8"))
819
819
  else:
820
820
  parts.append("# Coding-conventions preflight\n\nApply project-local conventions before editing.")
821
+ diff_review_path = profiles / "_implementation-diff-review.md"
822
+ if diff_review_path.is_file():
823
+ parts.append(diff_review_path.read_text(encoding="utf-8"))
821
824
  selfcheck_path = profiles / "_implementation-self-check.md"
822
825
  if selfcheck_path.is_file():
823
826
  parts.append(selfcheck_path.read_text(encoding="utf-8"))
@@ -68,6 +68,10 @@ def read_stage_consumer_state(
68
68
  if recover_from_carry:
69
69
  backfill_done_from_carry(plan_run_root)
70
70
  rows = read_consumers(plan_run_root)
71
+ return stage_consumer_state_from_rows(rows)
72
+
73
+
74
+ def stage_consumer_state_from_rows(rows: List[Dict[str, Any]]) -> StageConsumerState:
71
75
  done_rows = [r for r in rows if r.get("status") == "done"]
72
76
  done_by_stage = latest_done_by_stage(rows)
73
77
  started = {
@@ -12,7 +12,7 @@ import subprocess
12
12
  from pathlib import Path
13
13
  from typing import Any, Callable, Dict, List, Optional, Tuple
14
14
 
15
- from . import consumers, worktree_registry
15
+ from . import consumers, stage_targets, worktree_registry
16
16
  from .final_report_paths import final_report_markdown_path
17
17
  from .worktree import (compute_branch_name, compute_worktree_path,
18
18
  main_worktree_path, is_dirty_excluding_okstra,
@@ -44,28 +44,10 @@ def compute_eligibility(stage_map: List[Dict[str, Any]],
44
44
  rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
45
45
  """stage 별 PR 가능 여부와 차단 사유. 의존 폐포는 선택 집합의 속성이라
46
46
  여기서는 판정하지 않는다 (assemble 의 check_dependency_closure 가 담당)."""
47
- done = consumers.latest_done_by_stage(rows)
48
- verified = consumers.verified_accepted_stages(rows)
49
- in_pr = consumers.pr_covered_stages(rows)
50
- out = []
51
- for s in stage_map:
52
- n = s["stage_number"]
53
- reasons = []
54
- if n in in_pr:
55
- reasons.append("already-in-pr")
56
- else:
57
- if n not in done:
58
- reasons.append("not-done")
59
- if n not in verified:
60
- reasons.append("not-verified-accepted")
61
- out.append({
62
- "stage": n,
63
- "depends_on": list(s["depends_on"]),
64
- "eligible": not reasons,
65
- "reasons": reasons,
66
- "head_commit": (done.get(n) or {}).get("head_commit", ""),
67
- })
68
- return out
47
+ return stage_targets.stage_lifecycle_snapshot_from_rows(
48
+ stage_map,
49
+ rows,
50
+ ).handoff_eligibility()
69
51
 
70
52
 
71
53
  def latest_whole_task_fv_accepted(project_root, project_id: str,
@@ -66,32 +66,32 @@ def select_and_provision_implementation_stage(
66
66
  """
67
67
  del task_key # The registry uses the split identity fields.
68
68
 
69
- from .consumers import backfill_done_from_carry, read_stage_consumer_state
69
+ from .consumers import backfill_done_from_carry
70
70
  from . import worktree as _worktree
71
71
  from . import worktree_registry as _reg
72
72
 
73
73
  plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
74
74
  backfill_done_from_carry(plan_run_root)
75
75
  auto_reconcile_best_effort(inp, plan_run_root)
76
- consumers = read_stage_consumer_state(plan_run_root)
77
76
  reserved_stages = _reg.list_active_stage_numbers(
78
77
  inp.project_id,
79
78
  inp.task_group,
80
79
  inp.task_id,
81
80
  )
81
+ snapshot = stage_targets.read_stage_lifecycle_snapshot(
82
+ ctx_stage_map,
83
+ plan_run_root,
84
+ reserved_stages=reserved_stages,
85
+ )
82
86
 
83
87
  try:
84
- selected = stage_targets.resolve_implementation_stage(
85
- ctx_stage_map,
86
- consumers.done_stages,
87
- inp.stage,
88
- started_stages=consumers.started_stages,
89
- reserved_stages=reserved_stages,
90
- )
88
+ selected = snapshot.resolve_implementation_stage(inp.stage)
91
89
  except stage_targets.StageTargetError as exc:
92
90
  raise _as_implementation_stage_error(exc) from exc
93
91
 
94
- concurrent_stages = sorted(reserved_stages - consumers.done_stages - {selected})
92
+ concurrent_stages = snapshot.concurrent_stage_numbers(
93
+ selected_stage=selected,
94
+ )
95
95
 
96
96
  if executor_worktree_status.startswith("skipped"):
97
97
  head = _git_out(inp.project_root, "rev-parse", "HEAD")
@@ -133,7 +133,7 @@ def select_and_provision_implementation_stage(
133
133
  try:
134
134
  stage_base = stage_targets.resolve_stage_base_commit(
135
135
  selected_stage,
136
- consumers.done_rows,
136
+ snapshot.done_rows,
137
137
  anchor_base_commit=anchor,
138
138
  candidate_base=head_sha,
139
139
  project_root=Path(task_worktree_path),
@@ -2,11 +2,17 @@
2
2
 
3
3
  Single product, single source of truth:
4
4
 
5
- * ``render_html(src_md, *, run_meta)`` — deterministic self-contained
6
- HTML renderer for human readers. Sections §1/§3/§4 user-actionable
7
- rows (those reachable from §1 ``C-*`` IDs) get embedded ``<form>``
8
- controls. §5.6 / §5.7 / §5.8 deliverable sub-sections are explicitly
9
- excluded from form attachmentthey are read-only deliverables.
5
+ * ``build_report_view_model(src_md, *, run_meta)`` — deterministic
6
+ server-side Report View Model for human readers. It carries source digest,
7
+ rendered body HTML, response IDs, sidecar hints, and optional approval
8
+ context.
9
+ * ``render_report_view_model(model, *, css, js)`` self-contained HTML
10
+ renderer over that model. Sections §1/§3/§4 user-actionable rows (those
11
+ reachable from §1 ``C-*`` IDs) get embedded ``<form>`` controls. §5.6 /
12
+ §5.7 / §5.8 deliverable sub-sections are explicitly excluded from form
13
+ attachment — they are read-only deliverables.
14
+ * ``render_html(src_md, *, run_meta)`` remains a compatibility wrapper around
15
+ the model builder + renderer.
10
16
 
11
17
  User responses are NEVER merged back into the original report. The HTML
12
18
  serialises a ``user-response`` markdown sidecar via ``Export user
@@ -101,34 +107,56 @@ class RunMeta:
101
107
  source_report: str # relative path of the .md the HTML is derived from
102
108
 
103
109
 
104
- def render_html(
110
+ @dataclass(frozen=True)
111
+ class ReportViewModel:
112
+ run_meta: RunMeta
113
+ title: str
114
+ body_html: str
115
+ source_digest: str
116
+ response_ids: tuple[str, ...]
117
+ sidecar_name: str
118
+ sidecar_dir: str
119
+ approval_ctx: PlanApprovalContext | None = None
120
+
121
+
122
+ def build_report_view_model(
105
123
  src_md: str,
106
124
  *,
107
125
  run_meta: RunMeta,
108
- css: str,
109
- js: str,
110
126
  approval_ctx: PlanApprovalContext | None = None,
111
- ) -> str:
112
- """Return a single self-contained HTML document for ``src_md``.
113
-
114
- ``css`` / ``js`` are inlined verbatim. No external URLs are written
115
- into the document (validator enforces this — see
116
- validate-report-views.py).
117
- """
118
- # Compute the digest over the source MD body. The validator
119
- # recomputes the same digest from the current MD and compares it to
120
- # the value embedded in run-meta below.
127
+ ) -> ReportViewModel:
121
128
  digest = source_digest(src_md)
122
129
  body_md = _strip_leading_frontmatter(src_md)
123
130
  body_html, toc_headings = _markdown_to_html(body_md)
124
131
  body_html = _inject_toc(body_html, toc_headings)
125
- response_ids = sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
132
+ response_ids = tuple(
133
+ sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
134
+ )
135
+ return ReportViewModel(
136
+ run_meta=run_meta,
137
+ title=f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}",
138
+ body_html=body_html,
139
+ source_digest=digest,
140
+ response_ids=response_ids,
141
+ sidecar_name=f"user-response-{run_meta.task_type}-{run_meta.seq}.md",
142
+ sidecar_dir=f"runs/{run_meta.task_type}/user-responses/",
143
+ approval_ctx=approval_ctx,
144
+ )
126
145
 
127
- title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
128
- response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
129
- sidecar_name = html.escape(f"user-response-{run_meta.task_type}-{run_meta.seq}.md")
130
- sidecar_dir = html.escape(f"runs/{run_meta.task_type}/user-responses/")
131
146
 
147
+ def render_report_view_model(
148
+ model: ReportViewModel,
149
+ *,
150
+ css: str,
151
+ js: str,
152
+ ) -> str:
153
+ title = html.escape(model.title)
154
+ response_ids_json = "[" + ",".join(
155
+ '"' + html.escape(rid) + '"' for rid in model.response_ids
156
+ ) + "]"
157
+ sidecar_name = html.escape(model.sidecar_name)
158
+ sidecar_dir = html.escape(model.sidecar_dir)
159
+ run_meta = model.run_meta
132
160
  return (
133
161
  f"<!DOCTYPE html>\n"
134
162
  f"<html lang=\"ko\">\n"
@@ -143,8 +171,8 @@ def render_html(
143
171
  f" <div>{title}</div>\n"
144
172
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
145
173
  f"</header>\n"
146
- f"<main>{body_html}</main>\n"
147
- f"{_plan_approval_section(approval_ctx, run_meta) if approval_ctx else ''}"
174
+ f"<main>{model.body_html}</main>\n"
175
+ f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
148
176
  f"<footer class=\"report-footer\">\n"
149
177
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
150
178
  f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
@@ -158,7 +186,7 @@ def render_html(
158
186
  f"\"task-type\":\"{html.escape(run_meta.task_type)}\","
159
187
  f"\"seq\":\"{html.escape(run_meta.seq)}\","
160
188
  f"\"source-report\":\"{html.escape(run_meta.source_report)}\","
161
- f"\"source-sha256\":\"{digest}\","
189
+ f"\"source-sha256\":\"{model.source_digest}\","
162
190
  f"\"response-ids\":{response_ids_json}}}"
163
191
  f"</script>\n"
164
192
  f"<script>{js}</script>\n"
@@ -167,6 +195,28 @@ def render_html(
167
195
  )
168
196
 
169
197
 
198
+ def render_html(
199
+ src_md: str,
200
+ *,
201
+ run_meta: RunMeta,
202
+ css: str,
203
+ js: str,
204
+ approval_ctx: PlanApprovalContext | None = None,
205
+ ) -> str:
206
+ """Return a single self-contained HTML document for ``src_md``.
207
+
208
+ ``css`` / ``js`` are inlined verbatim. No external URLs are written
209
+ into the document (validator enforces this — see
210
+ validate-report-views.py).
211
+ """
212
+ model = build_report_view_model(
213
+ src_md,
214
+ run_meta=run_meta,
215
+ approval_ctx=approval_ctx,
216
+ )
217
+ return render_report_view_model(model, css=css, js=js)
218
+
219
+
170
220
  def _markdown_to_html(
171
221
  src_md: str,
172
222
  ) -> tuple[str, list[tuple[int, str, str]]]:
@@ -38,6 +38,158 @@ class FinalVerificationTarget:
38
38
  reports: list[str]
39
39
 
40
40
 
41
+ @dataclass(frozen=True)
42
+ class StageLifecycle:
43
+ stage: int
44
+ depends_on: list[int]
45
+ done: bool
46
+ started: bool
47
+ reserved: bool
48
+ verified_accepted: bool
49
+ pr_covered: bool
50
+ head_commit: str
51
+ report_path: str
52
+
53
+ def handoff_eligibility_record(self) -> dict[str, Any]:
54
+ reasons: list[str] = []
55
+ if self.pr_covered:
56
+ reasons.append("already-in-pr")
57
+ else:
58
+ if not self.done:
59
+ reasons.append("not-done")
60
+ if not self.verified_accepted:
61
+ reasons.append("not-verified-accepted")
62
+ return {
63
+ "stage": self.stage,
64
+ "depends_on": list(self.depends_on),
65
+ "eligible": not reasons,
66
+ "reasons": reasons,
67
+ "head_commit": self.head_commit,
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class StageLifecycleSnapshot:
73
+ stage_map: list[dict[str, Any]]
74
+ rows: list[dict[str, Any]]
75
+ done_rows: list[dict[str, Any]]
76
+ done_by_stage: dict[int, dict[str, Any]]
77
+ done_stages: set[int]
78
+ started_stages: set[int]
79
+ reserved_stages: set[int]
80
+ verified_accepted_stages: set[int]
81
+ pr_covered_stages: set[int]
82
+ lifecycles: list[StageLifecycle]
83
+
84
+ def lifecycle_for(self, stage_number: int) -> StageLifecycle:
85
+ for lifecycle in self.lifecycles:
86
+ if lifecycle.stage == stage_number:
87
+ return lifecycle
88
+ raise StageTargetError(f"stage {stage_number} not in Stage Map")
89
+
90
+ def resolve_effective_stages(
91
+ self,
92
+ requested: str,
93
+ budget: int = RUN_STEP_BUDGET,
94
+ ) -> list[int]:
95
+ return resolve_effective_stages(
96
+ self.stage_map,
97
+ self.done_stages,
98
+ requested,
99
+ budget=budget,
100
+ started_stages=self.started_stages,
101
+ reserved_stages=self.reserved_stages,
102
+ )
103
+
104
+ def resolve_implementation_stage(self, requested: str) -> int:
105
+ return self.resolve_effective_stages(requested)[0]
106
+
107
+ def concurrent_stage_numbers(self, *, selected_stage: int) -> list[int]:
108
+ return sorted(self.reserved_stages - self.done_stages - {selected_stage})
109
+
110
+ def handoff_eligibility(self) -> list[dict[str, Any]]:
111
+ return [
112
+ lifecycle.handoff_eligibility_record()
113
+ for lifecycle in self.lifecycles
114
+ ]
115
+
116
+
117
+ def _stage_consumer_state_from_rows(rows: list[dict[str, Any]]) -> Any:
118
+ from .consumers import stage_consumer_state_from_rows
119
+
120
+ return stage_consumer_state_from_rows(rows)
121
+
122
+
123
+ def stage_lifecycle_snapshot_from_state(
124
+ stage_map: list[dict[str, Any]],
125
+ consumer_state: Any,
126
+ *,
127
+ reserved_stages: set[int] | None = None,
128
+ ) -> StageLifecycleSnapshot:
129
+ reserved = set(reserved_stages or set())
130
+ lifecycles: list[StageLifecycle] = []
131
+ for stage in stage_map:
132
+ stage_number = stage["stage_number"]
133
+ done_row = consumer_state.done_by_stage.get(stage_number) or {}
134
+ lifecycles.append(StageLifecycle(
135
+ stage=stage_number,
136
+ depends_on=list(stage.get("depends_on") or []),
137
+ done=stage_number in consumer_state.done_stages,
138
+ started=stage_number in consumer_state.started_stages,
139
+ reserved=stage_number in reserved,
140
+ verified_accepted=stage_number in (
141
+ consumer_state.verified_accepted_stages
142
+ ),
143
+ pr_covered=stage_number in consumer_state.pr_covered_stages,
144
+ head_commit=str(done_row.get("head_commit") or ""),
145
+ report_path=str(done_row.get("report_path") or ""),
146
+ ))
147
+ return StageLifecycleSnapshot(
148
+ stage_map=stage_map,
149
+ rows=list(consumer_state.rows),
150
+ done_rows=list(consumer_state.done_rows),
151
+ done_by_stage=dict(consumer_state.done_by_stage),
152
+ done_stages=set(consumer_state.done_stages),
153
+ started_stages=set(consumer_state.started_stages),
154
+ reserved_stages=reserved,
155
+ verified_accepted_stages=set(consumer_state.verified_accepted_stages),
156
+ pr_covered_stages=set(consumer_state.pr_covered_stages),
157
+ lifecycles=lifecycles,
158
+ )
159
+
160
+
161
+ def stage_lifecycle_snapshot_from_rows(
162
+ stage_map: list[dict[str, Any]],
163
+ rows: list[dict[str, Any]],
164
+ *,
165
+ reserved_stages: set[int] | None = None,
166
+ ) -> StageLifecycleSnapshot:
167
+ return stage_lifecycle_snapshot_from_state(
168
+ stage_map,
169
+ _stage_consumer_state_from_rows(rows),
170
+ reserved_stages=reserved_stages,
171
+ )
172
+
173
+
174
+ def read_stage_lifecycle_snapshot(
175
+ stage_map: list[dict[str, Any]],
176
+ plan_run_root: Path,
177
+ *,
178
+ recover_from_carry: bool = False,
179
+ reserved_stages: set[int] | None = None,
180
+ ) -> StageLifecycleSnapshot:
181
+ from .consumers import read_stage_consumer_state
182
+
183
+ return stage_lifecycle_snapshot_from_state(
184
+ stage_map,
185
+ read_stage_consumer_state(
186
+ plan_run_root,
187
+ recover_from_carry=recover_from_carry,
188
+ ),
189
+ reserved_stages=reserved_stages,
190
+ )
191
+
192
+
41
193
  def resolve_effective_stages(
42
194
  stages: list[dict[str, Any]],
43
195
  done_stages: set[int],
@@ -12,6 +12,7 @@ Public surface:
12
12
  - ``next_prompt()`` - deterministic; pure read on state.
13
13
  - ``submit()`` - validate + advance.
14
14
  - ``render_args()`` - final args for ``okstra render-bundle``.
15
+ - ``wizard_outcome()`` - final args plus persistence actions and confirmation.
15
16
 
16
17
  The skill calls these via the ``okstra wizard`` CLI subcommand; it never
17
18
  imports this module directly.
@@ -3707,6 +3708,36 @@ def confirmation_block(state: WizardState) -> str:
3707
3708
  return "\n".join(lines)
3708
3709
 
3709
3710
 
3711
+ def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
3712
+ if state.task_type != "release-handoff":
3713
+ return []
3714
+ if not state.pr_template_path:
3715
+ return []
3716
+ if state.pr_template_scope not in ("project", "global"):
3717
+ return []
3718
+ return [
3719
+ {
3720
+ "command": "config.set",
3721
+ "key": "pr-template-path",
3722
+ "scope": state.pr_template_scope,
3723
+ "value": state.pr_template_path,
3724
+ }
3725
+ ]
3726
+
3727
+
3728
+ def wizard_outcome(state: WizardState) -> dict[str, Any]:
3729
+ """Public outcome for callers that need launch data and follow-up writes."""
3730
+ if state.aborted:
3731
+ raise WizardError("wizard was aborted by the user — outcome is unavailable")
3732
+ if state.confirmed is not True:
3733
+ raise WizardError("wizard is not complete — outcome is unavailable")
3734
+ return {
3735
+ "renderArgs": render_args(state),
3736
+ "persistActions": _wizard_persist_actions(state),
3737
+ "confirmationText": confirmation_block(state),
3738
+ }
3739
+
3740
+
3710
3741
  # ---- File I/O helpers (used by CLI) -------------------------------------
3711
3742
 
3712
3743
  def load_state_file(path: Path) -> WizardState:
@@ -3731,6 +3762,7 @@ def _cli(argv: list[str]) -> int:
3731
3762
  step --state-file PATH (--answer VALUE | --no-submit)
3732
3763
  render-args --state-file PATH
3733
3764
  confirmation --state-file PATH
3765
+ outcome --state-file PATH
3734
3766
  """
3735
3767
  import argparse
3736
3768
 
@@ -3759,6 +3791,9 @@ def _cli(argv: list[str]) -> int:
3759
3791
  p_conf = sub.add_parser("confirmation")
3760
3792
  p_conf.add_argument("--state-file", required=True)
3761
3793
 
3794
+ p_outcome = sub.add_parser("outcome")
3795
+ p_outcome.add_argument("--state-file", required=True)
3796
+
3762
3797
  args = parser.parse_args(argv)
3763
3798
  state_path = Path(args.state_file)
3764
3799
 
@@ -3836,6 +3871,18 @@ def _cli(argv: list[str]) -> int:
3836
3871
  ensure_ascii=False, indent=2))
3837
3872
  return 0
3838
3873
 
3874
+ if args.cmd == "outcome":
3875
+ state = load_state_file(state_path)
3876
+ try:
3877
+ out = wizard_outcome(state)
3878
+ except WizardError as exc:
3879
+ print(json.dumps({"ok": False, "error": str(exc)},
3880
+ ensure_ascii=False, indent=2))
3881
+ return 0
3882
+ print(json.dumps({"ok": True, "outcome": out},
3883
+ ensure_ascii=False, indent=2))
3884
+ return 0
3885
+
3839
3886
  return 2
3840
3887
 
3841
3888
 
@@ -38,6 +38,7 @@ from .state import (
38
38
  resolve_task_identity,
39
39
  resolve_task_reference,
40
40
  slugify,
41
+ task_read_side_snapshot,
41
42
  )
42
43
 
43
44
  __all__ = [
@@ -64,6 +65,7 @@ __all__ = [
64
65
  "resolve_task_identity",
65
66
  "resolve_task_reference",
66
67
  "slugify",
68
+ "task_read_side_snapshot",
67
69
  "tasks_root",
68
70
  "upsert_project_json",
69
71
  ]
@@ -37,6 +37,10 @@ from .dirs import (
37
37
  class StateError(Exception):
38
38
  """state file 읽기/파싱 실패 — 호출자가 surface 해야 할 오류."""
39
39
 
40
+ def __init__(self, message: str, *, stage: Optional[str] = None):
41
+ super().__init__(message)
42
+ self.stage = stage
43
+
40
44
 
41
45
  def _load_json(path: Path) -> Optional[dict]:
42
46
  if not path.is_file():
@@ -268,10 +272,16 @@ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
268
272
  """
269
273
  task_root = find_task_root(project_root, task_key)
270
274
  if task_root is None:
271
- raise StateError(f"task root not found for {task_key!r}")
275
+ raise StateError(
276
+ f"task root not found for {task_key!r}",
277
+ stage="task_root_missing",
278
+ )
272
279
  manifest = read_task_manifest(task_root)
273
280
  if manifest is None:
274
- raise StateError(f"task-manifest.json missing under {task_root}")
281
+ raise StateError(
282
+ f"task-manifest.json missing under {task_root}",
283
+ stage="manifest_missing",
284
+ )
275
285
  workflow = manifest.get("workflow") or {}
276
286
  if not isinstance(workflow, dict):
277
287
  workflow = {}
@@ -292,3 +302,35 @@ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
292
302
  "taskBriefPath": manifest.get("taskBriefPath") or "",
293
303
  "manifest": manifest,
294
304
  }
305
+
306
+
307
+ def task_read_side_snapshot(project_root: Path, task_key: str) -> dict:
308
+ """task-show / inspect 용 curated task 상태 view 를 반환한다.
309
+
310
+ resolve_task_identity 가 내부 raw manifest 를 들고 있지만, 이 accessor 는
311
+ caller 에게 manifest shape 를 노출하지 않는 read-side 계약이다.
312
+ """
313
+ identity = resolve_task_identity(project_root, task_key)
314
+ manifest = identity["manifest"]
315
+ workflow = manifest.get("workflow")
316
+ if not isinstance(workflow, dict):
317
+ workflow = {}
318
+ return {
319
+ "projectRoot": identity["projectRoot"],
320
+ "taskKey": manifest.get("taskKey"),
321
+ "taskType": manifest.get("taskType"),
322
+ "taskRoot": identity["taskRoot"],
323
+ "taskBriefPath": manifest.get("taskBriefPath"),
324
+ "workflow": {
325
+ "currentPhase": workflow.get("currentPhase"),
326
+ "currentPhaseState": workflow.get("currentPhaseState"),
327
+ "lastCompletedPhase": workflow.get("lastCompletedPhase"),
328
+ "nextRecommendedPhase": workflow.get("nextRecommendedPhase"),
329
+ "routingStatus": workflow.get("routingStatus"),
330
+ "phaseStates": workflow.get("phaseStates"),
331
+ },
332
+ "resultContract": manifest.get("resultContract"),
333
+ "artifacts": manifest.get("artifacts"),
334
+ "modelAssignments": manifest.get("modelAssignments"),
335
+ "latestRunPath": manifest.get("latestRunPath"),
336
+ }
@@ -145,13 +145,30 @@ Output: `{ok: true, text: "선택 확인:\n task-type : ...\n ..."}`. Prin
145
145
 
146
146
  ## Step 5: Render the task bundle
147
147
 
148
- When `next.kind == "done"`, fetch the final args:
148
+ When `next.kind == "done"`, fetch the public wizard outcome:
149
149
 
150
150
  ```bash
151
- okstra wizard render-args --state-file /var/folders/.../okstra-wizard.AbCd.json
151
+ okstra wizard outcome --state-file /var/folders/.../okstra-wizard.AbCd.json
152
152
  ```
153
153
 
154
- Output: `{ok: true, args: {"project-root": "...", "task-type": "...", ...}}`. Build the `okstra render-bundle` invocation from `args`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
154
+ Output: `{ok: true, outcome: {renderArgs: {...}, persistActions: [...], confirmationText: "..."}}`.
155
+
156
+ Run every `outcome.persistActions[]` entry BEFORE `render-bundle`. The only supported action is:
157
+
158
+ ```json
159
+ {"command":"config.set","key":"pr-template-path","scope":"project|global","value":"<path>"}
160
+ ```
161
+
162
+ Execute the matching command for `scope`:
163
+
164
+ ```bash
165
+ okstra config set pr-template-path "<value>" --scope project
166
+ okstra config set pr-template-path "<value>" --scope global
167
+ ```
168
+
169
+ If an action has an unknown `command`, `key`, or `scope`, stop and report the wizard output instead of inventing a command.
170
+
171
+ Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
155
172
 
156
173
  **Empty-value rule (same as Step 3)**: every flag whose value is the empty string MUST still be passed explicitly as `--<key> ""`. For example: `--workers ""`, `--directive ""`, `--related-tasks ""`. Omitting the flag is forbidden — `render-bundle` distinguishes "flag absent" from "flag present with empty value", and the wizard's intent is always the latter.
157
174
 
@@ -295,20 +312,7 @@ stale-SHA 재조정(git-reconcile 분기)을 띄우면, **그 stage 에서 연
295
312
 
296
313
  ## Persisting the PR template scope (release-handoff)
297
314
 
298
- When `wizard render-args` returns a non-empty `pr-template-path` AND the state has `pr_template_scope == "project"` or `"global"`, run the matching config command BEFORE `render-bundle`:
299
-
300
- ```bash
301
- # project scope
302
- okstra config set pr-template-path "<path>" --scope project
303
- # global scope (must be absolute or ~/-prefixed)
304
- okstra config set pr-template-path "<path>" --scope global
305
- ```
306
-
307
- The scope is held in the wizard state but is not yet exposed by any `okstra wizard` subcommand. Until the subcommand below ships, read the JSON state file directly with the `Read` tool (literal path captured in Step 2) and inspect the `pr_template_scope` field — it is a plain serialized `WizardState`. Do not shell out (`python3 -c`, `jq`, etc.); the literal-token Bash rule rejects them.
308
-
309
- ## Out-of-scope backlog
310
-
311
- - **`okstra wizard pr-template-scope --state-file PATH`**: add a thin subcommand to `scripts/okstra_ctl/wizard.py` that prints `{ok: true, scope: "once" | "project" | "global"}` so this skill can drop the `Read`-the-raw-state-file detour. The subcommand should reuse the existing `load_state_file` path; no schema changes required.
315
+ Do not read the wizard state file directly. `okstra wizard outcome` exposes any release-handoff PR template write as `outcome.persistActions[]`; execute those actions before `render-bundle`.
312
316
 
313
317
  ## Concurrency
314
318