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.
- 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/docs/cli.md +17 -1
- 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/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 +18 -3
- 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-manager/SKILL.md +15 -2
- 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
|
@@ -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
|
-
|
|
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">
|