okstra 0.196.0 → 0.197.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.
Files changed (39) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/cli.md +14 -1
  4. package/docs/project-structure-overview.md +1 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
  8. package/runtime/prompts/lead/plan-body-verification.md +21 -3
  9. package/runtime/prompts/profiles/implementation-planning.md +3 -2
  10. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  11. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
  13. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
  15. package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
  16. package/runtime/python/okstra_ctl/conformance.py +36 -5
  17. package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
  18. package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
  19. package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
  20. package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
  21. package/runtime/python/okstra_ctl/group_context.py +96 -3
  22. package/runtime/python/okstra_ctl/plan_items.py +14 -0
  23. package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
  24. package/runtime/python/okstra_ctl/run.py +77 -0
  25. package/runtime/python/okstra_ctl/session_transcript.py +4 -4
  26. package/runtime/python/okstra_ctl/set_work_status.py +30 -1
  27. package/runtime/python/okstra_ctl/stage_close.py +244 -0
  28. package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
  29. package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
  30. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
  31. package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
  32. package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
  33. package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
  34. package/runtime/skills/okstra-brief-gen/SKILL.md +24 -7
  35. package/runtime/skills/okstra-inspect/facets/recap.md +17 -1
  36. package/runtime/skills/okstra-run/SKILL.md +63 -4
  37. package/runtime/templates/reports/group-context.template.md +1 -1
  38. package/runtime/validators/validate-implementation-plan-stages.py +109 -23
  39. package/runtime/validators/validate-run.py +63 -8
@@ -61,7 +61,10 @@ from .worker_prompt_contract import (
61
61
  validate_prompt_model_header,
62
62
  validate_reverify_prompt,
63
63
  )
64
- from .worker_prompt_policy import is_verification_dispatch_kind
64
+ from .worker_prompt_policy import (
65
+ is_verification_dispatch_kind,
66
+ verification_dispatch_round,
67
+ )
65
68
  from .worker_runner import LIVE, QUIET
66
69
  from .worker_request import verifier_extra_dirs
67
70
  from .worker_artifact_paths import audit_sidecar_rel
