chore: push all design docs, V2 plan specs, and current working state

Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2,
AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code
changes across packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-12 17:12:29 +08:00
parent 8f55c962bb
commit ae44be31d5
364 changed files with 46779 additions and 2812 deletions

View File

@@ -0,0 +1,103 @@
from .contracts import (
DEFAULT_DEBUG_POLICY,
DEFAULT_XDB_POLICY,
DEFAULT_REPAIR_POLICY,
ParallelGroup,
ParallelReview,
RepairAttempt,
ReviewConflict,
TaskRecord,
ValidationRecord,
WorkerResult,
XdbSession,
default_worker_result,
now_iso,
)
from .airxdb_runtime import (
ensure_xdb_sessions_for_result,
list_xdb_sessions,
load_xdb_policy,
merge_xdb_sessions_into_state,
normalize_xdb_policy,
task_requires_xdb,
)
from .debug_runtime import (
ensure_debug_sessions_for_result,
list_debug_sessions,
load_debug_policy,
merge_debug_sessions_into_state,
normalize_debug_policy,
)
from .repair_runtime import (
ensure_repair_attempts_for_result,
list_repair_attempts,
load_active_repair_attempt,
load_repair_policy,
merge_repair_attempts_into_state,
normalize_repair_policy,
summarize_active_repairs,
write_repair_queue,
)
from .engine import (
artifact_health,
build_engine_plan,
enter_engine,
merge_worker_result,
status_engine,
)
from .doc_sync import (
apply_document_updates,
enforce_doc_sync_requirements,
sync_engine_managed_docs,
update_todo_after_merge,
)
from .review import build_parallel_review, render_review_markdown
from .todo_parser import find_task, parse_tasks
__all__ = [
"DEFAULT_DEBUG_POLICY",
"DEFAULT_XDB_POLICY",
"DEFAULT_REPAIR_POLICY",
"ParallelGroup",
"ParallelReview",
"RepairAttempt",
"ReviewConflict",
"TaskRecord",
"ValidationRecord",
"WorkerResult",
"XdbSession",
"artifact_health",
"apply_document_updates",
"build_engine_plan",
"build_parallel_review",
"default_worker_result",
"ensure_xdb_sessions_for_result",
"ensure_debug_sessions_for_result",
"enforce_doc_sync_requirements",
"enter_engine",
"find_task",
"list_xdb_sessions",
"list_debug_sessions",
"list_repair_attempts",
"load_active_repair_attempt",
"load_xdb_policy",
"load_debug_policy",
"load_repair_policy",
"merge_xdb_sessions_into_state",
"merge_debug_sessions_into_state",
"merge_repair_attempts_into_state",
"merge_worker_result",
"normalize_xdb_policy",
"normalize_debug_policy",
"normalize_repair_policy",
"now_iso",
"parse_tasks",
"render_review_markdown",
"summarize_active_repairs",
"sync_engine_managed_docs",
"task_requires_xdb",
"status_engine",
"update_todo_after_merge",
"write_repair_queue",
"ensure_repair_attempts_for_result",
]

View File

@@ -0,0 +1,817 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
from .contracts import (
DEFAULT_XDB_POLICY,
TaskRecord,
ValidationRecord,
WorkerResult,
XdbSession,
now_iso,
)
from .paths import (
agents_path,
aireng_root,
airxdb_artifacts_dir,
airxdb_root,
architecture_adr_dir,
architecture_c4_module_path,
gui_debug_log_path,
todo_path,
)
from .todo_parser import find_task, parse_tasks
def _json_load(path: Path) -> Dict[str, object]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8-sig"))
def _json_dump(path: Path, payload: Dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def _ordered_unique(items: Iterable[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in items:
cleaned = str(item).strip()
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered
def normalize_xdb_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
merged = dict(DEFAULT_XDB_POLICY)
if policy:
merged.update(policy)
merged["enabled"] = bool(merged.get("enabled", True))
merged["requireForGuiTasks"] = bool(merged.get("requireForGuiTasks", True))
merged["captureOnBlocked"] = bool(merged.get("captureOnBlocked", True))
merged["captureOnDone"] = bool(merged.get("captureOnDone", True))
merged["preferOriginalAirXDB"] = bool(merged.get("preferOriginalAirXDB", True))
merged["remoteFirstIfConfigured"] = bool(merged.get("remoteFirstIfConfigured", True))
merged["triggerValidationStatuses"] = _ordered_unique(
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
)
merged["taskKeywords"] = _ordered_unique(
str(item).lower() for item in list(merged.get("taskKeywords", []))
)
try:
max_sessions = int(merged.get("maxSessionsPerTask", 1) or 1)
except (TypeError, ValueError):
max_sessions = 1
merged["maxSessionsPerTask"] = max(1, max_sessions)
return merged
def load_xdb_policy(project_root: Path) -> Dict[str, object]:
state_path = aireng_root(project_root) / "state.json"
state = _json_load(state_path)
return normalize_xdb_policy(state.get("xdbPolicy", {}))
def merge_xdb_sessions_into_state(
state: Dict[str, object], xdb_sessions: List[XdbSession]
) -> Dict[str, object]:
state["xdbPolicy"] = normalize_xdb_policy(dict(state.get("xdbPolicy", {})))
existing_items = list(state.get("xdbSessions", []))
seen_ids = {
str(item.get("sessionId", "")).strip()
for item in existing_items
if isinstance(item, dict)
}
for session in xdb_sessions:
if session.session_id in seen_ids:
continue
existing_items.append(session.to_dict())
seen_ids.add(session.session_id)
task_counts: Dict[str, int] = {}
for item in existing_items:
task_id = str(item.get("taskId", "")).strip()
if not task_id:
continue
task_counts[task_id] = task_counts.get(task_id, 0) + 1
state["xdbSessions"] = existing_items
state["taskXdbCounts"] = task_counts
if xdb_sessions:
latest = xdb_sessions[-1]
state["lastXdbSessionId"] = latest.session_id
state["lastXdbTaskId"] = latest.task_id
state["lastXdbAt"] = latest.created_at
return state
def _xdb_paths(project_root: Path) -> Dict[str, Path]:
root = airxdb_root(project_root)
return {
"root": root,
"state": root / "state.json",
"requests": root / "requests",
"sessions": root / "sessions",
"artifacts": airxdb_artifacts_dir(project_root),
"gui_debug_log": gui_debug_log_path(project_root),
}
def _airxdb_health(project_root: Path) -> Dict[str, bool]:
paths = _xdb_paths(project_root)
return {
"AirPlan/AGENTS.md": agents_path(project_root).exists(),
"AirPlan/docs/architecture/c4/module.md": architecture_c4_module_path(project_root).exists(),
"AirPlan/docs/architecture/adr": architecture_adr_dir(project_root).exists(),
"gui_debug_log": paths["gui_debug_log"].exists(),
}
def _ensure_xdb_layout(project_root: Path) -> Dict[str, Path]:
paths = _xdb_paths(project_root)
paths["requests"].mkdir(parents=True, exist_ok=True)
paths["sessions"].mkdir(parents=True, exist_ok=True)
paths["artifacts"].mkdir(parents=True, exist_ok=True)
paths["gui_debug_log"].parent.mkdir(parents=True, exist_ok=True)
if not paths["gui_debug_log"].exists():
paths["gui_debug_log"].write_text("# GUI Debug Log\n\n", encoding="utf-8")
return paths
def _resolve_original_airxdb_script(script_name: str) -> Path | None:
candidates = [
Path.home() / "plugins" / "airxdb" / "scripts" / script_name,
Path(r"C:\Users\20392\plugins\airxdb\scripts") / script_name,
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _bootstrap_airxdb(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]:
mode = "runtime-bootstrap"
stdout = ""
error = ""
script_path = (
_resolve_original_airxdb_script("airxdb_mode.py") if prefer_original else None
)
if script_path is not None:
try:
completed = subprocess.run(
[
sys.executable,
str(script_path),
"--mode",
"enter",
"--project",
str(project_root),
],
capture_output=True,
text=True,
check=True,
)
mode = "original-airxdb"
stdout = completed.stdout.strip()
except subprocess.CalledProcessError as exc:
mode = "runtime-fallback"
stdout = exc.stdout.strip()
error = exc.stderr.strip() or str(exc)
paths = _ensure_xdb_layout(project_root)
state = _json_load(paths["state"])
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airxdb_health(project_root)
if stdout:
state["lastBootstrapStdout"] = stdout
if error:
state["lastBootstrapError"] = error
_json_dump(paths["state"], state)
return mode, stdout, error
def list_xdb_sessions(project_root: Path, task_id: str = "") -> List[XdbSession]:
session_dir = _xdb_paths(project_root)["sessions"]
if not session_dir.exists():
return []
sessions: List[XdbSession] = []
for path in sorted(session_dir.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
session_payload = payload.get("xdbSession", payload)
session = XdbSession.from_dict(session_payload)
session.validate()
except (json.JSONDecodeError, TypeError, ValueError):
continue
if task_id and session.task_id != task_id:
continue
sessions.append(session)
sessions.sort(key=lambda item: (item.created_at, item.session_id))
return sessions
def _load_task(project_root: Path, task_id: str) -> TaskRecord | None:
current_todo_path = todo_path(project_root)
if not current_todo_path.exists():
return None
try:
return find_task(parse_tasks(current_todo_path), task_id)
except ValueError:
return None
def _parse_env_file(path: Path) -> Dict[str, str]:
if not path.exists():
return {}
payload: Dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and value:
payload[key] = value
return payload
def _is_remote_configured(project_root: Path) -> bool:
if os.environ.get("AIRXDB_REMOTE_SSH_TARGET", "").strip():
return True
remote_env = airxdb_root(project_root) / "remote-device.env"
return bool(_parse_env_file(remote_env).get("AIRXDB_REMOTE_SSH_TARGET", "").strip())
def task_requires_xdb(
task: TaskRecord | None,
result: WorkerResult | None,
policy: Dict[str, object] | None = None,
) -> bool:
effective_policy = normalize_xdb_policy(policy)
if not bool(effective_policy.get("enabled", True)):
return False
if not bool(effective_policy.get("requireForGuiTasks", True)):
return False
keyword_set = {
str(item).lower().strip()
for item in list(effective_policy.get("taskKeywords", []))
if str(item).strip()
}
search_parts = []
if task is not None:
search_parts.extend(
[
task.module,
task.task,
task.files_dirs,
task.done_when,
task.validation,
task.adr_c4_update,
]
)
if result is not None:
search_parts.extend(
[
result.summary,
" ".join(result.files_changed),
" ".join(result.evidence_paths),
" ".join(result.blockers),
" ".join(item.kind for item in result.validations),
" ".join(item.command for item in result.validations),
]
)
blob = " ".join(search_parts).lower()
for keyword in keyword_set:
pattern = re.compile(rf"(?<![a-z0-9]){re.escape(keyword)}(?![a-z0-9])")
if pattern.search(blob):
return True
return False
def _result_trigger_summary(
task: TaskRecord | None,
result: WorkerResult,
policy: Dict[str, object],
) -> Tuple[bool, List[str], str]:
if not task_requires_xdb(task, result, policy):
return False, [], ""
triggers: List[str] = []
reasons: List[str] = []
if result.status == "done" and bool(policy.get("captureOnDone", True)):
triggers.append("done-acceptance")
reasons.append("GUI acceptance requires AirXDB evidence before closure")
if result.status == "blocked" and bool(policy.get("captureOnBlocked", True)):
triggers.append("blocked-visual")
reasons.append("blocked GUI result should capture current screen evidence")
tracked_validation_statuses = {
str(item).lower() for item in list(policy.get("triggerValidationStatuses", []))
}
failed_validations = [
item
for item in result.validations
if item.status.lower() in tracked_validation_statuses
]
if failed_validations:
triggers.append("validation-failure")
reasons.append(
"GUI-related validation failures: "
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
)
return bool(triggers), triggers, "; ".join(reasons)
def _session_stamp() -> str:
return now_iso().replace(":", "-").replace(".", "-").replace("+", "-")
def _session_trigger_set(session: XdbSession) -> set[str]:
return {
item.strip()
for item in session.trigger.split(",")
if item.strip()
}
def _session_matches_triggers(session: XdbSession, triggers: List[str]) -> bool:
if not triggers:
return False
trigger_set = set(triggers)
return trigger_set.issubset(_session_trigger_set(session))
def _parse_kv_output(stdout: str) -> Dict[str, str]:
payload: Dict[str, str] = {}
for raw_line in stdout.splitlines():
line = raw_line.strip()
if not line or "=" not in line:
continue
key, value = line.split("=", 1)
payload[key.strip()] = value.strip()
return payload
def _load_report_payload(report_path: Path) -> Dict[str, object]:
if not report_path or not report_path.exists() or not report_path.is_file():
return {}
try:
return json.loads(report_path.read_text(encoding="utf-8-sig"))
except json.JSONDecodeError:
return {}
def _extract_report_images(report_payload: Dict[str, object]) -> List[str]:
images: List[str] = []
screenshot = str(report_payload.get("screenshot", "")).strip()
if screenshot:
images.append(screenshot)
for step in list(report_payload.get("steps", [])):
if not isinstance(step, dict):
continue
for image in list(step.get("images", [])):
cleaned = str(image).strip()
if cleaned:
images.append(cleaned)
return _ordered_unique(images)
def _capture_airxdb(
project_root: Path, policy: Dict[str, object]
) -> Tuple[str, str, Dict[str, str], str, str]:
prefer_original = bool(policy.get("preferOriginalAirXDB", True))
output_dir = _xdb_paths(project_root)["artifacts"]
remote_first = bool(policy.get("remoteFirstIfConfigured", True)) and _is_remote_configured(
project_root
)
def run_capture(script_name: str, mode: str, remote_mode: bool) -> Tuple[str, str, Dict[str, str], str, str]:
script_path = _resolve_original_airxdb_script(script_name) if prefer_original else None
if script_path is None:
return (
mode,
"failed",
{},
"",
f"AirXDB helper script not found: {script_name}",
)
command = [
sys.executable,
str(script_path),
"--project",
str(project_root),
"--output-dir",
str(output_dir),
"--action",
"screenshot",
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
stdout = completed.stdout.strip()
stderr = completed.stderr.strip()
parsed = _parse_kv_output(stdout)
status_key = "airxdb_remote_status" if remote_mode else "airxdb_smoke"
status = parsed.get(status_key, "")
if not status:
status = "ok" if completed.returncode == 0 else "failed"
if completed.returncode != 0 and status == "ok":
status = "failed"
return mode, status, parsed, stdout, stderr
if not remote_first:
return run_capture("airxdb_computer_mcp_smoke.py", "local-screenshot", False)
remote_attempt = run_capture("airxdb_remote_device.py", "remote-screenshot", True)
if remote_attempt[1] == "ok":
return remote_attempt
local_attempt = run_capture("airxdb_computer_mcp_smoke.py", "local-screenshot", False)
if local_attempt[1] == "ok":
parsed = dict(local_attempt[2])
parsed["fallbackFrom"] = remote_attempt[0]
parsed["fallbackRemoteStdout"] = remote_attempt[3]
parsed["fallbackRemoteError"] = remote_attempt[4]
return (
"remote-fallback-local",
local_attempt[1],
parsed,
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[3]}",
f"[local-attempt]\n{local_attempt[3]}",
]
if part.strip()
),
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[4]}",
f"[local-attempt]\n{local_attempt[4]}",
]
if part.strip()
),
)
parsed = dict(remote_attempt[2])
parsed["fallbackLocalStdout"] = local_attempt[3]
parsed["fallbackLocalError"] = local_attempt[4]
return (
"remote-fallback-local",
"failed",
parsed,
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[3]}",
f"[local-attempt]\n{local_attempt[3]}",
]
if part.strip()
),
"\n".join(
part
for part in [
f"[remote-attempt]\n{remote_attempt[4]}",
f"[local-attempt]\n{local_attempt[4]}",
]
if part.strip()
),
)
def _append_gui_debug_log(
gui_debug_log_path: Path,
task: TaskRecord | None,
result: WorkerResult,
session: XdbSession,
bootstrap_mode: str,
) -> None:
target_surface = task.module if task is not None else "unknown"
screenshot_text = ", ".join(f"`{item}`" for item in session.screenshots) or "`none`"
entry_lines = [
f"### {session.created_at}: auto-xdb capture for {result.task_id}",
"",
f"- Target surface: {target_surface}",
f"- Task: `{result.task_id}`",
f"- Source: `{session.source}`",
f"- Trigger: `{session.trigger}`",
f"- Midscene mode: `{session.mode}`",
f"- Symptom: {result.summary}",
"- Expected: GUI acceptance evidence should exist before closure, and blocked GUI paths should keep reproducible screen evidence.",
f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}",
f"- Report path: `{session.report_path or 'n/a'}`",
f"- Screenshots: {screenshot_text}",
f"- AirDbg handoff: linked debug sessions={', '.join(f'`{item.session_id}`' for item in result.debug_sessions) or '`none`'}",
f"- Validation after fix: pending runtime merge; bootstrap mode `{bootstrap_mode}`; xdb status `{session.status}`",
f"- ADR/C4 updates: {'required' if task and task.global_doc_paths else 'none'}",
f"- Residual risk: {'GUI acceptance is not complete until XDB evidence is successful.' if session.status != 'captured' else 'none'}",
"",
]
existing = (
gui_debug_log_path.read_text(encoding="utf-8")
if gui_debug_log_path.exists()
else "# GUI Debug Log\n\n"
)
gui_debug_log_path.write_text(
existing.rstrip() + "\n\n" + "\n".join(entry_lines),
encoding="utf-8",
)
def _update_airxdb_state(
project_root: Path,
session: XdbSession,
bootstrap_mode: str,
bootstrap_stdout: str,
bootstrap_error: str,
capture_stdout: str,
capture_error: str,
) -> None:
paths = _ensure_xdb_layout(project_root)
state = _json_load(paths["state"])
sessions = list(state.get("autoXdbSessions", []))
known_ids = {
str(item.get("sessionId", "")).strip()
for item in sessions
if isinstance(item, dict)
}
if session.session_id not in known_ids:
sessions.append(
{
"sessionId": session.session_id,
"taskId": session.task_id,
"source": session.source,
"trigger": session.trigger,
"status": session.status,
"createdAt": session.created_at,
"requestPath": session.request_path,
"reportPath": session.report_path,
}
)
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airxdb_health(project_root)
state["autoXdbSessions"] = sessions
state["autoXdbSessionCount"] = len(sessions)
state["lastAutoXdbAt"] = session.created_at
state["lastAutoXdbSessionId"] = session.session_id
state["lastBootstrapMode"] = bootstrap_mode
if bootstrap_stdout:
state["lastBootstrapStdout"] = bootstrap_stdout
if bootstrap_error:
state["lastBootstrapError"] = bootstrap_error
if capture_stdout:
state["lastCaptureStdout"] = capture_stdout
if capture_error:
state["lastCaptureError"] = capture_error
_json_dump(paths["state"], state)
def _attach_session_to_result(result: WorkerResult, session: XdbSession, note: str) -> None:
if not any(item.session_id == session.session_id for item in result.xdb_sessions):
result.xdb_sessions.append(session)
for path in [session.request_path, session.gui_debug_log_path, session.state_path, session.report_path]:
if path and path not in result.evidence_paths:
result.evidence_paths.append(path)
for path in session.screenshots:
if path and path not in result.evidence_paths:
result.evidence_paths.append(path)
if note not in result.notes:
result.notes.append(note)
def _session_linked_to_result(result: WorkerResult, session: XdbSession) -> bool:
linked_paths = set(result.evidence_paths)
session_paths = {
session.request_path,
session.report_path,
session.gui_debug_log_path,
session.state_path,
*session.screenshots,
}
return bool(linked_paths.intersection(path for path in session_paths if path))
def _mark_xdb_capture_blocker(result: WorkerResult, reason: str, session: XdbSession | None = None) -> None:
result.status = "blocked"
if reason not in result.blockers:
result.blockers.append(reason)
evidence = []
if session is not None:
evidence.extend(
[
session.request_path,
session.report_path,
session.gui_debug_log_path,
*session.screenshots,
]
)
evidence = [item for item in evidence if item]
if not any(
item.kind == "airxdb-acceptance" and item.status == "failed"
for item in result.validations
):
result.validations.append(
ValidationRecord(
kind="airxdb-acceptance",
status="failed",
command="automatic AirXDB capture",
evidence=evidence,
)
)
note = f"AirXDB acceptance was downgraded to blocked: {reason}"
if note not in result.notes:
result.notes.append(note)
def _latest_successful_session(sessions: List[XdbSession]) -> XdbSession | None:
for session in reversed(sessions):
if session.status == "captured":
return session
return None
def ensure_xdb_sessions_for_result(
project_root: Path,
result: WorkerResult,
source: str,
strict_done: bool = True,
) -> List[XdbSession]:
policy = load_xdb_policy(project_root)
task = _load_task(project_root, result.task_id)
needs_capture, triggers, reason = _result_trigger_summary(task, result, policy)
if not needs_capture:
return result.xdb_sessions
current_matching = [
item for item in result.xdb_sessions if _session_matches_triggers(item, triggers)
]
successful_current = _latest_successful_session(current_matching)
if successful_current is not None:
_attach_session_to_result(
result,
successful_current,
f"Reused current AirXDB session for {result.task_id}: {successful_current.session_id}",
)
return result.xdb_sessions
existing_sessions = list_xdb_sessions(project_root, result.task_id)
matching_existing = [
item for item in existing_sessions if _session_matches_triggers(item, triggers)
]
successful_existing = _latest_successful_session(matching_existing)
if (
source != "worker-finish"
and successful_existing is not None
and _session_linked_to_result(result, successful_existing)
):
_attach_session_to_result(
result,
successful_existing,
f"Reused existing AirXDB session for {result.task_id}: {successful_existing.session_id}",
)
return result.xdb_sessions
session_limit = int(policy.get("maxSessionsPerTask", 1) or 1)
if source != "worker-finish" and len(matching_existing) >= session_limit:
latest_matching = matching_existing[-1] if matching_existing else None
if latest_matching is not None:
if _session_linked_to_result(result, latest_matching):
_attach_session_to_result(
result,
latest_matching,
f"Reused latest matching AirXDB session for {result.task_id}: {latest_matching.session_id}",
)
else:
latest_matching = None
if strict_done and result.status == "done":
_mark_xdb_capture_blocker(
result,
f"AirXDB acceptance evidence is stale or exhausted for {result.task_id}",
latest_matching,
)
return result.xdb_sessions
paths = _ensure_xdb_layout(project_root)
bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airxdb(
project_root, bool(policy.get("preferOriginalAirXDB", True))
)
mode, capture_status, parsed, capture_stdout, capture_error = _capture_airxdb(
project_root, policy
)
created_at = now_iso()
session_id = f"{result.task_id}-{_session_stamp()}"
request_path = paths["requests"] / f"{session_id}.json"
session_path = paths["sessions"] / f"{session_id}.json"
report_path = (
Path(str(parsed.get("report", "")).strip()).resolve()
if str(parsed.get("report", "")).strip()
else None
)
report_payload = _load_report_payload(report_path)
screenshots = _extract_report_images(report_payload)
remote_target = parsed.get("target", "")
status = "captured" if capture_status == "ok" else "failed"
session = XdbSession(
session_id=session_id,
task_id=result.task_id,
source=source,
trigger=",".join(triggers),
reason=reason,
request_path=str(request_path),
gui_debug_log_path=str(paths["gui_debug_log"]),
report_path=str(report_path) if report_path is not None else "",
state_path=str(paths["state"]),
mode=mode,
status=status,
screenshots=screenshots,
created_at=created_at,
remote_target=remote_target,
)
_json_dump(
request_path,
{
"taskId": result.task_id,
"source": source,
"trigger": triggers,
"reason": reason,
"requestedAt": created_at,
"bootstrapMode": bootstrap_mode,
"captureMode": mode,
"captureStatus": capture_status,
"captureParsedOutput": parsed,
"bootstrapStdout": bootstrap_stdout,
"bootstrapError": bootstrap_error,
"captureStdout": capture_stdout,
"captureError": capture_error,
"resultSummary": result.summary,
"validationStatuses": [item.to_dict() for item in result.validations],
},
)
_json_dump(
session_path,
{
"xdbSession": session.to_dict(),
"captureReport": report_payload,
},
)
_append_gui_debug_log(paths["gui_debug_log"], task, result, session, bootstrap_mode)
_update_airxdb_state(
project_root,
session,
bootstrap_mode,
bootstrap_stdout,
bootstrap_error,
capture_stdout,
capture_error,
)
_attach_session_to_result(
result,
session,
(
f"AirXDB evidence captured for {result.task_id}: {session.session_id}"
if session.status == "captured"
else f"AirXDB capture failed for {result.task_id}: {session.session_id}"
),
)
if strict_done and result.status == "done" and session.status != "captured":
_mark_xdb_capture_blocker(
result,
f"AirXDB acceptance capture failed for {result.task_id}",
session,
)
return result.xdb_sessions

