okstra 0.199.1 → 0.199.3
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/docs/cli.md +4 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +3 -2
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +3 -2
- package/runtime/prompts/lead/plan-body-verification.md +9 -5
- package/runtime/prompts/lead/report-writer.md +12 -9
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +5 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +8 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +10 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +5 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +7 -1
- package/runtime/python/okstra_ctl/blocking_checks.py +1 -1
- package/runtime/python/okstra_ctl/conformance.py +7 -0
- package/runtime/python/okstra_ctl/convergence_store.py +16 -1
- package/runtime/python/okstra_ctl/error_log_core.py +2 -1
- package/runtime/python/okstra_ctl/error_log_write.py +36 -0
- package/runtime/python/okstra_ctl/error_report.py +20 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +22 -4
- package/runtime/python/okstra_ctl/plan_items.py +5 -2
- package/runtime/python/okstra_ctl/plan_items_cli.py +94 -37
- package/runtime/python/okstra_ctl/qa_commands.py +26 -2
- package/runtime/python/okstra_ctl/report_assembly.py +13 -2
- package/runtime/python/okstra_ctl/report_finalize.py +108 -20
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +7 -0
- package/runtime/python/okstra_ctl/run.py +10 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +13 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -0
- package/runtime/skills/okstra-inspect/SKILL.md +4 -2
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/validators/forbidden_actions.py +3 -0
- package/runtime/validators/validate-run.py +105 -27
- package/runtime/validators/validate_session_conformance.py +5 -0
|
@@ -31,6 +31,7 @@ from .json_boundary import (
|
|
|
31
31
|
write_owned_object_atomic,
|
|
32
32
|
)
|
|
33
33
|
from .report_views import normalize_direction_selection_identity
|
|
34
|
+
from .qa_commands import find_denied_tokens
|
|
34
35
|
from .scope_provenance import brief_end_state_id_sequence
|
|
35
36
|
from .user_response import UserResponseError, parse_direction_selection
|
|
36
37
|
|
|
@@ -814,14 +815,31 @@ _MANUAL_VALIDATION_MARKERS = (
|
|
|
814
815
|
)
|
|
815
816
|
|
|
816
817
|
|
|
817
|
-
def
|
|
818
|
+
def stage_validation_executability_errors(
|
|
818
819
|
planning: Mapping[str, Any],
|
|
819
820
|
) -> list[str]:
|
|
820
821
|
failures: list[str] = []
|
|
821
822
|
for row in planning.get("validationChecklist") or ():
|
|
822
|
-
if not isinstance(row, Mapping)
|
|
823
|
+
if not isinstance(row, Mapping):
|
|
823
824
|
continue
|
|
824
|
-
|
|
825
|
+
command = str(row.get("commandOrObservation") or "")
|
|
826
|
+
denied = find_denied_tokens(command)
|
|
827
|
+
if re.search(r"(?<![\w/])\.?/?\.okstra/tasks/", command):
|
|
828
|
+
denied.append(
|
|
829
|
+
"project-relative QA path (use the absolute task artifact path; keep worktree cwd)"
|
|
830
|
+
)
|
|
831
|
+
if re.search(r">\s*/(?:tmp|private/tmp|var/tmp)/", command):
|
|
832
|
+
denied.append(
|
|
833
|
+
"output outside the worktree (use a worktree-local output path)"
|
|
834
|
+
)
|
|
835
|
+
if denied:
|
|
836
|
+
failures.append(
|
|
837
|
+
f"validationChecklist {row.get('id')} conflicts with verifier command rules: "
|
|
838
|
+
f"{'; '.join(denied)}; correct and approve the plan before implementation"
|
|
839
|
+
)
|
|
840
|
+
if not row.get("stageRefs"):
|
|
841
|
+
continue
|
|
842
|
+
lowered = command.lower()
|
|
825
843
|
matched = [
|
|
826
844
|
marker for marker in _MANUAL_VALIDATION_MARKERS if marker in lowered
|
|
827
845
|
]
|
|
@@ -947,6 +965,6 @@ def validate_selected_direction_plan(
|
|
|
947
965
|
planning, brief_end_state_id_sequence(Path(brief_path))
|
|
948
966
|
)
|
|
949
967
|
)
|
|
950
|
-
failures.extend(
|
|
968
|
+
failures.extend(stage_validation_executability_errors(planning))
|
|
951
969
|
failures.extend(_micro_stage_fold_errors(planning))
|
|
952
970
|
return failures
|
|
@@ -643,7 +643,7 @@ CRITIC_WORKER_ID = "critic-worker"
|
|
|
643
643
|
|
|
644
644
|
|
|
645
645
|
def is_critic_worker(worker: str) -> bool:
|
|
646
|
-
"""본문
|
|
646
|
+
"""본문 판정을 교정하는 비판 검토자 표인지."""
|
|
647
647
|
name = str(worker or "").strip().lower()
|
|
648
648
|
return name == CRITIC_WORKER_ID or name.endswith("-critic-worker")
|
|
649
649
|
|
|
@@ -738,7 +738,10 @@ CRITIC_TIE_PREAMBLE = (
|
|
|
738
738
|
"You are the critic tie-break. Only the items below are in dispute. "
|
|
739
739
|
"Each item already has one AGREE and one DISAGREE from the two plan-body "
|
|
740
740
|
"verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
|
|
741
|
-
"settles the split
|
|
741
|
+
"settles the split, including a prior DISAGREE(a) or DISAGREE(f). "
|
|
742
|
+
"Explain whether the earlier dissent is supported or mistaken; a corrected "
|
|
743
|
+
"dissent remains in the audit history without overriding your decision. "
|
|
744
|
+
"Do not re-open items that are not listed.\n"
|
|
742
745
|
)
|
|
743
746
|
|
|
744
747
|
# 라운드 2+ 는 직전 라운드의 반대 의견을 해결하려고 존재한다. 그 의견을 프롬프트가
|
|
@@ -34,6 +34,7 @@ from .plan_items import (
|
|
|
34
34
|
content_hash,
|
|
35
35
|
correction_prompt_text,
|
|
36
36
|
critic_is_rostered,
|
|
37
|
+
is_critic_worker,
|
|
37
38
|
critic_tie_prompt_text,
|
|
38
39
|
dispatch_item_ids,
|
|
39
40
|
extract_plan_items,
|
|
@@ -53,6 +54,8 @@ from .stage_ledger import build_stage_ledger
|
|
|
53
54
|
from .claim_reproduction import NOT_RUNNABLE, reproduce
|
|
54
55
|
from .final_report_schema import load_schema_version
|
|
55
56
|
from .report_narrative import parse_narrative
|
|
57
|
+
from .report_assembly import validate_plan_draft
|
|
58
|
+
from .error_log_write import record_runtime_failure
|
|
56
59
|
from .user_response import parse_user_response_entries
|
|
57
60
|
from .verdict_blocks import (
|
|
58
61
|
PLAN_ITEM_VERDICTS,
|
|
@@ -266,7 +269,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
266
269
|
)
|
|
267
270
|
apply_verdicts.add_argument(
|
|
268
271
|
"--append", action="store_true",
|
|
269
|
-
help="add
|
|
272
|
+
help="add critic corrections while preserving analyser votes; a later "
|
|
273
|
+
"critic verdict updates that critic's current row after its earlier round is complete; "
|
|
270
274
|
"without it every recorded verdict row is replaced, so a round "
|
|
271
275
|
"that was never closed with complete-round is refused first",
|
|
272
276
|
)
|
|
@@ -285,6 +289,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
285
289
|
complete.add_argument("--state", type=Path, required=True)
|
|
286
290
|
complete.add_argument("--run-manifest", type=Path, required=True)
|
|
287
291
|
complete.add_argument("--round", type=int, required=True, dest="round_number")
|
|
292
|
+
complete.add_argument(
|
|
293
|
+
"--items", type=Path,
|
|
294
|
+
help="restore this round's dispatched queue from its prepared items artifact; "
|
|
295
|
+
"earlier verdicts outside that queue remain unchanged",
|
|
296
|
+
)
|
|
288
297
|
complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
|
|
289
298
|
complete.add_argument(
|
|
290
299
|
"--self-fix-group", action="append", default=[],
|
|
@@ -405,6 +414,15 @@ def _sync_task_manifest_gating(run_manifest: Path, gating: bool) -> None:
|
|
|
405
414
|
def _prepare(args: argparse.Namespace) -> dict[str, Any]:
|
|
406
415
|
output = _prepared_items_path(args.run_manifest)
|
|
407
416
|
source = _plan_source(args)
|
|
417
|
+
authority = validated_run_authority(args.run_manifest)
|
|
418
|
+
if authority.payload.get("reportContractVersion") == "3.0":
|
|
419
|
+
failures = validate_plan_draft(source, authority.project_root, authority.payload)
|
|
420
|
+
if failures:
|
|
421
|
+
raise PlanItemContractError(
|
|
422
|
+
"owner=report-writer fieldPath=implementationPlanning: "
|
|
423
|
+
+ "; ".join(failures)
|
|
424
|
+
+ "; correct the narrative in this run, then retry plan-items prepare"
|
|
425
|
+
)
|
|
408
426
|
envelope = _envelope(source)
|
|
409
427
|
envelope["dispatchQueue"] = _queue_for(
|
|
410
428
|
envelope["items"],
|
|
@@ -469,6 +487,18 @@ def _label(key: str) -> str:
|
|
|
469
487
|
return " ".join(part.capitalize() for part in key.replace("Id", " ID").split())
|
|
470
488
|
|
|
471
489
|
|
|
490
|
+
def _render_literal(label: str, value: str, indent: str = "") -> list[str]:
|
|
491
|
+
"""명령·코드의 줄바꿈을 보존하고 본문보다 긴 울타리로 감싼다."""
|
|
492
|
+
fence = "`" * max(
|
|
493
|
+
3, 1 + max((len(part) for part in re.findall(r"`+", value)), default=0)
|
|
494
|
+
)
|
|
495
|
+
body = "".join(indent + " " + row for row in value.splitlines(keepends=True))
|
|
496
|
+
ending = "" if value.endswith("\n") else "\n"
|
|
497
|
+
return [
|
|
498
|
+
f"{indent}- {label}:\n\n{indent} {fence}text\n{body}{ending}{indent} {fence}\n"
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
|
|
472
502
|
def _render_object_list(key: str, value: object) -> list[str]:
|
|
473
503
|
if not isinstance(value, list):
|
|
474
504
|
raise PlanItemContractError(f"plan item {key} must be an array")
|
|
@@ -491,6 +521,8 @@ def _render_object_list(key: str, value: object) -> list[str]:
|
|
|
491
521
|
raise PlanItemContractError(
|
|
492
522
|
f"plan item {field_path} must be scalar"
|
|
493
523
|
)
|
|
524
|
+
elif field == "details" and isinstance(field_value, str):
|
|
525
|
+
rows.extend(_render_literal(f"`{field_path}`", field_value, " "))
|
|
494
526
|
else:
|
|
495
527
|
rows.append(
|
|
496
528
|
f" - `{field_path}`: `{scalar(field_value)}`\n"
|
|
@@ -542,6 +574,8 @@ def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[st
|
|
|
542
574
|
return rows
|
|
543
575
|
if isinstance(value, Mapping):
|
|
544
576
|
raise PlanItemContractError(f"plan item {key} must be scalar")
|
|
577
|
+
if key in {"command", "commandOrObservation"} and isinstance(value, str):
|
|
578
|
+
return _render_literal(_label(key), value, indent)
|
|
545
579
|
return [indent + line(_label(key), value)]
|
|
546
580
|
|
|
547
581
|
|
|
@@ -1389,12 +1423,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1389
1423
|
discard_open_rounds = bool(getattr(args, "discard_open_rounds", False))
|
|
1390
1424
|
if discard_open_rounds and args.state is not None and args.result:
|
|
1391
1425
|
_restore_queue_from_results(verification, args.result, recorded)
|
|
1392
|
-
|
|
1393
|
-
assigned = (
|
|
1394
|
-
{item_id for item_id in queue if isinstance(item_id, str)}
|
|
1395
|
-
if isinstance(queue, list) else known
|
|
1396
|
-
)
|
|
1397
|
-
assigned = _narrow_assignment(args, assigned)
|
|
1426
|
+
assigned = _narrow_dispatch_queue(args, verification, known)
|
|
1398
1427
|
rows = _incoming_verdict_rows(args, assigned)
|
|
1399
1428
|
missing = sorted(item_id for item_id in rows if item_id not in known)
|
|
1400
1429
|
if missing:
|
|
@@ -1405,21 +1434,14 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1405
1434
|
)
|
|
1406
1435
|
if args.round_number < 1:
|
|
1407
1436
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
1408
|
-
if (
|
|
1409
|
-
isinstance(verification, Mapping)
|
|
1410
|
-
and verification.get("gating") is False
|
|
1411
|
-
and args.round_number > 1
|
|
1412
|
-
):
|
|
1413
|
-
# `complete-round` 가 같은 조건으로 거절하지만 그때는 이미 라운드 2 판정이
|
|
1414
|
-
# 상태에 쓰인 뒤다 — 판정은 있는데 `roundHistory` 에 그 라운드가 없는
|
|
1415
|
-
# 불일치가 남고, 상태 파일은 수렴 엔진 소유라 복구 경로가 없었다
|
|
1416
|
-
# (2026-09-05 실측, dev-10626 planning). 쓰기 전에 같은 문장으로 거절한다.
|
|
1417
|
-
raise PlanItemContractError(
|
|
1418
|
-
"advisory plan-body gating allows one verification round"
|
|
1419
|
-
)
|
|
1420
1437
|
project_root = _probe_project_root(getattr(args, "run_manifest", None))
|
|
1421
1438
|
append = bool(getattr(args, "append", False))
|
|
1422
|
-
|
|
1439
|
+
replaces_critic = append and any(
|
|
1440
|
+
is_critic_worker(row.get("worker", ""))
|
|
1441
|
+
and any(v.get("worker") == row.get("worker") for v in rows.get(item.get("id"), []))
|
|
1442
|
+
for item in recorded for row in item.get("verdicts", [])
|
|
1443
|
+
)
|
|
1444
|
+
if not append or replaces_critic:
|
|
1423
1445
|
_reject_uncompleted_round_loss(
|
|
1424
1446
|
recorded, rows, data.get("roundHistory"), args.round_number, target,
|
|
1425
1447
|
discard_open_rounds=discard_open_rounds,
|
|
@@ -1428,6 +1450,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1428
1450
|
for item in recorded:
|
|
1429
1451
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
1430
1452
|
writer(item, rows[item["id"]], args.round_number, project_root)
|
|
1453
|
+
_validate_advisory_round(verification, args.round_number)
|
|
1431
1454
|
write_json_atomic(target, data)
|
|
1432
1455
|
return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
|
|
1433
1456
|
|
|
@@ -1462,23 +1485,26 @@ def _append_item_verdicts(
|
|
|
1462
1485
|
round_number: int,
|
|
1463
1486
|
project_root: Path | None,
|
|
1464
1487
|
) -> None:
|
|
1465
|
-
"""
|
|
1488
|
+
"""분석자 표를 보존하고, 비판 검토자의 후속 판정을 현재 표로 갱신한다."""
|
|
1466
1489
|
stamped = _stamped_verdicts(incoming, round_number, project_root)
|
|
1467
1490
|
existing = item.get("verdicts")
|
|
1468
1491
|
current = existing if isinstance(existing, list) else []
|
|
1469
|
-
seen = {
|
|
1470
|
-
|
|
1492
|
+
seen = {row.get("worker"): row.get("round", 1) for row in current if isinstance(row, Mapping)}
|
|
1493
|
+
refreshed = {
|
|
1494
|
+
row.get("worker") for row in stamped
|
|
1495
|
+
if is_critic_worker(row.get("worker", ""))
|
|
1496
|
+
and row.get("worker") in seen and round_number > seen[row["worker"]]
|
|
1471
1497
|
}
|
|
1472
|
-
clash = [row.get("worker") for row in stamped if row.get("worker") in seen]
|
|
1498
|
+
clash = [row.get("worker") for row in stamped if row.get("worker") in seen and row.get("worker") not in refreshed]
|
|
1473
1499
|
if clash:
|
|
1474
1500
|
raise PlanItemContractError(
|
|
1475
1501
|
f"plan item {item.get('id')} already has a vote from {clash} — "
|
|
1476
1502
|
"the extra vote must come from a worker who has not voted on it. "
|
|
1477
|
-
"--append
|
|
1503
|
+
"--append accepts critic corrections; an analyser re-voting in a later "
|
|
1478
1504
|
"round is recorded without --append, after the earlier round is "
|
|
1479
1505
|
"closed with complete-round"
|
|
1480
1506
|
)
|
|
1481
|
-
item["verdicts"] = [
|
|
1507
|
+
item["verdicts"] = [row for row in current if row.get("worker") not in refreshed] + stamped
|
|
1482
1508
|
_remember_verified_hash(item)
|
|
1483
1509
|
|
|
1484
1510
|
|
|
@@ -1586,7 +1612,7 @@ def _reject_uncompleted_round_loss(
|
|
|
1586
1612
|
)
|
|
1587
1613
|
raise PlanItemContractError(
|
|
1588
1614
|
f"round {round_number} would replace verdicts of a round that was never "
|
|
1589
|
-
f"completed ({detail}) —
|
|
1615
|
+
f"completed ({detail}) — replacing these verdicts would lose their history; "
|
|
1590
1616
|
"only complete-round keeps a round's votes in planItems[].rounds. Close "
|
|
1591
1617
|
f"the earlier round first: run {commands}, then re-run this command. "
|
|
1592
1618
|
"If those rows are themselves being re-applied from their result files "
|
|
@@ -1699,7 +1725,11 @@ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[s
|
|
|
1699
1725
|
audit, history = data.get("planItems"), data.get("roundHistory")
|
|
1700
1726
|
if not isinstance(audit, list) or not isinstance(history, list):
|
|
1701
1727
|
raise PlanItemContractError("state planItems and roundHistory must be arrays")
|
|
1702
|
-
|
|
1728
|
+
current = _state_plan_body_items(data, args.state)
|
|
1729
|
+
_narrow_dispatch_queue(
|
|
1730
|
+
args, data["planBodyVerification"], {item.get("id") for item in current},
|
|
1731
|
+
)
|
|
1732
|
+
return data, current, audit, history
|
|
1703
1733
|
|
|
1704
1734
|
|
|
1705
1735
|
def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
|
|
@@ -1782,16 +1812,27 @@ def _reject_round_gap(history: list[Any], round_number: int) -> None:
|
|
|
1782
1812
|
)
|
|
1783
1813
|
|
|
1784
1814
|
|
|
1815
|
+
def _validate_advisory_round(verification: Mapping[str, Any], round_number: int) -> None:
|
|
1816
|
+
"""분석자 검증 횟수 제한은 비판 검토자의 교정에 적용하지 않는다."""
|
|
1817
|
+
if verification.get("gating") is not False or round_number <= 1:
|
|
1818
|
+
return
|
|
1819
|
+
if any(
|
|
1820
|
+
row.get("round") == round_number and not is_critic_worker(row.get("worker", ""))
|
|
1821
|
+
for item in verification.get("planItems", []) for row in item.get("verdicts", [])
|
|
1822
|
+
):
|
|
1823
|
+
raise PlanItemContractError(
|
|
1824
|
+
"advisory plan-body gating allows one verification round for analysers; "
|
|
1825
|
+
"critic corrections do not consume that limit"
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
|
|
1785
1829
|
def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
1786
1830
|
if args.round_number < 1:
|
|
1787
1831
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
1788
1832
|
data, current, audit, history = _round_inputs(args)
|
|
1789
1833
|
verification = data["planBodyVerification"]
|
|
1834
|
+
_validate_advisory_round(verification, args.round_number)
|
|
1790
1835
|
if verification.get("gating") is False:
|
|
1791
|
-
if args.round_number > 1:
|
|
1792
|
-
raise PlanItemContractError(
|
|
1793
|
-
"advisory plan-body gating allows one verification round"
|
|
1794
|
-
)
|
|
1795
1836
|
if args.self_fix_group or args.self_fix_note or args.self_fix_stop_reason:
|
|
1796
1837
|
raise PlanItemContractError(
|
|
1797
1838
|
"advisory plan-body gating forbids the self-fix loop"
|
|
@@ -1936,8 +1977,8 @@ def _correction_prompt(args: argparse.Namespace) -> str:
|
|
|
1936
1977
|
return correction_prompt_text(_prompt(args))
|
|
1937
1978
|
|
|
1938
1979
|
|
|
1939
|
-
def
|
|
1940
|
-
args: argparse.Namespace,
|
|
1980
|
+
def _narrow_dispatch_queue(
|
|
1981
|
+
args: argparse.Namespace, verification: dict[str, Any], known: set[object],
|
|
1941
1982
|
) -> set[object]:
|
|
1942
1983
|
"""이 라운드가 실제로 배정한 항목들.
|
|
1943
1984
|
|
|
@@ -1946,10 +1987,15 @@ def _narrow_assignment(
|
|
|
1946
1987
|
37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
|
|
1947
1988
|
tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
|
|
1948
1989
|
`--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1990
|
+
배정 범위는 판정 저장과 완료 기록이 함께 써야 한다. 지역 변수만 좁히면
|
|
1991
|
+
저장은 성공해도 완료가 과거 큐 전체에서 새 라운드의 표를 요구한다.
|
|
1992
|
+
상태의 큐만 좁히고 기존 표와 라운드 이력은 보존한다.
|
|
1952
1993
|
"""
|
|
1994
|
+
queue = verification.get("dispatchQueue")
|
|
1995
|
+
assigned = (
|
|
1996
|
+
{item_id for item_id in queue if isinstance(item_id, str)}
|
|
1997
|
+
if isinstance(queue, list) else known
|
|
1998
|
+
)
|
|
1953
1999
|
items_path = getattr(args, "items", None)
|
|
1954
2000
|
if items_path is None:
|
|
1955
2001
|
return assigned
|
|
@@ -1965,6 +2011,10 @@ def _narrow_assignment(
|
|
|
1965
2011
|
f"queue does not contain — pass the artifact this round dispatched, "
|
|
1966
2012
|
f"not another round's"
|
|
1967
2013
|
)
|
|
2014
|
+
verification["dispatchQueue"] = [
|
|
2015
|
+
item_id for item_id in (queue if isinstance(queue, list) else sorted(assigned))
|
|
2016
|
+
if item_id in narrowed
|
|
2017
|
+
]
|
|
1968
2018
|
return narrowed
|
|
1969
2019
|
|
|
1970
2020
|
|
|
@@ -2011,6 +2061,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2011
2061
|
result = _HANDLERS[args.command](args)
|
|
2012
2062
|
except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
|
|
2013
2063
|
print(f"plan-items: {exc}", file=sys.stderr)
|
|
2064
|
+
manifest = getattr(args, "run_manifest", None)
|
|
2065
|
+
if manifest:
|
|
2066
|
+
logged = record_runtime_failure(
|
|
2067
|
+
Path(manifest), command=f"plan-items {args.command}", exit_code=2, detail=str(exc)
|
|
2068
|
+
)
|
|
2069
|
+
if not logged["ok"]:
|
|
2070
|
+
print(f"error-log: {logged['reason']}", file=sys.stderr)
|
|
2014
2071
|
return 2
|
|
2015
2072
|
if isinstance(result, str):
|
|
2016
2073
|
print(result, end="")
|
|
@@ -72,6 +72,31 @@ def _has_insta_update_set(cmd: str) -> bool:
|
|
|
72
72
|
return match.group(1).lower() != "no"
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def find_unfrozen_installs(cmd: str) -> list[str]:
|
|
76
|
+
"""계획과 실행 기록에서 같은 잠금파일 고정 조건을 판정한다."""
|
|
77
|
+
found: list[str] = []
|
|
78
|
+
if _has_npm_install_without_ci(cmd):
|
|
79
|
+
found.append("npm install (use 'npm ci' instead)")
|
|
80
|
+
for match in re.finditer(r"\b(pnpm|yarn|bun)\s+(?:install|i)\b([^;&|\n]*)", cmd):
|
|
81
|
+
flags = match.group(2).split()
|
|
82
|
+
frozen = "--frozen-lockfile" in flags or (
|
|
83
|
+
match.group(1) == "yarn" and "--immutable" in flags
|
|
84
|
+
)
|
|
85
|
+
disabled = any(
|
|
86
|
+
flag
|
|
87
|
+
in {
|
|
88
|
+
"--no-frozen-lockfile",
|
|
89
|
+
"--frozen-lockfile=false",
|
|
90
|
+
"--no-immutable",
|
|
91
|
+
"--immutable=false",
|
|
92
|
+
}
|
|
93
|
+
for flag in flags
|
|
94
|
+
)
|
|
95
|
+
if not frozen or disabled:
|
|
96
|
+
found.append(f"{match.group(1)} install (requires a frozen lockfile)")
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
75
100
|
def find_denied_tokens(cmd: str) -> list[str]:
|
|
76
101
|
"""`cmd` 안에 포함된 모든 denied 토큰 목록을 반환. 비어 있으면 안전."""
|
|
77
102
|
if not isinstance(cmd, str):
|
|
@@ -84,8 +109,7 @@ def find_denied_tokens(cmd: str) -> list[str]:
|
|
|
84
109
|
for sub in _DENIED_SUBSTRINGS:
|
|
85
110
|
if sub in cmd:
|
|
86
111
|
found.append(sub)
|
|
87
|
-
|
|
88
|
-
found.append("npm install (use 'npm ci' instead)")
|
|
112
|
+
found.extend(find_unfrozen_installs(cmd))
|
|
89
113
|
if _has_insta_update_set(cmd):
|
|
90
114
|
found.append("INSTA_UPDATE=<not-no>")
|
|
91
115
|
return found
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""역할별 리포트 입력을 검증해 계약 3.0 정본을 한 번 게시한다."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import copy
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import tempfile
|
|
@@ -573,7 +574,7 @@ def _apply_selected_direction_snapshot(
|
|
|
573
574
|
)
|
|
574
575
|
|
|
575
576
|
|
|
576
|
-
def
|
|
577
|
+
def selected_direction_plan_errors(
|
|
577
578
|
data: Mapping[str, Any], project_root: Path, manifest: Mapping[str, Any]
|
|
578
579
|
) -> list[str]:
|
|
579
580
|
"""게시 직전에 구현 진입 검증을 그대로 돌린다.
|
|
@@ -593,6 +594,16 @@ def _selected_direction_publication_errors(
|
|
|
593
594
|
return validate_selected_direction_plan(data, brief_path, snapshot_path)
|
|
594
595
|
|
|
595
596
|
|
|
597
|
+
def validate_plan_draft(
|
|
598
|
+
data: Mapping[str, Any], project_root: Path, manifest: Mapping[str, Any]
|
|
599
|
+
) -> list[str]:
|
|
600
|
+
"""작성자 입력을 게시하지 않고 같은 기계 투영과 의미 검사로 검증한다."""
|
|
601
|
+
draft = copy.deepcopy(dict(data))
|
|
602
|
+
_apply_selected_direction_snapshot(draft, project_root, manifest)
|
|
603
|
+
_attach_metadata(draft, manifest)
|
|
604
|
+
return selected_direction_plan_errors(draft, project_root, manifest)
|
|
605
|
+
|
|
606
|
+
|
|
596
607
|
def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
|
|
597
608
|
task_key = str(manifest.get("taskKey") or "")
|
|
598
609
|
parts = task_key.split(":")
|
|
@@ -872,7 +883,7 @@ def assemble_report(
|
|
|
872
883
|
raise ReportAssemblyError(tuple(input_issues))
|
|
873
884
|
data = _compose(project_root, manifest_path, manifest, inputs, schema)
|
|
874
885
|
errors = validate(data, schema)
|
|
875
|
-
direction_errors =
|
|
886
|
+
direction_errors = selected_direction_plan_errors(
|
|
876
887
|
data, project_root, manifest
|
|
877
888
|
)
|
|
878
889
|
value = manifest.get("expectedReportRecordPath")
|
|
@@ -44,7 +44,7 @@ import json
|
|
|
44
44
|
from datetime import date, datetime, timezone
|
|
45
45
|
import subprocess
|
|
46
46
|
import sys
|
|
47
|
-
from dataclasses import dataclass
|
|
47
|
+
from dataclasses import asdict, dataclass
|
|
48
48
|
from pathlib import Path
|
|
49
49
|
from typing import Any, Callable, Mapping, Sequence
|
|
50
50
|
|
|
@@ -73,6 +73,7 @@ from .stage_targets import (
|
|
|
73
73
|
integrate_and_teardown_whole_task,
|
|
74
74
|
)
|
|
75
75
|
from .session import observe_lead_session
|
|
76
|
+
from .error_log_write import record_runtime_failure
|
|
76
77
|
|
|
77
78
|
# 포인터 값 타입만 쓴다. `okstra_project.phase_pointer` 는 okstra 안의
|
|
78
79
|
# 어떤 것도 import 하지 않는 leaf 라 순환이 없다 — 그 모듈 도크스트링.
|
|
@@ -93,6 +94,7 @@ STEP_SPAWN_FOLLOWUPS = "spawn-followups"
|
|
|
93
94
|
STEP_VALIDATE_RUN = "validate-run"
|
|
94
95
|
STEP_RECORD_GROUP_MEMORY = "record-group-memory"
|
|
95
96
|
STEP_TEARDOWN_STAGES = "teardown-stages"
|
|
97
|
+
STEP_PREFLIGHT = "preflight"
|
|
96
98
|
|
|
97
99
|
STEP_ORDER = (
|
|
98
100
|
STEP_PROJECT_ACTIVITY,
|
|
@@ -112,6 +114,7 @@ STEP_ORDER = (
|
|
|
112
114
|
V3_STEP_ORDER = (
|
|
113
115
|
STEP_TOKEN_USAGE,
|
|
114
116
|
STEP_PROJECT_ACTIVITY,
|
|
117
|
+
STEP_PREFLIGHT,
|
|
115
118
|
# Before the render that overlays its sidecar.
|
|
116
119
|
STEP_TRANSLATE,
|
|
117
120
|
STEP_RENDER_VIEWS,
|
|
@@ -401,6 +404,11 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
|
|
|
401
404
|
if ctx.report_contract_version != "3.0":
|
|
402
405
|
return commands
|
|
403
406
|
by_name = dict(commands)
|
|
407
|
+
by_name[STEP_PREFLIGHT] = [
|
|
408
|
+
*_validate_run_command(ctx, ctx.data_path),
|
|
409
|
+
"--section",
|
|
410
|
+
"preflight",
|
|
411
|
+
]
|
|
404
412
|
usage = by_name[STEP_TOKEN_USAGE]
|
|
405
413
|
marker = usage.index("--substitute-data")
|
|
406
414
|
by_name[STEP_TOKEN_USAGE] = usage[:marker]
|
|
@@ -553,13 +561,17 @@ def step_payload(
|
|
|
553
561
|
command: Sequence[str],
|
|
554
562
|
result: subprocess.CompletedProcess[str],
|
|
555
563
|
) -> dict[str, Any]:
|
|
556
|
-
|
|
564
|
+
payload = {
|
|
557
565
|
"name": name,
|
|
558
566
|
"command": list(command),
|
|
559
567
|
"exitCode": result.returncode,
|
|
560
568
|
"stdoutTail": tail(result.stdout),
|
|
561
569
|
"stderrTail": tail(result.stderr),
|
|
562
570
|
}
|
|
571
|
+
if name == STEP_PROJECT_ACTIVITY and result.returncode != 0 and result.stdout:
|
|
572
|
+
# 소유자 오류는 요약 문자열 길이에 잘리지 않은 전체 목록으로 전달한다.
|
|
573
|
+
payload["issues"] = json.loads(result.stdout).get("issues", [])
|
|
574
|
+
return payload
|
|
563
575
|
|
|
564
576
|
|
|
565
577
|
def run_finalize(
|
|
@@ -585,6 +597,8 @@ def run_finalize(
|
|
|
585
597
|
|
|
586
598
|
if only:
|
|
587
599
|
selected = set(only)
|
|
600
|
+
if ctx.report_contract_version == "3.0" and STEP_TRANSLATE in selected:
|
|
601
|
+
selected.add(STEP_PREFLIGHT)
|
|
588
602
|
contract_order = (
|
|
589
603
|
V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
|
|
590
604
|
)
|
|
@@ -601,23 +615,7 @@ def run_finalize(
|
|
|
601
615
|
ctx, commands, selected, all_commands
|
|
602
616
|
)
|
|
603
617
|
|
|
604
|
-
first_failure =
|
|
605
|
-
validated = False
|
|
606
|
-
for name, command in commands:
|
|
607
|
-
# 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라지고, 검증 안 된
|
|
608
|
-
# 레코드를 기억하면 형제 task 가 그것을 결론으로 읽는다.
|
|
609
|
-
if name in _SKIPPED_AFTER_FAILURE and first_failure:
|
|
610
|
-
continue
|
|
611
|
-
if before_step is not None:
|
|
612
|
-
before_step(name)
|
|
613
|
-
result = _run_finalize_step(ctx, name, command)
|
|
614
|
-
steps.append(step_payload(name, command, result))
|
|
615
|
-
if result.returncode != 0 and not first_failure:
|
|
616
|
-
first_failure = f"{name} failed with exit code {result.returncode}"
|
|
617
|
-
if name == STEP_VALIDATE_RUN and result.returncode == 0:
|
|
618
|
-
validated = True
|
|
619
|
-
if validated:
|
|
620
|
-
_record_run_end(ctx.team_state_path)
|
|
618
|
+
steps, first_failure = _execute_finalize_steps(ctx, commands, before_step)
|
|
621
619
|
pointer, pointer_error = _recorded_next_phase(ctx)
|
|
622
620
|
payload: dict[str, Any] = {
|
|
623
621
|
"ok": not first_failure,
|
|
@@ -630,12 +628,89 @@ def run_finalize(
|
|
|
630
628
|
following = _next_in_group(steps)
|
|
631
629
|
if following:
|
|
632
630
|
payload["nextInGroup"] = following
|
|
631
|
+
if first_failure:
|
|
632
|
+
payload["recovery"] = _finalize_recovery(ctx, payload)
|
|
633
633
|
# closeout 이 실제로 건넬 명령. 표를 리드의 기억에 맡기지 않는다.
|
|
634
634
|
payload["nextCommand"] = closeout_command(payload)
|
|
635
635
|
payload["reportPaths"] = _closeout_report_paths(ctx)
|
|
636
636
|
return payload
|
|
637
637
|
|
|
638
638
|
|
|
639
|
+
def _execute_finalize_steps(
|
|
640
|
+
ctx: FinalizeContext, commands: Sequence[tuple[str, list[str]]],
|
|
641
|
+
before_step: Callable[[str], None] | None,
|
|
642
|
+
) -> tuple[list[dict[str, Any]], str]:
|
|
643
|
+
"""처리 순서를 지키며 각 실패를 기록하고, 전체 성공일 때 실행을 닫는다."""
|
|
644
|
+
steps: list[dict[str, Any]] = []
|
|
645
|
+
first_failure = ""
|
|
646
|
+
validated = False
|
|
647
|
+
preflight_failed = False
|
|
648
|
+
for name, command in commands:
|
|
649
|
+
# 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라지고, 검증 안 된
|
|
650
|
+
# 레코드를 기억하면 형제 task 가 그것을 결론으로 읽는다.
|
|
651
|
+
if name in _SKIPPED_AFTER_FAILURE and first_failure:
|
|
652
|
+
continue
|
|
653
|
+
if name == STEP_TRANSLATE and preflight_failed:
|
|
654
|
+
continue
|
|
655
|
+
if before_step is not None:
|
|
656
|
+
before_step(name)
|
|
657
|
+
result = _run_finalize_step(ctx, name, command)
|
|
658
|
+
step = step_payload(name, command, result)
|
|
659
|
+
if result.returncode != 0:
|
|
660
|
+
step["errorLogAppend"] = record_runtime_failure(
|
|
661
|
+
ctx.manifest_path, project_root=ctx.project_root,
|
|
662
|
+
command=f"report-finalize {name}", exit_code=result.returncode,
|
|
663
|
+
detail="\n".join(filter(None, [result.stderr, result.stdout])) or f"{name} exited {result.returncode}",
|
|
664
|
+
)
|
|
665
|
+
steps.append(step)
|
|
666
|
+
if name == STEP_PREFLIGHT:
|
|
667
|
+
preflight_failed = result.returncode != 0
|
|
668
|
+
if result.returncode != 0 and not first_failure:
|
|
669
|
+
first_failure = f"{name} failed with exit code {result.returncode}"
|
|
670
|
+
if name == STEP_VALIDATE_RUN and result.returncode == 0:
|
|
671
|
+
validated = True
|
|
672
|
+
if validated and not first_failure:
|
|
673
|
+
_record_run_end(ctx.team_state_path)
|
|
674
|
+
return steps, first_failure
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _finalize_recovery(ctx: FinalizeContext, result: Mapping[str, Any]) -> dict[str, Any]:
|
|
678
|
+
"""새 실행 대신 실패한 처리부터 재개할 명령과 원본 소유자 오류를 전달한다."""
|
|
679
|
+
order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
|
|
680
|
+
failed = {step["name"] for step in result.get("steps") or [] if step.get("exitCode")}
|
|
681
|
+
pointer = promote_next_phase(result.get("nextRecommendedPhase"))
|
|
682
|
+
if (failed == {STEP_VALIDATE_RUN} and pointer["status"] == STATUS_BLOCKED
|
|
683
|
+
and pointer["phase"] and pointer["phase"] != ctx.task_type):
|
|
684
|
+
return {"mode": "phase-reentry", "phase": pointer["phase"], "instruction": pointer["rationale"]}
|
|
685
|
+
command = [
|
|
686
|
+
"okstra", "report-finalize", "--project-root", str(ctx.project_root),
|
|
687
|
+
"--run-manifest", str(ctx.manifest_path), "--report", str(ctx.data_path),
|
|
688
|
+
"--team-state", str(ctx.team_state_path),
|
|
689
|
+
]
|
|
690
|
+
resume_steps = _recovery_step_names(result, order)
|
|
691
|
+
if ctx.report_contract_version == "3.0" and STEP_PROJECT_ACTIVITY not in resume_steps:
|
|
692
|
+
# 수정한 서사·판정 상태를 정본에 다시 조립해야 후속 검사가 새 입력을 읽는다.
|
|
693
|
+
resume_steps = list(order[order.index(STEP_PROJECT_ACTIVITY):])
|
|
694
|
+
for name in resume_steps:
|
|
695
|
+
command.extend(["--only", name])
|
|
696
|
+
issues = []
|
|
697
|
+
for step in result.get("steps") or []:
|
|
698
|
+
if step.get("name") != STEP_PROJECT_ACTIVITY or not step.get("exitCode"):
|
|
699
|
+
continue
|
|
700
|
+
issues.extend(step.get("issues") or [])
|
|
701
|
+
return {
|
|
702
|
+
"mode": "same-run", "resumeCommand": command, "issues": issues,
|
|
703
|
+
"instruction": (
|
|
704
|
+
"Repair the reported causes in this run using the owning input's correction command, "
|
|
705
|
+
"then execute resumeCommand. Preserve approvals, model choices and completed evidence. "
|
|
706
|
+
"If plan content changes, re-verify the affected items before finalizing. "
|
|
707
|
+
"After a narrative correction, check existing translations with report-translate check-data "
|
|
708
|
+
"and regenerate them if their source no longer matches. "
|
|
709
|
+
"Ask only for an unresolved user decision or an actual external prerequisite."
|
|
710
|
+
),
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
639
714
|
def _closeout_report_paths(ctx: FinalizeContext) -> dict[str, Any]:
|
|
640
715
|
"""closeout 이 인용할 task 한정 경로와, 그대로 답장에 붙일 링크.
|
|
641
716
|
|
|
@@ -838,7 +913,11 @@ def _run_project_activity(
|
|
|
838
913
|
ctx.data_path,
|
|
839
914
|
)
|
|
840
915
|
count = len(rows)
|
|
841
|
-
except
|
|
916
|
+
except ReportAssemblyError as exc:
|
|
917
|
+
return subprocess.CompletedProcess(
|
|
918
|
+
command, 1, json.dumps({"issues": [asdict(issue) for issue in exc.issues]}), str(exc)
|
|
919
|
+
)
|
|
920
|
+
except ActivityProjectionError as exc:
|
|
842
921
|
return subprocess.CompletedProcess(command, 1, "", str(exc))
|
|
843
922
|
return subprocess.CompletedProcess(
|
|
844
923
|
command, 0, json.dumps({"count": count}), ""
|
|
@@ -1076,12 +1155,21 @@ def closeout_command(result: Mapping[str, Any]) -> dict[str, str]:
|
|
|
1076
1155
|
돌려주는 것은 `command` 와 `note` 두 칸이다. `command` 가 비면 근거
|
|
1077
1156
|
문장이 그 자리를 대신한다.
|
|
1078
1157
|
"""
|
|
1158
|
+
recovery = result.get("recovery")
|
|
1159
|
+
if isinstance(recovery, Mapping) and recovery.get("mode") == "same-run":
|
|
1160
|
+
return {"command": "", "note": str(recovery["instruction"])}
|
|
1079
1161
|
failed_validate = any(
|
|
1080
1162
|
step.get("name") == STEP_VALIDATE_RUN and step.get("exitCode") != 0
|
|
1081
1163
|
for step in (result.get("steps") or [])
|
|
1082
1164
|
if isinstance(step, Mapping)
|
|
1083
1165
|
)
|
|
1084
1166
|
if failed_validate:
|
|
1167
|
+
recovery = promote_next_phase(result.get("nextRecommendedPhase"))
|
|
1168
|
+
if recovery["status"] == STATUS_BLOCKED and recovery["phase"]:
|
|
1169
|
+
return {
|
|
1170
|
+
"command": f"/okstra-run → {recovery['phase']}",
|
|
1171
|
+
"note": recovery["rationale"],
|
|
1172
|
+
}
|
|
1085
1173
|
return {
|
|
1086
1174
|
"command": "/okstra-run",
|
|
1087
1175
|
"note": "validate-run failed — name the blocking cause in one line, "
|