@@ -1000,13 +1003,14 @@ def prompt_anchor_values(
1000
1003
 
1001
1004
 
1002
1005
  def _execution_dispatch_round(dispatch_kind: str) -> int:
1003
- prefix = "reverify-r"
1004
- if not dispatch_kind.startswith(prefix):
1006
+ """attempt 행에 적는 라운드 번호. 예약(`agent-prompt materialize` 의
1007
+ `_reservation_round`)과 같은 계산을 써야 한다."""
1008
+ if not is_verification_dispatch_kind(dispatch_kind):
1005
1009
  return 1
1006
- suffix = dispatch_kind[len(prefix):]
1007
- if not suffix.isdigit() or int(suffix) < 1:
1008
- raise DispatchError(f"invalid reverify dispatch kind: {dispatch_kind}")
1009
- return int(suffix)
1010
+ round_number = verification_dispatch_round(dispatch_kind)
1011
+ if round_number is None:
1012
+ raise DispatchError(f"invalid verification dispatch kind: {dispatch_kind}")
1013
+ return round_number
1010
1014
 
1011
1015
 
1012
1016
  def link_agent_dispatch_result(
@@ -38,7 +38,6 @@ _MAX_SUMMARY = 120
38
38
  # 결과 하나가 pane 을 다 차지하면 직전 호출이 위로 밀려 나가므로 앞부분만
39
39
  # 보여주고, 나머지는 로그가 갖는다.
40
40
  _LIVE_BODY_ROWS = 4
41
- _BODY_INDENT = " "
42
41
 
43
42
 
44
43
  @dataclass(frozen=True)
@@ -197,8 +196,8 @@ def _rows(event: StreamEvent, *, limit: int | None, body_rows: int | None) -> li
197
196
  tail = (
198
197
  f"the tool reported: {report}" if report else "the tool reported nothing"
199
198
  )
200
- return [_truncate(f" ← {_outcome(event.failed)} — no body returned; {tail}", limit)]
201
- head = f" ← {_outcome(event.failed)} ({event.size_bytes} bytes)"
199
+ return [_truncate(f"← {_outcome(event.failed)} — no body returned; {tail}", limit)]
200
+ head = f"← {_outcome(event.failed)} ({event.size_bytes} bytes)"
202
201
  return [head, *_result_body(event.body, limit=limit, keep=body_rows)]
203
202
  if isinstance(event, Denial):
204
203
  return [_truncate(f"!! PERMISSION DENIED — {event.tool}: {event.reason}", limit)]
@@ -217,10 +216,10 @@ def _result_body(body: str, *, limit: int | None, keep: int | None) -> list[str]
217
216
  rows = _body_rows(body)
218
217
  if keep is None:
219
218
  return rows
220
- shown = [_truncate(_BODY_INDENT + row, limit) for row in rows[:keep]]
219
+ shown = [_truncate(row, limit) for row in rows[:keep]]
221
220
  dropped = len(rows) - keep
222
221
  if dropped > 0:
223
- shown.append(f"{_BODY_INDENT}… +{dropped} more line(s) — full body in the log")
222
+ shown.append(f"… +{dropped} more line(s) — full body in the log")
224
223
  return shown
225
224
 
226
225
 
@@ -202,7 +202,68 @@ class _Validator:
202
202
  elif schema.get("additionalProperties") is False:
203
203
  # Don't fire for keys we know are part of the conditional
204
204
  # branches (we still validate values when they appear).
205
- self._err(path, f"additional property '{name}' is not allowed")
205
+ self._err(
206
+ path,
207
+ f"additional property '{name}' is not allowed"
208
+ + self._misplaced_property_hint(name, path),
209
+ )
210
+
211
+ def _misplaced_property_hint(
212
+ self, name: str, path: tuple[str | int, ...],
213
+ ) -> str:
214
+ """이 키가 스키마의 다른 자리에서 정의된 필드면 "옮겨라" 를 덧붙인다.
215
+
216
+ `additional property 'X' is not allowed` 만 보면 "스키마에 그런 칸이
217
+ 없다" 로 읽힌다. 실제로는 X 가 다른 객체의 필드이고 작성자가 자리를
218
+ 틀린 경우가 있고, 그때 지우면 다음 라운드에서 `required property 'X'
219
+ is missing` 이 나온다 — 라운드 하나를 왕복에 쓴다(2026-09-09 실측,
220
+ dev-10642 implementation-planning: 최상위 필수 `summary` 를
221
+ `rationale` 안에 써서 지웠다가 되살렸다).
222
+ """
223
+ found = self._required_property_owners(name) - {_format_path(path)}
224
+ if not found:
225
+ return ""
226
+ # 최상위를 앞에 둔다. 잘못 놓인 키는 대개 최상위 필드가 한 단계 안으로
227
+ # 들어간 것이고, 나머지 소유자는 같은 이름을 쓰는 다른 행 타입이다.
228
+ owners = (
229
+ ["<root>"] if "<root>" in found else []
230
+ ) + sorted(found - {"<root>"})
231
+ return (
232
+ f" — '{name}' is a REQUIRED property of {', '.join(owners[:3])}, not "
233
+ f"of {_format_path(path)}; move it there rather than removing it"
234
+ )
235
+
236
+ def _required_property_owners(self, name: str) -> set[str]:
237
+ """`name` 을 **필수**로 요구하는 객체들의 이름.
238
+
239
+ 정의만 가진 자리까지 세면(예: `summary` 는 8곳에서 정의된다) 힌트가
240
+ 이름 목록이 돼 아무것도 가리키지 못한다. 지우면 다음 라운드에서
241
+ `required property ... is missing` 이 나오는 자리, 즉 필수인 곳만 센다.
242
+ """
243
+ owners: set[str] = set()
244
+
245
+ def walk(node: Any, label: str) -> None:
246
+ if isinstance(node, list):
247
+ for item in node:
248
+ walk(item, label)
249
+ return
250
+ if not isinstance(node, dict):
251
+ return
252
+ required = node.get("required")
253
+ if isinstance(required, list) and name in required:
254
+ owners.add(label)
255
+ for key, value in node.items():
256
+ if key == "properties" and isinstance(value, dict):
257
+ for child, sub in value.items():
258
+ walk(sub, f"{label}.{child}" if label else child)
259
+ elif key in ("definitions", "$defs") and isinstance(value, dict):
260
+ for child, sub in value.items():
261
+ walk(sub, child)
262
+ elif key in ("items", "allOf", "oneOf", "anyOf", "then", "else"):
263
+ walk(value, label)
264
+
265
+ walk(self.root, "<root>")
266
+ return owners
206
267
 
207
268
  def _validate_array(self, instance: list, schema: dict, path: tuple[str | int, ...]) -> None:
208
269
  min_items = schema.get("minItems")
@@ -70,7 +70,8 @@ _NONE_MARKERS = {"_(none)_", ""}
70
70
  MEMORY_BEGIN = "<!-- okstra:task-memory:begin -->"
71
71
  MEMORY_END = "<!-- okstra:task-memory:end -->"
72
72
  MEMORY_NOTE = (
73
- "<!-- okstra rewrites this region at every report-finalize. "
73
+ "<!-- okstra redraws this region after every report-finalize, at "
74
+ "`okstra set-work-status`, and before each run copies this file. "
74
75
  "Edit the sections above it, not this one. -->"
75
76
  )
76
77
  MEMORY_HEADING = "## Task Memory"
@@ -323,6 +324,32 @@ def record_task_memory(
323
324
  return target, queue
324
325
 
325
326
 
327
+ def refresh_group_queue(project_root: Path, task_group: str) -> Path | None:
328
+ """그룹 문서의 시작 순서만 지금 값으로 다시 그린다. 문서가 없으면 아무것도 안 한다.
329
+
330
+ 기록된 항목과 사람 절은 바이트 그대로 두고 큐만 다시 계산한다. 이 투영을
331
+ 쓰는 곳은 원래 report-finalize 의 `record-group-memory` 하나뿐이라, 리포트
332
+ 없이 끝난 run 이나 사람이 손으로 바꾼 상태는 다음 finalize 까지 문서에
333
+ 닿지 않았다(2026-09-10 실측, cache 그룹 dev-10635: `workStatus` 를 done 으로
334
+ 적었는데 큐는 `[in progress] — requirements-discovery (blocked)` 그대로).
335
+ 같은 그룹의 finalize 와 겹칠 수 있으므로 같은 flock 아래에서 읽고-쓴다.
336
+ """
337
+ target = group_context_file(project_root, task_group)
338
+ if not target.is_file():
339
+ return None
340
+ with dir_flock(target.parent, LOCK_FILENAME):
341
+ text = target.read_text(encoding="utf-8")
342
+ before, region, after = split_memory_region(text)
343
+ if not region:
344
+ return None
345
+ entries = parse_memory_entries(region)
346
+ queue = group_queue(project_root, task_group, entries)
347
+ updated = before + render_memory_region(entries, queue) + after
348
+ if updated != text:
349
+ target.write_text(updated, encoding="utf-8")
350
+ return target
351
+
352
+
326
353
  # --- 시작 순서 ----------------------------------------------------------------
327
354
  #
328
355
  # 그룹의 task 는 순서가 있을 수 있다. 순서는 brief-gen 이 매긴 브리프 순번
@@ -332,6 +359,11 @@ def record_task_memory(
332
359
  # 15번 가드 티켓이 1번을 `blocks` 한다고 적었지만 사용자는 1번을 먼저 끝냈다.
333
360
  # 큐는 영역 머리에 실리고, 이 task 가 끝나는 closeout 은 큐의 다음 task 를 이름한다.
334
361
 
362
+ # `okstra set-work-status` 가 task-manifest 에 적는 값 중 큐 상태를 덮는 것.
363
+ # 나머지(`todo` / `in-progress` / `blocked`)는 큐의 세 상태로 옮길 때 파생값보다
364
+ # 나은 정보가 없어 덮지 않는다.
365
+ WORK_STATUS_DONE = "done"
366
+
335
367
  QUEUE_DONE = "done"
336
368
  QUEUE_IN_PROGRESS = "in progress"
337
369
  QUEUE_NOT_STARTED = "not started"
@@ -354,6 +386,9 @@ class QueueRow:
354
386
  status: str # QUEUE_*
355
387
  progress: str # "<task-type> #<seq>" for a recorded task, else ""
356
388
  waits_for: tuple[str, ...] = () # ticket ids the graph says come first and are not done
389
+ # 같은 `brief-id` 를 가진 다른 브리프 파일들. 하나의 task 를 두 파일이
390
+ # 주장하는 상태이므로 큐는 정본 한 줄만 싣고 나머지를 여기 이름한다.
391
+ duplicate_briefs: tuple[str, ...] = ()
357
392
 
358
393
 
359
394
  def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
@@ -417,6 +452,15 @@ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str
417
452
  state = str(entry.get("currentPhaseState") or entry.get("latestRunStatus") or "")
418
453
  progress = f"{phase} ({state})" if phase and state else phase
419
454
  status = QUEUE_DONE if pointer.get("status") == STATUS_TERMINAL else QUEUE_IN_PROGRESS
455
+ # 사용자가 `okstra set-work-status <task> done` 으로 끝났다고 선언한 task
456
+ # 는 끝난 것이다. 파생 포인터만 보면 리포트 없이 끝난 run 이 영원히
457
+ # `[in progress]` 로 남는다 — 실측(2026-09-10, fontsninja-v3-site cache
458
+ # 그룹 dev-10635): 사람이 AWS 콘솔에서 작업을 마치고 `done` 을 적었는데,
459
+ # 그 run 이 리포트 조립 실패로 끝나 포인터가 `blocked` 에 멈춰 있었다.
460
+ # 파생 표기는 지우지 않는다 — 왜 done 인지가 그 차이에 남는다.
461
+ if str(entry.get("workStatus") or "") == WORK_STATUS_DONE:
462
+ status = QUEUE_DONE
463
+ progress = f"{progress} · marked done" if progress else "marked done"
420
464
  out[task_id] = (status, progress)
421
465
  return out
422
466
 
@@ -439,6 +483,7 @@ def group_queue(
439
483
  for brief in briefs:
440
484
  for ticket, before in brief["waits_for"].items():
441
485
  waits.setdefault(ticket, set()).update((before & by_ticket.keys()) - {ticket})
486
+ briefs, duplicates = _fold_duplicate_briefs(briefs)
442
487
  ordered = sorted(briefs, key=lambda b: (b["ordinal"] is None, b["ordinal"] or 0, b["brief_id"]))
443
488
  by_task = {entry.task_id: entry for entry in entries}
444
489
  recorded = catalog_progress(project_root, task_group)
@@ -464,10 +509,50 @@ def group_queue(
464
509
  rows.append(QueueRow(
465
510
  slugify_task_segment(brief["brief_id"]), brief["brief_id"], brief["ticket_id"],
466
511
  brief["brief"], status, progress, open_waits,
512
+ duplicates.get(brief["brief_id"], ()),
467
513
  ))
468
514
  return rows
469
515
 
470
516
 
517
+ def _fold_duplicate_briefs(
518
+ briefs: list[dict[str, Any]],
519
+ ) -> tuple[list[dict[str, Any]], dict[str, tuple[str, ...]]]:
520
+ """같은 `brief-id` 를 주장하는 브리프들을 정본 하나로 접는다.
521
+
522
+ `brief-id` 는 task 디렉터리 이름(`task_id`)의 출처라, 두 파일이 같은 id 를
523
+ 달면 하나의 task 를 둘이 주장하는 상태다. 접지 않으면 큐가 같은 task 를 두
524
+ 줄로 싣고 번호가 브리프 수보다 커진다 — `next_in_group` 과 closeout 도 같은
525
+ task 를 두 번 가리킨다(2026-09-10 실측, fontsninja-v3-site `cache` 그룹:
526
+ 개정 전 브리프를 `.superseded-<날짜>.md` 로 같은 디렉터리에 남겨 15개 그룹의
527
+ 큐가 16번까지 갔다).
528
+
529
+ 정본은 파일 이름이 곧 `brief-id` 인 파일이다 — brief-gen 이 쓰는 이름이고,
530
+ 같은 id 를 단 다른 이름은 사본이다. 그런 파일이 없거나 여럿이면 경로 순서로
531
+ 첫 번째다. 거절하지 않는 이유는 이 큐를 report-finalize 와 위저드가 읽기
532
+ 때문이다: 브리프 디렉터리 정리가 안 됐다고 run 발행을 막을 일은 아니다.
533
+ 대신 남은 사본을 `duplicate_briefs` 로 실어 큐가 그 사실을 말한다.
534
+ """
535
+ by_id: dict[str, list[dict[str, Any]]] = {}
536
+ for brief in briefs:
537
+ by_id.setdefault(brief["brief_id"], []).append(brief)
538
+ kept: list[dict[str, Any]] = []
539
+ duplicates: dict[str, tuple[str, ...]] = {}
540
+ for brief_id, group in by_id.items():
541
+ if len(group) == 1:
542
+ kept.append(group[0])
543
+ continue
544
+ ordered_group = sorted(group, key=lambda b: b["brief"])
545
+ canonical = next(
546
+ (b for b in ordered_group if Path(b["brief"]).stem == brief_id),
547
+ ordered_group[0],
548
+ )
549
+ kept.append(canonical)
550
+ duplicates[brief_id] = tuple(
551
+ b["brief"] for b in ordered_group if b is not canonical
552
+ )
553
+ return kept, duplicates
554
+
555
+
471
556
  def next_in_group(queue: list[QueueRow]) -> QueueRow | None:
472
557
  """시작 순서에서 아직 시작하지 않은 첫 task."""
473
558
  return next((row for row in queue if row.status == QUEUE_NOT_STARTED), None)
@@ -482,6 +567,13 @@ def render_queue(queue: list[QueueRow]) -> str:
482
567
  if row.waits_for:
483
568
  tail += f" (waits for {', '.join(row.waits_for)})"
484
569
  lines.append(f"{number}. [{row.status}] {row.task_id}{tail}")
570
+ # 사본을 이름한다. 이 줄이 없으면 큐가 조용히 한 파일을 무시하고, 그
571
+ # 파일을 고친 사람은 자기 편집이 어디로 갔는지 알 길이 없다.
572
+ for extra in row.duplicate_briefs:
573
+ lines.append(
574
+ f" - ignored duplicate of this brief-id: `{extra}` — one task, "
575
+ "two brief files. Move the copy out of the briefs directory."
576
+ )
485
577
  return "\n".join(lines) + "\n"
486
578
 
487
579
 
@@ -657,8 +749,9 @@ def _init_command(args: argparse.Namespace) -> int:
657
749
  "line remains; delete the file if the group needs no context."
658
750
  )
659
751
  print(
660
- f"The trailing `{MEMORY_HEADING}` region is written by okstra at every "
661
- "report-finalize; leave it alone."
752
+ f"The trailing `{MEMORY_HEADING}` region is okstra's redrawn after every "
753
+ "report-finalize, at `okstra set-work-status`, and before each run copies "
754
+ "this file; leave it alone."
662
755
  )
663
756
  return 0
664
757
 
@@ -790,6 +790,20 @@ PLAN_VERIFY_RESPONSE_FORMAT = (
790
790
  )
791
791
 
792
792
 
793
+ # 계획 항목 큐의 렌더러 서명. 검증 계약(`worker_prompt_contract.
794
+ # validate_reverify_prompt`)이 `plan-verify-r<N>` 디스패치에 이 줄을 요구한다 —
795
+ # 번호 reverify 가 `okstra convergence reverify-prompt` 서명을 요구하는 것과
796
+ # 같은 이유다. 손으로 쓴 큐는 항목 id·직전 반대 의견·응답 형식이 임의로 빠지고,
797
+ # 그렇게 버려진 라운드가 실재한다(run 003 실측, `with_response_format` 주석).
798
+ RENDERED_BY_LINE = "**Rendered by:** okstra plan-items prompt"
799
+
800
+
801
+ def with_rendered_by(queue_markdown: str) -> str:
802
+ """렌더러 서명을 프롬프트 맨 앞에 붙인다. 디스패치는 이 출력을 그대로
803
+ `--instruction` 으로 받으므로 서명은 `## Task Instructions` 뒤에 놓인다."""
804
+ return f"{RENDERED_BY_LINE}\n\n{queue_markdown.lstrip()}"
805
+
806
+
793
807
  def with_response_format(queue_markdown: str) -> str:
794
808
  """큐 본문 뒤에 응답 형식 블록을 덧붙인다. run 003 실측: 리드가 손 조립
795
809
  중 이 블록을 빼먹어 검증자 2명의 판정 32건 전부가 형식 불일치로 버려졌다."""
@@ -43,6 +43,7 @@ from .plan_items import (
43
43
  reverify_prompt_text,
44
44
  tie_vote_item_ids,
45
45
  voting_analyser_keys,
46
+ with_rendered_by,
46
47
  with_response_format,
47
48
  )
48
49
  from .paths import RunRef
@@ -242,6 +243,14 @@ def _parser() -> argparse.ArgumentParser:
242
243
  metavar="<worker-id>=<path>",
243
244
  help="one worker's plan-verify Markdown result (repeatable)",
244
245
  )
246
+ apply_verdicts.add_argument(
247
+ "--items", type=Path,
248
+ metavar="<plan-items artifact>",
249
+ help="the plan-items artifact this round dispatched, when it dispatched "
250
+ "part of the queue (the `--tie-vote` artifact). Its dispatchQueue "
251
+ "is what each --result must answer; without it a result is checked "
252
+ "against the whole persisted queue",
253
+ )
245
254
  apply_verdicts.add_argument(
246
255
  "--run-manifest", type=Path,
247
256
  help="resolve the project a `fact` claim's probe runs against; without "
@@ -831,10 +840,10 @@ def _prompt(args: argparse.Namespace) -> str:
831
840
  rows.append(split)
832
841
  body = with_response_format("".join(rows))
833
842
  if envelope.get("dispatchKind") == "critic-tie":
834
- return critic_tie_prompt_text(body)
843
+ return with_rendered_by(critic_tie_prompt_text(body))
835
844
  if envelope.get("dispatchKind") == "reverify":
836
- return reverify_prompt_text(body)
837
- return body
845
+ return with_rendered_by(reverify_prompt_text(body))
846
+ return with_rendered_by(body)
838
847
 
839
848
 
840
849
  def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
@@ -1377,6 +1386,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1377
1386
  {item_id for item_id in queue if isinstance(item_id, str)}
1378
1387
  if isinstance(queue, list) else known
1379
1388
  )
1389
+ assigned = _narrow_assignment(args, assigned)
1380
1390
  rows = _incoming_verdict_rows(args, assigned)
1381
1391
  missing = sorted(item_id for item_id in rows if item_id not in known)
1382
1392
  if missing:
@@ -1872,6 +1882,38 @@ def _correction_prompt(args: argparse.Namespace) -> str:
1872
1882
  return correction_prompt_text(_prompt(args))
1873
1883
 
1874
1884
 
1885
+ def _narrow_assignment(
1886
+ args: argparse.Namespace, assigned: set[object],
1887
+ ) -> set[object]:
1888
+ """이 라운드가 실제로 배정한 항목들.
1889
+
1890
+ tie 라운드는 큐의 일부(7항목)만 critic 에게 보낸다. 그런데 `--result` 는
1891
+ 지금까지 state 에 남은 라운드 큐(44항목) 전체를 배정으로 보고 답 없는
1892
+ 37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
1893
+ tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
1894
+ `--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
1895
+ `--items` 로 그 라운드의 artifact 를 주면 그 `dispatchQueue` 가 배정이
1896
+ 된다. 미응답 검사 자체는 그대로다 — 좁힌 배정 안에서 여전히 전건을
1897
+ 요구하므로, 워커가 자기 몫을 조용히 빠뜨리는 것은 계속 잡힌다.
1898
+ """
1899
+ items_path = getattr(args, "items", None)
1900
+ if items_path is None:
1901
+ return assigned
1902
+ narrowed = {item_id for item_id in _assigned_item_ids(items_path)}
1903
+ if not narrowed:
1904
+ raise PlanItemContractError(
1905
+ f"items artifact dispatches nothing: {items_path}"
1906
+ )
1907
+ unknown = sorted(narrowed - {i for i in assigned if isinstance(i, str)})
1908
+ if unknown:
1909
+ raise PlanItemContractError(
1910
+ f"items artifact dispatches {unknown}, which this round's persisted "
1911
+ f"queue does not contain — pass the artifact this round dispatched, "
1912
+ f"not another round's"
1913
+ )
1914
+ return narrowed
1915
+
1916
+
1875
1917
  def _incoming_verdict_rows(
1876
1918
  args: argparse.Namespace, known: set[object],
1877
1919
  ) -> dict[str, list[dict[str, Any]]]:
@@ -551,6 +551,11 @@ class PrepareInputs:
551
551
  # implementation 전용: `--qa-waiver "<stageKey>:<reason>"` 사용자 확인형 우회.
552
552
  # prepare-time 에 task-level conformance 매니페스트 entry.waiver 를 채운다.
553
553
  qa_waiver: str = ""
554
+ # implementation-planning 전용: `--tdd-bypass "<stage>:<reason>"` 사용자
555
+ # 확인형 TDD 우회. prepare-time 에 `<task-root>/qa/tdd-bypass.json` 에
556
+ # 사유를 원문 그대로 남기고, S10e 는 그 원장이 있을 때만 계획서의
557
+ # `tddExemption: user-bypass` 를 인정한다.
558
+ tdd_bypass: str = ""
554
559
  stage: str = "auto"
555
560
  # release-handoff 전용: PR 로 내보낼 stage 묶음 (csv, 예: "2,3"). 빈 값 =
556
561
  # whole-task 모드. `--stage`(impl/fv 의 Stage Map 실행/검증 선택)와는
@@ -1915,6 +1920,40 @@ def validate_project_qa_commands(task_type: str, project_root: Path) -> None:
1915
1920
  raise PrepareError(_format_qa_errors(qa_errors))
1916
1921
 
1917
1922
 
1923
+ def _apply_tdd_bypass_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1924
+ """`--tdd-bypass` 가 있으면 task-level TDD 우회 원장에 사유를 남긴다.
1925
+
1926
+ 같은 task-key 의 다른 prepare 와 같은 파일을 read-modify-write 하므로
1927
+ `_apply_qa_waiver_if_requested` 와 동일한 per-task-key 락 안에서 쓴다.
1928
+ 원장이 없으면 만든다 — 사용자가 빈 파일을 손으로 만들 이유가 없다.
1929
+ """
1930
+ if not inp.tdd_bypass:
1931
+ return
1932
+ from .paths import task_dir
1933
+ from .tdd_bypass import (
1934
+ TddBypassError,
1935
+ bypass_file,
1936
+ parse_bypass_arg,
1937
+ record_bypass,
1938
+ )
1939
+ parsed = parse_bypass_arg(inp.tdd_bypass)
1940
+ if parsed is None:
1941
+ raise PrepareError(
1942
+ '--tdd-bypass must be "<stage>:<reason>" with a positive stage '
1943
+ f"number, got {inp.tdd_bypass!r}"
1944
+ )
1945
+ stage, reason = parsed
1946
+ path = bypass_file(task_dir(project_root, inp.task_group, inp.task_id))
1947
+ when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1948
+ with worktree_provision_mutex(
1949
+ okstra_home(), inp.project_id, slugify(inp.task_group), slugify(inp.task_id),
1950
+ ):
1951
+ try:
1952
+ record_bypass(path, stage, reason, at=when)
1953
+ except TddBypassError as exc:
1954
+ raise PrepareError(f"--tdd-bypass: {exc}") from exc
1955
+
1956
+
1918
1957
  def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1919
1958
  """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다.
1920
1959
 
@@ -2008,6 +2047,9 @@ def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
2008
2047
  # waiver 는 stage 단위 Tier 3 면제라 implementation 진입에서만 적용한다.
2009
2048
  if inp.task_type == "implementation":
2010
2049
  _apply_qa_waiver_if_requested(inp, project_root)
2050
+ # TDD 우회는 계획서의 stage 를 면제하므로 계획 진입에서만 적용한다.
2051
+ if inp.task_type == "implementation-planning":
2052
+ _apply_tdd_bypass_if_requested(inp, project_root)
2011
2053
 
2012
2054
 
2013
2055
  def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str], str]:
@@ -3589,6 +3631,23 @@ def _stage_or_clear(path: Path, body: str) -> None:
3589
3631
  path.unlink(missing_ok=True)
3590
3632
 
3591
3633
 
3634
+ def _refresh_group_context_queue(project_root: Path, task_group: str) -> None:
3635
+ """그룹 문서의 시작 순서를 지금 값으로 다시 그린다. 실패해도 run 은 계속한다.
3636
+
3637
+ 파생 표면이라 이것 때문에 run 준비를 멈출 이유가 없다. 대신 삼키지 않고
3638
+ stderr 로 알린다 — 워커가 낡은 형제 상태를 읽었다는 사실을 나중에 설명할
3639
+ 근거가 남아야 한다.
3640
+ """
3641
+ try:
3642
+ group_context.refresh_group_queue(project_root, task_group)
3643
+ except (OSError, ValueError) as exc:
3644
+ print(
3645
+ f"prepare: group-context start order for {task_group!r} could not be "
3646
+ f"redrawn before it was copied into the instruction set: {exc}",
3647
+ file=sys.stderr,
3648
+ )
3649
+
3650
+
3592
3651
  def _write_instruction_set_sources(
3593
3652
  inp: PrepareInputs,
3594
3653
  ctx: dict,
@@ -3652,6 +3711,12 @@ def _write_instruction_set_sources(
3652
3711
  (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
3653
3712
  shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
3654
3713
  # 그룹 맥락은 preflight 를 통과한 파일만 여기 온다(`_validate_group_context_preflight`).
3714
+ #
3715
+ # 복사 직전에 시작 순서를 다시 그린다. 이 파일은 그대로 워커의 analysis
3716
+ # packet 에 실리므로, 형제 task 의 상태가 낡은 채로 복사되면 워커는 이미 끝난
3717
+ # task 를 진행 중으로 읽는다. 투영을 쓰는 곳이 report-finalize 뿐이던 동안
3718
+ # 리포트 없이 끝난 run 의 상태는 다음 finalize 까지 문서에 닿지 않았다.
3719
+ _refresh_group_context_queue(Path(inp.project_root), inp.task_group)
3655
3720
  group_context_source = group_context.group_context_file(
3656
3721
  Path(inp.project_root), inp.task_group
3657
3722
  )
@@ -5058,6 +5123,17 @@ def build_prepare_argument_parser():
5058
5123
  p.add_argument("--critic", default="")
5059
5124
  p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
5060
5125
  p.add_argument("--approved-plan", default="", dest="approved_plan_path")
5126
+ p.add_argument(
5127
+ "--tdd-bypass",
5128
+ default="",
5129
+ dest="tdd_bypass",
5130
+ help=(
5131
+ 'User-recorded TDD bypass for one plan stage: "<stage>:<reason>" '
5132
+ "(implementation-planning only). Records the reason verbatim into "
5133
+ "<task-root>/qa/tdd-bypass.json; S10e accepts a stage declaring "
5134
+ "tddExemption `user-bypass` only while that record exists."
5135
+ ),
5136
+ )
5061
5137
  p.add_argument(
5062
5138
  "--qa-waiver",
5063
5139
  default="",
@@ -5323,6 +5399,7 @@ def main(argv: list[str]) -> int:
5323
5399
  base_ref=args.base_ref,
5324
5400
  approved_plan_path=args.approved_plan_path,
5325
5401
  qa_waiver=args.qa_waiver,
5402
+ tdd_bypass=args.tdd_bypass,
5326
5403
  stage=args.stage,
5327
5404
  stages=args.stages,
5328
5405
  clarification_response_path=clarification_abs,
@@ -18,10 +18,10 @@ _RESET = "\x1b[0m"
18
18
  _MUTED = "\x1b[90m"
19
19
  _LIVE_COLORS = (
20
20
  ("→ ", "\x1b[36m"),
21
- (" ← ok", "\x1b[32m"),
22
- (" ← error", "\x1b[31m"),
21
+ ("← ok", "\x1b[32m"),
22
+ ("← error", "\x1b[31m"),
23
23
  ("!! PERMISSION DENIED", "\x1b[1;31m"),
24
- (" ← done", "\x1b[33m"),
24
+ ("← done", "\x1b[33m"),
25
25
  )
26
26
 
27
27
  # 파일 사본이 담는 워커 진행 줄의 상한. 진행은 워커 출력의 부피가 몰리는
@@ -99,7 +99,7 @@ class SessionTranscript:
99
99
  self._keep(self._row(speaker, line), capped=True)
100
100
 
101
101
  def _row(self, speaker: str, line: str) -> str:
102
- label = f"[{speaker}] "
102
+ label = f"[{speaker}]"
103
103
  return f"{self._clock()} {label}{line}".rstrip()
104
104
 
105
105
  def _show(self, row: str, line: str) -> None:
@@ -14,6 +14,7 @@ import sys
14
14
  from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
+ from okstra_ctl import group_context
17
18
  from okstra_ctl.ids import slugify_task_segment
18
19
  from okstra_ctl.fixed_text import line, scalar
19
20
  from okstra_ctl.paths import task_dir, task_manifest_file
@@ -38,6 +39,7 @@ def _emit(payload: dict, *, text: bool = False) -> None:
38
39
  ("taskKey", "Task key"), ("previousWorkStatus", "Previous work status"),
39
40
  ("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
40
41
  ("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
42
+ ("groupContextPath", "Group context"),
41
43
  ):
42
44
  if key in payload:
43
45
  lines.append(line(label, payload.get(key)).rstrip("\n"))
@@ -152,6 +154,29 @@ def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) ->
152
154
  }
153
155
 
154
156
 
157
+ def _refresh_group_queue(project_root: Path, entry: dict) -> str:
158
+ """상태를 바꾼 task 가 속한 그룹 문서의 시작 순서를 다시 그린다.
159
+
160
+ 그 투영을 쓰는 곳이 report-finalize 하나뿐이라, 여기서 갱신하지 않으면
161
+ 사람이 적은 상태가 다음 finalize 까지 그룹 문서에 닿지 않는다. 그룹 문서가
162
+ 없거나 갱신에 실패해도 상태 기록 자체는 성공이다 — 매니페스트는 이미
163
+ 쓰였고, 이것은 파생 표면이다. 실패는 삼키지 않고 stderr 로 알린다.
164
+ """
165
+ task_group = str(entry.get("taskGroup") or "")
166
+ if not task_group:
167
+ return ""
168
+ try:
169
+ target = group_context.refresh_group_queue(project_root, task_group)
170
+ except (OSError, ValueError) as exc:
171
+ print(
172
+ f"set-work-status: work status recorded, but the group-context start "
173
+ f"order for {task_group!r} could not be redrawn: {exc}",
174
+ file=sys.stderr,
175
+ )
176
+ return ""
177
+ return str(target) if target is not None else ""
178
+
179
+
155
180
  def main(argv: list[str] | None = None) -> int:
156
181
  args = _parse_args(argv)
157
182
  project_root, entry, resolution_exit = _resolve_entry(args)
@@ -184,7 +209,11 @@ def main(argv: list[str] | None = None) -> int:
184
209
  )
185
210
  return 1
186
211
 
187
- _emit(_updated_payload(manifest_path, manifest, entry, args), text=args.text)
212
+ payload = _updated_payload(manifest_path, manifest, entry, args)
213
+ refreshed = _refresh_group_queue(project_root, entry)
214
+ if refreshed:
215
+ payload["groupContextPath"] = refreshed
216
+ _emit(payload, text=args.text)
188
217
  return 0
189
218
 
190
219