View File

@@ -0,0 +1,617 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List
from .paths import required_project_artifacts
KNOWN_TASK_STATUSES = {"TODO", "DOING", "DONE", "BLOCKED"}
ACTIVE_TASK_STATUSES = {"TODO", "DOING"}
FINAL_WORKER_STATUSES = {"done", "blocked", "skipped"}
DOCUMENT_UPDATE_ACTIONS = {"replace_block", "append_lines", "create_file"}
DEFAULT_WORKER_RESULT_NOTE = (
"Do not merge this result until summary, filesChanged, validations, and risks are reviewed."
)
DEFAULT_DEBUG_POLICY = {
"enabled": True,
"triggerOnBlocked": True,
"triggerValidationStatuses": ["failed", "error"],
"maxSessionsPerTask": 1,
"preferOriginalAirDbg": True,
}
DEFAULT_XDB_POLICY = {
"enabled": True,
"requireForGuiTasks": True,
"captureOnBlocked": True,
"captureOnDone": True,
"triggerValidationStatuses": ["failed", "error"],
"maxSessionsPerTask": 1,
"preferOriginalAirXDB": True,
"remoteFirstIfConfigured": True,
"taskKeywords": [
"gui",
"ui",
"desktop",
"browser",
"screen",
"screenshot",
"visual",
"layout",
"popup",
"focus",
"canvas",
"acceptance",
"midscene",
"airxdb",
"lvgl",
"lcd",
"qt-app",
],
}
DEFAULT_REPAIR_POLICY = {
"enabled": True,
"triggerOnBlocked": True,
"triggerValidationStatuses": ["failed", "error"],
"maxAttemptsPerTask": 2,
"requeueTodoStatus": "DOING",
"autoPrepareWorker": True,
}
REQUIRED_PROJECT_ARTIFACTS = required_project_artifacts()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _dedupe(items: List[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in items:
cleaned = str(item).strip()
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered
def _ensure_str_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, list):
return _dedupe([str(item) for item in value])
return _dedupe([str(value)])
@dataclass
class ValidationRecord:
kind: str
status: str
command: str = ""
evidence: List[str] = field(default_factory=list)
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "ValidationRecord":
return cls(
kind=str(payload.get("kind", "")).strip(),
status=str(payload.get("status", "")).strip(),
command=str(payload.get("command", "")).strip(),
evidence=_ensure_str_list(payload.get("evidence")),
)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class TaskRecord:
task_id: str
status: str
module: str
task: str
files_dirs: str
done_when: str
validation: str
adr_c4_update: str
line_number: int
dependencies: List[str] = field(default_factory=list)
write_paths: List[str] = field(default_factory=list)
global_doc_paths: List[str] = field(default_factory=list)
def normalized_write_set(self) -> List[str]:
return _dedupe(self.write_paths + self.global_doc_paths)
def touches_global_docs(self) -> bool:
return bool(self.global_doc_paths)
def to_dict(self) -> Dict[str, Any]:
payload = asdict(self)
payload["taskId"] = payload.pop("task_id")
payload["filesDirs"] = payload.pop("files_dirs")
payload["doneWhen"] = payload.pop("done_when")
payload["adrC4Update"] = payload.pop("adr_c4_update")
payload["lineNumber"] = payload.pop("line_number")
payload["writeSet"] = payload.pop("write_paths")
payload["globalDocPaths"] = payload.pop("global_doc_paths")
return payload
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "TaskRecord":
return cls(
task_id=str(payload.get("taskId", "")).strip(),
status=str(payload.get("status", "")).strip().upper(),
module=str(payload.get("module", "")).strip(),
task=str(payload.get("task", "")).strip(),
files_dirs=str(payload.get("filesDirs", "")).strip(),
done_when=str(payload.get("doneWhen", "")).strip(),
validation=str(payload.get("validation", "")).strip(),
adr_c4_update=str(payload.get("adrC4Update", "")).strip(),
line_number=int(payload.get("lineNumber", 0) or 0),
dependencies=_ensure_str_list(payload.get("dependencies")),
write_paths=_ensure_str_list(payload.get("writeSet")),
global_doc_paths=_ensure_str_list(payload.get("globalDocPaths")),
)
@dataclass
class WorkerResult:
task_id: str
status: str
summary: str
files_changed: List[str] = field(default_factory=list)
validations: List[ValidationRecord] = field(default_factory=list)
evidence_paths: List[str] = field(default_factory=list)
risks: List[str] = field(default_factory=list)
blockers: List[str] = field(default_factory=list)
recommend_global_doc_updates: List[str] = field(default_factory=list)
global_doc_paths: List[str] = field(default_factory=list)
document_updates: List["DocumentUpdate"] = field(default_factory=list)
debug_sessions: List["DebugSession"] = field(default_factory=list)
xdb_sessions: List["XdbSession"] = field(default_factory=list)
repair_attempts: List["RepairAttempt"] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
finalized_at: str = ""
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "WorkerResult":
return cls(
task_id=str(payload.get("taskId", "")).strip(),
status=str(payload.get("status", "")).strip().lower(),
summary=str(payload.get("summary", "")).strip(),
files_changed=_ensure_str_list(payload.get("filesChanged")),
validations=[
ValidationRecord.from_dict(item)
for item in payload.get("validations", [])
],
evidence_paths=_ensure_str_list(payload.get("evidencePaths")),
risks=_ensure_str_list(payload.get("risks")),
blockers=_ensure_str_list(payload.get("blockers")),
recommend_global_doc_updates=_ensure_str_list(
payload.get("recommendGlobalDocUpdates")
),
global_doc_paths=_ensure_str_list(payload.get("globalDocPaths")),
document_updates=[
DocumentUpdate.from_dict(item)
for item in payload.get("documentUpdates", [])
],
debug_sessions=[
DebugSession.from_dict(item)
for item in payload.get("debugSessions", [])
],
xdb_sessions=[
XdbSession.from_dict(item)
for item in payload.get("xdbSessions", [])
],
repair_attempts=[
RepairAttempt.from_dict(item)
for item in payload.get("repairAttempts", [])
],
notes=_ensure_str_list(payload.get("notes")),
finalized_at=str(payload.get("finalizedAt", "")).strip(),
)
def validate(self) -> None:
if not self.task_id:
raise ValueError("worker result missing taskId")
if self.status not in FINAL_WORKER_STATUSES:
raise ValueError(
f"worker result status must be one of {sorted(FINAL_WORKER_STATUSES)}"
)
if not self.summary:
raise ValueError("worker result summary must not be empty")
if self.status == "blocked" and not self.blockers:
raise ValueError("blocked worker result must include blockers")
for item in self.document_updates:
item.validate()
for item in self.debug_sessions:
item.validate()
for item in self.xdb_sessions:
item.validate()
for item in self.repair_attempts:
item.validate()
def has_meaningful_done_payload(self) -> bool:
return bool(
self.files_changed
or self.validations
or self.evidence_paths
or self.document_updates
or self.debug_sessions
or self.xdb_sessions
)
def validate_for_finalize(self) -> None:
self.validate()
if self.status == "done" and not self.has_meaningful_done_payload():
raise ValueError(
"done worker result must include filesChanged, validations, evidencePaths, "
"documentUpdates, debugSessions, or xdbSessions before finalize"
)
def to_dict(self) -> Dict[str, Any]:
return {
"taskId": self.task_id,
"status": self.status,
"summary": self.summary,
"filesChanged": self.files_changed,
"validations": [item.to_dict() for item in self.validations],
"evidencePaths": self.evidence_paths,
"risks": self.risks,
"blockers": self.blockers,
"recommendGlobalDocUpdates": self.recommend_global_doc_updates,
"globalDocPaths": self.global_doc_paths,
"documentUpdates": [item.to_dict() for item in self.document_updates],
"debugSessions": [item.to_dict() for item in self.debug_sessions],
"xdbSessions": [item.to_dict() for item in self.xdb_sessions],
"repairAttempts": [item.to_dict() for item in self.repair_attempts],
"notes": self.notes,
"finalizedAt": self.finalized_at,
}
def default_worker_result(task_id: str, summary: str = "") -> WorkerResult:
return WorkerResult(
task_id=task_id,
status="done",
summary=summary,
files_changed=[],
validations=[],
evidence_paths=[],
risks=[],
blockers=[],
recommend_global_doc_updates=[],
global_doc_paths=[],
document_updates=[],
debug_sessions=[],
xdb_sessions=[],
repair_attempts=[],
notes=[
DEFAULT_WORKER_RESULT_NOTE
],
finalized_at="",
)
@dataclass
class DebugSession:
session_id: str
task_id: str
source: str
trigger: str
reason: str
request_path: str
debug_log_path: str
state_path: str = ""
mode: str = "original-airdbg"
status: str = "requested"
created_at: str = ""
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "DebugSession":
return cls(
session_id=str(payload.get("sessionId", "")).strip(),
task_id=str(payload.get("taskId", "")).strip(),
source=str(payload.get("source", "")).strip(),
trigger=str(payload.get("trigger", "")).strip(),
reason=str(payload.get("reason", "")).strip(),
request_path=str(payload.get("requestPath", "")).strip(),
debug_log_path=str(payload.get("debugLogPath", "")).strip(),
state_path=str(payload.get("statePath", "")).strip(),
mode=str(payload.get("mode", "original-airdbg")).strip(),
status=str(payload.get("status", "requested")).strip(),
created_at=str(payload.get("createdAt", "")).strip(),
)
def validate(self) -> None:
if not self.session_id:
raise ValueError("debug session missing sessionId")
if not self.task_id:
raise ValueError("debug session missing taskId")
if not self.request_path:
raise ValueError("debug session missing requestPath")
if not self.debug_log_path:
raise ValueError("debug session missing debugLogPath")
def to_dict(self) -> Dict[str, Any]:
return {
"sessionId": self.session_id,
"taskId": self.task_id,
"source": self.source,
"trigger": self.trigger,
"reason": self.reason,
"requestPath": self.request_path,
"debugLogPath": self.debug_log_path,
"statePath": self.state_path,
"mode": self.mode,
"status": self.status,
"createdAt": self.created_at,
}
@dataclass
class XdbSession:
session_id: str
task_id: str
source: str
trigger: str
reason: str
request_path: str
gui_debug_log_path: str
report_path: str = ""
state_path: str = ""
mode: str = "local-screenshot"
status: str = "captured"
screenshots: List[str] = field(default_factory=list)
created_at: str = ""
remote_target: str = ""
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "XdbSession":
return cls(
session_id=str(payload.get("sessionId", "")).strip(),
task_id=str(payload.get("taskId", "")).strip(),
source=str(payload.get("source", "")).strip(),
trigger=str(payload.get("trigger", "")).strip(),
reason=str(payload.get("reason", "")).strip(),
request_path=str(payload.get("requestPath", "")).strip(),
gui_debug_log_path=str(payload.get("guiDebugLogPath", "")).strip(),
report_path=str(payload.get("reportPath", "")).strip(),
state_path=str(payload.get("statePath", "")).strip(),
mode=str(payload.get("mode", "local-screenshot")).strip(),
status=str(payload.get("status", "captured")).strip(),
screenshots=_ensure_str_list(payload.get("screenshots")),
created_at=str(payload.get("createdAt", "")).strip(),
remote_target=str(payload.get("remoteTarget", "")).strip(),
)
def validate(self) -> None:
if not self.session_id:
raise ValueError("xdb session missing sessionId")
if not self.task_id:
raise ValueError("xdb session missing taskId")
if not self.request_path:
raise ValueError("xdb session missing requestPath")
if not self.gui_debug_log_path:
raise ValueError("xdb session missing guiDebugLogPath")
def to_dict(self) -> Dict[str, Any]:
return {
"sessionId": self.session_id,
"taskId": self.task_id,
"source": self.source,
"trigger": self.trigger,
"reason": self.reason,
"requestPath": self.request_path,
"guiDebugLogPath": self.gui_debug_log_path,
"reportPath": self.report_path,
"statePath": self.state_path,
"mode": self.mode,
"status": self.status,
"screenshots": self.screenshots,
"createdAt": self.created_at,
"remoteTarget": self.remote_target,
}
@dataclass
class RepairAttempt:
repair_id: str
task_id: str
attempt_index: int
source_status: str
source_result_path: str
reason: str
repair_brief_path: str
state_path: str = ""
worker_brief_path: str = ""
debug_session_ids: List[str] = field(default_factory=list)
status: str = "queued"
created_at: str = ""
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "RepairAttempt":
return cls(
repair_id=str(payload.get("repairId", "")).strip(),
task_id=str(payload.get("taskId", "")).strip(),
attempt_index=int(payload.get("attemptIndex", 0) or 0),
source_status=str(payload.get("sourceStatus", "")).strip(),
source_result_path=str(payload.get("sourceResultPath", "")).strip(),
reason=str(payload.get("reason", "")).strip(),
repair_brief_path=str(payload.get("repairBriefPath", "")).strip(),
state_path=str(payload.get("statePath", "")).strip(),
worker_brief_path=str(payload.get("workerBriefPath", "")).strip(),
debug_session_ids=_ensure_str_list(payload.get("debugSessionIds")),
status=str(payload.get("status", "queued")).strip(),
created_at=str(payload.get("createdAt", "")).strip(),
)
def validate(self) -> None:
if not self.repair_id:
raise ValueError("repair attempt missing repairId")
if not self.task_id:
raise ValueError("repair attempt missing taskId")
if self.attempt_index <= 0:
raise ValueError("repair attempt must have positive attemptIndex")
if not self.repair_brief_path:
raise ValueError("repair attempt missing repairBriefPath")
def to_dict(self) -> Dict[str, Any]:
return {
"repairId": self.repair_id,
"taskId": self.task_id,
"attemptIndex": self.attempt_index,
"sourceStatus": self.source_status,
"sourceResultPath": self.source_result_path,
"reason": self.reason,
"repairBriefPath": self.repair_brief_path,
"statePath": self.state_path,
"workerBriefPath": self.worker_brief_path,
"debugSessionIds": self.debug_session_ids,
"status": self.status,
"createdAt": self.created_at,
}
@dataclass
class DocumentUpdate:
path: str
action: str
content: str = ""
marker: str = ""
append_lines: List[str] = field(default_factory=list)
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "DocumentUpdate":
return cls(
path=str(payload.get("path", "")).strip(),
action=str(payload.get("action", "")).strip(),
content=str(payload.get("content", "")).rstrip(),
marker=str(payload.get("marker", "")).strip(),
append_lines=_ensure_str_list(payload.get("appendLines")),
)
def validate(self) -> None:
if not self.path:
raise ValueError("document update missing path")
if self.action not in DOCUMENT_UPDATE_ACTIONS:
raise ValueError(
f"document update action must be one of {sorted(DOCUMENT_UPDATE_ACTIONS)}"
)
if self.action == "replace_block" and not self.marker:
raise ValueError("replace_block document update requires marker")
if self.action == "append_lines" and not self.append_lines:
raise ValueError("append_lines document update requires appendLines")
if self.action in {"replace_block", "create_file"} and not self.content:
raise ValueError(f"{self.action} document update requires content")
if self.action in {"replace_block", "create_file"} and "TODO" in self.content:
raise ValueError(
f"{self.action} document update for {self.path} still contains TODO placeholders"
)
if self.action == "append_lines" and any("TODO" in line for line in self.append_lines):
raise ValueError(
f"append_lines document update for {self.path} still contains TODO placeholders"
)
def to_dict(self) -> Dict[str, Any]:
payload = {
"path": self.path,
"action": self.action,
}
if self.content:
payload["content"] = self.content
if self.marker:
payload["marker"] = self.marker
if self.append_lines:
payload["appendLines"] = self.append_lines
return payload
@dataclass
class ReviewConflict:
task_ids: List[str]
reason: str
overlap_paths: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"taskIds": self.task_ids,
"reason": self.reason,
"overlapPaths": self.overlap_paths,
}
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "ReviewConflict":
return cls(
task_ids=_ensure_str_list(payload.get("taskIds")),
reason=str(payload.get("reason", "")).strip(),
overlap_paths=_ensure_str_list(payload.get("overlapPaths")),
)
@dataclass
class ParallelGroup:
name: str
task_ids: List[str]
reason: str
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"taskIds": self.task_ids,
"reason": self.reason,
}
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "ParallelGroup":
return cls(
name=str(payload.get("name", "")).strip(),
task_ids=_ensure_str_list(payload.get("taskIds")),
reason=str(payload.get("reason", "")).strip(),
)
@dataclass
class ParallelReview:
source_todo: str
generated_at: str
tasks_considered: List[TaskRecord]
dependency_edges: List[Dict[str, str]]
parallel_groups: List[ParallelGroup]
conflicts: List[ReviewConflict]
serialization_points: List[Dict[str, Any]]
notes: List[str]
def to_dict(self) -> Dict[str, Any]:
return {
"sourceTodo": self.source_todo,
"generatedAt": self.generated_at,
"tasksConsidered": [task.to_dict() for task in self.tasks_considered],
"dependencyEdges": self.dependency_edges,
"parallelGroups": [group.to_dict() for group in self.parallel_groups],
"conflicts": [conflict.to_dict() for conflict in self.conflicts],
"serializationPoints": self.serialization_points,
"notes": self.notes,
}
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "ParallelReview":
return cls(
source_todo=str(payload.get("sourceTodo", "")).strip(),
generated_at=str(payload.get("generatedAt", "")).strip(),
tasks_considered=[
TaskRecord.from_dict(item)
for item in payload.get("tasksConsidered", [])
],
dependency_edges=list(payload.get("dependencyEdges", [])),
parallel_groups=[
ParallelGroup.from_dict(item)
for item in payload.get("parallelGroups", [])
],
conflicts=[
ReviewConflict.from_dict(item)
for item in payload.get("conflicts", [])
],
serialization_points=list(payload.get("serializationPoints", [])),
notes=_ensure_str_list(payload.get("notes")),
)

