Merge PR: Phase 1 Slice 2 — profiles (persona/caps/scope inheritance) + deps/DAG (blocked, cycle detection)

Reviewed against §5/§6 single-host scope; selftest 46/46 (34 regression + 12 new);
profile resolution precedence, persona injection, warn-only scope, deps soft/cycle verified.
This commit is contained in:
saravanakumardb1 2026-05-29 19:49:28 -07:00
commit d43cab8afe
13 changed files with 623 additions and 26 deletions

View File

@ -109,10 +109,10 @@ are otherwise **no-ops until a later phase** (they do not yet affect execution).
| `prefers-engine` | **active** | _(none)_ | optional order hint for `engine-class` resolution, e.g. `[claude, devin]` |
| `capabilities` | **active** | _(none)_ | hard host requirements, e.g. `[os:any, node>=20, has:git]`. If the host can't satisfy them the job is sent to `failed/` with `result=capability_mismatch` **and the agent is never launched** (grammar below) |
| `idempotency-key` | **active** | _(none)_ | dedupe on `add` (semantics below) |
| `profile` | RESERVED | _(none)_ | role/persona + caps (profiles land in a later slice) |
| `profile` | **active** | _(none)_ | inherit persona + verify/caps/engine-class/prefers-engine/allowed-scope/review-policy from `profiles/<name>.md` (job fields override — see **Profiles**) |
| `prefers` | RESERVED | _(none)_ | soft routing/affinity hints (e.g. `[factory:mac-2]`) |
| `budget` | RESERVED | _(none)_ | `{ usd, tokens, wall }` ceilings (`wall` enforcement is a later slice) |
| `deps` / `deps-mode` | RESERVED | _(none)_ | DAG dependencies (single-host blocking is a later slice) |
| `deps` / `deps-mode` | **active** | _(none)_ | block until each referenced `idempotency-key` is in `shipped/` (or `testing/` when `deps-mode: soft`). Submit-time cycle detection (see **Profiles & deps**) |
| `retry` | **active** | _(none)_ | `{ max: N, backoff: 5m, on: [timeout, verify_failed, crash] }` — requeue failures with backoff up to `max`, then `retries_exhausted` (see **Resilience**) |
| `review-policy` | RESERVED | _(none)_ | `auto\|manual\|reviewers:[…]` |
| `artifacts` | RESERVED | _(none)_ | extra outputs to capture (coverage, screenshots) |
@ -232,6 +232,42 @@ queue/
(transient: requeued for another attempt), `recovered` (transient: an orphan was
reclaimed to `inbox/`).
## Profiles & deps
### Profiles (roadmap §6)
A **profile** is a reusable role preset in `profiles/<name>.md`. A job opts in with
`profile: <name>` and inherits any of these fields it does **not** set itself:
`verify` (from the profile's `default-verify`), `capabilities`, `engine-class`,
`prefers-engine`, `allowed-scope`, `review-policy`. The profile's `persona` block is
**prepended** to the body sent to the engine (the job `.md` on disk is unchanged;
secrets are never logged). Resolution runs **before** the capability gate and engine
resolution, so inherited caps / engine-class take effect.
**Precedence:** `job field > profile field > built-in default`. Set `AGENT_QUEUE_PROFILES`
to point at a different catalog directory (defaults to `./profiles`).
Starter catalog: `developer`, `backend-engineer`, `frontend-engineer`, `ux-designer`,
`ui-designer`, `qa`, `reviewer`, `docs-writer`, and a reserved `planner`. Each presets
`name`, `persona`, `capabilities`, `default-verify`, `engine-class`, `prefers-engine`,
`allowed-scope`, and `review-policy`.
**allowed-scope (warn-only this phase).** After a run on a git `cwd`, changed paths
outside the profile/job `allowed-scope` globs (`dir/**` matches the whole subtree) are
logged as a `WARNING` and recorded as `scope_warning=` in the meta — **non-blocking**
(the job is not failed). `path_in_scope` is exposed as a unit-testable function.
### deps / DAG, single host (roadmap §5)
`deps: [keyA, keyB]` references other jobs by their author-controlled
`idempotency-key`. A dep is **satisfied** when a job with that key is in `shipped/`
(default), or in `shipped/` **or** `testing/` when the dependent job sets
`deps-mode: soft`. A job with unmet deps is **blocked**: it is skipped in inbox
selection (never launched, never failed) and surfaced in `status` as
`blocked (waiting on: <keys>)`, then re-evaluated every loop until its deps are met.
`add` performs **submit-time cycle detection** over the inbox + active-stage dep graph
and rejects (nonzero exit) a job that would create a cycle. Cross-machine deps are P2.
## Resilience (crash recovery & work preservation)
Single-host implementations of the durability model (roadmap §25):

View File

@ -29,6 +29,8 @@ set -uo pipefail
# ── Resolve paths ───────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QUEUE_ROOT="${AGENT_QUEUE_ROOT:-$SCRIPT_DIR/queue}"
# Profile catalog dir (persona + capability presets). Override for tests.
PROFILES_DIR="${AGENT_QUEUE_PROFILES:-$SCRIPT_DIR/profiles}"
INBOX="$QUEUE_ROOT/inbox"
BUILDING="$QUEUE_ROOT/building"
REVIEW="$QUEUE_ROOT/review"
@ -120,6 +122,51 @@ lock_key_for() {
# _keyhash <key> -> stable filename-safe token for a lock key
_keyhash() { printf '%s' "$1" | cksum | awk '{print $1}'; }
# ── Profiles (§6): persona + capability/engine/scope presets ─────────
#
# profile_get <profile-name> <profile-key> [default] -> a single-line value from
# profiles/<name>.md, else the default.
profile_get() {
local pf="$PROFILES_DIR/$1.md"
[[ -f "$pf" ]] || { printf '%s' "${3:-}"; return; }
fm_get "$pf" "$2" "${3:-}"
}
# profile_persona <profile-name> -> the multi-line `persona: |` block (2-space
# indent stripped), or empty. Used to prepend a persona overlay to the job body.
profile_persona() {
local pf="$PROFILES_DIR/$1.md"
[[ -f "$pf" ]] || return 0
awk '
NR==1 && $0!="---" { exit }
NR==1 { infm=1; next }
infm && $0=="---" { exit }
!infm { next }
inpersona {
if ($0 ~ /^[A-Za-z0-9_-]+[ \t]*:/) { inpersona=0 }
else { line=$0; sub(/^ /,"",line); print line; next }
}
$0 ~ /^persona[ \t]*:[ \t]*\|[ \t]*$/ { inpersona=1; next }
' "$pf"
}
# fm_eff <file> <job-key> [default] [profile-key] -> effective value with
# precedence job > profile > built-in default (§6 resolution). The profile is the
# job's `profile:` frontmatter; `profile-key` defaults to `job-key` (e.g. `verify`
# inherits the profile's `default-verify`). Inheritable: verify, capabilities,
# engine-class, prefers-engine, allowed-scope, review-policy.
fm_eff() {
local file=$1 jkey=$2 def=${3:-} pkey=${4:-$2} v prof pv
v=$(fm_get "$file" "$jkey" "")
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
prof=$(fm_get "$file" profile "")
if [[ -n "$prof" ]]; then
pv=$(profile_get "$prof" "$pkey" "")
[[ -n "$pv" ]] && { printf '%s' "$pv"; return; }
fi
printf '%s' "$def"
}
# _mtime <file> -> file modification time in epoch seconds (BSD or GNU stat); empty if missing
_mtime() {
[[ -e "$1" ]] || { echo ""; return; }
@ -356,10 +403,10 @@ resolve_engine() {
local f=$1 eng cls prefers
eng=$(fm_get "$f" engine "")
if [[ -n "$eng" ]]; then printf '%s' "$eng"; return 0; fi
cls=$(fm_get "$f" engine-class "")
cls=$(fm_eff "$f" engine-class "") # inherit engine-class from the job's profile
if [[ -z "$cls" ]]; then printf '%s' "$DEFAULT_ENGINE"; return 0; fi
local class_engines; class_engines=$(engine_class_engines "$cls")
prefers=$(fm_get "$f" prefers-engine "")
prefers=$(fm_eff "$f" prefers-engine "")
local ordered=() seen=" " p c
if [[ -n "$prefers" ]]; then
while IFS= read -r p; do
@ -377,6 +424,134 @@ resolve_engine() {
printf '%s' ""
}
# ── deps / DAG, single host (§5) ─────────────────────────────────────
# deps reference other jobs by their (author-controlled) `idempotency-key`.
#
# _key_in_dir <key> <dir> -> 0 if some .md in <dir> has idempotency-key == key.
_key_in_dir() {
local key=$1 d=$2 ef
for ef in "$d"/*.md; do
[[ -e "$ef" ]] || continue
[[ "$(fm_get "$ef" idempotency-key "")" == "$key" ]] && return 0
done
return 1
}
# dep_satisfied <key> <mode> -> 0 when the dep is met: a job with <key> is in
# shipped/ (default), or shipped/ OR testing/ when mode is `soft`.
dep_satisfied() {
local key=$1 mode=$2
_key_in_dir "$key" "$SHIPPED" && return 0
[[ "$mode" == soft ]] && _key_in_dir "$key" "$TESTING" && return 0
return 1
}
# deps_unmet <file> -> space-separated list of this job's UNMET dep keys (empty if
# none / no deps). `deps-mode` (hard|soft) is job-level.
deps_unmet() {
local f=$1 keys mode k unmet=""
keys=$(parse_list "$(fm_get "$f" deps "")" | tr '\n' ' ')
[[ -n "${keys// /}" ]] || { printf ''; return 0; }
mode=$(fm_get "$f" deps-mode "hard")
for k in $keys; do
[[ -n "$k" ]] || continue
dep_satisfied "$k" "$mode" || unmet+="$k "
done
printf '%s' "${unmet% }"
}
# _deps_of_key <key> -> dep keys (space-separated) of the job carrying <key>,
# scanned across inbox + active stages.
_deps_of_key() {
local key=$1 d ef
for d in "$INBOX" "$BUILDING" "$REVIEW" "$TESTING" "$SHIPPED"; do
for ef in "$d"/*.md; do
[[ -e "$ef" ]] || continue
[[ "$(fm_get "$ef" idempotency-key "")" == "$key" ]] || continue
parse_list "$(fm_get "$ef" deps "")" | tr '\n' ' '
return 0
done
done
}
# deps_would_cycle <new-key> <new-deps-space> -> 0 if adding a job with <new-key>
# depending on <new-deps> would create a cycle (BFS over existing key->deps edges
# back to new-key; also catches self-dependency).
deps_would_cycle() {
local newkey=$1 newdeps=$2 visited=" " frontier next k d kd
[[ -n "$newkey" ]] || return 1
_in_list "$newkey" "$newdeps" && return 0
frontier="$newdeps"
while [[ -n "${frontier// /}" ]]; do
next=""
for k in $frontier; do
[[ -n "$k" ]] || continue
[[ "$k" == "$newkey" ]] && return 0
case "$visited" in *" $k "*) continue;; esac
visited+="$k "
kd=$(_deps_of_key "$k")
for d in $kd; do next+="$d "; done
done
frontier="$next"
done
return 1
}
# _drain_pending -> 0 if some inbox job can still make progress on its own: it is
# runnable now, or it is waiting on a retry/recovery backoff (which elapses with
# time). A job blocked ONLY by unmet deps is NOT pending while the loop is idle
# (no running job can satisfy its deps), so `--once` can drain past it.
_drain_pending() {
local cand cj ne now; now=$(date +%s)
for cand in "$INBOX"/*.md; do
[[ -e "$cand" ]] || continue
cj=$(basename "$cand"); cj=${cj%.md}
ne=$(grep '^next_eligible=' "$STATE/$cj.meta" 2>/dev/null | tail -1 | cut -d= -f2)
if [[ "$ne" =~ ^[0-9]+$ ]] && [[ "$ne" -gt "$now" ]]; then return 0; fi
[[ -n "$(deps_unmet "$cand")" ]] && continue
return 0
done
return 1
}
# ── allowed-scope guardrail (§6/§12) — WARN-ONLY this phase ───────────
# path_in_scope <path> <globs-space> -> 0 if <path> matches any allowed-scope glob
# (`dir/**` matches the whole subtree; `*` matches across `/`). Pure + testable.
path_in_scope() {
local path=$1 globs=$2 g pat
for g in $globs; do
[[ -n "$g" ]] || continue
pat=${g//\*\*/\*}
# shellcheck disable=SC2053
[[ "$path" == $pat ]] && return 0
[[ "$path" == "$g"/* ]] && return 0
done
return 1
}
# scope_check <cwd> <base> <scope> <logf> <metaf> -> log a WARNING (non-blocking)
# for changed paths outside allowed-scope. Records scope_warning= in the meta.
scope_check() {
local cwd=$1 base=$2 scope=$3 logf=$4 metaf=$5 changed globs p out=""
_is_git_repo "$cwd" || return 0
if [[ -n "$base" ]]; then
changed=$(git -C "$cwd" diff --name-only "$base" HEAD 2>/dev/null)
else
changed=$(git -C "$cwd" diff --name-only HEAD 2>/dev/null)
fi
[[ -n "$changed" ]] || return 0
globs=$(parse_list "$scope" | tr '\n' ' ')
[[ -n "${globs// /}" ]] || return 0
while IFS= read -r p; do
[[ -n "$p" ]] || continue
path_in_scope "$p" "$globs" || out+="$p "
done <<< "$changed"
if [[ -n "$out" ]]; then
echo "WARNING: allowed-scope violation (warn-only) — changed outside [$globs]: ${out% }" >> "$logf"
echo "scope_warning=${out% }" >> "$metaf"
fi
}
# ── Engine driver: builds argv into AGENT_CMD[]; sets AGENT_STDIN if the ──
# prompt should be fed on stdin (claude/codex) rather than a flag. $pf is the
# frontmatter-STRIPPED body file, so a body starting with '--' is never
@ -424,8 +599,9 @@ run_worker() {
# The worker only ever APPENDS (ended/exit/result) to avoid a truncation race.
# ── Capability gate (§5/§8 single-host): if the job declares `capabilities`
# this host does not satisfy, route to failed/ WITHOUT launching the agent. ──
local req_caps; req_caps=$(parse_list "$(fm_get "$doing_file" capabilities "")" | tr '\n' ' ')
# (own or inherited from its profile) this host does not satisfy, route to
# failed/ WITHOUT launching the agent. ──
local req_caps; req_caps=$(parse_list "$(fm_eff "$doing_file" capabilities "")" | tr '\n' ' ')
if [[ -n "${req_caps// /}" ]]; then
local avail; avail=$(detect_capabilities)
if ! caps_match "$req_caps" "$avail"; then
@ -474,6 +650,16 @@ run_worker() {
# Strip our frontmatter so the agent only sees the task body.
local bodyf="$STATE/$job.body.md"
strip_frontmatter "$doing_file" > "$bodyf"
# ── Persona injection (§6): prepend the profile's persona to the body fed to
# the engine (job body unchanged on disk). Secrets are never logged. ──
local prof; prof=$(fm_get "$doing_file" profile "")
if [[ -n "$prof" ]]; then
local persona; persona=$(profile_persona "$prof")
if [[ -n "$persona" ]]; then
{ printf '%s\n\n' "$persona"; cat "$bodyf"; } > "$bodyf.tmp" && mv "$bodyf.tmp" "$bodyf"
echo "profile: injected persona overlay from '$prof'" >> "$logf"
fi
fi
build_agent_cmd "$engine" "$bodyf" "$yolo"
# ── WIP checkpoint setup (§25.2): on a git cwd, create/checkout aq/wip/<job>
@ -547,6 +733,11 @@ run_worker() {
_numstat_into_meta "$cwd" "$WIP_BASE" "$metaf"
parse_usage "$engine" "$logf" >> "$metaf"
# ── allowed-scope guardrail (§6) — WARN-ONLY: flag out-of-scope changes but
# never block the job this phase. Scope may be inherited from the profile. ──
local scope; scope=$(fm_eff "$doing_file" allowed-scope "" allowed-scope)
[[ -n "$scope" ]] && scope_check "$cwd" "$WIP_BASE" "$scope" "$logf" "$metaf"
if $timed_out; then
echo "TIMED OUT after ${tmo}s (rc=$rc): $(date)" >> "$logf"
_finish_failure "$job" "$doing_file" "$metaf" "$logf" "timeout" "$rc" "$started"
@ -558,7 +749,8 @@ run_worker() {
local review_file="$REVIEW/$job.md"
echo "exit=$rc" >> "$metaf"
echo "completed OK (rc=0): landed in review — $(date)" >> "$logf"
local verify; verify=$(fm_get "$review_file" verify "$DEFAULT_VERIFY")
# verify is job-level, else inherited from the profile's default-verify.
local verify; verify=$(fm_eff "$review_file" verify "$DEFAULT_VERIFY" default-verify)
if [[ -z "$verify" ]]; then
_meta_end "$metaf" "review" "$started"
echo "no verify command — parked in review for manual promote: $(date)" >> "$logf"
@ -896,6 +1088,13 @@ cmd_add() {
done
fi
# ── dep cycle detection (§5): reject a submit that would create a cycle in the
# idempotency-key dependency graph (inbox + active stages). ──
local newdeps; newdeps=$(parse_list "$(fm_get "$file" deps "")" | tr '\n' ' ')
if [[ -n "${newdeps// /}" ]] && deps_would_cycle "$idem" "$newdeps"; then
die "dependency cycle detected: job (key '${idem:-<none>}') with deps [${newdeps% }] would create a cycle — refusing."
fi
local base; base=$(basename "$file")
local stamp; stamp=$(date +%Y%m%d-%H%M%S)
local dest="$INBOX/${stamp}__${base}"
@ -963,6 +1162,8 @@ cmd_run() {
cand_job=$(basename "$cand"); cand_job=${cand_job%.md}
cand_ne=$(grep '^next_eligible=' "$STATE/$cand_job.meta" 2>/dev/null | tail -1 | cut -d= -f2)
if [[ "$cand_ne" =~ ^[0-9]+$ ]] && [[ "$cand_ne" -gt "$now_s" ]]; then continue; fi
# skip jobs whose deps (§5 DAG) are unmet — blocked, re-evaluated next loop
if [[ -n "$(deps_unmet "$cand")" ]]; then continue; fi
next="$cand"; break
done < <(inbox_sorted)
[[ -z "$next" ]] && break
@ -997,16 +1198,17 @@ cmd_run() {
echo "attempts=$w_attempts"
echo "priority=$(fm_get "$doing_file" priority medium)"
echo "profile=$(fm_get "$doing_file" profile "")"
echo "engine_class=$(fm_get "$doing_file" engine-class "")"
echo "capabilities=$(fm_get "$doing_file" capabilities "")"
echo "engine_class=$(fm_eff "$doing_file" engine-class "")"
echo "capabilities=$(fm_eff "$doing_file" capabilities "")"
echo "prefers=$(fm_get "$doing_file" prefers "")"
echo "prefers_engine=$(fm_get "$doing_file" prefers-engine "")"
echo "prefers_engine=$(fm_eff "$doing_file" prefers-engine "")"
echo "allowed_scope=$(fm_eff "$doing_file" allowed-scope "" allowed-scope)"
echo "budget=$(fm_get "$doing_file" budget "")"
echo "deps=$(fm_get "$doing_file" deps "")"
echo "deps_mode=$(fm_get "$doing_file" deps-mode "")"
echo "idempotency_key=$(fm_get "$doing_file" idempotency-key "")"
echo "retry=$(fm_get "$doing_file" retry "")"
echo "review_policy=$(fm_get "$doing_file" review-policy "")"
echo "review_policy=$(fm_eff "$doing_file" review-policy "" review-policy)"
echo "artifacts=$(fm_get "$doing_file" artifacts "")"
echo "tracker_item=$(fm_get "$doing_file" tracker-item "")"
} > "$STATE/$job.meta"
@ -1018,8 +1220,11 @@ cmd_run() {
done
if $once; then
[[ "$(active_workers)" -eq 0 && -z "$(ls -1 "$INBOX"/*.md 2>/dev/null)" ]] && {
log "drain complete — inbox empty, no workers running"; rm -f "$STATE/daemon.pid"; exit 0; }
# drain when no worker is running and nothing in inbox can still progress on
# its own (backoff jobs still count as pending; dep-blocked jobs do not).
if [[ "$(active_workers)" -eq 0 ]] && ! _drain_pending; then
log "drain complete — no runnable work, no workers running"; rm -f "$STATE/daemon.pid"; exit 0
fi
fi
sleep "$POLL_SECONDS"
done
@ -1072,6 +1277,18 @@ cmd_status() {
printf ' %s%s%s\n' "$C_DIM" "$(_insights_line "$f")" "$C_RESET"
done
$printed || printf ' %sno workers running%s\n' "$C_DIM" "$C_RESET"
# blocked jobs (unmet deps, §5) — waiting in inbox/, re-evaluated each loop
local bf bj unmet bprinted=false
for bf in "$INBOX"/*.md; do
[[ -e "$bf" ]] || continue
unmet=$(deps_unmet "$bf")
[[ -n "$unmet" ]] || continue
if ! $bprinted; then echo; printf ' %sBLOCKED%s\n' "$C_BOLD" "$C_RESET"; bprinted=true; fi
bj=$(basename "$bf"); bj=${bj%.md}
printf ' %s%-26s%s %sblocked (waiting on: %s)%s\n' \
"$C_BOLD" "$bj" "$C_RESET" "$C_YEL" "$unmet" "$C_RESET"
done
echo
}
@ -1328,10 +1545,18 @@ ${C_BOLD}TASK FRONTMATTER${C_RESET} (top of each .md)
capabilities: [os:any, node>=20, has:git] # hard host requirements; unmet -> failed (capability_mismatch)
idempotency-key: my-task-1 # re-adding same key+body = no-op; same key+different body = reject/supersede
retry: { max: 2, backoff: 5m, on: [timeout, verify_failed, crash] } # requeue on these classes up to max, then retries_exhausted
profile: backend-engineer # inherit persona + verify/caps/engine-class/scope/review-policy (job fields override)
deps: [other-key] # block until each idempotency-key is shipped/ (or testing/ if deps-mode: soft)
deps-mode: soft # soft = a dep also counts as satisfied while in testing/
# --- reserved (parsed + shown in status, but no-op until a later phase) ---
profile: prefers: budget: deps: deps-mode: review-policy: artifacts: tracker-item:
prefers: budget: review-policy: artifacts: tracker-item:
---
${C_BOLD}PROFILES${C_RESET} profiles/<name>.md presets persona + capabilities + default-verify + engine-class +
prefers-engine + allowed-scope + review-policy. A job's own fields always override.
Catalog: developer, backend-engineer, frontend-engineer, ux-designer, ui-designer,
qa, reviewer, docs-writer, planner(reserved).
${C_BOLD}RESILIENCE${C_RESET} crash-safe: orphaned building/ jobs (dead worker) are recovered to inbox/ on
'run' startup; git-repo cwd work is checkpointed to branch aq/wip/<job> on every
exit (resumed on retry); 'retry' requeues failures with backoff. See 'insights'.

View File

@ -11,7 +11,7 @@
| Phase | Theme | Status | % | Gate |
| ----- | ----- | ------ | - | ---- |
| **0** | Baseline (today) | ✅ shipped | 100% | `selftest.sh` green |
| **1** | Manifest + profiles + capabilities + tracker adapter (single host) | ◐ in progress | 55% | adapter e2e + selftest |
| **1** | Manifest + profiles + capabilities + tracker adapter (single host) | ◐ in progress | 80% | adapter e2e + selftest |
| **2** | Coordinator as platform-service module + Cosmos + multi-factory leasing | ☐ not started | 0% | fleet e2e + module tests |
| **3** | Fleet control plane in tracker-web + DAG deps + budgets + scoring router | ☐ not started | 0% | web e2e + router tests |
| **4** | Message bus + autoscaling + cross-OS capability marketplace | ☐ not started | 0% | load/chaos suite |
@ -142,7 +142,7 @@ tracker-item: ITEM-789 # link back to the originating tracker task
- [x] **Capability grammar** defined: tokens are `key` (presence, e.g. `has:xcode`), `key:value` (e.g. `os:mac`, `engine:devin`), or `key<op>version` with `op ∈ {>=,>,=,<=,<}` (e.g. `node>=20`). `os:any` is a wildcard that matches every factory. A job matches a factory iff every required token is satisfied by the factory descriptor. *(P1-S1: `caps_match`/`detect_capabilities` in `agent-queue.sh`.)*
- [x] **`engine-class` taxonomy** defined as an enum (`agentic-coder`, `chat-coder`, `review-only`) with a documented engine→class map (`devin,claude,codex → agentic-coder`; `copilot → chat-coder`). If `engine` is set it wins; else the scheduler picks any free engine in the class honoring `prefers-engine`. *(P1-S1: `resolve_engine`; `review-only` mapping reserved.)*
- [x] **`idempotency-key` semantics:** `key + content-hash` identical ⇒ no-op (returns existing job). Same `key`, **different** content ⇒ **rejected with 409** unless the prior job is still `queued`/`blocked` (then it is superseded). A re-`run`/`retry` of an existing job is **not** a new submit and never trips dedupe. *(P1-S1: add-time dedupe; bash maps "409" → clear error, `queued` → still in `inbox/` ⇒ superseded.)*
- [ ] **`deps` semantics:** a dep is satisfied when it reaches `shipped` (default) or `testing` if `deps-mode: soft`. Submit-time **cycle detection** rejects cyclic graphs; unmet deps put the job in `blocked` (not `queued`). Cross-factory deps require the coordinator (P2); single-host deps work in P1.
- [x] **`deps` semantics:** a dep is satisfied when it reaches `shipped` (default) or `testing` if `deps-mode: soft`. Submit-time **cycle detection** rejects cyclic graphs; unmet deps put the job in `blocked` (not `queued`). Cross-factory deps require the coordinator (P2); single-host deps work in P1. *(P1-S2: `deps_unmet` skip-with-reason in selection + `status` surfacing; `deps_would_cycle` on `add`. Cross-machine deps remain P2.)*
- **Acceptance:** a manifest fixture suite parses/validates; invalid manifests fail with precise errors; capability-grammar + dep-cycle + idempotency-conflict cases covered.
- **Verify gate:** schema unit tests (≥ 1 per field incl. defaults + 5 invalid cases + grammar/cycle/409 cases).
@ -167,11 +167,11 @@ review-policy: manual
---
```
- [ ] Author starter catalog: `developer`, `backend-engineer`, `frontend-engineer`, `ux-designer`, `ui-designer`, `qa`, `reviewer`, `docs-writer`.
- [ ] Persona overlay is **prepended** to the job body before the agent runs; secrets are never written to logs or the event stream (redaction at the source).
- [ ] Profile supplies default `verify`, `capabilities`, `engine-class`, `allowed-scope` when the job omits them.
- [ ] Profile versioning: changing a profile doesn't mutate in-flight jobs (snapshot at assign time).
- [ ] `allowed-scope` enforced as a guardrail (warn in P1, enforce/deny in P2 via pre-flight diff check).
- [x] Author starter catalog: `developer`, `backend-engineer`, `frontend-engineer`, `ux-designer`, `ui-designer`, `qa`, `reviewer`, `docs-writer`. *(P1-S2: `profiles/*.md` + a reserved `planner`.)*
- [x] Persona overlay is **prepended** to the job body before the agent runs; secrets are never written to logs or the event stream (redaction at the source). *(P1-S2: `profile_persona` prepended to the stripped body file.)*
- [x] Profile supplies default `verify`, `capabilities`, `engine-class`, `allowed-scope` when the job omits them. *(P1-S2: `fm_eff` — also `prefers-engine` + `review-policy`; job fields always override.)*
- [ ] Profile versioning: changing a profile doesn't mutate in-flight jobs (snapshot at assign time). *(P2 — needs Cosmos snapshot at assign time.)*
- [x] `allowed-scope` enforced as a guardrail (warn in P1, enforce/deny in P2 via pre-flight diff check). *(P1-S2: `scope_check` post-run WARN-only + `scope_warning=` in meta; `path_in_scope` unit-testable.)*
- **Acceptance:** a job with `profile: backend-engineer` and no `verify` inherits the profile's verify + persona.
- **Verify gate:** profile-resolution unit tests; persona-injection golden test.
@ -342,18 +342,20 @@ Each phase: **Goal → checklist → Exit criteria**. Don't start a phase until
> **Slice progress — P1-S1:** manifest parsing (all §5 fields, defaulted + backward-compatible), `priority` ordering, capability detection+match gate, `engine-class` resolution, and `idempotency-key` dedupe are **done** on the bash runner.
>
> **Slice progress — P1-S3 (resilience & insights, single host):** crash recovery (`recover_orphans` + `aq recover`), git WIP checkpoint/resume (`aq/wip/<job>`), functional `retry` policy (backoff + `retries_exhausted`), and execution insights (`parse_usage`, per-run metrics in meta, `aq insights`, `status`/`dash` insights) are **done** — see §11/§25/§26. Profiles, `deps` DAG, `budget.wall`, `allowed-scope`, and the tracker adapter remain **for later slices**.
> **Slice progress — P1-S3 (resilience & insights, single host):** crash recovery (`recover_orphans` + `aq recover`), git WIP checkpoint/resume (`aq/wip/<job>`), functional `retry` policy (backoff + `retries_exhausted`), and execution insights (`parse_usage`, per-run metrics in meta, `aq insights`, `status`/`dash` insights) are **done** — see §11/§25/§26.
>
> **Slice progress — P1-S2 (profiles + deps/DAG, single host):** the `profiles/` catalog + resolution (`fm_eff` inheritance with job>profile>default precedence, persona injection), the warn-only `allowed-scope` guardrail (`scope_check`/`path_in_scope`), and single-host `deps` (block-with-reason in selection, `status` surfacing, submit-time cycle detection) are **done** — see §5/§6. The tracker adapter and `budget.wall` remain **for later slices**.
- [x] Extend `agent-queue.sh` frontmatter parsing for all new manifest fields (§5), defaulted + backward-compatible. *(P1-S1)*
- [ ] Add `profiles/` directory + profile resolution (persona injection, default verify/caps/scope) (§6).
- [x] Add `profiles/` directory + profile resolution (persona injection, default verify/caps/scope) (§6). *(P1-S2)*
- [x] Local capability detection + a job/factory capability match check before launch (§8 subset). *(P1-S1: `detect_capabilities` + `caps_match`; mismatch ⇒ `failed/` `result=capability_mismatch`, agent never launched.)*
- [x] `priority` ordering in the inbox pick (replace pure FIFO with priority-then-age). *(P1-S1: `inbox_sorted`; per-lock serialization preserved.)*
- [ ] `deps` (DAG) blocking on a single host; `idempotency-key` dedupe on `add`. *(P1-S1: `idempotency-key` dedupe DONE; `deps` DAG blocking still pending.)*
- [x] `deps` (DAG) blocking on a single host; `idempotency-key` dedupe on `add`. *(P1-S1 idempotency dedupe + P1-S2 `deps` blocking/cycle detection.)*
- [ ] `retry` with backoff into `failed`/requeue; `budget.wall` enforced (extends `timeout`). *(P1-S3: `retry` with backoff + `retries_exhausted` DONE; `budget.wall` still pending.)*
- [ ] `allowed-scope` guardrail (warn-only this phase) + post-run diff report.
- [x] `allowed-scope` guardrail (warn-only this phase) + post-run diff report. *(P1-S2: `scope_check` WARN-only + `scope_warning=`.)*
- [ ] **Tracker adapter** `aq from-tracker <ITEM>` + `aq to-tracker` event poster (§10 P1).
- [ ] Dashboard shows profile + priority + capability tags + tracker-item link. *(P1-S1: `status` shows priority/profile/caps/tracker-item; Node `dash` surfacing pending.)*
- [ ] Update `selftest.sh` with: manifest parse fixtures, profile resolution, priority order, dep-block, idempotency, adapter round-trip (mock). *(P1-S1: added backward-compat, priority, capability-mismatch, engine-class, idempotency cases; profile/dep-block/adapter pending.)*
- [ ] Update `selftest.sh` with: manifest parse fixtures, profile resolution, priority order, dep-block, idempotency, adapter round-trip (mock). *(P1-S1 manifest/priority/idempotency + P1-S2 profile resolution/persona/scope/dep-block/cycle + P1-S3 resilience/insights; tracker adapter round-trip still pending.)*
- [x] Update README + this doc's progress table. *(P1-S1)*
- **Exit criteria:** all boxes ✅; `selftest.sh` green; a tracker task → executed → tracker `done` with SHA comment, fully on one host; no regression to Phase-0 `.md` files.

View File

@ -0,0 +1,19 @@
---
name: backend-engineer
persona: |
You are a senior backend engineer. Favor minimal, well-tested changes. Respect
service boundaries, validate inputs, handle errors explicitly, and never log
secrets. Prefer existing libraries and patterns over new dependencies. Keep
migrations and API changes backward-compatible unless the task says otherwise.
capabilities: [os:any, node>=20, has:pnpm]
default-verify: pnpm -s typecheck && pnpm -s test
engine-class: agentic-coder
prefers-engine: [devin, claude]
allowed-scope: ["backend/**", "services/**", "packages/**"]
review-policy: manual
---
# backend-engineer
Server-side work. Inherits a typecheck+test verify gate and a scope limited to
backend/service/package code.

View File

@ -0,0 +1,20 @@
---
name: developer
persona: |
You are a pragmatic senior software engineer. Make the smallest correct change
that satisfies the task. Match the surrounding code style and existing patterns,
keep diffs focused, and never commit secrets. Add or update tests when you change
behavior, and explain non-obvious decisions briefly in the commit message.
capabilities: [os:any, has:git]
default-verify:
engine-class: agentic-coder
prefers-engine: [devin, claude, codex]
allowed-scope: ["**"]
review-policy: manual
---
# developer
General-purpose engineering profile. No default verify (parks in review for a
human gate) and an unrestricted scope — pick a more specific profile when you
want a tighter blast radius or an automatic QA gate.

View File

@ -0,0 +1,18 @@
---
name: docs-writer
persona: |
You are a technical writer. Produce clear, accurate documentation that matches
the repository's existing voice and structure. Update READMEs, guides, and
references; keep examples runnable and links valid. Do not change source code
beyond doc comments. Never include secrets in examples.
capabilities: [os:any]
default-verify:
engine-class: agentic-coder
prefers-engine: [claude, devin]
allowed-scope: ["docs/**", "**/*.md", "**/*.mdx"]
review-policy: manual
---
# docs-writer
Documentation profile. Scoped to docs + markdown; parks in review for a human read.

View File

@ -0,0 +1,18 @@
---
name: frontend-engineer
persona: |
You are a senior frontend engineer. Build accessible, responsive UI that matches
the existing component library and design tokens. Keep state management simple,
avoid unnecessary dependencies, and ensure type-safety. Verify the build and
tests pass before finishing; never hardcode secrets or API keys.
capabilities: [os:any, node>=20, has:pnpm]
default-verify: pnpm -s typecheck && pnpm -s build
engine-class: agentic-coder
prefers-engine: [claude, devin]
allowed-scope: ["dashboards/**", "apps/**", "packages/ui/**", "src/**"]
review-policy: manual
---
# frontend-engineer
Client/UI work. Inherits a typecheck+build gate and a UI-oriented scope.

View File

@ -0,0 +1,19 @@
---
name: planner
persona: |
You are a planning agent. Break an objective into a dependency-ordered set of
small, well-scoped tasks, each mappable to a job .md (with a profile, scope, and
verify). Output the plan as markdown; do not implement the tasks yourself.
capabilities: [os:any]
default-verify:
engine-class: agentic-coder
prefers-engine: [claude]
allowed-scope: ["docs/**", "**/*.md"]
review-policy: manual
---
# planner (reserved)
Reserved for a future planning/decomposition flow that emits child jobs with
`deps:` wiring. Usable today as a docs-scoped persona; automatic job emission is
a later slice.

View File

@ -0,0 +1,18 @@
---
name: qa
persona: |
You are a QA engineer. Write and strengthen tests; reproduce bugs with a failing
test first, then confirm the fix. Cover edge cases, error paths, and regressions.
Do not weaken or delete existing tests to make a suite pass — fix the cause.
Keep tests deterministic and fast.
capabilities: [os:any, node>=20, has:pnpm]
default-verify: pnpm -s test
engine-class: agentic-coder
prefers-engine: [codex, claude]
allowed-scope: ["**/*.test.*", "**/*.spec.*", "test/**", "tests/**", "e2e/**"]
review-policy: manual
---
# qa
Test-focused profile. Inherits a `pnpm -s test` gate and a test-files scope.

View File

@ -0,0 +1,19 @@
---
name: reviewer
persona: |
You are a code reviewer. Do NOT modify code. Read the diff/changes and produce a
concise review: correctness, security, tests, readability, and scope adherence.
Flag risky or out-of-scope changes and supply-chain concerns (edits to shared
packages). Output findings as markdown with severity labels.
capabilities: [os:any, has:git]
default-verify:
engine-class: review-only
prefers-engine: [claude]
allowed-scope: ["docs/**", "**/*.md"]
review-policy: manual
---
# reviewer
Read-only review profile. `engine-class: review-only` has no concrete runner
mapping yet (reserved) — use an explicit `engine:` until a review engine lands.

View File

@ -0,0 +1,19 @@
---
name: ui-designer
persona: |
You are a UI/visual designer. Focus on visual hierarchy, spacing, color, and
typography using the existing design tokens and component library. Keep changes
consistent with the design system, ensure sufficient contrast, and respect
light/dark themes. Prefer token references over hardcoded values.
capabilities: [os:any, node>=20]
default-verify:
engine-class: agentic-coder
prefers-engine: [claude, devin]
allowed-scope: ["packages/ui/**", "packages/design-tokens/**", "**/*.css", "design/**"]
review-policy: manual
---
# ui-designer
Visual/design-system work scoped to UI + tokens + styles. Parks in review for a
human visual check.

View File

@ -0,0 +1,19 @@
---
name: ux-designer
persona: |
You are a UX designer. Focus on user flows, information architecture, and
interaction states (empty, loading, error, success). Produce wireframes,
flow descriptions, and copy as markdown/specs. Justify decisions with usability
heuristics and accessibility (WCAG) considerations. Do not change production code.
capabilities: [os:any]
default-verify:
engine-class: agentic-coder
prefers-engine: [claude]
allowed-scope: ["docs/**", "design/**", "**/*.md"]
review-policy: manual
---
# ux-designer
Flows, IA, and interaction specs. Documentation-scoped; parks in review for human
sign-off (no automatic verify gate).

View File

@ -420,4 +420,169 @@ else
printf '%s\n' "$out" >&2; fail "insights aggregate rollup missing/incorrect"
fi
# ─────────────────────────────────────────────────────────────────────
# Phase 1 — Slice 2 cases (profiles + deps/DAG, single host).
# Uses a temp profile catalog (AGENT_QUEUE_PROFILES) + temp git repos.
# ─────────────────────────────────────────────────────────────────────
profdir="$tmp/profiles"; mkdir -p "$profdir"
printf '%s\n' '---' 'name: vfail' 'persona: |' ' PERSONA-VFAIL' 'default-verify: false' '---' > "$profdir/vfail.md"
printf '%s\n' '---' 'name: vpass' 'default-verify: true' '---' > "$profdir/vpass.md"
printf '%s\n' '---' 'name: capreq' 'capabilities: [has:definitely-not-installed]' '---' > "$profdir/capreq.md"
printf '%s\n' '---' 'name: personap' 'persona: |' ' PERSONA-MARKER-XYZ' ' second persona line' 'default-verify: true' '---' > "$profdir/personap.md"
printf '%s\n' '---' 'name: scoped' 'allowed-scope: [backend/**]' '---' > "$profdir/scoped.md"
export AGENT_QUEUE_PROFILES="$profdir"
funcs="$tmp/aq-funcs.sh"; sed '/^main "\$@"/d' "$AQ" > "$funcs"
# 19. profile inherits default-verify: vfail (verify=false) → failed/verify_failed;
# vpass (verify=true) → testing/.
export AGENT_QUEUE_ROOT="$tmp/queue-pverify"
"$AQ" init >/dev/null
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'profile: vfail' '---' '' '# pv-fail' \
> "$AGENT_QUEUE_ROOT/inbox/pvfail.md"
DEVIN_BIN="$stub" "$AQ" run --once >/dev/null 2>&1
if ls "$AGENT_QUEUE_ROOT"/failed/pvfail.md >/dev/null 2>&1 \
&& [ "$(metaval "$AGENT_QUEUE_ROOT/.state/pvfail.meta" result)" = "verify_failed" ]; then
pass "profile inherit: default-verify=false → failed/ (verify_failed)"
else
fail "profile verify=false did not route to failed (result=$(metaval "$AGENT_QUEUE_ROOT/.state/pvfail.meta" result))"
fi
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'profile: vpass' '---' '' '# pv-pass' \
> "$AGENT_QUEUE_ROOT/inbox/pvpass.md"
DEVIN_BIN="$stub" "$AQ" run --once >/dev/null 2>&1
if ls "$AGENT_QUEUE_ROOT"/testing/pvpass.md >/dev/null 2>&1; then
pass "profile inherit: default-verify=true → testing/"
else
fail "profile verify=true did not reach testing/"
fi
# 19b. job-level verify overrides the profile (precedence job > profile).
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'profile: vfail' 'verify: true' '---' '' '# override' \
> "$AGENT_QUEUE_ROOT/inbox/pvoverride.md"
DEVIN_BIN="$stub" "$AQ" run --once >/dev/null 2>&1
ls "$AGENT_QUEUE_ROOT"/testing/pvoverride.md >/dev/null 2>&1 \
&& pass "profile precedence: job verify overrides profile default-verify" \
|| fail "job-level verify did not override profile"
# 20. persona injection (golden): the body fed to the engine begins with the
# profile persona. A stub copies its --prompt-file to a sentinel.
export AGENT_QUEUE_ROOT="$tmp/queue-persona"
sentinel="$tmp/persona-body.txt"; rm -f "$sentinel"
copystub="$tmp/copy-engine"
cat > "$copystub" <<STUBEOF
#!/usr/bin/env bash
pf=""
while [ \$# -gt 0 ]; do case "\$1" in --prompt-file) pf="\$2"; shift 2;; *) shift;; esac; done
[ -n "\$pf" ] && cp "\$pf" "$sentinel"
exit 0
STUBEOF
chmod +x "$copystub"
"$AQ" init >/dev/null
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'profile: personap' '---' '' 'TASK-BODY-LINE' \
> "$AGENT_QUEUE_ROOT/inbox/personajob.md"
DEVIN_BIN="$copystub" "$AQ" run --once >/dev/null 2>&1
if [ "$(head -1 "$sentinel" 2>/dev/null)" = "PERSONA-MARKER-XYZ" ] \
&& grep -q 'TASK-BODY-LINE' "$sentinel" 2>/dev/null; then
pass "persona injection: engine body begins with profile persona, task preserved"
else
echo "body head: $(head -3 "$sentinel" 2>/dev/null)" >&2
fail "persona was not prepended to the engine body"
fi
# 21. profile capability inheritance: a job omitting capabilities inherits the
# profile's → unmet → failed/ capability_mismatch, agent never launched.
export AGENT_QUEUE_ROOT="$tmp/queue-pcaps"
launchflag="$tmp/pcaps-launched"; rm -f "$launchflag"
launchstub3="$tmp/cap-launch3"
printf '#!/usr/bin/env bash\ntouch %q\nexit 0\n' "$launchflag" > "$launchstub3"; chmod +x "$launchstub3"
"$AQ" init >/dev/null
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'profile: capreq' '---' '' '# pcaps' \
> "$AGENT_QUEUE_ROOT/inbox/pcapsjob.md"
DEVIN_BIN="$launchstub3" "$AQ" run --once >/dev/null 2>&1
if ls "$AGENT_QUEUE_ROOT"/failed/pcapsjob.md >/dev/null 2>&1 \
&& [ "$(metaval "$AGENT_QUEUE_ROOT/.state/pcapsjob.meta" result)" = "capability_mismatch" ] \
&& [ ! -e "$launchflag" ]; then
pass "profile caps inheritance: unmet inherited capability → capability_mismatch (no launch)"
else
fail "profile caps inheritance failed (result=$(metaval "$AGENT_QUEUE_ROOT/.state/pcapsjob.meta" result) launched=$([ -e "$launchflag" ] && echo yes || echo no))"
fi
# 22. allowed-scope warn-only: an out-of-scope change logs a WARNING and the job
# still succeeds; plus a direct path_in_scope unit check.
export AGENT_QUEUE_ROOT="$tmp/queue-scope"
repos="$tmp/repo-scope"; mkrepo "$repos"
scopestub="$tmp/scope-engine"
printf '#!/usr/bin/env bash\nmkdir -p frontend && echo changed > frontend/out.txt\nexit 0\n' > "$scopestub"
chmod +x "$scopestub"
"$AQ" init >/dev/null
printf '%s\n' '---' 'engine: devin' "cwd: $repos" 'yolo: true' 'profile: scoped' '---' '' '# scope task' \
> "$AGENT_QUEUE_ROOT/inbox/scopejob.md"
DEVIN_BIN="$scopestub" "$AQ" run --once >/dev/null 2>&1
if grep -q 'allowed-scope violation' "$AGENT_QUEUE_ROOT/logs/scopejob.log" 2>/dev/null \
&& ls "$AGENT_QUEUE_ROOT"/review/scopejob.md >/dev/null 2>&1; then
pass "allowed-scope: out-of-scope change WARNS (warn-only) and job still succeeds"
else
fail "allowed-scope warn-only did not warn / job did not succeed"
fi
if bash -c 'set -uo pipefail; source "'"$funcs"'"; path_in_scope "backend/a/b.ts" "backend/**" && ! path_in_scope "frontend/x.ts" "backend/**"'; then
pass "allowed-scope: path_in_scope matches subtree, rejects outside (unit)"
else
fail "path_in_scope unit logic wrong"
fi
# 23. deps block→run: B deps:[keyA] stays blocked until A is shipped/, then runs.
export AGENT_QUEUE_ROOT="$tmp/queue-deps"
"$AQ" init >/dev/null
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'idempotency-key: keyA' '---' '' '# A' \
> "$AGENT_QUEUE_ROOT/inbox/jobA.md"
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'idempotency-key: keyB' 'deps: [keyA]' '---' '' '# B' \
> "$AGENT_QUEUE_ROOT/inbox/jobB.md"
DEVIN_BIN="$stub" "$AQ" run --once >/dev/null 2>&1
if ls "$AGENT_QUEUE_ROOT"/inbox/jobB.md >/dev/null 2>&1 && ls "$AGENT_QUEUE_ROOT"/review/jobA.md >/dev/null 2>&1; then
pass "deps: B stays blocked in inbox while A is unshipped"
else
fail "deps: B should be blocked while A unshipped (A=$(ls "$AGENT_QUEUE_ROOT"/review 2>/dev/null) B-in-inbox=$(ls "$AGENT_QUEUE_ROOT"/inbox 2>/dev/null))"
fi
# status surfaces the blocked job
"$AQ" status 2>/dev/null | grep -q 'blocked (waiting on: keyA)' \
&& pass "deps: status surfaces 'blocked (waiting on: keyA)'" \
|| fail "deps: status did not surface blocked job"
# ship A (review -> testing -> shipped), then B becomes runnable
"$AQ" promote jobA >/dev/null 2>&1 # review -> testing
"$AQ" promote jobA >/dev/null 2>&1 # testing -> shipped
DEVIN_BIN="$stub" "$AQ" run --once >/dev/null 2>&1
if ls "$AGENT_QUEUE_ROOT"/review/jobB.md >/dev/null 2>&1; then
pass "deps: once A is shipped, B unblocks and completes"
else
fail "deps: B did not run after A shipped"
fi
# 24. deps-mode soft: dep satisfied when the dependency is in testing/.
export AGENT_QUEUE_ROOT="$tmp/queue-depsoft"
"$AQ" init >/dev/null
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'idempotency-key: keyA' 'verify: true' '---' '' '# A-soft' \
> "$AGENT_QUEUE_ROOT/inbox/sjobA.md"
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'idempotency-key: keyC' 'deps: [keyA]' 'deps-mode: soft' '---' '' '# C-soft' \
> "$AGENT_QUEUE_ROOT/inbox/sjobC.md"
DEVIN_BIN="$stub" "$AQ" run --once >/dev/null 2>&1
if ls "$AGENT_QUEUE_ROOT"/testing/sjobA.md >/dev/null 2>&1 && ls "$AGENT_QUEUE_ROOT"/review/sjobC.md >/dev/null 2>&1; then
pass "deps-mode soft: dep satisfied while dependency is in testing/"
else
fail "deps-mode soft did not unblock from testing/ (A=$(ls "$AGENT_QUEUE_ROOT"/testing 2>/dev/null) C=$(ls "$AGENT_QUEUE_ROOT"/review 2>/dev/null))"
fi
# 25. cycle detection: adding A deps:[keyB] while B deps:[keyA] exists is rejected.
export AGENT_QUEUE_ROOT="$tmp/queue-cycle"
"$AQ" init >/dev/null
cycB="$tmp/cyc-b.md"
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'idempotency-key: keyB' 'deps: [keyA]' '---' '' '# cyc B' > "$cycB"
"$AQ" add "$cycB" >/dev/null 2>&1 # B added (blocked on keyA — allowed)
cycA="$tmp/cyc-a.md"
printf '%s\n' '---' 'engine: devin' "cwd: $work" 'yolo: true' 'idempotency-key: keyA' 'deps: [keyB]' '---' '' '# cyc A' > "$cycA"
if DEVIN_BIN="$stub" "$AQ" add "$cycA" >/dev/null 2>&1; then
fail "cycle detection: adding A deps:[keyB] while B deps:[keyA] should be rejected"
else
pass "cycle detection: dependency cycle on add is rejected"
fi
unset AGENT_QUEUE_PROFILES
echo "self-test PASS"