fix(agent-ops): PLAN 쓰기 경로 검증을 강제한다

PLAN 생성과 리뷰 후속 상태 기록 전에 동일한 디스패처 검증을 적용해 broad path가 다시 활성 작업을 차단하지 않도록 한다.
This commit is contained in:
toki 2026-07-29 13:43:00 +09:00
parent b1c541db36
commit 2d406426eb
6 changed files with 226 additions and 36 deletions

View file

@ -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.

View file

@ -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`.

View file

@ -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.

View file

@ -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:

View file

@ -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)

View file

@ -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.