Compare commits

...
2 Commits
Author SHA1 Message Date
Godopu 4a3328d0b7 chore(release): bump framework and 8 skills to v3.1.0 (MINOR)
- Define immutable MAM_VERSION="3.1.0" runtime constant in lib.sh
- Bump YAML frontmatter version to 3.1.0 across all 8 skill packages
- Document v3.1.0 changelog, 2-tier TUI readiness architecture, and migration guide in VERSIONS.md
- Document new tuning knobs (MAM_KILL_ON_READY_TIMEOUT, MAM_DIALOG_TAIL_LINES, MAM_READY_EMPTY_GIVEUP) in .mam.env.example
- Add 3-way lockstep and immutability test suite in tests/test_version_consistency.py
2026-08-28 18:25:14 +09:00
Godopu 17edf90676 fix(tui): establish 2-tier readiness model, modal/hint token separation, and adapter modal contracts
- Separate TUI dialog detection into blocking gate layer (_MAM_MODAL_TOKENS) and recovery layer (_MAM_HINT_TOKENS) to eliminate idle dialog starvation
- Add modal_tokens contract across agent adapters (claude, cline) with dynamic scope facts resolution in send_keys_safe
- Implement fail-closed resolution for ambiguous same-kind panes (R-1) and enforce WORKSPACE_ROOT export (R-2)
- Preserve session on TUI readiness timeout when PID is alive for diagnostics
- Add comprehensive test suite in tests/test_c1_tui_readiness.py and adapter contract assertions in test_a4
2026-08-28 15:21:10 +09:00
20 changed files with 1057 additions and 67 deletions

No files matched your search

