fix(agent-ops): PLAN 쓰기 경로 검증을 강제한다
PLAN 생성과 리뷰 후속 상태 기록 전에 동일한 디스패처 검증을 적용해 broad path가 다시 활성 작업을 차단하지 않도록 한다.
This commit is contained in:
parent
f4604919c0
commit
da506ba71d
6 changed files with 226 additions and 36 deletions
|
|
@ -226,8 +226,9 @@ The follow-up handoff contains the selected `{task_name}`, the current plan's re
|
|||
|
||||
- `prepare-follow-up` must return `status: routed`, the exact routed basenames, `prepared_plan`, `prepared_review`, `plan_number`, `current_plan_archive_name`, `current_plan_archive_number`, `current_review_archive_name`, `current_review_archive_number`, `plan_log_number`, `review_log_number`, and `gitignore_repair_needed`. It must have executed `finalize-task-routing` in `isolated-reassessment` mode.
|
||||
- Verify that the returned current archive names/numbers equal the values derived before preparation, and that `plan_log_number` / `review_log_number` are the post-archive counts embedded in the new review stub for its future archive.
|
||||
- Materialize `prepared_plan` only as a temporary candidate outside the repository and run `python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-plan>`. Require exit code `0` before archiving either active file. The candidate must contain exactly one non-empty `Modified Files Summary` with only exact workspace files; globs, directories, workspace root, URLs, outside-workspace paths, malformed paths, and placeholders are invalid. Remove the temporary candidate after validation.
|
||||
- If preparation returns `needs_evidence`, collect all named new evidence and rerun after the input changes; never rerun with unchanged evidence. If the evidence cannot be obtained in the current scope, leave the verdict-appended pair in place and report the exact finalization blocker.
|
||||
- If preparation returns `blocked`, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable finalization blocker. A later code-review invocation resumes this step without appending another verdict.
|
||||
- If preparation returns `blocked` or prepared PLAN validation fails, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable finalization blocker. A later code-review invocation resumes this step without appending another verdict.
|
||||
|
||||
After the required next state is prepared, archive is mandatory for `PASS`, `WARN`, and `FAIL`. Ensure `.gitignore` has the Agent-Ops managed gitignore block for task artifacts before writing `*.log` outputs. Prefer `source agent-ops/bin/ai-ignore.sh && agent_ops_ensure_gitignore_task_artifact_block .gitignore`; if the helper is unavailable, add or update a block containing `!agent-task/`, `!agent-task/**/`, `!agent-task/**/*.md`, `!agent-task/**/*.log`, and `agent-roadmap/current.md`. Apply the repair here when `prepare-follow-up` returned `gitignore_repair_needed: true`.
|
||||
|
||||
|
|
@ -259,6 +260,7 @@ For `WARN` or `FAIL`, materialize the next state prepared in Step 5 immediately
|
|||
- If the user-review gate triggered, write the prepared body to `agent-task/{task_name}/USER_REVIEW.md`. It must use only `milestone-lock`, contain every archived loop entry plus the exact linked decision, and contain no placeholder. Do not write active PLAN/CODE_REVIEW files or `complete.log`.
|
||||
- Otherwise write `prepared_plan` and `prepared_review` byte-for-byte to their routed basenames. Do not rerun, adjust, compare, or upgrade their lane/G after archive.
|
||||
- Verify the written follow-up pair contains the predicted archived plan/review paths in identical `Archive Evidence Snapshot` sections and contains no unresolved token from the review-stub template inventory. Unrelated braces in commands or code are allowed.
|
||||
- Re-run `dispatch.py --workspace <workspace> --validate-plan <written-plan>` and require exit code `0` to confirm that the byte-for-byte materialized PLAN retained the validated write claim.
|
||||
- Do not adjust the prepared route after finalization. For a `local-fit` base, `review_rework_count >= 2` or `evidence_integrity_failure=true` must produce `recovery-boundary`; `capability-gap` and `grade-boundary` keep their own basis.
|
||||
|
||||
If the task group is `m-<milestone-slug>` and the user-review gate triggered, report that the milestone task is blocked on user review; do not emit PASS completion metadata and do not call `update-roadmap`.
|
||||
|
|
@ -318,6 +320,7 @@ Report Required/Suggested counts, archive names, the final task archive path for
|
|||
- PASS with `Roadmap Targets`: `complete.log` contains `Roadmap Completion` with Milestone path, Task ids, archived plan/review evidence, and verification evidence.
|
||||
- PASS without `Roadmap Targets`: `complete.log` omits `Roadmap Completion` and reported metadata says `roadmap-completion=none`.
|
||||
- WARN/FAIL without user-review gate: the plan skill was invoked for the exact task path with verified `review_rework_count` and `evidence_integrity_failure`, completed `finalize-task-routing`, and created new active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` files matching the fresh routed output; no `complete.log`.
|
||||
- WARN/FAIL prepared PLAN passed `dispatch.py --validate-plan` before active-pair archive and again after byte-for-byte materialization; invalid write claims leave the verdict-appended prior pair active.
|
||||
- WARN/FAIL follow-up: the plan input omitted prior route fields, revalidated outcome/acceptance/exclusions from current evidence, used the completed in-memory PLAN as the packet, and copied identical `Archive Evidence Snapshot` sections into the new plan/review pair.
|
||||
- Follow-up plans and review stubs keep implementation agents limited to implementation/test/evidence and contain no implementation-owned user-review request section.
|
||||
- USER_REVIEW: `USER_REVIEW.md` exists from template, no active `PLAN-*.md` or `CODE_REVIEW-*.md` remains, and no `complete.log` was written.
|
||||
|
|
|
|||
|
|
@ -264,7 +264,12 @@ Required sections:
|
|||
- `Final Routing`: record `evaluation_mode`, finalizer, both targets' closure/grade/route, `large_indivisible_context`, positive loop-risk names/count, recovery signals, capability-gap evidence, and canonical filenames. Do not include or compare a previous loop's lane/G.
|
||||
- `Implementation Checklist`: a top-level checklist the implementing agent must follow while coding. Include one item per implementation/verification unit; if the roadmap feature Task has `검증:`, keep that verification in the same checklist item instead of making a separate completion-criteria item. Include one item for whole-plan intermediate/final verification only when it is not already covered by the feature items. Make the last item exactly `- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.` Copy this checklist into the review stub's `Implementation Checklist` section with the same item text and order.
|
||||
- One item per change: `### [TAG-1] Title`, `TAG-2`, etc.
|
||||
- `Modified Files Summary`: table mapping files to item ids.
|
||||
- `Modified Files Summary`: table mapping files to item ids. This section is the dispatcher's workspace write-claim source of truth.
|
||||
- Include exactly one `## Modified Files Summary` section and at least one exact workspace file path.
|
||||
- Use repository-relative or canonical absolute file paths. Never use a glob (`*`, `?`, `[]`), directory path, workspace root, URL, path outside the workspace, malformed path, or prose placeholder as a claim.
|
||||
- Enumerate only implementer- or reviewer-owned workspace files, including the active review evidence file and deterministic workspace evidence artifacts.
|
||||
- For generated verification artifacts, choose deterministic exact workspace filenames or write them under a task-specific temporary directory outside the repository. Never substitute a directory or glob claim for dynamic filenames.
|
||||
- Before writing or returning a prepared pair, validate the rendered PLAN with `python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-plan>`. A prepared in-memory PLAN may be materialized only as a temporary candidate outside the repository for this validation. Require exit code `0`; on failure, do not write or return the pair.
|
||||
- `Final Verification`: runnable commands and expected outcome. Prefer commands from verified handoff facts when supplied; fill missing coverage from repository manifests, scripts, workflows, domain rules, and related tests, and record the source in `Analysis > Verification Context`. Commands must be exact and deterministic enough for the reviewer to rerun; use stable ordering for searches and state whether cached test output is acceptable. End this section with **"After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`."**
|
||||
|
||||
Each plan item must include:
|
||||
|
|
@ -337,6 +342,7 @@ Do not write or return a prepared pair when either routing target is not `routed
|
|||
## Final Checklist
|
||||
|
||||
- In `write` mode, the routed `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. In `prepare-follow-up` mode, neither routed file was written; both exact bodies and basenames were returned while the verdict-appended current pair remained active.
|
||||
- The rendered PLAN passed `dispatch.py --validate-plan` before the pair was written or returned; its single non-empty `Modified Files Summary` contains only exact workspace file claims and no glob or directory claim.
|
||||
- In `write` mode, `.gitignore` has the Agent-Ops managed block that unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`. In `prepare-follow-up` mode, the block was only inspected and any needed repair was returned as `gitignore_repair_needed`.
|
||||
- Single-plan work stores active files directly under `agent-task/{task_group}/`.
|
||||
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin
|
|||
- If existing `state.json` cannot be read or validated as a JSON object, block the dispatcher. Never replace it with empty state or reset the 10-attempt budget. Repair or explicitly handle it before rerunning.
|
||||
- When a new user turn arrives, continue tracking the same overall request unless it explicitly cancels the previous request.
|
||||
- Let the execution layer display `작업시작`, `자가검증시작`, `리뷰시작`, `리뷰재시도`, `Pi복구재시도`, `세션응답복구재시도`, `세션연결재시도`, `리뷰결과`, `작업대기`, `작업차단`, `디스패치추적대기`, and `작업완료` directly from dispatcher stdout. Never duplicate them in model-authored `commentary`. Use `commentary` only when an attention event actually requires caller reasoning or a user decision. Event silence never grants `final`; only the two permissions in the absolute-priority section do.
|
||||
- Determine every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, plus native session events when available. Before accepting PID, marker, native-session, or stream evidence, require the locator path and recorded workspace identity to belong to the current physical workspace; accept an identity-less legacy locator only under the current store's `runs` root. Never use heartbeat mtime as progress evidence. Record workspace id, dispatcher PID, agent PID, each process start token, and a workspace-namespaced per-attempt process environment marker in the locator. Another dispatcher must not start a duplicate attempt merely because the stream is quiet when the PID/start token or marker shows the same process is alive. For a locator without an agent PID, never infer stale state or duplicate recovery from elapsed time while any stream/native progress evidence exists; use only an actual terminal error or confirmed process exit as recovery evidence for every model. Run Pi with `--mode json` so `thinking_delta`, `text_delta`, and tool streams reach stdout. End an **exact** Pi toolCall-to-all-toolResult interval only when every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`; never terminate the process on a time limit. If the locator lacks an agent PID during this interval, never classify it as stale or duplicate recovery based on log age; require recorded process evidence to show termination. Do not infer tool execution from `starting`, `unknown`, model reasoning, or post-toolResult state. Outside this interval, use only `stream.log` updates for Pi liveness; toolResult alone does not reset the model-response silence clock. If the stream stops for three minutes outside tool execution, store the final stream excerpt as `pi_silence_inspection` for Pi or `stream_silence_inspection` for another CLI, emit `모델응답점검`, and do not terminate the model process. Recover only from an actual terminal error or process exit.
|
||||
- Determine every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, plus native session events when available. Before accepting PID, marker, native-session, or stream evidence, require the locator path and recorded workspace identity to belong to the current physical workspace; accept an identity-less legacy locator only under the current store's `runs` root. Never use heartbeat mtime as progress evidence. Record workspace id, dispatcher PID, agent PID, each process start token, and the per-attempt process environment marker in the locator; namespace that marker by workspace. Another dispatcher must not start a duplicate attempt merely because the stream is quiet when the PID/start token or marker shows the same process is alive. For a locator without an agent PID, never infer stale state or duplicate recovery from elapsed time while any stream/native progress evidence exists; use only an actual terminal error or confirmed process exit as recovery evidence for every model. Run Pi with `--mode json` so `thinking_delta`, `text_delta`, and tool streams reach stdout. End an **exact** Pi toolCall-to-all-toolResult interval only when every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`; never terminate the process on a time limit. If the locator lacks an agent PID during this interval, never classify it as stale or duplicate recovery based on log age; require recorded process evidence to show termination. Do not infer tool execution from `starting`, `unknown`, model reasoning, or post-toolResult state. Outside this interval, use only `stream.log` updates for Pi liveness; toolResult alone does not reset the model-response silence clock. If the stream stops for three minutes outside tool execution, store the final stream excerpt as `pi_silence_inspection` for Pi or `stream_silence_inspection` for another CLI, emit `모델응답점검`, and do not terminate the model process. Recover only from an actual terminal error or process exit.
|
||||
- Detect a local-model `repetition-loop` only when the same normalized chunk repeats three consecutive times with no new tool event or file/state change. Do not infer it from similarity or semantic duplication in `thinking_delta`/`text_delta`. This signal alone must not terminate the process, block the task, trigger recovery/retry, or escalate the model; keep observing for substantive progress or an actual terminal error.
|
||||
- Keep `provider-connection`, `provider-stream-disconnect`, `session-stall`, `generic-error`, `process-terminated`, context/quota/model errors, and review-control violations distinct, but make them share a budget of 10 consecutive automatic recovery failures for the same task stage. On the 10th failure, block that task and do not auto-resume after cooldown. Reset the stage counter after success.
|
||||
- Record an explicit terminal blocker when Pi self-check leaves implementation fields incomplete 10 consecutive times or official review makes no change 10 consecutive times.
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ observation = load_sibling_observation_module()
|
|||
SEP = observation.SEP
|
||||
banner = observation.banner
|
||||
attempt_event = observation.attempt_event
|
||||
validation_claim = observation.validation_claim
|
||||
|
||||
PLAN_RE = re.compile(r"^PLAN-(local|cloud)-G(0[1-9]|10)\.md$")
|
||||
REVIEW_RE = re.compile(r"^CODE_REVIEW-(local|cloud)-G(0[1-9]|10)\.md$")
|
||||
|
|
@ -60,6 +61,11 @@ REVIEW_LOG_RE = re.compile(
|
|||
)
|
||||
SUBTASK_RE = re.compile(r"^(?P<index>\d{2})(?:\+(?P<deps>\d{2}(?:,\d{2})*))?_[a-z0-9_]+$")
|
||||
MODIFIED_FILES_HEADINGS = ("Modified Files Summary", "수정 파일 요약")
|
||||
MODIFIED_FILES_HEADER_CELLS = frozenset({"file", "files", "path", "paths", "파일", "경로"})
|
||||
PLACEHOLDER_PATH_RE = re.compile(
|
||||
r"(?:[<>{}]|\.\.\.|(?:^|[/_.-])(?:tbd|todo|placeholder)(?:$|[/_.-]))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
IMPLEMENTATION_CHECKLIST_HEADINGS = ("Implementation Checklist", "구현 체크리스트")
|
||||
# The canonical English and legacy Korean verdict contracts are paired: a
|
||||
# heading only accepts the verdict label of its own schema. Mixed pairs are not
|
||||
|
|
@ -1088,52 +1094,115 @@ def parse_task_name(task_root: Path, directory: Path) -> str:
|
|||
return directory.relative_to(task_root).as_posix()
|
||||
|
||||
|
||||
def extract_write_set(plan: Path | None, workspace: Path) -> tuple[set[str], bool]:
|
||||
if plan is None or not plan.exists():
|
||||
return set(), False
|
||||
def inspect_write_set(
|
||||
plan: Path | None,
|
||||
workspace: Path,
|
||||
) -> tuple[set[str], list[str]]:
|
||||
if plan is None:
|
||||
return set(), ["PLAN 경로가 없다"]
|
||||
if not plan.is_file():
|
||||
return set(), [f"PLAN 파일이 없다: {plan}"]
|
||||
workspace = workspace.resolve()
|
||||
text = plan.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
text = plan.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return set(), [f"PLAN 파일을 읽을 수 없다: {plan}: {exc}"]
|
||||
matches = []
|
||||
for heading in MODIFIED_FILES_HEADINGS:
|
||||
pattern = rf"^##\s*{re.escape(heading)}[ \t]*$([\s\S]*?)(?=^##\s|\Z)"
|
||||
for m in re.finditer(pattern, text, re.MULTILINE):
|
||||
matches.append(m)
|
||||
if not matches:
|
||||
return set(), ["Modified Files Summary 섹션이 없다"]
|
||||
if len(matches) != 1:
|
||||
return set(), False
|
||||
return set(), [
|
||||
f"Modified Files Summary 섹션은 정확히 1개여야 한다: count={len(matches)}"
|
||||
]
|
||||
match = matches[0]
|
||||
result: set[str] = set()
|
||||
invalid = False
|
||||
diagnostics: list[str] = []
|
||||
for line in match.group(1).splitlines():
|
||||
if not line.lstrip().startswith("|"):
|
||||
continue
|
||||
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
if not cells:
|
||||
continue
|
||||
for value in re.findall(r"`([^`]+)`", cells[0]):
|
||||
if all(set(cell) <= {":", "-"} for cell in cells):
|
||||
continue
|
||||
if cells[0].casefold() in MODIFIED_FILES_HEADER_CELLS:
|
||||
continue
|
||||
claims = re.findall(r"`([^`]+)`", cells[0])
|
||||
if not claims:
|
||||
diagnostics.append(
|
||||
f"정확한 backtick workspace 파일 경로가 없는 claim 행: {cells[0]}"
|
||||
)
|
||||
continue
|
||||
for value in claims:
|
||||
normalized = re.sub(r":\d+(?::\d+)?$", "", value.strip())
|
||||
if normalized and not normalized.startswith(("http://", "https://")):
|
||||
if (
|
||||
any(character in normalized for character in "*?[]")
|
||||
or normalized.endswith(("/", "\\"))
|
||||
):
|
||||
invalid = True
|
||||
continue
|
||||
candidate = Path(normalized)
|
||||
if not normalized:
|
||||
diagnostics.append("빈 경로 claim은 허용되지 않는다")
|
||||
continue
|
||||
if PLACEHOLDER_PATH_RE.search(normalized):
|
||||
diagnostics.append(
|
||||
f"placeholder 또는 malformed path claim은 허용되지 않는다: {normalized}"
|
||||
)
|
||||
continue
|
||||
if normalized.startswith(("http://", "https://")):
|
||||
diagnostics.append(f"URL claim은 허용되지 않는다: {normalized}")
|
||||
continue
|
||||
if "\\" in normalized:
|
||||
diagnostics.append(
|
||||
f"malformed path claim은 허용되지 않는다: {normalized}"
|
||||
)
|
||||
continue
|
||||
if any(character in normalized for character in "*?[]"):
|
||||
diagnostics.append(
|
||||
f"glob 또는 broad path claim은 허용되지 않는다: {normalized}"
|
||||
)
|
||||
continue
|
||||
if normalized.endswith(("/", "\\")):
|
||||
diagnostics.append(
|
||||
f"디렉터리 claim은 허용되지 않는다: {normalized}"
|
||||
)
|
||||
continue
|
||||
candidate = Path(normalized)
|
||||
try:
|
||||
resolved = (
|
||||
candidate.resolve()
|
||||
if candidate.is_absolute()
|
||||
else (workspace / candidate).resolve()
|
||||
)
|
||||
try:
|
||||
resolved.relative_to(workspace)
|
||||
except ValueError:
|
||||
invalid = True
|
||||
continue
|
||||
if resolved == workspace or resolved.is_dir():
|
||||
invalid = True
|
||||
continue
|
||||
result.add(str(resolved))
|
||||
return result, bool(result) and not invalid
|
||||
except (OSError, RuntimeError) as exc:
|
||||
diagnostics.append(
|
||||
f"경로를 canonicalize할 수 없다: {normalized}: {exc}"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
resolved.relative_to(workspace)
|
||||
except ValueError:
|
||||
diagnostics.append(
|
||||
f"workspace 밖 claim은 허용되지 않는다: {normalized}"
|
||||
)
|
||||
continue
|
||||
if resolved == workspace:
|
||||
diagnostics.append("workspace root claim은 허용되지 않는다")
|
||||
continue
|
||||
if resolved.is_dir():
|
||||
diagnostics.append(
|
||||
f"디렉터리 claim은 허용되지 않는다: {normalized}"
|
||||
)
|
||||
continue
|
||||
result.add(str(resolved))
|
||||
if not result:
|
||||
diagnostics.append("정확한 workspace 파일 claim이 하나 이상 필요하다")
|
||||
return result, diagnostics
|
||||
|
||||
|
||||
def extract_write_set(plan: Path | None, workspace: Path) -> tuple[set[str], bool]:
|
||||
write_set, diagnostics = inspect_write_set(plan, workspace)
|
||||
if diagnostics:
|
||||
return set(), False
|
||||
return write_set, True
|
||||
|
||||
|
||||
def latest_verdict_log(directory: Path) -> Path | None:
|
||||
|
|
@ -1206,12 +1275,21 @@ def read_task_directory(workspace: Path, directory: Path) -> Task | None:
|
|||
lane, grade = parse_route(plan)
|
||||
recovery_plan = matching_plan_log(directory, recovery_log)
|
||||
write_set_source = plan or recovery_plan
|
||||
write_set, write_set_known = extract_write_set(write_set_source, workspace)
|
||||
if (write_set_source is not None and not write_set_known) or (
|
||||
recovery_log is not None and recovery_plan is None
|
||||
):
|
||||
write_set: set[str] = set()
|
||||
write_set_known = False
|
||||
if recovery_log is not None and recovery_plan is None:
|
||||
errors.append(
|
||||
"PLAN Modified Files Summary가 없거나 비어 있거나 유효하지 않다"
|
||||
"PLAN Modified Files Summary를 복구할 matching PLAN log가 없다"
|
||||
)
|
||||
elif write_set_source is not None:
|
||||
write_set, write_set_diagnostics = inspect_write_set(
|
||||
write_set_source,
|
||||
workspace,
|
||||
)
|
||||
write_set_known = bool(write_set) and not write_set_diagnostics
|
||||
errors.extend(
|
||||
f"PLAN Modified Files Summary가 유효하지 않다: {diagnostic}"
|
||||
for diagnostic in write_set_diagnostics
|
||||
)
|
||||
if plan is not None:
|
||||
metadata = PLAN_IDENTITY_RE.search(
|
||||
|
|
@ -6360,11 +6438,30 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.add_argument("--task-group", help="run only agent-task/<task_group>")
|
||||
parser.add_argument("--dry-run", action="store_true", help="classify and print without launching CLIs")
|
||||
parser.add_argument("--retry-blocked", action="store_true", help="clear dispatcher-local blocked state")
|
||||
parser.add_argument(
|
||||
"--validate-plan",
|
||||
metavar="PATH",
|
||||
help="validate one PLAN Modified Files Summary without starting the dispatcher",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
validate_plan = getattr(args, "validate_plan", None)
|
||||
if validate_plan:
|
||||
workspace = Path(args.workspace).resolve()
|
||||
candidate = Path(validate_plan)
|
||||
if not candidate.is_absolute():
|
||||
candidate = (Path.cwd() / candidate).resolve()
|
||||
write_set, diagnostics = inspect_write_set(candidate, workspace)
|
||||
if diagnostics:
|
||||
for diagnostic in diagnostics:
|
||||
print(f"plan write-set error: {diagnostic}", file=sys.stderr)
|
||||
return 2
|
||||
for path in sorted(write_set):
|
||||
validation_claim(path)
|
||||
return 0
|
||||
try:
|
||||
return asyncio.run(dispatch(args))
|
||||
except KeyboardInterrupt:
|
||||
|
|
|
|||
|
|
@ -19,3 +19,8 @@ def banner(event: str, task: str, lines: list[str] | None = None) -> None:
|
|||
|
||||
def attempt_event(prefix: str, message: str) -> None:
|
||||
print(f"{prefix} {message}", flush=True)
|
||||
|
||||
|
||||
def validation_claim(path: str) -> None:
|
||||
"""Emit one canonical write claim for standalone PLAN validation."""
|
||||
print(path, flush=True)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
from datetime import datetime, timezone, timedelta
|
||||
import importlib.util
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -5009,7 +5010,7 @@ class WriteSetTest(unittest.TestCase):
|
|||
self.assertFalse(scanned.write_set_known)
|
||||
self.assertEqual(
|
||||
scanned.errors,
|
||||
["PLAN Modified Files Summary가 없거나 비어 있거나 유효하지 않다"],
|
||||
["PLAN Modified Files Summary를 복구할 matching PLAN log가 없다"],
|
||||
)
|
||||
|
||||
def test_rejects_broad_or_outside_workspace_write_sets(self):
|
||||
|
|
@ -5019,14 +5020,45 @@ class WriteSetTest(unittest.TestCase):
|
|||
plan = workspace / "unsafe.md"
|
||||
plan.write_text(
|
||||
"## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n"
|
||||
"| `src/exact.go` | TEST-0 |\n"
|
||||
"| `src/` | TEST-1 |\n"
|
||||
"| `../outside.go` | TEST-2 |\n"
|
||||
"| `src/*.go` | TEST-3 |\n",
|
||||
"| `src/*.go` | TEST-3 |\n"
|
||||
"| <generated files> | TEST-4 |\n"
|
||||
"| `<path>` | TEST-5 |\n"
|
||||
"| `src\\windows.go` | TEST-6 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_set, known = dispatch.extract_write_set(plan, workspace)
|
||||
inspected, diagnostics = dispatch.inspect_write_set(plan, workspace)
|
||||
self.assertFalse(known)
|
||||
self.assertEqual(write_set, set())
|
||||
self.assertEqual(inspected, {str((workspace / "src/exact.go").resolve())})
|
||||
self.assertIn(
|
||||
"디렉터리 claim은 허용되지 않는다: src/",
|
||||
diagnostics,
|
||||
)
|
||||
self.assertIn(
|
||||
"workspace 밖 claim은 허용되지 않는다: ../outside.go",
|
||||
diagnostics,
|
||||
)
|
||||
self.assertIn(
|
||||
"glob 또는 broad path claim은 허용되지 않는다: src/*.go",
|
||||
diagnostics,
|
||||
)
|
||||
self.assertIn(
|
||||
"정확한 backtick workspace 파일 경로가 없는 claim 행: "
|
||||
"<generated files>",
|
||||
diagnostics,
|
||||
)
|
||||
self.assertIn(
|
||||
"placeholder 또는 malformed path claim은 허용되지 않는다: <path>",
|
||||
diagnostics,
|
||||
)
|
||||
self.assertIn(
|
||||
r"malformed path claim은 허용되지 않는다: src\windows.go",
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
def test_active_plan_without_valid_modified_files_summary_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
|
@ -5048,7 +5080,38 @@ class WriteSetTest(unittest.TestCase):
|
|||
self.assertFalse(task.write_set_known)
|
||||
self.assertEqual(
|
||||
task.errors,
|
||||
["PLAN Modified Files Summary가 없거나 비어 있거나 유효하지 않다"],
|
||||
[
|
||||
"PLAN Modified Files Summary가 유효하지 않다: "
|
||||
"Modified Files Summary 섹션이 없다"
|
||||
],
|
||||
)
|
||||
|
||||
def test_validate_plan_mode_reports_precise_invalid_claim(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
plan = workspace / "PLAN-cloud-G10.md"
|
||||
plan.write_text(
|
||||
"## Modified Files Summary\n\n"
|
||||
"| File | Items |\n|---|---|\n"
|
||||
"| `agent-test/runs/output-filter-recovery/**` | TEST-1 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
str(SCRIPT),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--validate-plan",
|
||||
str(plan),
|
||||
],
|
||||
), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
|
||||
self.assertEqual(dispatch.main(), 2)
|
||||
self.assertIn(
|
||||
"glob 또는 broad path claim은 허용되지 않는다: "
|
||||
"agent-test/runs/output-filter-recovery/**",
|
||||
stderr.getvalue(),
|
||||
)
|
||||
|
||||
def test_workspace_claims_persist_replace_wait_and_release_on_completion(self):
|
||||
|
|
@ -11020,6 +11083,22 @@ class ArtifactLanguageContractTest(unittest.TestCase):
|
|||
with self.subTest(document=name, alias_pair=pair):
|
||||
self.assertIn(pair, documents[name])
|
||||
|
||||
def test_plan_and_review_share_dispatch_write_set_contract(self):
|
||||
documents = self.contract_documents()
|
||||
plan_skill = documents["plan_skill"]
|
||||
review_skill = documents["review_skill"]
|
||||
orchestrator_skill = documents["orchestrator_skill"]
|
||||
|
||||
for document in (plan_skill, review_skill):
|
||||
self.assertIn("dispatch.py --workspace <workspace> --validate-plan", document)
|
||||
self.assertIn("exact workspace", document)
|
||||
self.assertIn("Never use a glob (`*`, `?`, `[]`)", plan_skill)
|
||||
self.assertIn("globs, directories, workspace root", review_skill)
|
||||
self.assertIn(
|
||||
"Fail the task closed when any path is broad",
|
||||
orchestrator_skill,
|
||||
)
|
||||
|
||||
# Legacy Korean artifact labels are allowed only as explicit aliases.
|
||||
# Korean roadmap, USER_REVIEW.md, runtime banner, and user-facing
|
||||
# response literals are deliberately outside this assertion.
|
||||
|
|
|
|||
Loading…
Reference in a new issue