View File

@@ -0,0 +1,413 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
from .contracts import DEFAULT_DEBUG_POLICY, DebugSession, WorkerResult, now_iso
from .paths import airdbg_root, agents_path, aireng_root, architecture_adr_dir, architecture_c4_module_path, debug_log_path
def _json_load(path: Path) -> Dict[str, object]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8-sig"))
def _json_dump(path: Path, payload: Dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def _ordered_unique(items: Iterable[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in items:
cleaned = str(item).strip()
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered
def normalize_debug_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
merged = dict(DEFAULT_DEBUG_POLICY)
if policy:
merged.update(policy)
merged["enabled"] = bool(merged.get("enabled", True))
merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True))
merged["preferOriginalAirDbg"] = bool(merged.get("preferOriginalAirDbg", True))
merged["triggerValidationStatuses"] = _ordered_unique(
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
)
try:
max_sessions = int(merged.get("maxSessionsPerTask", 1) or 1)
except (TypeError, ValueError):
max_sessions = 1
merged["maxSessionsPerTask"] = max(1, max_sessions)
return merged
def load_debug_policy(project_root: Path) -> Dict[str, object]:
state_path = aireng_root(project_root) / "state.json"
state = _json_load(state_path)
return normalize_debug_policy(state.get("debugPolicy", {}))
def merge_debug_sessions_into_state(
state: Dict[str, object], debug_sessions: List[DebugSession]
) -> Dict[str, object]:
state["debugPolicy"] = normalize_debug_policy(dict(state.get("debugPolicy", {})))
existing_items = list(state.get("debugSessions", []))
seen_ids = {
str(item.get("sessionId", "")).strip()
for item in existing_items
if isinstance(item, dict)
}
for session in debug_sessions:
if session.session_id in seen_ids:
continue
existing_items.append(session.to_dict())
seen_ids.add(session.session_id)
task_counts: Dict[str, int] = {}
for item in existing_items:
task_id = str(item.get("taskId", "")).strip()
if not task_id:
continue
task_counts[task_id] = task_counts.get(task_id, 0) + 1
state["debugSessions"] = existing_items
state["taskDebugCounts"] = task_counts
if debug_sessions:
latest = debug_sessions[-1]
state["lastDebugSessionId"] = latest.session_id
state["lastDebugTaskId"] = latest.task_id
state["lastDebugAt"] = latest.created_at
return state
def _debug_paths(project_root: Path) -> Dict[str, Path]:
root = airdbg_root(project_root)
return {
"root": root,
"state": root / "state.json",
"requests": root / "requests",
"sessions": root / "sessions",
"debug_log": debug_log_path(project_root),
}
def _airdbg_health(project_root: Path) -> Dict[str, bool]:
paths = _debug_paths(project_root)
return {
"AirPlan/AGENTS.md": agents_path(project_root).exists(),
"AirPlan/docs/architecture/c4/module.md": architecture_c4_module_path(project_root).exists(),
"AirPlan/docs/architecture/adr": architecture_adr_dir(project_root).exists(),
"debug_log": paths["debug_log"].exists(),
}
def _ensure_debug_layout(project_root: Path) -> Dict[str, Path]:
paths = _debug_paths(project_root)
paths["requests"].mkdir(parents=True, exist_ok=True)
paths["sessions"].mkdir(parents=True, exist_ok=True)
paths["debug_log"].parent.mkdir(parents=True, exist_ok=True)
if not paths["debug_log"].exists():
paths["debug_log"].write_text("# Debug Log\n\n", encoding="utf-8")
return paths
def _resolve_original_airdbg_script() -> Path | None:
candidates = [
Path.home() / "plugins" / "airdbg" / "scripts" / "airdbg_mode.py",
Path(r"C:\Users\20392\plugins\airdbg\scripts\airdbg_mode.py"),
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _bootstrap_airdbg(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]:
mode = "runtime-bootstrap"
stdout = ""
error = ""
script_path = _resolve_original_airdbg_script() if prefer_original else None
if script_path is not None:
try:
completed = subprocess.run(
[
sys.executable,
str(script_path),
"--mode",
"enter",
"--project",
str(project_root),
],
capture_output=True,
text=True,
check=True,
)
mode = "original-airdbg"
stdout = completed.stdout.strip()
except subprocess.CalledProcessError as exc:
mode = "runtime-fallback"
stdout = exc.stdout.strip()
error = exc.stderr.strip() or str(exc)
paths = _ensure_debug_layout(project_root)
state = _json_load(paths["state"])
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airdbg_health(project_root)
if stdout:
state["lastBootstrapStdout"] = stdout
if error:
state["lastBootstrapError"] = error
_json_dump(paths["state"], state)
return mode, stdout, error
def list_debug_sessions(project_root: Path, task_id: str = "") -> List[DebugSession]:
session_dir = _debug_paths(project_root)["sessions"]
if not session_dir.exists():
return []
sessions: List[DebugSession] = []
for path in sorted(session_dir.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
session_payload = payload.get("debugSession", payload)
session = DebugSession.from_dict(session_payload)
session.validate()
except (json.JSONDecodeError, TypeError, ValueError):
continue
if task_id and session.task_id != task_id:
continue
sessions.append(session)
sessions.sort(key=lambda item: (item.created_at, item.session_id))
return sessions
def _result_trigger_summary(
result: WorkerResult, policy: Dict[str, object]
) -> Tuple[List[str], str]:
triggers: List[str] = []
reasons: List[str] = []
if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)):
triggers.append("blocked-status")
blocker_text = ", ".join(result.blockers) if result.blockers else "worker returned blocked"
reasons.append(f"worker result is blocked: {blocker_text}")
tracked_validation_statuses = {
str(item).lower() for item in list(policy.get("triggerValidationStatuses", []))
}
failed_validations = [
item
for item in result.validations
if item.status.lower() in tracked_validation_statuses
]
if failed_validations:
triggers.append("validation-failure")
reasons.append(
"validation failures: "
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
)
return triggers, "; ".join(reasons)
def _session_stamp() -> str:
return now_iso().replace(":", "-").replace(".", "-").replace("+", "-")
def _append_debug_log(
debug_log_path: Path,
result: WorkerResult,
session: DebugSession,
bootstrap_mode: str,
) -> None:
section_lines = [
f"### {session.created_at}: auto-debug request for {result.task_id}",
"",
f"- Task: `{result.task_id}`",
f"- Source: `{session.source}`",
f"- Trigger: `{session.trigger}`",
f"- Symptom: {result.summary}",
"- Expected: Task validations should pass, or the task should complete without blockers.",
f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}",
"- Reproduction: finalize the worker result and let the runtime auto-route the failure into AirDbg.",
"- Root cause: pending AirDbg investigation.",
f"- Fix: pending AirDbg session `{session.session_id}`",
(
"- Validation: debug request stored at "
f"`{session.request_path}`; log appended automatically; bootstrap mode `{bootstrap_mode}`"
),
f"- Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}",
"- ADR/C4 updates: none yet",
"- Residual risk: the underlying blocker is unresolved until the debug session is completed.",
"",
]
existing = debug_log_path.read_text(encoding="utf-8") if debug_log_path.exists() else "# Debug Log\n\n"
debug_log_path.write_text(existing.rstrip() + "\n\n" + "\n".join(section_lines), encoding="utf-8")
def _update_airdbg_state(
project_root: Path,
session: DebugSession,
bootstrap_mode: str,
bootstrap_stdout: str,
bootstrap_error: str,
) -> None:
paths = _ensure_debug_layout(project_root)
state = _json_load(paths["state"])
sessions = list(state.get("autoDebugSessions", []))
known_ids = {
str(item.get("sessionId", "")).strip()
for item in sessions
if isinstance(item, dict)
}
if session.session_id not in known_ids:
sessions.append(
{
"sessionId": session.session_id,
"taskId": session.task_id,
"source": session.source,
"trigger": session.trigger,
"status": session.status,
"createdAt": session.created_at,
"requestPath": session.request_path,
}
)
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airdbg_health(project_root)
state["autoDebugSessions"] = sessions
state["autoDebugSessionCount"] = len(sessions)
state["lastAutoDebugAt"] = session.created_at
state["lastAutoDebugSessionId"] = session.session_id
state["lastBootstrapMode"] = bootstrap_mode
if bootstrap_stdout:
state["lastBootstrapStdout"] = bootstrap_stdout
if bootstrap_error:
state["lastBootstrapError"] = bootstrap_error
_json_dump(paths["state"], state)
def _attach_session_to_result(result: WorkerResult, session: DebugSession, note: str) -> None:
if not any(item.session_id == session.session_id for item in result.debug_sessions):
result.debug_sessions.append(session)
for path in [session.request_path, session.debug_log_path, session.state_path]:
if path and path not in result.evidence_paths:
result.evidence_paths.append(path)
if note not in result.notes:
result.notes.append(note)
def ensure_debug_sessions_for_result(
project_root: Path, result: WorkerResult, source: str
) -> List[DebugSession]:
policy = load_debug_policy(project_root)
if not bool(policy.get("enabled", True)):
return result.debug_sessions
triggers, reason = _result_trigger_summary(result, policy)
if not triggers:
return result.debug_sessions
existing_sessions = list_debug_sessions(project_root, result.task_id)
if result.debug_sessions:
for session in result.debug_sessions:
_attach_session_to_result(
result,
session,
f"Debug session already linked for {result.task_id}: {session.session_id}",
)
return result.debug_sessions
if existing_sessions:
latest = existing_sessions[-1]
_attach_session_to_result(
result,
latest,
f"Reused existing debug session for {result.task_id}: {latest.session_id}",
)
return result.debug_sessions
session_limit = int(policy.get("maxSessionsPerTask", 1) or 1)
if len(existing_sessions) >= session_limit:
return result.debug_sessions
paths = _ensure_debug_layout(project_root)
bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airdbg(
project_root, bool(policy.get("preferOriginalAirDbg", True))
)
created_at = now_iso()
session_id = f"{result.task_id}-{_session_stamp()}"
request_path = paths["requests"] / f"{session_id}.json"
session_path = paths["sessions"] / f"{session_id}.json"
trigger = ",".join(triggers)
request_payload = {
"sessionId": session_id,
"taskId": result.task_id,
"source": source,
"trigger": trigger,
"reason": reason or result.summary,
"workerStatus": result.status,
"summary": result.summary,
"filesChanged": result.files_changed,
"validations": [item.to_dict() for item in result.validations],
"evidencePaths": result.evidence_paths,
"risks": result.risks,
"blockers": result.blockers,
"createdAt": created_at,
"bootstrapMode": bootstrap_mode,
"bootstrapStdout": bootstrap_stdout,
"bootstrapError": bootstrap_error,
}
_json_dump(request_path, request_payload)
session = DebugSession(
session_id=session_id,
task_id=result.task_id,
source=source,
trigger=trigger,
reason=reason or result.summary,
request_path=str(request_path),
debug_log_path=str(paths["debug_log"]),
state_path=str(paths["state"]),
mode=bootstrap_mode,
status="requested",
created_at=created_at,
)
_json_dump(
session_path,
{
"debugSession": session.to_dict(),
"request": request_payload,
},
)
_append_debug_log(paths["debug_log"], result, session, bootstrap_mode)
_update_airdbg_state(project_root, session, bootstrap_mode, bootstrap_stdout, bootstrap_error)
_attach_session_to_result(
result,
session,
f"Auto-requested debug session for {result.task_id}: {session.session_id}",
)
return result.debug_sessions

