okstra 0.225.1 → 0.226.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 (67) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.mjs +5 -0
  4. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  5. package/docs/cli.md +17 -1
  6. package/docs/project-structure-overview.md +2 -0
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/bin/lib/okstra/cli.sh +5 -1
  10. package/runtime/bin/lib/okstra/globals.sh +1 -0
  11. package/runtime/bin/lib/okstra/usage.sh +3 -0
  12. package/runtime/bin/okstra.sh +2 -0
  13. package/runtime/prompts/duties/technical-verification-worker.json +2 -1
  14. package/runtime/prompts/lead/convergence.md +4 -1
  15. package/runtime/prompts/lead/phase-routing.md +7 -3
  16. package/runtime/prompts/lead/team-contract.md +1 -1
  17. package/runtime/prompts/wizard/prompts.ko.json +15 -0
  18. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +5 -0
  19. package/runtime/python/okstra_ctl/analysis_packet.py +108 -2
  20. package/runtime/python/okstra_ctl/approved_plan_stage_extract.py +206 -0
  21. package/runtime/python/okstra_ctl/brief_sources.py +200 -0
  22. package/runtime/python/okstra_ctl/business_flow/store.py +15 -2
  23. package/runtime/python/okstra_ctl/cmux.py +18 -3
  24. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -1
  25. package/runtime/python/okstra_ctl/design_surfaces.py +19 -7
  26. package/runtime/python/okstra_ctl/execution_mutation_audit.py +20 -18
  27. package/runtime/python/okstra_ctl/implementation_direction.py +10 -0
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +41 -0
  29. package/runtime/python/okstra_ctl/manager_split.py +185 -27
  30. package/runtime/python/okstra_ctl/next_phase.py +3 -1
  31. package/runtime/python/okstra_ctl/phases/final_verification/prior_blockers.py +128 -0
  32. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +8 -5
  33. package/runtime/python/okstra_ctl/phases/final_verification/report_assets/final-verification.template.html +1 -1
  34. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +84 -0
  35. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
  36. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +13 -5
  37. package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +1 -1
  38. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +67 -0
  39. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +4 -2
  40. package/runtime/python/okstra_ctl/phases/implementation_option_selection/report_assets/implementation-option-selection.template.html +2 -1
  41. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +127 -18
  42. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  43. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +2 -2
  44. package/runtime/python/okstra_ctl/report_assembly.py +23 -2
  45. package/runtime/python/okstra_ctl/report_views.py +2 -1
  46. package/runtime/python/okstra_ctl/run.py +46 -1
  47. package/runtime/python/okstra_ctl/run_context.py +13 -8
  48. package/runtime/python/okstra_ctl/wizard/confirmation.py +6 -0
  49. package/runtime/python/okstra_ctl/wizard/ids.py +6 -0
  50. package/runtime/python/okstra_ctl/wizard/registry.py +21 -0
  51. package/runtime/python/okstra_ctl/wizard/render.py +4 -0
  52. package/runtime/python/okstra_ctl/wizard/sources.py +33 -11
  53. package/runtime/python/okstra_ctl/wizard/state.py +4 -0
  54. package/runtime/python/okstra_ctl/wizard/steps_options.py +74 -9
  55. package/runtime/python/okstra_ctl/worker_prompt_body.py +21 -1
  56. package/runtime/python/okstra_ctl/worker_prompt_headers.py +11 -4
  57. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  58. package/runtime/schemas/final-report-v2.0.schema.json +74 -0
  59. package/runtime/schemas/final-report-v3.0.schema.json +99 -2
  60. package/runtime/skills/okstra-brief-gen/SKILL.md +26 -0
  61. package/runtime/skills/okstra-manager/SKILL.md +15 -2
  62. package/runtime/templates/reports/brief.template.md +10 -1
  63. package/runtime/templates/reports/html/i18n/en.json +8 -0
  64. package/runtime/templates/reports/html/i18n/ko.json +8 -0
  65. package/runtime/templates/worker-prompt-preamble.md +1 -1
  66. package/runtime/validators/validate-brief.py +39 -1
  67. package/runtime/validators/validate-run.py +5 -0
@@ -40,8 +40,10 @@ from .worker_prompt_body import (
40
40
  )
41
41
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
42
42
  from .worker_prompt_headers import source_worktree_headers, worker_prompt_headers
