okstra 0.178.0 → 0.179.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/commands/execute/plan-verify.mjs +1 -1
- package/dist/commands/execute/worktree-status.mjs +8 -2
- package/dist/commands/execute/worktree-status.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +1 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/report/render-final-report.mjs +3 -3
- package/docs/architecture/storage-model.md +3 -3
- package/docs/architecture.md +10 -9
- package/docs/cli.md +11 -13
- package/docs/for-ai/skills/okstra-inspect.md +3 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +10 -11
- package/docs/task-process/implementation-planning.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +11 -12
- package/runtime/bin/lib/okstra/globals.sh +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +1 -1
- package/runtime/bin/lib/okstra/usage.sh +11 -9
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-central.sh +2 -2
- package/runtime/bin/okstra-render-final-report.py +1 -1
- package/runtime/bin/okstra-token-usage.py +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +6 -1
- package/runtime/prompts/lead/context-loader.md +3 -2
- package/runtime/prompts/lead/convergence.md +3 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +5 -5
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +21 -20
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +5 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +9 -5
- package/runtime/prompts/profiles/implementation.md +4 -4
- package/runtime/prompts/profiles/improvement-discovery.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +77 -6
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
- package/runtime/python/okstra_ctl/backfill.py +12 -5
- package/runtime/python/okstra_ctl/consumers.py +70 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
- package/runtime/python/okstra_ctl/convergence_store.py +13 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +85 -14
- package/runtime/python/okstra_ctl/dispatch_state.py +34 -20
- package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
- package/runtime/python/okstra_ctl/execution_manifest.py +22 -3
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +35 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
- package/runtime/python/okstra_ctl/handoff.py +1 -2
- package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
- package/runtime/python/okstra_ctl/index.py +4 -4
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
- package/runtime/python/okstra_ctl/listing.py +4 -2
- package/runtime/python/okstra_ctl/manager_launch.py +1 -1
- package/runtime/python/okstra_ctl/manager_sync.py +1 -1
- package/runtime/python/okstra_ctl/path_hints.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +24 -15
- package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
- package/runtime/python/okstra_ctl/recap.py +3 -2
- package/runtime/python/okstra_ctl/reconcile.py +3 -1
- package/runtime/python/okstra_ctl/render.py +22 -22
- package/runtime/python/okstra_ctl/report_finalize.py +4 -4
- package/runtime/python/okstra_ctl/rollup.py +1 -1
- package/runtime/python/okstra_ctl/run.py +170 -294
- package/runtime/python/okstra_ctl/run_audit.py +5 -5
- package/runtime/python/okstra_ctl/run_index_row.py +2 -2
- package/runtime/python/okstra_ctl/session_transcript.py +89 -0
- package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
- package/runtime/python/okstra_ctl/stage_map.py +28 -29
- package/runtime/python/okstra_ctl/stage_targets.py +61 -0
- package/runtime/python/okstra_ctl/user_response.py +97 -12
- package/runtime/python/okstra_ctl/wizard.py +102 -78
- package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
- package/runtime/python/okstra_ctl/worker_runner.py +76 -213
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
- package/runtime/python/okstra_ctl/write_policy.py +51 -9
- package/runtime/python/okstra_project/state.py +2 -2
- package/runtime/python/okstra_token_usage/__init__.py +1 -1
- package/runtime/python/okstra_token_usage/cli.py +3 -3
- package/runtime/python/okstra_token_usage/report.py +7 -24
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v2.0.schema.json +11 -1
- package/runtime/skills/okstra-inspect/facets/history.md +3 -3
- package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
- package/runtime/skills/okstra-inspect/facets/report.md +5 -5
- package/runtime/skills/okstra-inspect/facets/status.md +2 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +3 -3
- package/runtime/templates/project-docs/task-index.template.md +1 -1
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/validators/forbidden_actions.py +76 -5
- package/runtime/validators/lib/fixtures.sh +14 -10
- package/runtime/validators/lib/runners.sh +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +3 -0
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +95 -37
- package/runtime/validators/validate_session_conformance.py +44 -8
|
@@ -87,8 +87,19 @@ def reserve_dynamic_verifier(
|
|
|
87
87
|
input_digest: str,
|
|
88
88
|
invocation_ref: str | None = None,
|
|
89
89
|
artifact_paths: tuple[Path, ...],
|
|
90
|
+
worktree: Path | None = None,
|
|
90
91
|
) -> tuple[RoleExecution, Invocation]:
|
|
91
|
-
"""Reserve one provider-neutral verifier identity for a logical round.
|
|
92
|
+
"""Reserve one provider-neutral verifier identity for a logical round.
|
|
93
|
+
|
|
94
|
+
`worktree` 는 이 런의 워커가 실제로 서는 루트다. 디스패치가 정책을 다시
|
|
95
|
+
계산할 때 쓰는 값(`dispatch_core._canonical_write_contract` 이 job 의
|
|
96
|
+
worktree 를 넘긴다)과 같아야 한다. 여기서 None 으로 고정하면 예약된 정책의
|
|
97
|
+
`sourcePolicy.allowedRoot` 는 프로젝트 루트, 디스패치가 계산한 정책은 스테이지
|
|
98
|
+
워크트리가 되어 writePolicyDigest 가 갈리고, 같은 invocationRef 가
|
|
99
|
+
`invocationRef drift` 로 거부된다 — 워크트리를 쓰는 런(implementation stage)
|
|
100
|
+
에서만 나타나고 워크트리가 없는 런(implementation-planning)에서는 안 나타나
|
|
101
|
+
버전 문제로 보이기 쉽다.
|
|
102
|
+
"""
|
|
92
103
|
if round_number < 1:
|
|
93
104
|
raise ExecutionManifestError("dynamic verifier round must be positive")
|
|
94
105
|
manifest_path = Path(manifest_path).resolve()
|
|
@@ -128,7 +139,7 @@ def reserve_dynamic_verifier(
|
|
|
128
139
|
policy, enforcement = build_invocation_write_contract(
|
|
129
140
|
role="verifier",
|
|
130
141
|
project_root=project_root,
|
|
131
|
-
worktree=
|
|
142
|
+
worktree=worktree,
|
|
132
143
|
artifact_paths=artifact_paths,
|
|
133
144
|
maximum_precision=capability.max_boundary_precision,
|
|
134
145
|
auxiliary_roots=verifier_extra_dirs("verifier"),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
import subprocess
|
|
6
7
|
import time
|
|
7
8
|
from dataclasses import dataclass, field, replace
|
|
@@ -110,6 +111,8 @@ from .worker_prompt_headers import (
|
|
|
110
111
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
111
112
|
from .wrapper_status import (
|
|
112
113
|
log_path_for_prompt,
|
|
114
|
+
mutation_snapshot_path_for_prompt,
|
|
115
|
+
prompt_derived_paths,
|
|
113
116
|
read_wrapper_status,
|
|
114
117
|
status_path_for_prompt,
|
|
115
118
|
)
|
|
@@ -356,7 +359,9 @@ def build_dispatch_plan(
|
|
|
356
359
|
default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
|
|
357
360
|
)
|
|
358
361
|
if jobs_file:
|
|
359
|
-
jobs = _jobs_from_file(
|
|
362
|
+
jobs = _jobs_from_file(
|
|
363
|
+
project_root, workspace_root, jobs_file, manifest, active_context, options
|
|
364
|
+
)
|
|
360
365
|
else:
|
|
361
366
|
jobs = _jobs_from_roster(
|
|
362
367
|
project_root,
|
|
@@ -413,7 +418,10 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
|
413
418
|
return 0
|
|
414
419
|
round_artifact_paths = _round_artifact_paths(plan)
|
|
415
420
|
handles = [
|
|
416
|
-
_spawn_job(
|
|
421
|
+
_spawn_job(
|
|
422
|
+
plan, job, _next_attempt(plan, job),
|
|
423
|
+
batch_artifact_paths=round_artifact_paths,
|
|
424
|
+
)
|
|
417
425
|
for job in plan.jobs
|
|
418
426
|
]
|
|
419
427
|
_record_dispatch_facts(plan.team_state_path, _mode_from_handles(handles))
|
|
@@ -1289,6 +1297,7 @@ def _jobs_from_file(
|
|
|
1289
1297
|
workspace_root: Path,
|
|
1290
1298
|
jobs_file: Path | None,
|
|
1291
1299
|
manifest: Mapping[str, Any],
|
|
1300
|
+
active_context: Mapping[str, Any],
|
|
1292
1301
|
options: _BuildOptions,
|
|
1293
1302
|
) -> list[WorkerJob]:
|
|
1294
1303
|
if jobs_file is None:
|
|
@@ -1297,6 +1306,7 @@ def _jobs_from_file(
|
|
|
1297
1306
|
project_root,
|
|
1298
1307
|
jobs_file,
|
|
1299
1308
|
manifest=manifest,
|
|
1309
|
+
active_context=active_context,
|
|
1300
1310
|
backend=options.default_backend,
|
|
1301
1311
|
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
1302
1312
|
default_dispatch_kind=options.dispatch_kind,
|
|
@@ -1387,6 +1397,34 @@ def _spawn_cli_job_nonblocking(
|
|
|
1387
1397
|
)
|
|
1388
1398
|
|
|
1389
1399
|
|
|
1400
|
+
def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
1401
|
+
"""이 invocation 이 다음에 청구할 attempt 번호.
|
|
1402
|
+
|
|
1403
|
+
attempt 를 1 로 고정하면 같은 워커를 두 번째로 디스패치할 수 없다. 원장은
|
|
1404
|
+
단조 증가를 요구하므로(`execution_manifest._validate_next_attempt`) 두 번째
|
|
1405
|
+
호출이 항상 `next attempt must be 2` 로 거부되고, 재시도 예산이 계약에는
|
|
1406
|
+
있는데 그 경로에는 쓸 수단이 없는 상태가 된다. 예산은 프로세스가 아니라
|
|
1407
|
+
invocation 에 붙어 있고, 그 잔액이 적힌 곳은 원장뿐이다.
|
|
1408
|
+
"""
|
|
1409
|
+
if not job.has_execution_identity:
|
|
1410
|
+
return job.attempt
|
|
1411
|
+
manifest = read_execution_manifest(plan.manifest_path)
|
|
1412
|
+
prior = [
|
|
1413
|
+
row.attempt for row in manifest.attempts
|
|
1414
|
+
if row.invocation_ref == job.invocation_ref
|
|
1415
|
+
]
|
|
1416
|
+
if not prior:
|
|
1417
|
+
return job.attempt
|
|
1418
|
+
spent = max(prior)
|
|
1419
|
+
if spent >= MAX_WORKER_ATTEMPTS:
|
|
1420
|
+
raise DispatchError(
|
|
1421
|
+
f"worker retry budget is spent: {job.invocation_ref} used "
|
|
1422
|
+
f"{spent} of {MAX_WORKER_ATTEMPTS} attempts. Materialize a new "
|
|
1423
|
+
"invocation to dispatch this worker again."
|
|
1424
|
+
)
|
|
1425
|
+
return spent + 1
|
|
1426
|
+
|
|
1427
|
+
|
|
1390
1428
|
def _prepare_job_attempt(
|
|
1391
1429
|
plan: DispatchPlan, job: WorkerJob, attempt: int
|
|
1392
1430
|
) -> WorkerJob:
|
|
@@ -1462,7 +1500,7 @@ def _mutation_snapshot(
|
|
|
1462
1500
|
|
|
1463
1501
|
|
|
1464
1502
|
def _mutation_snapshot_path(job: WorkerJob) -> Path:
|
|
1465
|
-
return
|
|
1503
|
+
return mutation_snapshot_path_for_prompt(job.prompt_path)
|
|
1466
1504
|
|
|
1467
1505
|
|
|
1468
1506
|
def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
|
|
@@ -1517,6 +1555,11 @@ def _canonical_write_contract(
|
|
|
1517
1555
|
raise DispatchError("worker write policy has no runner write capability")
|
|
1518
1556
|
artifacts = _worker_artifact_paths(plan, job)
|
|
1519
1557
|
worktree = Path(job.worktree_path) if job.worktree_path else None
|
|
1558
|
+
planned = (
|
|
1559
|
+
planned_paths_from_run_manifest(plan.project_root, plan.manifest)
|
|
1560
|
+
if execution.role == "implementer"
|
|
1561
|
+
else ((), True)
|
|
1562
|
+
)
|
|
1520
1563
|
try:
|
|
1521
1564
|
return build_invocation_write_contract(
|
|
1522
1565
|
role=execution.role,
|
|
@@ -1524,11 +1567,8 @@ def _canonical_write_contract(
|
|
|
1524
1567
|
worktree=worktree,
|
|
1525
1568
|
artifact_paths=artifacts,
|
|
1526
1569
|
maximum_precision=capability.max_boundary_precision,
|
|
1527
|
-
planned_paths=
|
|
1528
|
-
|
|
1529
|
-
if execution.role == "implementer"
|
|
1530
|
-
else ()
|
|
1531
|
-
),
|
|
1570
|
+
planned_paths=planned[0],
|
|
1571
|
+
planned_paths_declared=planned[1],
|
|
1532
1572
|
auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
1533
1573
|
validated_auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
1534
1574
|
)
|
|
@@ -1545,14 +1585,12 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
|
|
|
1545
1585
|
job.worker_result_path,
|
|
1546
1586
|
*job.completion_paths,
|
|
1547
1587
|
Path(audit_sidecar_rel(str(job.worker_result_path))),
|
|
1548
|
-
status_path_for_prompt(job.prompt_path),
|
|
1549
|
-
log_path_for_prompt(job.prompt_path),
|
|
1550
1588
|
# The prompt's three derived files are written together and belong in
|
|
1551
1589
|
# one list. The audit snapshot used to be listed only for the job whose
|
|
1552
1590
|
# snapshot it was, so a sibling's snapshot — written by okstra as that
|
|
1553
1591
|
# sibling started — landed inside this worker's window as an
|
|
1554
1592
|
# unauthorized artifact-root change.
|
|
1555
|
-
|
|
1593
|
+
*prompt_derived_paths(job.prompt_path),
|
|
1556
1594
|
}
|
|
1557
1595
|
error_logs = active_context.get("errorLogs")
|
|
1558
1596
|
if isinstance(error_logs, Mapping):
|
|
@@ -1619,7 +1657,7 @@ def _dispatch_round(dispatch_kind: str) -> int:
|
|
|
1619
1657
|
|
|
1620
1658
|
|
|
1621
1659
|
def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
1622
|
-
for attempt in range(
|
|
1660
|
+
for attempt in range(_next_attempt(plan, job), MAX_WORKER_ATTEMPTS + 1):
|
|
1623
1661
|
handle = _spawn_job(plan, job, attempt)
|
|
1624
1662
|
if handle.completed_process is None:
|
|
1625
1663
|
return await_dispatches(plan, timeout_seconds=None)
|
|
@@ -1914,8 +1952,41 @@ def _audit_attempt(
|
|
|
1914
1952
|
)
|
|
1915
1953
|
|
|
1916
1954
|
|
|
1955
|
+
def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
|
|
1956
|
+
"""워커가 자기 결과의 `Out-of-plan edits` 블록에 선언한 경로.
|
|
1957
|
+
|
|
1958
|
+
감사는 워커가 끝나는 시점에 돈다. 그때 디스크에 있는 것은 워커 결과
|
|
1959
|
+
마크다운뿐이고, `implementation.outOfPlanEdits` 는 리드가 나중에 쓰는 최종
|
|
1960
|
+
리포트의 필드다. JSON 형태만 읽었기 때문에 executor 의 선언이 한 번도 보이지
|
|
1961
|
+
않았고, 계약대로 선언한 편집까지 미허가 변경으로 집계됐다. 블록의 형태는 이
|
|
1962
|
+
판독기를 위해 `_implementation-executor.md` 가 고정한다 — `- ` 줄마다 첫 백틱
|
|
1963
|
+
토큰이 경로다.
|
|
1964
|
+
"""
|
|
1965
|
+
if not result_path.is_file() or result_path.suffix != ".md":
|
|
1966
|
+
return ()
|
|
1967
|
+
try:
|
|
1968
|
+
text = result_path.read_text(encoding="utf-8")
|
|
1969
|
+
except (OSError, UnicodeError):
|
|
1970
|
+
return ()
|
|
1971
|
+
paths: list[str] = []
|
|
1972
|
+
inside = False
|
|
1973
|
+
for line in text.splitlines():
|
|
1974
|
+
stripped = line.strip()
|
|
1975
|
+
if stripped.startswith("#"):
|
|
1976
|
+
inside = stripped.lstrip("#").strip().lower() == "out-of-plan edits"
|
|
1977
|
+
continue
|
|
1978
|
+
if not inside or not stripped.startswith("- "):
|
|
1979
|
+
continue
|
|
1980
|
+
quoted = re.findall(r"`([^`\n]+)`", stripped)
|
|
1981
|
+
if quoted and quoted[0].strip():
|
|
1982
|
+
paths.append(quoted[0].strip())
|
|
1983
|
+
return tuple(paths)
|
|
1984
|
+
|
|
1985
|
+
|
|
1917
1986
|
def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
|
|
1918
|
-
if
|
|
1987
|
+
if result_path.suffix != ".json":
|
|
1988
|
+
return _declared_out_of_plan_paths(result_path)
|
|
1989
|
+
if not result_path.is_file():
|
|
1919
1990
|
return ()
|
|
1920
1991
|
try:
|
|
1921
1992
|
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
@@ -2917,7 +2988,7 @@ def _reject_stale_schema_excerpt(
|
|
|
2917
2988
|
)
|
|
2918
2989
|
if writer is None:
|
|
2919
2990
|
return
|
|
2920
|
-
expected = _string_value(manifest.get("
|
|
2991
|
+
expected = _string_value(manifest.get("expectedReportRecordPath"))
|
|
2921
2992
|
if not expected:
|
|
2922
2993
|
return
|
|
2923
2994
|
excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
|
|
@@ -46,10 +46,7 @@ from .execution_manifest import (
|
|
|
46
46
|
record_invocation_attempt,
|
|
47
47
|
)
|
|
48
48
|
from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
|
|
49
|
-
from .final_report_paths import
|
|
50
|
-
final_report_data_path,
|
|
51
|
-
final_report_markdown_path,
|
|
52
|
-
)
|
|
49
|
+
from .final_report_paths import final_report_data_path
|
|
53
50
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
54
51
|
from .worker_prompt_contract import (
|
|
55
52
|
PromptRecord,
|
|
@@ -59,7 +56,11 @@ from .worker_prompt_contract import (
|
|
|
59
56
|
from .worker_runner import LIVE, QUIET
|
|
60
57
|
from .worker_request import verifier_extra_dirs
|
|
61
58
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
62
|
-
from .wrapper_status import
|
|
59
|
+
from .wrapper_status import (
|
|
60
|
+
log_path_for_prompt,
|
|
61
|
+
prompt_derived_paths,
|
|
62
|
+
status_path_for_prompt,
|
|
63
|
+
)
|
|
63
64
|
from .write_policy import (
|
|
64
65
|
build_invocation_write_contract,
|
|
65
66
|
planned_paths_from_run_manifest,
|
|
@@ -812,6 +813,11 @@ def _agent_write_contract(
|
|
|
812
813
|
maximum_precision = capability.max_boundary_precision
|
|
813
814
|
else:
|
|
814
815
|
raise DispatchError("agent write policy has no runner capability")
|
|
816
|
+
planned = (
|
|
817
|
+
planned_paths_from_run_manifest(project_root, authority)
|
|
818
|
+
if execution.role == "implementer"
|
|
819
|
+
else ((), True)
|
|
820
|
+
)
|
|
815
821
|
try:
|
|
816
822
|
return build_invocation_write_contract(
|
|
817
823
|
role=execution.role,
|
|
@@ -819,11 +825,8 @@ def _agent_write_contract(
|
|
|
819
825
|
worktree=worktree,
|
|
820
826
|
artifact_paths=artifacts,
|
|
821
827
|
maximum_precision=maximum_precision,
|
|
822
|
-
planned_paths=
|
|
823
|
-
|
|
824
|
-
if execution.role == "implementer"
|
|
825
|
-
else ()
|
|
826
|
-
),
|
|
828
|
+
planned_paths=planned[0],
|
|
829
|
+
planned_paths_declared=planned[1],
|
|
827
830
|
auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
828
831
|
validated_auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
829
832
|
)
|
|
@@ -927,8 +930,7 @@ def _prompt_write_paths(
|
|
|
927
930
|
artifacts = {
|
|
928
931
|
_project_or_absolute(project_root, value) for value in artifact_values
|
|
929
932
|
}
|
|
930
|
-
artifacts.
|
|
931
|
-
artifacts.add(log_path_for_prompt(prompt_path))
|
|
933
|
+
artifacts.update(prompt_derived_paths(prompt_path))
|
|
932
934
|
worktree_value = values.get("Worktree")
|
|
933
935
|
worktree = (
|
|
934
936
|
_project_or_absolute(project_root, worktree_value)
|
|
@@ -1467,7 +1469,7 @@ def dispatch_result_path(
|
|
|
1467
1469
|
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1468
1470
|
return worker_result_path
|
|
1469
1471
|
return final_report_data_path(
|
|
1470
|
-
resolve_required_path(project_root, manifest, "
|
|
1472
|
+
resolve_required_path(project_root, manifest, "expectedReportRecordPath")
|
|
1471
1473
|
)
|
|
1472
1474
|
|
|
1473
1475
|
|
|
@@ -1479,17 +1481,16 @@ def dispatch_completion_paths(
|
|
|
1479
1481
|
) -> tuple[Path, ...]:
|
|
1480
1482
|
"""Every artifact that must exist before this worker counts as done.
|
|
1481
1483
|
|
|
1482
|
-
The report writer's
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
the same answer.
|
|
1484
|
+
The report writer's two are the report record and the worker-result
|
|
1485
|
+
pointer. The full reading copy is rendered on demand and is not a
|
|
1486
|
+
completion artifact.
|
|
1486
1487
|
"""
|
|
1487
1488
|
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1488
1489
|
return (worker_result_path,)
|
|
1489
1490
|
data_json = final_report_data_path(
|
|
1490
|
-
resolve_required_path(project_root, manifest, "
|
|
1491
|
+
resolve_required_path(project_root, manifest, "expectedReportRecordPath")
|
|
1491
1492
|
)
|
|
1492
|
-
return (data_json,
|
|
1493
|
+
return (data_json, worker_result_path)
|
|
1493
1494
|
|
|
1494
1495
|
|
|
1495
1496
|
def validate_initial_prompts(
|
|
@@ -1762,6 +1763,7 @@ def worker_jobs_from_file(
|
|
|
1762
1763
|
jobs_file: Path,
|
|
1763
1764
|
*,
|
|
1764
1765
|
manifest: Mapping[str, Any],
|
|
1766
|
+
active_context: Mapping[str, Any] | None = None,
|
|
1765
1767
|
backend: str,
|
|
1766
1768
|
idle_timeout_seconds: int,
|
|
1767
1769
|
default_dispatch_kind: str,
|
|
@@ -1781,6 +1783,7 @@ def worker_jobs_from_file(
|
|
|
1781
1783
|
project_root,
|
|
1782
1784
|
item,
|
|
1783
1785
|
manifest=manifest,
|
|
1786
|
+
active_context=active_context,
|
|
1784
1787
|
backend=backend,
|
|
1785
1788
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
1786
1789
|
dispatch_kind=dispatch_kind,
|
|
@@ -1799,6 +1802,7 @@ def _worker_job_from_file(
|
|
|
1799
1802
|
item: Mapping[str, Any],
|
|
1800
1803
|
*,
|
|
1801
1804
|
manifest: Mapping[str, Any],
|
|
1805
|
+
active_context: Mapping[str, Any] | None = None,
|
|
1802
1806
|
backend: str,
|
|
1803
1807
|
idle_timeout_seconds: int,
|
|
1804
1808
|
dispatch_kind: str,
|
|
@@ -1849,7 +1853,17 @@ def _worker_job_from_file(
|
|
|
1849
1853
|
result_path=result_path,
|
|
1850
1854
|
worker_result_path=worker_result_path,
|
|
1851
1855
|
completion_paths=completion_paths,
|
|
1852
|
-
|
|
1856
|
+
# 파일이 적지 않았으면 run authority 에서 해소한다. 로스터 경로는
|
|
1857
|
+
# 언제나 그렇게 하고(`dispatch_core._jobs_from_roster`), 예약 쪽
|
|
1858
|
+
# (`agent_prompt_cli._run_worktree`)도 같은 seam 을 읽는다. 이 자리만
|
|
1859
|
+
# 손으로 적은 값을 유일한 출처로 삼던 동안, 같은 run 이 어떤 경로로
|
|
1860
|
+
# 디스패치됐는지에 따라 워커의 소스 루트가 달라졌다 — 그 값은
|
|
1861
|
+
# writePolicy 의 `sourcePolicy.allowedRoot` 이므로, 예약본과 갈리면
|
|
1862
|
+
# 같은 invocationRef 가 `invocationRef drift` 로 거부된다.
|
|
1863
|
+
worktree_path=(
|
|
1864
|
+
string_value(item.get("worktreePath"))
|
|
1865
|
+
or worktree_path(manifest, active_context or {})
|
|
1866
|
+
),
|
|
1853
1867
|
role=require_string(item, "role"),
|
|
1854
1868
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
1855
1869
|
dispatch_kind=dispatch_kind,
|
|
@@ -7,30 +7,16 @@ regardless of provider — see ``ExecutionPolicy``.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import dataclass
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from
|
|
13
|
-
from typing import Any, Literal, Protocol, runtime_checkable
|
|
12
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
14
13
|
|
|
15
|
-
from .
|
|
14
|
+
from .worker_presentation import Presentation
|
|
16
15
|
from ..write_policy import WriteEnforcement, WritePolicy
|
|
17
16
|
|
|
18
|
-
# What ``ExecCommand.stream_format`` may hold. The value decides whether the
|
|
19
|
-
# runner pipes the CLI's output through the stream formatter or forwards it
|
|
20
|
-
# unchanged, so it is part of the contract rather than a display hint.
|
|
21
|
-
STREAM_JSON = "stream-json"
|
|
22
|
-
TEXT = "text"
|
|
23
17
|
SERVED_MODEL_MISMATCH_EXIT_CODE = 78
|
|
24
18
|
|
|
25
19
|
|
|
26
|
-
def no_model_observation(_event: Mapping[str, Any]) -> str | None:
|
|
27
|
-
"""Truthful default for provider output that exposes no model identity."""
|
|
28
|
-
return None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
32
|
-
|
|
33
|
-
|
|
34
20
|
@dataclass(frozen=True)
|
|
35
21
|
class ExecutionPolicy:
|
|
36
22
|
"""How a non-interactive worker is allowed to act.
|
|
@@ -75,31 +61,24 @@ class WorkerExecRequest:
|
|
|
75
61
|
|
|
76
62
|
@dataclass(frozen=True)
|
|
77
63
|
class ExecCommand:
|
|
78
|
-
"""
|
|
64
|
+
"""공급자 호출 하나, 완전히 해소된 상태.
|
|
79
65
|
|
|
80
|
-
``stdin_text``
|
|
81
|
-
rather than on stdin.
|
|
66
|
+
``stdin_text`` 는 프롬프트를 인자로 받는 CLI 에서 None 이다.
|
|
82
67
|
|
|
83
|
-
``cwd``
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
that had to re-derive it would be guessing at what the strategy already
|
|
87
|
-
decided.
|
|
68
|
+
``cwd`` 가 계약에 있는 이유는 argv 에서 유도할 수 없기 때문이다 — 어떤
|
|
69
|
+
CLI 는 작업 디렉터리를 플래그로 받고 어떤 CLI 는 프로세스의 것을 물려받는데,
|
|
70
|
+
두 무리가 같은 디렉터리를 고르지 않는다.
|
|
88
71
|
|
|
89
|
-
``
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
72
|
+
``presentation`` 은 이 CLI 의 출력을 무엇으로 볼 것인가를 정한다. 스트림
|
|
73
|
+
형식을 따로 선언하고 읽는 법을 나중에 붙이던 구조에서는, 형식만 선언하고
|
|
74
|
+
읽는 법이 어긋난 공급자의 화면이 런 내내 비어 있었다. 둘을 한 값으로 묶어
|
|
75
|
+
그 상태를 표현 불가능하게 만든다.
|
|
93
76
|
"""
|
|
94
77
|
|
|
95
78
|
argv: tuple[str, ...]
|
|
96
79
|
stdin_text: str | None
|
|
97
|
-
stream_format: str
|
|
98
80
|
cwd: Path
|
|
99
|
-
|
|
100
|
-
observe_served_model: ObserveServedModel = field(
|
|
101
|
-
default=no_model_observation
|
|
102
|
-
)
|
|
81
|
+
presentation: Presentation
|
|
103
82
|
|
|
104
83
|
|
|
105
84
|
@dataclass(frozen=True)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""워커 출력의 표현 전략.
|
|
2
|
+
|
|
3
|
+
공급자 CLI 는 저마다 다른 것을 뱉는다 — 사람이 읽는 텍스트를 흘리는 CLI 도
|
|
4
|
+
있고, 자기 어휘의 JSON 을 흘리는 CLI 도 있다. okstra 가 그 형식을 알아야만
|
|
5
|
+
화면이 나오는 구조에서는 공급자가 형식을 바꿀 때마다 화면이 조용히 빈다.
|
|
6
|
+
그래서 "해석하지 않는다" 를 1급 선택지로 둔다.
|
|
7
|
+
|
|
8
|
+
전략은 두 가지를 답한다. stderr 를 stdout 에 합칠 것인가, 그리고 각 스트림의
|
|
9
|
+
줄을 누가 받는가. 둘을 함께 두는 이유는 배치와 해석이 짝이기 때문이다 —
|
|
10
|
+
JSON 을 요청하면 한 스트림으로 합쳐 읽어야 하고, 결과와 진행을 나눠 내는
|
|
11
|
+
CLI 는 갈라 읽어야 한다.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from .worker_stream import (
|
|
21
|
+
Normalise,
|
|
22
|
+
final_text,
|
|
23
|
+
format_live,
|
|
24
|
+
format_log,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
Sink = Callable[[str], str | None]
|
|
28
|
+
Channel = Literal["stdout", "stderr"]
|
|
29
|
+
SinkSpec = tuple[Channel, Sink]
|
|
30
|
+
ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
31
|
+
|
|
32
|
+
WORKER = "worker"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TranscriptWriter(Protocol):
|
|
36
|
+
"""세션 기록에 한 줄을 남긴다. 화면 출력도 이 구현이 함께 맡는다."""
|
|
37
|
+
|
|
38
|
+
def write(self, speaker: str, line: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class Presentation(Protocol):
|
|
43
|
+
def merges_stderr(self) -> bool: ...
|
|
44
|
+
|
|
45
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class MergedText:
|
|
50
|
+
"""CLI 가 사람에게 보여주는 출력을 그대로 흘린다.
|
|
51
|
+
|
|
52
|
+
한 스트림에 진행과 결과가 함께 온다. 해석하지 않으므로 공급자가 형식을
|
|
53
|
+
바꿔도 화면이 깨지지 않는다.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
served_model_at_exit: Callable[[str, Path], str | None] | None = None
|
|
57
|
+
|
|
58
|
+
def merges_stderr(self) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
|
|
62
|
+
def emit(line: str) -> str | None:
|
|
63
|
+
writer.write(WORKER, line)
|
|
64
|
+
return line
|
|
65
|
+
|
|
66
|
+
return (("stdout", emit),)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SplitText:
|
|
71
|
+
"""결과와 진행을 다른 스트림으로 내는 CLI.
|
|
72
|
+
|
|
73
|
+
stdout 은 답이고 stderr 는 진행이다. 답은 어느 모드에서도 호출자에게
|
|
74
|
+
가야 하므로 종결 텍스트로 돌려주고, 진행은 기록과 화면에만 남는다.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def merges_stderr(self) -> bool:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
|
|
81
|
+
def result(line: str) -> str | None:
|
|
82
|
+
writer.write(WORKER, line)
|
|
83
|
+
return line
|
|
84
|
+
|
|
85
|
+
def progress(line: str) -> str | None:
|
|
86
|
+
writer.write(WORKER, line)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return (("stdout", result), ("stderr", progress))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class JsonEvents:
|
|
94
|
+
"""공급자 어휘의 JSON 을 공통 이벤트로 옮겨 적는다.
|
|
95
|
+
|
|
96
|
+
사람이 읽는 출력에 진행이 없는 CLI 를 위한 경로다. 어휘를 아는 대가로
|
|
97
|
+
도구 호출을 구조로 보여줄 수 있다.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
normalise: Normalise
|
|
101
|
+
observe: ObserveServedModel
|
|
102
|
+
|
|
103
|
+
def merges_stderr(self) -> bool:
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
|
|
107
|
+
def emit(line: str) -> str | None:
|
|
108
|
+
stripped = line.strip()
|
|
109
|
+
if not stripped:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
event = json.loads(stripped)
|
|
113
|
+
except ValueError:
|
|
114
|
+
# 이벤트가 아니다. CLI 자신의 오류 텍스트가 이 스트림으로
|
|
115
|
+
# 오므로 삼키면 아무도 못 본다.
|
|
116
|
+
writer.write(WORKER, stripped)
|
|
117
|
+
return None
|
|
118
|
+
if not isinstance(event, dict):
|
|
119
|
+
return None
|
|
120
|
+
self.observe(event)
|
|
121
|
+
closing: str | None = None
|
|
122
|
+
for entry in self.normalise(event):
|
|
123
|
+
for row in (format_live(entry) if live else format_log(entry)):
|
|
124
|
+
writer.write(WORKER, row)
|
|
125
|
+
closing = final_text(entry) or closing
|
|
126
|
+
return closing
|
|
127
|
+
|
|
128
|
+
return (("stdout", emit),)
|
|
@@ -118,6 +118,22 @@ def append_role_execution(
|
|
|
118
118
|
)
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
def _drift_detail(existing: Invocation, incoming: Invocation) -> str:
|
|
122
|
+
"""어긋난 필드를 이름으로 돌려준다.
|
|
123
|
+
|
|
124
|
+
`invocationRef drift: <ref>` 만으로는 무엇이 달라졌는지 알 수 없어, 리드가
|
|
125
|
+
새 invocation id 를 몇 개씩 만들어 보는 것 말고 할 수 있는 일이 없었다.
|
|
126
|
+
같은 ref 로 두 번째 예약이 오는 것 자체는 정상 경로(재materialize)이고,
|
|
127
|
+
거절해야 하는 것은 '내용이 달라진' 경우뿐이므로 그 내용을 이름 붙인다.
|
|
128
|
+
"""
|
|
129
|
+
before = existing.to_payload()
|
|
130
|
+
after = incoming.to_payload()
|
|
131
|
+
changed = sorted(
|
|
132
|
+
key for key in (*before, *after) if before.get(key) != after.get(key)
|
|
133
|
+
)
|
|
134
|
+
return ", ".join(changed) if changed else "(no field differs)"
|
|
135
|
+
|
|
136
|
+
|
|
121
137
|
def reserve_derived_role_invocation(
|
|
122
138
|
path: Path,
|
|
123
139
|
*,
|
|
@@ -169,7 +185,8 @@ def reserve_derived_role_invocation(
|
|
|
169
185
|
if existing_invocation is not None:
|
|
170
186
|
if existing_invocation != invocation:
|
|
171
187
|
raise ExecutionManifestError(
|
|
172
|
-
f"invocationRef drift: {invocation.invocation_ref}"
|
|
188
|
+
f"invocationRef drift: {invocation.invocation_ref} "
|
|
189
|
+
f"(differs in: {_drift_detail(existing_invocation, invocation)})"
|
|
173
190
|
)
|
|
174
191
|
if created:
|
|
175
192
|
raise ExecutionManifestError(
|
|
@@ -242,7 +259,8 @@ def ensure_invocation(path: Path, row: Invocation, *, task_key: str) -> Invocati
|
|
|
242
259
|
if existing is not None:
|
|
243
260
|
if existing != row:
|
|
244
261
|
raise ExecutionManifestError(
|
|
245
|
-
f"invocationRef drift: {row.invocation_ref}"
|
|
262
|
+
f"invocationRef drift: {row.invocation_ref} "
|
|
263
|
+
f"(differs in: {_drift_detail(existing, row)})"
|
|
246
264
|
)
|
|
247
265
|
return existing
|
|
248
266
|
_validate_next_invocation(manifest, row)
|
|
@@ -289,7 +307,8 @@ def record_invocation_attempt(
|
|
|
289
307
|
)
|
|
290
308
|
if existing is not None and existing != invocation:
|
|
291
309
|
raise ExecutionManifestError(
|
|
292
|
-
f"invocationRef drift: {invocation.invocation_ref}"
|
|
310
|
+
f"invocationRef drift: {invocation.invocation_ref} "
|
|
311
|
+
f"(differs in: {_drift_detail(existing, invocation)})"
|
|
293
312
|
)
|
|
294
313
|
invocations = manifest.invocations
|
|
295
314
|
if existing is None:
|
|
@@ -528,6 +528,32 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
|
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
|
|
531
|
+
def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
|
|
532
|
+
"""이 정책의 경로 장부를 근거로 변경을 거절할 수 있는가.
|
|
533
|
+
|
|
534
|
+
승인된 계획서에 `plannedPaths` 컬럼이 있으면 실행기는 그 목록에 묶이고,
|
|
535
|
+
목록은 반드시 비어 있지 않다(`write_policy._planned_paths_from_report` 는
|
|
536
|
+
선언된 경우에만 항목을 싣는다). 그 컬럼이 없는 옛 계획서에서는 실을 값이
|
|
537
|
+
없어 장부가 빈 채로 온다 — 종전에는 산문에서 유도한 문장 조각(`28 rows)`,
|
|
538
|
+
`captured in Stage 1)`)을 실었고, 그래서 계획이 지시한 파일 전부가 미허가
|
|
539
|
+
변경으로 읽혔다.
|
|
540
|
+
|
|
541
|
+
`project-mutation` 정책에서만 빈 장부가 "물을 수 없음" 을 뜻한다.
|
|
542
|
+
`source-readonly` 워커는 장부가 원래 비어 있고 그것이 "아무것도 바꾸지
|
|
543
|
+
말라" 는 뜻이므로, 그쪽 집행은 건드리지 않는다.
|
|
544
|
+
|
|
545
|
+
이 판정은 감사의 두 절반이 같은 함수를 읽는다. 종전에는 git 쪽만
|
|
546
|
+
`plannedPathsDeclared` 를 봤는데 그 키는 `build_write_policy` 가 만드는
|
|
547
|
+
sourcePolicy 에 아예 실리지 않아(4개 키 고정, `validate_write_policy_payload`
|
|
548
|
+
가 그 집합을 강제) 어느 쪽에서도 참이 된 적이 없다.
|
|
549
|
+
"""
|
|
550
|
+
source = policy.source_policy
|
|
551
|
+
return (
|
|
552
|
+
source.get("mode") == "project-mutation"
|
|
553
|
+
and not source.get("plannedPaths")
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
531
557
|
def _source_policy_failures(
|
|
532
558
|
policy: WritePolicy,
|
|
533
559
|
changed: set[str],
|
|
@@ -537,7 +563,10 @@ def _source_policy_failures(
|
|
|
537
563
|
declared = set(out_of_plan_edits)
|
|
538
564
|
protected = set(policy.source_policy.get("protectedPaths", ()))
|
|
539
565
|
failures: list[str] = []
|
|
540
|
-
if
|
|
566
|
+
if (
|
|
567
|
+
not _path_ledger_is_unenforceable(policy)
|
|
568
|
+
and not changed <= planned | declared
|
|
569
|
+
):
|
|
541
570
|
failures.append("source changes exceed planned and declared out-of-plan paths")
|
|
542
571
|
if not declared <= changed:
|
|
543
572
|
failures.append("declared out-of-plan path did not change")
|
|
@@ -571,6 +600,11 @@ def _git_policy_failures(
|
|
|
571
600
|
if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
|
|
572
601
|
failures.append("final HEAD is not a fast-forward descendant")
|
|
573
602
|
allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
|
|
603
|
+
if _path_ledger_is_unenforceable(policy):
|
|
604
|
+
# The plan predates the declared path column, so there is no ledger
|
|
605
|
+
# anyone can be held to. Every other check above still applies; only
|
|
606
|
+
# the path comparison stands down.
|
|
607
|
+
return failures
|
|
574
608
|
if any(
|
|
575
609
|
not paths <= allowed
|
|
576
610
|
for paths in _commit_paths_by_commit(
|