View File

@@ -0,0 +1,350 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, Iterable, List
from .contracts import DocumentUpdate, ValidationRecord, WorkerResult, now_iso
from .paths import (
agents_path,
architecture_c4_module_path,
plan_path as workflow_plan_path,
todo_path as workflow_todo_path,
)
from .todo_parser import parse_tasks
def _project_relative(project_root: Path, raw_path: str) -> Path:
candidate = Path(raw_path)
if candidate.is_absolute():
resolved = candidate.resolve()
else:
resolved = (project_root / candidate).resolve()
project_root_resolved = project_root.resolve()
try:
resolved.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(f"document update path escapes project root: {raw_path}") from exc
return resolved
def _replace_marker_block(text: str, marker: str, content: str) -> str:
begin = f"<!-- AIR-ENGINE:{marker}:BEGIN -->"
end = f"<!-- AIR-ENGINE:{marker}:END -->"
block = f"{begin}\n{content.rstrip()}\n{end}\n"
start_index = text.find(begin)
end_index = text.find(end)
if start_index >= 0 and end_index > start_index:
end_index += len(end)
return text[:start_index].rstrip() + "\n\n" + block + text[end_index:].lstrip()
return text.rstrip() + "\n\n" + block
def _extract_marker_block(text: str, marker: str) -> str:
begin = f"<!-- AIR-ENGINE:{marker}:BEGIN -->"
end = f"<!-- AIR-ENGINE:{marker}:END -->"
start_index = text.find(begin)
end_index = text.find(end)
if start_index < 0 or end_index <= start_index:
return ""
content_start = start_index + len(begin)
return text[content_start:end_index].strip()
def _apply_document_update(project_root: Path, update: DocumentUpdate) -> str:
target_path = _project_relative(project_root, update.path)
target_path.parent.mkdir(parents=True, exist_ok=True)
existing = target_path.read_text(encoding="utf-8") if target_path.exists() else ""
if update.action == "create_file":
target_path.write_text(update.content.rstrip() + "\n", encoding="utf-8")
elif update.action == "append_lines":
joined = "\n".join(update.append_lines).rstrip()
if existing.strip():
target_path.write_text(existing.rstrip() + "\n" + joined + "\n", encoding="utf-8")
else:
target_path.write_text(joined + "\n", encoding="utf-8")
elif update.action == "replace_block":
updated = _replace_marker_block(existing, update.marker, update.content)
target_path.write_text(updated, encoding="utf-8")
else:
raise ValueError(f"unsupported document update action: {update.action}")
return str(target_path)
def apply_document_updates(project_root: Path, result: WorkerResult) -> List[str]:
applied: List[str] = []
for update in result.document_updates:
applied.append(_apply_document_update(project_root, update))
return applied
def _validation_summary(validations: List[ValidationRecord]) -> str:
if not validations:
return "No explicit validation recorded"
return "; ".join(
f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "")
for item in validations
)
def _debug_summary(result: WorkerResult) -> str:
if not result.debug_sessions:
return "none"
return ", ".join(
f"{item.session_id} [{item.trigger}]"
for item in result.debug_sessions
)
def _xdb_summary(result: WorkerResult) -> str:
if not result.xdb_sessions:
return "none"
return ", ".join(
f"{item.session_id} [{item.mode}:{item.status}]"
for item in result.xdb_sessions
)
def _repair_summary(result: WorkerResult) -> str:
if not result.repair_attempts:
return "none"
return ", ".join(
f"{item.repair_id} [{item.status}]"
for item in result.repair_attempts
)
def _todo_status_from_worker_status(worker_status: str) -> str:
if worker_status == "done":
return "DONE"
return "BLOCKED"
def _todo_status_from_result(result: WorkerResult) -> str:
if result.status == "done":
return "DONE"
if result.repair_attempts:
return "DOING"
return "BLOCKED"
def _rebuild_table_row(cells: List[str]) -> str:
return "| " + " | ".join(cells) + " |"
def update_todo_after_merge(
project_root: Path,
result: WorkerResult,
applied_doc_paths: List[str],
sync_paths: List[str],
) -> Path:
todo_path = workflow_todo_path(project_root)
original_text = todo_path.read_text(encoding="utf-8")
lines = original_text.splitlines()
updated_lines: List[str] = []
task_found = False
unique_applied_doc_paths = list(dict.fromkeys(applied_doc_paths))
unique_sync_paths = list(dict.fromkeys(sync_paths))
for raw_line in lines:
if raw_line.startswith(f"| {result.task_id} "):
cells = [cell.strip() for cell in raw_line.strip().strip("|").split("|")]
if len(cells) > 8:
cells = cells[:7] + ["; ".join(cell for cell in cells[7:] if cell)]
if len(cells) >= 8:
cells[1] = _todo_status_from_result(result)
validation_cell = _validation_summary(result.validations)
if result.evidence_paths:
validation_cell += f"; evidence={len(result.evidence_paths)}"
if result.blockers:
validation_cell += "; blockers=" + ", ".join(result.blockers)
if result.xdb_sessions:
validation_cell += f"; xdb={len(result.xdb_sessions)}"
if result.debug_sessions:
validation_cell += f"; debug={len(result.debug_sessions)}"
if result.repair_attempts:
validation_cell += f"; repair={len(result.repair_attempts)}"
cells[6] = validation_cell
if unique_applied_doc_paths:
cells[7] = "Merged by engine: " + ", ".join(
Path(path).name for path in unique_applied_doc_paths
)
updated_lines.append(_rebuild_table_row(cells))
task_found = True
continue
updated_lines.append(raw_line)
if not task_found:
raise ValueError(f"task row not found in todo.md for {result.task_id}")
log_lines = [
f"- `{now_iso()}` task `{result.task_id}` merged with status `{result.status}`",
f" Summary: {result.summary}",
f" Files Changed: {', '.join(f'`{item}`' for item in result.files_changed) or '`none`'}",
f" Validations: {_validation_summary(result.validations)}",
f" Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}",
f" XDB Sessions: {_xdb_summary(result)}",
f" Debug Sessions: {_debug_summary(result)}",
f" Repair Attempts: {_repair_summary(result)}",
f" Risks: {', '.join(result.risks) or 'none'}",
f" Blockers: {', '.join(result.blockers) or 'none'}",
f" Applied Doc Paths: {', '.join(f'`{item}`' for item in unique_applied_doc_paths) or '`none`'}",
f" Engine Sync Paths: {', '.join(f'`{item}`' for item in unique_sync_paths) or '`none`'}",
]
existing_log = _extract_marker_block(original_text, "TODO-RUN-LOG")
existing_body = ""
if existing_log:
existing_lines = existing_log.splitlines()
if existing_lines and existing_lines[0].strip() in {"# Air Engine Merge Log", "# AirEng Merge Log"}:
existing_body = "\n".join(existing_lines[1:]).strip()
else:
existing_body = existing_log.strip()
merged_log_content = "# AirEng Merge Log\n\n" + "\n".join(log_lines)
if existing_body:
merged_log_content += "\n\n" + existing_body
text = "\n".join(updated_lines).rstrip() + "\n"
text = _replace_marker_block(
text,
"TODO-RUN-LOG",
merged_log_content,
)
todo_path.write_text(text, encoding="utf-8")
return todo_path
def mark_tasks_dispatched(
project_root: Path,
group_name: str,
dispatched_tasks: List[Dict[str, object]],
recommended_concurrency: int,
) -> List[str]:
updated_paths: List[str] = []
current_todo_path = workflow_todo_path(project_root)
current_plan_path = workflow_plan_path(project_root)
task_ids = {str(item.get("taskId", "")).strip() for item in dispatched_tasks if str(item.get("taskId", "")).strip()}
if current_todo_path.exists() and task_ids:
lines = current_todo_path.read_text(encoding="utf-8").splitlines()
rewritten: List[str] = []
for raw_line in lines:
if raw_line.startswith("| "):
cells = [cell.strip() for cell in raw_line.strip().strip("|").split("|")]
if cells and cells[0] in task_ids and len(cells) >= 2:
cells[1] = "DOING"
raw_line = _rebuild_table_row(cells)
rewritten.append(raw_line)
current_todo_path.write_text("\n".join(rewritten).rstrip() + "\n", encoding="utf-8")
updated_paths.append(str(current_todo_path))
if current_plan_path.exists():
plan_text = current_plan_path.read_text(encoding="utf-8")
lines = [
"## AirEng Active Dispatch",
"",
f"- Updated At: `{now_iso()}`",
f"- Group: `{group_name or 'default'}`",
f"- Recommended Concurrency: `{recommended_concurrency}`",
"- Tasks:",
]
if dispatched_tasks:
for item in dispatched_tasks:
lines.append(
f" - `{item.get('taskId', '')}` handoff=`{item.get('handoffPath', '')}` brief=`{item.get('briefPath', '')}`"
)
else:
lines.append(" - none")
plan_text = _replace_marker_block(plan_text, "DISPATCH-STATUS", "\n".join(lines))
current_plan_path.write_text(plan_text, encoding="utf-8")
updated_paths.append(str(current_plan_path))
return updated_paths
def _sync_block_content(result: WorkerResult, applied_doc_paths: List[str], title: str) -> str:
unique_applied_doc_paths = list(dict.fromkeys(applied_doc_paths))
lines = [
title,
"",
f"- Last Synced At: `{now_iso()}`",
f"- Task: `{result.task_id}`",
f"- Status: `{result.status}`",
f"- Summary: {result.summary}",
f"- Files Changed: {', '.join(f'`{item}`' for item in result.files_changed) or '`none`'}",
f"- Validations: {_validation_summary(result.validations)}",
f"- XDB Sessions: {_xdb_summary(result)}",
f"- Debug Sessions: {_debug_summary(result)}",
f"- Repair Attempts: {_repair_summary(result)}",
f"- Applied Doc Paths: {', '.join(f'`{item}`' for item in unique_applied_doc_paths) or '`none`'}",
f"- Risks: {', '.join(result.risks) or 'none'}",
f"- Blockers: {', '.join(result.blockers) or 'none'}",
]
return "\n".join(lines)
def sync_engine_managed_docs(project_root: Path, result: WorkerResult, applied_doc_paths: List[str]) -> List[str]:
updated_paths: List[str] = []
current_agents_path = agents_path(project_root)
if current_agents_path.exists():
agents_text = current_agents_path.read_text(encoding="utf-8")
agents_text = _replace_marker_block(
agents_text,
"AGENTS-SYNC",
_sync_block_content(result, applied_doc_paths, "## AirEng Sync"),
)
current_agents_path.write_text(agents_text, encoding="utf-8")
updated_paths.append(str(current_agents_path))
c4_path = architecture_c4_module_path(project_root)
if c4_path.exists():
c4_text = c4_path.read_text(encoding="utf-8")
c4_text = _replace_marker_block(
c4_text,
"C4-SYNC",
_sync_block_content(result, applied_doc_paths, "## AirEng Sync"),
)
c4_path.write_text(c4_text, encoding="utf-8")
updated_paths.append(str(c4_path))
return updated_paths
def enforce_doc_sync_requirements(project_root: Path, result: WorkerResult) -> None:
task = None
todo_path = workflow_todo_path(project_root)
if todo_path.exists():
task = next((item for item in parse_tasks(todo_path) if item.task_id == result.task_id), None)
required_doc_paths = set(result.global_doc_paths)
if task is not None:
required_doc_paths.update(task.global_doc_paths)
if result.status != "done":
return
if not required_doc_paths:
return
covered_paths = set()
for update in result.document_updates:
covered_paths.add(update.path)
missing_paths = []
for required in sorted(required_doc_paths):
if required == "AirPlan/docs/architecture/adr/":
if not any("AirPlan/docs/architecture/adr/" in path.replace("\\", "/") for path in covered_paths):
missing_paths.append(required)
continue
if required not in covered_paths:
missing_paths.append(required)
if missing_paths:
raise ValueError(
"document sync is incomplete for task "
+ result.task_id
+ ": missing updates for "
+ ", ".join(missing_paths)
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
from pathlib import Path
AIRPLAN_DIRNAME = "AirPlan"
def airplan_root(project_root: Path) -> Path:
return project_root / AIRPLAN_DIRNAME
def docs_root(project_root: Path) -> Path:
return airplan_root(project_root) / "docs"
def state_root(project_root: Path) -> Path:
return airplan_root(project_root) / "state"
def agents_path(project_root: Path) -> Path:
return airplan_root(project_root) / "AGENTS.md"
def root_agents_bootstrap_path(project_root: Path) -> Path:
return project_root / "AGENTS.md"
def plan_path(project_root: Path) -> Path:
return airplan_root(project_root) / "plan.md"
def todo_path(project_root: Path) -> Path:
return airplan_root(project_root) / "todo.md"
def analysis_requirements_path(project_root: Path) -> Path:
return docs_root(project_root) / "analysis" / "requirements.md"
def architecture_root(project_root: Path) -> Path:
return docs_root(project_root) / "architecture"
def architecture_solution_path(project_root: Path) -> Path:
return architecture_root(project_root) / "solution-architecture.md"
def architecture_adr_dir(project_root: Path) -> Path:
return architecture_root(project_root) / "adr"
def architecture_c4_module_path(project_root: Path) -> Path:
return architecture_root(project_root) / "c4" / "module.md"
def debug_root(project_root: Path) -> Path:
return docs_root(project_root) / "debug"
def debug_log_path(project_root: Path) -> Path:
return debug_root(project_root) / "debug-log.md"
def gui_debug_log_path(project_root: Path) -> Path:
return debug_root(project_root) / "gui-debug-log.md"
def airxdb_artifacts_dir(project_root: Path) -> Path:
return debug_root(project_root) / "airxdb-artifacts"
def network_root(project_root: Path) -> Path:
return docs_root(project_root) / "network"
def airndb_log_path(project_root: Path) -> Path:
return network_root(project_root) / "airndb-log.md"
def airndb_captures_dir(project_root: Path) -> Path:
return network_root(project_root) / "airndb-captures"
def validation_root(project_root: Path) -> Path:
return docs_root(project_root) / "validation"
def staticanalysis_path(project_root: Path) -> Path:
return docs_root(project_root) / "staticanalysis.md"
def plugin_state_root(project_root: Path, plugin_name: str) -> Path:
return state_root(project_root) / plugin_name
def airarc_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "airarc")
def aireng_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "aireng")
def airdo_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "airdo")
def airdbg_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "airdbg")
def airndb_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "airndb")
def airsdb_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "airsdb")
def airxdb_root(project_root: Path) -> Path:
return plugin_state_root(project_root, "airxdb")
def required_project_artifacts() -> tuple[str, ...]:
return (
"AirPlan/AGENTS.md",
"AirPlan/docs/architecture/adr",
"AirPlan/docs/architecture/c4/module.md",
"AirPlan/plan.md",
"AirPlan/todo.md",
)