43
+ from .approved_plan_stage_extract import StageExtractError, materialize_stage_extract
43
44
  from .worker_prompt_policy import (
44
45
  APPROVED_PLAN_HEADER,
46
+ APPROVED_PLAN_STAGE_EXTRACT_HEADER,
45
47
  IMPLEMENTATION_STAGE_HEADER,
46
48
  STAGE_QA_OWNER_HEADER,
47
49
  PromptPlan,
@@ -720,6 +722,11 @@ def _prompt_body(
720
722
  plan,
721
723
  _optional_approved_plan_path(context) if plan.include_approved_plan else "",
722
724
  _selected_direction_path(context) if plan.include_selected_direction else "",
725
+ (
726
+ _approved_plan_stage_extract(context)
727
+ if plan.audience == "implementation-executor"
728
+ else ""
729
+ ),
723
730
  )
724
731
 
725
732
 
@@ -1466,6 +1473,10 @@ def _implementation_anchor_lines(
1466
1473
  lines.append(
1467
1474
  f"{APPROVED_PLAN_HEADER} {_required_approved_plan_path(context)}"
1468
1475
  )
1476
+ if plan.audience == "implementation-executor":
1477
+ extract = _approved_plan_stage_extract(context)
1478
+ if extract:
1479
+ lines.append(f"{APPROVED_PLAN_STAGE_EXTRACT_HEADER} {extract}")
1469
1480
  lines.append(
1470
1481
  f"{IMPLEMENTATION_STAGE_HEADER} {_required_stage_number(context)}"
1471
1482
  )
@@ -1526,6 +1537,36 @@ def _required_approved_plan_path(context: _MaterializationContext) -> str:
1526
1537
  return str(_resolve_input_path(context.project_root, Path(value)))
1527
1538
 
1528
1539
 
1540
+ def _approved_plan_stage_extract(context: _MaterializationContext) -> str:
1541
+ """Write this run's stage extract of the approved plan; its project path.
1542
+
1543
+ Empty when the plan is not a report record (see `materialize_stage_extract`),
1544
+ and the executor then reads the plan itself.
1545
+ """
1546
+ stage = _required_stage_number(context)
1547
+ if not stage.isdigit():
1548
+ raise InitialPromptMaterializationError(
1549
+ "required_input_missing",
1550
+ f"implementation stage is not a stage number: {stage}",
1551
+ )
1552
+ active_context_path = _resolve_input_path(
1553
+ context.project_root,
1554
+ Path(_required_string(context.manifest, "activeRunContextPath")),
1555
+ )
1556
+ suffix = active_context_path.name.removeprefix("active-run-context-")
1557
+ output = active_context_path.with_name(f"approved-plan-stage-{stage}-{suffix}")
1558
+ try:
1559
+ written = materialize_stage_extract(
1560
+ Path(_required_approved_plan_path(context)), int(stage), output
1561
+ )
1562
+ except (StageExtractError, JsonBoundaryError) as exc:
1563
+ raise InitialPromptMaterializationError(
1564
+ "required_input_missing",
1565
+ f"cannot project the approved plan onto stage {stage}: {exc}",
1566
+ ) from exc
1567
+ return _project_relative(context.project_root, output) if written else ""
1568
+
1569
+
1529
1570
  def _optional_approved_plan_path(context: _MaterializationContext) -> str:
1530
1571
  """The approved plan path when this run recorded one, otherwise empty.
1531
1572
 
@@ -18,6 +18,7 @@ from types import ModuleType
18
18
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
19
19
  from okstra_ctl.jsonl import append_jsonl
20
20
 
21
+ from .analysis_packet import BY_REFERENCE_FORMAT, _fence_mask
21
22
  from .ids import slugify_task_segment
22
23
  from .manager_paths import children_json_path, events_jsonl_path, split_plan_path, task_manifest_path
23
24
  from .manager_store import (
@@ -47,6 +48,7 @@ _LIST_FIELDS = (
47
48
  "constraints",
48
49
  "openQuestions",
49
50
  "outOfScope",
51
+ "localSources",
50
52
  )
51
53
  # 파일명·하위 task id 로 쓰이므로 경로 구분자나 공백을 받지 않는다.
52
54
  _TICKET_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@@ -54,6 +56,9 @@ _TICKET_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
54
56
  _FILE_TITLE_DROP = re.compile(r"[^0-9A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ ]+")
55
57
  _FILE_TITLE_MAX = 60
56
58
  _NONE = "_(none)_"
59
+ _CREATED = re.compile(r"^created: (\d{4}-\d{2}-\d{2})$", re.M)
60
+ # 다른 이슈나 같은 이슈의 다른 프로젝트가 늘 때 바뀌는 생성 절. 기존 브리프에서는 이 절의 바뀐 줄만 고친다.
61
+ _REFRESHED_SECTIONS = ("### Out of scope", "## Related Artifacts", "## Related Task Graph")
57
62
 
58
63
 
59
64
  def brief_file_title(title: str) -> str:
@@ -246,6 +251,40 @@ def _source_block(index: int, ref: str, fetched_via: str, fetched_at: str, body:
246
251
  ]
247
252
 
248
253
 
254
+ def _local_source_block(index: int, ref: str, fetched_at: str) -> list[str]:
255
+ return [
256
+ f"### Source {index} — file",
257
+ "",
258
+ f"- ref: {ref}",
259
+ "- fetched-via: okstra manager task split",
260
+ f"- fetched-at: {fetched_at}",
261
+ f"- format: {BY_REFERENCE_FORMAT}",
262
+ "",
263
+ "```",
264
+ "```",
265
+ "",
266
+ ]
267
+
268
+
269
+ def _local_source_refs(assignment: dict, project_root: Path, where: str, errors: list[str]) -> list[str]:
270
+ """계획의 `localSources` 를 프로젝트 루트 기준 상대 경로로 바꾼다. 루트 밖이나 없는 파일은 거절한다."""
271
+ root = project_root.resolve()
272
+ refs: list[str] = []
273
+ for index, raw in enumerate(assignment["localSources"], 1):
274
+ candidate = Path(raw).expanduser()
275
+ path = (candidate if candidate.is_absolute() else root / candidate).resolve()
276
+ try:
277
+ relative = path.relative_to(root).as_posix()
278
+ except ValueError:
279
+ errors.append(f"{where}.localSources[{index}] {raw!r} is outside project root {str(root)!r}")
280
+ continue
281
+ if not path.is_file():
282
+ errors.append(f"{where}.localSources[{index}] {raw!r} is not a file under {str(root)!r}")
283
+ continue
284
+ refs.append(relative)
285
+ return list(dict.fromkeys(refs))
286
+
287
+
249
288
  def _source_is_issue(source: dict, issue: dict) -> bool:
250
289
  return source["ref"] in {issue["ticketId"], issue["url"]}
251
290
 
@@ -284,6 +323,7 @@ def _render_brief(
284
323
  project_root: str,
285
324
  siblings: list[tuple[str, dict, Path]],
286
325
  graph: list[tuple[str, ...]],
326
+ local_sources: list[str],
287
327
  ) -> str:
288
328
  ticket = issue["ticketId"]
289
329
  project_id = assignment["projectId"]
@@ -322,6 +362,10 @@ def _render_brief(
322
362
  ]
323
363
  if not _source_is_issue(source, issue):
324
364
  lines.extend(_source_block(2, source["ref"], source["fetchedVia"], source["fetchedAt"], source["body"]))
365
+ first_local = 2 if _source_is_issue(source, issue) else 3
366
+ for index, ref in enumerate(local_sources, first_local):
367
+ # 같은 계획을 다시 돌려도 브리프가 같도록 날짜만 쓴다.
368
+ lines.extend(_local_source_block(index, ref, created))
325
369
  lines.extend(
326
370
  [
327
371
  "## Context",
@@ -385,10 +429,125 @@ def _validate_rendered(items: list[dict], validator: ModuleType, group_segment:
385
429
  path = briefs_root / group_segment / item["briefPath"].name
386
430
  path.parent.mkdir(parents=True)
387
431
  path.write_text(item["text"], encoding="utf-8")
388
- errors.extend(f"{item['taskKey']}: {error}" for error in validator.validate_brief(path, briefs_root))
432
+ # discussion follow-up 도 이 함수를 쓰며 projectRoot 를 넘기지 않는다. 그 브리프는 prepare 가 다시 검사한다.
433
+ project_root = Path(item["projectRoot"]) if item.get("projectRoot") else None
434
+ errors.extend(
435
+ f"{item['taskKey']}: {error}"
436
+ for error in validator.validate_brief(path, briefs_root, project_root)
437
+ )
389
438
  return errors
390
439
 
391
440
 
441
+ def _plan_items(
442
+ projects: dict, task_group: str, group_segment: str, source: dict, issues: list[dict], validator: ModuleType,
443
+ ) -> tuple[list[dict], list[str]]:
444
+ """(이슈, 프로젝트) 쌍마다 렌더링 입력을 만든다. 본문은 `created` 가 정해진 뒤 `_render_item` 이 쓴다."""
445
+ keys: dict[tuple[str, str], str] = {}
446
+ items: list[dict] = []
447
+ for issue in issues:
448
+ ticket = issue["ticketId"]
449
+ stem = "-".join(part for part in (ticket, brief_file_title(issue["title"])) if part)
450
+ for assignment in issue["assignments"]:
451
+ project_id = assignment["projectId"]
452
+ child_task_id = slugify_task_segment(ticket)
453
+ key = f"{project_id}:{task_group}:{child_task_id}"
454
+ keys[(ticket, project_id)] = key
455
+ root = Path(str(projects[project_id].get("projectRoot") or ""))
456
+ items.append({"issue": issue, "assignment": assignment, "stem": stem, "taskKey": key,
457
+ "projectId": project_id, "childTaskId": child_task_id, "projectRoot": str(root),
458
+ "briefPath": root / ".okstra" / "briefs" / group_segment / f"{stem}.md"})
459
+ errors: list[str] = []
460
+ for item in items:
461
+ item["localSources"] = _local_source_refs(item["assignment"], Path(item["projectRoot"]),
462
+ item["taskKey"], errors)
463
+ graph = _graph_rows(source, issues, keys, set(validator.DIRECTED_TASK_RELATIONS))
464
+ for item in items:
465
+ item["graph"] = graph
466
+ item["siblings"] = [(other["taskKey"], other["assignment"], other["briefPath"]) for other in items
467
+ if other["issue"] is item["issue"] and other is not item]
468
+ return items, errors
469
+
470
+
471
+ def _render_item(item: dict, source: dict, task_group: str, manager_task: str, created: str) -> str:
472
+ return _render_brief(source=source, issue=item["issue"], assignment=item["assignment"],
473
+ task_group=task_group, manager_task=manager_task,
474
+ child_task_id=item["childTaskId"], stem=item["stem"],
475
+ created=created, key=item["taskKey"], project_root=item["projectRoot"],
476
+ siblings=item["siblings"], graph=item["graph"], local_sources=item["localSources"])
477
+
478
+
479
+ def _previous_items(
480
+ home: Path, manager_id: str, task_group: str, task_id: str, projects: dict, group_segment: str,
481
+ validator: ModuleType,
482
+ ) -> tuple[dict, dict]:
483
+ """지난 분할이 남긴 계획의 렌더링 입력을 브리프 경로별로 돌려준다. 지금 프로젝트 목록으로 검증되지 않으면 비운다."""
484
+ path = split_plan_path(home, manager_id, task_group, task_id)
485
+ if not path.is_file():
486
+ return {}, {}
487
+ source, issues, errors = normalize_plan(_load_plan(path), set(projects), set(validator.TASK_RELATIONS))
488
+ if errors:
489
+ return {}, {}
490
+ items, errors = _plan_items(projects, task_group, group_segment, source, issues, validator)
491
+ if errors:
492
+ return {}, {}
493
+ return {item["briefPath"]: item for item in items}, source
494
+
495
+
496
+ def _existing_created(text: str) -> str:
497
+ match = _CREATED.search(text)
498
+ return match.group(1) if match else ""
499
+
500
+
501
+ def _section_spans(lines: list[str]) -> dict[str, tuple[int, int]]:
502
+ """펜스 밖 제목 줄마다 본문 범위 (시작, 끝). Source Material 본문 속 `#` 줄은 제목이 아니다."""
503
+ headings = [index for index, (line, fenced) in enumerate(zip(lines, _fence_mask(lines)))
504
+ if not fenced and line.startswith("#")]
505
+ spans: dict[str, tuple[int, int]] = {}
506
+ for position, start in enumerate(headings):
507
+ end = headings[position + 1] if position + 1 < len(headings) else len(lines)
508
+ spans.setdefault(lines[start], (start + 1, end))
509
+ return spans
510
+
511
+
512
+ def _outside_refreshed(lines: list[str], spans: dict[str, tuple[int, int]]) -> list[str]:
513
+ kept = list(lines)
514
+ for start, end in sorted((spans[heading] for heading in _REFRESHED_SECTIONS), reverse=True):
515
+ del kept[start:end]
516
+ return kept
517
+
518
+
519
+ def _merge_section(ours: list[str], base: list[str], new: list[str]) -> list[str]:
520
+ """지난 렌더링에서 이번 렌더링으로 바뀐 줄만 기존 절에 반영한다. 손으로 더한 줄은 남는다."""
521
+ removed = set(base) - set(new)
522
+ added = [line for line in new if line.strip() and line not in base]
523
+ merged = [line for line in ours if not (line.strip() and line in removed)]
524
+ if any(line != _NONE for line in added):
525
+ merged = [line for line in merged if line != _NONE]
526
+ added = [line for line in added if line != _NONE]
527
+ elif any(line.strip() and line != _NONE for line in merged):
528
+ added = []
529
+ content = [index for index, line in enumerate(merged) if line.strip()]
530
+ at = content[-1] + 1 if content else min(1, len(merged))
531
+ merged[at:at] = added
532
+ return merged
533
+
534
+
535
+ def _refresh(current: str, base: str, new: str) -> str | None:
536
+ """`base` 와 `new` 가 생성 갱신 절에서만 다르면 그 차이를 `current` 에 반영한 본문, 아니면 None."""
537
+ ours, old, fresh = current.split("\n"), base.split("\n"), new.split("\n")
538
+ ours_spans, old_spans, fresh_spans = _section_spans(ours), _section_spans(old), _section_spans(fresh)
539
+ if any(heading not in spans for spans in (ours_spans, old_spans, fresh_spans) for heading in _REFRESHED_SECTIONS):
540
+ return None
541
+ if _outside_refreshed(old, old_spans) != _outside_refreshed(fresh, fresh_spans):
542
+ return None
543
+ result = list(ours)
544
+ for heading in sorted(_REFRESHED_SECTIONS, key=lambda heading: ours_spans[heading][0], reverse=True):
545
+ start, end = ours_spans[heading]
546
+ result[start:end] = _merge_section(ours[start:end], old[slice(*old_spans[heading])],
547
+ fresh[slice(*fresh_spans[heading])])
548
+ return "\n".join(result)
549
+
550
+
392
551
  def split_task(
393
552
  home: Path,
394
553
  manager_id: str,
@@ -412,46 +571,45 @@ def split_task(
412
571
  raise ManagerError("split plan rejected:\n- " + "\n- ".join(errors))
413
572
  when = now or _now_iso()
414
573
  manager_task = f"{manager_id}/{task_group}/{task_id}"
415
- keys: dict[tuple[str, str], str] = {}
416
- items: list[dict] = []
417
- for issue in issues:
418
- ticket = issue["ticketId"]
419
- stem = "-".join(part for part in (ticket, brief_file_title(issue["title"])) if part)
420
- for assignment in issue["assignments"]:
421
- project_id = assignment["projectId"]
422
- child_task_id = slugify_task_segment(ticket)
423
- key = f"{project_id}:{task_group}:{child_task_id}"
424
- keys[(ticket, project_id)] = key
425
- root = Path(str(projects[project_id].get("projectRoot") or ""))
426
- items.append({"issue": issue, "assignment": assignment, "stem": stem, "taskKey": key,
427
- "projectId": project_id, "childTaskId": child_task_id, "projectRoot": str(root),
428
- "briefPath": root / ".okstra" / "briefs" / group_segment / f"{stem}.md"})
429
- graph = _graph_rows(source, issues, keys, set(validator.DIRECTED_TASK_RELATIONS))
574
+ items, errors = _plan_items(projects, task_group, group_segment, source, issues, validator)
575
+ if errors:
576
+ raise ManagerError("split plan rejected:\n- " + "\n- ".join(errors))
577
+ previous, previous_source = _previous_items(home, manager_id, task_group, task_id, projects, group_segment,
578
+ validator)
430
579
  for item in items:
431
- siblings = [(other["taskKey"], other["assignment"], other["briefPath"]) for other in items
432
- if other["issue"] is item["issue"] and other is not item]
433
- item["text"] = _render_brief(source=source, issue=item["issue"], assignment=item["assignment"],
434
- task_group=task_group, manager_task=manager_task,
435
- child_task_id=item["childTaskId"], stem=item["stem"],
436
- created=when[:10], key=item["taskKey"], project_root=item["projectRoot"],
437
- siblings=siblings, graph=graph)
580
+ path = item["briefPath"]
581
+ item["current"] = path.read_text(encoding="utf-8") if path.is_file() else None
582
+ # 다시 분할해도 브리프가 처음 쓰인 날짜를 유지한다. 실행 날짜를 쓰면 계획이 같아도 모든 브리프가 바뀐다.
583
+ created = _existing_created(item["current"] or "") or when[:10]
584
+ item["text"] = _render_item(item, source, task_group, manager_task, created)
585
+ before = previous.get(path)
586
+ item["base"] = _render_item(before, previous_source, task_group, manager_task, created) if before else None
438
587
  errors = _validate_rendered(items, validator, group_segment)
439
588
  if errors:
440
589
  raise ManagerError("rendered briefs failed validate-brief:\n- " + "\n- ".join(errors))
441
590
  for item in items:
442
- path = item["briefPath"]
443
- if not path.is_file():
591
+ current, base = item["current"], item["base"]
592
+ refreshed = _refresh(current, base, item["text"]) if current is not None and base is not None else None
593
+ if current is None:
444
594
  item["briefStatus"] = "created"
445
- elif path.read_text(encoding="utf-8") == item["text"]:
595
+ elif current == item["text"] or base == item["text"] or refreshed == current:
446
596
  item["briefStatus"] = "unchanged"
597
+ elif refreshed is not None:
598
+ item["briefStatus"] = "refreshed"
599
+ item["text"] = refreshed
447
600
  else:
448
601
  item["briefStatus"] = "overwritten"
449
602
  conflicts = [str(item["briefPath"]) for item in items if item["briefStatus"] == "overwritten"]
450
603
  if conflicts and not overwrite:
451
604
  raise ManagerError(
452
- "briefs already exist with different content; pass --overwrite to replace them:\n- "
605
+ "briefs already exist with content this plan would change outside the Related Task Graph, "
606
+ "Out of scope and Related Artifacts sections; pass --overwrite to replace them:\n- "
453
607
  + "\n- ".join(conflicts)
454
608
  )
609
+ errors = _validate_rendered([item for item in items if item["briefStatus"] == "refreshed"], validator,
610
+ group_segment)
611
+ if errors:
612
+ raise ManagerError("refreshed briefs failed validate-brief:\n- " + "\n- ".join(errors))
455
613
  for item in items:
456
614
  if item["briefStatus"] != "unchanged":
457
615
  item["briefPath"].parent.mkdir(parents=True, exist_ok=True)
@@ -270,7 +270,9 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
270
270
  return make(status=_OPTION_SELECTION_NON_PHASE[routing])
271
271
  if not routing:
272
272
  return make(status=STATUS_PENDING)
273
- return make(phase=routing, status=STATUS_READY)
273
+ # 이 phase 의 routing 은 enum 이라 이유 칸이 없다. technical-verification 과
274
+ # 검증된 preselected 방향의 근거도 차단 분기와 같은 selectionGuidance 에서 온다.
275
+ return make(phase=routing, status=STATUS_READY, rationale=_selection_guidance(selection))
274
276
 
275
277
 
276
278
  def _direction_selection_reason(selection: Mapping[str, Any]) -> str:
@@ -0,0 +1,128 @@
1
+ """같은 검증 대상의 이전 final-verification run 이 남긴 차단 사유를 packet 용으로 좁힌다.
2
+
3
+ final-verification run 은 직전 run 의 판정을 하나도 받지 못했다. 그래서 각 run 이
4
+ 결함 하나를 새로 찾고, 같은 계열의 다음 칸은 다음 run 이 찾았다. 이 블록은 같은
5
+ reports 디렉터리(단일 stage 면 stage 별, whole-task 면 task 별)의 이전 리포트에서
6
+ 차단 사유 전부와 가장 최근 run 의 잔여 위험만 싣는다. 리포트 전문은 싣지 않는다.
7
+
8
+ 재발 판단은 소유하지 않는다. 워커가 원리를 비교해 `defectClass.recurrence` 를
9
+ 적고, 검증기는 그 표기의 형식과 라우팅만 본다(`validation.py`).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ from okstra_ctl.final_report_paths import final_report_data_path
18
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
19
+
20
+ _REPORT_RE = re.compile(r"^final-report-final-verification-(\d{3,})\.data\.json$")
21
+ _SUMMARY_LIMIT = 300
22
+ _WHITESPACE_RE = re.compile(r"\s+")
23
+
24
+
25
+ def prior_blockers_packet_block(final_report_path: Path) -> str:
26
+ """이번 run 리포트보다 앞선 순번의 리포트 요약. 차단 사유가 없으면 빈 문자열."""
27
+ current = _REPORT_RE.match(final_report_data_path(Path(final_report_path)).name)
28
+ current_seq = int(current.group(1)) if current else None
29
+ reports_dir = Path(final_report_path).parent
30
+ priors = []
31
+ for path in sorted(reports_dir.glob("final-report-final-verification-*.data.json")):
32
+ match = _REPORT_RE.match(path.name)
33
+ if match is None:
34
+ continue
35
+ seq = int(match.group(1))
36
+ if current_seq is not None and seq >= current_seq:
37
+ continue
38
+ data = _load(path)
39
+ if data:
40
+ priors.append((match.group(1), data))
41
+ if not priors:
42
+ return ""
43
+ run_lines: list[str] = []
44
+ for seq, data in priors:
45
+ run_lines.extend(_run_lines(seq, data))
46
+ latest_seq, latest = priors[-1]
47
+ risk_lines = _risk_lines(latest)
48
+ if not any(line.startswith(" - AB-") for line in run_lines) and not risk_lines:
49
+ return ""
50
+ parts = [
51
+ "## Prior Final-Verification Blockers",
52
+ "",
53
+ "Earlier final-verification runs of this same target blocked on the",
54
+ "rows below. Before judging anything new, re-check each carried",
55
+ "blocker's whole class — every location and case its mechanism reaches —",
56
+ "on the current head. When a blocker you raise shares a carried",
57
+ "blocker's mechanism, set `defectClass.recurrence` to `recurring`, cite",
58
+ "the carried row in `defectClass.priorBlockers` as",
59
+ "`final-verification-NNN AB-NNN`, and route to a phase that re-plans",
60
+ "the class, never to `implementation`.",
61
+ "",
62
+ *run_lines,
63
+ ]
64
+ if risk_lines:
65
+ parts.extend([
66
+ "",
67
+ f"Residual risks `final-verification-{latest_seq}` left open:",
68
+ *risk_lines,
69
+ ])
70
+ return "\n".join(parts)
71
+
72
+
73
+ def _run_lines(seq: str, data: Mapping) -> list[str]:
74
+ fv = data.get("finalVerification")
75
+ fv = fv if isinstance(fv, Mapping) else {}
76
+ verdict = _one_line((data.get("finalVerdict") or {}).get("verdictToken")) or "unrecorded"
77
+ routing = fv.get("routingRecommendation")
78
+ target = _one_line(routing.get("target")) if isinstance(routing, Mapping) else ""
79
+ lines = [
80
+ f"- `final-verification-{seq}`: verdict `{verdict}`, routed "
81
+ f"`{target or 'unrecorded'}`"
82
+ ]
83
+ for row in fv.get("acceptanceBlockers") or []:
84
+ if not isinstance(row, Mapping):
85
+ continue
86
+ defect_class = row.get("defectClass")
87
+ mechanism = (
88
+ _one_line(defect_class.get("mechanism"))
89
+ if isinstance(defect_class, Mapping) else ""
90
+ )
91
+ line = (
92
+ f" - {_one_line(row.get('id')) or '<no id>'} "
93
+ f"({_one_line(row.get('severity')) or 'unrated'}): "
94
+ f"{_one_line(row.get('statement'))}"
95
+ )
96
+ if mechanism:
97
+ line += f" — mechanism: {mechanism}"
98
+ lines.append(line)
99
+ return lines
100
+
101
+
102
+ def _risk_lines(data: Mapping) -> list[str]:
103
+ fv = data.get("finalVerification")
104
+ rows = fv.get("residualRisk") if isinstance(fv, Mapping) else None
105
+ return [
106
+ f"- {_one_line(row.get('id')) or '<no id>'}: {_one_line(row.get('item'))}"
107
+ for row in rows or []
108
+ if isinstance(row, Mapping)
109
+ ]
110
+
111
+
112
+ def _load(path: Path) -> Mapping:
113
+ try:
114
+ data = load_owned_object(path, artifact="prior final-verification report")
115
+ except (JsonBoundaryError, OSError):
116
+ # expected-miss: 이전 run 이 깨뜨린 리포트. 맥락 블록일 뿐이라 그 run 을
117
+ # 건너뛰고, 이번 검증 준비는 막지 않는다.
118
+ return {}
119
+ return data if isinstance(data, Mapping) else {}
120
+
121
+
122
+ def _one_line(value: object) -> str:
123
+ if not isinstance(value, str):
124
+ return ""
125
+ collapsed = _WHITESPACE_RE.sub(" ", value).strip()
126
+ if len(collapsed) <= _SUMMARY_LIMIT:
127
+ return collapsed
128
+ return collapsed[: _SUMMARY_LIMIT - 1].rstrip() + "…"
@@ -30,6 +30,8 @@
30
30
  - Worker verification procedure:
31
31
  - **Target confirmation:** analyse the injected target and nothing else. Read `verification-target.md` for the stage/report mapping and the complete diff stat. Prepare fixed that target and `phases/final_verification/validation.py` `validate_verification_target_match` (called by `validators/validate-run.py`) re-checks the report against its digest, so the procedure to follow here is simply: if the worktree you can see does not match the injected target, record a `tool-failure` — never reselect a target.
32
32
  - **Evidence:** attach file:line, exact command + exit code, log excerpt, or MCP SELECT evidence to every finding. Mark a requirement as covered only when the cited artifact demonstrates it.
33
+ - **Defect-class sweep:** a confirmed defect is one instance of a class, and finding it does not end the search. Name its mechanism in one sentence (for example "after a recovery the editor keeps a copy of server data taken before the recovery"). Enumerate every location in the verified diff and its direct callers where that mechanism can occur, and the cases that change its outcome (for example same version, version changed, two recoveries overlapping, cancelled request). Judge every location × case cell `defective`, `sound`, `unverified`, or `not-applicable` with evidence, and report every defective cell in this run. Each blocker records the sweep as `defectClass` (`mechanism`, `cells[]`, `recurrence`). Its fix is the whole class: every `defective` cell names the `regressionTest` that must fail before the fix and pass after it. Sweep a Residual Risk's class the same way before writing it; a residual row has no `defectClass` field, so the sweep goes into its `item` text. **Enforced:** `phases/final_verification/validation.py` `_validate_defect_class_sweep` refuses a blocker without `defectClass`, without a `defective` cell, or with a `defective` cell that names no `regressionTest`. Whether the enumeration is complete is a judgement no validator can make.
34
+ - **Prior-run carry-in:** when an earlier final-verification run of the same target exists, the analysis packet carries its blockers under `## Prior Final-Verification Blockers` (`phases/final_verification/prior_blockers.py`). Re-check each carried blocker's whole class on the current head. A blocker whose mechanism matches a carried one is `recurrence: recurring` and cites it in `priorBlockers` as `final-verification-NNN AB-NNN`; a class a single-point fix already failed to close is re-planned, so a report with a `recurring` blocker routes to `error-analysis`, `implementation-option-selection`, or `implementation-planning`, never to `implementation`. **Enforced:** `validation.py` `_validate_defect_class_sweep` refuses a `recurring` blocker with no `priorBlockers`, and `_validate_recurring_class_routing` refuses any other routing target. Marking a matching class `new` is a judgement the validator cannot see.
33
35
  - **Tier 1 and Tier 2 read-only validation:** Tier 1 is the originating brief/approved plan `validation` set. Tier 2 is the `Project QA Commands` section from `okstra model-io project-context --project-root <PROJECT_ROOT> --task-ref <TASK_KEY>` (the full `Task key` your dispatch prompt names). Do not auto-detect commands from package manifests. A missing tier is `qa-command not configured: <category>`. Before execution, reject commands containing source/lockfile mutation tokens such as `--fix`, `--write`, ` -w`, ` -u`, `--snapshot-update`, `INSTA_UPDATE=<not-no>`, `cargo update`, or `npm install` without `ci`; record the exact denied token. Tier 2 is already screened by prepare, so this check catches a Tier 1 command the brief or plan named.
34
36
  - **External QA outcome policy:** continue to attempt every in-scope Tier 3
35
37
  command. For an entry requiring `db`, `http`, or `external`, record non-PASS
@@ -56,7 +58,7 @@
56
58
  - **Source Implementation Report(s)** (**Enforced:** `phases/final_verification/validation.py` `validate_verification_target_match` (called by `validators/validate-run.py`) compares `verificationScope`, `worktreePath`, `implementationBaseRef`, `capturedHeadSha`, and the `stageReports` stage set against the digest-verified `instruction-set/verification-target.md`; a snapshot whose digest no longer checks out is ignored rather than trusted. `verificationScope` in particular gates both stage-group eligibility and release-handoff routing, so it is not the report's to restate): the `VERIFICATION_TARGET` snapshot verbatim — verification scope, worktree path, base/head refs, the list of stages under verification, and one row per stage citing its originating implementation final-report (`report_path` from `consumers.jsonl`; render `(report_path unrecorded)` when absent). Every analyser prompt carries the same compact target identity (`**Verification scope:** / **Worktree:** / **Verification base ref:** / **Verification head ref:** / **Verification target path:** / **Verification target digest:**`) and reads the sidecar on demand for the complete diff stat. A worker that cannot confirm its analysis ran against that worktree's delivered diff MUST record a `tool-failure`.
57
59
  - **Verdict vocabulary**: Section 7 (`Final Verdict`) MUST include a `Verdict Token` field whose value is exactly one of `accepted`, `conditional-accept`, or `blocked`. `conditional-accept` requires an explicit, exhaustive list of conditions; ambiguous verdicts ("looks good", "mostly ready") are not allowed. Each condition MUST be recorded as a row in the **Conditional Acceptance Conditions** deliverable (`id` `CA-NNN`, `condition`, `evidenceRequired`, `blocksReleaseHandoff`). `blocksReleaseHandoff` is a gate, not a note: `false` means this condition alone would not stop the release, and a `conditional-accept` whose conditions are all `false` may route to `release-handoff` (the conditions travel into the PR body as unresolved items). Declare `true` for anything that must be settled before release. The validator enforces verdict↔deliverable consistency: `accepted` ⇒ zero acceptance blockers, `blocked` ⇒ at least one, `conditional-accept` ⇒ at least one condition, and a `release-handoff` routing recommendation is allowed only when the verdict is release-ready by `okstra_ctl.release_gate.release_handoff_allowed`. **Any Acceptance Blocker therefore forces the verdict off `accepted` (to `conditional-accept` or `blocked`); the gates below cite this rule instead of restating the arithmetic.**
58
60
  - **Added-surface audit** (`finalVerification.addedSurfaceAudit`): the reverse of requirement coverage — one row per surface the merged diff added (`id` `AS-NNN`, `surface` as `path:line` + the added name, `callers`, `requirement`, `disposition`, `note`). Requirement coverage proves every requirement reached the diff; this table proves every addition answers a requirement. An empty array is a claim that the diff added no identifier, module, or configuration entry, not permission to skip the enumeration. Single-stage scope audits its own stage's diff; whole-task audits the merged diff, which is the only place a surface added by one stage and orphaned by another is visible.
59
- - **Acceptance Blockers block** (under section 4): one row per blocker with `id`, `severity` (`critical` / `major`), evidence (file path, log excerpt, or test output), and the recommended follow-up phase: `error-analysis` for a cause problem, `implementation-option-selection` for a direction problem, or `implementation-planning` for a detailed-plan problem. Empty block is acceptable and preferred — render the single line `- No acceptance blockers found.`
61
+ - **Acceptance Blockers block** (under section 4): one row per defect class with `id`, `severity` (`critical` / `major`), evidence (file path, log excerpt, or test output), the recommended follow-up phase: `error-analysis` for a cause problem, `implementation-option-selection` for a direction problem, or `implementation-planning` for a detailed-plan problem, and the `defectClass` sweep from the worker procedure above. The `statement` names the fix for the whole class, not for the first cell found. Empty block is acceptable and preferred — render the single line `- No acceptance blockers found.`
60
62
  - **Residual Risk block** (under section 4): risks that are not blockers but should be tracked, each with mitigation owner and a trigger that would escalate them to a blocker.
61
63
  - **Validation Evidence**: for every requirement in the originating plan or task brief, cite the artifact (commit SHA, test output, log line, MCP SELECT result) that demonstrates coverage. Paraphrased "verified" claims without an artifact are rejected.
62
64
  - **Read-only command log**: any pre-existing test/validation command touched during this run MUST be listed with its exact command line and one honest status — `executed` (ran; carries its exit code) / `advisory` (external Tier 3 did not PASS; carries observed/expected results and remains user-owned) / `env-unavailable` (should run but cannot in this environment — missing replica DB, container, or service; carries the reason, never a faked pass) / `not-configured` (no such qa-command tier) / `rejected` (a mutating/denied token — skipped, carries the denied token). A check that could not run locally is recorded as `env-unavailable` or `advisory` according to the external QA policy — never silently dropped and never reported as `executed` with an invented exit code. Mutating-command prohibition is the shared read-only boundary (see Non-goals); it is not restated per row.
@@ -68,11 +70,12 @@
68
70
  - Self-review pass before finalising the report (the Okstra lead runs this; do not delegate it):
69
71
  1. **Verdict precision** — section 7 (`Final Verdict`) includes `Verdict Token` with one of the three allowed verdict tokens; `conditional-accept` lists every condition as an actionable item.
70
72
  2. **Blocker traceability** — every blocker cites a concrete artifact (file:line, log excerpt, test exit code, MCP SELECT). Blockers without evidence are demoted to residual risk or removed.
71
- 3. **Coverage check** — every requirement in the originating plan/task brief is either marked covered (with artifact) or listed as a blocker. A user-owned external advisory gap is the sole outcome exception: keep it as a Residual Risk with the exact rerun Recommended Next Step instead of promoting it to a blocker. No silent omissions.
72
- 4. **Verifier dissent preserved** — if workers reach different verdicts, the disagreement is visible in section 1.2; synthesis hides nothing.
73
- 5. **No source-mutation audit** — scan the run's session transcripts for Edit / Write or state-mutating Bash commands that touch paths OUTSIDE `<PROJECT_ROOT>/.okstra/**` and outside the assigned run-artifact paths. Writes to worker prompts, audit sidecars, team-state, the final-report `data.json`, and rendered reports under the run directory are allowed okstra artifacts. Any source/schema/deployment mutation means the run has crossed into implementation and MUST be re-routed; do NOT silently strip the evidence.
73
+ 3. **Class completeness** — the first confirmed blocker is not the whole list. Before the verdict, check each blocker's `defectClass` against the verified diff: a location or case the workers did not judge is swept now (re-dispatch the verifiers on that class when the lead cannot judge it from the cited evidence), and blockers sharing one mechanism merge into one row. Carried prior blockers are re-checked the same way.
74
+ 4. **Coverage check** — every requirement in the originating plan/task brief is either marked covered (with artifact) or listed as a blocker. A user-owned external advisory gap is the sole outcome exception: keep it as a Residual Risk with the exact rerun Recommended Next Step instead of promoting it to a blocker. No silent omissions.
75
+ 5. **Verifier dissent preserved** — if workers reach different verdicts, the disagreement is visible in section 1.2; synthesis hides nothing.
76
+ 6. **No source-mutation audit** — scan the run's session transcripts for Edit / Write or state-mutating Bash commands that touch paths OUTSIDE `<PROJECT_ROOT>/.okstra/**` and outside the assigned run-artifact paths. Writes to worker prompts, audit sidecars, team-state, the final-report `data.json`, and rendered reports under the run directory are allowed okstra artifacts. Any source/schema/deployment mutation means the run has crossed into implementation and MUST be re-routed; do NOT silently strip the evidence.
74
77
  - Cross-verification mode:
75
- - **Acceptance critic (opt-in, one slot — recommended 1)**: the user chooses whether to add the slot at launch and, when they add it, picks its model. With no critic slot this pass does not run. A reused-worker **acceptance devil's-advocate** pass is dispatched concurrently with the first convergence reverify round to surface candidate acceptance blockers the verifiers may have missed; candidates are verified only after convergence completes. Each candidate is verified **confirm-or-downgrade**: confirmed → an `Acceptance Blockers` row; unconfirmed → a `Residual Risk` row (never dropped). See `prompts/lead/convergence.md` "Acceptance critic pass (final-verification)".
78
+ - **Acceptance critic (opt-in, one slot — recommended 1)**: the user chooses whether to add the slot at launch and, when they add it, picks its model. With no critic slot this pass does not run. A reused-worker **acceptance devil's-advocate** pass is dispatched concurrently with the first convergence reverify round to surface candidate acceptance blockers the verifiers may have missed; candidates are verified only after convergence completes. Each candidate is verified **confirm-or-downgrade**: confirmed → an `Acceptance Blockers` row; unconfirmed → a `Residual Risk` row (never dropped). The critic also sweeps the classes the verifiers raised: a location or case of an existing blocker's mechanism that the verifiers did not judge is a new candidate, not a restatement. Reverify rounds stay confirm-or-refute only; the sweep belongs to the workers that raise a finding and to the critic. See `prompts/lead/convergence.md` "Acceptance critic pass (final-verification)".
76
79
  - Non-goals:
77
80
  - proposing unrelated refactors beyond the delivered scope
78
81
  - **source code edits, follow-up bug fixes, or scope expansion** — this run renders a verdict only; defects detected here become inputs to a new `error-analysis`, `implementation-option-selection`, or `implementation-planning` run according to whether the cause, direction, or detailed plan is invalid
@@ -34,7 +34,7 @@
34
34
  <section data-report-section="blockers" data-report-field="finalVerification.acceptanceBlockers">
35
35
  <h2>{{ t('tasks.final-verification.what-blocks-acceptance') }}</h2>
36
36
  {{ render_narrative(narrative.blockerExplanation, "finalVerification.userNarrative.blockerExplanation") }}
37
- <div class="summary-grid">{% for row in final.acceptanceBlockers %}<article class="summary-card tone-important" id="id-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ row.severity }}</p><h3>{{ row.statement | inline_code }}</h3><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p><p>{{ t('tasks.final-verification.follow-up') }} {{ row.followUpPhase | inline_code }}</p></article>{% else %}<p>{{ t('tasks.final-verification.nothing-is-holding-acceptance') }}</p>{% endfor %}</div>
37
+ <div class="summary-grid">{% for row in final.acceptanceBlockers %}<article class="summary-card tone-important" id="id-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ row.severity }}</p><h3>{{ row.statement | inline_code }}</h3><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p><p>{{ t('tasks.final-verification.follow-up') }} {{ row.followUpPhase | inline_code }}</p>{% if row.get("defectClass") %}{% set dc = row.defectClass %}<p><strong>{{ t('tasks.final-verification.defect-mechanism') }}</strong> {{ dc.mechanism | inline_code }}</p>{% if dc.recurrence == "recurring" %}<p><strong>{{ t('tasks.final-verification.recurring-class') }}</strong> {{ (dc.get("priorBlockers") or []) | join(", ") }}</p>{% endif %}<table data-report-field="finalVerification.acceptanceBlockers.defectClass.cells"><thead><tr><th>{{ t('tasks.final-verification.location') }}</th><th>{{ t('tasks.final-verification.case') }}</th><th>{{ t('tasks.final-verification.verdict') }}</th><th>{{ t('tasks.final-verification.evidence') }}</th><th>{{ t('tasks.final-verification.regression-test') }}</th></tr></thead><tbody>{% for cell in dc.cells %}<tr><td>{{ cell.location | inline_code }}</td><td>{{ cell.case | inline_code }}</td><td>{{ cell.verdict }}</td><td>{{ cell.evidence | inline_code }}</td><td>{{ (cell.get("regressionTest") or "") | inline_code }}</td></tr>{% endfor %}</tbody></table>{% endif %}</article>{% else %}<p>{{ t('tasks.final-verification.nothing-is-holding-acceptance') }}</p>{% endfor %}</div>
38
38
  </section>
39
39
 
40
40
  <section data-report-section="residual-risk" data-report-field="finalVerification.residualRisk">