okstra 0.118.0 → 0.119.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.118.0",
3
+ "version": "0.119.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.118.0",
3
- "builtAt": "2026-07-15T06:18:58.589Z",
2
+ "package": "0.119.0",
3
+ "builtAt": "2026-07-15T11:10:05.274Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -23,7 +23,6 @@ from __future__ import annotations
23
23
 
24
24
  import argparse
25
25
  import os
26
- import re
27
26
  import sys
28
27
  from pathlib import Path
29
28
 
@@ -43,7 +42,7 @@ if (SCRIPTS_DIR / "okstra_ctl" / "report_views.py").is_file():
43
42
  elif HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
44
43
  sys.path.insert(0, str(HOME_LIB))
45
44
 
46
- from okstra_ctl.report_views import RunMeta, render_html_view # noqa: E402
45
+ from okstra_ctl.report_views import infer_run_meta, render_html_view # noqa: E402
47
46
 
48
47
 
49
48
  _OKSTRA_HOME = Path(os.environ.get("OKSTRA_HOME", str(Path.home() / ".okstra")))
@@ -60,15 +59,6 @@ _TEMPLATES_DIRS = (
60
59
  _OKSTRA_HOME / "templates" / "reports",
61
60
  )
62
61
 
63
- # task-type itself can contain hyphens (``implementation-planning``,
64
- # ``final-verification``, ``release-handoff``), so the filename segment
65
- # between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
66
- # trailing ``-(?P<seq>\d+)\.md$`` anchor pins seq to the last numeric tail.
67
- _PATH_RE = re.compile(
68
- r"runs/(?P<task_type>[^/]+)/reports/final-report-.+-(?P<seq>\d+)\.md$"
69
- )
70
-
71
-
72
62
  def _load_assets() -> tuple[str, str]:
73
63
  css_text: str | None = None
74
64
  js_text: str | None = None
@@ -87,23 +77,6 @@ def _load_assets() -> tuple[str, str]:
87
77
  return css_text, js_text
88
78
 
89
79
 
90
- def _infer_from_path(path: Path) -> dict[str, str]:
91
- posix = path.as_posix()
92
- m = _PATH_RE.search(posix)
93
- if m:
94
- return {"task_type": m.group("task_type"), "seq": m.group("seq")}
95
- return {}
96
-
97
-
98
- def _infer_from_body(text: str) -> dict[str, str]:
99
- found: dict[str, str] = {}
100
- for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
101
- m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
102
- if m:
103
- found[key] = m.group(1)
104
- return found
105
-
106
-
107
80
  def main(argv: list[str] | None = None) -> int:
108
81
  parser = argparse.ArgumentParser(
109
82
  description="Render the self-contained HTML view of an okstra final-report."
@@ -119,14 +92,11 @@ def main(argv: list[str] | None = None) -> int:
119
92
  if not report_path.is_file():
120
93
  parser.error(f"final-report not found: {report_path}")
121
94
 
122
- inferred = {**_infer_from_path(report_path), **_infer_from_body(report_path.read_text(encoding="utf-8"))}
123
- task_key = args.task_key or inferred.get("task_key") or "unknown"
124
- task_type = args.task_type or inferred.get("task_type") or "unknown"
125
- seq = args.seq or inferred.get("seq") or "000"
126
- source_report = args.source_report or report_path.name
127
-
95
+ meta = infer_run_meta(
96
+ report_path, task_key=args.task_key, task_type=args.task_type,
97
+ seq=args.seq, source_report=args.source_report,
98
+ )
128
99
  css, js = _load_assets()
129
- meta = RunMeta(task_key=task_key, task_type=task_type, seq=seq, source_report=source_report)
130
100
  html_path = render_html_view(report_path, run_meta=meta, css=css, js=js)
131
101
  if html_path is None:
132
102
  print("html: skipped (no §1 clarification rows — html view carries no interactive forms for this report)")
@@ -85,6 +85,7 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
85
85
  - Source path: `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}`
86
86
  - If the source path above is empty, no prior clarification response was attached to this run.
87
87
  - If the source path is set, a copy is staged at `{{INSTRUCTION_SET_RELATIVE_PATH}}/clarification-response.md`. Read it before running workers; reconcile each `C-*` row in section 1 (`## 1. Clarification Items`) of the prior report against new evidence and record the outcome in the conditional `## 0. Clarification Response Carried In From Previous Run` section of this run's final report (render that heading only when carry-in is non-empty — the validator fails empty Section 0 stubs).
88
+ - When a `## <C-id>` block carries `- Disposition: reframe`, it is not an answer but a request to re-question — the user is asking you to redefine this item. Do not treat it as answered; reconstruct the question itself in a fresh `## 1` Clarification Item, and never let a `reframe` item satisfy the approval gate.
88
89
 
89
90
  ### Incremental re-verification (implementation-planning clarification re-runs only)
90
91
 
@@ -208,6 +208,54 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
208
208
  return _walk_section_1_table(section).items
209
209
 
210
210
 
211
+ def parse_clarification_rows(report_text: str) -> list[dict]:
212
+ """§1 행마다 메타(ClarificationItem) + 원문 Statement / Expected form 셀.
213
+
214
+ ``parse_clarification_items`` 와 같은 lenient 계약: §1 부재 또는 헤더
215
+ 미인식이면 ``[]``. §1 테이블 셀 추출의 단일 참조점 — 다른 모듈이 §1
216
+ 레이아웃을 다시 훑지 않도록 이 함수로 통일한다.
217
+ """
218
+ section = _section_1_slice(report_text)
219
+ if section is None:
220
+ return []
221
+ lines = section.splitlines()
222
+ header_idx = -1
223
+ for idx, line in enumerate(lines):
224
+ if not line.lstrip().startswith("|"):
225
+ continue
226
+ cells = [c.lower() for c in _split_pipe_row(line)]
227
+ if "user input" in cells and any(c.startswith("statement") for c in cells):
228
+ header_idx = idx
229
+ break
230
+ if header_idx < 0:
231
+ return []
232
+ header = [c.lower() for c in _split_pipe_row(lines[header_idx])]
233
+ s_col = next((j for j, h in enumerate(header) if h.startswith("statement")), -1)
234
+ e_col = next((j for j, h in enumerate(header) if h.startswith("expected form")), -1)
235
+ rows: list[dict] = []
236
+ body = False
237
+ for line in lines[header_idx + 1:]:
238
+ if not line.lstrip().startswith("|"):
239
+ if body:
240
+ break
241
+ continue
242
+ if is_separator_row(line):
243
+ body = True
244
+ continue
245
+ if not body:
246
+ continue
247
+ cells = _split_pipe_row(line)
248
+ item = parse_meta_cell(cells[0]) if cells else None
249
+ if item is None:
250
+ continue
251
+ rows.append({
252
+ "item": item,
253
+ "statement": cells[s_col] if 0 <= s_col < len(cells) else "",
254
+ "expected_form": cells[e_col] if 0 <= e_col < len(cells) else "",
255
+ })
256
+ return rows
257
+
258
+
211
259
  UNRESOLVED_STATUSES = {"open", "answered"}
212
260
 
213
261
 
@@ -108,6 +108,46 @@ class RunMeta:
108
108
  source_report: str # relative path of the .md the HTML is derived from
109
109
 
110
110
 
111
+ # task-type itself can contain hyphens (``implementation-planning``,
112
+ # ``final-verification``, ``release-handoff``), so the filename segment
113
+ # between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
114
+ # trailing ``-(?P<seq>\d+)\.md$`` anchor pins seq to the last numeric tail.
115
+ _REPORT_PATH_RE = re.compile(
116
+ r"runs/(?P<task_type>[^/]+)/reports/final-report-.+-(?P<seq>\d+)\.md$"
117
+ )
118
+
119
+
120
+ def _infer_run_meta_from_path(path: Path) -> dict:
121
+ m = _REPORT_PATH_RE.search(path.as_posix())
122
+ return {"task_type": m.group("task_type"), "seq": m.group("seq")} if m else {}
123
+
124
+
125
+ def _infer_run_meta_from_body(text: str) -> dict:
126
+ found: dict[str, str] = {}
127
+ for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
128
+ m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
129
+ if m:
130
+ found[key] = m.group(1)
131
+ return found
132
+
133
+
134
+ def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
135
+ task_type: Optional[str] = None, seq: Optional[str] = None,
136
+ source_report: Optional[str] = None) -> RunMeta:
137
+ """Derive a ``RunMeta`` from a final-report path/body, honouring any
138
+ explicit override. Single reference point for BOTH the HTML view render
139
+ script and the in-session user-response writer, so sidecar match keys
140
+ (task_type/seq/source-report) never drift between the two paths."""
141
+ text = report_path.read_text(encoding="utf-8")
142
+ inferred = {**_infer_run_meta_from_path(report_path), **_infer_run_meta_from_body(text)}
143
+ return RunMeta(
144
+ task_key=task_key or inferred.get("task_key") or "unknown",
145
+ task_type=task_type or inferred.get("task_type") or "unknown",
146
+ seq=seq or inferred.get("seq") or "000",
147
+ source_report=source_report or report_path.name,
148
+ )
149
+
150
+
111
151
  @dataclass(frozen=True)