View File

@@ -0,0 +1,342 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, Tuple
from .paths import (
agents_path,
airplan_root,
analysis_requirements_path,
architecture_adr_dir,
architecture_c4_module_path,
architecture_solution_path,
debug_log_path,
gui_debug_log_path,
root_agents_bootstrap_path,
state_root,
staticanalysis_path,
validation_root,
)
AIRARC_BEGIN = "<!-- AIRARC:BEGIN -->"
AIRARC_END = "<!-- AIRARC:END -->"
AIRENG_BEGIN = "<!-- AIRENG:BEGIN -->"
AIRENG_END = "<!-- AIRENG:END -->"
AIRDO_BEGIN = "<!-- AIRDO:BEGIN -->"
AIRDO_END = "<!-- AIRDO:END -->"
ROOT_AGENTS_TEMPLATE = """# AGENTS.md
- Canonical workflow context for this repository lives in `AirPlan/AGENTS.md`.
- Always load `AirPlan/AGENTS.md` first for project instructions, workflow rules, plan/todo state, ADR/C4 context, and current Air sync blocks.
- Treat `AirPlan/plan.md`, `AirPlan/todo.md`, and `AirPlan/docs/` as the authoritative workflow documents.
- Treat `AirPlan/state/` as the authoritative plugin and runtime state root.
- This root file is only a bootstrap shim; keep real workflow context maintained inside `AirPlan/AGENTS.md`.
"""
PROJECT_AGENTS_TEMPLATE = f"""# AGENTS.md
## Workflow Root
- This project uses `AirPlan/` as the workflow root.
- Keep planning, execution state, ADR, C4, validation, debug, and plugin runtime data under `AirPlan/`.
- The repo-root `AGENTS.md` only bootstraps into this file.
{AIRARC_BEGIN}
## AirArc Workflow
1. Use `AirPlan/AGENTS.md` as the canonical project context entry point.
2. Load and maintain:
- `AirPlan/docs/analysis/requirements.md`
- `AirPlan/docs/architecture/solution-architecture.md`
- `AirPlan/docs/architecture/c4/module.md`
- `AirPlan/docs/architecture/adr/`
3. Produce or refine `AirPlan/plan.md` and `AirPlan/todo.md`.
4. Keep plans optimized for lower-cost follow-up sessions, including scope, validation, file targets, and parallelization boundaries.
5. AirArc is architecture-only: it may plan tasks and edit planning or architecture documents, but it must not write code or implement tasks directly.
{AIRARC_END}
{AIRENG_BEGIN}
## AirEng Workflow
1. Use `/aireng` as the sole scheduler for confirmed execution.
2. Prefer `AirPlan/state/airarc/reviews/execution-plan.json`, then `AirPlan/state/airarc/reviews/parallel-review.json`, before falling back to local `AirPlan/todo.md`.
3. Dispatch isolated `/airdo` subagents with bounded concurrency instead of defaulting to parent-thread coding.
4. Monitor active workers on a 5-minute cadence, merge ready results, and continue later waves automatically when work remains.
5. Keep `AirPlan/todo.md`, `AirPlan/plan.md`, `AirPlan/AGENTS.md`, ADR, and C4 docs synchronized during dispatch, monitoring, repair, and merge.
6. AirEng owns global debug, XDB, repair, intervention, and document convergence, but it should only intervene directly for hard blockers and must return to scheduler mode immediately afterward.
{AIRENG_END}
{AIRDO_BEGIN}
## AirDo Workflow
1. Use `/airdo` for one narrow task slice from `AirPlan/todo.md`.
2. Before editing, load:
- `AirPlan/AGENTS.md`
- `AirPlan/docs/architecture/adr/`
- `AirPlan/docs/architecture/c4/module.md`
- `AirPlan/plan.md`
- `AirPlan/todo.md`
3. Keep task-local progress resumable in `AirPlan/state/airdo/`.
4. When AirEng owns orchestration, return shared document changes through `documentUpdates`.
5. Route GUI work through AirXDB, debugging through AirDbg, network evidence through AirNDB, and static analysis through AirSDB when needed.
{AIRDO_END}
"""
PLAN_TEMPLATE = """# Implementation Plan
## Read First
1. `AirPlan/AGENTS.md`
2. `AirPlan/docs/analysis/requirements.md`
3. `AirPlan/docs/architecture/solution-architecture.md`
4. `AirPlan/docs/architecture/c4/module.md`
5. `AirPlan/docs/architecture/adr/`
6. `AirPlan/todo.md`
## Goal
- Replace this section with the concrete product or project goal.
## Constraints
- Record technical, organizational, legal, hardware, or platform constraints here.
## Phases
- Add implementation phases once AirArc planning is complete.
## Validation Strategy
- Record build, test, debug, GUI, network, and static-analysis validation commands here.
"""
TODO_TEMPLATE = """# TODO
Status values: TODO / DOING / DONE / BLOCKED
| ID | Status | Module | Task | Files/Dirs | Done When | Validation | Static Analysis | ADR/C4 Update |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| T-001 | TODO | Planning | Replace with the first confirmed execution task | `AirPlan/plan.md`, `AirPlan/docs/` | Acceptance criteria are explicit and testable | Record the exact validation command | Record the static-analysis plan or why it is not applicable | Record required ADR or C4 updates |
## Quality Gates
- Run the validation command listed in `AirPlan/plan.md` before marking a task `DONE`.
- Keep ADR and C4 docs synchronized whenever architecture, module boundaries, dependencies, or ownership change.
- Record skipped validation, residual risk, and follow-up work explicitly.
"""
REQUIREMENTS_TEMPLATE = """# Requirements
## Product Intent
- Replace with the user-visible outcome this repository should deliver.
## Functional Requirements
- Replace with numbered or grouped functional requirements.
## Constraints
- Replace with non-functional constraints, environmental limits, or safety rules.
## Acceptance Notes
- Replace with the most important acceptance criteria and evidence rules.
"""
SOLUTION_ARCHITECTURE_TEMPLATE = """# Solution Architecture
## Overview
- Replace with the top-level architecture summary.
## Major Components
- Replace with the main containers and their responsibilities.
## Data And Control Flow
- Replace with the major interaction paths between components.
## Key Risks
- Replace with the architecture risks, unknowns, and open decisions.
"""
C4_MODULE_TEMPLATE = """# C4 Module
## System Context
- Replace with the project purpose and external actors or systems.
## Containers
- Replace with the main runtime or repository containers.
## Modules
| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | Quality Notes |
| --- | --- | --- | --- | --- | --- |
| `replace_me` | Replace with the first real module | Replace with interfaces | Replace with dependencies | Replace with owned data | Replace with testing or quality notes |
"""
ADR_TEMPLATE = """# ADR-0001: Use AirPlan As The Workflow Root
- Status: Accepted
- Date: YYYY-MM-DD
## Context
This project needs a durable workflow root for planning, execution state, architecture context, validation evidence, and resumable AI sessions.
## Decision
Store project workflow artifacts under `AirPlan/`, use the repo-root `AGENTS.md` only as a bootstrap shim, and let `aireng` plus `airdo` maintain plan, todo, ADR, and C4 context there.
## Consequences
- Planning and execution context stay resumable across sessions.
- Global workflow docs live in one predictable location.
- Plugin runtime state does not clutter the main project tree.
"""
DEBUG_LOG_TEMPLATE = """# Debug Log
- Add reproducible bug investigations, root-cause notes, and validation outcomes here.
"""
GUI_DEBUG_LOG_TEMPLATE = """# GUI Debug Log
- Add screenshots, GUI observations, Midscene evidence, and visual acceptance notes here.
"""
NETWORK_LOG_TEMPLATE = """# AirNDB Log
- Add packet-capture commands, pcap paths, network observations, and conclusions here.
"""
STATIC_ANALYSIS_TEMPLATE = """# Static Analysis
- Add cppcheck or other static-analysis summaries, report paths, and residual risks here.
"""
VALIDATION_README_TEMPLATE = """# Validation Artifacts
- Save build logs, flash logs, test logs, screenshots, and validation summaries under this directory.
"""
ARTIFACTS_README_TEMPLATE = """# Artifact Output
- Save generated evidence files in this directory.
"""
def _write_if_missing(path: Path, content: str) -> str:
if path.exists():
return "exists"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content.rstrip() + "\n", encoding="utf-8", newline="\n")
return "created"
def _upsert_marked_block(existing: str, begin: str, end: str, block: str) -> Tuple[str, bool]:
begin_index = existing.find(begin)
end_index = existing.find(end)
normalized_block = block.rstrip() + "\n"
if begin_index >= 0 and end_index > begin_index:
end_index += len(end)
updated = existing[:begin_index].rstrip() + "\n\n" + normalized_block + existing[end_index:].lstrip()
return updated, updated != existing
updated = existing.rstrip() + "\n\n" + normalized_block
return updated, True
def _ensure_project_agents(path: Path) -> str:
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(PROJECT_AGENTS_TEMPLATE.rstrip() + "\n", encoding="utf-8", newline="\n")
return "created"
original = path.read_text(encoding="utf-8-sig")
updated = original
changed = False
for begin, end, block in [
(
AIRARC_BEGIN,
AIRARC_END,
PROJECT_AGENTS_TEMPLATE[
PROJECT_AGENTS_TEMPLATE.index(AIRARC_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRARC_END) + len(AIRARC_END)
],
),
(
AIRENG_BEGIN,
AIRENG_END,
PROJECT_AGENTS_TEMPLATE[
PROJECT_AGENTS_TEMPLATE.index(AIRENG_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRENG_END) + len(AIRENG_END)
],
),
(
AIRDO_BEGIN,
AIRDO_END,
PROJECT_AGENTS_TEMPLATE[
PROJECT_AGENTS_TEMPLATE.index(AIRDO_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRDO_END) + len(AIRDO_END)
],
),
]:
updated, block_changed = _upsert_marked_block(updated, begin, end, block)
changed = changed or block_changed
if not changed:
return "exists"
path.write_text(updated.rstrip() + "\n", encoding="utf-8", newline="\n")
return "updated"
def ensure_project_bootstrap(project_root: Path) -> Dict[str, str]:
airplan_root(project_root).mkdir(parents=True, exist_ok=True)
architecture_adr_dir(project_root).mkdir(parents=True, exist_ok=True)
architecture_c4_module_path(project_root).parent.mkdir(parents=True, exist_ok=True)
analysis_requirements_path(project_root).parent.mkdir(parents=True, exist_ok=True)
debug_log_path(project_root).parent.mkdir(parents=True, exist_ok=True)
gui_debug_log_path(project_root).parent.mkdir(parents=True, exist_ok=True)
(debug_log_path(project_root).parent / "airxdb-artifacts").mkdir(parents=True, exist_ok=True)
(airplan_root(project_root) / "docs" / "network" / "airndb-captures").mkdir(parents=True, exist_ok=True)
(validation_root(project_root) / "logs").mkdir(parents=True, exist_ok=True)
state_root(project_root).mkdir(parents=True, exist_ok=True)
results = {
str(root_agents_bootstrap_path(project_root)): _write_if_missing(
root_agents_bootstrap_path(project_root), ROOT_AGENTS_TEMPLATE
),
str(agents_path(project_root)): _ensure_project_agents(agents_path(project_root)),
str(airplan_root(project_root) / "plan.md"): _write_if_missing(
airplan_root(project_root) / "plan.md", PLAN_TEMPLATE
),
str(airplan_root(project_root) / "todo.md"): _write_if_missing(
airplan_root(project_root) / "todo.md", TODO_TEMPLATE
),
str(analysis_requirements_path(project_root)): _write_if_missing(
analysis_requirements_path(project_root), REQUIREMENTS_TEMPLATE
),
str(architecture_solution_path(project_root)): _write_if_missing(
architecture_solution_path(project_root), SOLUTION_ARCHITECTURE_TEMPLATE
),
str(architecture_c4_module_path(project_root)): _write_if_missing(
architecture_c4_module_path(project_root), C4_MODULE_TEMPLATE
),
str(architecture_adr_dir(project_root) / "ADR-0001-use-airplan-as-the-workflow-root.md"): _write_if_missing(
architecture_adr_dir(project_root) / "ADR-0001-use-airplan-as-the-workflow-root.md",
ADR_TEMPLATE,
),
str(debug_log_path(project_root)): _write_if_missing(
debug_log_path(project_root), DEBUG_LOG_TEMPLATE
),
str(gui_debug_log_path(project_root)): _write_if_missing(
gui_debug_log_path(project_root), GUI_DEBUG_LOG_TEMPLATE
),
str(debug_log_path(project_root).parent / "airxdb-artifacts" / "README.md"): _write_if_missing(
debug_log_path(project_root).parent / "airxdb-artifacts" / "README.md",
ARTIFACTS_README_TEMPLATE,
),
str(airplan_root(project_root) / "docs" / "network" / "airndb-log.md"): _write_if_missing(
airplan_root(project_root) / "docs" / "network" / "airndb-log.md",
NETWORK_LOG_TEMPLATE,
),
str(airplan_root(project_root) / "docs" / "network" / "airndb-captures" / "README.md"): _write_if_missing(
airplan_root(project_root) / "docs" / "network" / "airndb-captures" / "README.md",
ARTIFACTS_README_TEMPLATE,
),
str(validation_root(project_root) / "README.md"): _write_if_missing(
validation_root(project_root) / "README.md", VALIDATION_README_TEMPLATE
),
str(validation_root(project_root) / "logs" / "README.md"): _write_if_missing(
validation_root(project_root) / "logs" / "README.md", ARTIFACTS_README_TEMPLATE
),
str(staticanalysis_path(project_root)): _write_if_missing(
staticanalysis_path(project_root), STATIC_ANALYSIS_TEMPLATE
),
}
return results

