okstra 0.225.0 → 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.
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -0
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/pr/pr.mjs +42 -13
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/docs/cli.md +19 -3
- package/docs/project-structure-overview.md +2 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/code-reviewer.json +7 -4
- package/runtime/prompts/duties/technical-verification-worker.json +2 -1
- package/runtime/prompts/lead/convergence.md +4 -1
- package/runtime/prompts/lead/phase-routing.md +7 -3
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +15 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +5 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +108 -2
- package/runtime/python/okstra_ctl/approved_plan_stage_extract.py +206 -0
- package/runtime/python/okstra_ctl/brief_sources.py +200 -0
- package/runtime/python/okstra_ctl/business_flow/store.py +15 -2
- package/runtime/python/okstra_ctl/cmux.py +22 -5
- package/runtime/python/okstra_ctl/code_review_coverage.py +26 -2
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +19 -7
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +20 -18
- package/runtime/python/okstra_ctl/implementation_direction.py +10 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +41 -0
- package/runtime/python/okstra_ctl/manager_split.py +185 -27
- package/runtime/python/okstra_ctl/next_phase.py +3 -1
- package/runtime/python/okstra_ctl/phases/final_verification/prior_blockers.py +128 -0
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +8 -5
- package/runtime/python/okstra_ctl/phases/final_verification/report_assets/final-verification.template.html +1 -1
- package/runtime/python/okstra_ctl/phases/final_verification/validation.py +84 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +13 -5
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +67 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +4 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/report_assets/implementation-option-selection.template.html +2 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +127 -18
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +2 -2
- package/runtime/python/okstra_ctl/report_assembly.py +23 -2
- package/runtime/python/okstra_ctl/report_views.py +2 -1
- package/runtime/python/okstra_ctl/run.py +46 -1
- package/runtime/python/okstra_ctl/run_context.py +13 -8
- package/runtime/python/okstra_ctl/wizard/confirmation.py +6 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +6 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +21 -0
- package/runtime/python/okstra_ctl/wizard/render.py +4 -0
- package/runtime/python/okstra_ctl/wizard/sources.py +33 -11
- package/runtime/python/okstra_ctl/wizard/state.py +4 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +74 -9
- package/runtime/python/okstra_ctl/worker_prompt_body.py +21 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +11 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/schemas/final-report-v2.0.schema.json +74 -0
- package/runtime/schemas/final-report-v3.0.schema.json +99 -2
- package/runtime/skills/okstra-brief-gen/SKILL.md +26 -0
- package/runtime/skills/okstra-code-review/SKILL.md +2 -0
- package/runtime/skills/okstra-code-review/references/census-rules.md +16 -2
- package/runtime/skills/okstra-code-review/references/review-calibration.md +7 -1
- package/runtime/skills/okstra-manager/SKILL.md +15 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +15 -1
- package/runtime/templates/reports/brief.template.md +10 -1
- package/runtime/templates/reports/html/i18n/en.json +8 -0
- package/runtime/templates/reports/html/i18n/ko.json +8 -0
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-brief.py +39 -1
- package/runtime/validators/validate-run.py +5 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
443
|
-
if not
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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. **
|
|
72
|
-
4. **
|
|
73
|
-
5. **
|
|
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">
|
|
@@ -20,6 +20,8 @@ def validate_final_verification_content(data: dict, failures: list[str]) -> None
|
|
|
20
20
|
if (data.get("header") or {}).get("taskType") != "final-verification":
|
|
21
21
|
return
|
|
22
22
|
_validate_added_surface_audit(data, failures)
|
|
23
|
+
_validate_defect_class_sweep(data, failures)
|
|
24
|
+
_validate_recurring_class_routing(data, failures)
|
|
23
25
|
_validate_verdict_consistency(data, failures)
|
|
24
26
|
_validate_verification_scope(data, failures)
|
|
25
27
|
|
|
@@ -97,6 +99,88 @@ def _added_surface_row_failures(
|
|
|
97
99
|
return []
|
|
98
100
|
|
|
99
101
|
|
|
102
|
+
def _acceptance_blockers(data: dict) -> list[Mapping]:
|
|
103
|
+
rows = (data.get("finalVerification") or {}).get("acceptanceBlockers") or []
|
|
104
|
+
return [row for row in rows if isinstance(row, Mapping)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_defect_class_sweep(data: dict, failures: list[str]) -> None:
|
|
108
|
+
"""차단 사유마다 그것이 속한 결함 계열을 위치×경우 칸 단위로 훑었는지 본다.
|
|
109
|
+
|
|
110
|
+
위치 하나만 적은 차단 사유는 다음 run 이 같은 원리의 다른 위치나 다른 경우를
|
|
111
|
+
새로 찾게 만들었다. 실측: 한 계열이 final-verification 네 번에 걸쳐 하나씩
|
|
112
|
+
드러났다(dev-11562 stage 1, 001-004).
|
|
113
|
+
"""
|
|
114
|
+
for row in _acceptance_blockers(data):
|
|
115
|
+
row_id = str(row.get("id") or "<id 없음>")
|
|
116
|
+
defect_class = row.get("defectClass")
|
|
117
|
+
if not isinstance(defect_class, Mapping):
|
|
118
|
+
failures.append(
|
|
119
|
+
f"final-verification: acceptance blocker {row_id} has no "
|
|
120
|
+
"`defectClass` — name the mechanism and sweep every location "
|
|
121
|
+
"and case that shares it before reporting the blocker."
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
cells = [
|
|
125
|
+
cell for cell in defect_class.get("cells") or []
|
|
126
|
+
if isinstance(cell, Mapping)
|
|
127
|
+
]
|
|
128
|
+
defective = [cell for cell in cells if cell.get("verdict") == "defective"]
|
|
129
|
+
if not defective:
|
|
130
|
+
failures.append(
|
|
131
|
+
f"final-verification: acceptance blocker {row_id} sweeps no "
|
|
132
|
+
"`defective` cell — the blocker itself is one, so record it."
|
|
133
|
+
)
|
|
134
|
+
untested = [
|
|
135
|
+
f"{cell.get('location')} / {cell.get('case')}"
|
|
136
|
+
for cell in defective
|
|
137
|
+
if not str(cell.get("regressionTest") or "").strip()
|
|
138
|
+
]
|
|
139
|
+
if untested:
|
|
140
|
+
failures.append(
|
|
141
|
+
f"final-verification: acceptance blocker {row_id} has "
|
|
142
|
+
f"`defective` cells with no `regressionTest`: {untested} — the "
|
|
143
|
+
"fix covers the whole class, one regression test per cell."
|
|
144
|
+
)
|
|
145
|
+
if (
|
|
146
|
+
defect_class.get("recurrence") == "recurring"
|
|
147
|
+
and not defect_class.get("priorBlockers")
|
|
148
|
+
):
|
|
149
|
+
failures.append(
|
|
150
|
+
f"final-verification: acceptance blocker {row_id} is "
|
|
151
|
+
"`recurring` but cites no `priorBlockers` "
|
|
152
|
+
"(`final-verification-NNN AB-NNN`)."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_CLASS_REPLANNING_TARGETS = (
|
|
157
|
+
"error-analysis",
|
|
158
|
+
"implementation-option-selection",
|
|
159
|
+
"implementation-planning",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_recurring_class_routing(data: dict, failures: list[str]) -> None:
|
|
164
|
+
"""이전 run 이 이미 막았던 계열은 한 지점 수정으로 다시 보내지 않고 재계획한다."""
|
|
165
|
+
recurring = [
|
|
166
|
+
str(row.get("id") or "<id 없음>")
|
|
167
|
+
for row in _acceptance_blockers(data)
|
|
168
|
+
if isinstance(row.get("defectClass"), Mapping)
|
|
169
|
+
and row["defectClass"].get("recurrence") == "recurring"
|
|
170
|
+
]
|
|
171
|
+
if not recurring:
|
|
172
|
+
return
|
|
173
|
+
routing = (data.get("finalVerification") or {}).get("routingRecommendation")
|
|
174
|
+
target = str((routing or {}).get("target") or "") if isinstance(routing, Mapping) else ""
|
|
175
|
+
if target not in _CLASS_REPLANNING_TARGETS:
|
|
176
|
+
failures.append(
|
|
177
|
+
f"final-verification: acceptance blockers {recurring} are "
|
|
178
|
+
f"`recurring`, but routing targets `{target}`. A class a single-point "
|
|
179
|
+
"fix already failed to close routes to one of "
|
|
180
|
+
f"{list(_CLASS_REPLANNING_TARGETS)} so the whole class is re-planned."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
100
184
|
def _validate_verdict_consistency(data: dict, failures: list[str]) -> None:
|
|
101
185
|
verdict = data.get("finalVerdict") or {}
|
|
102
186
|
token = (verdict.get("verdictToken") or "").strip().lower()
|
package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md
CHANGED
|
@@ -52,7 +52,7 @@ template's check; that template is gone.
|
|
|
52
52
|
- **External-source adapters — structure AND fixture both derive from a captured real sample; a self-authored fixture is NOT reality evidence:** when this run's diff builds or changes an `external-interface` or `transformation-mapping` surface (an HTTP / network client, or a parser / mapper of a third-party payload — HTML / JSON / XML / CSV originating outside this repo), the adapter's structural assumptions (selectors, field paths, expected response shape) AND the static fixture / golden that tests them MUST BOTH derive from a **captured real sample** of that payload — the capture cited in the stage's `external-interface` / `transformation-mapping` design-prep item, or one captured this run and recorded with its `source` + capture time. The captured sample is a static fixture (no live socket), so a parser test against it stays in source like any unit test — the Real-IO isolation rule below governs *live* calls, not the captured bytes. Do NOT hand-invent the shape and then hand-write a fixture that agrees with it: the passing test then only proves the code matches your assumption, never that the assumption matches reality (self-confirming oracle — the observed failure was a parser whose selectors existed in its synthetic fixture and in zero real pages: hundreds of green units over a fiction, and the whole structure built on the wrong shape). When no real sample is reachable (no network this run, or the brief supplied none), do NOT synthesize a stand-in and present its green tests as correctness: mark the adapter's shape `reality-unverified (no captured sample)` in `Validation evidence`, keep any placeholder fixture explicitly labelled an assumption (never validation evidence), and surface an explicit **user-owned** item in the routing recommendation to confirm against real data. Unlike the DB gate above this does NOT itself block acceptance — live external verification stays a user-owned item per `final-verification`'s External QA advisory policy — but a synthetic external fixture presented as reality-verified is exactly the mock-only external evidence the `final-verification` test-correctness pass is meant to reject.
|
|
53
53
|
- **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa scripts directory `<task_root>/qa/scripts/` (`<TASK_QA_PATH>/scripts`; the `qa/` root itself holds only data sidecars — the Tier 3 conformance manifest and `result-*.json`). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/scripts/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite. **Author qa specs with the project's own test framework — never hand-roll `describe`/`it`/`expect`.** When the project ships a test runner as a devDependency (jest / vitest / pytest …), the qa spec uses it, invoked with the project config plus a discovery override pointing at the qa scripts dir (jest: `npx jest --config <project jest config> --roots <task_root>/qa/scripts --runInBand <spec-name>`) — the project config keeps module aliases resolving while the default sweep never collects the file; never widen the project's own test config to include qa paths. For TypeScript qa specs also write `<task_root>/qa/scripts/tsconfig.json` (`extends` the project tsconfig, adds the runner's `types` entry, `"include": ["**/*.ts"]`) so editors resolve path aliases and test globals — it is a qa artifact like the rest (untracked). **These qa artifacts stay untracked — never commit them.** `.okstra/**` is gitignored (the artifact-home rule); conformance scripts and their results are *executed* and recorded in the carry sidecar / verifier result, never written into git history. A committed `.okstra/qa` file is a stage-branch defect that leaks okstra internals into the eventual PR (see the `git add` rules below).
|
|
54
54
|
- **Stage conformance script (BLOCKING when the approved plan declared `Conformance tests:`).** Planning only declared the path and `requires`. This run MUST write the script to that path under `<task_root>/qa/scripts/` and add the matching `<task_root>/qa/conformance-manifest.json` entry: `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (the set the plan declared, widened by any db/io/http/external surface this stage's diff touches that the plan left out — never narrowed; `validate-run.py` `_declared_conformance_errors` accepts a superset and rejects a subset), `passContract`, `exemption: null`, `waiver: null`. Do not skip this when the plan declared tests. If the plan declared `Conformance exemption:`, do not invent a script — with one exception: when this stage's diff touches a db/io/http/external surface the exemption promised it would not (the verifier's diff-surface cross-check names the surface), write the script and the manifest entry for this stage exactly as for a declared stage, with `requires` covering those surfaces. The approved plan is not rewritten; `validate-run.py` `_declared_conformance_errors` accepts an entry for a stage the plan exempted and still rejects one for a stage the plan does not have. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <reason>` line per requirement. The verifier runs `runCommand` from the **worktree cwd**, and that cwd is the tree under test. `runCommand` MUST NOT repoint it: a leading `cd <checkout> &&` sends the script at a tree without this stage's changes. Absolute paths are fine and usually necessary — the script and its `tsconfig` live under `<task_root>/qa/scripts/`, i.e. under `.okstra/`, and a worktree does not carry `.okstra/`. Point at those by absolute path; leave the cwd alone. **Enforced:** `scripts/okstra_ctl/conformance.py` `_check_entry` rejects a `runCommand` whose first word in any `&&` / `;` segment changes directory; `validators/validate-run.py` `_validate_conformance` fails the run if the inherited declaration has no script file.
|
|
55
|
-
- read the
|
|
55
|
+
- read the stage extract at this prompt's `**Approved plan stage extract:**` anchor end-to-end. It is the approved plan projected onto this run's stage: the whole Stage Map (`stageMap`), this stage's entry, the requirement-coverage, validation, rollback and risk rows that name this stage or name no stage, each with the `planItemId` (`P-Val-4`, `P-Rb-2`) directives cite it by, and the clarification items. Its `omitted` object names everything left out and why. The plan at the `**Approved plan:**` anchor stays the source of record: open it only for a targeted lookup of a key the extract omits, never end-to-end. When the prompt has no extract anchor (a Markdown plan, or a prompt published before the extract existed), read the approved plan end-to-end and parse its Stage Map instead. Read this prompt's `**Stage for this implementation run:**` anchor: the single stage number this run owns. The runtime already selected and reserved this stage (one run = one stage) — do NOT recompute the start stage from `consumers.jsonl`. Both anchors are generated headers; when either is missing, stop and report `contract-violated` rather than inferring the value.
|
|
56
56
|
- load every `runs/<plan-key>/carry/stage-<i>.json` for `i ∈ depends-on(this stage)` and inject them into the executor's working context as "runtime carry-in". For a `depends-on (none)` stage, no sidecar load — task-brief only.
|
|
57
57
|
- this stage's `depends-on` are all already `status:done`. Its file list, step order, Stage Validation commands, Stage Exit Contract, and rollback path are the authoritative scope.
|
|
58
58
|
- **Clarification answers carried in (read before the first edit):** when the user answered the approved plan's `## 1. Clarification Items` rows, those answers arrive with this prompt under the heading `# Clarification answers carried in (authoritative)`, or as the `Clarification response` path under `## Inputs`. Treat each answer as an authoritative refinement of the plan's scope for the matching row; an answer that contradicts or expands the approved scope beyond the plan is a re-plan trigger (route to a new `implementation-planning` run), not a silent in-run change.
|