112
152
  class ReportViewModel:
113
153
  run_meta: RunMeta
@@ -924,6 +964,7 @@ class UserResponseEntry:
924
964
  kind: str
925
965
  value: str
926
966
  rationale: Optional[str] = None
967
+ disposition: str = "answer"
927
968
 
928
969
 
929
970
  @dataclass(frozen=True)
@@ -961,12 +1002,11 @@ def serialize_user_response(
961
1002
  has_approval = approval is not None and approval.approved
962
1003
  body_chunks: list[str] = []
963
1004
  for e in entries:
964
- chunk = (
965
- f"\n## {e.response_id}\n"
966
- f"- Kind: {e.kind}\n"
967
- "- Value:\n"
968
- f" > {e.value.strip()}\n"
969
- )
1005
+ chunk = f"\n## {e.response_id}\n- Kind: {e.kind}\n"
1006
+ if e.disposition != "answer":
1007
+ chunk += f"- Disposition: {e.disposition}\n"
1008
+ value_lines = e.value.strip().split("\n")
1009
+ chunk += "- Value:\n" + "".join(f" > {ln}\n" for ln in value_lines)
970
1010
  if e.rationale:
971
1011
  chunk += f"- Rationale: {e.rationale.strip()}\n"
972
1012
  body_chunks.append(chunk)
@@ -8,10 +8,26 @@ the ``## APPROVAL`` block — the implementation wizard consumes it to offer
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import argparse
12
+ import datetime as dt
13
+ import json
11
14
  import re
15
+ import sys
12
16
  from dataclasses import dataclass
17
+ from pathlib import Path
13
18
  from typing import Optional
14
19
 
20
+ from okstra_ctl.report_views import (
21
+ serialize_user_response, UserResponseEntry, UserResponseApproval, infer_run_meta,
22
+ )
23
+ from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
24
+ from okstra_ctl.listing import list_runs, absolute_final_report_path
25
+ from okstra_ctl.clarification_items import (
26
+ parse_clarification_rows,
27
+ scan_approval_gate,
28
+ section_1_present_but_unparsed,
29
+ )
30
+
15
31
  _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
16
32
  _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
17
33
 
@@ -53,3 +69,177 @@ def parse_user_response_approval(
53
69
  source_report=rm.group(1) if rm else "",
54
70
  seq=sm.group(1) if sm else "",
55
71
  )
72
+
73
+
74
+ _RESPONSE_HEADING_RE = re.compile(r"^## (?P<id>[A-Za-z][A-Za-z0-9]*-\d+)\s*$", re.MULTILINE)
75
+
76
+
77
+ def _field(block: str, key: str) -> Optional[str]:
78
+ m = re.search(rf"^- {re.escape(key)}:\s*(\S.*?)\s*$", block, re.MULTILINE)
79
+ return m.group(1) if m else None
80
+
81
+
82
+ def _value(block: str) -> str:
83
+ # Value 는 "- Value:" 다음 줄들의 " > " 인용 블록.
84
+ m = re.search(r"^- Value:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE)
85
+ if not m:
86
+ return ""
87
+ lines = [re.sub(r"^\s*>\s?", "", ln) for ln in m.group(1).splitlines()]
88
+ return "\n".join(lines).strip()
89
+
90
+
91
+ def parse_user_response_entries(sidecar_text: str) -> list[UserResponseEntry]:
92
+ """Reverse of ``serialize_user_response`` for the per-response ``## C-*``
93
+ blocks. The ``## APPROVAL`` block is skipped (read separately by
94
+ ``parse_user_response_approval``)."""
95
+ entries: list[UserResponseEntry] = []
96
+ matches = list(_RESPONSE_HEADING_RE.finditer(sidecar_text))
97
+ for i, m in enumerate(matches):
98
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(sidecar_text)
99
+ block = sidecar_text[m.end():end]
100
+ rationale = _field(block, "Rationale")
101
+ entries.append(UserResponseEntry(
102
+ response_id=m.group("id"),
103
+ kind=_field(block, "Kind") or "",
104
+ value=_value(block),
105
+ rationale=rationale,
106
+ disposition=_field(block, "Disposition") or "answer",
107
+ ))
108
+ return entries
109
+
110
+
111
+ _SEQ_FROM_REPORT_RE = re.compile(r"final-report-.+-(\w+)\.md$")
112
+
113
+
114
+ def _seq_from_report(report: Path) -> str:
115
+ m = _SEQ_FROM_REPORT_RE.search(report.name)
116
+ return m.group(1) if m else ""
117
+
118
+
119
+ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
120
+ """approval-open clarification 이 있는 태스크를 최신 report mtime 순으로."""
121
+ seen: set[str] = set()
122
+ out: list[dict] = []
123
+ for row in list_runs(home, project=project_id, limit=0): # startedAt desc
124
+ grp, tid = row.get("taskGroup", ""), row.get("taskId", "")
125
+ key = f"{grp}/{tid}" if grp and tid else ""
126
+ if not key or key in seen:
127
+ continue
128
+ seen.add(key)
129
+ report = absolute_final_report_path(row)
130
+ if report is None or not report.is_file():
131
+ continue
132
+ text = report.read_text(encoding="utf-8")
133
+ scan = scan_approval_gate(text)
134
+ base = {"taskKey": key, "taskType": row.get("taskType", ""),
135
+ "seq": _seq_from_report(report), "reportPath": str(report),
136
+ "reportMtime": report.stat().st_mtime}
137
+ if scan.unreadable_reason is not None:
138
+ # §1 heading exists but drifted → real warning; §1 simply absent → skip.
139
+ if section_1_present_but_unparsed(text):
140
+ out.append({**base, "openApprovalCount": 0, "unreadable": True})
141
+ continue
142
+ if not scan.blockers:
143
+ continue
144
+ out.append({**base, "openApprovalCount": len(scan.blockers), "unreadable": False})
145
+ out.sort(key=lambda t: t["reportMtime"], reverse=True)
146
+ return out[:limit] if limit > 0 else out
147
+
148
+
149
+ _SECTION_REF_RE = re.compile(r"§[\d.]+|C-\d+|[\w./-]+\.\w+:\d+")
150
+ _ALTERNATIVES_CUE = "Alternatives:"
151
+
152
+
153
+ def _alternatives_from_expected(expected_form: str) -> list[str]:
154
+ idx = expected_form.find(_ALTERNATIVES_CUE)
155
+ if idx < 0:
156
+ return []
157
+ tail = expected_form[idx + len(_ALTERNATIVES_CUE):]
158
+ parts = re.split(r"[/;]", tail)
159
+ return [p.strip(" .,;—-") for p in parts if p.strip(" .,;—-")]
160
+
161
+
162
+ def show_open_rows(report_path: Path) -> dict:
163
+ text = report_path.read_text(encoding="utf-8")
164
+ rows = []
165
+ for r in parse_clarification_rows(text):
166
+ it = r["item"]
167
+ if it.status not in ("open", "answered"):
168
+ continue
169
+ statement, expected = r["statement"], r["expected_form"]
170
+ refs = sorted(set(_SECTION_REF_RE.findall(statement + " " + expected)))
171
+ rows.append({"id": it.row_id, "kind": it.kind, "blocks": it.blocks,
172
+ "status": it.status, "statement": statement,
173
+ "recommended": expected, # raw cell keeps answer + rationale
174
+ "alternatives": _alternatives_from_expected(expected),
175
+ "contextRefs": refs})
176
+ return {"reportPath": str(report_path), "rows": rows}
177
+
178
+
179
+ def write_sidecar(report_path: Path, answers: list[dict],
180
+ approval: Optional[dict], created_at: str,
181
+ task_key: str = "") -> Path:
182
+ run_meta = infer_run_meta(report_path, task_key=task_key or None)
183
+ out_dir = user_responses_dir_for_report(report_path)
184
+ out_dir.mkdir(parents=True, exist_ok=True)
185
+ sidecar = out_dir / f"user-response-{run_meta.task_type}-{run_meta.seq}.md"
186
+
187
+ merged: dict[str, UserResponseEntry] = {}
188
+ if sidecar.is_file():
189
+ for e in parse_user_response_entries(sidecar.read_text(encoding="utf-8")):
190
+ merged[e.response_id] = e
191
+ for a in answers:
192
+ merged[a["id"]] = UserResponseEntry(
193
+ response_id=a["id"], kind=a.get("kind", ""), value=a["value"],
194
+ rationale=a.get("rationale"), disposition=a.get("disposition", "answer"))
195
+
196
+ appr = None
197
+ if approval and approval.get("approved"):
198
+ appr = UserResponseApproval(
199
+ approved=True, implementation_option=approval.get("implementationOption", ""))
200
+ sidecar.write_text(
201
+ serialize_user_response(run_meta=run_meta, entries=list(merged.values()),
202
+ created_at=created_at, approval=appr),
203
+ encoding="utf-8")
204
+ return sidecar
205
+
206
+
207
+ def main(argv: Optional[list[str]] = None) -> int:
208
+ parser = argparse.ArgumentParser(prog="okstra user-response")
209
+ sub = parser.add_subparsers(dest="cmd", required=True)
210
+
211
+ pl = sub.add_parser("list")
212
+ pl.add_argument("--home", required=True)
213
+ pl.add_argument("--project", required=True)
214
+ pl.add_argument("--limit", type=int, default=3)
215
+
216
+ ps = sub.add_parser("show")
217
+ ps.add_argument("--report", required=True)
218
+
219
+ pw = sub.add_parser("write")
220
+ pw.add_argument("--report", required=True)
221
+ pw.add_argument("--answers", required=True, help="JSON array of answer entries")
222
+ pw.add_argument("--approval", default="", help="JSON approval object")
223
+ pw.add_argument("--task-key", default="", help="task-key from list/show context")
224
+
225
+ ns = parser.parse_args(argv)
226
+ if ns.cmd == "list":
227
+ out = list_awaiting_tasks(Path(ns.home), ns.project, ns.limit)
228
+ json.dump(out, sys.stdout, ensure_ascii=False)
229
+ return 0
230
+ if ns.cmd == "show":
231
+ json.dump(show_open_rows(Path(ns.report)), sys.stdout, ensure_ascii=False)
232
+ return 0
233
+ if ns.cmd == "write":
234
+ answers = json.loads(ns.answers)
235
+ approval = json.loads(ns.approval) if ns.approval else None
236
+ created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
237
+ p = write_sidecar(Path(ns.report), answers, approval, created_at,
238
+ task_key=ns.task_key)
239
+ json.dump({"sidecar": str(p)}, sys.stdout, ensure_ascii=False)
240
+ return 0
241
+ return 1
242
+
243
+
244
+ if __name__ == "__main__":
245
+ raise SystemExit(main())
@@ -777,7 +777,7 @@ okstra recap note <resolved-target> --project-root <projectRoot> \
777
777
  **self-check 규칙 (validator 가 없으니 스스로 지킨다):**
778
778
 
779
779
  1. **렌더된 report 를 수기 편집하지 않는다** (`runs/*/reports/` 아래 `*.md` / `*.data.json` / `*.html`). `data.json` 에서 재생성되므로 편집은 덮어써지고 SSOT 와 어긋난다. 발견 내용은 `notes/` 에 쓴다.
780
- 2. **`user-responses/` 파일을 작성하거나 `created-by: user` 를 달지 않는다.** 그건 사용자의 결정이며, 대신 쓰면 사용자가 내리지 않은 결정을 위조하는 것이다. 네 증거가 특정 답을 뒷받침하면 `notes/` 에 그렇게 적고 결정은 사용자에게 맡긴다.
780
+ 2. **`user-responses/` 파일을 작성하거나 `created-by: user` 를 달지 않는다.** 그건 사용자의 결정이며, 대신 쓰면 사용자가 내리지 않은 결정을 위조하는 것이다. 네 증거가 특정 답을 뒷받침하면 `notes/` 에 그렇게 적고 결정은 사용자에게 맡긴다. 단, `okstra-user-response` 스킬의 에코백 확인 흐름은 예외다 — 그 흐름에서는 사용자가 세션에서 직접 결정을 내리고 CLI 는 전사 채널일 뿐이므로, `write` 로 `created-by: user` sidecar 를 기록하는 것이 정당하다. 이 예외는 사용자가 명시적으로 "맞음" 을 확인한 뒤에만, verbatim 입력에 대해서만 성립한다.
781
781
  3. **okstra 관리 디렉토리에 쓰지 않는다** (`runs/`, `instruction-set/`, `history/`, `recap/`, `.okstra/decisions/`). `notes/` 만이 agent 소유 레인이다. `recap/recap-log.jsonl` 은 예외적으로 쓰되 손으로 편집하지 말고 `okstra recap record` CLI 로만 남긴다.
782
782
  4. **`notes/` 는 okstra 에 inert 하다** — 어떤 run 도 자동으로 읽지 않는다. 작성 후 CLI 가 출력한 `clarificationResponseArg`(예: `--clarification-response <notePath>`)를 사용자에게 그대로 알려, 다음 run 을 그 인자와 함께 실행해야만 반영된다는 것을 전한다.
783
783
 
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: okstra-user-response
3
+ description: >-
4
+ Use this to answer an okstra task's open clarification questions in-session, without hand-editing any file. The tell is a request to respond to an okstra run's clarification items or its approval gate — "answer okstra", "I'll answer the questions", "clarification response", "user response", "approve and move on". This skill lists tasks whose latest report still has open clarification blockers, presents each open C-id (statement + recommended answer + alternatives), collects the user's own verbatim answers (or a reframe/hold), echoes the full sidecar back for an explicit "confirmed" acknowledgement, optionally records approval, and writes the `user-responses/` sidecar via `okstra user-response write`. NOT for starting a run (okstra-run), inspecting a finished task (okstra-inspect), or generating a brief (okstra-brief-gen). The skill never picks an answer for the user — it only relays recommendations and transcribes what the user decides.
5
+ ---
6
+
7
+ # OKSTRA User Response
8
+
9
+ Single entry point for answering the clarification questions an okstra run left behind (the open `C-*` rows under the final report's `## 1. Clarification Items`) **in-session**, and recording those answers as a `runs/<type>/user-responses/` sidecar. The next `/okstra-run` auto-attaches this sidecar via `--clarification-response`.
10
+
11
+ **Core principle — the skill never picks an answer for the user.** This skill only *presents* recommendations and alternatives; every `value` is the user's own verbatim input. It never calls `write` until the user has explicitly confirmed.
12
+
13
+ | Sub-command | What it does |
14
+ |---|---|
15
+ | `list` | List tasks that still have approval-open clarification, newest report first. |
16
+ | `show` | Expand one report's open `C-*` rows (statement + recommended + alternatives + contextRefs). |
17
+ | `write` | Record the collected answers (+ optional approval) as a `user-responses/` sidecar. |
18
+
19
+ ## Step 0: Preflight (shared)
20
+
21
+ Before anything, run one Bash tool call whose leading token is the literal `okstra` (never wrapped in `if`/`eval`/`export`/`$(...)`/`VAR=...`/`||`/`&&`/`npx` — a non-literal leading token defeats the `Bash(okstra:*)` permission match):
22
+
23
+ ```bash
24
+ okstra preflight --runtime claude-code --json
25
+ ```
26
+
27
+ Branch on the stdout JSON:
28
+ - `ok: true` → carry `projectRoot` and `projectId` as literal strings; they are the base for every step below.
29
+ - `ok: false` → this project has no okstra setup. Tell the user: "this project has no okstra setup. Run `/okstra-setup` first." Then stop. If the user pointed at a specific project directory, re-run targeting it: `okstra preflight --runtime claude-code --cwd <that-dir> --json` (`--cwd` is the sanctioned way to target a project — a leading `cd` would break the permission match); only if that **also** returns `ok:false` do you stop. If the call fails with `unknown command: preflight`, the `okstra` binary on PATH predates this skill — tell the user to update it (`npm i -g okstra@latest`), then stop (`/okstra-setup` does not update the binary).
30
+
31
+ Then resolve the okstra home once (the `list` sub-command needs it):
32
+
33
+ ```bash
34
+ okstra paths --field home
35
+ ```
36
+
37
+ Paste the printed path literally into `--home` below. Every subsequent `okstra <subcmd>` call self-bootstraps its Python path, so this skill never needs `okstra paths --shell` / `export PYTHONPATH=...`.
38
+
39
+ ## Step 1: List awaiting tasks → picker
40
+
41
+ ```bash
42
+ okstra user-response list --home <resolved-home> --project <projectId> --limit 3
43
+ ```
44
+
45
+ Returns a JSON array (latest report mtime first); each entry:
46
+ `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`.
47
+
48
+ - Empty array → answer `No task has open clarification items.` and stop.
49
+ - Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType, N open items)`), and the **final option is always "Enter directly"** — where the user pastes a `reportPath` or a `task-key` directly (for a task not in the top-3 window).
50
+ - An entry with `unreadable: true` means its `## 1. Clarification Items` heading exists but drifted from the expected format. Flag it as `⚠ §1 format drift — the CLI could not parse its items` and do not proceed on it until the report is regenerated; do not fabricate rows for it.
51
+
52
+ Carry the chosen entry's `reportPath` and `taskKey` forward.
53
+
54
+ ## Step 2: Show open rows
55
+
56
+ ```bash
57
+ okstra user-response show --report <reportPath>
58
+ ```
59
+
60
+ Returns `{reportPath, rows: [{id, kind, blocks, status, statement, recommended, alternatives, contextRefs}]}`. For each open `C-*` row, present to the user:
61
+
62
+ - **`id`** and its **`statement`** (the original question).
63
+ - **`recommended`** — the answer okstra proposed (+ rationale). It is only a recommendation.
64
+ - **`alternatives[]`** — list them if present.
65
+ - **`contextRefs[]`** — the report `§`/`C-id`/`path:line` references this question points at.
66
+
67
+ ## Step 3: Per-item — collect the user's decision (4 branches)
68
+
69
+ For each open item, the user picks one of four dispositions. You relay and transcribe; you never choose:
70
+
71
+ 1. **Answer** — the user accepts the recommendation or gives their own answer. `disposition: "answer"`, `value` = the user's utterance verbatim.
72
+ 2. **Ask-to-explain** — if the user asks "what does this mean?", open the report `§`/`path:line` that this item's `contextRefs[]` points at **using Read** and explain it in plain language. Only explain — **do not pick the answer for the user**; after explaining, hand the decision back to the user.
73
+ 3. **Free-form** — the user gives a narrative answer that matches neither a recommendation nor an alternative. `value` = that narrative verbatim, with the rationale in `rationale` if needed. `disposition: "answer"`.
74
+ 4. **Hold + reframe** — if the user asks to "re-frame this question itself", record it as `disposition: "reframe"`. Put what the user wants re-asked into `value` verbatim. A reframe is not an answer, so it does not satisfy the approval gate — the next run re-frames that question.
75
+
76
+ Each answer item's JSON shape: `{id, kind, value, rationale?, disposition}`. `kind` uses the same `kind` that `show` gave for that row.
77
+
78
+ ## Step 4: Echo the full sidecar back → explicit "confirmed" gate
79
+
80
+ Once every open item is handled, **before** calling `write`, **echo back** everything collected (each `id`, `disposition`, `value`, `rationale` if present, and the approval decision from Step 5 below) to the user as-is. Then ask for explicit confirmation:
81
+
82
+ > Record it as shown above? (Reply `confirmed` to write the sidecar.)
83
+
84
+ - **Never call `write`** until the user says `confirmed` (or gives clear approval).
85
+ - If the user changes any item, show the echo-back again with the changed value and re-confirm.
86
+ - Every `value` must be the user's utterance verbatim — this gate guarantees that.
87
+
88
+ ## Step 5: (Optional) Record approval
89
+
90
+ Record approval alongside only when the report's approval-blocking clarification items are **all filled with an answer** and the user explicitly said "approve with this option":
91
+
92
+ ```
93
+ --approval '{"approved":true,"implementationOption":"<the option the user chose>"}'
94
+ ```
95
+
96
+ If any approval-target item is not filled with an answer, or is a reframe/hold, do not approve — tell the user the gate is still open.
97
+
98
+ ## Step 6: Write the sidecar
99
+
100
+ Run only after the user has confirmed the echo-back with "confirmed":
101
+
102
+ ```bash
103
+ okstra user-response write --report <reportPath> --answers '<answers-json>' --approval '<approval-json>' --task-key <taskKey>
104
+ ```
105
+
106
+ - `--answers` = the JSON array of `{id, kind, value, rationale?, disposition}` objects collected in Step 3.
107
+ - `--approval` is attached only when recording approval in Step 5 (otherwise omit it).
108
+ - `--task-key` is the value carried forward from Step 1/2.
109
+ - Report the path from the returned JSON `{sidecar: <path>}` to the user. (`write` merges items when a sidecar already exists — the same `id` is overwritten with the new value.)
110
+
111
+ ## Step 7: Self-describing next-step guidance
112
+
113
+ Leave the following in the final output as-is:
114
+
115
+ > This answer was recorded in the `user-responses/` sidecar (`<sidecar path>`). Re-running this task's `<task-type>` with `/okstra-run` auto-attaches this answer (if you approved all approval-blocking items, the next phase is `implementation`).
116
+
117
+ ## Output Rules (shared)
118
+
119
+ - Keep responses concise and in the language the user is using, unless they ask otherwise.
120
+ - Give path guidance host-relative (`~/.okstra/...` or relative to the `projectRoot` preflight returned). Use repo paths only when pointing at a code source.
121
+ - This skill never hand-edits a rendered report (`runs/*/reports/*.md` / `*.data.json`). Writing the `user-responses/` sidecar goes only through the `okstra user-response write` CLI.
@@ -144,9 +144,12 @@
144
144
  var e = entries[i];
145
145
  var chunk =
146
146
  "\n## " + e.responseId + "\n" +
147
- "- Kind: " + e.kind + "\n" +
148
- "- Value:\n" +
149
- " > " + trimMultiline(e.value) + "\n";
147
+ "- Kind: " + e.kind + "\n";
148
+ if (e.disposition && e.disposition !== "answer") {
149
+ chunk += "- Disposition: " + e.disposition + "\n";
150
+ }
151
+ var valueLines = trimMultiline(e.value).split("\n");
152
+ chunk += "- Value:\n" + valueLines.map(function (ln) { return " > " + ln + "\n"; }).join("");
150
153
  if (e.rationale) {
151
154
  chunk += "- Rationale: " + trimMultiline(e.rationale) + "\n";
152
155
  }
@@ -25,6 +25,7 @@ created-at: <ISO 8601 UTC timestamp>
25
25
  ```markdown
26
26
  ## <Response ID>
27
27
  - Kind: <material | decision | data-point>
28
+ - Disposition: <answer | reframe> # answer(기본)면 라인 생략. reframe = 답 보류 + 재질문(행 미해결 유지)
28
29
  - Value:
29
30
  > <multi-line value, trimmed>
30
31
  - Rationale: <optional one-line rationale>
@@ -166,6 +166,13 @@ export const COMMAND_REGISTRY = [
166
166
  category: "introspection",
167
167
  summary: ["Assemble a task's run-to-run phase recap or append a recap log entry"],
168
168
  },
169
+ {
170
+ name: "user-response",
171
+ module: "./commands/inspect/user-response.mjs",
172
+ export: "run",
173
+ category: "introspection",
174
+ summary: ["Answer a task's open clarification questions in-session and write the response sidecar"],
175
+ },
169
176
  {
170
177
  name: "rollup",
171
178
  module: "./commands/inspect/rollup.mjs",
@@ -0,0 +1,26 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra user-response — 태스크의 열린 clarification 질문에 세션 내에서 답하고 sidecar 기록
4
+
5
+ A thin shim over \`python3 -m okstra_ctl.user_response\`. JSON 출력.
6
+
7
+ Usage:
8
+ okstra user-response list --home <dir> --project <id> [--limit <n>]
9
+ okstra user-response show --report <md>
10
+ okstra user-response write --report <md> --answers <json> \\
11
+ [--approval <json>] [--task-key <key>]
12
+
13
+ Exit codes: 0 ok / 1 오류
14
+ `;
15
+
16
+ export async function run(args) {
17
+ if (args.includes("--help") || args.includes("-h")) {
18
+ process.stdout.write(USAGE);
19
+ return 0;
20
+ }
21
+ const { code } = await runPythonModule({
22
+ module: "okstra_ctl.user_response",
23
+ args,
24
+ });
25
+ return code ?? 1;
26
+ }
@@ -16,6 +16,7 @@ export const USER_SKILL_NAMES = Object.freeze([
16
16
  "okstra-container-build",
17
17
  "okstra-graphify",
18
18
  "okstra-pr-gen",
19
+ "okstra-user-response",
19
20
  ]);
20
21
 
21
22
  // Names okstra used to install as skills before they were renamed or moved