+162 -33
View File
@@ -24,6 +24,13 @@ fi
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="3.1.0"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
_sanitize_herdr_agent_name() {
@@ -43,12 +50,14 @@ _sanitize_herdr_agent_name() {
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v openssl >/dev/null 2>&1; then
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
else
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
fi
s="${s:0:23}-${h}"
if [ -n "$h" ]; then
s="${s:0:23}-$h"
else
s="${s:0:32}"
fi
fi
printf '%s\n' "${s:0:32}"
printf '%s\n' "$s"
}
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
@@ -59,7 +68,9 @@ for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME
done
# Central TUI dialog and readiness validation tokens (OP-6)
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
# Workspace-relative defaults with environment overrides (Phase Z)
@@ -348,17 +359,18 @@ _resolve_herdr_pane_id() {
local ws_flag=()
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c "
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$PWD}" python3 -c "
import sys, json, os
tn = os.environ.get('TARGET_NAME', '')
tsa = os.environ.get('TARGET_SAN', '')
tws = os.environ.get('TARGET_WS', '')
ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
try:
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
# Client-side filter in case an older herdr ignores --workspace.
if tws:
panes = [p for p in panes if p.get('workspace_id') == tws]
# ISSUE-2: exact match only.
# ISSUE-2: exact match first, then agent-sessions.yaml lookup fallback
for key in ('label', 'name', 'agent'):
for p in panes:
v = p.get(key)
@@ -367,6 +379,27 @@ try:
if pid:
print(pid)
sys.exit(0)
agent_kind = None
yaml_path = os.path.join(ws_root, '.mam', 'agent-sessions.yaml')
if os.path.isfile(yaml_path):
try:
import yaml
with open(yaml_path) as f:
ydata = yaml.safe_load(f) or {}
for s in ydata.get('herdr_sessions', []):
if s.get('name') == tn or s.get('name') == tsa:
agent_kind = s.get('pane', {}).get('cmd') or ''
break
except Exception as e:
sys.stderr.write(f'warning: yaml load failed: {e}\n')
if agent_kind:
matching = [p.get('pane_id') for p in panes if p.get('agent') == agent_kind and p.get('pane_id')]
if len(matching) == 1:
print(matching[0])
sys.exit(0)
elif len(matching) > 1:
# R-1: Ambiguous resolution with multiple same-kind panes — fail closed
sys.exit(1)
except Exception:
pass
sys.exit(1)
@@ -731,13 +764,13 @@ except Exception:
else
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
fi
# Fatal CLI errors abort immediately. agent_not_ready is herdr's
# documented "process is up, blocked on a dialog" status — continue
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
# startup" as success: herdr returns that for a dead process too.
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
break
fi
# Fatal CLI errors abort immediately. agent_not_ready is the
# documented "process is up, blocked on a dialog" status — continue
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
# startup" as success: herdr returns that for a dead process too.
if echo "$res" | grep -qE "agent_started|agent_not_ready"; then
success=1
break
@@ -1793,41 +1826,117 @@ start_watchdog() {
# wait_for_tui_ready <session_name> <agent>
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
wait_for_tui_ready() {
local sess="$1" agent="$2"
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
local tokens="${MAM_READY_TOKENS:-}"
if [ -z "$tokens" ]; then
local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
local placeholder="${MAM_INPUT_PLACEHOLDER:-}"
local prompt="${MAM_INPUT_PROMPT:-}"
local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
local modal_tokens="${MAM_MODAL_TOKENS:-}"
# Self-contained resolution: fetch facts in 1 call if not already in environment
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then
local _facts=""
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
[ -n "$tokens" ] && eval "tokens=$tokens"
if [ -n "$_facts" ]; then
eval "$_facts"
strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
placeholder="${MAM_INPUT_PLACEHOLDER:-}"
prompt="${MAM_INPUT_PROMPT:-}"
rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
modal_tokens="${MAM_MODAL_TOKENS:-}"
fi
fi
if [ -z "$tokens" ]; then
# Fallback if strong_tokens is empty but MAM_READY_TOKENS is set
if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then
strong_tokens="$MAM_READY_TOKENS"
fi
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
return 1
fi
local i
local empty_streak=0
local empty_giveup="${MAM_READY_EMPTY_GIVEUP:-5}"
local modal_pat="$_MAM_MODAL_TOKENS"
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
local i content tail_lines
for i in {1..30}; do
if [ "$agent" = "claude" ]; then
handle_startup_dialogs "$sess" 1 || true
fi
if _pane_dialog_open "$sess"; then
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
_sks_herdr send-keys -t "$sess" Enter || true
sleep 1
# 1. Single capture per iteration (T-3)
content=$(_pane_capture "$sess")
# Consecutive blank capture check (C-6)
if [ -z "$content" ]; then
empty_streak=$((empty_streak + 1))
if [ "$empty_streak" -ge "$empty_giveup" ]; then
echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2
return 2
fi
sleep 1
continue
fi
local content
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
if [ -n "$content" ]; then
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
echo "✅ $agent TUI detected ready."
return 0
empty_streak=0
# 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
# handle it and do NOT declare ready on this iteration.
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
handle_startup_dialogs "$sess" 1 || true
sleep 1
continue
fi
# 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first)
local s_matched=false
local w_matched=false
local c_matched=false
# S-layer: Strong ready token match
if [ -n "$strong_tokens" ]; then
if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then
s_matched=true
fi
fi
# S-layer: Placeholder match (must be >= 8 characters)
if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then
if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then
s_matched=true
fi
fi
# W-layer: Weak ready token match
if [ -n "$weak_tokens" ]; then
if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then
w_matched=true
fi
fi
# C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match)
if [ -n "$prompt" ]; then
if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then
c_matched=true
fi
fi
if [ -n "$rule_pattern" ]; then
if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then
c_matched=true
fi
fi
# Decision: S OR (W AND C)
if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then
echo "✅ $agent TUI detected ready."
return 0
fi
# 4. Startup dialog & hint resolution if not ready
handle_startup_dialogs "$sess" 1 || true
sleep 1
done
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
@@ -1920,11 +2029,14 @@ _pane_quiescent() {
}
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
# permission / OAuth / list-selection), checked in the bottom 20 pane lines
# permission / OAuth / list-selection), checked in the bottom 8 pane lines
# only (dialogs render near the input area; conversation text above must not
# trigger this). Tokens must NOT appear on normal idle prompt screens.
_pane_dialog_open() {
_pane_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS"
local extra="${MAM_MODAL_TOKENS:-}"
local pat="$_MAM_MODAL_TOKENS"
[ -n "$extra" ] && pat="${pat}|${extra}"
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
}
# send_keys_safe <sess> <text> [job_id]
@@ -1939,6 +2051,23 @@ _pane_dialog_open() {
send_keys_safe() {
local sess="$1" text="$2" job_id="${3:-adhoc}"
local pre_submit deadline try
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN
local MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
local _sks_agent=""
case "$sess" in
*-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;;
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
*-cline|*-cline-[0-9]*|cline|cline-[0-9]*) _sks_agent="cline" ;;
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
esac
if [ -n "$_sks_agent" ]; then
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
fi
fi
local _q_rc=0
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
+3
View File
@@ -23,6 +23,9 @@ def main():
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}")
print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}")
print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}")
print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}")
print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}")
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
@@ -40,6 +40,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
def input_rule_pattern(self) -> str:
return '─{10,}'
@property
def modal_tokens(self) -> Optional[str]:
return 'Try the new fullscreen renderer\\?'
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
@@ -16,6 +16,18 @@ class ClineAgentAdapter(BaseAgentAdapter):
def ready_tokens(self) -> str:
return 'Cline|history|Chat|What can I do|slash commands'
@property
def strong_ready_tokens(self) -> str:
return 'Cline|What can I do|slash commands'
@property
def weak_ready_tokens(self) -> str:
return 'history|Chat'
@property
def modal_tokens(self) -> Optional[str]:
return 'Select API Provider|Enter API Key|Select an API provider|API Provider|Cline API key'
@property
def exit_key(self) -> str:
return '/exit'
+12
View File
@@ -73,6 +73,18 @@ class BaseAgentAdapter:
def input_rule_pattern(self) -> Optional[str]:
return None
@property
def modal_tokens(self) -> Optional[str]:
return None
@property
def strong_ready_tokens(self) -> str:
return self.ready_tokens
@property
def weak_ready_tokens(self) -> str:
return ''
def derive_session_name(self, slug: str, role: str = "creator") -> str:
r = role.lower()
return f"{slug}-{r}-{self.name}"
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -225,7 +225,7 @@ spawn
cleanup_herdr_on_error() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "⚠️ Error occurred during initialization. Rolling back and killing herdr session '$SESSION_NAME'..." >&2
echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2
_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
fi
}
@@ -237,11 +237,49 @@ if [ -z "$HERDR_SERVER_OPT" ]; then
fi
# TUI 준비 대기
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2
exit 1
TUI_READY_RC=0
wait_for_tui_ready "$SESSION_NAME" "$AGENT" || TUI_READY_RC=$?
LAST_VISIBLE_STATUS_OVERRIDE=""
if [ "$TUI_READY_RC" -ne 0 ]; then
# 1. Diagnostic dump
DIAG_DIR="${WORKSPACE:-$PWD}/.mam/diagnostics"
mkdir -p "$DIAG_DIR" 2>/dev/null || true
DIAG_FILE="$DIAG_DIR/${SESSION_NAME}-$(date +%s).txt"
{
echo "=== MAM Diagnostic Dump ==="
echo "Date: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "MAM Version: ${MAM_VERSION:-unknown}"
echo "Session: $SESSION_NAME"
echo "Agent: $AGENT"
echo "ReturnCode: $TUI_READY_RC"
echo "--- Pane Raw Capture ---"
_pane_capture "$SESSION_NAME" 2>/dev/null || true
} > "$DIAG_FILE" 2>/dev/null || true
if [ ! -f "$DIAG_FILE" ]; then
echo "WARNING: Failed to write diagnostic dump to $DIAG_FILE" >&2
fi
# 2. Check pane PID liveness
PANE_PID_CHECK=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
ALIVE=false
if [ -n "$PANE_PID_CHECK" ] && [[ "$PANE_PID_CHECK" =~ ^[0-9]+$ ]]; then
if kill -0 "$PANE_PID_CHECK" 2>/dev/null; then
ALIVE=true
fi
fi
if [ "$ALIVE" = true ] && [ "${MAM_KILL_ON_READY_TIMEOUT:-0}" != "1" ]; then
echo "WARNING: agent TUI never became ready within timeout, but process (PID $PANE_PID_CHECK) is alive. Preserving session for diagnosis." >&2
LAST_VISIBLE_STATUS_OVERRIDE="tui-ready-timeout"
SUBMIT_JOB_PROMPT="" # disable instruction injection on unready session
else
echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2
exit 42
fi
fi
if [ "$AGENT" = "claude" ]; then
if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
handle_startup_dialogs "$SESSION_NAME" 15
fi
@@ -257,8 +295,8 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
# If --onboard is specified, automatically build the onboarding prompt
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
1. Read the project documentation at README.md and the multi-agent protocol guidelines at .agents/MULTI_AGENT_RULES.md to understand the design rules.
2. Run 'git status' and 'git diff' to analyze the current modifications and active work in the repository.
@@ -268,6 +306,9 @@ fi
# agent-sessions.yaml 에 append
DELEGATE_JOB_ID=""
if [ -n "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT=""
fi
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}"
if [ -z "$delegate_agent" ]; then
@@ -306,6 +347,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
MAM_WS_LABEL="$MAM_WS_LABEL" \
SESSION_UUID="$SESSION_UUID" \
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
@@ -389,6 +431,10 @@ elif agent == 'grok':
entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '')
if last_vis_override:
entry['last_visible_status'] = last_vis_override
sessions.append(entry)
snap = d.setdefault('snapshot', {})
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, cline, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos, windows]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture.
version: 3.0.1
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 3.0.1
version: 3.1.0
author: godopu
license: MIT
platforms: [linux, macos]
+13
View File
@@ -158,6 +158,19 @@
#default: 2
# MAM_MAX_PANE_ROWS=2
# Kill the session instead of preserving it when TUI readiness times out.
#default: 0
# MAM_KILL_ON_READY_TIMEOUT=0
# Number of trailing non-blank pane lines scanned for blocking modal signatures.
#default: 8
# MAM_DIALOG_TAIL_LINES=8
# Consecutive empty captures before wait_for_tui_ready declares the pane unobservable.
# NOTE: distinct from SKS_EMPTY_GIVEUP, which governs send_keys_safe quiescence (default 3).
#default: 5
# MAM_READY_EMPTY_GIVEUP=5
# ==============================================================================
# deploy / distribution source (for forks/mirrors)
# ==============================================================================
+70 -16
View File
@@ -6,37 +6,91 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v3.0.1`
- **최신 릴리스 일시**: 2026-08-27 (KST)
- **프레임워크 버전**: `v3.1.0`
- **최신 릴리스 일시**: 2026-08-28 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Herdr Shim Routing & Multi-Workspace Isolation Engine**: `HERDR_WORKSPACE_ID` 환경 변수 스코핑 및 `$WORKSPACE_ROOT/.mam/herdr_workspace_id` 영속화 메커니즘을 통해 다중 워크스페이스 동시 실행 시 페인 교차 오염 원천 차단
- **Atomic Safe Paste Insertion (`pane send-text`)**: 미지원 서브커맨드(`agent send`) 제거 및 `pane send-text` 단일 삽입 계약 도입, `send_keys_safe` 실패 전파(`rc 3`) 및 중복 제출(Double Submit) 방지
- **Strict Exact-Match Single Pane Resolver**: 부분 문자열 매칭(`in tn`) 완전 제거, 단일 중앙 `_resolve_herdr_pane_id` 헬퍼 기반 5대 서브커맨드(`has-session`, `kill-session`, `capture-pane`, `send-keys`, `paste-buffer`) 무결성 정립
- **TUI Readiness & Dialog Token Precision**: `_MAM_DIALOG_TOKENS` 일반 팁(`Yes, try it`) 오탐 제거 및 풀스크린 모달 Escape 거절 브랜치 분리
- **Comprehensive Test Suite Milestone**: 412개 전체 테스트 100% PASS (412 passed / 0 failed).
- **2-Tier TUI Readiness Model (`S ∨ (W ∧ C)`) & Modal Priority**: 강한 토큰(`S`) 단독 충족 및 약한 토큰(`W`) + 보강 패턴(`C`) 결합 판정식 도입, 모달 선행 검사 및 힌트 분리를 통한 다이얼로그 기아(starvation) 원천 해소
- **Adapter Modal Contract (`T-2d`)**: `BaseAgentAdapter` 내 `modal_tokens` 프로퍼티 신설 및 Cline/Claude 맞춤형 모달 시그니처 팩트 브리지 통합
- **Fail-Closed Exact Pane Resolver & Multi-Workspace Isolation Engine**: `_resolve_herdr_pane_id` 동종 페인 다중 매치 시 fail-closed(`exit 1`) 차단, `HERDR_WORKSPACE_ID` 스코핑 및 `$WORKSPACE_ROOT/.mam/herdr_workspace_id` 영속화
- **Atomic Safe Paste Insertion & Preserved Diagnostic Dumps**: `pane send-text` 단일 안전 삽입 계약, TUI 미준비 시 세션 보존(`exit 0`) 및 `.mam/diagnostics/` 진단 덤프 생성
- **Runtime Framework Version Constant (`MAM_VERSION`) & 3-Way Lockstep**: `lib.sh` 내 `MAM_VERSION="3.1.0"` 런타임 진실 공급원 정의 및 3자 동기화 가드 체계 구축
- **Comprehensive Test Suite Milestone**: 436개 전체 테스트 100% PASS (436 passed / 0 failed).
---
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v3.0.1`으로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v3.1.0`으로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `3.0.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `3.0.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `3.0.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `3.0.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `3.0.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `3.0.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `3.0.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `3.0.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `3.1.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `3.1.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `3.1.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `3.1.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `3.1.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `3.1.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `3.1.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `3.1.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v3.1.0` — 2-Tier TUI Readiness Model, Adapter Modal Contract & Fail-Closed Pane Resolution (2026-08-28)
> **주요 마일스톤**: 다이얼로그 기아(starvation) 구조적 해소를 위한 2계층 TUI 준비 판정 모델(`S ∨ (W ∧ C)`), 모달/힌트 2계층 토큰 분리, 어댑터별 `modal_tokens` 계약(`T-2d`) 정립, 동종 페인 다중 매치 fail-closed 방어, 준비 실패 세션 진단 보존(`create_session.sh`), `lib.sh` 런타임 버전 상수(`MAM_VERSION="3.1.0"`) 신설 및 436개 전체 테스트 100% PASS 달성.
#### ⚠️ 동작 변경 및 마이그레이션 안내
* **B-1: TUI 미준비 세션 보존 (`create_session.sh`)**:
- TUI 준비 감지 타임아웃 발생 시, PID가 생존해 있다면 세션을 강제 종료(`kill-session`)하지 않고 `status: running`, `last_visible_status: tui-ready-timeout`으로 등록한 뒤 `exit 0`(성공)으로 반환합니다.
- 구 `v3.0.1`의 무조건 롤백 및 종료 동작을 원할 경우 `MAM_KILL_ON_READY_TIMEOUT=1`을 설정하십시오.
* **B-2: 프로세스 사망 시 종료 코드 변경**:
- `create_session.sh` 실행 중 PID가 사망한 경우 명시적 구분 코드 `42`(`MAM_EXIT_READY_TIMEOUT`)로 종료합니다.
* **B-3: 동종 페인 다중 매치 시 Fail-Closed 거부 (`_resolve_herdr_pane_id`)**:
- 동일한 에이전트 종류의 페인이 여러 개 발견되고 정확한 세션명 일치가 실패할 경우, 임의의 첫 번째 페인을 선택하지 않고 즉시 `exit 1`로 실패하여 교차 세션 오염을 원천 차단합니다.
* **B-4 & B-5: 환경변수 전파 (`WORKSPACE_ROOT`, `MAM_VERSION`)**:
- `WORKSPACE_ROOT` 및 `MAM_VERSION`이 자식 프로세스로 자동 `export`됩니다. `MAM_VERSION`은 런타임 위조 방지를 위해 환경변수 오버라이드가 차단됩니다.
#### 🔧 기존 워크스페이스 사용자 안내
기존 배포된 워크스페이스 사용자는 `.mam.env`에 아래 3종의 신규 환경변수를 추가하여 동작을 튜닝할 수 있습니다:
| 변수명 | 기본값 | 설명 |
| :--- | :---: | :--- |
| `MAM_KILL_ON_READY_TIMEOUT` | `0` | `1`로 설정 시 TUI 준비 타임아웃 발생 시 세션을 보존하지 않고 강제 종료(v3.0.1 동작) |
| `MAM_DIALOG_TAIL_LINES` | `8` | 블로킹 모달 시그니처를 검사할 페인 하단 비공백 라인 수 |
| `MAM_READY_EMPTY_GIVEUP` | `5` | `wait_for_tui_ready`가 페인을 관측 불가(Headless)로 판정하기까지의 연속 빈 캡처 횟수 |
#### 1. 2계층 TUI 준비 판정 모델 (`S ∨ (W ∧ C)`)
* **단독 충분 강한 신호 (`S`)**: `strong_ready_tokens` 또는 8자 이상의 유의미한 `input_placeholder`(`Ask anything...`) 일치 시 준비 완료 선언.
* **보강 결합 약한 신호 (`W ∧ C`)**: `weak_ready_tokens`(`history`, `Chat` 등)는 `input_prompt`(`❯`, `>`) 또는 `input_rule_pattern`(`─{10,}`)과 동시 일치할 때만 준비 완료로 판정하여 오탐 차단.
* **하강 호환성 (Fallback)**: `input_*` 속성이 미정의된 에이전트(Hermes 등)는 강한 토큰 단독 검사로 안전하게 하강.
#### 2. 모달/힌트 토큰 분리 및 어댑터 `modal_tokens` 계약 (`T-2a` / `T-2d`)
* **토큰 집합 분리**: `_MAM_MODAL_TOKENS`(고신뢰 블로킹 모달)와 `_MAM_HINT_TOKENS`(자연어 TUI 안내 팁)를 분리. `_pane_dialog_open`이 힌트 텍스트로 인해 영구 차단(Starvation)되는 결함 해소. `_MAM_DIALOG_TOKENS`는 하위호환용 합집합으로 존치.
* **`BaseAgentAdapter.modal_tokens`**: 에이전트별 특화 모달(Claude의 `Try the new fullscreen renderer?`, Cline의 `Select API Provider` 등)을 팩트 브리지(`MAM_MODAL_TOKENS`)로 통합.
#### 3. Fail-Closed 페인 해석 및 워크스페이스 스코프 강화 (`R-1` / `R-2`)
* `_resolve_herdr_pane_id`에서 다중 매치 시 `sys.exit(1)` 처리 및 `export WORKSPACE_ROOT` 보강으로 격리 안정성 확보.
#### 4. 준비 실패 세션 진단 보존 및 `MAM_VERSION` 스탬프
* `create_session.sh` 실패/타임아웃 시 `.mam/diagnostics/<session>-<timestamp>.txt`로 페인 덤프 및 `MAM Version` 스탬프 자동 기록.
#### 5. 런타임 버전 상수 `MAM_VERSION` 신설 및 3자 락스텝 검증
* `lib.sh` 내 `MAM_VERSION="3.1.0"` 선언 및 `test_version_consistency.py`를 통한 `lib.sh` ↔ `VERSIONS.md` ↔ 8개 `SKILL.md` 3자 일치 자동 검증.
#### 6. 신규 환경변수 3종 문서화
* `.mam.env.example`에 `MAM_KILL_ON_READY_TIMEOUT`, `MAM_DIALOG_TAIL_LINES`, `MAM_READY_EMPTY_GIVEUP` 3종 등록.
#### 7. 테스트 스위트 확장: 412 → 436 (+24, 100% PASS)
* `test_c1_tui_readiness.py` (21 tests), `test_adapter_modal_tokens_contract`, `test_version_consistency.py` 신설 등 총 436개 테스트 무결점 통과.
#### 8. 교차 피어 리뷰 이력
* F-1~F-8 및 R-1, R-2 총 10건의 피어 리뷰 이슈 도출 및 해소:
- F-1 (`Yes, try it` 팁 오탐 제거), F-2 (업셀 모달 선행 검사), F-3 (변수 스코프 격리), F-4 (리뷰어 실측 검증 후 자진 철회), F-5/F-6/F-7 (어댑터 팩트 연동 및 세션 패턴 보강), F-8 (비-클로드 어댑터 modal_tokens 계약 정립), R-1 (Fail-closed 페인 리졸버), R-2 (`WORKSPACE_ROOT` export 전파).
- 계획 단계 챌린지(`b9c29c3b`)를 통한 C-1 (`MAM_VERSION` 3자 락스텝) 및 C-2 (마이그레이션 안내) 수용.
### 🚀 `v3.0.1` — Herdr Shim Routing Contract Refactor, Multi-Workspace Isolation & 412-Test Milestone (2026-08-27)
> **주요 마일스톤**: Herdr 심 라우팅 5대 결함(ISSUE-1~5) 및 F-1~F-4 보완 완결, `HERDR_WORKSPACE_ID` 환경 변수 스코핑 및 파일 기반 격리 영속화 엔진 탑재, `pane send-text` 기반 단일 안전 삽입 계약 정립, 부분 문자열 매칭(`in tn`) 완전 제거, 412개 전체 테스트 100% PASS 달성.
+16
View File
@@ -84,6 +84,22 @@ def test_adapter_required_properties():
assert adapter.delegate_agent_key == delk
assert adapter.identity_cache_fields == cache_f
def test_adapter_modal_tokens_contract():
"""F-8: Verify adapter-level modal_tokens contract (T-2d)."""
expected_modal = {
'claude': 'Try the new fullscreen renderer\\?',
'cline': 'Select API Provider|Enter API Key|Select an API provider|API Provider|Cline API key',
'agy': None,
'hermes': None,
'grok': None,
}
for agent, expected in expected_modal.items():
adapter = get_adapter(agent)
assert adapter is not None
assert adapter.modal_tokens == expected, f"modal_tokens mismatch for {agent}"
def test_facts_bridge_eval_contract():
import subprocess, sys
from pathlib import Path
+4 -2
View File
@@ -284,8 +284,10 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED";
def test_mam_dialog_tokens_exclude_yes_try_it():
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays."""
content = open(_LIB_SH, encoding="utf-8").read()
token_line = next(l for l in content.splitlines() if l.startswith("_MAM_DIALOG_TOKENS="))
assert "Yes, try it" not in token_line
modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS="))
hint_line = next(l for l in content.splitlines() if l.startswith("_MAM_HINT_TOKENS="))
assert "Yes, try it" not in modal_line
assert "Yes, try it" not in hint_line
assert "grep -q 'Yes, try it'" in content
+609
View File
@@ -0,0 +1,609 @@
# test_c1_tui_readiness.py — Unit and integration tests for TUI readiness,
# 2-tier readiness model, modal/hint token separation, and session diagnostics/preservation.
import os, subprocess, time, json
from pathlib import Path
import pytest
_LIB_SH = str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "lib.sh")
_CREATE_SH = str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh")
def _run_lib_helpers(script_body, env_extra=None):
import tempfile, shutil
tmp_dir = tempfile.mkdtemp(prefix="test_tui_ready_")
try:
env = dict(os.environ)
if env_extra:
env.update(env_extra)
env["WORKSPACE_ROOT"] = tmp_dir
wrapper = f"""
set -euo pipefail
cd "{tmp_dir}"
source "{_LIB_SH}"
_init_herdr_isolation() {{ :; }}
{script_body}
"""
return subprocess.run(["bash", "-c", wrapper], capture_output=True, text=True, env=env, cwd=tmp_dir)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _lib_helper_src():
content = open(_LIB_SH, encoding="utf-8").read()
start = content.find("_herdr_ws_id_file() {")
end = content.find('cmd="${1:-}"', start)
assert start != -1 and end != -1
return content[start:end]
def test_c1_hint_tokens_only_dialog_closed():
"""C-1: Hint tokens (Use arrow keys / Esc to cancel / Press Enter) alone must return dialog CLOSED."""
screen = """Select an option:
1. Option A
2. Option B
Use arrow keys to navigate · Esc to cancel
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "DIALOG_CLOSED" in res.stdout
def test_c1b_hint_press_enter_sends_enter():
"""C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, and wait_for_tui_ready must send Enter exactly once per iteration (30 total across 30 iterations, not 60)."""
screen = """Some startup banner
Press Enter to continue
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
KEYS=/tmp/mam-press-enter-keys.$$
: > "$KEYS"
_sks_herdr() {{
if [ "${{1:-}}" = "send-keys" ]; then echo "$*" >> "$KEYS"; fi
return 0
}}
sleep() {{ :; }}
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
export MAM_WEAK_READY_TOKENS=''
export MAM_INPUT_PLACEHOLDER=''
rc=0
wait_for_tui_ready dummy-sess custom || rc=$?
echo "RC=$rc"
echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)"
rm -f "$KEYS"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "DIALOG_CLOSED" in res.stdout
assert "KEYS_COUNT=30" in res.stdout
def test_c1c_transition_to_upsell_modal_sends_escape_not_enter():
"""C-1c: Screen transition from Press Enter to Upsell Modal (Yes, try it) sends Escape, not duplicate Enter."""
state_file = f"/tmp/mam-screen-state.{os.getpid()}"
script = f"""
echo "1" > "{state_file}"
_pane_capture() {{
local s
s=$(cat "{state_file}" 2>/dev/null || echo 1)
if [ "$s" = "1" ]; then
printf 'Some banner\\nPress Enter to continue\\n'
else
printf 'Claude Code\\nYes, try it\\n'
fi
}}
KEYS=/tmp/mam-transition-keys.{os.getpid()}
: > "$KEYS"
_sks_herdr() {{
if [ "${{1:-}}" = "send-keys" ]; then
echo "$*" >> "$KEYS"
# Transition to screen 2 on first Enter
if [ "${{4:-}}" = "Enter" ]; then
echo "2" > "{state_file}"
fi
fi
return 0
}}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='AnthropicReady'
export MAM_WEAK_READY_TOKENS=''
export MAM_INPUT_PLACEHOLDER=''
export MAM_TUI_TIMEOUT=2
rc=0
wait_for_tui_ready dummy-sess claude 2 || rc=$?
echo "RC=$rc"
echo "KEYS_CONTENT=$(tr '\\n' '|' < "$KEYS")"
rm -f "$KEYS" "{state_file}"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "send-keys -t dummy-sess Enter" in res.stdout
assert "send-keys -t dummy-sess Escape" in res.stdout
assert "send-keys -t dummy-sess Enter|send-keys -t dummy-sess Escape|" in res.stdout
def test_c2_modal_tokens_dialog_open():
"""C-2: High-confidence modal tokens (e.g. Do you trust the files) must return dialog OPEN."""
screen = """Security Alert
Do you trust the files in this repository?
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "DIALOG_OPEN" in res.stdout
def test_c3_strong_token_and_hint_token_readiness_succeeds():
"""C-3: Strong ready token + hint token together must detect readiness immediately (rc 0)."""
screen = """Cline v3.0.0
Use arrow keys to select a tool
❯
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
sleep() {{ :; }}
wait_for_tui_ready dummy-sess cline
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "TUI detected ready" in res.stdout
def test_c4_placeholder_only_readiness_succeeds():
"""C-4: Long placeholder (Ask anything..., len>=8) alone satisfies S-tier readiness."""
screen = """Welcome to the AI Assistant
Ask anything...
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
sleep() {{ :; }}
# Override ready tokens so strong tokens do not match
export MAM_STRONG_READY_TOKENS='NonExistentStrongToken'
export MAM_WEAK_READY_TOKENS=''
export MAM_INPUT_PLACEHOLDER='Ask anything...'
wait_for_tui_ready dummy-sess cline
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "TUI detected ready" in res.stdout
def test_c4b_box_modal_does_not_succeed():
"""C-4b: Box modal frame (────── + solo ❯) with no strong token or placeholder must NOT satisfy readiness."""
screen = """┌───────────────────────────────────┐
│ Please select authentication mode │
│ ───────────────────────────────── │
│ 1. API Key │
│ 2. OAuth │
└───────────────────────────────────┘
❯
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='NonExistentStrongToken'
export MAM_WEAK_READY_TOKENS='Chat|history'
export MAM_INPUT_PLACEHOLDER='Ask anything...'
export MAM_INPUT_PROMPT='❯'
export MAM_INPUT_RULE_PATTERN='─{{10,}}'
_MAM_MODAL_TOKENS='NON_MATCHING_MODAL'
rc=0
wait_for_tui_ready dummy-sess cline || rc=$?
echo "RC=$rc"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=1" in res.stdout
def test_c4c_prompt_and_rule_corroboration_alone_cannot_satisfy_readiness():
"""C-4c: Neither prompt alone, rule alone, nor prompt+rule alone can satisfy readiness (C-tier cannot promote alone)."""
screen = """────────────────────────────
❯
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='NonExistentToken'
export MAM_WEAK_READY_TOKENS='NonExistentWeak'
export MAM_INPUT_PLACEHOLDER='NonExistentPlaceholder'
export MAM_INPUT_PROMPT='❯'
export MAM_INPUT_RULE_PATTERN='─{{10,}}'
rc=0
wait_for_tui_ready dummy-sess cline || rc=$?
echo "RC=$rc"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=1" in res.stdout
def test_c4d_weak_token_requires_corroboration():
"""C-4d: Weak token (Chat) alone fails; Weak token + prompt in last 5 lines succeeds."""
# 1. Weak token alone without prompt in last 5 lines
screen_weak_alone = """Chat history from earlier session:
line 1
line 2
line 3
line 4
line 5
line 6
"""
script1 = f"""
_pane_capture() {{ printf '%s' '{screen_weak_alone}'; }}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
export MAM_WEAK_READY_TOKENS='Chat|history'
export MAM_INPUT_PLACEHOLDER=''
export MAM_INPUT_PROMPT='❯'
export MAM_INPUT_RULE_PATTERN=''
rc=0
wait_for_tui_ready dummy-sess cline || rc=$?
echo "RC=$rc"
"""
res1 = _run_lib_helpers(script1)
assert res1.returncode == 0, res1.stderr + res1.stdout
assert "RC=1" in res1.stdout
# 2. Weak token + prompt in last 5 lines
screen_weak_corroborated = """Chat Session Active
Ready for input
❯
"""
script2 = f"""
_pane_capture() {{ printf '%s' '{screen_weak_corroborated}'; }}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
export MAM_WEAK_READY_TOKENS='Chat|history'
export MAM_INPUT_PLACEHOLDER=''
export MAM_INPUT_PROMPT='❯'
export MAM_INPUT_RULE_PATTERN=''
rc=0
wait_for_tui_ready dummy-sess cline || rc=$?
echo "RC=$rc"
"""
res2 = _run_lib_helpers(script2)
assert res2.returncode == 0, res2.stderr + res2.stdout
assert "RC=0" in res2.stdout
def test_c4e_short_placeholder_not_promoted_to_strong():
"""C-4e: Placeholder with length < 8 chars must NOT be promoted to strong S-tier."""
screen = """Ask
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
export MAM_WEAK_READY_TOKENS=''
export MAM_INPUT_PLACEHOLDER='Ask'
rc=0
wait_for_tui_ready dummy-sess custom || rc=$?
echo "RC=$rc"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=1" in res.stdout
def test_c5_hermes_fallback_to_strong_only():
"""C-5: When input_* are all empty (Hermes), strong_ready_tokens alone satisfies readiness."""
screen = """Hermes Agent v1.0
System initialized.
"""
script = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
sleep() {{ :; }}
wait_for_tui_ready dummy-sess hermes
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "TUI detected ready" in res.stdout
def test_c6_consecutive_empty_captures_unobservable_rc2():
"""C-6: Consecutive empty captures give up as unobservable/headless (rc 2) without burning 30s."""
script = """
_pane_capture() { echo ""; }
sleep() { :; }
export MAM_READY_EMPTY_GIVEUP=3
rc=0
wait_for_tui_ready dummy-sess claude || rc=$?
echo "RC=$rc"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=2" in res.stdout
assert "unobservable/headless pane" in res.stderr
def test_c7_readiness_checks_decoded_text_not_raw_json():
"""C-7: Raw JSON field names (e.g. Chat in JSON envelope) do not cause false positives."""
raw_json = json.dumps({"result": {"read": {"text": "Just raw text with no ready tokens", "Chat": 1}}})
script = f"""
_sks_herdr() {{
if [ "${{1:-}}" = "capture-pane" ]; then
printf '%s' '{raw_json}'
fi
return 0
}}
sleep() {{ :; }}
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
export MAM_WEAK_READY_TOKENS='Chat'
export MAM_INPUT_PLACEHOLDER=''
export MAM_INPUT_PROMPT='❯'
export MAM_INPUT_RULE_PATTERN=''
rc=0
wait_for_tui_ready dummy-sess cline || rc=$?
echo "RC=$rc"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=1" in res.stdout
def test_c8_mam_dialog_tokens_is_union_of_modal_and_hint():
"""C-8: _MAM_DIALOG_TOKENS must remain the union of _MAM_MODAL_TOKENS and _MAM_HINT_TOKENS."""
script = """
echo "MODAL=$_MAM_MODAL_TOKENS"
echo "HINT=$_MAM_HINT_TOKENS"
echo "DIALOG=$_MAM_DIALOG_TOKENS"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
lines = dict(line.split("=", 1) for line in res.stdout.strip().splitlines())
expected_dialog = f"{lines['MODAL']}|{lines['HINT']}"
assert lines["DIALOG"] == expected_dialog
def test_c8b_agent_modal_tokens_merged_into_dialog_open():
"""C-8b (T-2d / F-8): Per-agent modal_tokens from adapter are merged into _pane_dialog_open and block send_keys_safe."""
# 1. Direct _pane_dialog_open test
screen = """Please enter your Cline API key to continue:
"""
script1 = f"""
_pane_capture() {{ printf '%s' '{screen}'; }}
export MAM_MODAL_TOKENS='Cline API key'
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
"""
res1 = _run_lib_helpers(script1)
assert res1.returncode == 0, res1.stderr + res1.stdout
assert "DIALOG_OPEN" in res1.stdout
# 2. Dynamic facts resolution via send_keys_safe on cline session
script2 = f"""
_pane_quiescent() {{ return 0; }}
_pane_capture() {{ printf '%s' 'Select an API provider to get started:'; }}
_sks_herdr() {{ return 0; }}
rc=0
SKS_DIALOG_TIMEOUT=1 send_keys_safe "my-project-worker-cline-01" "some text" || rc=$?
echo "RC=$rc"
"""
res2 = _run_lib_helpers(script2)
assert res2.returncode == 0, res2.stderr + res2.stdout
assert "RC=2" in res2.stdout, f"Expected dialog blocking (RC=2) for cline modal: {res2.stdout}"
def test_c9_create_session_timeout_alive_pid_preserves_session(mam_sandbox, mock_herdr, mock_agents):
"""C-9 & C-11: create_session with readiness timeout + alive PID preserves session with timeout status (exit 0) and dumps diagnostic."""
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
diag_dir = mam_sandbox / ".mam" / "diagnostics"
yaml_file = mam_sandbox / ".mam" / "agent-sessions.yaml"
# Make wait_for_tui_ready immediately return 1 in sandboxed lib.sh
lib_content = lib_path.read_text()
lib_path.write_text(lib_content.replace("wait_for_tui_ready() {", "wait_for_tui_ready() { return 1; #"))
# In sandbox create_session.sh, simulate alive process using $$
create_content = script_path.read_text()
script_path.write_text(create_content.replace(
'PANE_PID_CHECK=$(_herdr list-panes',
'PANE_PID_CHECK=$$ # $(_herdr list-panes'
))
cmd = [
"bash", str(script_path),
"--workspace", str(mam_sandbox),
"--agent", "cline",
"--role", "creator",
"--session", "test-timeout-sess",
"--onboard"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0, res.stderr + res.stdout
assert "WARNING: agent TUI never became ready within timeout, but process" in res.stderr
# Check diagnostic dump file created (C-11)
dump_files = list(diag_dir.glob("test-timeout-sess-*.txt"))
assert len(dump_files) == 1
dump_content = dump_files[0].read_text()
assert "ReturnCode: 1" in dump_content
# Check YAML registration with tui-ready-timeout and NO delegate job (N-C)
assert yaml_file.exists()
yaml_content = yaml_file.read_text()
assert "tui-ready-timeout" in yaml_content
assert "delegate_job_id: null" in yaml_content or "delegate_job_id: None" in yaml_content or "delegate_job_id:" not in yaml_content or "delegate_job_id: ''" in yaml_content
def test_c10_create_session_timeout_dead_pid_kills_session(mam_sandbox, mock_herdr, mock_agents):
"""C-10: create_session with readiness timeout + dead PID triggers rollback kill and exits with code 42."""
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
diag_dir = mam_sandbox / ".mam" / "diagnostics"
# Make wait_for_tui_ready return 1 and mock list-panes to return dead PID
lib_content = lib_path.read_text()
create_content = script_path.read_text()
script_path.write_text(create_content.replace(
'PANE_PID_CHECK=$(_herdr list-panes',
'PANE_PID_CHECK=99999999 # $(_herdr list-panes'
))
lib_path.write_text(lib_content.replace("wait_for_tui_ready() {", "wait_for_tui_ready() { return 1; #"))
cmd = [
"bash", str(script_path),
"--workspace", str(mam_sandbox),
"--agent", "cline",
"--role", "creator",
"--session", "test-dead-sess"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 42, res.stderr + res.stdout
assert "ERROR: agent TUI never became ready" in res.stderr
# Check diagnostic dump file created
dump_files = list(diag_dir.glob("test-dead-sess-*.txt"))
assert len(dump_files) == 1
assert "ReturnCode: 1" in dump_files[0].read_text()
def test_adapter_strong_weak_partition():
"""N-D: Verify strong_ready_tokens and weak_ready_tokens partition ready_tokens for all adapters."""
from lib_py.agents.registry import get_adapter
for agent in ('claude', 'agy', 'hermes', 'cline', 'grok'):
adapter = get_adapter(agent)
assert adapter is not None
ready_set = set(t for t in adapter.ready_tokens.split('|') if t)
strong_set = set(t for t in adapter.strong_ready_tokens.split('|') if t)
weak_set = set(t for t in adapter.weak_ready_tokens.split('|') if t)
assert strong_set | weak_set == ready_set, f"Mismatch in {agent}: {strong_set} | {weak_set} != {ready_set}"
assert strong_set.isdisjoint(weak_set), f"Overlap in {agent}: {strong_set & weak_set}"
def test_sks_agent_suffix_resolution_not_substring():
"""Verify send_keys_safe resolves agent by strict suffix without substring false positives."""
script = """
_pane_quiescent() { return 0; }
_pane_capture() { echo "prompt > "; }
_sks_herdr() { return 0; }
RESOLVED=""
mock_py() {
if [ "$1" = "-m" ] && [ "$2" = "lib_py.agents" ] && [ "$3" = "facts" ]; then
echo "echo RESOLVED_AGENT=$4"
fi
}
_delegate_py_bin() { echo "mock_py"; }
check_sks() {
local sess="$1"
unset MAM_MODAL_TOKENS
# Call send_keys_safe and capture output
send_keys_safe "$sess" "hello" 2>&1 || true
}
echo "1: $(check_sks 'claude-reviewer-cline-01')"
echo "2: $(check_sks 'my-claude-creator-cline')"
echo "3: $(check_sks 'reviewer-creator-grok-01')"
echo "4: $(check_sks 'planner-reviewer-claude-01')"
echo "5: $(check_sks 'test-worker-hermes-02')"
echo "6: $(check_sks 'workspace-creator-agy-05')"
echo "7: $(check_sks 'claude')"
echo "8: $(check_sks 'cline-01')"
echo "9: $(check_sks 'grok-02')"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "1: RESOLVED_AGENT=cline" in res.stdout
assert "2: RESOLVED_AGENT=cline" in res.stdout
assert "3: RESOLVED_AGENT=grok" in res.stdout
assert "4: RESOLVED_AGENT=claude" in res.stdout
assert "5: RESOLVED_AGENT=hermes" in res.stdout
assert "6: RESOLVED_AGENT=agy" in res.stdout
assert "7: RESOLVED_AGENT=claude" in res.stdout
assert "8: RESOLVED_AGENT=cline" in res.stdout
assert "9: RESOLVED_AGENT=grok" in res.stdout
def test_f2_upsell_modal_defense_with_real_facts_and_banner():
"""F-2/F-6: Real Claude Code banner with upsell modal must trigger modal defense, not return ready."""
script = """
SENT_KEYS=""
_sks_herdr() {
if [ "$1" = "send-keys" ]; then
SENT_KEYS="$SENT_KEYS $*"
fi
return 0
}
COUNT_FILE=$(mktemp)
echo "0" > "$COUNT_FILE"
_pane_capture() {
local c
c=$(cat "$COUNT_FILE")
c=$((c + 1))
echo "$c" > "$COUNT_FILE"
if [ "$c" -le 2 ]; then
# First 2 captures (loop iter 1 capture + tail check): Claude Code banner with Fullscreen Modal
cat << 'EOF'
Claude Code (Sonnet 3.5)
Try the new fullscreen renderer?
Flicker-free output
Selected text auto-copies to your clipboard
Yes, try it
EOF
else
# Subsequent captures (after Escape sent): modal dismissed, normal prompt
cat << 'EOF'
Claude Code (Sonnet 3.5)
❯
EOF
fi
}
rc=0
wait_for_tui_ready "my-claude-sess" "claude" || rc=$?
rm -f "$COUNT_FILE"
echo "RC=$rc"
echo "KEYS=$SENT_KEYS"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "Escape" in res.stdout, f"Expected Escape to be sent for upsell modal: {res.stdout}"
assert "RC=0" in res.stdout
def test_r1_resolve_pane_id_fail_closed_on_multiple_same_kind_panes():
"""R-1: If fallback finds multiple panes of same agent kind, fail closed (exit 1)."""
helper = _lib_helper_src()
script = helper + """
# Mock real herdr pane list returning 2 claude panes
_real_herdr() {
cat << 'EOF'
{"result": {"panes": [
{"pane_id": "w1:p1", "label": "unrelated-1", "agent": "claude"},
{"pane_id": "w1:p2", "label": "unrelated-2", "agent": "claude"}
]}}
EOF
}
# Create a dummy yaml with unknown session mapped to claude cmd
mkdir -p .mam
cat << 'EOF' > .mam/agent-sessions.yaml
herdr_sessions:
- name: my-unmatched-session
pane:
cmd: claude
EOF
rc=0
pid=$(_resolve_herdr_pane_id "my-unmatched-session") || rc=$?
echo "RC=$rc|PID=$pid"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=1|PID=" in res.stdout, f"Expected fail-closed RC=1 and empty PID: {res.stdout}"
+90
View File
@@ -0,0 +1,90 @@
# ==============================================================================
# test_version_consistency.py — 3-Way Version Lockstep & Immutability Test Suite
# ==============================================================================
import os
import re
import subprocess
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
LIB_SH = REPO_ROOT / ".agents" / "skills" / "lib.sh"
VERSIONS_MD = REPO_ROOT / "VERSIONS.md"
SKILLS_DIR = REPO_ROOT / ".agents" / "skills"
SKILL_NAMES = [
"multi-agent-mux-create",
"multi-agent-mux-stop",
"multi-agent-mux-resume",
"multi-agent-mux-status",
"multi-agent-mux-monitor",
"multi-agent-mux-delegate-job",
"multi-agent-mux-loop",
"multi-agent-mux-orc-onboard",
]
def _get_lib_sh_mam_version():
cmd = f'source "{LIB_SH}" && echo "$MAM_VERSION"'
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(REPO_ROOT))
assert res.returncode == 0, f"Failed to source lib.sh: {res.stderr}"
return res.stdout.strip()
def _get_versions_md_versions():
text = VERSIONS_MD.read_text(encoding="utf-8")
# 1. Current release framework version
m_curr = re.search(r"-\s*\*\*프레임워크 버전\*\*:\s*`v(\d+\.\d+\.\d+)`", text)
assert m_curr, "Failed to parse Current Release framework version in VERSIONS.md"
current_framework_ver = m_curr.group(1)
# 2. Skill matrix table versions
table_matches = re.findall(r"\|\s*\*\*`([^`]+)`\*\*\s*\|\s*`(\d+\.\d+\.\d+)`\s*\|", text)
matrix_dict = dict(table_matches)
return current_framework_ver, matrix_dict
def _get_skill_md_frontmatter_version(skill_name: str):
skill_file = SKILLS_DIR / skill_name / "SKILL.md"
assert skill_file.is_file(), f"Skill file not found: {skill_file}"
content = skill_file.read_text(encoding="utf-8")
# Strict frontmatter parsing
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
assert fm_match, f"No YAML frontmatter found in {skill_file}"
fm_data = yaml.safe_load(fm_match.group(1))
assert isinstance(fm_data, dict) and "version" in fm_data, f"No version in frontmatter of {skill_file}"
return str(fm_data["version"])
def test_three_way_version_lockstep():
"""T-4: lib.sh(MAM_VERSION) == VERSIONS.md(Current + Matrix 8 items) == 8x SKILL.md frontmatters."""
# 1. lib.sh runtime constant
lib_ver = _get_lib_sh_mam_version()
assert lib_ver != "", "MAM_VERSION in lib.sh is empty"
# 2. VERSIONS.md
vmd_curr, vmd_matrix = _get_versions_md_versions()
assert lib_ver == vmd_curr, f"lib.sh MAM_VERSION ({lib_ver}) != VERSIONS.md current ({vmd_curr})"
for skill in SKILL_NAMES:
assert skill in vmd_matrix, f"Skill {skill} missing from VERSIONS.md matrix"
assert vmd_matrix[skill] == lib_ver, f"VERSIONS.md matrix for {skill} ({vmd_matrix[skill]}) != lib.sh ({lib_ver})"
# 3. 8x SKILL.md files
for skill in SKILL_NAMES:
fm_ver = _get_skill_md_frontmatter_version(skill)
assert fm_ver == lib_ver, f"{skill}/SKILL.md version ({fm_ver}) != lib.sh ({lib_ver})"
def test_mam_version_is_not_env_overridable():
"""T-0 (Constraint 1): MAM_VERSION cannot be spoofed by caller environment."""
cmd = f'source "{LIB_SH}" && echo "$MAM_VERSION"'
env = os.environ.copy()
env["MAM_VERSION"] = "9.9.9"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env=env, cwd=str(REPO_ROOT))
assert res.returncode == 0
assert res.stdout.strip() != "9.9.9", "MAM_VERSION was improperly overwritten by caller environment"
assert res.stdout.strip() == _get_lib_sh_mam_version()