sync: from iop v1.1.188

This commit is contained in:
toki 2026-08-06 12:07:57 +09:00
parent 3ba979814c
commit 308c42c2f0
32 changed files with 2437 additions and 18267 deletions

View file

@ -1 +1 @@
1.1.187
1.1.188

View file

@ -26,6 +26,41 @@ create_project_agent_ops_dirs() {
mkdir -p "$agent_ops_dir/skills/private"
}
remove_generated_caches() {
local root="$1"
[ -d "$root" ] || return 0
find "$root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
find "$root" -depth -type d \( \
-name '__pycache__' -o \
-name '.pytest_cache' -o \
-name '.mypy_cache' -o \
-name '.ruff_cache' \
\) -exec rm -rf -- {} +
}
copy_tree_without_caches() {
local src="$1"
local dst="$2"
mkdir -p "$dst"
(
cd "$src"
tar \
--exclude='__pycache__' \
--exclude='*/__pycache__' \
--exclude='.pytest_cache' \
--exclude='*/.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='*/.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='*/.ruff_cache' \
--exclude='*.pyc' \
--exclude='*.pyo' \
-cf - .
) | tar -C "$dst" -xf -
}
copy_common_agent_ops() {
local source_dir="$1"
local target_agent_ops_dir="$2"
@ -38,8 +73,10 @@ copy_common_agent_ops() {
rm -rf "$target_agent_ops_dir/rules/common"
rm -rf "$target_agent_ops_dir/skills/common"
cp -r "$source_dir/bin" "$target_agent_ops_dir/"
cp -r "$source_dir/rules/common" "$target_agent_ops_dir/rules/"
cp -r "$source_dir/skills/common" "$target_agent_ops_dir/skills/"
copy_tree_without_caches "$source_dir/rules/common" "$target_agent_ops_dir/rules/common"
copy_tree_without_caches "$source_dir/skills/common" "$target_agent_ops_dir/skills/common"
remove_generated_caches "$target_agent_ops_dir/rules/common"
remove_generated_caches "$target_agent_ops_dir/skills/common"
}
ensure_common_rules_file() {

View file

@ -42,6 +42,38 @@ bump_version() {
bash "$SCRIPT_DIR/bump-version.sh" "$1"
}
remove_generated_caches() {
local root="$1"
[[ -d "$root" ]] || return 0
find "$root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
find "$root" -depth -type d \( \
-name '__pycache__' -o \
-name '.pytest_cache' -o \
-name '.mypy_cache' -o \
-name '.ruff_cache' \
\) -exec rm -rf -- {} +
}
copy_tree_without_caches() {
local src="$1" dst="$2"
mkdir -p "$dst"
(
cd "$src"
tar \
--exclude='__pycache__' \
--exclude='*/__pycache__' \
--exclude='.pytest_cache' \
--exclude='*/.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='*/.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='*/.ruff_cache' \
--exclude='*.pyc' \
--exclude='*.pyo' \
-cf - .
) | tar -C "$dst" -xf -
}
# ── 폴더 동기화 (삭제된 파일도 반영) ────────────────────────────────────────
sync_folder() {
local src="$1" dst="$2" exclude="${3:-}"
@ -61,10 +93,14 @@ sync_folder() {
name="$(basename "$item")"
[[ -n "$exclude" && "$name" == "$exclude" ]] && continue
rm -rf "$dst/$name"
cp -r "$item" "$dst/"
# 검증 실행 중 생긴 Python bytecode는 공통 산출물이 아니므로 전파하지 않는다.
find "$dst/$name" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
find "$dst/$name" -depth -type d -name '__pycache__' -empty -delete
if [[ -d "$item" ]]; then
mkdir -p "$dst/$name"
copy_tree_without_caches "$item" "$dst/$name"
else
cp "$item" "$dst/"
fi
# 검증 중 생긴 cache는 공통 산출물이 아니므로 전파하지 않는다.
remove_generated_caches "$dst/$name"
done
}
@ -85,7 +121,14 @@ common_differs() {
if [[ ! -d "$src/$path" || ! -d "$dst/$path" ]]; then
return 0
fi
if ! diff -qr "$src/$path" "$dst/$path" >/dev/null; then
if ! diff -qr \
--exclude='__pycache__' \
--exclude='.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='*.pyc' \
--exclude='*.pyo' \
"$src/$path" "$dst/$path" >/dev/null; then
return 0
fi
done
@ -121,8 +164,10 @@ copy_common_scaffold() {
cp "$src/.version" "$dst/"
rm -rf "$dst/bin" "$dst/rules/common" "$dst/skills/common"
cp -r "$src/bin" "$dst/"
cp -r "$src/rules/common" "$dst/rules/"
cp -r "$src/skills/common" "$dst/skills/"
copy_tree_without_caches "$src/rules/common" "$dst/rules/common"
copy_tree_without_caches "$src/skills/common" "$dst/skills/common"
remove_generated_caches "$dst/rules/common"
remove_generated_caches "$dst/skills/common"
create_project_agent_ops_dirs "$dst"
}

View file

@ -21,7 +21,7 @@
- 긴 배경 설명은 README나 별도 참조 문서로 보내고, 실행 문서에는 실행 규칙만 남긴다.
- 룰 문서는 협업자가 직접 읽는 계약 문서이므로 한국어 `한다`체로 작성한다.
- README, GUIDE, roadmap 문서는 사람이 함께 검토하는 협업 문서이므로 한국어 설명체 또는 존댓말을 사용할 수 있다.
- 스킬 문서는 실행 안정성을 우선한다. 한국어 또는 영어를 사용할 수 있고, 이미 잘 동작하는 절차 계약은 언어 통일만을 위해 수정하지 않는다.
- 스킬 문서의 frontmatter `description`과 Markdown 본문은 영어로 작성한다. 기존 스킬은 언어 통일만을 위해 수정하지 않되, 새로 만들거나 실질적으로 갱신하는 스킬은 영어를 사용한다. 프로젝트가 요구하는 사용자-facing 출력 literal은 해당 언어를 유지할 수 있다.
- path, filename, 상태값, id, regex, command, frontmatter key, runtime protocol token은 원문 ASCII 식별자를 유지한다.
## 라우팅

View file

@ -1,56 +1,51 @@
---
name: <skill-name>
version: 1.0.0
description: <skill이 하는 일을 줄로 설명. 트리거 키워드 포함 권장>
description: <Describe in one sentence what this skill does and when to use it. Include trigger keywords when useful.>
---
# <skill-name>
## 목적
## Purpose
<skill이 해결하는 문제를 1~2문장으로 설명>
<Explain the problem this skill solves in one or two sentences.>
## 언제 호출할지
## When to use
- < skill을 호출해야 하는 상황 1>
- < skill을 호출해야 하는 상황 2>
- < skill을 호출해야 하는 상황 3>
- <Invocation case 1>
- <Invocation case 2>
## 입력
<!-- Optional: remove this comment and the entire Inputs section when the skill has no explicit parameters. -->
## Inputs
- `<param-name>`: <설명> (필수)
- `<param-name>`: <설명> (선택)
- `<param-name>`: <Description.> (required)
## 먼저 확인할 것
<!-- Optional: remove this comment and the entire Preflight section when no check must precede execution. -->
## Preflight
- [ ] <실행 반드시 확인해야 조건 1>
- [ ] <실행 반드시 확인해야 조건 2>
- [ ] <Condition that must be checked before execution.>
## 실행 절차
## Procedure
1. **<단계명>**
- <세부 행동>
- <세부 행동>
1. **<Step name>**
- <Required action.>
2. **<단계명>**
- <세부 행동>
2. **Report the result**
- <Result to report.>
3. **결과 보고**
- <출력할 내용>
## Validation
## 실행 결과 검증
- [ ] <Verifiable success condition.>
- If validation fails, <define the retry, rollback, or reporting action.>
- [ ] <실행 확인해야 성공 조건 1>
- [ ] <실행 확인해야 성공 조건 2>
- 검증 실패 시: <실패 취할 행동 롤백, 사용자 알림, 재시도 >
## 출력 형식
<!-- Optional: remove this comment and the entire Output format section unless an exact shape is required. -->
## Output format
```
<출력 예시>
<Output example.>
```
## 금지 사항
<!-- Optional: remove this comment and the entire Prohibitions section unless a specific likely or unsafe action must be forbidden. -->
## Prohibitions
- <절대 하면 되는 >
- <절대 하면 되는 >
- <Forbidden action.>

View file

@ -20,12 +20,12 @@ Implementation agents never decide or request user review. They record implement
## Core Loop Rules
- Trigger: Korean or English active-task review requests, including `리뷰 진행해` and `리뷰해줘`, must use this skill when an active `CODE_REVIEW-*-G??.md` or `USER_REVIEW.md` exists under `agent-task/*/` or `agent-task/*/*/`, excluding `agent-task/archive/**`.
- Finalize every selected active state: for `CODE_REVIEW-*-G??.md`, append one verdict, prepare the required next state, archive the active review and plan files, then materialize exactly one next state; for `USER_REVIEW.md` completion, update the stop state, write `complete.log`, and archive the task.
- Finalize every selected state unless Step 5 returns blocked. Normally append one verdict, prepare one next state, archive the pair, and materialize that state. For `dependency-wait`, keep the pair and persist `next-task`/resume condition without another verdict. A resolved `USER_REVIEW.md` writes `complete.log` and archives the task.
- Next state: `PASS` writes `complete.log` and moves the task under `agent-task/archive/YYYY/MM/`; if the task group is `m-<milestone-slug>`, report completion metadata for the runtime event. `WARN` or `FAIL` normally invokes `agent-ops/skills/common/plan/SKILL.md`, which must run `finalize-task-routing` before writing the next active pair; if the user-review gate triggers, write `USER_REVIEW.md` instead. A completed `USER_REVIEW.md` uses the same terminal `complete.log` and archive path as `PASS`.
- The user-review gate is review-agent-owned and triggers only when current evidence proves either that a concrete selected Milestone `구현 잠금 > 결정 필요` item blocks the next safe implementation step or that required external verification cannot proceed without a user-controlled capability or authorization. Generic status fields or blocker text written by implementation are never a user-review request.
- Do not replace `USER_REVIEW.md` with an inline user question. When the user-review gate triggers, write the file-based stop state and report its path.
- Do not ask for confirmation before WARN/FAIL follow-up files. If the user-review gate triggers, write `USER_REVIEW.md`; otherwise invoke the plan skill with the current raw findings and let it write the smallest concrete follow-up after fresh routing.
- Recovery: if a prior turn appended a verdict without archive or next-state files, do not append another verdict; resume Step 5 preparation/archive from that verdict. If exactly one member of the pair was archived after both archive destinations had been preflighted, verify the archived member and remaining source/destination, finish that archive, then use the post-archive recovery below. If both logs exist with a verdict but the required next state is absent, reconstruct it from those exact logs: PASS resumes `complete.log`; WARN/FAIL reruns the plan skill in `write` mode with raw archived findings and `isolated-reassessment`; a valid user-review gate rerenders `USER_REVIEW.md`. If a prior turn resolved `USER_REVIEW.md` without `complete.log`, resume at the matching finalization step.
- Recovery: if a prior turn appended a verdict without archive or next-state files, do not append another verdict; resume Step 5 preparation/archive from that verdict. If a pre-existing verdict predates stable finding ids, assign `R1..` and `S1..` once in displayed order in the recovery handoff and record that mapping without appending a second verdict. If exactly one member of the pair was archived after both archive destinations had been preflighted, verify the archived member and remaining source/destination, finish that archive, then use the post-archive recovery below. If both logs exist with a verdict but the required next state is absent, reconstruct it from those exact logs: PASS resumes `complete.log`; WARN/FAIL reruns the plan skill in `write` mode with raw archived findings and `isolated-reassessment`; a valid user-review gate rerenders `USER_REVIEW.md`. If a prior turn resolved `USER_REVIEW.md` without `complete.log`, resume at the matching finalization step.
## User Review Gate
@ -98,6 +98,8 @@ Milestone task group contract:
Follow-up routing boundary:
- This skill records current source, actual verification output, and findings, but it must not estimate or recommend the next lane/G.
- Decide each Required/Suggested disposition here and validate it directly; dispatcher use is optional. Default repository-fixable work to `direct-fix`, expanding stale exclusions when required by original acceptance. Allow `verified-dependency` only with the exact PLAN/task ordering proof, or `complete.log` plus fresh proof that the precondition is satisfied; vague owners and `complete.log` alone are invalid. Set `ownership_closed=true` only after every id has that proof.
- Never send an unchanged-precondition verification packet. For an unordered dependency, keep the verdict pair and return `status=blocked`, `blocked_reason=dependency-wait`, `next-task`, and resume condition; do not redispatch it or request user review.
- On WARN/FAIL, invoke the plan skill in `prepare-follow-up` mode with the selected task path and raw current evidence before archiving the current pair.
- Do not pass the archived lane, grade, routing score, rationale, or filename as plan-routing input. Archive paths remain evidence pointers, and actual logs/findings remain raw evidence.
- The plan skill must complete its full analysis and mandatory `finalize-task-routing` step before it writes the next pair. Code-review must not create a routed follow-up pair directly.
@ -109,7 +111,7 @@ Directory states:
|-------|---------|
| `PLAN-*-G??.md` + unfilled `CODE_REVIEW-*-G??.md` stub/placeholders | Implementation is not judgeable; review should fail completeness if invoked |
| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` without verdict | Ready for code-review skill |
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization pending; do not append another verdict, resume Step 5 preparation/archive |
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization pending or `dependency-wait`; do not append another verdict. Resume Step 5 immediately for unfinished finalization, or only after the recorded dependency changes for a wait. |
| Exactly one active pair member + its newly archived counterpart | Partial archive after a preflighted finalization; verify both identities, finish the remaining archive, then resume post-archive recovery |
| `complete.log` + `*.log` files | Task complete (PASS or user-review-resolved PASS), before final task-directory archive move |
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; its recorded Milestone decision or external-execution user action must be resolved before creating another plan |
@ -190,7 +192,7 @@ Required fields for canonical English active pairs:
- `Overall Verdict`: exactly `PASS`, `WARN`, or `FAIL`.
- `Dimension Assessment`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, implementation deviation, verification trust. If SDD Evidence Map applies through `milestone-task`, also include spec conformance.
- `Findings`: `None`, or bullets using `Required`, `Suggested`, or `Nit` with `file:line` and a concrete fix.
- `Findings`: `None`, or bullets using stable `Required R1`, `Required R2`, `Suggested S1`, or `Suggested S2` ids with `file:line` and a concrete fix; Nit findings do not need ids. Keep every Required/Suggested id unchanged in the follow-up handoff and plan.
- `Routing Signals`: calculate once and append `review_rework_count=<N>` and `evidence_integrity_failure=true|false`. Set rework count to archived same-task `WARN|FAIL` verdicts plus one only when the current verdict is non-PASS. Set integrity failure to true only when a claimed test, command, exit code, or production path is absent, unexecuted, or contradicted by fresh reviewer evidence.
- `Next Step`: keep only the matching PASS, WARN/FAIL follow-up, or USER_REVIEW line.
@ -229,13 +231,14 @@ Do not archive WARN/FAIL files until the next-state content is fully prepared in
Reuse the routing signals appended in Step 4; do not recount verdict history for routing. Separately count the existing logs once for archive identity: set `current_review_archive_number=count(code_review_*.log)` and `current_plan_archive_number=count(plan_*.log)`, then derive both archive names from the current active files' own lane/grade. These archive values describe the pair being closed, not the next route.
The follow-up handoff contains the selected `{task_name}`, the current plan's requested outcome/acceptance/exclusions revalidated against current evidence, current verdict, Required/Suggested/Nit findings, affected files, actual verification output, current ownership/dependency facts, roadmap carryover, `review_rework_count`, `evidence_integrity_failure`, `REVIEW_<PARENT_TAG>`, and those predicted current-pair archive names. Keep current active paths only as evidence pointers. Do not add the prior lane, grade, routing score, rationale, or a preferred next route to the neutral routing snapshot, and require plan to omit route-bearing basenames from the isolated routing input. The plan may use current archive names only after routing to render `Archive Evidence Snapshot`.
The follow-up handoff contains the selected `{task_name}`, revalidated outcome/acceptance/exclusions, current verdict and stable Required/Suggested ids, affected files, actual verification output, each id's `direct-fix` or `verified-dependency` disposition and exact evidence, roadmap carryover, routing signals, `REVIEW_<PARENT_TAG>`, and predicted current-pair archive names. Keep current active paths only as evidence pointers. Omit prior lane, grade, routing score, rationale, filename, and preferred next route from routing input. The plan may use current archive names only after routing to render `Archive Evidence Snapshot`.
- `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/common/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.
- Before archiving either active file, inspect `prepared_plan` directly. Require one non-empty `Finding Resolution Map` that maps every Required/Suggested id exactly once; every `direct-fix` target appears in `Modified Files Summary`; every `verified-dependency` has the exact task-protocol/current-state proof above; and a planned change or satisfied dependency makes repeated verification meaningful. Reuse the existing review/plan analysis; do not add a separate model pass.
- 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` 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.
- If preparation returns `blocked` or the direct prepared-PLAN check fails, correct the handoff/plan from already collected evidence in the same review pass. If exact new evidence is genuinely required, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable internal finalization blocker. A later code-review invocation resumes this step without appending another verdict; this condition is not user review by itself.
- For `dependency-wait`, keep one `### Finalization State` under the verdict with status, next task, resume condition, and checked state. Do no work while unchanged; when satisfied, mark it resolved with evidence and resume without 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`.
@ -268,7 +271,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 exactly one supported type, `milestone-lock` or `external-execution`, contain every archived loop entry plus the exact required user action or 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 `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/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.
- Read the written PLAN once and confirm byte-for-byte materialization retained the checked `Finding Resolution Map` and `Modified Files Summary` invariants.
- 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`.
@ -319,6 +322,8 @@ Report Required/Suggested counts, archive names, the final task archive path for
## Final Checklist
For `status=blocked`, keep the verdict pair and persist/report blocker, next task, and resume condition. Archive/next-state items below wait until it changes; unchanged dependency state triggers no review work.
- `{current_review_archive_name}` exists with the verdict appended and was derived from the archived active review's own route.
- `{current_plan_archive_name}` exists and was derived from the archived active plan's own route.
- `.gitignore` unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`; generated task artifacts are not ignored by `git check-ignore`.
@ -328,8 +333,8 @@ Report Required/Suggested counts, archive names, the final task archive path for
- PASS `complete.log` first line is byte-for-byte identical to the archived PLAN header. An `m-*` log contains non-empty `milestone-task` ids and reports them in completion metadata; a non-milestone log omits the field.
- PASS does not create `Roadmap Completion` or directly check a Milestone Task. Aggregated evaluation is deferred to `sync-milestone-workstate`.
- 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 `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-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.
- WARN/FAIL prepared PLAN passed the intrinsic finding-resolution and write-boundary checks before active-pair archive and retained them after byte-for-byte materialization. Invalid resolution or 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, mapped every stable Required/Suggested id to a direct fix or exact verified dependency, 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.
- Review-agent-owned USER_REVIEW: the generated `USER_REVIEW.md` records one supported gate type, the exact Milestone decision or external-execution user action, and evidence that made automatic continuation unsafe.

View file

@ -86,7 +86,7 @@ README는 프로젝트 특성에 맞게 필요한 섹션만 사용하되, 기본
## 먼저 확인할 것
- [ ] 루트 `README.md` 존재 여부와 기존 내용 확인
- [ ] `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `Makefile`, `docker-compose.yml` 실행/검증 명령 근거 확인
- [ ] 프로젝트 manifest, build 설정, container 설정, CI workflow 등에서 실행/검증 명령 근거 확인
- [ ] `agent-ops/rules/project/rules.md`가 있으면 프로젝트 개요, 기술 스택, 도메인 매핑 확인
- [ ] `agent-roadmap/ROADMAP.md` 또는 로컬 `agent-roadmap/current.md`가 있으면 제품 방향과 활성 Milestone 문서 경로만 확인
- [ ] 주요 소스 디렉터리와 테스트 디렉터리를 `rg --files`로 가볍게 확인

View file

@ -1,109 +1,116 @@
---
name: create-skill
version: 1.0.1
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
version: 1.1.0
description: Create an agent-ops skill with one responsibility, a minimal execution contract, unambiguous English instructions, and correct routing. Use when adding a new common, project, or private SKILL.md.
---
# Create Skill
## 목적
## Purpose
`agent-ops/skills/` 하위에 올바른 형식의 SKILL.md 파일을 생성한다.
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
생성 후 라우팅 항목을 추가한다.
Create a correctly structured `SKILL.md` under `agent-ops/skills/` from the current skill template, then register its routing entry.
이 스킬은 프로젝트 내부 `agent-ops` 라우터가 읽는 스킬을 만든다.
`$CODEX_HOME/skills`에 설치되어 Codex가 직접 discover하는 스킬을 만들 때는 시스템 `skill-creator` 규칙을 우선하고, frontmatter는 `name``description`만 사용한다.
This skill creates skills read by the project-local `agent-ops` router. When creating a Codex-discoverable skill under `$CODEX_HOME/skills`, follow the system `skill-creator` rules instead and use only `name` and `description` in the frontmatter.
### 생성 위치 결정
- `.agent-ops-source` 파일이 **있으면** (공통 관리 레포): `agent-ops/skills/common/<skill-name>/SKILL.md`
- `.agent-ops-source` 파일이 **없고** 사용자가 private 또는 operator-local을 명시하면 (타겟 프로젝트): `agent-ops/skills/private/<skill-name>/SKILL.md`
- `.agent-ops-source` 파일이 **없고** private 요청이 없으면 (타겟 프로젝트): `agent-ops/skills/project/<skill-name>/SKILL.md`
### Select the creation location
## 언제 호출할지
- If `.agent-ops-source` exists, create `agent-ops/skills/common/<skill-name>/SKILL.md`.
- If `.agent-ops-source` does not exist and the user explicitly requests private or operator-local visibility, create `agent-ops/skills/private/<skill-name>/SKILL.md`.
- If `.agent-ops-source` does not exist and the user does not request private visibility, create `agent-ops/skills/project/<skill-name>/SKILL.md`.
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
## When to use
## 입력
- A repeatable task pattern is not covered by an existing skill.
- The user asks to create a specific skill.
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
- `visibility`: `common`, `project`, `private` 중 하나. 사용자가 private 또는 operator-local을 명시했을 때만 `private`을 선택한다. (선택)
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
## Inputs
## 먼저 확인할 것
- `skill-name`: Kebab-case skill name. (required)
- `purpose`: One-sentence summary of the problem the skill solves. (required)
- `visibility`: `common`, `project`, or `private`. Select `private` only when the user explicitly requests private or operator-local visibility. (optional)
- `trigger-cases`: Situations that should invoke the skill. (optional)
- [ ] `agent-ops/skills/common/`, `agent-ops/skills/project/`, `agent-ops/skills/private/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
- [ ] `agent-ops/skills/common/router.md``agent-ops/rules/project/rules.md` 에 이미 유사한 라우팅 항목이 있는지 확인
- [ ] `agent-ops/skills/common/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
## Preflight
## 실행 절차
- [ ] Check `agent-ops/skills/common/`, `agent-ops/skills/project/`, and `agent-ops/skills/private/` for an existing directory with the same name.
- [ ] Check `agent-ops/skills/common/router.md` and `agent-ops/rules/project/rules.md` for equivalent routing or functionality.
- [ ] Read `agent-ops/skills/common/_templates/skill-template.md` for the current structure.
1. **중복 확인**
- 같은 visibility 경로에 이미 있는 skill은 덮어쓰지 않고 중단한다.
- private 요청에서 같은 이름의 project skill은 의도된 override 후보이므로 중복으로 중단하지 않는다. common skill과의 같은 이름 또는 다른 기능의 중복은 사용자에게 알리고 중단한다.
- private override가 아닌 기능 중복은 사용자에게 알리고 중단한다.
- project skill과 같은 이름의 private override는 해당 project skill의 책임을 완전히 대체하는지 확인한다.
## Procedure
2. **목적 분석**
- `purpose``trigger-cases` 를 바탕으로 아래 항목을 도출한다
- 언제 호출할지 (2~4개)
- 필요한 입력 파라미터
- 사전 확인 항목
- 실행 절차 (3~7단계)
- 출력 형식
- 금지 사항
1. **Reject unintended duplication**
- Stop instead of overwriting a skill in the same visibility path.
- For a private request, treat a same-name project skill as a possible intentional override rather than an automatic duplicate. Stop and report a same-name common skill or a functional duplicate.
- Confirm that a private override fully replaces the responsibility of its same-name project skill.
3. **SKILL.md 생성**
- 경로: 생성 위치 결정 규칙에 따라 `common/`, `project/`, 또는 `private/` 하위에 생성
- `skill-template.md` 형식을 따른다
- agent-ops 내부 스킬은 기존 로컬 관례에 맞춰 `version`을 둘 수 있다. Codex 설치형 스킬로 배포할 목적이면 `version`이나 `depends` 같은 비표준 frontmatter를 넣지 않는다.
- 프로젝트 특화 내용보다 범용 절차를 우선한다
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
2. **Define one responsibility and its minimum contract**
- Select only the representative trigger cases needed to distinguish this skill from existing routes. Do not pad the list to reach a target count.
- Define the required procedure and success or failure criteria. Add inputs, preflight checks, an exact output format, or prohibitions only when they change correct execution or verdict determination.
- Use the fewest procedure steps that preserve the workflow. Three to seven steps are a guideline for a genuinely multi-stage workflow, not a required count.
- Include only contracts required to execute the repeated task and determine success or failure.
- Do not add speculative inputs, states, branches, output fields, validation rules, or extension points for unsupported future cases.
- Omit a rule that does not change an action, selection, validation result, or failure response. Link to an existing owning rule instead of restating its contract.
- Keep exactly one independent responsibility in the skill.
4. **라우팅 업데이트**
- `.agent-ops-source` 마커가 **있으면** (공통 관리 레포): `agent-ops/skills/common/router.md`에 라우팅 항목 추가
- private skill이 같은 이름의 project skill을 override하면 별도 라우팅 항목을 추가하지 않는다. 공통 규칙의 private 우선순위를 사용한다.
- project skill과 짝이 없는 private skill은 `agent-ops/rules/private/rules.md`에만 라우팅 항목을 추가한다. 파일이 없으면 private route만 담은 ignored local rule을 생성한다.
- private rule의 trigger는 project router와 중복 등록하지 않는다.
- `.agent-ops-source` 마커가 **없고** private skill이 아니면 (타겟 프로젝트): `agent-ops/rules/project/rules.md`의 프로젝트 스킬 라우터 섹션에 라우팅 항목 추가
- 기존 공통 스킬을 수정해 trigger가 달라졌다면 새 skill을 만들지 말고 `agent-ops/skills/common/router.md`의 기존 행을 갱신한다
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
- 기존 라우팅 구조를 깨지 않는다
3. **Write `SKILL.md`**
- Create the file under the selected `common/`, `project/`, or `private/` path and follow `skill-template.md`.
- Write the frontmatter `description` and Markdown instructions in English.
- Use short imperative sentences with one meaning each. State the condition, required action, and verifiable success or failure criterion when they affect execution.
- Do not use discretionary terms such as `appropriately`, `if needed`, or `when possible` without a decision criterion.
- Keep the procedure specific without encoding unnecessary implementation detail.
- Preserve paths, filenames, IDs, commands, regexes, status values, protocol tokens, and other exact literals. User-facing output literals may use the language required by the project.
- Agent-ops internal skills may retain the local `version` convention. For Codex-installed skills, do not add nonstandard frontmatter such as `version` or `depends`.
- Prefer reusable procedures over project-specific implementation details.
- Remove unused optional template sections, authoring comments, and placeholders from the completed skill.
5. **결과 보고**
- 생성한 파일 경로
- 라우팅 항목을 추가한 파일과 내용
- 이 skill이 다루지 않는 범위(필요 시)
4. **Update routing**
- If `.agent-ops-source` exists, add the routing entry to `agent-ops/skills/common/router.md`.
- Do not add a separate route when a private skill overrides a same-name project skill; use the common private-precedence rule.
- Route a private skill with no project counterpart only from `agent-ops/rules/private/rules.md`. If the file does not exist, create an ignored local rule containing only the private route.
- Do not duplicate a private trigger in the project router.
- If `.agent-ops-source` does not exist and the skill is not private, add the route to the project skill router section in `agent-ops/rules/project/rules.md`.
- If an existing common skill only needs different triggers, update its existing row in `agent-ops/skills/common/router.md` instead of creating another skill.
- Select the existing routing axis that matches the skill and preserve the current routing structure.
## 출력 형식
5. **Report the result**
- Report the created file path.
- Report the routing file and added entry.
- Report excluded scope only when it prevents a likely misunderstanding.
```
## Validation
- [ ] `agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md` exists.
- [ ] The skill contains the required purpose, invocation cases, procedure, and validation sections.
- [ ] Inputs, preflight, output format, and prohibitions are present only when they define a necessary contract.
- [ ] Every instruction is necessary for execution or verdict determination; no speculative contract remains.
- [ ] Instructions are concise, single-meaning, and free of discretionary wording without decision criteria.
- [ ] The frontmatter description and Markdown instructions are in English, except exact literals that must retain another language.
- [ ] No template authoring comment or unfilled placeholder remains.
- [ ] The frontmatter has a valid `name` and `description`.
- [ ] An agent-ops skill follows local frontmatter conventions, while a Codex-installed skill follows the system `skill-creator` frontmatter rules.
- [ ] A private override takes precedence over its same-name project skill, and only an unmatched private skill is routed from the private rule.
- If validation fails, report the missing or conflicting item and change only that item.
## Output format
```markdown
## 생성 완료
- SKILL 경로: agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md
- 라우팅 추가: <대상 파일><라우팅 ><skill-name>
## 주의사항 (해당 시)
- < skill이 다루지 않는 범위 또는 주의할 >
```
## 실행 결과 검증
## Prohibitions
- [ ] `agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md` 파일이 생성되었는가
- [ ] 생성된 파일이 `skill-template.md`의 필수 섹션(목적, 언제 호출할지, 실행 절차, 실행 결과 검증, 출력 형식, 금지 사항)을 포함하는가
- [ ] frontmatter에 name, description이 올바르게 기재되었는가
- [ ] agent-ops 내부 스킬이면 version 등 로컬 관례를 따르고, Codex 설치형 스킬이면 시스템 `skill-creator` frontmatter 규칙을 따르는가
- [ ] private override는 동일 이름의 project skill보다 우선되고, 짝이 없는 private skill만 private rule에 라우팅되었는가
- 검증 실패 시: 누락된 섹션 또는 라우팅 항목을 사용자에게 알리고 해당 부분만 보완한다
## 금지 사항
- private skill 또는 private rule의 내용을 tracked common·project 경로에 복사하지 않는다
- 이미 존재하는 skill 을 덮어쓰지 않는다
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
- skill 생성과 무관한 코드 파일을 수정하지 않는다
- 라우팅 대상 파일의 기존 항목을 삭제하거나 재정렬하지 않는다
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다
- Do not copy private skill or private rule content into tracked common or project paths.
- Do not overwrite an existing skill.
- Do not hardcode project-specific paths such as `app/screens/` in a reusable skill.
- Do not modify code unrelated to skill creation.
- Do not delete or reorder existing routing entries.
- Do not combine multiple independent responsibilities in one skill.
- Do not add contracts for hypothetical future requirements.
- Do not use ambiguous instructions without executable decision criteria.

View file

@ -46,7 +46,7 @@ description: agent-test 환경 rules.md와 도메인/검증 시나리오별 테
- [ ] `agent-ops/skills/common/router.md``create-test` 라우팅이 있는지 확인한다.
- [ ] `agent-ops/rules/project/rules.md`가 있으면 도메인 매핑 테이블을 확인한다.
- [ ] `agent-ops/rules/project/domain/` 하위 domain rule 목록을 확인한다.
- [ ] 테스트 명령 확인을 위해 프로젝트의 대표 설정 파일을 가볍게 확인한다. 예: `package.json`, `Makefile`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `docker-compose*.yml`, `.github/workflows/**`.
- [ ] 테스트 명령 확인을 위해 프로젝트의 대표 manifest, build 설정, container 설정, CI workflow를 가볍게 확인한다.
- [ ] `agent-ops/rules/common/_templates/test-env-rules-template.md`를 읽는다.
- [ ] `agent-ops/rules/common/_templates/test-case-rule-template.md`를 읽는다.
- [ ] 프로젝트에 `agent-test/_templates/env-rules-template.md` 또는 `agent-test/_templates/test-profile-template.md`가 있으면 해당 프로젝트 템플릿을 공통 템플릿보다 우선한다.

View file

@ -7,7 +7,7 @@ description: PLAN/CODE_REVIEW 작성 직전 완성된 build packet을 한 번
## 목표
완성된 in-memory PLAN 하나를 한 번 평가해 build/review route를 확정한다. routing 전용 문서나 증거 탐색을 만들지 않는다. Build의 기본값은 local이며 아래 표의 cloud 조건에 일치할 때만 승격한다. 공식 review는 항상 cloud의 Codex `gpt-5.6-sol` xhigh다. 이 스킬은 task 파일을 수정하지 않는다.
완성된 in-memory PLAN 하나를 한 번 평가해 build/review route를 확정한다. routing 전용 문서나 증거 탐색을 만들지 않는다. Build의 기본값은 local이며 아래 표의 cloud 조건에 일치할 때만 승격한다. 공식 review는 항상 cloud lane을 사용하되 agent와 model은 런타임 실행 카탈로그가 결정한다. 이 스킬은 task 파일을 수정하지 않는다.
## 입력
@ -129,9 +129,9 @@ finalizer 출력만 사용한다. lane, grade, boundary, filename을 수작업
항상 `status`, `evaluation_mode`, `missing_evidence`, `blocked_reason`을 반환한다. `status=routed`이면 다음 필드를 모두 반환한다.
- 공통: `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`
- target별: `closures`, `closure_basis`, `capability_gap`, `grade_scores`, `route_basis`, `lane`, `grade`, `filename`
- target별: `closures`, `closure_basis`, `capability_gap`, `grade_scores`, `route_basis`, `lane`, `grade`, `filename`, `catalog_route`
- build 전용: `base_route_basis`, `large_indivisible_context`, `matched_loop_risk_signatures`, `loop_risk_count`, `review_rework_count`, `evidence_integrity_failure`, `risk_boundary_matched`, `recovery_boundary_matched`
- review 전용: `route_basis=official-review`, `adapter=codex`, `model=gpt-5.6-sol`, `reasoning_effort=xhigh`
- review 전용: `route_basis=official-review`, `catalog_route=review/cloud/GNN`. 구체적인 agent와 model은 이 출력에 포함하지 않는다.
## 완료 확인

View file

@ -98,9 +98,6 @@ finalize_review() {
REVIEW_LANE=$(field "$route" lane)
REVIEW_GRADE=$(field "$route" grade)
REVIEW_FILENAME=$(field "$route" filename)
REVIEW_ADAPTER=codex
REVIEW_MODEL=gpt-5.6-sol
REVIEW_REASONING_EFFORT=xhigh
}
emit_build() {
@ -115,6 +112,7 @@ emit_build() {
printf 'build_lane=%s\n' "$BUILD_LANE"
printf 'build_grade=%s\n' "$BUILD_GRADE"
printf 'build_filename=%s\n' "$BUILD_FILENAME"
printf 'build_catalog_route=worker/%s/%s\n' "$BUILD_LANE" "$BUILD_GRADE"
}
emit_review() {
@ -122,9 +120,7 @@ emit_review() {
printf 'review_lane=%s\n' "$REVIEW_LANE"
printf 'review_grade=%s\n' "$REVIEW_GRADE"
printf 'review_filename=%s\n' "$REVIEW_FILENAME"
printf 'review_adapter=%s\n' "$REVIEW_ADAPTER"
printf 'review_model=%s\n' "$REVIEW_MODEL"
printf 'review_reasoning_effort=%s\n' "$REVIEW_REASONING_EFFORT"
printf 'review_catalog_route=review/%s/%s\n' "$REVIEW_LANE" "$REVIEW_GRADE"
}
mode=${1:-}

View file

@ -241,7 +241,7 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
self.assertEqual(result["finalizer_mode"], "pair")
self.assert_route(result, "build", basis, lane, grade)
def test_official_review_keeps_grade_and_fixes_execution_target(self) -> None:
def test_official_review_keeps_grade_without_fixing_execution_target(self) -> None:
for grade in range(1, 11):
with self.subTest(grade=grade):
result = fields(
@ -250,9 +250,11 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
self.assert_route(
result, "review", "official-review", "cloud", grade
)
self.assertEqual(result["review_adapter"], "codex")
self.assertEqual(result["review_model"], "gpt-5.6-sol")
self.assertEqual(result["review_reasoning_effort"], "xhigh")
self.assertEqual(
result["review_catalog_route"], f"review/cloud/G{grade:02d}"
)
self.assertNotIn("review_adapter", result)
self.assertNotIn("review_model", result)
def test_low_grade_cloud_requires_capability_gap_basis(self) -> None:
rejected = run(

View file

@ -264,6 +264,7 @@ common/rules.md와 내용이 중복되지 않도록 한다.
- [ ] `.gitignore``agent-test/local/``agent-test/runs/`가 추가되어 있는가
- [ ] `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore`에 Agent-Ops 관리 block이 있고 그 안에 `agent-task/archive/**``agent-roadmap/archive/**`가 포함되어 있는가
- [ ] `.claude/settings.json`, `opencode.json``agent-task/archive/**` 또는 `agent-roadmap/archive/**` hard read/glob deny가 남아 있지 않은가
- [ ] `rules/common``skills/common``__pycache__`, tool cache, `*.pyc`, `*.pyo`가 없고 초기화 복사에서도 제외됐는가
- [ ] 기존 archive hard deny가 있으면 init-agent-ops 표준에 맞게 제거했는가
- [ ] `.gitignore``agent-task/archive/**` 또는 `agent-roadmap/archive/**` ignore 항목을 추가하지 않았는가
- 검증 실패 시: 누락된 파일/항목을 사용자에게 알리고 해당 부분만 보완한다

View file

@ -1,295 +1,142 @@
---
name: orchestrate-agent-task-loop
description: Run agent-task work and autonomously execute active PLAN/CODE_REVIEW loops on request. Use when dispatching dependency-ready work in parallel by predecessor completion and workspace write claims, running lane/G-specific Codex, Claude, agy, and Pi workers, adding Pi self-checks, converging official Codex reviews, and escalating cloud context until the task loop finishes.
description: Execute dependency-ready PLAN and CODE_REVIEW task loops with workspace write claims, a runtime-injected agent/model catalog, deterministic target failover, and persistent recovery state.
---
# Orchestrate Agent Task Loop
## 🚨 ABSOLUTE PRIORITY — NEVER SEND `final` EXCEPT IN THE TWO CASES BELOW
## Final-channel gate
> [!CAUTION]
> **This section overrides every success, blocker, exit-code, error-handling, and termination rule below.**
>
> **Never send on the `final` channel or end the caller turn unless at least one of the two titled permissions below applies. Never infer another exception from a lower section or runtime condition.**
Do not end the caller turn through `final` until either:
### `final` Permission 1 — Verified Successful Completion
- every in-scope task has a verified archived `complete.log`, every generated work log is archived, no task or execution remains active, and the dispatcher exits `0`; or
- the user explicitly asks to stop the current run.
Allow `final` only after every condition below is true:
- Every user-defined completion condition is satisfied.
- Every observed task in every in-scope task group has a verified archived `complete.log`.
- Every generated `WORK_LOG.md` is archived as `work_log_N.log`.
- No active pair or running, pending, or blocked task remains.
- The final dispatcher exit code is `0`.
### `final` Permission 2 — Explicit User Instruction to Stop This Run
Allow `final` when the user explicitly instructs the caller to stop the current run and return through `final`.
### Persistent-Run Instructions Revoke Successful-Completion Permission
If the user says “do not stop,” “never send final,” “keep going,” or gives an equivalent persistent-run instruction, verified success alone does not permit `final`. Only an explicit user instruction to stop the current run or return through `final` releases this restriction.
### Every Other User-Visible Message Must Use `commentary`
Use only the `commentary` channel for every user-visible message before `final` is permitted. This includes status, partial success, completion candidates, blockers, failures, questions, apologies, waits, retries, and recovery guidance.
Partial success, FAIL/WARN, USER_REVIEW, a blocker, retry exhaustion, timeout, a tool error, plan-generation failure, dispatcher exit code `2` or `3`, child exit, loss of a session/cell, and context compaction never permit `final`.
Dispatcher stdout streamed directly by the execution layer is tool output, not a caller-authored message. Never spend an LLM turn restating, summarizing, or relaying a routine dispatcher event.
### Child Prompt Text Never Grants Caller `final` Permission
The prompt-contract phrase `Final in Korean.` controls only the child model response language. It never authorizes the caller to use the `final` channel.
Use `commentary` for non-terminal status, blockers, questions, recovery notices, and partial completion. If the user asked for a persistent run, successful completion alone does not release this gate.
## Purpose
Monitor the file-based state contract under `agent-task/` and converge the workflow from ready PLAN implementation through official code review and follow-up PLANs. Let the script determine filenames, dependencies, slots, and session locators; let each CLI agent make semantic implementation and review decisions.
Treat Korean text inside code spans or fenced examples as exact runtime or file-contract literals. Keep all surrounding instructions in English, and never translate those literals unless the runtime contract changes.
Monitor the file-backed workflow under `agent-task/` and converge ready PLAN implementation, optional self-check, official review, follow-up PLAN, and archive completion. The dispatcher owns deterministic scheduling, recovery, target transitions, and runtime evidence. Child agents own implementation and review judgments within their assigned artifact.
## Inputs
- `workspace`: Trusted repository root containing `agent-task/` (optional; defaults to the current directory).
- `task_group`: Name of a specific `agent-task/<task_group>` to run (optional).
- `dry_run`: Inspect state, routes, and dependencies without starting a CLI (optional).
- `max_parallel`: Non-negative integer cap on unique active task-stage attempts across the physical workspace. Omission defaults to `3`; explicit `0` is unlimited. `--task-group` does not narrow occupancy, adopted external attempts count, internal helper coroutines do not count separately, and an override must be supplied again after restart.
- `retry_blocked`: Explicitly retry the same PLAN blocked by a previous dispatcher run in non-dry-run mode (optional). With `task_group`, reset only that group's blockers and 10-attempt counters while preserving other group state.
- `workspace`: trusted repository root containing `agent-task/`; defaults to the current directory.
- `execution_catalog`: required runtime agent/model catalog path, supplied with `--execution-catalog` or `AGENT_TASK_EXECUTION_CATALOG`.
- `task_group`: optional `agent-task/<task_group>` scope.
- `dry_run`: inspect routes, dependencies, claims, and catalog validity without launching an agent.
- `max_parallel`: workspace-wide active task-stage limit; defaults to `3`; `0` means unlimited.
- `retry_blocked`: retry eligible blocked tasks without changing their catalog route history.
`--validate-plan` validates one PLAN without launching orchestration and therefore does not require an execution catalog.
## Preconditions
- [ ] Read the current state contracts in `agent-ops/skills/common/plan/SKILL.md` and `agent-ops/skills/common/code-review/SKILL.md`.
- [ ] Verify that `codex`, `claude`, `agy`, and `pi` are on PATH and their login/provider configuration is valid.
- [ ] Limit automatic approval to PLAN execution inside the current workspace; do not expand scope to external-system changes or destructive work.
- [ ] Verify that no other dispatcher is running in the same workspace. Never bypass a workspace-lock failure.
- [ ] Run `--dry-run` before the first live run to inspect active-task classification and dependency state.
- Read the current plan and code-review contracts routed by `agent-ops/skills/common/router.md`.
- Obtain the execution catalog from the runtime or project layer. Common owns no default agent, model, provider, or route catalog.
- Run `--dry-run` before the first live execution.
- Never bypass the physical-workspace dispatcher lock.
- Keep automatic approval inside the current workspace and the PLAN's declared write set.
## Routing Contract
## Runtime catalog contract
| PLAN route | Worker |
|---|---|
| `local-G01``local-G06` | Pi `iop/ornith:35b`, thinking high |
| `local-G07``local-G08` | KST `[07:00,23:00)` agy `Gemini 3.6 Flash (Medium)`; `[23:00,07:00)` Pi `iop/laguna-s:2.1` |
| `local-G09``local-G10` | Claude `claude-opus-4-8`, effort xhigh |
| `cloud-G01``cloud-G02` | agy `Gemini 3.6 Flash (Low)` |
| `cloud-G03``cloud-G04` | agy `Gemini 3.6 Flash (Medium)` |
| `cloud-G05``cloud-G06` | agy `Gemini 3.6 Flash (High)` |
| `cloud-G07``cloud-G08` | Claude `claude-opus-4-8`, effort xhigh |
| `cloud-G09``cloud-G10` | Codex `gpt-5.6-sol`, reasoning xhigh |
| Every `CODE_REVIEW-*` | Codex `gpt-5.6-sol`, reasoning xhigh |
The catalog root contains exactly `schema_version`, `targets`, and `routes`. It must cover `worker` and `review`, and each stage must define every `local-G01` through `local-G10` and `cloud-G01` through `cloud-G10` route.
Concurrency limits:
Each target has:
- Global physical-workspace limit: omitting `max_parallel` caps execution at `3`; explicit `max_parallel=0` is unlimited. A positive value caps unique active task-stage attempts and is not narrowed by `task_group`. The cap applies across worker, self-check, review, and verified external-active attempts in the same physical workspace.
- Pi `ornith:35b`: 3.
- agy: 1.
- Official Codex review: no separate review-only limit; subject to the global
cap.
- Run worker/self-check and official review in parallel only when they belong to different dependency-ready tasks and their canonical PLAN write sets do not collide in the current physical workspace. Prevent duplicate execution of the same task.
- Even with `complete.log`, treat an explicit predecessor as unfinished while live model/review execution evidence for that task remains. Delay only its consumers; do not propagate the delay to dependency-free siblings or other task groups.
- Run official reviews for different dependency-ready tasks with disjoint workspace claims in parallel.
- Before the first review batch, normalize the Agent-Ops-managed `.gitignore` block once so reviews do not concurrently modify the same shared control file.
- Require exactly one valid, non-empty `Modified Files Summary` (and legacy `수정 파일 요약`) in the active or recovery PLAN. Fail the task closed when any path is broad, outside the workspace, a directory, malformed, or missing.
- Atomically claim every canonical modified-file path before admitting worker, self-check, or review. A collision is a runtime wait, not a predecessor dependency. Retain the task's claim through every stage, retry, dispatcher restart, and follow-up PLAN; replace or expand its own claim only when the new set does not collide, and release it only after verifying the completed archive.
- Scope write claims to the canonical physical workspace. Separate worktrees and clones use independent state and may run in parallel; task-group filtering never narrows the claim ledger inside one workspace.
- an opaque `agent` identity;
- an opaque `model` identity;
- `execution_class`: `local_model` or `cloud_model`;
- optional `selfcheck_required` boolean;
- `runtime.command`: a non-empty argv template executed without a shell;
- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, and `auxiliary_logs`;
- optional `runtime.output_format`: `text` or `jsonl`.
## Prompt Contract
Command templates may use only `{agent}`, `{model}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, and `{prompt}`. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration.
Keep control prompts in English, insert absolute paths only, and do not expand these sentences unnecessarily.
Each route owns its ordered `candidates` plus optional `rule_id`, `policy_priority`, and `reason_codes`. A route may use catalog-owned `windows` instead of a fixed candidate list; every window supplies an IANA timezone, start/end time, and candidates. Exactly one window must match.
- A dispatcher child runs only while `AGENT_TASK_EXECUTION_ID` is present.
- Prefix every worker and review prompt with: `You are a child agent already launched by the dispatcher, not the orchestration caller. Execute only the assigned role directly. Do not start, monitor, or wait for orchestration through dispatch.py or orchestrate-agent-task-loop. You may run dispatch.py --validate-plan only when required by plan or code-review finalization because that mode validates one candidate PLAN without starting or monitoring orchestration.`
- Keep local self-check prompts short. Start them with: `Think in English. Final in Korean.`
Before work starts, the dispatcher:
- Cloud worker: `Read {PLAN_PATH} and complete the task. Keep artifact content in English. Final in Korean.`
- Pi worker: `Think in English. Keep artifact content in English. Final in Korean. Read {PLAN_PATH} and complete the task.`
- Pi self-check full pass: `Think in English. Final in Korean. Read {PLAN_PATH}; review all work once, fix omissions, and update {CODE_REVIEW_PATH}. Keep files in English.`
- Pi self-check unchecked-item retry: `Think in English. Final in Korean. Read {PLAN_PATH}; complete every unchecked implementation item and update {CODE_REVIEW_PATH}. Keep files in English.`
- Official review: `Read {CODE_REVIEW_PATH} and start the review. Keep artifact content in English. Final in Korean.`
- Review-exit recovery: `Continue the review for {TASK_PATH}. Keep artifact content in English. Final in Korean.`
- Context escalation: `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.`
1. loads and validates the entire catalog;
2. verifies exact route coverage and every target reference;
3. verifies each target command is executable;
4. runs an optional target `preflight_command` for live execution;
5. records the catalog source and SHA-256 revision in the decision.
Never ask a worker, self-check, or review model to create, edit, or summarize `WORK_LOG.md`.
A persisted decision is valid only while the injected catalog revision and selected target snapshot still match. Catalog changes fail closed instead of silently changing an active work unit.
Do not treat Pi self-check exit code `0` as success by itself. Set `selfcheck_done=true` only when `## Implementation Checklist` (or legacy `## 구현 체크리스트`) in `CODE_REVIEW_PATH` contains at least one Markdown list checkbox and every `[...]` checkbox value has at least one non-whitespace character. If both canonical and legacy checklist headings are present in the same file, fail closed. Accept any non-empty value, including `x`, `v`, and `✅`. Do not inspect `## Implementation Item Completion`, `Deviations from Plan`, `Key Design Decisions`, `Verification Results`, or final CODE_REVIEW synchronization text. Run the full self-check prompt exactly once. If its checklist condition fails, resume that successful pass's Pi native session and run the unchecked-item retry prompt up to 10 times. Each retry must resume the locator returned by the preceding successful pass so the same conversation context is preserved; never repeat the full review prompt or start a fresh retry session. Persist the latest successful context locator for dispatcher restart, and block instead of starting fresh when that context cannot be resumed. Block that task after the 10th unchecked-item retry remains incomplete, and continue draining independent work.
## Selection and failover
After an AGY/Gemini worker exits `0`, apply the same `CODE_REVIEW_PATH` implementation-checklist regex before accepting worker completion. If it is incomplete, run a fresh quota probe: only an `exhausted` target becomes `provider-quota` and enters the existing selector failover/promotion chain; `available` or `unknown` remains a completion-evidence recovery on Gemini.
- Initial execution selects the first candidate in the injected route.
- Resume pins the persisted target and route revision.
- The dispatcher never queries quota before admission and never accepts a quota snapshot as selector input.
- Classify actual terminal output after an attempt. `provider-quota`, `context-limit`, `model-unavailable`, `provider-stream-disconnect`, and `provider-connection` may advance to the next unused route candidate.
- In particular, a confirmed quota/rate-limit error advances directly to the next candidate. A plain mention of quota in source text, model prose, or non-terminal output is not sufficient evidence.
- `generic-error`, process termination, work-log failure, and review-control failure do not imply quota and do not change the selected target.
- Never use a hidden promotion table or provider-specific fallback. If no next catalog candidate exists, keep recovery within the stage budget or block the task with evidence.
- Transfer logical context using the prior locator, normalized output, raw stream, workspace, and PLAN. Use native resume only when both targets opt into the same catalog-declared native-session mechanism and the session belongs to the current workspace.
For Pi worker recovery attempts, pass only `Read {PLAN_PATH}. Continue.` without a locator explanation. Pi self-check recovery must preserve the current full-pass or unchecked-item role and use its concise prompt. For other CLI escalation attempts, pass `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.` Preserve the collaboration prohibition and next-state-materialization sentence in official-review escalation and recovery prompts. Do not ask the model to write a separate handoff summary.
## Scheduling and write claims
When recovering a KST-night `local-G07``local-G08` Laguna locator or a terminal `session-stall` locator left by an earlier dispatcher, first require the locator and native session to belong to the current physical workspace. Do not create a fresh session ID for an owned locator. Resume its native session file with `pi --session` and the existing `--session-dir`. For worker recovery pass `Think in English. Keep artifact content in English. Final in Korean. Continue this session and complete the current task.` For interrupted full self-check recovery pass `Think in English. Final in Korean. Continue. Keep files in English.` For an unchecked-item retry, pass its normal concise prompt while resuming the existing native session. After a dispatcher restart, find the owned locator and resume the same session. Count this same-session restart toward the same stage's 10-consecutive-failure limit.
- Admit every dependency-ready task whose canonical PLAN write set does not collide with another active claim.
- Require exactly one non-empty `Modified Files Summary` or supported legacy heading. Reject broad, malformed, directory, outside-workspace, or missing paths.
- Atomically claim canonical paths before worker, self-check, or review execution. Keep a task's claim across retries and follow-up PLANs; release it only after verified archive completion.
- Treat explicit predecessors as unfinished while matching live execution evidence exists, even if a `complete.log` is already visible.
- Apply `max_parallel` across the physical workspace, independent of task-group filtering. Do not count internal helper coroutines as agent slots.
- A blocker delays only that task and its dependency closure. Continue draining independent work.
## Work-Log Contract
## Prompt and child boundary
- Keep exactly one `agent-task/{task_group}/WORK_LOG.md` per task group. Do not create one in a split-subtask directory.
- Allow only the dispatcher to modify this file. Worker/self-check/review models need not read or update it, and success must not depend on its prose.
- Append chronological `START`/`FINISH` rows with time, task, loop, role, attempt, model, result, and locator. In `task`, record the active role artifact relative to `agent-task/`: the PLAN path for a worker and the CODE_REVIEW path for self-check/review. In `loop`, record the PLAN identity's zero-based `plan` number (`0` is the initial plan). Record time in KST (`UTC+09:00`) as `YY-MM-DD HH:MM:SS`, for example `26-07-26 07:40:15`. Use this single timeline to inspect parallel execution order.
- Do not require the common code-review skill to preserve `WORK_LOG.md`. For split work the group log normally remains in the parent because review moves only the selected subtask. For a single task review may move the log with the task archive; after review exits, resolve exactly one source from the active group path or verified completed archive and normalize it to `work_log_N.log`.
- After every observed task in a task group has a verified complete archive and no active/running task remains, append the final `FINISH` and move the generated `WORK_LOG.md` under the final completed archive's group root as `work_log_N.log`. If an archive exists after restart but the last `START` lacks `FINISH`, do not terminate or archive while any PID/start token, per-attempt process marker, or pidless stream/native evidence remains live. Track it until execution evidence has ended and the complete archive is verified, then append `FINISH` with `reconciled:verified-complete-archive` and move the log. Use `agent-task/archive/YYYY/MM/{task_group}/` for split tasks and the actual suffix-bearing archive destination for a single task. Set `N` to one more than the maximum suffix for the same task group across all months, starting at `0`.
- If `WORK_LOG.md` archiving fails or multiple active/archive sources exist, drain other independent work and return non-terminal exit `3` for retry. Return successful exit `0` only after a completed group that generated a log has no active `WORK_LOG.md` and its `work_log_N.log` is verified. Keep an incomplete group's `WORK_LOG.md` active for blocker or exit `3` recovery.
- Split each attempt locator into `stream.log` for model stdout/stderr and `heartbeat.log` for dispatcher state. Determine health only from the newest progress in `stream.log` and native session events; never use heartbeat mtime as progress evidence. Do not copy either log into `WORK_LOG.md`.
- Keep child stdout/stderr, normalized model output, and periodic heartbeat records in locator-owned logs only. The dispatcher's user-visible stdout is an event stream and must never mirror model stream lines or heartbeat ticks.
- If locator refresh temporarily fails after an attempt starts, do not terminate a live model process or start a duplicate task. Record a warning, keep monitoring, and preserve error evidence at the next successful refresh.
- After verifying a PASS archive's `complete.log` and confirming no live execution evidence for that task, delete all of its attempt directories, including locators, native sessions, `stream.log`, `heartbeat.log`, and CLI auxiliary logs. Do not delete them while a model process or conservatively active pidless stream/native evidence remains. Treat transient deletion failure as non-terminal exit `3` for the next reconciliation without blocking the completed task or other tasks; do not return successful exit `0` while any attempt directory remains. Preserve failed or blocked attempt logs as recovery evidence.
- Record log-creation or append failure in the locator as `work-log-setup` or `work-log-runtime-write` and block the task.
- Exclude dispatcher-authored `WORK_LOG.md` changes from official-review progress/stagnation signatures. Count only real changes in PLAN/CODE_REVIEW, review logs, and the write-set.
Prefix worker and review prompts with the dispatcher-child boundary that prohibits starting or monitoring another orchestration loop. A child may use `dispatch.py --validate-plan` only when its plan or review finalization requires it.
## Caller Lifecycle and Status Display
Prompts must include absolute artifact paths and instruct the child to follow the repository's language and output rules. Do not hardcode a programming language, human language, agent, model, or provider in common prompts.
- **ABSOLUTE RULE — Do not stop the whole task group when a task-local blocker appears.** Delay only the blocked task and consumers that require its incomplete result as a predecessor. Keep the caller turn active until every independent ready/running task finishes.
- **ABSOLUTE RULE — Scan the complete new-task candidate set only on initial dispatcher entry and immediately after creating a verified `complete.log`.** After a worker/self-check/review attempt ends or a task changes stage, reclassify only that task. After `complete.log` is created, immediately start every runnable task except currently running tasks in the same pass. Another task's execution, wait, dependency, review, or recovery state must not block a candidate. If no candidate or running task remains and only blockers and their dependent waits remain, exit with code `2`.
- Treat the dispatcher as the execution lifecycle and observation owner. It performs deterministic health checks, recovery, retries, routing, and state transitions without caller-LLM supervision. The caller owns only launch authorization, intervention after an attention event, and the `final` gate.
- Keep the caller turn suspended and launch the dispatcher as one persistent foreground execution. Use execution-layer event waiting or direct stdout streaming; never use an LLM-generated polling turn as a keepalive. Never start a duplicate dispatcher while the child is live.
- Never wrap the dispatcher in `timeout`, a short `wait_for`, or an arbitrary cancel/terminate wrapper. Tool yield or expiration of a response window is not process termination. Resume the same execution-layer wait without commentary, analysis, or inspection.
- **ABSOLUTE RULE — The caller never monitors.** During normal execution or event silence, do not run a timer loop, periodically poll through the model, or inspect `ps`, dispatcher `--dry-run`, `state.json`, locator files, `stream.log`, `heartbeat.log`, or `WORK_LOG.md`. A tool yield, empty wait, routine lifecycle event, or response-window expiration does not permit caller-LLM involvement.
- Stream routine lifecycle banners directly from dispatcher stdout to the user without routing them through the caller LLM. Routine events include starts, deterministic retries/recovery, waits, per-task review results, per-task completion while other work remains, and any event for which the dispatcher has already selected the next action.
- Wake the caller LLM only for an attention event that the dispatcher cannot resolve autonomously: a verified `USER_REVIEW` decision, an exhausted terminal blocker, an unrecoverable state/log contract error, loss of the execution handle that requires targeted recovery, or terminal dispatcher exit. A warning or automatic retry is not an attention event merely because it reports an error.
- No dispatcher output, an empty wait, or a wait-window expiration is normal event silence. It never permits `final`, caller termination, a duplicate dispatcher, a state inspection, or a model wake-up. Keep the execution-layer wait attached with the longest supported window.
- A lost session/cell exists only when the execution layer reports the tracked identifier unavailable or aborted, or reports the child process exited; a normal wait return alone is insufficient. Then perform exactly one reinspection of active tasks, locators, PIDs, and state. If that snapshot proves a live dispatcher owner, do not inspect it again until an attention event is observed. Resume event waiting from the same session/cell when available; otherwise subscribe from EOF to only newly appended START/FINISH rows in the task-group WORK_LOG.md. If the fallback observer itself ends without an event while the dispatcher remains live, reattach the same EOF-only observer without reading any prior row or inspecting state. A routine START/FINISH row or direct output only confirms the subscription and does not permit model wake-up or state inspection. Only a dispatcher exit, explicit attention event, fallback-observer error, or explicit user request permits the next targeted inspection. Exit code `0` is successful terminal state. Exit code `2` is a drained blocker or explicit persistent-state-error terminal state. Exit code `3` is a non-terminal tracking state, including another dispatcher workspace lock, a live external agent, or an unexpected dispatcher interruption; inspect PID, locator, and state only after that event.
- On a scheduler/control-plane exception or unexpected exception in an individual agent coroutine, do not immediately freeze it as a task blocker or let the dispatcher event loop cancel other running agents and child processes. Monitor every independent running agent until natural completion, return non-terminal exit `3`, and let the next dispatcher reconcile file and state results. Even when the original exception is a persistent-state error, do not convert it to exit `2` if any agent was running.
- In drained-blocker terminal state, persist the orchestration group as `blocked`, directly blocked tasks as `blocked`, consumers waiting on their predecessors as `waiting`, and verified independent completed tasks as `complete` in `.git/agent-task-dispatcher/state.json`. On re-entry, set incomplete observed tasks back to orchestration state `active`, then reevaluate actual task-local blockers and dependencies.
- Persist observed tasks and the complete same-name archive baseline present at startup, regardless of `complete.log`, in `.git/agent-task-dispatcher/state.json`. If an active task disappears after child restart, recover completion only when exactly one new `complete.log` archive absent from the baseline exists; block when none or multiple exist. Do not count a late `complete.log` added to an incomplete archive that existed before execution as current-run completion.
- 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 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 the initial Pi full self-check plus 10 same-context unchecked-item retries leave the implementation checklist incomplete, or official review makes no change 10 consecutive times.
- While one task recovers or becomes blocked, continue every ready/running task that neither requires it as a predecessor nor collides with its retained workspace claim. Internal recovery or blocking must not trigger an arbitrary complete-candidate rescan.
- If review shared-state preflight fails, block only ready review tasks and still start every worker/self-check with a disjoint claim in the same pass. The complete scan after `complete.log` must preserve the existing snapshot rather than reread already running task directories, avoiding races with parallel archive moves that could stop another process.
- For KST-night `local-G07``local-G08` Laguna locator `context-limit`/`session-stall`, prefer the Prompt Contract's same-session resume and display `Pi세션연속재시작`. Use a fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery.
- Do not stop for user review based on filename alone. Recognize a `user-review` terminal blocker only when the active task's `USER_REVIEW.md` contains `상태: USER_REVIEW`, exactly one supported type, a concrete target, non-`없음`/`미정` blocker rationale, unresolved user actions or decisions, and resume conditions that prevent the next safe implementation step. For `milestone-lock`, require a real `agent-roadmap/**/milestones/*.md` target. For `external-execution`, require an exact runner/device/service/access target and evidence that no authorized automatic executor can perform the required verification. If the form is incomplete or conflicts with active PLAN/CODE_REVIEW, block it as a task-state contract error instead.
- Recognize `## Code Review Result` (with `Overall Verdict: PASS|WARN|FAIL`) or legacy `## 코드리뷰 결과` (with `종합 판정: PASS|WARN|FAIL`) as the review verdict. If both canonical and legacy headings are present in the same file, fail closed. Never parse the same string in implementation evidence, command output, or example text as the runtime verdict.
- Locator/raw logs under `.git/agent-task-dispatcher/runs/` are internal recovery state and may not appear in the normal project tree. Include the `locator=` path emitted when the dispatcher starts an attempt and the task-group `WORK_LOG.md` path in status updates.
- If a specified `task_group` has neither an observed active task nor a persisted completed task, return state error `unobserved-task-group` with exit code `2`; never treat it as empty completion.
- If child failure is recoverable inside the repository, continue within the 10-attempt budget. After draining independent work, report a blocker that the caller cannot clear in the current turn—such as exhausted budget, required user decision, or external permission—with its path, evidence, and resume condition.
Never ask a child to create, edit, or summarize `WORK_LOG.md`; that file is dispatcher-owned.
## Failure Classification and Reporting Contract
## Self-check
- Record dispatcher PID, actual agent PID, import time, source path, import-time SHA-256, attempt-start current SHA-256, and `dispatcher_source_matches_loaded` in every attempt locator. Every failure banner and subsequent status must present the locator's exact `failure_class`, `failure_source`, `provider_transport_failure_confirmed`, `dispatcher_pid`, `agent_pid`, `dispatcher_source_sha256`, source-match state, and `locator`; never summarize them into a broader cause.
- A running Python dispatcher does not hot-reload source edits. If `dispatcher_source_matches_loaded=false`, do not claim that new rules are active. Report the loaded/current hashes and execution-version difference until the process-owning session can safely exit and restart.
- Use `provider-connection` or `provider-stream-disconnect` only when original CLI terminal diagnostics contain a strong provider pattern in provider/backend/SSE context. Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr. For a confirmed attempt, preserve `failure_source=provider-terminal-diagnostic`, `provider_transport_failure_confirmed=true`, `failure_evidence_source`, and `failure_evidence_excerpt` in the locator.
- Treat legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure. During recovery, report `failure_source=dispatcher-timeout`, `provider_transport_failure_confirmed=false`, `termination_initiator=dispatcher`, and the original timeout phase/seconds. Never let the current dispatcher create a new silence timeout.
- Record a SIGTERM-family termination not initiated by the dispatcher as `process-terminated`, with `failure_source=process-termination` and `termination_initiator=unknown`. Never classify exit code `143` as provider failure without actual provider terminal evidence.
- Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage. Describe a system-level provider outage only with additional controlled reproduction using the same command, model, and prompt, or backend-health evidence.
- Count `process-terminated` in the same per-stage consecutive-failure budget as other automatic-recovery classes. On the 10th consecutive failure, block that task; never reset the budget after cooldown or auto-resume. A shared budget does not imply common causation or establish provider-failure evidence.
Run self-check only when the selected catalog target declares `selfcheck_required=true`. The completing decision, not a fixed agent identity or execution class, determines the requirement.
## Procedure
Accept self-check completion only when `## Implementation Checklist` or its supported legacy heading contains at least one checkbox and every checkbox has a non-empty value. Run one full pass, then resume the latest successful native context for at most 10 unchecked-item retries when the target supports native resume. Block instead of silently starting a new context when a required persisted context is unavailable.
1. **Inspect state.**
- Print active tasks, routes, stages, and dependencies:
## Runtime evidence and recovery
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --dry-run
```
- Store each attempt under the dispatcher state directory with `locator.json`, `stream.log`, `normalized-output.log`, and `heartbeat.log`.
- Record the target id, opaque agent/model identity, execution class, runtime contract, catalog evidence, process identity, workspace identity, timestamps, result, and exact failure evidence.
- Treat stderr as terminal diagnostic evidence. For JSONL, recognize generic terminal event fields such as error/fatal type or severity, rejected/failed status with an error code, and explicit error flags.
- Determine liveness from PID/start-token/process-marker evidence and actual stream or native-session progress. Heartbeat mtime is never agent progress.
- Never start a duplicate attempt while owned live evidence remains.
- Keep a 10-consecutive-failure budget per task stage. Reset only that stage's budget after success.
- Preserve failed attempt logs. Delete successful attempt logs only after verified archive completion and no live evidence.
- Treat `NN_...` as immediately eligible. Treat `NN+PP[,QQ...]_...` as eligible only after each predecessor's `complete.log` is found once in the active or narrow archive lookup for the same task group and predecessor execution evidence has ended.
- Never infer an implicit dependency from numeric order alone.
## Work log
2. **Run the dispatcher.**
- Run all active tasks with the default physical-workspace cap of `3`:
- Keep one dispatcher-owned `WORK_LOG.md` per task group.
- Append chronological `START` and `FINISH` rows with UTC time, task artifact, plan loop, role, attempt, selected agent/model display, result, and locator.
- Archive the group log as the next `work_log_N.log` only after every observed task in the group is verified complete and idle.
- Work-log write or archive failure is a retryable control-plane failure and prevents exit `0`.
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py
```
## Invocation
- Run one task group:
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --task-group <task_group>
```
- Cap total concurrent attempts across the physical workspace:
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --max-parallel 2
```
- Explicitly disable the cap:
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --max-parallel 0
```
- Preview classification without launching CLIs under the same cap:
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --dry-run --max-parallel 2
```
- If a worker/self-check/review future ends without `complete.log`, reread only that task and run its next stage. Do not rescan the complete candidate set.
- Persist `active_stage` for a running task. After dispatcher restart, exclude that task from candidates, restore or conservatively adopt its workspace write claim, and immediately dispatch every other dependency-ready task whose claim does not collide.
- **ABSOLUTE RULE:** Scan the complete candidate set only at initial entry and immediately after creating a verified `complete.log`. In that scan, exclude tasks shown as running by current-workspace state and native session/locator evidence, then atomically admit every dependency-ready task with a non-colliding write claim. An unmet dependency or write collision excludes only that task. Exit instead of polling when no candidate remains.
- Persist Pi worker success, Pi self-check success, and official review as separate stages. If restart state is `worker_done=true` and `selfcheck_done=false`, resume on the same Pi model, not with worker or review. Run the full pass when `selfcheck_incomplete=0`; otherwise resume the persisted successful self-check context locator with an unchecked-item retry. Never replace a missing or invalid persisted context with a fresh session.
- Key persistent state to the first-line `task/plan/tag` generation and, for `m-*`, its `milestone-task` scope. Checklist/body edits to the same PLAN do not reset the stage; a new plan number or changed Milestone Task scope does.
- Send an already completed review stub with no dispatcher execution record to review. Never send dispatcher-recorded Pi worker success to review before self-check completes.
- Start official review and worker/self-check together when they belong to different dependency-ready tasks with disjoint workspace claims. Wait for a claim owner to reach verified completion before admitting a colliding task.
- Let the dispatcher record every worker/self-check/review attempt start and finish in the task-group `WORK_LOG.md`.
- Archive `WORK_LOG.md` as `work_log_N.log` only after the final task review process exits, the dispatcher appends `FINISH`, and a complete scan finds no active/running task in that group. Accept the log at either the active group path or the verified completed single-task archive; do not impose either location contract on common plan/code-review.
3. **Escalate and recover context.**
- Escalate `agy -> Claude -> Codex` or `Claude -> Codex` only on terminal provider error events or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For Claude, classify a `rate_limit_event` with `rate_limit_info.status=rejected`, an error `result` with `api_error_status=429` or `error=rate_limit`, or a `You've hit your session limit · resets ...` terminal diagnostic as `provider-quota`. Never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log.
- Target Codex `gpt-5.6-terra` with reasoning `high` when escalating from Claude to Codex.
- If Codex returns the same error, retry in a fresh Codex session using the locator while preserving the previous Codex model/reasoning and sharing the same stage's 10-consecutive-failure limit. Continue dispatching other tasks during recovery.
- When current source reads a locator blocked 10 times as `generic-error` by older dispatcher source, collapse those 10 failures into one terminal error and clear only that task's blocker only if all 10 terminal-evidence records for the same task/plan/role/source/execution target reclassify to the same escalatable error. Include `stream.log` and the attempt's `agy-cli.log` for AGY. Do not adjust automatically when any history is missing or mixed, or when the locator dispatcher source hash equals the current source hash. Dry-run must display this escalation recovery and next model without writing state. Live execution must choose the higher target from the locator's actual failed target, not the initial PLAN route, inherit locator context, and restore the same escalation target and locator from persisted reclassification metadata after immediate restart.
- Recover timeout, crash, process termination, permission, and ordinary implementation errors on the same target within the same stage's 10-consecutive-failure limit, preserving the actual failure class and locator. At exhaustion, block only that task and keep dispatching independent work.
- On success after escalation, record `worker_cli` and `worker_model` from the successful locator's actual target, not the initial PLAN route.
- Never escalate Pi to a cloud model.
- Use attempt identity `<task-name>__p<plan>__<role>__aNN` and namespace the process marker with the physical workspace id. Record canonical workspace root/id, CLI/model/reasoning effort, PLAN/review, `WORK_LOG.md`, session ID, native session path, and raw output log in the locator.
- Store locators under repository `.git/agent-task-dispatcher/runs/`. Fall back to `${XDG_STATE_HOME}/agent-task-dispatcher/<workspace-id>/runs/` only when `.git` state is unwritable.
4. **Converge review.**
- Run every official review in an independent Codex one-shot session with no separate numeric limit. Dispatch all ready reviews with disjoint workspace claims in parallel.
- For finalization recovery without an active PLAN, recover the review target and write claim from the archived plan log for the same first-line generation metadata, including `milestone-task` when present. Keep the claim until the completed archive is verified.
- Forbid collaboration/sub-agent tools in official review and finish inside the current one-shot session. If such a tool call appears, clean up that attempt's independent subprocess group and retry in a fresh review session. Count the failure toward the same stage's 10-consecutive-failure limit.
- Delegate PASS archive, WARN/FAIL follow-up pairs, and review-finalization recovery to the `code-review` file contract.
- Reclassify any remaining active pair and send it to worker or review.
- Declare stagnation only when the plan write-set source snapshot and review/finding artifacts are all unchanged. Display `루프정체경고` and retry with backoff; on the 10th unchanged attempt, block that task as `review-no-progress-limit`.
- Record a verified `USER_REVIEW.md`, dependency ambiguity, 10 repeated failures, or work-log setup/runtime-write failure only as that task's blocker. Delay only the blocker and consumers that depend on it; continue every independent ready/running task. Return drained terminal blocker exit code `2` only when no independent work remains.
## Verification Checklist
- [ ] Scan the complete candidate set only on initial entry and immediately after verified `complete.log`; atomically claim and start every non-running, dependency-ready, non-colliding candidate in the same pass.
- [ ] Confirm the actual CLI/model for each route matches the routing table.
- [ ] Run exactly one full fresh-session self-check only for Pi work, followed by at most 10 unchecked-item retries in that same Pi native session context when its checklist remains incomplete.
- [ ] Run every official review with Codex `gpt-5.6-sol` xhigh and dispatch dependency-ready reviews with disjoint workspace claims in parallel, subject to the global `--max-parallel` cap (no separate review-only limit).
- [ ] Locate the native session and output log for every attempt locator.
- [ ] Record every worker/self-check/review attempt `START`/`FINISH` in one task-group `WORK_LOG.md`.
- [ ] For every completed task group that generated `WORK_LOG.md`, archive a `work_log_N.log` containing the final review `FINISH` and leave no active `WORK_LOG.md`.
- [ ] Verify that a PASS task is archived and each newly released dependent task starts.
- [ ] For success, verify every task's `complete.log`. For blocker exit, verify that no ready/running task remains and only task-local blockers and their dependent waits remain.
- [ ] Verify dispatcher stdout contains lifecycle/attention events only; raw child output and heartbeat ticks remain in locator-owned logs and never require caller-LLM relay.
- [ ] On blocking, output the task, reason, and locator.
- If verification fails, stop the dispatcher and report only the cause without manually moving or overwriting active PLAN/CODE_REVIEW files.
## Output Format
```text
------------------------------------------
작업시작: 03+01_event_contract_unit_tests
------------------------------------------
model=pi/iop/ornith:35b
plan=/absolute/path/PLAN-local-G05.md
work_log=/absolute/path/WORK_LOG.md
------------------------------------------
리뷰시작: 03+01_event_contract_unit_tests
------------------------------------------
model=codex/gpt-5.6-sol xhigh
review=/absolute/path/CODE_REVIEW-local-G05.md
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py \
--workspace /absolute/repository \
--execution-catalog /runtime/config/execution-catalog.json \
--dry-run
```
Use the same separator format for `작업대기`, `작업수행중`, `자가검증시작`, `로그보완재시도`, `모델승격`, `리뷰결과`, `루프정체경고`, `작업차단`, `작업로그아카이브`, and `작업완료`.
Remove `--dry-run` to start execution. Add `--task-group <name>`, `--max-parallel <n>`, or `--retry-blocked` only when requested by the workflow.
## Prohibitions
Launch the live dispatcher as one persistent foreground process. Do not wrap it in an arbitrary timeout and do not start a second dispatcher after a normal tool yield. Wait on the same execution handle until an attention event or terminal exit.
- Never print periodic heartbeat ticks or child model stdout/stderr to dispatcher stdout. Preserve them only in locator-owned logs.
- Never reevaluate PLAN/CODE_REVIEW lane or G in the dispatcher or rename those files.
- Never infer dependency from numeric order when no predecessor index is present.
- Never scan the complete archive or read archive files outside dependency candidates.
- Never ask a worker to perform official review, archive work, or create `complete.log`.
- Never treat Pi self-check as official review.
- Never depend on a model-authored handoff summary for context recovery.
- Never treat a generic failure as token/quota failure and escalate it to a higher model.
- Never resolve `USER_REVIEW.md` automatically or guess a user decision.
## Completion checklist
- [ ] Catalog was injected, fully validated, preflighted, and revision-pinned.
- [ ] No fixed common agent/model/provider route or quota probe was used.
- [ ] Runtime quota errors moved only to the next catalog candidate.
- [ ] Dependencies, write claims, and workspace concurrency were enforced.
- [ ] Required self-check and official review stages completed.
- [ ] Every observed task has a verified archived `complete.log`.
- [ ] Work logs and successful attempt cleanup were reconciled.
- [ ] No active, waiting, pending, or blocked in-scope task remains.
- [ ] Dispatcher exited `0` before successful final response.

View file

@ -1,4 +1,4 @@
interface:
display_name: "Agent Task Loop Orchestrator"
short_description: "Orchestrate PLAN execution and Codex review loops"
short_description: "Orchestrate PLAN and review loops with an injected runtime catalog"
default_prompt: "Use $orchestrate-agent-task-loop to execute the active agent-task workflow."

View file

@ -1,125 +1,350 @@
#!/usr/bin/env python3
"""Pure execution-target policy for Agent Task worker and review stages."""
"""Runtime-injected execution-target catalog and route policy.
This common module intentionally owns no agent or model catalog. A caller
supplies a JSON catalog at runtime; this module validates it and resolves one
ordered route without interpreting provider-specific identities.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from datetime import datetime, time
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
KST = ZoneInfo("Asia/Seoul")
CATALOG_SCHEMA_VERSION = "1.0"
VALID_STAGES = {"worker", "review"}
VALID_LANES = {"local", "cloud"}
VALID_EXECUTION_CLASSES = {"local_model", "cloud_model"}
VALID_OUTPUT_FORMATS = {"jsonl", "text"}
ALLOWED_TEMPLATE_FIELDS = {
"agent",
"attempt_dir",
"model",
"prompt",
"resume_session",
"session_id",
"target_id",
"workspace",
}
class CatalogError(ValueError):
"""The injected execution catalog is missing or malformed."""
@dataclass(frozen=True)
class RouteTarget:
adapter: str
target: str
catalog_id: str
agent: str
model: str
execution_class: str
selfcheck_required: bool
runtime: dict[str, Any]
@dataclass(frozen=True)
class ExecutionTargetCatalog:
source: Path
revision: str
targets: dict[str, RouteTarget]
routes: dict[str, dict[str, dict[str, Any]]]
@dataclass(frozen=True)
class PolicyDecision:
route_id: str
rule_id: str
policy_priority: int
reason_codes: tuple[str, ...]
time_window: str
catalog_revision: str
candidates: tuple[RouteTarget, ...]
PI_ORNITH = RouteTarget("pi", "iop/ornith:35b", "local_model", True)
AGY_GEMINI_LOW = RouteTarget(
"agy", "Gemini 3.6 Flash (Low)", "cloud_model", False
)
AGY_GEMINI_MEDIUM = RouteTarget(
"agy", "Gemini 3.6 Flash (Medium)", "cloud_model", False
)
AGY_GEMINI_HIGH = RouteTarget(
"agy", "Gemini 3.6 Flash (High)", "cloud_model", False
)
PI_LAGUNA = RouteTarget("pi", "iop/laguna-s:2.1", "local_model", True)
CLAUDE_OPUS = RouteTarget("claude", "claude-opus-4-8", "cloud_model", False)
CLAUDE_HAIKU_XHIGH = RouteTarget(
"claude", "claude-haiku-4-5", "cloud_model", False
)
CODEX_SPARK_XHIGH = RouteTarget(
"codex", "gpt-5.3-codex-spark", "cloud_model", False
)
CODEX_SOL_XHIGH = RouteTarget("codex", "gpt-5.6-sol", "cloud_model", False)
CODEX_TERRA_HIGH = RouteTarget("codex", "gpt-5.6-terra", "cloud_model", False)
def _require_string(value: object, label: str) -> str:
if not isinstance(value, str) or not value:
raise CatalogError(f"{label} must be a non-empty string")
return value
CANONICAL_TARGETS = (
PI_ORNITH,
AGY_GEMINI_LOW,
AGY_GEMINI_MEDIUM,
AGY_GEMINI_HIGH,
PI_LAGUNA,
CLAUDE_OPUS,
CLAUDE_HAIKU_XHIGH,
CODEX_SPARK_XHIGH,
CODEX_SOL_XHIGH,
CODEX_TERRA_HIGH,
)
def _validate_template(parts: object, label: str) -> tuple[str, ...]:
if not isinstance(parts, list) or not parts:
raise CatalogError(f"{label} must be a non-empty string list")
if not all(isinstance(part, str) and part for part in parts):
raise CatalogError(f"{label} must contain only non-empty strings")
for part in parts:
offset = 0
while True:
start = part.find("{", offset)
if start < 0:
break
end = part.find("}", start + 1)
if end < 0:
raise CatalogError(f"{label} contains an unmatched '{{': {part!r}")
field = part[start + 1 : end]
if field not in ALLOWED_TEMPLATE_FIELDS:
raise CatalogError(
f"{label} uses unsupported template field {field!r}"
)
offset = end + 1
return tuple(parts)
def canonical_target(adapter: str, target: str) -> RouteTarget | None:
"""Resolve one policy-owned adapter + target identity."""
return next(
(
candidate
for candidate in CANONICAL_TARGETS
if candidate.adapter == adapter and candidate.target == target
),
None,
def _validate_runtime(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
unknown = set(value) - {
"command",
"resume_command",
"preflight_command",
"environment",
"output_format",
"session_path",
"native_session_monitor",
"auxiliary_logs",
}
if unknown:
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
command = list(_validate_template(value.get("command"), f"{label}.command"))
if "{" in command[0] or "}" in command[0]:
raise CatalogError(f"{label}.command executable must be a literal path or name")
runtime: dict[str, Any] = {
"command": command,
"output_format": value.get("output_format", "text"),
}
if runtime["output_format"] not in VALID_OUTPUT_FORMATS:
raise CatalogError(
f"{label}.output_format must be one of {sorted(VALID_OUTPUT_FORMATS)}"
)
for field in ("resume_command", "preflight_command"):
if field in value:
template = list(
_validate_template(value[field], f"{label}.{field}")
)
if "{" in template[0] or "}" in template[0]:
raise CatalogError(
f"{label}.{field} executable must be a literal path or name"
)
runtime[field] = template
environment = value.get("environment", {})
if not isinstance(environment, dict) or not all(
isinstance(key, str)
and key
and isinstance(item, str)
for key, item in environment.items()
):
raise CatalogError(f"{label}.environment must be a string map")
runtime["environment"] = dict(environment)
session_path = value.get("session_path")
if session_path is not None:
runtime["session_path"] = _require_string(
session_path, f"{label}.session_path"
)
_validate_template([session_path], f"{label}.session_path")
monitor = value.get("native_session_monitor", False)
if not isinstance(monitor, bool):
raise CatalogError(f"{label}.native_session_monitor must be a boolean")
runtime["native_session_monitor"] = monitor
auxiliary_logs = value.get("auxiliary_logs", [])
if not isinstance(auxiliary_logs, list) or not all(
isinstance(item, str) and item for item in auxiliary_logs
):
raise CatalogError(f"{label}.auxiliary_logs must be a string list")
for index, item in enumerate(auxiliary_logs):
_validate_template([item], f"{label}.auxiliary_logs[{index}]")
runtime["auxiliary_logs"] = list(auxiliary_logs)
return runtime
def _validate_target(target_id: str, value: object) -> RouteTarget:
label = f"targets.{target_id}"
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
unknown = set(value) - {
"agent",
"model",
"execution_class",
"selfcheck_required",
"runtime",
}
if unknown:
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
execution_class = value.get("execution_class")
if execution_class not in VALID_EXECUTION_CLASSES:
raise CatalogError(
f"{label}.execution_class must be one of "
f"{sorted(VALID_EXECUTION_CLASSES)}"
)
selfcheck_required = value.get("selfcheck_required", False)
if not isinstance(selfcheck_required, bool):
raise CatalogError(f"{label}.selfcheck_required must be a boolean")
return RouteTarget(
catalog_id=target_id,
agent=_require_string(value.get("agent"), f"{label}.agent"),
model=_require_string(value.get("model"), f"{label}.model"),
execution_class=execution_class,
selfcheck_required=selfcheck_required,
runtime=_validate_runtime(value.get("runtime"), f"{label}.runtime"),
)
def promotion_target(current: RouteTarget) -> RouteTarget | None:
"""Return the next target in the policy-owned cloud promotion chain."""
if current.adapter == "agy" and current in {
AGY_GEMINI_LOW,
AGY_GEMINI_MEDIUM,
AGY_GEMINI_HIGH,
}:
return CLAUDE_OPUS
if current == CLAUDE_OPUS:
return CODEX_TERRA_HIGH
return None
def _validate_window(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
required = {"timezone", "start", "end", "candidates"}
missing = required - set(value)
if missing:
raise CatalogError(f"{label} missing keys: {sorted(missing)}")
timezone_name = _require_string(value["timezone"], f"{label}.timezone")
try:
ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise CatalogError(f"{label}.timezone is unknown: {timezone_name}") from exc
for field in ("start", "end"):
raw = _require_string(value[field], f"{label}.{field}")
try:
time.fromisoformat(raw)
except ValueError as exc:
raise CatalogError(f"{label}.{field} must be HH:MM[:SS]") from exc
return dict(value)
@dataclass(frozen=True)
class QuotaProbeSpec:
command: str
target: str
required_caps: tuple[str, ...]
def quota_probe_spec(target: RouteTarget) -> QuotaProbeSpec | None:
"""Return the policy-owned quota probe spec for a route target."""
if target.execution_class == "local_model":
return None
if target.adapter == "agy":
return QuotaProbeSpec(
command="agy",
target=target.target,
required_caps=("overall", f"model:{target.target}"),
def _validate_route(
value: object,
label: str,
target_ids: set[str],
) -> dict[str, Any]:
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
unknown = set(value) - {
"candidates",
"rule_id",
"policy_priority",
"reason_codes",
"windows",
}
if unknown:
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
candidates = value.get("candidates")
windows = value.get("windows")
if (candidates is None) == (windows is None):
raise CatalogError(
f"{label} must define exactly one of candidates or windows"
)
if target.adapter in {"claude", "codex"}:
return QuotaProbeSpec(
command=target.adapter,
target=target.target,
required_caps=("overall",),
normalized = dict(value)
if windows is not None:
if not isinstance(windows, list) or not windows:
raise CatalogError(f"{label}.windows must be a non-empty list")
normalized["windows"] = [
_validate_window(item, f"{label}.windows[{index}]")
for index, item in enumerate(windows)
]
candidate_lists = [item["candidates"] for item in normalized["windows"]]
else:
candidate_lists = [candidates]
for index, candidate_list in enumerate(candidate_lists):
item_label = f"{label}.candidates[{index}]"
if not isinstance(candidate_list, list) or not candidate_list:
raise CatalogError(f"{item_label} must be a non-empty list")
if len(candidate_list) != len(set(candidate_list)):
raise CatalogError(f"{item_label} must not contain duplicates")
unknown_targets = [item for item in candidate_list if item not in target_ids]
if unknown_targets:
raise CatalogError(
f"{item_label} references unknown targets: {unknown_targets}"
)
priority = value.get("policy_priority", 0)
if isinstance(priority, bool) or not isinstance(priority, int):
raise CatalogError(f"{label}.policy_priority must be an integer")
reasons = value.get("reason_codes", [])
if not isinstance(reasons, list) or not all(
isinstance(item, str) and item for item in reasons
):
raise CatalogError(f"{label}.reason_codes must be a string list")
return normalized
def load_catalog(path: str | Path) -> ExecutionTargetCatalog:
source = Path(path).expanduser().resolve()
try:
raw = source.read_bytes()
except OSError as exc:
raise CatalogError(f"execution catalog is unreadable: {source}: {exc}") from exc
try:
value = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CatalogError(f"execution catalog is not valid UTF-8 JSON: {source}") from exc
if not isinstance(value, dict):
raise CatalogError("execution catalog root must be an object")
if set(value) != {"schema_version", "targets", "routes"}:
raise CatalogError(
"execution catalog root must contain exactly schema_version, targets, routes"
)
return None
if value["schema_version"] != CATALOG_SCHEMA_VERSION:
raise CatalogError(
f"execution catalog schema_version must be {CATALOG_SCHEMA_VERSION!r}"
)
raw_targets = value["targets"]
if not isinstance(raw_targets, dict) or not raw_targets:
raise CatalogError("execution catalog targets must be a non-empty object")
targets = {
_require_string(target_id, "target id"): _validate_target(target_id, item)
for target_id, item in raw_targets.items()
}
raw_routes = value["routes"]
if not isinstance(raw_routes, dict) or set(raw_routes) != VALID_STAGES:
raise CatalogError(
f"execution catalog routes must contain exactly {sorted(VALID_STAGES)}"
)
routes: dict[str, dict[str, dict[str, Any]]] = {}
required_route_ids = {
f"{lane}-G{grade:02d}"
for lane in VALID_LANES
for grade in range(1, 11)
}
for stage in sorted(VALID_STAGES):
stage_routes = raw_routes[stage]
if not isinstance(stage_routes, dict) or set(stage_routes) != required_route_ids:
missing = sorted(required_route_ids - set(stage_routes or {}))
extra = sorted(set(stage_routes or {}) - required_route_ids)
raise CatalogError(
f"routes.{stage} must cover local/cloud G01..G10 exactly; "
f"missing={missing}, extra={extra}"
)
routes[stage] = {
route_id: _validate_route(
route, f"routes.{stage}.{route_id}", set(targets)
)
for route_id, route in stage_routes.items()
}
revision = hashlib.sha256(raw).hexdigest()
return ExecutionTargetCatalog(source, revision, targets, routes)
def _validate(stage: str, lane: str, grade: int, evaluated_at: datetime) -> None:
def canonical_target(catalog: ExecutionTargetCatalog, target_id: str) -> RouteTarget | None:
return catalog.targets.get(target_id)
def _window_matches(window: dict[str, Any], evaluated_at: datetime) -> bool:
local_time = evaluated_at.astimezone(ZoneInfo(window["timezone"])).time()
start = time.fromisoformat(window["start"])
end = time.fromisoformat(window["end"])
return start <= local_time < end if start < end else local_time >= start or local_time < end
def select_policy(
*,
catalog: ExecutionTargetCatalog,
stage: str,
lane: str,
grade: int,
evaluated_at: datetime,
) -> PolicyDecision:
if stage not in VALID_STAGES:
raise ValueError(f"unsupported stage: {stage}")
if lane not in VALID_LANES:
@ -128,93 +353,27 @@ def _validate(stage: str, lane: str, grade: int, evaluated_at: datetime) -> None
raise ValueError(f"grade must be in G01..G10: {grade}")
if evaluated_at.tzinfo is None or evaluated_at.utcoffset() is None:
raise ValueError("evaluated_at must be timezone-aware")
def _kst_time_window(evaluated_at: datetime) -> str:
kst_time = evaluated_at.astimezone(KST).time()
if 7 <= kst_time.hour < 23:
return "kst-day-[07:00,23:00)"
return "kst-night-[23:00,07:00)"
def select_policy(
*, stage: str, lane: str, grade: int, evaluated_at: datetime
) -> PolicyDecision:
"""Return the ordered target policy for one initial route evaluation."""
_validate(stage, lane, grade, evaluated_at)
if stage == "review":
return PolicyDecision(
rule_id="official-review-codex",
policy_priority=10,
reason_codes=("official_review_fixed",),
time_window="not_applicable",
candidates=(CODEX_SOL_XHIGH,),
)
if lane == "local":
if grade <= 6:
return PolicyDecision(
rule_id="worker-local-g01-g06",
policy_priority=30,
reason_codes=("local_low_grade",),
time_window="not_applicable",
candidates=(PI_ORNITH,),
route_id = f"{lane}-G{grade:02d}"
route = catalog.routes[stage][route_id]
selected_route = route
time_window = "not_applicable"
if "windows" in route:
matches = [item for item in route["windows"] if _window_matches(item, evaluated_at)]
if len(matches) != 1:
raise CatalogError(
f"routes.{stage}.{route_id}.windows must match exactly once; matches={len(matches)}"
)
if grade <= 8:
time_window = _kst_time_window(evaluated_at)
if time_window == "kst-day-[07:00,23:00)":
rule_id = "worker-local-g07-g08-kst-day"
reason_code = "kst_day_gemini_medium"
candidates = (AGY_GEMINI_MEDIUM, PI_LAGUNA)
else:
rule_id = "worker-local-g07-g08-kst-night"
reason_code = "kst_night_laguna"
candidates = (PI_LAGUNA, AGY_GEMINI_MEDIUM)
return PolicyDecision(
rule_id=rule_id,
policy_priority=20,
reason_codes=(reason_code,),
time_window=time_window,
candidates=candidates,
)
return PolicyDecision(
rule_id="worker-local-g09-g10",
policy_priority=30,
reason_codes=("local_high_grade_cloud_target",),
time_window="not_applicable",
candidates=(CLAUDE_OPUS,),
selected_route = {**route, **matches[0]}
time_window = (
f"{matches[0]['timezone']}:{matches[0]['start']}-{matches[0]['end']}"
)
if grade <= 2:
candidates = (
CODEX_SPARK_XHIGH,
AGY_GEMINI_LOW,
CLAUDE_HAIKU_XHIGH,
)
rule_id = "worker-cloud-g01-g02"
reason_code = "cloud_spark_priority_grade"
elif grade <= 4:
candidates = (AGY_GEMINI_MEDIUM,)
rule_id = "worker-cloud-g03-g04"
reason_code = "cloud_gemini_medium_grade"
elif grade <= 6:
candidates = (AGY_GEMINI_HIGH,)
rule_id = "worker-cloud-g05-g06"
reason_code = "cloud_gemini_high_grade"
elif grade <= 8:
candidates = (CLAUDE_OPUS,)
rule_id = "worker-cloud-g07-g08"
reason_code = "cloud_opus_grade"
else:
candidates = (CODEX_SOL_XHIGH,)
rule_id = "worker-cloud-g09-g10"
reason_code = "cloud_codex_grade"
candidate_ids = selected_route["candidates"]
return PolicyDecision(
rule_id=rule_id,
policy_priority=30,
reason_codes=(reason_code,),
time_window="not_applicable",
candidates=candidates,
route_id=route_id,
rule_id=str(selected_route.get("rule_id") or f"{stage}-{route_id}"),
policy_priority=int(selected_route.get("policy_priority", 0)),
reason_codes=tuple(selected_route.get("reason_codes", [])),
time_window=time_window,
catalog_revision=catalog.revision,
candidates=tuple(catalog.targets[target_id] for target_id in candidate_ids),
)

View file

@ -130,10 +130,10 @@ class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
cwd,
actual_session_id,
attempt_dir,
pi_resume_session=None,
native_resume_session=None,
):
self.assertEqual(actual_session_id, session_id)
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
native = attempt_dir / "native-sessions" / f"session_{session_id}.jsonl"
child = (
"from pathlib import Path\n"
"import sys,time\n"
@ -148,7 +148,20 @@ class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
)
return [sys.executable, "-c", child, str(native)]
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
spec = dispatch.AgentSpec(
"runtime-agent",
"runtime-model",
"runtime-target",
native_resume=True,
target_id="runtime-target",
execution_class="local_model",
runtime={
"command": ["runtime-command", "{prompt}"],
"session_path": "native-sessions/session_{session_id}.jsonl",
"native_session_monitor": True,
"output_format": "text",
},
)
try:
with (
mock.patch.object(dispatch, "build_command", side_effect=command_for),
@ -192,102 +205,14 @@ class SkillObservationContractTest(unittest.TestCase):
skill = (
Path(__file__).parents[1] / "SKILL.md"
).read_text(encoding="utf-8")
self.assertIn(
"dispatcher as the execution lifecycle and observation owner",
skill,
)
self.assertIn(
"without caller-LLM supervision",
skill,
)
self.assertIn(
"The caller never monitors",
skill,
)
self.assertIn(
"Wake the caller LLM only for an attention event that the dispatcher cannot resolve autonomously",
skill,
)
self.assertIn(
"Exit code `3` is a non-terminal tracking state, including another dispatcher workspace lock, "
"a live external agent, or an unexpected dispatcher interruption",
skill,
)
self.assertIn(
"every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, "
"plus native session events when available",
skill,
)
self.assertIn(
"dispatcher PID, agent PID, each process start token, and the per-attempt "
"process environment marker",
skill,
)
self.assertIn(
"use only an actual terminal error or confirmed process exit as recovery "
"evidence for every model",
skill,
)
self.assertIn(
"every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`",
skill,
)
self.assertIn(
"stream stops for three minutes outside tool execution",
skill,
)
self.assertIn(
"locator lacks an agent PID during this interval, never classify it as stale or "
"duplicate recovery based on log age",
skill,
)
self.assertIn(
"original exception is a persistent-state error, do not convert it to exit `2` if any agent was running",
skill,
)
self.assertIn(
"do not return successful exit `0` while any attempt directory remains",
skill,
)
self.assertIn(
"share a budget of 10 consecutive automatic recovery failures for the same task stage",
skill,
)
self.assertIn(
"On the 10th failure, block that task and do not auto-resume after cooldown",
skill,
)
self.assertIn(
"legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure",
skill,
)
self.assertIn(
"Never classify exit code `143` as provider failure without actual provider terminal evidence",
skill,
)
self.assertIn(
"Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage",
skill,
)
self.assertIn("provider_transport_failure_confirmed", skill)
self.assertIn(
"Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr",
skill,
)
self.assertIn(
"A running Python dispatcher does not hot-reload source edits",
skill,
)
self.assertIn("dispatcher_source_sha256", skill)
self.assertIn("`dispatcher_source_matches_loaded=false`", skill)
self.assertIn(
"KST-night `local-G07``local-G08` Laguna locator `context-limit`/`session-stall`",
skill,
)
self.assertIn(
"fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery",
skill,
)
self.assertIn("The dispatcher owns deterministic scheduling, recovery", skill)
self.assertIn("Launch the live dispatcher as one persistent foreground process", skill)
self.assertIn("Do not count internal helper coroutines as agent slots", skill)
self.assertIn("actual stream or native-session progress", skill)
self.assertIn("PID/start-token/process-marker evidence", skill)
self.assertIn("never queries quota before admission", skill)
self.assertIn("confirmed quota/rate-limit error advances directly", skill)
self.assertIn("Common owns no default agent, model, provider, or route catalog", skill)
if __name__ == "__main__":

View file

@ -1,248 +1,178 @@
import importlib.util
import json
import sys
import unittest
from unittest import mock
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
SCRIPT = (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "execution_target_policy.py"
)
SPEC = importlib.util.spec_from_file_location("execution_target_policy", SCRIPT)
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "execution_target_policy.py"
SPEC = importlib.util.spec_from_file_location("execution_target_policy_test", SCRIPT)
policy = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = policy
SPEC.loader.exec_module(policy)
def at_utc(hour: int, minute: int = 0, second: int = 0) -> datetime:
return datetime(2026, 7, 24, hour, minute, second, tzinfo=timezone.utc)
def catalog_value(*, windows: bool = False) -> dict:
targets = {
"target-a": {
"agent": "runner-a",
"model": "model-a",
"execution_class": "local_model",
"selfcheck_required": True,
"runtime": {
"command": ["runner-a", "--model", "{model}", "{prompt}"],
"resume_command": ["runner-a", "--resume", "{resume_session}", "{prompt}"],
"output_format": "jsonl",
"native_session_monitor": True,
},
},
"target-b": {
"agent": "runner-b",
"model": "model-b",
"execution_class": "cloud_model",
"runtime": {"command": ["runner-b", "{prompt}"]},
},
}
routes = {"worker": {}, "review": {}}
for stage in routes:
for lane in ("local", "cloud"):
for grade in range(1, 11):
route = {
"candidates": ["target-a", "target-b"],
"rule_id": f"{stage}-{lane}-g{grade:02d}",
"policy_priority": grade,
"reason_codes": ["catalog-route"],
}
routes[stage][f"{lane}-G{grade:02d}"] = route
if windows:
routes["worker"]["local-G07"] = {
"windows": [
{
"timezone": "UTC",
"start": "00:00",
"end": "12:00",
"candidates": ["target-a", "target-b"],
"rule_id": "day-route",
},
{
"timezone": "UTC",
"start": "12:00",
"end": "00:00",
"candidates": ["target-b", "target-a"],
"rule_id": "night-route",
},
]
}
return {"schema_version": "1.0", "targets": targets, "routes": routes}
def write_catalog(root: Path, value: dict | None = None) -> Path:
path = root / "catalog.json"
path.write_text(json.dumps(value or catalog_value()), encoding="utf-8")
return path
class ExecutionTargetPolicyTests(unittest.TestCase):
def test_local_g07_route_uses_kst_boundaries(self):
cases = [
(at_utc(21, 59, 59), "pi", "iop/laguna-s:2.1", "kst-night-[23:00,07:00)"),
(at_utc(22, 0, 0), "agy", "Gemini 3.6 Flash (Medium)", "kst-day-[07:00,23:00)"),
(at_utc(13, 59, 59), "agy", "Gemini 3.6 Flash (Medium)", "kst-day-[07:00,23:00)"),
(at_utc(14, 0, 0), "pi", "iop/laguna-s:2.1", "kst-night-[23:00,07:00)"),
]
for evaluated_at, adapter, target, time_window in cases:
with self.subTest(evaluated_at=evaluated_at):
decision = policy.select_policy(
stage="worker",
lane="local",
grade=7,
evaluated_at=evaluated_at,
)
self.assertEqual(decision.candidates[0].adapter, adapter)
self.assertEqual(decision.candidates[0].target, target)
self.assertEqual(decision.time_window, time_window)
def test_policy_is_unaffected_by_process_environment_variables(self):
night_time = datetime(2026, 7, 25, 17, 0, tzinfo=timezone.utc) # 02:00 KST
with mock.patch.dict("os.environ", {"OTHER_UNRELATED_ENV": "2026-07-26", "ANY_UNRELATED_ENV": "1"}):
def test_catalog_is_runtime_loaded_and_route_is_complete(self):
with TemporaryDirectory() as tmp:
catalog = policy.load_catalog(write_catalog(Path(tmp)))
decision = policy.select_policy(
stage="worker", lane="local", grade=8, evaluated_at=night_time
catalog=catalog,
stage="worker",
lane="cloud",
grade=3,
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(decision.rule_id, "worker-local-g07-g08-kst-night")
self.assertEqual(decision.candidates, (policy.PI_LAGUNA, policy.AGY_GEMINI_MEDIUM))
self.assertEqual(decision.time_window, "kst-night-[23:00,07:00)")
self.assertEqual(decision.candidates[0].target, "iop/laguna-s:2.1")
self.assertEqual(decision.route_id, "cloud-G03")
self.assertEqual([item.catalog_id for item in decision.candidates], ["target-a", "target-b"])
self.assertEqual(decision.candidates[0].agent, "runner-a")
self.assertEqual(decision.candidates[0].model, "model-a")
self.assertEqual(decision.catalog_revision, catalog.revision)
def test_worker_grade_matrix_has_no_gaps(self):
daytime = at_utc(3)
expected = {
"local": {
**{
grade: ("pi", "iop/ornith:35b", True)
for grade in range(1, 7)
},
7: ("agy", "Gemini 3.6 Flash (Medium)", False),
8: ("agy", "Gemini 3.6 Flash (Medium)", False),
9: ("claude", "claude-opus-4-8", False),
10: ("claude", "claude-opus-4-8", False),
},
"cloud": {
**{
grade: ("codex", "gpt-5.3-codex-spark", False)
for grade in range(1, 3)
},
**{
grade: ("agy", "Gemini 3.6 Flash (Medium)", False)
for grade in range(3, 5)
},
**{
grade: ("agy", "Gemini 3.6 Flash (High)", False)
for grade in range(5, 7)
},
7: ("claude", "claude-opus-4-8", False),
8: ("claude", "claude-opus-4-8", False),
9: ("codex", "gpt-5.6-sol", False),
10: ("codex", "gpt-5.6-sol", False),
},
}
for lane, grades in expected.items():
for grade, route in grades.items():
with self.subTest(lane=lane, grade=grade):
selected = policy.select_policy(
stage="worker",
lane=lane,
grade=grade,
evaluated_at=daytime,
).candidates[0]
self.assertEqual(
(
selected.adapter,
selected.target,
selected.selfcheck_required,
),
route,
)
def test_common_policy_has_no_built_in_catalog(self):
self.assertFalse(hasattr(policy, "CANONICAL_TARGETS"))
self.assertFalse(hasattr(policy, "quota_probe_spec"))
self.assertFalse(hasattr(policy, "promotion_target"))
def test_cloud_g01_g02_uses_ordered_spark_gemini_haiku_candidates(self):
for grade in (1, 2):
with self.subTest(grade=grade):
decision = policy.select_policy(
stage="worker",
lane="cloud",
grade=grade,
evaluated_at=at_utc(3),
)
self.assertEqual(
decision.candidates,
(
policy.CODEX_SPARK_XHIGH,
policy.AGY_GEMINI_LOW,
policy.CLAUDE_HAIKU_XHIGH,
),
)
self.assertEqual(
decision.reason_codes,
("cloud_spark_priority_grade",),
)
def test_review_matrix_is_fixed_to_codex(self):
for lane in ("local", "cloud"):
for grade in range(1, 11):
with self.subTest(lane=lane, grade=grade):
decision = policy.select_policy(
stage="review",
lane=lane,
grade=grade,
evaluated_at=at_utc(3),
)
self.assertEqual(decision.rule_id, "official-review-codex")
self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,))
def test_local_g07_g08_candidate_order_uses_kst_boundaries(self):
daytime = policy.select_policy(
stage="worker",
lane="local",
grade=8,
evaluated_at=at_utc(3),
)
nighttime = policy.select_policy(
stage="worker",
lane="local",
grade=8,
evaluated_at=at_utc(15),
)
self.assertEqual(
[candidate.adapter for candidate in daytime.candidates],
["agy", "pi"],
)
self.assertEqual(
[candidate.adapter for candidate in nighttime.candidates],
["pi", "agy"],
)
def test_invalid_inputs_are_rejected(self):
cases = [
{"stage": "selfcheck", "lane": "local", "grade": 7},
{"stage": "worker", "lane": "hybrid", "grade": 7},
{"stage": "worker", "lane": "local", "grade": 0},
{"stage": "worker", "lane": "local", "grade": 11},
]
for values in cases:
with self.subTest(values=values):
with self.assertRaises(ValueError):
policy.select_policy(
**values,
evaluated_at=at_utc(3),
)
with self.assertRaisesRegex(ValueError, "timezone-aware"):
policy.select_policy(
def test_optional_windows_are_catalog_owned_and_timezone_generic(self):
with TemporaryDirectory() as tmp:
catalog = policy.load_catalog(write_catalog(Path(tmp), catalog_value(windows=True)))
morning = policy.select_policy(
catalog=catalog,
stage="worker",
lane="local",
grade=7,
evaluated_at=datetime(2026, 7, 25, 12, 0, 0),
evaluated_at=datetime(2026, 1, 1, 6, tzinfo=timezone.utc),
)
evening = policy.select_policy(
catalog=catalog,
stage="worker",
lane="local",
grade=7,
evaluated_at=datetime(2026, 1, 1, 18, tzinfo=timezone.utc),
)
self.assertEqual(morning.candidates[0].catalog_id, "target-a")
self.assertEqual(evening.candidates[0].catalog_id, "target-b")
self.assertEqual(morning.rule_id, "day-route")
self.assertEqual(evening.rule_id, "night-route")
def test_cloud_promotion_matrix(self):
cases = [
(policy.AGY_GEMINI_LOW, policy.CLAUDE_OPUS),
(policy.AGY_GEMINI_MEDIUM, policy.CLAUDE_OPUS),
(policy.AGY_GEMINI_HIGH, policy.CLAUDE_OPUS),
(policy.CLAUDE_OPUS, policy.CODEX_TERRA_HIGH),
(policy.CLAUDE_HAIKU_XHIGH, None),
(policy.CODEX_SPARK_XHIGH, None),
(policy.CODEX_SOL_XHIGH, None),
(policy.CODEX_TERRA_HIGH, None),
(policy.PI_ORNITH, None),
(policy.PI_LAGUNA, None),
def test_catalog_requires_every_stage_lane_grade_route(self):
value = catalog_value()
del value["routes"]["review"]["cloud-G10"]
with TemporaryDirectory() as tmp:
with self.assertRaisesRegex(policy.CatalogError, "cover local/cloud G01..G10 exactly"):
policy.load_catalog(write_catalog(Path(tmp), value))
def test_unknown_target_and_unknown_template_field_are_rejected(self):
unknown_target = catalog_value()
unknown_target["routes"]["worker"]["local-G01"]["candidates"] = ["missing"]
bad_template = catalog_value()
bad_template["targets"]["target-a"]["runtime"]["command"] = ["runner", "{provider_secret}"]
with TemporaryDirectory() as tmp:
root = Path(tmp)
with self.assertRaisesRegex(policy.CatalogError, "unknown targets"):
policy.load_catalog(write_catalog(root, unknown_target))
with self.assertRaisesRegex(policy.CatalogError, "unsupported template field"):
policy.load_catalog(write_catalog(root, bad_template))
def test_command_executable_must_be_literal_for_preflight(self):
value = catalog_value()
value["targets"]["target-a"]["runtime"]["command"] = [
"{workspace}",
"{prompt}",
]
for current, expected in cases:
with self.subTest(current=current):
self.assertEqual(policy.promotion_target(current), expected)
with TemporaryDirectory() as tmp:
with self.assertRaisesRegex(policy.CatalogError, "executable must be a literal"):
policy.load_catalog(write_catalog(Path(tmp), value))
for target in policy.CANONICAL_TARGETS:
with self.subTest(identity=target.target):
self.assertEqual(
policy.canonical_target(target.adapter, target.target),
target,
)
self.assertIsNone(policy.canonical_target("codex", "unknown"))
def test_catalog_revision_changes_with_content(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
path = write_catalog(root)
first = policy.load_catalog(path)
changed = catalog_value()
changed["targets"]["target-a"]["model"] = "model-a-next"
path.write_text(json.dumps(changed), encoding="utf-8")
second = policy.load_catalog(path)
self.assertNotEqual(first.revision, second.revision)
def test_quota_probe_spec_matrix(self):
cases = [
(policy.PI_ORNITH, None),
(policy.PI_LAGUNA, None),
(
policy.AGY_GEMINI_LOW,
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Low)", ("overall", "model:Gemini 3.6 Flash (Low)")),
),
(
policy.AGY_GEMINI_MEDIUM,
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Medium)", ("overall", "model:Gemini 3.6 Flash (Medium)")),
),
(
policy.AGY_GEMINI_HIGH,
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (High)", ("overall", "model:Gemini 3.6 Flash (High)")),
),
(
policy.CLAUDE_OPUS,
policy.QuotaProbeSpec("claude", "claude-opus-4-8", ("overall",)),
),
(
policy.CLAUDE_HAIKU_XHIGH,
policy.QuotaProbeSpec("claude", "claude-haiku-4-5", ("overall",)),
),
(
policy.CODEX_SPARK_XHIGH,
policy.QuotaProbeSpec("codex", "gpt-5.3-codex-spark", ("overall",)),
),
(
policy.CODEX_SOL_XHIGH,
policy.QuotaProbeSpec("codex", "gpt-5.6-sol", ("overall",)),
),
]
for target, expected in cases:
with self.subTest(target=target.target):
self.assertEqual(policy.quota_probe_spec(target), expected)
def test_invalid_route_inputs_are_rejected(self):
with TemporaryDirectory() as tmp:
catalog = policy.load_catalog(write_catalog(Path(tmp)))
for values in (
{"stage": "selfcheck", "lane": "local", "grade": 1},
{"stage": "worker", "lane": "hybrid", "grade": 1},
{"stage": "worker", "lane": "local", "grade": 0},
):
with self.subTest(values=values), self.assertRaises(ValueError):
policy.select_policy(
catalog=catalog,
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
**values,
)
if __name__ == "__main__":

View file

@ -54,6 +54,7 @@ Filename rules:
Role boundary rules:
- Keep root cause, scope, ownership, and next-state decisions under plan/review, and validate their artifacts directly; dispatcher use is optional. Let implementing agents execute the packet without reinterpreting findings or changing its owner/write boundary.
- Implementing agents fill implementation-owned `CODE_REVIEW-*-G??.md` sections, keep active files in place, and report ready for review.
- If implementation cannot continue, implementing agents record the exact blocker, attempted commands/output, and resume condition only in `Verification Results` or `Deviations from Plan` (legacy: `검증 결과` or `계획 대비 변경 사항`), then leave the active files in place for official review.
- During implementation, do not ask the user directly, present choices, call user-input tools, or create control-plane stop files. The official reviewer owns all next-state classification.
@ -99,7 +100,7 @@ Task directory naming rules:
- For predecessor index `PP`, the only valid archive lookup candidates are `agent-task/archive/*/*/{task_group}/PP_*/complete.log` and `agent-task/archive/*/*/{task_group}/PP+*/complete.log`.
- Archive lookup matches the predecessor index at the start of the archived subtask directory name, such as `01_...` or `01+...`, under the same `{task_group}`. If multiple candidates match one predecessor index, do not choose by guess; record the ambiguity and require a concrete task path or runtime selection.
- Do not treat an archived predecessor as the active task to edit. Archive lookup is only for dependency satisfaction before writing or implementing a dependent split plan.
- Example: split a refactoring common core plus two app integrations under `agent-task/refactoring/` as `01_core`, `02+01_edge_integration`, `03+01_node_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`.
- Example: split a refactoring common core plus two app integrations under `agent-task/refactoring/` as `01_core`, `02+01_app_a_integration`, `03+01_app_b_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`.
- Example: split three sequential tasks under one task group as `01_schema`, `02+01_migration`, `03+02_api`.
- Example: split independent docs/UI plus an integration under one task group as `01_core`, `02+01_db`, `03+02_api`, `04_docs`, `05_ui`, `06+05_integration`; `01_core`, `04_docs`, and `05_ui` can start together, and `06+05_integration` waits only for `05_ui`.
- After a pair is written, preserve its task group and subtask directory name verbatim. Only an explicit `refine-plans` run may rename eligible unstarted siblings by its dependency-order rules.
@ -200,12 +201,13 @@ Complete all items below before creating active plan/review files. Work through
- [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it.
- [ ] **Assess split boundaries once** — reconcile request acceptance with source/tests, then split only where every child has a stable contract and independent PASS verification. Otherwise keep the invariant together; do not gather extra evidence solely to lower routing risk.
- [ ] **Capture recovery signals once** — first-pass uses `review_rework_count=0` and `evidence_integrity_failure=false`. In `prepare-follow-up`, reuse the values already validated and appended by code-review; do not recount verdict history. For another isolated replan, derive them once from the same-task state already loaded for planning, without a routing-only log pass.
- [ ] **Resolve follow-up findings once** — in `prepare-follow-up`, map every inherited Required/Suggested id. Default repository-fixable work to `direct-fix` with exact root-cause files, overriding stale verification-only exclusions. Allow `verified-dependency` only when an exact active PLAN claims those files and task-protocol ordering applies, or when `complete.log` plus fresh evidence proves the failed precondition is satisfied; vague owners or `complete.log` alone are invalid. Set `ownership_closed=true` only after all mappings are proven. Reject unchanged-precondition verification loops. Reuse the existing analysis; add no model, sub-agent, or routing-only pass.
- [ ] **Resolve split predecessor completion** — if the selected or proposed subtask directory has `NN+PP[,QQ...]_...`, resolve each predecessor index under the same task group. Check only the active and archive candidate patterns defined in the task directory naming rules. Record found active/archive paths, missing predecessors, or ambiguous matches in `Analysis > Split Judgment` (legacy: `분석 결과 > 분할 판단`) and, when order matters, `Dependencies and Execution Order` (legacy: `의존 관계 및 구현 순서`).
- [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain.
- [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest.
- [ ] **Check dependency manifests** — before adding any dependency, inspect the repository's relevant dependency manifest and lockfile, confirm whether it already exists, and follow the repository-native version and update policy.
- [ ] **Pre-check compile issues** — identify missing interface implementations, type mismatches, and broken imports.
- [ ] **Verify verification commands** — confirm that the final verification commands actually run in this repository layout.
- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or `-count=1` is required.
- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or the repository's test runner must use its fresh-run or cache-bypass option.
- [ ] **Derive routing signals once** — treat each completed in-memory PLAN as the worker packet. From facts already collected, record `large_indivisible_context`, positive matched loop-risk names/count, and recovery signals. Do not reread files, prove unmatched signatures false, or aggregate parent/sibling risk for routing.
## Step 3 - Finalize Task Routing
@ -246,7 +248,7 @@ Use the second form for every `m-*` task and the first form for every non-milest
Example:
```markdown
<!-- task=m-principal-provider-credential-slot-routing/07+01,02,05_secret_material plan=0 tag=API milestone-task=secret-at-rest -->
<!-- task=m-sample-capability/03+01,02_storage plan=0 tag=API milestone-task=sample-item -->
```
Required sections:
@ -255,6 +257,7 @@ Required sections:
- `For the Implementing Agent`: warn that filling implementation-owned `CODE_REVIEW-*-G??.md` sections is mandatory. Tell the implementer to run verification, fill actual notes/output, keep active files in place, and report ready for review; finalization is code-review-skill only. If blocked, the implementer records only exact blocker evidence, attempted commands/output, and resume conditions in implementation-owned evidence fields. It must not ask the user, call user-input tools, create control-plane stop files, classify the next state, archive logs, or write `complete.log`.
- `Background`: 2-4 sentences explaining why the work is needed.
- `Archive Evidence Snapshot`: include this section only when the plan resumes from `USER_REVIEW.md`, a prior archived review, or any archive evidence. Omit it for first-pass plans with no archive evidence. The section must contain only the archive facts needed to implement without rereading archive by default: prior task/archive paths, verdict, Required/Suggested/Nit summary, affected files, verification evidence, and any roadmap carryover. If exact prior context is still required, cite the specific archive file paths allowed to read; do not ask the implementer to search `agent-task/archive/**` broadly.
- `Finding Resolution Map`: for WARN/FAIL follow-ups only, use one row per Required/Suggested id: mode, exact fix/dependency evidence, and changed/satisfied precondition. Put every `direct-fix` file in `Modified Files Summary`. For an unordered dependency, emit no worker packet; return `status=blocked`, `blocked_reason=dependency-wait`, `next-task`, and resume condition.
- `Analysis`: record the findings from Step 2 and the final routed output from Step 3. This section is the written output of the analysis — not a summary, but the actual findings that justify the plan's scope and decisions. Must include all of the following subsections:
- `Files Read`: list every source and test file read during analysis, with path. List verification-context source files only when they were actually present and read.
- `SDD Criteria`: for `SDD: 필요` Milestones, list the SDD path, status, first-line `milestone-task` ids, targeted Acceptance Scenario ids, and the Evidence Map rows that drive the plan. State explicitly how those rows shaped the implementation checklist and final verification. If the selected Milestone has `SDD: 불필요`, state the recorded reason. If the work is not Milestone-linked, state "not applicable".
@ -266,13 +269,13 @@ 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. This section is the dispatcher's workspace write-claim source of truth.
- `Modified Files Summary`: table mapping files to item ids. This is the skill-owned implementation write boundary.
- Include exactly one `## Modified Files Summary` section and at least one exact workspace file path.
- Wrap every claimed file path in backticks. A bare path cell is invalid.
- 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/common/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.
- Before writing or returning a prepared pair, inspect the rendered PLAN itself and confirm this section occurs exactly once, is non-empty, and satisfies every path rule above. For a follow-up, also confirm every `direct-fix` target in `Finding Resolution Map` is present here.
- `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:
@ -318,7 +321,7 @@ Verification fidelity rules:
- `Verification Results` (legacy: `검증 결과`) must contain actual stdout/stderr, not summarized or reconstructed output. If output is too long, record the saved output file path and the exact command used to create it.
- If mobile/UI verification has no progress for 2 minutes or times out, stop blind retries; collect focused stdout plus screenshot/window/UI-tree evidence when available, or record why capture is impossible.
- If the plan's pass condition says all leftovers must be intentional exceptions, any `변경 필요` item forces FAIL until resolved or explicitly reclassified with evidence.
- Decide in the plan whether Go test cache output is acceptable. If fresh execution matters, use `go test -count=1 ...`.
- Decide in the plan whether cached test output is acceptable. If fresh execution matters, use the repository's test runner option that forces a fresh run or bypasses cached results, and record the exact command.
## Step 6 - Write Review Stub
@ -345,10 +348,10 @@ 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 `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-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.
- The plan skill directly checked the rendered 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`.
- 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_app_a_integration`, `03+01_app_b_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
- Split sibling indices follow topological dependency order: every predecessor is lower than its consumer, and every gap is explained by an unchanged existing predecessor or an occupied active/archive index.
- Milestone-linked work uses `agent-task/m-<milestone-slug>/` as the task group; non-roadmap task groups do not start with `m-`.
- Both first lines are identical. Non-milestone pairs match `<!-- task={task_name} plan={plan_number} tag={TAG} -->`; `m-*` pairs append exactly ` milestone-task=<task-id>[,<task-id>...]` before ` -->`.
@ -365,6 +368,7 @@ Do not write or return a prepared pair when either routing target is not `routed
- The plan and review stub have matching `Implementation Checklist` (legacy: `구현 체크리스트`) item text/order; their final checkbox is the mandatory `CODE_REVIEW-*-G??.md` evidence item.
- `finalize-task-routing` ran once after the PLAN body was complete, used no routing-only evidence pass, counted only positive packet-local risk, kept capability/grade basis from being relabeled by escalation signals, and produced matching filenames.
- Review WARN/FAIL follow-ups entered through this plan skill and did not inherit or compare the archived lane/G.
- Every WARN/FAIL finding has one proven direct fix or ordered/satisfied dependency; only then is ownership closed, and verification does not repeat against an unchanged precondition.
- The plan's implementer instructions and review stub limit local implementation agents to implementation/test/evidence work and keep user-review classification plus control-plane stop files out of their input and ownership.
- The review stub has a clearly marked `Review-Only Checklist` (legacy: `코드리뷰 전용 체크리스트`) owned only by the review agent.
- Routed review file completion table lists every plan item.

View file

@ -6,6 +6,7 @@
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `Implementation Checklist`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> Execute the plan's selected root cause, scope, files, and dependency decisions as written. Do not choose another owner, narrow/expand the write boundary, or replace a fix with another verification attempt.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`Code Review Result`, log rename, `complete.log`, archive moves, `Review-Only Checklist`) is review-agent-only, even after compaction/resume.

View file

@ -14,11 +14,9 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
- `workspace`: 준비된 feature worktree 절대 경로 (필수)
- `target-milestone`: 활성 Milestone slug 또는 경로 (필수)
- `target-epic`: 정확한 Epic id 또는 이름 (필수)
- `planner-agent`: `codex`, `claude`, `gemini`, `pi` 중 하나 (생략 시 `codex`)
- `review-agent`: 생략하면 `planner-agent`와 같다. (선택)
- `planner-model`, `review-model`: provider별 model override. Codex 기본 사용 시 `planner-model` 생략 시 `gpt-5.6-sol`, 다른 provider는 해당 CLI 기본 모델을 사용한다. (선택)
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
- `pi-provider`: Pi provider override (선택)
- `execution-catalog`: 런타임이 주입한 agent-model 실행 카탈로그 경로. `AGENT_TASK_EXECUTION_CATALOG`로 대신 주입할 수 있다. (필수)
- `planner-target`: 카탈로그에 선언된 materialize/refine 실행 target id. `AGENT_TASK_PLANNER_TARGET`로 대신 주입할 수 있다. (필수)
- `review-target`: 카탈로그에 선언된 initial/final review target id. `AGENT_TASK_REVIEW_TARGET`로 주입하거나 생략하면 `planner-target`과 같다. (선택)
- `retry`: terminal failure의 원인을 사용자가 해소한 뒤 같은 Epic 상태를 재개할 때만 사용한다. (선택)
- `batch-task-ids`: 상위 `prepare-milestone-workspace`가 고정한 선택 Epic Task id 합집합. 직접 호출에서는 사용하지 않는다. (내부 선택)
@ -55,13 +53,16 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py \
--workspace "$WORKSPACE" \
--milestone "$MILESTONE" \
--epic "$EPIC"
--epic "$EPIC" \
--execution-catalog "$EXECUTION_CATALOG" \
--planner-target "$PLANNER_TARGET" \
--review-target "$REVIEW_TARGET"
```
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
- 다른 agent, model, reasoning, Pi provider override와 `--retry`는 해당 입력이 있을 때만 전달한다.
- agent, model, 실행 명령과 provider별 옵션은 스킬이나 스크립트에 고정하지 않고 카탈로그 target의 opaque metadata와 argv template에서 가져온다.
- target id와 카탈로그 revision은 실행 evidence에 보존한다. `--retry`는 동일 카탈로그 계약과 target을 사용한다.
- 상위 batch에서 호출할 때만 고정된 Task id 합집합을 `--batch-task-ids`로 전달한다.
- 스크립트는 각 agent를 새 one-shot session으로 실행한다. Codex, Claude, Gemini(`agy` adapter), Pi를 같은 normalized runner 계약으로 지원한다.
- 스크립트는 카탈로그가 지시한 각 target을 새 one-shot session으로 실행한다.
- model stdout/stderr는 git common dir의 locator log에만 저장한다. caller stdout에는 lifecycle/attention event만 출력한다.
3. **상태 전이를 따른다**

View file

@ -1,25 +1,24 @@
#!/usr/bin/env python3
"""Run one fresh Codex, Claude, Gemini/agy, or Pi agent without polling."""
"""Run one fresh target from a runtime-injected execution catalog."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, Iterable
import uuid
AGENT_COMMAND = {"codex": "codex", "claude": "claude", "gemini": "agy", "pi": "pi"}
DEFAULT_AGENT = "codex"
DEFAULT_MODEL = "gpt-5.6-sol"
DEFAULT_REASONING_EFFORT = "xhigh"
CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG"
LABEL_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
PROBE_EXPECTED = "MILESTONE_AGENT_READY"
@ -28,6 +27,22 @@ class AgentRunError(RuntimeError):
"""One-shot runner contract error."""
def load_policy_module():
path = (
Path(__file__).resolve().parents[2]
/ "orchestrate-agent-task-loop"
/ "scripts"
/ "execution_target_policy.py"
)
spec = importlib.util.spec_from_file_location("epic_execution_target_policy", path)
if spec is None or spec.loader is None:
raise AgentRunError(f"execution catalog policy not found: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def now() -> str:
return datetime.now(timezone.utc).isoformat()
@ -44,7 +59,6 @@ def atomic_json(path: Path, value: dict[str, Any]) -> None:
def process_start_token(pid: int) -> str | None:
"""Return a best-effort token that distinguishes PID reuse."""
stat = Path(f"/proc/{pid}/stat")
try:
remainder = stat.read_text(encoding="utf-8").rsplit(")", 1)[1].split()
@ -123,76 +137,40 @@ def prompt_text(args: argparse.Namespace) -> str:
return path.read_text(encoding="utf-8")
def build_command(
*,
agent: str,
prompt: str,
workspace: Path,
model: str | None,
reasoning_effort: str | None,
pi_provider: str | None,
session_id: str,
attempt_dir: Path,
probe: bool = False,
) -> list[str]:
if agent == "codex":
command = ["codex", "exec", "--json", "-C", str(workspace)]
if model:
command.extend(["-m", model])
if reasoning_effort:
command.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
if not probe:
command.append("--dangerously-bypass-approvals-and-sandbox")
command.append(prompt)
return command
if agent == "claude":
command = [
"claude",
"-p",
"--output-format",
"stream-json",
"--verbose",
"--session-id",
session_id,
]
if model:
command.extend(["--model", model])
if reasoning_effort:
command.extend(["--effort", reasoning_effort])
if not probe:
command.append("--dangerously-skip-permissions")
command.append(prompt)
return command
if agent == "gemini":
command = ["agy", "--print", prompt, "--print-timeout", "8h"]
if model:
command.extend(["--model", model])
if not probe:
command.append("--dangerously-skip-permissions")
command.extend(["--log-file", str(attempt_dir / "agy-cli.log")])
return command
if agent == "pi":
command = [
"pi",
"-p",
"--mode",
"json",
"--session-id",
session_id,
"--session-dir",
str(attempt_dir / "pi-sessions"),
]
if not probe:
command.append("--approve")
if pi_provider:
command.extend(["--provider", pi_provider])
if model:
command.extend(["--model", model])
if reasoning_effort:
command.extend(["--thinking", reasoning_effort])
command.append(prompt)
return command
raise AgentRunError(f"unsupported agent: {agent}")
def resolve_target(catalog_path: str, target_id: str):
policy = load_policy_module()
try:
catalog = policy.load_catalog(catalog_path)
except (OSError, ValueError) as exc:
raise AgentRunError(f"invalid execution catalog: {exc}") from exc
target = policy.canonical_target(catalog, target_id)
if target is None:
raise AgentRunError(f"execution catalog target not found: {target_id}")
return catalog, target
def template_values(*, target, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> dict[str, str]:
return {
"agent": target.agent,
"attempt_dir": str(attempt_dir),
"model": target.model,
"prompt": prompt,
"resume_session": "",
"session_id": session_id,
"target_id": target.catalog_id,
"workspace": str(workspace),
}
def build_command(*, target, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> list[str]:
values = template_values(
target=target,
prompt=prompt,
workspace=workspace,
session_id=session_id,
attempt_dir=attempt_dir,
)
return [str(item).format_map(values) for item in target.runtime["command"]]
def sanitized_command(command: list[str], prompt: str) -> list[str]:
@ -201,14 +179,12 @@ def sanitized_command(command: list[str], prompt: str) -> list[str]:
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--agent", choices=sorted(AGENT_COMMAND), default=DEFAULT_AGENT)
value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV))
value.add_argument("--target-id", required=True)
value.add_argument("--workspace", required=True)
prompt_group = value.add_mutually_exclusive_group()
prompt_group.add_argument("--prompt")
prompt_group.add_argument("--prompt-file")
value.add_argument("--model")
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
value.add_argument("--pi-provider")
value.add_argument("--label", default="one-shot")
value.add_argument("--probe", action="store_true")
value.add_argument("--result-file")
@ -217,16 +193,20 @@ def parser() -> argparse.ArgumentParser:
def execute(args: argparse.Namespace) -> int:
workspace = workspace_root(args.workspace)
if args.model is None and args.agent == DEFAULT_AGENT:
args.model = DEFAULT_MODEL
if not args.execution_catalog:
raise AgentRunError(
f"--execution-catalog or {CATALOG_ENV} is required"
)
catalog, target = resolve_target(args.execution_catalog, args.target_id)
if not LABEL_PATTERN.fullmatch(args.label):
raise AgentRunError("--label may contain only letters, digits, dot, underscore, and hyphen")
prompt = prompt_text(args)
result = result_file(workspace, args.result_file)
executable = AGENT_COMMAND[args.agent]
resolved = shutil.which(executable)
if resolved is None:
raise AgentRunError(f"agent command not found: agent={args.agent} command={executable}")
executable = target.runtime["command"][0]
if shutil.which(executable) is None:
raise AgentRunError(
f"target command not found: target_id={target.catalog_id} command={executable}"
)
execution_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:12]}"
root = state_root(workspace)
@ -235,25 +215,37 @@ def execute(args: argparse.Namespace) -> int:
stream = attempt_dir / "stream.log"
locator = attempt_dir / "locator.json"
session_id = str(uuid.uuid4())
command = build_command(
agent=args.agent,
values = template_values(
target=target,
prompt=prompt,
workspace=workspace,
model=args.model,
reasoning_effort=args.reasoning_effort,
pi_provider=args.pi_provider,
session_id=session_id,
attempt_dir=attempt_dir,
probe=args.probe,
)
command = build_command(
target=target,
prompt=prompt,
workspace=workspace,
session_id=session_id,
attempt_dir=attempt_dir,
)
environment = {
str(key): str(item).format_map(values)
for key, item in target.runtime.get("environment", {}).items()
}
record: dict[str, Any] = {
"execution_id": execution_id,
"label": args.label,
"workspace": str(workspace),
"agent": args.agent,
"catalog": {
"source": str(catalog.source),
"revision": catalog.revision,
"schema_version": "1.0",
},
"target_id": target.catalog_id,
"agent": target.agent,
"model": target.model,
"command": sanitized_command(command, prompt),
"model": args.model,
"reasoning_effort": args.reasoning_effort,
"prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(),
"session_id": session_id,
"stream_log": str(stream),
@ -264,11 +256,12 @@ def execute(args: argparse.Namespace) -> int:
persist(locator, result, record)
emit(
"AGENT_STARTED",
agent=args.agent,
agent=target.agent,
execution_id=execution_id,
label=args.label,
locator=str(locator),
model=args.model or "default",
model=target.model,
target_id=target.catalog_id,
)
with stream.open("wb") as output:
try:
@ -278,6 +271,7 @@ def execute(args: argparse.Namespace) -> int:
env={
**os.environ,
"MILESTONE_PREPARATION_EXECUTION_ID": execution_id,
**environment,
},
stdout=output,
stderr=subprocess.STDOUT,
@ -332,7 +326,7 @@ def main(argv: Iterable[str] | None = None) -> int:
args = parser().parse_args(argv)
try:
return execute(args)
except (AgentRunError, OSError) as exc:
except (AgentRunError, OSError, ValueError) as exc:
emit("AGENT_FINISHED", label=getattr(args, "label", "one-shot"), result="failed", reason=str(exc))
return 2

View file

@ -16,9 +16,6 @@ from typing import Any, Iterable
STAGES = ("materialize", "initial-review", "refine", "final-review")
DEFAULT_PLANNER_AGENT = "codex"
DEFAULT_PLANNER_MODEL = "gpt-5.6-sol"
DEFAULT_REASONING_EFFORT = "xhigh"
PLAN_PATTERN = "PLAN-*-G??.md"
REVIEW_PATTERN = "CODE_REVIEW-*-G??.md"
HEADER = re.compile(r"^<!--\s+(?P<body>.*?)\s+-->$")
@ -325,20 +322,9 @@ def validate_pairs(
raise CycleError("target Epic Task ids must be inside the selected batch")
pairs: list[tuple[Path, Path, dict[str, str]]] = []
union: set[str] = set()
project_dispatcher = (
workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
dispatcher_root = (
workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
)
private_dispatcher = (
workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
)
if project_dispatcher.is_dir() and private_dispatcher.is_dir():
dispatcher_root = private_dispatcher
elif project_dispatcher.is_dir():
dispatcher_root = project_dispatcher
else:
dispatcher_root = (
workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
)
dispatcher = dispatcher_root / "scripts" / "dispatch.py"
for plan, review, header in all_pairs:
ids = header["milestone-task"].split(",")
@ -446,10 +432,8 @@ def run_agent_stage(
identity: str,
stage: str,
prompt: str,
agent: str,
model: str | None,
reasoning_effort: str | None,
pi_provider: str | None,
execution_catalog: str,
target_id: str,
prior_cycle_status: str,
retry: bool,
) -> Path:
@ -497,8 +481,10 @@ def run_agent_stage(
command = [
sys.executable,
str(runner),
"--agent",
agent,
"--execution-catalog",
execution_catalog,
"--target-id",
target_id,
"--workspace",
str(workspace),
"--prompt-file",
@ -508,12 +494,6 @@ def run_agent_stage(
"--result-file",
str(result_path),
]
if model:
command.extend(["--model", model])
if reasoning_effort:
command.extend(["--reasoning-effort", reasoning_effort])
if pi_provider:
command.extend(["--pi-provider", pi_provider])
result = run(command, cwd=workspace, check=False, capture=False)
if result.returncode != 0:
if result.returncode == 3:
@ -561,15 +541,15 @@ def parser() -> argparse.ArgumentParser:
value.add_argument("--milestone", required=True)
value.add_argument("--epic", required=True)
value.add_argument(
"--planner-agent",
choices=("codex", "claude", "gemini", "pi"),
default=DEFAULT_PLANNER_AGENT,
"--execution-catalog",
default=os.environ.get("AGENT_TASK_EXECUTION_CATALOG"),
)
value.add_argument(
"--planner-target", default=os.environ.get("AGENT_TASK_PLANNER_TARGET")
)
value.add_argument(
"--review-target", default=os.environ.get("AGENT_TASK_REVIEW_TARGET")
)
value.add_argument("--review-agent", choices=("codex", "claude", "gemini", "pi"))
value.add_argument("--planner-model")
value.add_argument("--review-model")
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
value.add_argument("--pi-provider")
value.add_argument(
"--batch-task-ids",
help="internal selected-Epic Task id union; permits earlier Epic pairs in the same batch",
@ -584,10 +564,16 @@ def parser() -> argparse.ArgumentParser:
def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
if args.planner_model is None and args.planner_agent == DEFAULT_PLANNER_AGENT:
args.planner_model = DEFAULT_PLANNER_MODEL
if args.reasoning_effort is None:
args.reasoning_effort = DEFAULT_REASONING_EFFORT
if not args.execution_catalog:
raise CycleError(
"--execution-catalog or AGENT_TASK_EXECUTION_CATALOG is required"
)
if not args.planner_target:
raise CycleError(
"--planner-target or AGENT_TASK_PLANNER_TARGET is required"
)
if args.review_target is None:
args.review_target = args.planner_target
return args
@ -758,17 +744,17 @@ def cycle(args: argparse.Namespace) -> int:
elif changed_paths(workspace) and not args.retry:
raise CycleError("dirty recovery state requires explicit --retry")
reviewer_agent = args.review_agent or args.planner_agent
reviewer_model = args.review_model or (
args.planner_model if reviewer_agent == args.planner_agent else None
)
reviewer_target = args.review_target or args.planner_target
start_index = STAGES.index(str(state.get("next_stage", STAGES[0])))
for stage in STAGES[start_index:]:
stage_head = git(workspace, "rev-parse", "HEAD")
event_prefix = stage.upper().replace("-", "_")
emit(f"{event_prefix}_STARTED", identity=identity)
agent = args.planner_agent if stage in {"materialize", "refine"} else reviewer_agent
model = args.planner_model if stage in {"materialize", "refine"} else reviewer_model
target_id = (
args.planner_target
if stage in {"materialize", "refine"}
else reviewer_target
)
prompt = stage_prompt(
stage=stage,
workspace=workspace,
@ -792,10 +778,8 @@ def cycle(args: argparse.Namespace) -> int:
identity=identity.replace(":", "-"),
stage=stage,
prompt=prompt,
agent=agent,
model=model,
reasoning_effort=args.reasoning_effort,
pi_provider=args.pi_provider,
execution_catalog=args.execution_catalog,
target_id=target_id,
prior_cycle_status=prior_cycle_status,
retry=args.retry,
)
@ -907,10 +891,11 @@ def cycle(args: argparse.Namespace) -> int:
def main(argv: Iterable[str] | None = None) -> int:
args = apply_defaults(parser().parse_args(argv))
args = parser().parse_args(argv)
state_path: Path | None = None
identity = "unknown"
try:
apply_defaults(args)
return cycle(args)
except (CycleError, OSError, ValueError) as exc:
try:

View file

@ -7,6 +7,7 @@ from pathlib import Path
import subprocess
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
@ -18,63 +19,63 @@ SPEC.loader.exec_module(MODULE)
REPOSITORY = Path(__file__).resolve().parents[5]
class AgentCommandTest(unittest.TestCase):
def build(self, agent: str) -> list[str]:
def catalog_value(executable: str) -> dict:
target = {
"agent": "runtime-agent",
"model": "runtime-model",
"execution_class": "cloud_model",
"selfcheck_required": False,
"runtime": {
"command": [executable, "{prompt}", "{target_id}"],
"environment": {"RUN_TARGET": "{target_id}"},
},
}
routes = {"worker": {}, "review": {}}
for stage in routes:
for lane in ("local", "cloud"):
for grade in range(1, 11):
routes[stage][f"{lane}-G{grade:02d}"] = {
"candidates": ["primary"]
}
return {"schema_version": "1.0", "targets": {"primary": target}, "routes": routes}
class ExecutionCatalogRunnerTest(unittest.TestCase):
def test_build_command_only_expands_injected_template(self) -> None:
target = SimpleNamespace(
agent="runtime-agent",
model="runtime-model",
catalog_id="target-a",
runtime={"command": ["runner", "--id", "{target_id}", "{prompt}"]},
)
with tempfile.TemporaryDirectory() as raw:
path = Path(raw)
return MODULE.build_command(
agent=agent,
command = MODULE.build_command(
target=target,
prompt="prompt",
workspace=path,
model="model-name",
reasoning_effort="high",
pi_provider="provider-name",
session_id="session-id",
attempt_dir=path,
)
self.assertEqual(command, ["runner", "--id", "target-a", "prompt"])
def test_codex_contract(self) -> None:
command = self.build("codex")
self.assertEqual(command[:3], ["codex", "exec", "--json"])
self.assertIn("--dangerously-bypass-approvals-and-sandbox", command)
def test_claude_contract(self) -> None:
command = self.build("claude")
self.assertEqual(command[0], "claude")
self.assertIn("--output-format", command)
self.assertIn("--session-id", command)
def test_gemini_maps_to_agy(self) -> None:
command = self.build("gemini")
self.assertEqual(command[0], "agy")
self.assertEqual(command[1:3], ["--print", "prompt"])
def test_pi_contract(self) -> None:
command = self.build("pi")
self.assertEqual(command[0], "pi")
self.assertIn("--mode", command)
self.assertIn("--session-id", command)
def test_probe_commands_do_not_enable_mutating_permission_bypass(self) -> None:
with tempfile.TemporaryDirectory() as raw:
path = Path(raw)
for agent in MODULE.AGENT_COMMAND:
command = MODULE.build_command(
agent=agent,
prompt="READY",
workspace=path,
model=None,
reasoning_effort=None,
pi_provider=None,
session_id="session-id",
attempt_dir=path,
probe=True,
def test_missing_catalog_is_rejected(self) -> None:
with tempfile.TemporaryDirectory(dir=REPOSITORY) as raw:
workspace = Path(raw) / "workspace"
workspace.mkdir()
subprocess.run(
["git", "init", "-b", "main", str(workspace)],
check=True,
stdout=subprocess.DEVNULL,
)
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop(MODULE.CATALOG_ENV, None)
result = MODULE.main(
["--target-id", "primary", "--workspace", str(workspace), "--probe"]
)
self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", command)
self.assertNotIn("--dangerously-skip-permissions", command)
self.assertNotIn("--approve", command)
self.assertEqual(result, 2)
def test_probe_executes_selected_command_once(self) -> None:
def test_probe_executes_catalog_target_once_and_records_revision(self) -> None:
with tempfile.TemporaryDirectory(dir=REPOSITORY) as raw:
root = Path(raw)
workspace = root / "workspace"
@ -86,12 +87,14 @@ class AgentCommandTest(unittest.TestCase):
check=True,
stdout=subprocess.DEVNULL,
)
fake = binary / "codex"
fake = binary / "runtime-runner"
fake.write_text(
"#!/bin/sh\nprintf '%s\\n' MILESTONE_AGENT_READY\n",
encoding="utf-8",
)
fake.chmod(0o755)
catalog = root / "catalog.json"
catalog.write_text(json.dumps(catalog_value("runtime-runner")), encoding="utf-8")
result_file = (
workspace
/ ".git"
@ -102,8 +105,10 @@ class AgentCommandTest(unittest.TestCase):
with mock.patch.dict(os.environ, {"PATH": f"{binary}:{os.environ['PATH']}"}):
result = MODULE.main(
[
"--agent",
"codex",
"--execution-catalog",
str(catalog),
"--target-id",
"primary",
"--workspace",
str(workspace),
"--probe",
@ -114,10 +119,15 @@ class AgentCommandTest(unittest.TestCase):
self.assertEqual(result, 0)
recorded = json.loads(result_file.read_text(encoding="utf-8"))
self.assertEqual(recorded["status"], "succeeded")
self.assertEqual(recorded["model"], "gpt-5.6-sol")
self.assertEqual(recorded["reasoning_effort"], "xhigh")
self.assertIn("agent_process_start_token", recorded)
locators = list((workspace / ".git" / "epic-work-preparation" / "runs").glob("*/locator.json"))
self.assertEqual(recorded["target_id"], "primary")
self.assertEqual(recorded["agent"], "runtime-agent")
self.assertEqual(recorded["model"], "runtime-model")
self.assertTrue(recorded["catalog"]["revision"])
locators = list(
(workspace / ".git" / "epic-work-preparation" / "runs").glob(
"*/locator.json"
)
)
self.assertEqual(len(locators), 1)

View file

@ -31,30 +31,25 @@ def command(cwd: Path, *args: str) -> str:
class EpicCycleContractTest(unittest.TestCase):
def test_cycle_defaults_to_codex_top_model_and_reasoning(self) -> None:
def test_cycle_requires_runtime_catalog_and_defaults_review_target(self) -> None:
args = MODULE.parser().parse_args(
["--workspace", "/workspace", "--milestone", "milestone.md", "--epic", "epic"]
)
MODULE.apply_defaults(args)
self.assertEqual(args.planner_agent, "codex")
self.assertEqual(args.planner_model, "gpt-5.6-sol")
self.assertEqual(args.reasoning_effort, "xhigh")
other = MODULE.parser().parse_args(
[
"--workspace",
"/workspace",
"--milestone",
"milestone.md",
"--epic",
"epic",
"--planner-agent",
"claude",
"--workspace", "/workspace",
"--milestone", "milestone.md",
"--epic", "epic",
"--execution-catalog", "/runtime/catalog.json",
"--planner-target", "planner-primary",
]
)
MODULE.apply_defaults(other)
self.assertIsNone(other.planner_model)
self.assertEqual(other.reasoning_effort, "xhigh")
MODULE.apply_defaults(args)
self.assertEqual(args.planner_target, "planner-primary")
self.assertEqual(args.review_target, "planner-primary")
missing = MODULE.parser().parse_args(
["--workspace", "/workspace", "--milestone", "milestone.md", "--epic", "epic"]
)
with self.assertRaises(MODULE.CycleError):
MODULE.apply_defaults(missing)
def test_live_stage_result_requires_tracking_without_relaunch(self) -> None:
with tempfile.TemporaryDirectory() as raw:
@ -84,10 +79,8 @@ class EpicCycleContractTest(unittest.TestCase):
identity="sample-epic",
stage="materialize",
prompt="prompt",
agent="codex",
model=None,
reasoning_effort=None,
pi_provider=None,
execution_catalog="/runtime/catalog.json",
target_id="planner-primary",
prior_cycle_status="tracking",
retry=False,
)
@ -119,10 +112,8 @@ class EpicCycleContractTest(unittest.TestCase):
"identity": "sample-epic",
"stage": "materialize",
"prompt": "prompt",
"agent": "codex",
"model": None,
"reasoning_effort": None,
"pi_provider": None,
"execution_catalog": "/runtime/catalog.json",
"target_id": "planner-primary",
"prior_cycle_status": "tracking",
}
with self.assertRaises(MODULE.TrackingRecoveryRequired):
@ -209,7 +200,7 @@ class EpicCycleContractTest(unittest.TestCase):
{"first-task", "second-task"},
)
def test_full_cycle_with_fresh_fake_codex_passes_and_pushes(self) -> None:
def test_full_cycle_with_fresh_injected_target_passes_and_pushes(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
remote = root / "remote.git"
@ -274,8 +265,10 @@ class EpicCycleContractTest(unittest.TestCase):
str(milestone.relative_to(workspace)),
"--epic",
"sample-epic",
"--planner-agent",
"codex",
"--execution-catalog",
"/runtime/catalog.json",
"--planner-target",
"planner-primary",
]
)
self.assertEqual(result, 0)
@ -302,8 +295,10 @@ class EpicCycleContractTest(unittest.TestCase):
str(milestone.relative_to(workspace)),
"--epic",
"sample-epic",
"--planner-agent",
"codex",
"--execution-catalog",
"/runtime/catalog.json",
"--planner-target",
"planner-primary",
"--batch-task-ids",
"large-task,later-task",
]
@ -333,8 +328,10 @@ class EpicCycleContractTest(unittest.TestCase):
str(milestone.relative_to(workspace)),
"--epic",
"sample-epic",
"--planner-agent",
"codex",
"--execution-catalog",
"/runtime/catalog.json",
"--planner-target",
"planner-primary",
]
)
self.assertEqual(resumed, 0)

View file

@ -1,6 +1,6 @@
---
name: prepare-milestone-workspace
description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다.
description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../sample-feature-worktree 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다.
---
# Prepare Milestone Workspace
@ -15,11 +15,9 @@ description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature
- `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수)
- `workspace`: 생성 모드에서는 feature worktree 절대 경로 또는 develop repository root 기준 상대 경로가 필수다. 현재 workspace 실행 모드에서는 현재 repository root를 사용한다.
- `planner-agent`: `codex`, `claude`, `gemini`, `pi` 중 하나 (생략 시 `codex`)
- `review-agent`: 생략하면 `planner-agent`와 같다. (선택)
- `planner-model`, `review-model`: provider별 model override. Codex 기본 사용 시 `planner-model` 생략 시 `gpt-5.6-sol`, 다른 provider는 해당 CLI 기본 모델을 사용한다. (선택)
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
- `pi-provider`: Pi provider override (선택)
- `execution-catalog`: 런타임이 주입한 agent-model 실행 카탈로그 경로. `AGENT_TASK_EXECUTION_CATALOG`로 대신 주입할 수 있다. (필수)
- `planner-target`: 카탈로그에 선언된 Epic materialize/refine 실행 target id. `AGENT_TASK_PLANNER_TARGET`로 대신 주입할 수 있다. (필수)
- `review-target`: 카탈로그에 선언된 review 실행 target id. `AGENT_TASK_REVIEW_TARGET`로 주입하거나 생략하면 `planner-target`과 같다. (선택)
- `target-epics`: `remaining`, `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택)
- `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택)
@ -32,7 +30,7 @@ description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature
- 두 모드 모두 `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다.
- `sync-milestone-workstate mode=consistency-check``ready`여야 한다.
- remote와 `gitflow.branch.develop`, `gitflow.prefix.feature`를 확인할 수 있어야 한다.
- 선택 agent의 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다.
- 선택 target의 카탈로그 검증과 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다.
## 절차
@ -50,12 +48,14 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
--repo "$REPO" \
--milestone "$MILESTONE" \
--workspace "$WORKSPACE" \
--epics "$EPICS"
--epics "$EPICS" \
--execution-catalog "$EXECUTION_CATALOG" \
--planner-target "$PLANNER_TARGET" \
--review-target "$REVIEW_TARGET"
```
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
- 다른 agent, model, reasoning, Pi provider override가 있으면 해당 인자를 전달한다.
- 스크립트는 develop HEAD와 remote develop의 일치, agent probe, branch 충돌, worktree 소유권을 mutation 전에 검사한다.
- agent, model, 실행 명령과 provider별 옵션은 스킬이나 스크립트에 고정하지 않고 주입된 카탈로그 target에서 가져온다.
- 스크립트는 develop HEAD와 remote develop의 일치, target probe, branch 충돌, worktree 소유권을 mutation 전에 검사한다.
- branch는 Milestone id가 아니라 파일 basename을 사용한 `feature/<milestone-slug>`다.
- 기존 branch/worktree는 정확히 같은 branch·경로이고 clean할 때만 재개한다.
- remote branch 생성 뒤 후속 단계가 실패해도 branch/worktree를 자동 삭제하지 않는다.
@ -66,7 +66,10 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
--existing-workspace \
--workspace "$CURRENT_WORKSPACE" \
--milestone "$MILESTONE" \
--epics "$EPICS"
--epics "$EPICS" \
--execution-catalog "$EXECUTION_CATALOG" \
--planner-target "$PLANNER_TARGET" \
--review-target "$REVIEW_TARGET"
```
- 현재 workspace가 target feature branch/current와 다르면 다른 worktree를 탐색하거나 branch를 바꾸지 않고 `FAILED`로 멈춘다.
@ -81,7 +84,7 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
4. **전체 준비 배리어 뒤 dispatcher로 전환한다**
- 모든 선택 Epic이 `EPIC_WORK_ITEMS_READY` 또는 `EPIC_COMPLETED`이고 deterministic batch validation과 모든 push가 끝난 경우에만 `MILESTONE_WORK_ITEMS_READY`를 낸다.
- active plan이 있으면 private/project/common 우선순위로 `orchestrate-agent-task-loop` dispatcher를 선택하고 같은 task group `m-<milestone-slug>``--dry-run`을 먼저 실행한 뒤 live를 정확히 한 번 시작한다.
- active plan이 있으면 공통 `orchestrate-agent-task-loop` dispatcher에 런타임 카탈로그를 주입하고 같은 task group `m-<milestone-slug>``--dry-run`을 먼저 실행한 뒤 live를 정확히 한 번 시작한다.
- 모든 선택 Epic이 `EPIC_COMPLETED`이면 dispatcher를 생략한다.
- foreground dispatcher가 종료될 때까지 caller는 timer polling이나 상태 파일 검사를 하지 않는다. batch/dispatcher PID와 start token은 git common dir 상태에 기록해 재진입 중복 실행을 막는다.

View file

@ -9,17 +9,12 @@ import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, Iterable, NamedTuple
VALID_AGENTS = {"codex", "claude", "gemini", "pi"}
AGENT_COMMAND = {"codex": "codex", "claude": "claude", "gemini": "agy", "pi": "pi"}
DEFAULT_PLANNER_AGENT = "codex"
DEFAULT_PLANNER_MODEL = "gpt-5.6-sol"
DEFAULT_REASONING_EFFORT = "xhigh"
CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG"
MILESTONE_PATTERN = re.compile(
r"^agent-roadmap/phase/(?P<phase>[a-z0-9-]+)/milestones/(?P<slug>[a-z0-9-]+)\.md$"
)
@ -345,14 +340,11 @@ def worktrees(repo: Path) -> list[dict[str, str]]:
return records
def probe_agents(
def probe_targets(
repo: Path,
planner_agent: str,
reviewer_agent: str,
planner_model: str | None,
reviewer_model: str | None,
reasoning_effort: str | None,
pi_provider: str | None,
execution_catalog: str,
planner_target: str,
review_target: str,
) -> None:
runner = (
Path(__file__).resolve().parents[2]
@ -362,33 +354,27 @@ def probe_agents(
)
if not runner.is_file():
raise PreparationError(f"agent runner not found: {runner}")
seen: set[tuple[str, str | None]] = set()
for agent, model in (
(planner_agent, planner_model),
(reviewer_agent, reviewer_model),
):
identity = (agent, model)
if identity in seen:
seen: set[str] = set()
for target_id in (planner_target, review_target):
if target_id in seen:
continue
seen.add(identity)
seen.add(target_id)
command = [
sys.executable,
str(runner),
"--agent",
agent,
"--execution-catalog",
execution_catalog,
"--target-id",
target_id,
"--workspace",
str(repo),
"--probe",
]
if model:
command.extend(["--model", model])
if reasoning_effort:
command.extend(["--reasoning-effort", reasoning_effort])
if agent == "pi" and pi_provider:
command.extend(["--pi-provider", pi_provider])
result = run(command, cwd=repo, check=False, capture=False)
if result.returncode != 0:
raise PreparationError(f"agent capability probe failed: agent={agent} model={model or 'default'}")
raise PreparationError(
f"execution target capability probe failed: target_id={target_id}"
)
def render_current(
@ -440,14 +426,7 @@ def epic_cycle_script(workspace: Path) -> Path:
def dispatcher_script(workspace: Path) -> Path:
project = workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
private = workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
if project.is_dir() and private.is_dir():
root = private
elif project.is_dir():
root = project
else:
root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
path = root / "scripts" / "dispatch.py"
if not path.is_file():
raise PreparationError(f"dispatcher script not found: {path}")
@ -472,21 +451,15 @@ def epic_cycle_command(
str(milestone),
"--epic",
epic.epic_id,
"--planner-agent",
args.planner_agent,
"--execution-catalog",
args.execution_catalog,
"--planner-target",
args.planner_target,
"--batch-task-ids",
",".join(batch_ids),
]
if args.review_agent:
command.extend(["--review-agent", args.review_agent])
if args.planner_model:
command.extend(["--planner-model", args.planner_model])
if args.review_model:
command.extend(["--review-model", args.review_model])
if args.reasoning_effort:
command.extend(["--reasoning-effort", args.reasoning_effort])
if args.pi_provider:
command.extend(["--pi-provider", args.pi_provider])
if args.review_target:
command.extend(["--review-target", args.review_target])
if validate_only:
command.append("--validate-only")
elif args.retry:
@ -549,10 +522,7 @@ def cross_epic_review(
batch_ids: list[str],
common: Path,
) -> tuple[int, dict[str, Any] | None]:
reviewer_agent = args.review_agent or args.planner_agent
reviewer_model = args.review_model or (
args.planner_model if reviewer_agent == args.planner_agent else None
)
reviewer_target = args.review_target or args.planner_target
state_root = common / "milestone-work-preparation" / milestone_slug
prompt_path = state_root / "prompts" / "cross-epic-review.txt"
result_path = (
@ -623,8 +593,10 @@ Review the complete prepared artifact union across these Epics from a fresh cont
command = [
sys.executable,
str(runner),
"--agent",
reviewer_agent,
"--execution-catalog",
args.execution_catalog,
"--target-id",
reviewer_target,
"--workspace",
str(workspace),
"--prompt-file",
@ -634,12 +606,6 @@ Review the complete prepared artifact union across these Epics from a fresh cont
"--result-file",
str(result_path),
]
if reviewer_model:
command.extend(["--model", reviewer_model])
if args.reasoning_effort:
command.extend(["--reasoning-effort", args.reasoning_effort])
if reviewer_agent == "pi" and args.pi_provider:
command.extend(["--pi-provider", args.pi_provider])
starting_head = git(workspace, "rev-parse", "HEAD")
result = run(command, cwd=workspace, check=False, capture=False)
if git(workspace, "rev-parse", "HEAD") != starting_head:
@ -696,6 +662,9 @@ def coordinate_batch(
"workspace": str(workspace),
"selected_epics": [epic.epic_id for epic in selected],
"batch_task_ids": batch_ids,
"execution_catalog": str(Path(args.execution_catalog).expanduser().resolve()),
"planner_target": args.planner_target,
"review_target": args.review_target,
}
state_path = common / "milestone-work-preparation" / milestone_slug / "batch-state.json"
state = read_json(state_path)
@ -922,6 +891,8 @@ def coordinate_batch(
str(workspace),
"--task-group",
task_group,
"--execution-catalog",
args.execution_catalog,
"--dry-run",
],
cwd=workspace,
@ -943,6 +914,8 @@ def coordinate_batch(
str(workspace),
"--task-group",
task_group,
"--execution-catalog",
args.execution_catalog,
]
if resume_blocked_dispatcher and args.retry:
command.append("--retry-blocked")
@ -1023,16 +996,13 @@ def parser() -> argparse.ArgumentParser:
action="store_true",
help="start selected Epic work in the current prepared feature workspace",
)
value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV))
value.add_argument(
"--planner-agent",
choices=sorted(VALID_AGENTS),
default=DEFAULT_PLANNER_AGENT,
"--planner-target", default=os.environ.get("AGENT_TASK_PLANNER_TARGET")
)
value.add_argument(
"--review-target", default=os.environ.get("AGENT_TASK_REVIEW_TARGET")
)
value.add_argument("--review-agent", choices=sorted(VALID_AGENTS))
value.add_argument("--planner-model")
value.add_argument("--review-model")
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
value.add_argument("--pi-provider")
value.add_argument(
"--epics",
help="prepare and dispatch remaining/first-incomplete/one/list/range selector",
@ -1053,10 +1023,17 @@ def parser() -> argparse.ArgumentParser:
def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
if args.planner_model is None and args.planner_agent == DEFAULT_PLANNER_AGENT:
args.planner_model = DEFAULT_PLANNER_MODEL
if args.reasoning_effort is None:
args.reasoning_effort = DEFAULT_REASONING_EFFORT
if not args.execution_catalog:
raise PreparationError(
f"--execution-catalog or {CATALOG_ENV} is required"
)
if not args.planner_target:
raise PreparationError(
"--planner-target or AGENT_TASK_PLANNER_TARGET is required"
)
args.execution_catalog = str(Path(args.execution_catalog).expanduser().resolve())
if args.review_target is None:
args.review_target = args.planner_target
return args
@ -1103,15 +1080,6 @@ def prepare_existing(args: argparse.Namespace) -> int:
raise PreparationError(
f"workspace-local current does not select target Milestone: {current_path}"
)
reviewer_agent = args.review_agent or args.planner_agent
reviewer_model = args.review_model or (
args.planner_model if reviewer_agent == args.planner_agent else None
)
for agent in {args.planner_agent, reviewer_agent} if selected else set():
command = AGENT_COMMAND[agent]
if shutil.which(command) is None and not args.skip_agent_probe:
raise PreparationError(f"agent command not found: agent={agent} command={command}")
common = git_common_dir(workspace)
state_root = common / "milestone-work-preparation" / milestone_slug
state_root.mkdir(parents=True, exist_ok=True)
@ -1169,14 +1137,11 @@ def prepare_existing(args: argparse.Namespace) -> int:
if args.dry_run:
return 0
if selected and not args.skip_agent_probe and not resuming_batch:
probe_agents(
probe_targets(
workspace,
args.planner_agent,
reviewer_agent,
args.planner_model,
reviewer_model,
args.reasoning_effort,
args.pi_provider,
args.execution_catalog,
args.planner_target,
args.review_target,
)
ensure_clean(workspace, "feature workspace after agent probe")
state = {
@ -1185,9 +1150,9 @@ def prepare_existing(args: argparse.Namespace) -> int:
"milestone_slug": milestone_slug,
"branch": branch,
"workspace": str(workspace),
"planner_agent": args.planner_agent,
"review_agent": reviewer_agent,
"reasoning_effort": args.reasoning_effort,
"execution_catalog": args.execution_catalog,
"planner_target": args.planner_target,
"review_target": args.review_target,
"existing_workspace": True,
}
atomic_json(state_path, state)
@ -1227,15 +1192,6 @@ def prepare(args: argparse.Namespace) -> int:
pass
else:
raise PreparationError("feature workspace must not be nested inside the develop checkout")
reviewer_agent = args.review_agent or args.planner_agent
reviewer_model = args.review_model or (
args.planner_model if reviewer_agent == args.planner_agent else None
)
for agent in {args.planner_agent, reviewer_agent}:
command = AGENT_COMMAND[agent]
if shutil.which(command) is None and not args.skip_agent_probe:
raise PreparationError(f"agent command not found: agent={agent} command={command}")
common = git_common_dir(repo)
state_root = common / "milestone-work-preparation" / milestone_slug
state_path = state_root / "workspace-state.json"
@ -1274,14 +1230,11 @@ def prepare(args: argparse.Namespace) -> int:
)
if not args.skip_agent_probe and not args.dry_run:
probe_agents(
probe_targets(
repo,
args.planner_agent,
reviewer_agent,
args.planner_model,
reviewer_model,
args.reasoning_effort,
args.pi_provider,
args.execution_catalog,
args.planner_target,
args.review_target,
)
ensure_clean(repo, "develop checkout after agent probe")
@ -1369,9 +1322,9 @@ def prepare(args: argparse.Namespace) -> int:
"milestone_slug": milestone_slug,
"branch": branch,
"workspace": str(workspace),
"planner_agent": args.planner_agent,
"review_agent": reviewer_agent,
"reasoning_effort": args.reasoning_effort,
"execution_catalog": args.execution_catalog,
"planner_target": args.planner_target,
"review_target": args.review_target,
}
atomic_json(state_path, state)
emit("WORKSPACE_READY", **state)
@ -1388,8 +1341,9 @@ def prepare(args: argparse.Namespace) -> int:
def main(argv: Iterable[str] | None = None) -> int:
args = apply_defaults(parser().parse_args(argv))
args = parser().parse_args(argv)
try:
apply_defaults(args)
return prepare_existing(args) if args.existing_workspace else prepare(args)
except (OSError, PreparationError) as exc:
emit("FAILED", reason=str(exc))

View file

@ -31,11 +31,24 @@ def command(cwd: Path, *args: str) -> str:
class PrepareWorkspaceTest(unittest.TestCase):
def setUp(self) -> None:
self.runtime_environment = mock.patch.dict(
os.environ,
{
"AGENT_TASK_EXECUTION_CATALOG": "/runtime/catalog.json",
"AGENT_TASK_PLANNER_TARGET": "planner-primary",
},
)
self.runtime_environment.start()
def tearDown(self) -> None:
self.runtime_environment.stop()
def test_relative_workspace_is_resolved_from_repository_root(self) -> None:
repo = Path("/tmp/example/iop")
repo = Path("/tmp/example/sample-repo")
self.assertEqual(
MODULE.resolve_workspace(repo, "../iop-s1"),
Path("/tmp/example/iop-s1"),
MODULE.resolve_workspace(repo, "../sample-feature-worktree"),
Path("/tmp/example/sample-feature-worktree"),
)
def test_epic_document_range_is_one_based_and_inclusive(self) -> None:
@ -94,14 +107,14 @@ class PrepareWorkspaceTest(unittest.TestCase):
[],
)
def test_workspace_defaults_to_codex_top_model_and_reasoning(self) -> None:
def test_workspace_uses_runtime_injected_catalog_and_targets(self) -> None:
args = MODULE.parser().parse_args(
["--repo", "/repo", "--milestone", "milestone.md", "--workspace", "/workspace"]
)
MODULE.apply_defaults(args)
self.assertEqual(args.planner_agent, "codex")
self.assertEqual(args.planner_model, "gpt-5.6-sol")
self.assertEqual(args.reasoning_effort, "xhigh")
self.assertEqual(args.execution_catalog, "/runtime/catalog.json")
self.assertEqual(args.planner_target, "planner-primary")
self.assertEqual(args.review_target, "planner-primary")
other = MODULE.parser().parse_args(
[
@ -111,13 +124,15 @@ class PrepareWorkspaceTest(unittest.TestCase):
"milestone.md",
"--workspace",
"/workspace",
"--planner-agent",
"claude",
"--planner-target",
"planner-secondary",
"--review-target",
"review-primary",
]
)
MODULE.apply_defaults(other)
self.assertIsNone(other.planner_model)
self.assertEqual(other.reasoning_effort, "xhigh")
self.assertEqual(other.planner_target, "planner-secondary")
self.assertEqual(other.review_target, "review-primary")
def test_agent_probe_bypass_is_test_only(self) -> None:
output = io.StringIO()
@ -132,8 +147,8 @@ class PrepareWorkspaceTest(unittest.TestCase):
"missing.md",
"--workspace",
"/missing-workspace",
"--planner-agent",
"codex",
"--planner-target",
"planner-primary",
"--skip-agent-probe",
]
)
@ -183,8 +198,8 @@ class PrepareWorkspaceTest(unittest.TestCase):
str(milestone.relative_to(repo)),
"--workspace",
str(worktree),
"--planner-agent",
"codex",
"--planner-target",
"planner-primary",
"--skip-agent-probe",
]
)
@ -532,6 +547,9 @@ class PrepareWorkspaceTest(unittest.TestCase):
"workspace": str(workspace),
"selected_epics": ["first"],
"batch_task_ids": ["first-task"],
"execution_catalog": "/runtime/catalog.json",
"planner_target": "planner-primary",
"review_target": "planner-primary",
"status": "dispatching",
"epic_events": {"first": "EPIC_WORK_ITEMS_READY"},
"dispatcher_pid": os.getpid(),