View File

@@ -0,0 +1,373 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, Iterable, List
from .contracts import DEFAULT_REPAIR_POLICY, RepairAttempt, WorkerResult, now_iso
from .paths import (
airdo_root,
aireng_root,
debug_log_path,
gui_debug_log_path,
plan_path,
todo_path,
)
def _json_load(path: Path) -> Dict[str, object]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8-sig"))
def _json_dump(path: Path, payload: Dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def _ordered_unique(items: Iterable[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in items:
cleaned = str(item).strip()
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered
def normalize_repair_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
merged = dict(DEFAULT_REPAIR_POLICY)
if policy:
merged.update(policy)
merged["enabled"] = bool(merged.get("enabled", True))
merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True))
merged["autoPrepareWorker"] = bool(merged.get("autoPrepareWorker", True))
merged["triggerValidationStatuses"] = _ordered_unique(
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
)
try:
max_attempts = int(merged.get("maxAttemptsPerTask", 2) or 2)
except (TypeError, ValueError):
max_attempts = 2
merged["maxAttemptsPerTask"] = max(1, max_attempts)
requeue_status = str(merged.get("requeueTodoStatus", "DOING")).strip().upper()
if requeue_status not in {"TODO", "DOING"}:
requeue_status = "DOING"
merged["requeueTodoStatus"] = requeue_status
return merged
def load_repair_policy(project_root: Path) -> Dict[str, object]:
state_path = aireng_root(project_root) / "state.json"
state = _json_load(state_path)
return normalize_repair_policy(state.get("repairPolicy", {}))
def _repair_root(project_root: Path) -> Path:
return aireng_root(project_root) / "repairs"
def _repair_queue_path(project_root: Path) -> Path:
return aireng_root(project_root) / "repair-queue.md"
def _repair_attempt_path(project_root: Path, task_id: str, repair_id: str) -> Path:
return _repair_root(project_root) / task_id / repair_id / "attempt.json"
def _repair_brief_path(project_root: Path, task_id: str, repair_id: str) -> Path:
return _repair_root(project_root) / task_id / repair_id / "repair-brief.md"
def _worker_repair_brief_path(project_root: Path, task_id: str) -> Path:
return airdo_root(project_root) / "tasks" / task_id / "repair-brief.md"
def _repair_reasons(result: WorkerResult, policy: Dict[str, object]) -> List[str]:
reasons: List[str] = []
if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)):
reasons.append("blocked worker result requires automatic repair")
tracked_statuses = {
str(item).lower() for item in list(policy.get("triggerValidationStatuses", []))
}
failed_validations = [
item for item in result.validations if item.status.lower() in tracked_statuses
]
if failed_validations:
reasons.append(
"validation failures: "
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
)
return reasons
def _build_repair_brief(project_root: Path, result: WorkerResult, attempt: RepairAttempt) -> str:
debug_session_ids = ", ".join(f"`{item}`" for item in attempt.debug_session_ids) or "`none`"
xdb_session_ids = ", ".join(f"`{item.session_id}`" for item in result.xdb_sessions) or "`none`"
validations = (
"; ".join(
f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "")
for item in result.validations
)
or "none"
)
evidence = ", ".join(f"`{item}`" for item in result.evidence_paths) or "`none`"
blockers = ", ".join(result.blockers) or "none"
risks = ", ".join(result.risks) or "none"
return "\n".join(
[
f"# Auto Repair Brief: {attempt.repair_id}",
"",
f"- Task ID: `{result.task_id}`",
f"- Attempt Index: `{attempt.attempt_index}`",
f"- Source Status: `{result.status}`",
f"- Reason: {attempt.reason}",
f"- Debug Sessions: {debug_session_ids}",
f"- Source Result Path: `{attempt.source_result_path}`",
"",
"## Required Context",
"",
f"- Load `{project_root / 'AirPlan' / 'AGENTS.md'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'adr'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'c4' / 'module.md'}`, `{plan_path(project_root)}`, `{todo_path(project_root)}`, `{debug_log_path(project_root)}`, and `{gui_debug_log_path(project_root)}` when present.",
"- Read the referenced AirDbg request/session artifacts before changing code.",
"- If this task has AirXDB evidence, read the GUI screenshots and report before changing code.",
"- Continue repairing automatically. Do not stop at the existence of a debug request.",
"- Only return `blocked` again when the repair budget is exhausted or a real user decision is required.",
"",
"## Failure Snapshot",
"",
f"- Summary: {result.summary}",
f"- Validations: {validations}",
f"- Blockers: {blockers}",
f"- Evidence: {evidence}",
f"- XDB Sessions: {xdb_session_ids}",
f"- Risks: {risks}",
"",
"## Repair Goal",
"",
"- Modify the scoped code until the original validation path passes again.",
"- Keep the same task id and reuse the existing debug session id in the final repaired result.",
"- Update document sync content if the final fix changes architecture or execution rules.",
"",
f"- Project Root: `{project_root}`",
]
) + "\n"
def _write_repair_attempt_artifacts(
project_root: Path, result: WorkerResult, attempt: RepairAttempt
) -> RepairAttempt:
attempt_path = Path(attempt.state_path)
brief_path = Path(attempt.repair_brief_path)
worker_brief_path = Path(attempt.worker_brief_path)
brief_content = _build_repair_brief(project_root, result, attempt)
brief_path.parent.mkdir(parents=True, exist_ok=True)
brief_path.write_text(brief_content, encoding="utf-8")
worker_brief_path.parent.mkdir(parents=True, exist_ok=True)
worker_brief_path.write_text(brief_content, encoding="utf-8")
_json_dump(
attempt_path,
{
"repairAttempt": attempt.to_dict(),
"sourceSummary": result.summary,
"sourceBlockers": result.blockers,
"sourceValidations": [item.to_dict() for item in result.validations],
"sourceEvidencePaths": result.evidence_paths,
},
)
return attempt
def list_repair_attempts(project_root: Path, task_id: str = "") -> List[RepairAttempt]:
repair_root = _repair_root(project_root)
if not repair_root.exists():
return []
attempts: List[RepairAttempt] = []
search_root = repair_root / task_id if task_id else repair_root
if not search_root.exists():
return []
for path in sorted(search_root.rglob("attempt.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
attempt_payload = payload.get("repairAttempt", payload)
attempt = RepairAttempt.from_dict(attempt_payload)
attempt.validate()
except (json.JSONDecodeError, TypeError, ValueError):
continue
if task_id and attempt.task_id != task_id:
continue
attempts.append(attempt)
attempts.sort(key=lambda item: (item.created_at, item.repair_id))
return attempts
def load_active_repair_attempt(project_root: Path, task_id: str) -> RepairAttempt | None:
for attempt in reversed(list_repair_attempts(project_root, task_id)):
if attempt.status in {"queued", "active"}:
return attempt
return None
def _set_attempt_status(attempt: RepairAttempt, status: str) -> RepairAttempt:
attempt.status = status
attempt_path = Path(attempt.state_path)
if attempt_path.exists():
payload = json.loads(attempt_path.read_text(encoding="utf-8-sig"))
payload["repairAttempt"] = attempt.to_dict()
_json_dump(attempt_path, payload)
return attempt
def ensure_repair_attempts_for_result(
project_root: Path, result: WorkerResult, source_result_path: str = ""
) -> List[RepairAttempt]:
policy = load_repair_policy(project_root)
if not bool(policy.get("enabled", True)):
return result.repair_attempts
if result.repair_attempts:
return result.repair_attempts
existing_attempts = list_repair_attempts(project_root, result.task_id)
active_attempts = [
item for item in existing_attempts if item.status in {"queued", "active"}
]
if result.status == "done":
if active_attempts:
resolved_attempts = []
for attempt in active_attempts:
resolved_attempts.append(_set_attempt_status(attempt, "resolved"))
result.repair_attempts = resolved_attempts
return result.repair_attempts
reasons = _repair_reasons(result, policy)
if not reasons:
return result.repair_attempts
if active_attempts:
result.repair_attempts = active_attempts
return result.repair_attempts
if len(existing_attempts) >= int(policy.get("maxAttemptsPerTask", 2) or 2):
result.notes.append(
f"Automatic repair budget exhausted for {result.task_id}; user decision may be required."
)
return result.repair_attempts
attempt_index = len(existing_attempts) + 1
repair_id = f"{result.task_id}-repair-{attempt_index:03d}"
created_at = now_iso()
attempt = RepairAttempt(
repair_id=repair_id,
task_id=result.task_id,
attempt_index=attempt_index,
source_status=result.status,
source_result_path=source_result_path or "",
reason="; ".join(reasons),
repair_brief_path=str(_repair_brief_path(project_root, result.task_id, repair_id)),
state_path=str(_repair_attempt_path(project_root, result.task_id, repair_id)),
worker_brief_path=str(_worker_repair_brief_path(project_root, result.task_id)),
debug_session_ids=_ordered_unique(
item.session_id for item in result.debug_sessions if item.session_id
),
status="queued",
created_at=created_at,
)
_write_repair_attempt_artifacts(project_root, result, attempt)
result.repair_attempts = [attempt]
result.notes.append(
f"Automatic repair attempt queued for {result.task_id}: {attempt.repair_id}"
)
return result.repair_attempts
def mark_repair_attempts_active(project_root: Path, task_id: str) -> List[RepairAttempt]:
updated: List[RepairAttempt] = []
for attempt in list_repair_attempts(project_root, task_id):
if attempt.status == "queued":
updated.append(_set_attempt_status(attempt, "active"))
return updated
def merge_repair_attempts_into_state(
state: Dict[str, object], repair_attempts: List[RepairAttempt]
) -> Dict[str, object]:
state["repairPolicy"] = normalize_repair_policy(dict(state.get("repairPolicy", {})))
existing_items = list(state.get("repairAttempts", []))
items_by_id = {}
for item in existing_items:
if not isinstance(item, dict):
continue
repair_id = str(item.get("repairId", "")).strip()
if repair_id:
items_by_id[repair_id] = item
for attempt in repair_attempts:
items_by_id[attempt.repair_id] = attempt.to_dict()
merged_items = [items_by_id[key] for key in sorted(items_by_id)]
state["repairAttempts"] = merged_items
state["activeRepairCount"] = sum(
1 for item in merged_items if str(item.get("status", "")).strip() in {"queued", "active"}
)
if repair_attempts:
latest = repair_attempts[-1]
state["lastRepairAttemptId"] = latest.repair_id
state["lastRepairTaskId"] = latest.task_id
state["lastRepairAt"] = latest.created_at
return state
def summarize_active_repairs(state: Dict[str, object]) -> List[Dict[str, str]]:
summary: List[Dict[str, str]] = []
for item in list(state.get("repairAttempts", [])):
if not isinstance(item, dict):
continue
status = str(item.get("status", "")).strip()
if status not in {"queued", "active"}:
continue
summary.append(
{
"repairId": str(item.get("repairId", "")).strip(),
"taskId": str(item.get("taskId", "")).strip(),
"status": status,
"reason": str(item.get("reason", "")).strip(),
"repairBriefPath": str(item.get("repairBriefPath", "")).strip(),
}
)
return summary
def write_repair_queue(project_root: Path, state: Dict[str, object]) -> Path:
queue_path = _repair_queue_path(project_root)
active_items = summarize_active_repairs(state)
lines = [
"# Air Engine Auto Repair Queue",
"",
"This file lists repair attempts that were automatically queued from blocked or failed worker results.",
"",
]
if active_items:
for item in active_items:
lines.append(f"- Repair: `{item['repairId']}`")
lines.append(f" Task: `{item['taskId']}`")
lines.append(f" Status: `{item['status']}`")
lines.append(f" Reason: {item['reason'] or 'n/a'}")
lines.append(f" Brief: `{item['repairBriefPath']}`")
else:
lines.append("- No active auto repair attempts.")
queue_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return queue_path

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
from itertools import combinations
from pathlib import Path
from typing import Dict, Iterable, List, Set
from .contracts import (
ACTIVE_TASK_STATUSES,
ParallelGroup,
ParallelReview,
ReviewConflict,
TaskRecord,
now_iso,
)
from .todo_parser import parse_tasks
def _overlap_paths(left: Iterable[str], right: Iterable[str]) -> List[str]:
overlap: Set[str] = set()
left_items = list(left)
right_items = list(right)
for left_path in left_items:
for right_path in right_items:
left_clean = left_path.rstrip("/\\")
right_clean = right_path.rstrip("/\\")
if left_clean == right_clean:
overlap.add(left_path)
continue
if left_clean.startswith(right_clean):
overlap.add(right_path)
elif right_clean.startswith(left_clean):
overlap.add(left_path)
return sorted(overlap)
def _task_conflict(left: TaskRecord, right: TaskRecord) -> ReviewConflict | None:
overlap = _overlap_paths(left.normalized_write_set(), right.normalized_write_set())
if overlap:
return ReviewConflict(
task_ids=[left.task_id, right.task_id],
reason="shared write set overlap",
overlap_paths=overlap,
)
if left.touches_global_docs() and right.touches_global_docs():
return ReviewConflict(
task_ids=[left.task_id, right.task_id],
reason="both tasks touch global planning or architecture documents",
overlap_paths=sorted(set(left.global_doc_paths + right.global_doc_paths)),
)
return None
def _greedy_parallel_groups(tasks: List[TaskRecord]) -> List[ParallelGroup]:
groups: List[List[TaskRecord]] = []
for task in tasks:
placed = False
for group in groups:
if all(_task_conflict(task, existing) is None for existing in group):
group.append(task)
placed = True
break
if not placed:
groups.append([task])
output: List[ParallelGroup] = []
for index, group in enumerate(groups, start=1):
output.append(
ParallelGroup(
name=f"group-{index}",
task_ids=[task.task_id for task in group],
reason="no detected write-set conflict inside this group",
)
)
return output
def _build_dependency_edges(tasks: List[TaskRecord]) -> List[Dict[str, str]]:
task_ids = {task.task_id for task in tasks}
edges: List[Dict[str, str]] = []
for task in tasks:
for dependency in task.dependencies:
if dependency in task_ids:
edges.append({"from": dependency, "to": task.task_id})
return edges
def build_parallel_review(todo_path: Path) -> ParallelReview:
all_tasks = parse_tasks(todo_path)
active_tasks = [task for task in all_tasks if task.status in ACTIVE_TASK_STATUSES]
active_task_ids = {task.task_id for task in active_tasks}
task_map = {task.task_id: task for task in active_tasks}
edges = _build_dependency_edges(active_tasks)
in_degree = {task.task_id: 0 for task in active_tasks}
children: Dict[str, List[str]] = {task.task_id: [] for task in active_tasks}
for edge in edges:
parent = edge["from"]
child = edge["to"]
if parent not in active_task_ids or child not in active_task_ids:
continue
in_degree[child] += 1
children[parent].append(child)
ready = sorted(
[task.task_id for task in active_tasks if in_degree[task.task_id] == 0],
key=lambda task_id: task_map[task_id].line_number,
)
scheduled = set()
parallel_groups: List[ParallelGroup] = []
wave_index = 1
while ready:
current_wave_ids = ready
ready = []
current_tasks = [task_map[task_id] for task_id in current_wave_ids]
wave_groups = _greedy_parallel_groups(current_tasks)
for group in wave_groups:
group.name = f"wave-{wave_index}-{group.name}"
parallel_groups.extend(wave_groups)
wave_index += 1
for task_id in current_wave_ids:
scheduled.add(task_id)
for child in children.get(task_id, []):
in_degree[child] -= 1
if in_degree[child] == 0:
ready.append(child)
ready.sort(key=lambda task_id: task_map[task_id].line_number)
notes: List[str] = []
unscheduled = sorted(active_task_ids - scheduled)
if unscheduled:
notes.append(
"Some active tasks could not be layered. Check for cyclic or missing dependencies: "
+ ", ".join(unscheduled)
)
if all(not task.dependencies for task in active_tasks) and len(active_tasks) > 1:
notes.append(
"No explicit dependency hints were found. Parallel grouping relies on write-set isolation and global-doc serialization rules."
)
blocked_tasks = [task.task_id for task in all_tasks if task.status == "BLOCKED"]
if blocked_tasks:
notes.append("Blocked tasks were excluded from ready groups: " + ", ".join(blocked_tasks))
conflicts: List[ReviewConflict] = []
for left, right in combinations(active_tasks, 2):
conflict = _task_conflict(left, right)
if conflict is not None:
conflicts.append(conflict)
serialization_points: List[Dict[str, object]] = []
for task in active_tasks:
reasons: List[str] = []
if task.touches_global_docs():
reasons.append("touches global planning or architecture documents")
if any(task.task_id in conflict.task_ids for conflict in conflicts):
reasons.append("has shared write-set conflicts that need engine-level scheduling")
if reasons:
serialization_points.append(
{
"taskId": task.task_id,
"reasons": reasons,
"paths": task.global_doc_paths or task.normalized_write_set(),
}
)
return ParallelReview(
source_todo=str(todo_path),
generated_at=now_iso(),
tasks_considered=active_tasks,
dependency_edges=edges,
parallel_groups=parallel_groups,
conflicts=conflicts,
serialization_points=serialization_points,
notes=notes,
)
def render_review_markdown(review: ParallelReview) -> str:
lines = [
"# AirArc Parallel Review",
"",
f"- Source TODO: `{review.source_todo}`",
f"- Generated At: `{review.generated_at}`",
f"- Active Tasks: `{', '.join(task.task_id for task in review.tasks_considered) or 'none'}`",
"",
"## Parallel Groups",
]
if review.parallel_groups:
for group in review.parallel_groups:
lines.append(f"- `{group.name}`: {', '.join(group.task_ids)}")
lines.append(f" Reason: {group.reason}")
else:
lines.append("- No ready parallel groups were detected.")
lines.extend(["", "## Dependency Edges"])
if review.dependency_edges:
for edge in review.dependency_edges:
lines.append(f"- `{edge['from']}` -> `{edge['to']}`")
else:
lines.append("- No explicit dependency edges were detected.")
lines.extend(["", "## Shared-Write Conflicts"])
if review.conflicts:
for conflict in review.conflicts:
lines.append(
f"- `{conflict.task_ids[0]}` <-> `{conflict.task_ids[1]}`: {conflict.reason}"
)
if conflict.overlap_paths:
lines.append(" Paths: " + ", ".join(f"`{path}`" for path in conflict.overlap_paths))
else:
lines.append("- No shared-write conflicts were detected.")
lines.extend(["", "## Serialization Points"])
if review.serialization_points:
for point in review.serialization_points:
lines.append(f"- `{point['taskId']}`: {'; '.join(point['reasons'])}")
paths = point.get("paths", [])
if paths:
lines.append(" Paths: " + ", ".join(f"`{path}`" for path in paths))
else:
lines.append("- No serialization points were detected.")
lines.extend(["", "## Notes"])
if review.notes:
for note in review.notes:
lines.append(f"- {note}")
else:
lines.append("- No extra notes.")
return "\n".join(lines) + "\n"

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Optional
from .contracts import TaskRecord
TASK_ID_RE = re.compile(r"T-\d+")
HEADER_RE = re.compile(r"^\|\s*ID\s*\|\s*Status\s*\|", re.IGNORECASE)
SEPARATOR_RE = re.compile(r"^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?$")
CODE_SPAN_RE = re.compile(r"`([^`]+)`")
DEPENDENCY_HINT_RE = re.compile(r"\[(?:deps?|depends)\s*:\s*([^\]]+)\]", re.IGNORECASE)
def _split_row(line: str) -> List[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def _normalize_paths(items: List[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in items:
cleaned = item.strip().strip("`")
if not cleaned or cleaned.lower() == "planned":
continue
if cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered
def extract_paths(cell_text: str) -> List[str]:
code_paths = CODE_SPAN_RE.findall(cell_text)
if code_paths:
return _normalize_paths(code_paths)
candidates = re.split(r"[,;+]", cell_text)
paths = [
token.strip()
for token in candidates
if "/" in token or "\\" in token or token.endswith((".md", ".py", ".json"))
]
return _normalize_paths(paths)
def extract_dependencies(*cells: str) -> List[str]:
found: List[str] = []
for cell in cells:
for match in DEPENDENCY_HINT_RE.finditer(cell):
found.extend(TASK_ID_RE.findall(match.group(1)))
return _normalize_paths(found)
def extract_global_doc_paths(*cells: str) -> List[str]:
text = " ".join(cells)
candidates: List[str] = []
lowered = text.lower()
if "agents.md" in lowered or "agents" in text:
candidates.append("AirPlan/AGENTS.md")
if "airplan/docs/architecture/adr" in lowered or "docs/architecture/adr" in lowered or "adr" in text:
candidates.append("AirPlan/docs/architecture/adr/")
if "airplan/docs/architecture/c4/module.md" in lowered or "docs/architecture/c4/module.md" in lowered or "c4" in text:
candidates.append("AirPlan/docs/architecture/c4/module.md")
if "plan.md" in lowered:
candidates.append("AirPlan/plan.md")
if "todo.md" in lowered or "todo" in text:
candidates.append("AirPlan/todo.md")
if "staticanalysis.md" in lowered:
candidates.append("AirPlan/docs/staticanalysis.md")
for path in extract_paths(text):
normalized = path.replace("\\", "/")
if normalized in {"AGENTS.md", "AirPlan/AGENTS.md"}:
candidates.append("AirPlan/AGENTS.md")
elif "AirPlan/docs/architecture/adr" in normalized or "docs/architecture/adr" in normalized:
candidates.append("AirPlan/docs/architecture/adr/")
elif "AirPlan/docs/architecture/c4/module.md" in normalized or "docs/architecture/c4/module.md" in normalized:
candidates.append("AirPlan/docs/architecture/c4/module.md")
elif normalized in {"plan.md", "AirPlan/plan.md"}:
candidates.append("AirPlan/plan.md")
elif normalized in {"todo.md", "AirPlan/todo.md"}:
candidates.append("AirPlan/todo.md")
elif normalized in {"staticanalysis.md", "AirPlan/docs/staticanalysis.md"}:
candidates.append("AirPlan/docs/staticanalysis.md")
return _normalize_paths(candidates)
def parse_tasks(todo_path: Path) -> List[TaskRecord]:
lines = todo_path.read_text(encoding="utf-8").splitlines()
header_index: Optional[int] = None
for idx, line in enumerate(lines):
if HEADER_RE.search(line):
header_index = idx
break
if header_index is None:
raise ValueError(f"no TODO task table found in {todo_path}")
header_cells = _split_row(lines[header_index])
tasks: List[TaskRecord] = []
for line_number in range(header_index + 1, len(lines)):
raw_line = lines[line_number]
if not raw_line.strip():
if tasks:
break
continue
if not raw_line.strip().startswith("|"):
if tasks:
break
continue
if SEPARATOR_RE.match(raw_line):
continue
row_cells = _split_row(raw_line)
if len(row_cells) < len(header_cells):
row_cells += [""] * (len(header_cells) - len(row_cells))
row: Dict[str, str] = dict(zip(header_cells, row_cells))
task_id = row.get("ID", "").strip()
if not task_id:
continue
files_dirs = row.get("Files/Dirs", "").strip()
adr_c4_update = row.get("ADR/C4 Update", "").strip()
task_text = row.get("Task", "").strip()
validation = row.get("Validation", "").strip()
tasks.append(
TaskRecord(
task_id=task_id,
status=row.get("Status", "").strip().upper(),
module=row.get("Module", "").strip(),
task=task_text,
files_dirs=files_dirs,
done_when=row.get("Done When", "").strip(),
validation=validation,
adr_c4_update=adr_c4_update,
line_number=line_number + 1,
dependencies=extract_dependencies(task_text, files_dirs, validation, adr_c4_update),
write_paths=extract_paths(files_dirs),
global_doc_paths=extract_global_doc_paths(
files_dirs, adr_c4_update, task_text
),
)
)
return tasks
def find_task(tasks: List[TaskRecord], task_id: str) -> Optional[TaskRecord]:
for task in tasks:
if task.task_id == task_id:
return task
return None

View File

@@ -0,0 +1,372 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List
from .airxdb_runtime import ensure_xdb_sessions_for_result, task_requires_xdb
from .contracts import (
DocumentUpdate,
WorkerResult,
default_worker_result,
now_iso,
required_project_artifacts,
)
from .debug_runtime import ensure_debug_sessions_for_result
from .doc_sync import enforce_doc_sync_requirements
from .paths import (
agents_path,
airdo_root,
architecture_adr_dir,
architecture_c4_module_path,
plan_path,
todo_path,
)
from .repair_runtime import load_active_repair_attempt
from .todo_parser import find_task, parse_tasks
def _json_dump(path: Path, payload: Dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def _artifact_health(project_root: Path) -> Dict[str, bool]:
return {
relative: (project_root / relative).exists()
for relative in required_project_artifacts()
}
def _paths(project_root: Path) -> Dict[str, Path]:
worker_root = airdo_root(project_root)
return {
"root": worker_root,
"state": worker_root / "state.json",
"tasks": worker_root / "tasks",
"results": worker_root / "results",
}
def _ensure_layout(project_root: Path) -> Dict[str, Path]:
paths = _paths(project_root)
paths["tasks"].mkdir(parents=True, exist_ok=True)
paths["results"].mkdir(parents=True, exist_ok=True)
return paths
def _task_dir(project_root: Path, task_id: str) -> Path:
return _paths(project_root)["tasks"] / task_id
def worker_state_path(project_root: Path, task_id: str) -> Path:
return _task_dir(project_root, task_id) / "worker-state.json"
def _load_task_record(project_root: Path, task_id: str):
current_todo = todo_path(project_root)
if not current_todo.exists():
return None
try:
return find_task(parse_tasks(current_todo), task_id)
except ValueError:
return None
def _worker_rules_block() -> List[str]:
return [
"## Worker Rules",
"",
"- Load `AirPlan/AGENTS.md`, `AirPlan/docs/architecture/adr/`, `AirPlan/docs/architecture/c4/module.md`, `AirPlan/plan.md`, and `AirPlan/todo.md` before doing work.",
"- Keep the task scoped to this brief.",
"- Preserve validation evidence on disk.",
"- Do not directly take ownership of global `AirPlan/todo.md`, `AirPlan/AGENTS.md`, ADR, or C4 updates unless explicitly delegated.",
"- If this task touches global docs, `result.json` must include executable `documentUpdates`, not just recommendations.",
"- If this task is GUI or visual acceptance related, `result.json` must end with successful AirXDB evidence before it can close as done.",
"- If the result is blocked, or a validation ends in failed/error, the worker auto-requests AirDbg before finalize.",
"- If the task is GUI-related, the worker auto-captures AirXDB evidence before finalize.",
"- If an active auto-repair attempt exists, continue repairing immediately instead of stopping at the debug request.",
"- Do not stop at an 'about to implement', 'implementation plan ready', or similar midpoint status. Continue editing, validating, and finalizing unless a real blocker or user decision is required.",
"- Do not return only a progress update when the task is actionable. Return only after finalize, or after recording a concrete blocked reason that needs intervention.",
"- Fill `result.json`, then finalize it for engine merge.",
"",
]
def _placeholder_result(project_root: Path, task_id: str) -> WorkerResult:
task_summary = ""
task = _load_task_record(project_root, task_id)
if task:
task_summary = task.task
result = default_worker_result(task_id, task_summary)
if task and task.global_doc_paths:
result.global_doc_paths = list(task.global_doc_paths)
result.recommend_global_doc_updates = list(task.global_doc_paths)
result.document_updates = []
for doc_path in task.global_doc_paths:
if doc_path == "AirPlan/docs/architecture/adr/":
result.document_updates.append(
DocumentUpdate(
path="AirPlan/docs/architecture/adr/ADR-XXXX-task-sync.md",
action="create_file",
content=(
"# ADR-XXXX: task-sync\n\n"
"- Status: Accepted\n"
"- Date: YYYY-MM-DD\n\n"
"## Context\n"
"Replace with the concrete context introduced by this task.\n\n"
"## Decision\n"
"Replace with the concrete decision introduced by this task.\n\n"
"## Consequences\n"
"- Replace with the concrete consequences introduced by this task.\n"
),
)
)
else:
result.document_updates.append(
DocumentUpdate(
path=doc_path,
action="replace_block",
marker=f"{task_id}-{Path(doc_path).name}".replace(".", "-").upper(),
content=(
f"## Sync For {task_id}\n\n"
"- Summary: Replace with a concrete summary.\n"
"- Code Reality: Replace with the actual code outcome.\n"
"- Validation: Replace with the actual validation result.\n"
),
)
)
return result
def _is_untouched_placeholder_result(project_root: Path, result: WorkerResult) -> bool:
placeholder = _placeholder_result(project_root, result.task_id)
return result.to_dict() == placeholder.to_dict()
def resolve_worker_result_path(project_root: Path, task_id: str) -> Path:
state_path = worker_state_path(project_root, task_id)
if state_path.exists():
payload = json.loads(state_path.read_text(encoding="utf-8-sig"))
raw_path = str(payload.get("resultPath", "")).strip()
if raw_path:
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = (project_root / candidate).resolve()
return candidate
return _task_dir(project_root, task_id) / "result.json"
def _write_brief(project_root: Path, task_id: str) -> Path:
task_folder = _task_dir(project_root, task_id)
task_folder.mkdir(parents=True, exist_ok=True)
task = _load_task_record(project_root, task_id)
active_repair = load_active_repair_attempt(project_root, task_id)
lines: List[str] = [f"# Worker Brief: {task_id}", ""]
if task:
requires_xdb = task_requires_xdb(task, None)
lines.extend(
[
f"- Module: `{task.module}`",
f"- Status In TODO: `{task.status}`",
f"- Task: {task.task}",
f"- Write Scope: {', '.join(f'`{path}`' for path in task.write_paths) or '`(not declared)`'}",
f"- Global Doc Paths: {', '.join(f'`{path}`' for path in task.global_doc_paths) or '`none`'}",
f"- Validation: {task.validation or '(not declared)'}",
f"- Document Sync Required: {'yes' if task.global_doc_paths else 'no'}",
f"- AirXDB Required: {'yes' if requires_xdb else 'no'}",
"",
]
)
if active_repair:
lines.extend(
[
"## Active Auto Repair",
"",
f"- Repair ID: `{active_repair.repair_id}`",
f"- Attempt Index: `{active_repair.attempt_index}`",
f"- Status: `{active_repair.status}`",
f"- Reason: {active_repair.reason}",
f"- Repair Brief: `{active_repair.repair_brief_path}`",
f"- Debug Sessions: {', '.join(f'`{item}`' for item in active_repair.debug_session_ids) or '`none`'}",
"",
]
)
lines.extend(_worker_rules_block())
brief_path = task_folder / "brief.md"
brief_path.write_text("\n".join(lines), encoding="utf-8")
return brief_path
def _write_handoff(project_root: Path, task_id: str, brief_path: Path, result_path: Path) -> Path:
task = _load_task_record(project_root, task_id)
current_worker_state_path = worker_state_path(project_root, task_id)
handoff_path = _task_dir(project_root, task_id) / "subagent-handoff.md"
lines = [
f"# AirDo Subagent Handoff: {task_id}",
"",
"You are an isolated AirDo execution subagent launched by AirEng.",
"",
f"- Task ID: `{task_id}`",
f"- Project Root: `{project_root}`",
f"- Brief Path: `{brief_path}`",
f"- Worker State Path: `{current_worker_state_path}`",
f"- Template Result Path: `{result_path}`",
f"- Task Summary: {task.task if task else '(load from brief)'}",
f"- Write Scope: {', '.join(f'`{path}`' for path in (task.write_paths if task else [])) or '`(load from brief)`'}",
"",
"## Required Reads",
"",
f"- `{agents_path(project_root)}`",
f"- `{architecture_adr_dir(project_root)}`",
f"- `{architecture_c4_module_path(project_root)}`",
f"- `{plan_path(project_root)}`",
f"- `{todo_path(project_root)}`",
f"- `{brief_path}`",
"",
"## Execution Contract",
"",
"- Stay inside this task scope and its declared write set.",
"- Do not assume the parent thread history is available; build context from the files above.",
"- Execute the task, gather validation evidence, and update `result.json` with honest status, files changed, risks, blockers, and document updates when required.",
"- Run AirDbg or AirXDB automatically when the runtime rules require them.",
"- Do not pause for intermediate implementation-status replies. Keep going until the task is finalized unless you hit a real blocker that requires external input.",
"- Finalize the result before returning control to AirEng.",
"",
"## Finalize Command",
"",
f"`python \"$HOME/plugins/airdo/scripts/airdo_mode.py\" --mode finish --project . --task-id {task_id}`",
"",
"## Return Contract",
"",
"- Report the final status and the finalized result path.",
"- After `finish`, treat `worker-state.json` `resultPath` as the canonical result location instead of re-reading the template `result.json`.",
"- Do not merge global docs yourself unless the task explicitly delegated document updates through `result.json`.",
"",
]
handoff_path.write_text("\n".join(lines), encoding="utf-8")
return handoff_path
def enter_worker(project_root: Path, task_id: str) -> Dict[str, object]:
paths = _ensure_layout(project_root)
task_folder = _task_dir(project_root, task_id)
task_folder.mkdir(parents=True, exist_ok=True)
result_path = task_folder / "result.json"
if not result_path.exists():
result = _placeholder_result(project_root, task_id)
_json_dump(result_path, result.to_dict())
brief_path = _write_brief(project_root, task_id)
handoff_path = _write_handoff(project_root, task_id, brief_path, result_path)
state_path = worker_state_path(project_root, task_id)
state = {
"enabled": True,
"updatedAt": now_iso(),
"projectRoot": str(project_root),
"artifactHealth": _artifact_health(project_root),
"activeTaskId": task_id,
}
_json_dump(paths["state"], state)
_json_dump(
state_path,
{
"taskId": task_id,
"status": "entered",
"enteredAt": now_iso(),
"templateResultPath": str(result_path),
"resultPath": str(result_path),
"briefPath": str(brief_path),
"handoffPath": str(handoff_path),
},
)
return {
"taskId": task_id,
"briefPath": str(brief_path),
"handoffPath": str(handoff_path),
"resultPath": str(result_path),
"workerStatePath": str(state_path),
}
def handoff_worker(project_root: Path, task_id: str) -> Dict[str, object]:
entered = enter_worker(project_root, task_id)
return {
"taskId": task_id,
"briefPath": entered["briefPath"],
"handoffPath": entered["handoffPath"],
"resultPath": entered["resultPath"],
"workerStatePath": entered["workerStatePath"],
}
def finish_worker(project_root: Path, task_id: str, result_path: Path | None) -> Dict[str, object]:
_ensure_layout(project_root)
task_folder = _task_dir(project_root, task_id)
if result_path is None:
result_path = task_folder / "result.json"
result = WorkerResult.from_dict(json.loads(result_path.read_text(encoding="utf-8-sig")))
if _is_untouched_placeholder_result(project_root, result):
raise ValueError(
"worker result is still the untouched default template; update result.json before finalize"
)
result.validate_for_finalize()
ensure_xdb_sessions_for_result(project_root, result, "worker-finish")
ensure_debug_sessions_for_result(project_root, result, "worker-finish")
result.validate_for_finalize()
enforce_doc_sync_requirements(project_root, result)
if result.task_id != task_id:
raise ValueError("result taskId does not match --task-id")
if not result.finalized_at:
result.finalized_at = now_iso()
finalized_path = _paths(project_root)["results"] / f"{task_id}.json"
_json_dump(finalized_path, result.to_dict())
previous_state: Dict[str, object] = {}
current_state_path = worker_state_path(project_root, task_id)
if current_state_path.exists():
previous_state = json.loads(current_state_path.read_text(encoding="utf-8-sig"))
_json_dump(
current_state_path,
{
"taskId": task_id,
"status": "completed",
"enteredAt": previous_state.get("enteredAt", ""),
"briefPath": previous_state.get("briefPath", ""),
"handoffPath": previous_state.get("handoffPath", ""),
"templateResultPath": previous_state.get("templateResultPath", str(task_folder / "result.json")),
"finalizedAt": result.finalized_at,
"resultPath": str(finalized_path),
},
)
return {
"taskId": task_id,
"status": result.status,
"finalizedResultPath": str(finalized_path),
"workerStatePath": str(current_state_path),
}
def status_worker(project_root: Path) -> Dict[str, object]:
paths = _ensure_layout(project_root)
state_path = paths["state"]
enabled = state_path.exists()
active_task_id = ""
if enabled:
payload = json.loads(state_path.read_text(encoding="utf-8-sig"))
active_task_id = str(payload.get("activeTaskId", ""))
task_ids = sorted(path.name for path in paths["tasks"].iterdir() if path.is_dir())
return {
"enabled": enabled,
"projectRoot": str(project_root),
"artifactHealth": _artifact_health(project_root),
"activeTaskId": active_task_id,
"taskIds": task_ids,
}