okstra 0.154.0 → 0.154.1

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.154.0",
3
+ "version": "0.154.1",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.154.0",
3
- "builtAt": "2026-08-05T16:08:35.394Z",
2
+ "package": "0.154.1",
3
+ "builtAt": "2026-08-05T18:07:27.600Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -395,11 +395,39 @@ def scan_clarification_blockers(
395
395
  report_path: Path, blocking_values: frozenset[str]
396
396
  ) -> ClarificationScan:
397
397
  """Shared fail-closed clarification walk for both gates above — schema-v2
398
- reads its rows from the data sibling, schema-v1 from the §1 table."""
398
+ reads its rows from the data sibling, schema-v1 from the §1 table, and the
399
+ user's `user-responses/` sidecar outranks whatever the report says about
400
+ those rows."""
399
401
  v2_scan = _scan_v2_blockers(report_path, blocking_values)
400
- if v2_scan is not None:
401
- return v2_scan
402
- return scan_section_1_blockers(_read_report_text(report_path), blocking_values)
402
+ scan = (
403
+ v2_scan if v2_scan is not None
404
+ else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
405
+ )
406
+ return _resolve_blockers_answered_by_user(report_path, scan)
407
+
408
+
409
+ def _resolve_blockers_answered_by_user(
410
+ report_path: Path, scan: ClarificationScan
411
+ ) -> ClarificationScan:
412
+ """사용자가 사이드카로 답한 행을 blocker 에서 뺀 스캔.
413
+
414
+ 답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
415
+ run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
416
+ `Export user response`, `okstra user-response write`)에서는 갱신되지
417
+ 않는다 — 게이트가 리포트만 보면 사용자가 답을 다 채운 뒤에도 같은 항목이
418
+ 영원히 미해결로 남아 다음 phase 를 막는다.
419
+
420
+ fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
421
+ 어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
422
+ """
423
+ if scan.unreadable_reason is not None or not scan.blockers:
424
+ return scan
425
+ answers = sidecar_answers(report_path)
426
+ if not answers:
427
+ return scan
428
+ return ClarificationScan(
429
+ [b for b in scan.blockers if b.row_id not in answers], None
430
+ )
403
431
 
404
432
 
405
433
  def scan_section_1_blockers(
@@ -587,11 +615,20 @@ def _sidecars_for_attachment(source: Path) -> list[Path]:
587
615
  return sorted([*ordinary, *selected])
588
616
 
589
617
 
590
- def _sidecar_answers(source: Path) -> dict[str, str]:
618
+ def sidecar_answers(source: Path) -> dict[str, str]:
591
619
  """`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
592
620
 
593
- 같은 id 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다. `value`
594
- 항목은 담지 않는다. `user_response` 지연 import 해 순환 참조를 피한다.
621
+ 사용자가 답한 항목이 무엇인지 아는 단일 참조점 carry-in 병합도, 승인
622
+ 게이트도, 스킬의 열린 항목 목록도 전부 곳을 본다.
623
+
624
+ `disposition` 이 `answer` 인 항목만 답으로 센다. `reframe` 은 "다음 run 에서
625
+ 다시 물어달라" 이지 답이 아니므로(`skills/okstra-user-response/SKILL.md` 의
626
+ "A reframe is not an answer") 답 집합에서 빠져야 게이트가 그 항목을 계속
627
+ 미해결로 잡는다.
628
+
629
+ 같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
630
+ reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
631
+ 미해결로 돌아온다. `user_response` 를 지연 import 해 순환 참조를 피한다.
595
632
  """
596
633
  from okstra_ctl.user_response import parse_user_response_entries
597
634
 
@@ -600,8 +637,10 @@ def _sidecar_answers(source: Path) -> dict[str, str]:
600
637
  for entry in parse_user_response_entries(
601
638
  sidecar.read_text(encoding="utf-8")
602
639
  ):
603
- if entry.value:
640
+ if entry.value and entry.disposition == "answer":
604
641
  answers[entry.response_id] = entry.value
642
+ else:
643
+ answers.pop(entry.response_id, None)
605
644
  return answers
606
645
 
607
646
 
@@ -644,7 +683,7 @@ def clarification_response_with_sidecars(source: Path) -> str:
644
683
  """
645
684
  text = source.read_text(encoding="utf-8")
646
685
  section = attached_user_responses_section(source)
647
- answers = _sidecar_answers(source)
686
+ answers = sidecar_answers(source)
648
687
  body = _clarification_carry_body(source, text, answers)
649
688
  if not section:
650
689
  return body
@@ -750,9 +789,13 @@ def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
750
789
 
751
790
 
752
791
  def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
753
- """답이 있고 open/answered 이며 `User input` 칸이 행이면 답을 채우고 Status 를
754
- resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다. 이미 채워진 칸은
755
- 표에 든 값이 정본이므로 덮어쓰지 않는다.
792
+ """답이 있고 open/answered 행이면 `User input` 칸을 답으로 채우고 Status 를
793
+ resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
794
+
795
+ 칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
796
+ run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
797
+ 갈라진다 — 사용자가 쓴 쪽을 정본으로 삼지 않으면 run 이 자기가 적어둔 값으로
798
+ 계속 되돌아간다.
756
799
 
757
800
  판정은 앵커/백틱을 벗긴 셀(`_split_pipe_row`)로 — 그래야 `_meta_id` 가 스크롤
758
801
  앵커의 소문자 slug 대신 진짜 대문자 ID 를 읽는다. 재조립은 원본 셀
@@ -764,7 +807,7 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
764
807
  if item.status not in UNRESOLVED_STATUSES:
765
808
  return line
766
809
  raw = split_pipe_row(line)
767
- if not (0 <= ui_col < len(raw) and raw[ui_col] == ""):
810
+ if not 0 <= ui_col < len(raw):
768
811
  return line
769
812
  raw[ui_col] = answers[item.row_id]
770
813
  raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
@@ -772,7 +815,7 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
772
815
 
773
816
 
774
817
  def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
775
- """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
818
+ """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
776
819
  Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
777
820
 
778
821
  답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
@@ -27,6 +27,7 @@ from okstra_ctl.clarification_items import (
27
27
  read_clarification_rows,
28
28
  scan_open_user_input,
29
29
  section_1_present_but_unparsed,
30
+ sidecar_answers,
30
31
  _section_1_slice,
31
32
  )
32
33
 
@@ -545,10 +546,13 @@ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
545
546
 
546
547
  def show_open_rows(report_path: Path) -> dict:
547
548
  text = report_path.read_text(encoding="utf-8")
549
+ # 사이드카에 답이 있는 행은 사용자가 이미 답한 것이다. 리포트의 `Status` 는
550
+ # 그 답을 반영하지 않으므로, 이걸 빼지 않으면 스킬이 같은 질문을 다시 묻는다.
551
+ answered = sidecar_answers(report_path)
548
552
  rows = []
549
553
  for r in read_clarification_rows(report_path):
550
554
  it = r["item"]
551
- if it.status not in ("open", "answered"):
555
+ if it.status not in ("open", "answered") or it.row_id in answered:
552
556
  continue
553
557
  statement, expected = r["statement"], r["expected_form"]
554
558
  refs = sorted(set(_SECTION_REF_RE.findall(statement + " " + expected)))
@@ -104,7 +104,9 @@
104
104
  "export-my-answers": "Export my answers",
105
105
  "at-the-foot-of-the-page": "at the foot of the page.",
106
106
  "answer-as": "Answer as",
107
- "questions-waiting-on-you": "Questions waiting on you"
107
+ "questions-waiting-on-you": "Questions waiting on you",
108
+ "count-questions-waiting-on-you": "{count} questions waiting on you",
109
+ "your-answer-to-id": "Your answer to {id}"
108
110
  },
109
111
  "visualizations": {
110
112
  "component": "Component",
@@ -104,7 +104,9 @@
104
104
  "export-my-answers": "내 답변 내보내기",
105
105
  "at-the-foot-of-the-page": "버튼을 누르세요.",
106
106
  "answer-as": "답변 형식",
107
- "questions-waiting-on-you": "답변을 기다리는 질문"
107
+ "questions-waiting-on-you": "답변을 기다리는 질문",
108
+ "count-questions-waiting-on-you": "답변을 기다리는 질문 {count}건",
109
+ "your-answer-to-id": "{id}에 대한 답변"
108
110
  },
109
111
  "visualizations": {
110
112
  "component": "구성 요소",
@@ -33,14 +33,14 @@
33
33
  {% macro clarification_responses(items) -%}
34
34
  {% if items %}
35
35
  <section class="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
36
- <h2>{{ items | length }} questions waiting on you</h2>
36
+ <h2>{{ t('macros.forms.count-questions-waiting-on-you') | replace('{count}', items | length) }}</h2>
37
37
  <p class="clarification-lede">{{ t('macros.forms.the-code-alone-could-not-settle-these-fill-i') }} <strong>{{ t('macros.forms.export-my-answers') }}</strong> {{ t('macros.forms.at-the-foot-of-the-page') }}</p>
38
38
  {% for row in items %}
39
39
  <article class="clarification-item" id="id-{{ row.id }}" data-response-id="{{ row.id }}" data-kind="{{ row.kind }}">
40
40
  <p class="eyebrow">{{ row.id }} · {{ row.kind }}</p>
41
41
  {{ row.statement | paragraphs }}
42
42
  <dl class="clarification-expected"><dt>{{ t('macros.forms.answer-as') }}</dt><dd>{{ row.expectedForm | inline_code }}</dd></dl>
43
- <label for="response-{{ row.id }}">Your answer to {{ row.id }}</label>
43
+ <label for="response-{{ row.id }}">{{ t('macros.forms.your-answer-to-id') | replace('{id}', row.id) }}</label>
44
44
  <textarea id="response-{{ row.id }}" data-response-id="{{ row.id }}" rows="4">{{ row.userInput | default('') }}</textarea>
45
45
  </article>
46
46
  {% endfor %}