| name | implement-ticket |
| description | Execute an existing tracker ticket through DinoStack planning, isolated implementation, adversarial review, QA, PR, merge, and wrap gates. |
Codex resource resolution
Before executing this skill, resolve the physical directory containing this loaded SKILL.md
(follow the installed skill-directory symlink) and bind it as AE_SKILL_ROOT. Set
AE_CORE_SKILL_ROOT to resources beneath that physical root and validate its
.dinostack-skill.json marker has magic=DINOSTACK_CODEX_SKILL, adapter=codex,
name=agentic-engineering, and schema_version=1. Resolve every logical resource through
the adjacent RESOURCE-MAP.json; reject missing, escaping, symlink-loop, or wrong-type targets.
Derive AE_REPO_DIR from the validated core marker plus its mapped bin resource and require the
repository signature (content/SKILL.md, .codex, and the dispatch helper); never fall back to
the process working directory. Bind AE_PROJECT_DIR to the absolute invoked project root before
changing directories (git rev-parse --show-toplevel when inside a repository, otherwise the
verified invocation directory). Project .claude/**, .agentic/**, .gitignore, QA, settings,
compression, and migration state resolve only beneath AE_PROJECT_DIR, never beneath
AE_REPO_DIR. Evaluate
$AE_REPO_DIR/bin/agentic-codex-dispatch runtime-bindings "<absolute-invocation-directory>"
before any operational step. Require its AE_REPO_DIR and AE_PROJECT_DIR values to match the
independently validated paths above, then consume the same JSON object to bind
AE_CODEX_CONFIG_DIR, AE_SHARED_CONFIG_DIR, and AE_ACTIVATION_CONFIG; fail closed on any
mismatch. Map canonical filesystem tools to Codex filesystem reads, rg --files, rg, shell, and
apply_patch; ask one bounded direct question only after default derivation.
Derive AE_SESSION_ID by passing hook JSON to
$AE_REPO_DIR/bin/agentic-codex-session-id. Native workflows are invoked with $ syntax.
Other DinoStack workflows remain manual command resources loaded with
$AE_REPO_DIR/bin/agentic-codex-dispatch command <name>; do not claim bare slash registration.
Codex spawn_agent accepts only task_name, message, and fork_turns. Put Tier and model intent
in the task brief or resolve it through role routing before the spawn; never pass Claude-only spawn
fields. When isolation is required, the conductor creates the worktree manually before spawning.
Codex spawn contract. Delegate with spawn_agent only. Before any spawn that needs an
isolated checkout, run the following from the invoked project root ($AE_PROJECT_DIR):
git fetch origin.
- Resolve
BASE_BRANCH with
$AE_REPO_DIR/bin/agentic-codex-dispatch base-branch "$AE_PROJECT_DIR". This applies the
canonical precedence: exactly one dedicated unfenced whole-line BASE_BRANCH: declaration in
project AGENTS.md (with an optional Markdown list prefix and optional Declaration: prefix),
then local develop, then local development. Multiple matching declarations are rejected as
ambiguous. If none exists, the helper fails closed; ask the operator whether to use main
(recommended, falling back to master) or establish a develop-based workflow, exactly as
required by the base-branch resolution protocol.
- Choose a unique branch and absolute worktree path beneath
$AE_PROJECT_DIR/.agentic/worktrees/.
- Run
git worktree add "$AE_PROJECT_DIR/.agentic/worktrees/<branch>" -b "<branch>" "origin/$BASE_BRANCH".
- Load the named role instructions with
$AE_REPO_DIR/bin/agentic-codex-dispatch agent <role>.
- Call
spawn_agent with supported inputs (task_name, message, and fork_turns). Begin the
message with Work only in the pre-created worktree <absolute-path> and include the loaded role
instructions plus the execution contract. The spawned agent must use shell commands in that
worktree and must not edit the conductor checkout.
Codex spawns are asynchronous. The conductor remains responsive, uses the collaboration status and
wait operations to collect completion, and applies the existing review gates to the returned diff.
Claude hook payload fields and Claude Task behavior do not apply on Codex.
Implement Ticket
Run the Activation preflight from $AE_CORE_SKILL_ROOT/METHODOLOGY.md before proceeding. If inactive, no-op and exit.
Context-size preflight (run immediately after Activation, before any other step): Assess the current session's context load against the soft and hard limits defined in $AE_REPO_DIR/content/references/subagent-protocol.md Section 13.
Hard limit check (Section 13.2) - checked first: If the session has reached the hard limit, Section 13.2 governs absolutely - there is no yes/proceed override at or above the hard limit. Do the following in this order: (1) print the hard-limit block below verbatim, (2) invoke $wrap automatically to preserve state via context.md and MEMORY.md updates (or instruct the operator to run $wrap if auto-invoke is unavailable in the current harness), (3) exit - refusing further implementation work, Skeptic rounds, and subagent spawns for the remainder of this session. Do not print the soft-limit warning block below or the "Proceed anyway?" prompt.
Hard-limit block (print verbatim - this is a plain print, not an one bounded direct question after default derivation tool call, and does not wait for operator confirmation):
Context-size hard limit reached: this session has reached the conductor
context hard limit (Section 13.2 of the Subagent Protocol). The hard
limit is absolute - there is no override, and further implementation
work, Skeptic rounds, and subagent spawns are refused for the rest of
this session.
Why: the hard limit exists to protect output quality. A conductor
operating past this point risks missing details from earlier turns,
re-introducing bugs already fixed, and producing stale crash-recovery
state. A fresh session is required to continue - this is not optional.
Next steps:
1. $wrap - save session state and generate a hand-off summary
2. Start a new session (on Claude Code, /clear also works)
3. $implement-ticket <your input> - in the fresh session
Danger signals below the hard limit (any one triggers the soft-limit warning, per Section 13.1):
- Session turn count at or above the soft limit with substantive tool-call results still in context.
- Any prior subagent result block, of substantive size, is visible and was produced in this same session before
$implement-ticket was invoked.
If a danger signal is detected below the hard limit, print verbatim (this is a plain print-and-wait for the operator's next message, not an one bounded direct question after default derivation tool call):
Context-size warning: your current session carries significant prior context
(a long turn history and/or one or more prior subagent result blocks still
visible). Running $implement-ticket now risks exhausting your token budget
before the architect-plan-review phase completes.
Recommended safe pattern:
1. $wrap - save session state and generate a hand-off summary
2. Start a new session (on Claude Code, /clear also works)
3. $implement-ticket <your input> - in the fresh session
Proceed anyway? (yes / no)
On no: exit immediately. On yes: continue with a one-line note: Context-size warning acknowledged - proceeding in large session. This yes override is valid only below the hard limit - it never applies once the hard limit is reached (see Hard limit check above).
If no danger signals are present: continue silently (no output).
Take a ticket (Linear, Jira, or none) from description to merged PR, with full agent orchestration (Architect → Orchestration Planner (conditional) → Engineer → Skeptic) and the CI Test URL posted back to the ticket.
Invocation
$implement-ticket <input>
<input> accepts any of:
- A single ticket ID:
DINO-639
- A comma- or space-separated list:
DINO-639, DINO-638 or DINO-639 DINO-638
- A tracker issue URL: Jira
/browse/DINO-639, Linear /issue/ENG-42/...
- A tracker search/filter URL: Jira
/issues?jql=..., Linear filter URL
- A pasted screenshot of a tracker board, column, or issue list
- A freeform description (no tracker reference)
- Any mixture of the above
- Any project-local extension classifier defined in
$AE_PROJECT_DIR/.agentic/phase0-classifiers.yml
Phase 0 normalizes the input into a canonical ordered list of ticket entries before any other phase runs. Bare-ID, single-issue-URL, and operator-enumerated list invocations bypass the confirmation prompt — backward compatible with the prior single-argument contract.
Open-goal / wallclock-cap parameters (optional, trailing key=value tokens). <input> may be followed by any of goal_mode=open_goal, goal_condition="<string>", max_iterations=<int>, max_wallclock_min=<int>, dry_run=true. Extracted by a trailing-token scan (\w+=(\S+|"[^"]*")) BEFORE Phase 0 classification runs; matched tokens are stripped so ticket-reference classification is unaffected. dry_run is meaningful ONLY when goal_mode=open_goal is also present - on any other invocation shape it is parsed and explicitly ignored (no effect, no error).
Conductor responsibilities (irreducible)
The conductor delegates implementation work aggressively to specialist subagents but retains a fixed set of responsibilities that are never delegated. This section enumerates at minimum:
- Risk classification. Must precede any spawn (per $AE_CORE_SKILL_ROOT/METHODOLOGY.md §Risk Classification).
- Promotion-gate check + Brief/Plan authoring. Comprehension artifacts that the conductor must produce itself (per $AE_CORE_SKILL_ROOT/METHODOLOGY.md §Planning Artifacts).
- Stop-and-ask decisions. The user-facing surface; subagents do not interact with the user.
- All
$AE_PROJECT_DIR/.agentic/*.json[l] writes. Sole-writer rule (across agents - not across sessions; tasks.jsonl is safe across concurrent conductor sessions via the append-only write contract and the task-state fold, see $AE_REPO_DIR/content/references/task-state-file.md) for the per-ticket keyed loop-state-$LOOP_KEY.json (and the legacy unkeyed loop-state.json it supersedes), tasks.jsonl, and any other state file under $AE_PROJECT_DIR/.agentic/.
- Re-route limit + convergence-failure tracking. Conductor must hold the full loop history across iterations.
- Status updates and breadcrumbs to user. All
[phase: ...] and [loop: ...] emissions originate from the conductor.
- Dispatch logic. Which agent, when, with what brief.
- Summary synthesis for downstream spawn briefs. PR body, tracker comment, findings input - the conductor extracts and reformats subagent outputs for downstream consumers.
BASE_BRANCH resolution and AGENTS.md config parsing. Setup phase work.
gh pr create in Phase 9. PR opener stays in the conductor; the synthesis output is consumed inline by the next phase and does not benefit from isolation in a separate spawn.
- CI Test URL polling in Phase 10.
- Branch/worktree creation on the Phase 5 parallel fan-out path. The Elevated single-engineer path AND the Trivial single-engineer path both delegate branch/worktree creation to the (worktree-isolated) engineer (see Phase 4). Only fan-out worktree creation remains conductor-orchestrated.
This list is not exhaustive — any operation listed elsewhere as conductor-direct is also irreducible.
Batch state contracts (binding)
These contracts govern every conductor write to $AE_PROJECT_DIR/.agentic/batch-state.json and $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json. Phases that write to either file (Phase 0a, Phase 0a-pre, Phase 6/6b, Phase 7, Phase 10, Phase 10a, Phase 12, Phase 12a) MUST apply the contracts below.
Contract A — Per-write session_id gate (applies to BOTH batch-state.json and the keyed loop-state-$LOOP_KEY.json).
Before every conductor write to either file:
-
Read the current on-disk file (if present).
-
If the file exists, its session_id field is a non-empty string AND does not match the current session, AND its liveness-timestamp field (last_updated for loop-state-$LOOP_KEY.json, updated_at for batch-state.json - same per-file mapping as step 4 below) is within the last 10 minutes: ABORT the write - EXCEPT that on batch-state.json ONLY, this condition additionally requires status to be active; a non-active batch-state.json (interrupted, paused, complete, or stalled) never aborts a write under this step. This per-file asymmetry is deliberate, not an oversight:
batch-state.json carries the status=active precondition because markInterrupted stamps updated_at=now on it at session exit (touchTimestampOnTerminal: true in $AE_REPO_DIR/hooks/lib/state-mark.js), so a dead session's file looks fresh for a full 10 minutes; and every one of its non-active states follows a path that exits the session - e.g. the pause path says Exit cleanly. Do NOT advance to the next ticket. Without this precondition, the first write of an approved resume of an interrupted or paused batch would abort against the dead session's still-fresh terminal-mark timestamp, with no live session left to kill.
loop-state-$LOOP_KEY.json does NOT carry the precondition, for two reasons: (a) it is already shielded from the stale-freshness hazard by touchTimestampOnTerminal: false (see "Why the keyed loop-state file's last_updated is never touched by the terminal mark" below - the two facts are linked); and (b) a live session CAN hold a non-active keyed loop-state file - the Phase 7 stall path sets status=stalled and then routes through "Batch-mode escalation routing (mark-blocked-and-continue)", so the conductor stays alive and continues to the next ticket rather than exiting. Adding the precondition here would let a foreign session's write clobber a live session's loop-state-$LOOP_KEY.json in that window. Per-ticket keying does not make this gate redundant: it removes DIFFERENT-ticket contention (those sessions now write different files), but two sessions on the SAME ticket derive the same LOOP_KEY and therefore still meet here, which is exactly the case the gate must keep catching.
Print the verbatim warning, substituting <field> with the file's own liveness-timestamp field name:
WARNING: write to $AE_PROJECT_DIR/.agentic/<file> aborted - another session (session_id=<X>, <field>=<Y>) appears to own this file. Identify the live session via $AE_PROJECT_DIR/.agentic/*.json's liveness-timestamp field. Resolve manually (kill the other session, or remove the file) and retry.
-
If the file exists and its session_id is null/missing/empty (legacy state from a prior version): treat as mismatch — force-takeover-eligible. Operator may resolve via the Phase 0a-pre force-takeover prompt or by manually removing the file. The same WARNING above is printed.
Self-ownership carve-out. Step 3 applies only when the CURRENT session has a non-empty session_id of its own. When the current session's session_id is itself null - a harness that has declared it cannot produce an id in the same namespace as its session-exit hook payload - a null/missing/empty session_id on disk is self-owned: proceed with the write, print nothing, and leave the field null. Rationale: two nulls on such a harness are indistinguishable by construction, so treating them as a mismatch produces a guaranteed false warning on every transition rather than catching a real collision.
-
Otherwise (no file, matching session_id, or stale > 10 min): proceed with the write. Set session_id to the current session's id and update the file's liveness-timestamp field in the new payload - the two files use different field names for this same timestamp: last_updated for loop-state-$LOOP_KEY.json, updated_at for batch-state.json.
Both readers and writers tolerate absence of session_id for back-compat with state files written by prior versions; absence is treated as mismatch for write-gating - unless the current session's own session_id is also null, per the self-ownership carve-out above - but not for read-only resume prompts (those follow the Phase 0a-pre decision table). This is now 5-way logic once the self-ownership carve-out is counted alongside steps 1-4 (see the two prose sites below that still say "4-way" - corrected in this pass).
The hook-side writes ($AE_REPO_DIR/hooks/lib/state-mark.js, used by $AE_REPO_DIR/hooks/stop-context.js and $AE_REPO_DIR/hooks/session-end-wrap.js) apply an intentionally ASYMMETRIC ownership predicate that differs from this conductor-side Contract A: the per-turn liveness refresh requires a POSITIVE session_id match (absent/null/empty/differing all skip), while the terminal interrupted-mark proceeds unless the on-disk session_id is a POSITIVELY differing non-empty string (absent/null/empty proceeds). See $AE_REPO_DIR/hooks/lib/state-mark.js's module manifest for the full rationale - a false-positive liveness refresh on an unowned file is much worse than a redundant true interrupted-mark.
Contract B — replan_log[] read-merge-write preservation (applies to batch-state.json).
Every conductor write to batch-state.json MUST:
- Read the current on-disk file first.
- Take the existing
replan_log[] from disk and merge any new entries authored in-memory by the current conductor turn (append-only; never reorder; never drop entries).
- Write the merged array back along with the rest of the payload.
This preserves the audit log across overlapping writes and across resume migrations.
Contract C — One batch per project root.
When Phase 0a is initializing a new batch-state.json (invocation where Phase 0 produced ≥ 2 entries, OR Phase 0a-open-goal is performing a Fresh init (goal_mode=open_goal), OR the Phase 0a-pre single-ticket-capped carve-out is performing its create (max_wallclock_min alone, no goal_mode)) and the file already exists with status=active, a different session_id, and updated_at within the last 10 minutes: REFUSE the new batch with the verbatim message:
Another batch session is active for this project root (session_id=<X>, updated_at=<Y>). Wait for it to finish, kill it and re-invoke, or remove $AE_PROJECT_DIR/.agentic/batch-state.json and re-invoke.
Concurrent batches per project root are not supported. Operators wanting parallel batches use separate worktrees with separate $AE_PROJECT_DIR/.agentic/.
N=1 foreign-batch warning. If Phase 0 produced exactly 1 entry (single-ticket) AND $AE_PROJECT_DIR/.agentic/batch-state.json exists with status=active + different session_id + updated_at within the last 10 minutes: print the verbatim warning:
NOTE: a batch session is active for this project root (session_id=<X>, updated_at=<Y>). Single-ticket invocations are not refused; per-ticket loop state is keyed separately so bookkeeping will not collide. Identify the live session via $AE_PROJECT_DIR/.agentic/batch-state.json updated_at. Continue? (yes/no)
On no: abort. On yes: proceed with the single-ticket flow. This is the only single-entry interaction with batch-state.json.
Contract D — Stop hook / SessionEnd hook mirror.
The Claude Code Stop hook ($AE_REPO_DIR/hooks/stop-context.js) fires once per TURN, not once per session. It no longer marks the loop-state / batch-state.json files interrupted on every turn. Instead it is wired with --cadence=turn, which dispatches to $AE_REPO_DIR/hooks/lib/state-mark.js's refreshLiveness(cwd, sessionId): a per-turn liveness-only touch that updates last_updated (every loop-state candidate) or updated_at (batch-state.json) ONLY when the file is status=active AND its on-disk session_id is a non-empty string EQUAL to the current session's id (POSITIVE match required - absent/null/empty/differing all skip). It never sets status=interrupted.
The terminal interrupted-mark now lives on $AE_REPO_DIR/hooks/session-end-wrap.js (the SessionEnd hook, which fires once per session). On a terminal reason (clear, logout, prompt_input_exit, bypass_permissions_disabled, other - resume is excluded), it calls $AE_REPO_DIR/hooks/lib/state-mark.js's markInterrupted(cwd, sessionId), which mirrors the ownership check across every candidate file: if the file's session_id is a non-empty string and does not match the current session's uuid, the write is aborted silently (the hook does not steal another session's state); absent/null/empty session_id on disk PROCEEDS (opposite polarity from refreshLiveness - see $AE_REPO_DIR/hooks/lib/state-mark.js's module manifest for why). Best-effort silent-fail throughout, gated per file. The mirror sets status=interrupted, interrupted_at=now, interrupt_reason="unknown" on every candidate, plus updated_at=now on batch-state.json only ($AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json's last_updated is deliberately NOT touched by the terminal mark - see below); all other fields including last_updated_phase, tickets[], and replan_log[] are preserved.
Candidate set (hook side). The hooks derive no LOOP_KEY and hardcode no path. $AE_REPO_DIR/hooks/lib/state-mark.js's candidatePaths(cwd) resolves the set at call time: every per-ticket keyed $AE_PROJECT_DIR/.agentic/loop-state-<LOOP_KEY>.json present on disk (newest-mtime-first, capped at 100), plus $AE_PROJECT_DIR/.agentic/batch-state.json and the legacy $AE_PROJECT_DIR/.agentic/loop-state.json, which are always included even when $AE_PROJECT_DIR/.agentic/ is unreadable. Selection among them is by the per-file session_id predicates above, never by key derivation. The keyed rows inherit tsField, healthTarget and touchTimestampOnTerminal verbatim from the legacy loop-state row, so a keyed file has no semantics of its own and cannot drift from the legacy file's.
--cadence=session (or an absent/unrecognized flag) is $AE_REPO_DIR/hooks/stop-context.js's fallback dispatch, preserved so callers that invoke the script directly without the flag (e.g. Pi's session_shutdown) keep their pre-existing once-per-invocation interrupted-mark behavior.
Why $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json's last_updated is never touched by the terminal mark: the Resume check above branches on last_updated staleness with no status exemption. Writing last_updated on the terminal interrupted-mark would make a freshly-interrupted loop look "recently live" to a resuming session for the full 10-minute staleness window, offering a resume it cannot actually execute. interrupted_at already timestamps the terminal event, which is sufficient.
$AE_PROJECT_DIR/.agentic/batch-state.json schema
{
"schema_version": 1,
"session_id": "<current session uuid or null>",
"batch_id": "<first-ticket-prefix>-batch-<ISO8601>-<4hex>",
"status": "active",
"mode": "batch",
"created_at": "<ISO8601>",
"updated_at": "<ISO8601>",
"last_updated_phase": "<phase label>",
"interrupted_at": null,
"interrupt_reason": null,
"paused_at": null,
"pause_reason": null,
"wallclock_cap_min": 90,
"wallclock_started_at": "<ISO8601>",
"tickets": [
{
"ticket_id": "ABC-123",
"status": "pending",
"cluster_id": "<planner cluster id>",
"depends_on": ["ABC-122"],
"started_at": null,
"ended_at": null,
"branch": null,
"pr_number": null,
"last_summary": null
}
],
"replan_log": [],
"resume_invocation_hint": "$implement-ticket",
"open_goal": {
"active": false,
"goal_condition": null,
"iteration": 1,
"max_iterations": null,
"risk_declared": null,
"termination_reason": null,
"dry_run": false
}
}
Field semantics:
schema_version: integer; current is 1.
session_id: uuid of the conductor session that last wrote the file; null only on legacy files written by a prior version.
updated_at: the file's liveness-timestamp field - loop-state-$LOOP_KEY.json's equivalent field is named last_updated (see that schema's Field notes below and $AE_REPO_DIR/content/references/cross-session-loop-resume.md); the two files intentionally use different field names for the same concept. Written on every conductor write per Contract A step 4, and refreshed per-turn by the Claude Code Stop hook's $AE_REPO_DIR/hooks/lib/state-mark.js refreshLiveness per Contract D - off Claude Code, updated_at advances only at conductor ticket-transition writes, which are routinely more than 10 minutes apart mid-ticket; this makes every staleness gate below fail-open (weaker, never a false-abort) rather than misfire, but it does mean the field is a coarser liveness signal off Claude Code than the per-turn refresh implies. Every staleness gate that reads this field (Contract A step 2, Contract C, the N=1 foreign-batch warning, the Phase 0a-pre decision table, and the Phase 0a-open-goal resume classification) treats an ABSENT updated_at as stale - i.e. the gate does NOT fire, matching the pre-fix effective behavior - rather than as fresh. This is deliberate back-compat tolerance for files written before this field existed; it is not a migration, and no on-read rewrite occurs.
batch_id: stable identifier for the batch. Format <prefix>-batch-<ISO8601>-<4hex> where <prefix> is the first ticket's TICKET_PREFIX (used when tickets span multiple prefixes; the first ticket wins).
status: enum active | paused | interrupted | complete | stalled.
mode: enum "batch" | "open_goal" | "single_ticket_capped". Absent = "batch" (100% back-compat). "open_goal" is set by Phase 0a-open-goal's Fresh init; "single_ticket_capped" is set by the Phase 0a-pre single-ticket wallclock carve-out - the ONLY N=1 path that creates batch-state.json.
interrupt_reason: enum unknown | null — only unknown is a writable value (other values reserved for future writers; the terminal-mark writer - the SessionEnd hook's markInterrupted, not the per-turn Stop hook - cannot distinguish rate-limit vs crash at hook time).
pause_reason: enum stale_pace | operator_pause | wallclock_cap | open_goal_iteration_cap | null - these four values match the four Phase 12a triggers. NOTE: paused_stale_pace / paused_operator_request / cap_reached_wallclock / cap_reached_iterations / goal_met / blocked are open_goal.termination_reason values, NOT pause_reason values - deliberately avoiding a dual-enum collision. Only open_goal_iteration_cap was added to pause_reason; triggers 1-3 keep their existing pause_reason values stale_pace / operator_pause / wallclock_cap.
wallclock_started_at: set once at Phase 0a init; preserved across resume. The wallclock cap is per-batch lifetime, not per-session.
wallclock_cap_min: integer minutes. Default 90. Overridable via env AGENTIC_BATCH_MAX_WALLCLOCK_MIN.
tickets[]: triage-derived executable cursor; contains only lane-assigned tickets (deferred and in-progress-excluded tickets are not included). status per-ticket is pending | in_progress | complete | blocked | skipped_already_merged.
replan_log[]: append-only audit log. Each entry: {ts, action, ticket_id, detail}. Actions include drop_merged, investigator_rerun, re_sequence. Preserved by Contract B.
open_goal: present (meaningfully populated) only when mode == "open_goal". active: boolean, whether an open-goal loop is currently running. goal_condition: operator-declared condition string, set once at Fresh init, read-only thereafter (divergent re-invocation values are warned and ignored - on-disk governs). iteration: current iteration number; invariant iteration == len(tickets[]) from the first synthetic entry onward (SOLE momentary exception: immediately after Fresh init, iteration=1 but tickets=[] - closed the moment Phase 1 of iteration 1 appends its entry). max_iterations: operator-declared cap, mandatory at Fresh init, no default. risk_declared: this iteration's risk classification (low | elevated | trivial), written at Phase 6 clean exit - the DURABLE audit record (see $AE_REPO_DIR/content/references/trigger-catalog.md §Risk and review discipline (c)). termination_reason: enum null | cap_reached_iterations | cap_reached_wallclock | goal_met | blocked | paused_stale_pace | paused_operator_request - partitions TERMINAL (cap_reached_iterations, cap_reached_wallclock, goal_met, blocked) from RESUMABLE (paused_stale_pace, paused_operator_request, or null with status in {paused, interrupted}). dry_run: boolean, set once at Fresh init, never changes mid-loop; when true, Phases 6b/8-11b are skipped for every iteration (no QA, no ship, no PR) - see Phase 6b and Phase 8 dry-run gates.
Resume check (before setup)
Loop-key derivation (runs first, exactly once per ticket)
Loop state is stored per ticket, at $AE_PROJECT_DIR/.agentic/loop-state-<LOOP_KEY>.json, so two sessions working two different tickets in one checkout never contend for one file. Derive LOOP_KEY once - here at the Resume check, or at Phase 6 loop init if that comes first - record it in the file's own loop_key field, and carry it in-context for the rest of that ticket. Never re-derive it, and never derive it from $BRANCH_NAME (the workflow deletes the branch after merge, so a later rework derives the same slug and two attempts look like one).
Inputs are the invoked TICKET_ID (may be absent on a batch or open-goal invocation) and the current session's SESSION_ID (may be absent, or the 4-char string null on a harness whose session_id is a JSON null read through jq -r).
ae_sanitize() {
printf '%s' "${1-}" \
| LC_ALL=C tr -c 'A-Za-z0-9._-' '-' \
| LC_ALL=C sed -e 's/--*/-/g' \
| LC_ALL=C sed -e 's/^[-.]*//' -e 's/[-.]*$//' \
| cut -c1-64 \
| LC_ALL=C sed -e 's/[-.]*$//'
}
ae_derive_loop_key() {
local raw_ticket="${1-}" raw_sid="${2-}" core key=""
core="$(ae_sanitize "$raw_ticket")"
[ -n "$core" ] && key="$core"
if [ -z "$key" ] && [ -n "$raw_sid" ] && [ "$raw_sid" != "null" ]; then
core="$(ae_sanitize "$raw_sid")"
[ -n "$core" ] && key="session-$core"
fi
if [ -n "$key" ]; then
key="$(printf '%s' "$key" | cut -c1-64 | LC_ALL=C sed -e 's/[-.]*$//')"
fi
if [ -z "$key" ]; then
key="session-nosid-$(od -An -tx1 -N4 /dev/urandom | tr -d ' \n')"
fi
printf '%s' "$key"
}
LOOP_STATE_FILE is $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json and its staging path is $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json.tmp - a flat sibling, not a subdirectory, so .tmp files stay direct children of $AE_PROJECT_DIR/.agentic/. Before every write, assert the assembled path's dirname resolves to $AE_PROJECT_DIR/.agentic/.
Traversal impossibility has three independent guards. Do not remove one thinking it redundant: (a) ae_sanitize maps / to -, so no key contains a path separator; (b) the key is always wrapped in the fixed affixes loop-state- and .json, so it can never be a path component like .. - note that .. does survive sanitization, because . is in the safe set (feature/../../etc/passwd yields feature-..-..-etc-passwd), and traversal safety never depended on stripping it; (c) the dirname assertion above.
Sanitize-to-empty notice (emitted HERE, by the caller - never from inside ae_derive_loop_key). When TICKET_ID was non-empty but ae_sanitize "$TICKET_ID" returned empty (e.g. ###, or ..), so the key fell through to the session or nosid branch, print exactly one line:
Ticket id '<raw>' sanitizes to empty; loop state keyed on session instead (<LOOP_KEY>).
Emit it after the key is derived and only under that condition. It must not live inside ae_derive_loop_key: that function's only output is the key via printf, so a diagnostic there corrupts every caller and every assertion in $AE_REPO_DIR/bin/tests/test_loop_key_derivation.sh.
LOOP_KEY also governs telemetry attribution: export AGENTIC_LOOP_KEY="$LOOP_KEY" at every $AE_REPO_DIR/bin/agentic-emit call site (see Phase 6).
Candidate check
Before reading AGENTS.md or doing any setup, check for $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json. Also enumerate the other candidates - $AE_PROJECT_DIR/.agentic/loop-state-*.json and the legacy $AE_PROJECT_DIR/.agentic/loop-state.json - because they affect the informational line and the legacy-adoption path below, but the recommendation is $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json only.
Key match is the primary guard, never freshness. If the keyed file is absent or not resumable, print the informational line below and proceed to Setup with no resume prompt. There is deliberately no cross-ticket freshness fallback: a fallback on "most recent last_updated" would offer a resuming session another ticket's live file whenever that file's timestamp was over 10 minutes old - routine during a CI wait, and guaranteed on the ten harnesses with no per-turn liveness refresh - and accepting it would then destroy that ticket's findings_log.
If $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json exists and status == "interrupted":
- Print: "Interrupted loop detected on branch [branch] for ticket [ticket_id]."
- Print: "Last phase: [last_phase] / [last_phase_action], iteration [loop_state.iteration]/[loop_state.max_iterations]."
- Print: "Open findings: [count of findings_log entries with status=open or status=addressed]"
- Ask: "Resume this loop or start fresh? (resume / fresh)"
- If "fresh": delete only
$AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json - the recommended candidate. Every other candidate listed in the informational line stays byte-identical and remains resumable later. Proceed normally from Setup below.
- If "resume": apply wait strategy (see below), then jump to the resume entry point determined by
last_phase / last_phase_action per the table below. Increment resume_count on the accepted file.
If $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json exists and status == "active" with last_updated more than 10 minutes ago: treat as implicitly interrupted (the SessionEnd hook's terminal markInterrupted write may not have fired - e.g. a hard kill with no clean session end - and the Stop hook's own --cadence=turn write only refreshes last_updated liveness, never status). Print: "Found an active loop state last written [elapsed] ago — treating as interrupted." Then follow the "interrupted" path above.
If $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json exists and status == "active" with last_updated within the last 10 minutes: a live session owns it. Not resumable. No prompt.
If $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json exists and status == "complete" or "stalled":
- Print: "A completed/stalled loop state file exists for ticket [ticket_id]. Clearing it."
- Delete that keyed file only. Proceed normally.
If $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json does not exist but other resumable $AE_PROJECT_DIR/.agentic/loop-state-*.json candidates do: print no resume prompt. Print one informational line, capped, then proceed to Setup:
N other resumable loops exist: <up to 3 keys, most-recent last_updated first>[, +M more]. Re-invoke with that ticket id to resume one.
One line, recommendation-plus-confirmation, never a co-equal ballot (required by the AskUserQuestion precondition in $AE_REPO_DIR/content/sections/02-delegation.md). If those other candidates exist but none is resumable, proceed silently.
If $AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json does not exist and the legacy $AE_PROJECT_DIR/.agentic/loop-state.json does - legacy adoption. A legacy file is never silently ignored. When it is resumable (status == "interrupted", or status == "active" and stale > 10 min via the implicit-interrupt path above):
- Derive the adoption key from the legacy file's own
ticket_id; else session-<its own session_id>; else the literal legacy.
- Atomically write the legacy payload to
$AE_PROJECT_DIR/.agentic/loop-state-<that adoption key>.json, setting loop_key to the adoption key and incrementing resume_count.
rm -f $AE_PROJECT_DIR/.agentic/loop-state.json (loop-key: legacy - this is the legacy-adoption path's own cleanup of the unkeyed file, deliberately not a keyed path).
- Adopt that
loop_key as this session's key for the rest of the ticket, then follow the "interrupted" path above.
When the legacy file is active and fresh, it is not resumable: no prompt, and leave it untouched. When it is complete/stalled, print the clearing line above and delete the legacy file. When BOTH the keyed file and a legacy file are resumable, the keyed file wins - the legacy file is listed in the informational line, not adopted, because adopting older state over newer would overwrite it.
Null-ticket resume (session-* and session-nosid-* key families). These families are resumable; a synthetic key regenerated per session would otherwise match nothing and every interrupted null-ticket run would orphan a file. When TICKET_ID is absent, resolve against the resumable $AE_PROJECT_DIR/.agentic/loop-state-session-*.json candidates:
Current SESSION_ID | Resumable session-* candidates | Behavior |
|---|
| non-empty | exactly one whose loop_key == "session-" + sanitize(SESSION_ID) | recommend it (exact match, highest precedence) |
| non-empty | no exact match, exactly one session-* | recommend it, and adopt its loop_key as this session's key for the rest of the ticket |
| non-empty | no exact match, 2+ | recommend none; print the informational line; proceed to Setup |
null/empty | exactly one | recommend it; adopt its loop_key. This row preserves today's behavior on harnesses with no session-id namespace - today there is exactly one file, hence exactly one candidate |
null/empty | 2+ | recommend none; print the informational line. Not a regression: two such loops would have collided into one file before keying, so no working behavior is lost |
| any | zero | no prompt |
Adopting the candidate's own loop_key rather than re-deriving is what makes the session-nosid-* family resumable at all.
If a candidate's loop_key field is present but differs from its own filename's key (a manual operator edit, or a partial write): the filename wins for selection, and the loop_key field wins for this session's in-context key after an accepted resume. Print one warning line. Do not auto-repair.
If no candidate of any kind exists: proceed normally, no prompt, no output.
Wait strategy (applied before resuming when interrupt_reason == "rate_limit"):
elapsed = now() - interrupted_at
if interrupt_reason == "rate_limit":
if elapsed < 60 seconds:
wait_remaining = 60 - elapsed
print: "Rate limit detected. Waiting [wait_remaining]s before resuming."
sleep(wait_remaining)
else:
# session_expiry or unknown: no wait needed
print: "Loop interrupted. Resuming from last checkpoint."
Resume entry point table:
| last_phase | last_phase_action | Resume action |
|---|
| skeptic | spawned | Re-spawn Skeptic with current diff (git diff origin/$BASE_BRANCH..HEAD). On iteration 2+, include prior-iteration findings block from findings_log (same as normal iteration 2+ behavior). |
| skeptic | returned | Skeptic output was received but Engineer fix pass was not yet spawned. Re-classify findings from findings_log (entries with status=open) and spawn the Engineer fix pass. |
| engineer | spawned | Check git status --porcelain on the branch. If clean: re-spawn Engineer with same open findings brief. If dirty (uncommitted changes): ask human "The Engineer had uncommitted changes. Discard and re-run, or commit what's there and re-run Skeptic?" |
| engineer | returned | Engineer returned but loop did not advance. Use last_engineer_summary from state file. Re-enter Skeptic spawn step. |
| qa | spawned | Re-spawn QA engineer with the prior brief. |
| qa | returned | QA engineer returned but loop did not advance. Re-spawn Engineer fix pass for QA failures. |
| quality_gate | engineer_spawned | Check git status --porcelain. If clean: re-spawn Phase 7 engineer with quality gate failure output from loop_state.last_engineer_summary. If dirty: ask human (discard and re-run, or commit and re-run $QUALITY_CMD). |
| quality_gate | engineer_returned | Phase 7 engineer committed. On the Elevated path: verify the engineer's reported quality_gate_results. On the Trivial path: re-run $QUALITY_CMD. |
| quality_gate | rerun_pending | On the Elevated path: wait for the fix-engineer return and verify its quality_gate_results - do not invoke $QUALITY_CMD directly. On the Trivial path: re-run $QUALITY_CMD. |
| quality_gate | debugger_spawned | Re-spawn Debugger from scratch with the captured gate failure output (Debugger is read-only and idempotent - same pattern as "Full Skeptic re-run on interruption"). |
| quality_gate | debugger_returned | Debugger output was captured before interruption. Proceed to spawn the next engineer fix pass with the Debugger's Fix brief. No Debugger re-run needed. |
| ci_wait | timeout | Re-enter Phase 10 poll loop once (operator may have manually fixed; if still timing out, re-escalate). |
| ci_loop | fix_engineer_spawned | Re-spawn the fix engineer from the latest commit on the branch (assumes prior spawn was interrupted). Resume from cycle N. |
| ci_loop | fix_engineer_returned | Re-enter Phase 10 poll loop to check CI status. |
| ci_loop | ci_poll_pending | Re-enter Phase 10 poll loop from current iteration. |
| ci_loop | cap_exceeded | Do NOT auto-resume. Surface the prior escalation summary and require human direction. |
After resuming: always run git -C $REPO diff origin/$BASE_BRANCH..HEAD to confirm branch state before re-spawning agents. If the diff is empty and open findings exist, the Engineer's prior work was lost (uncommitted at interruption); flag this to the human before resuming.
Parse failure: if a candidate ($AE_PROJECT_DIR/.agentic/loop-state-$LOOP_KEY.json, another $AE_PROJECT_DIR/.agentic/loop-state-*.json, or the legacy $AE_PROJECT_DIR/.agentic/loop-state.json) exists but cannot be parsed as JSON, print a warning and offer to delete that one candidate and start fresh. A parse failure demotes only the candidate that failed; every other candidate is unaffected and stays resumable. Do not silently ignore it.
Concurrent session guard. REPLACED in this version by Contract A's per-write session_id-mismatch abort gate, applied to every conductor write of the keyed loop-state-<LOOP_KEY>.json and of batch-state.json. See Phase 0a-pre and the "Batch state contracts" section above for the full contract. Every conductor write to the keyed loop-state file includes a top-level session_id: <current session> field; readers tolerate absence for back-compat with state files written by prior versions. Note that per-ticket keying means different-ticket contention no longer reaches this gate at all - the two sessions write different files. The gate still fires, as designed, when two sessions work the same ticket, because they derive the same LOOP_KEY and therefore the same file.
N=1 foreign-batch warning. Before proceeding to Phase 0a-pre on an invocation where Phase 0 produced exactly 1 entry, apply the N=1 foreign-batch check from "Batch state contracts" above. If $AE_PROJECT_DIR/.agentic/batch-state.json exists with status=active + different session_id + recent (≤10 min): print the verbatim NOTE, prompt yes/no, and abort on no.
Setup: Read project config
Before any phase, read the project's AGENTS.md and extract the following values:
REPO — absolute path to the repo root
GH_REPO — GitHub repo slug (e.g. org/repo-name)
BASE_BRANCH — the branch all work is based from. Resolve in this order: (1) if declared via a BASE_BRANCH: line in AGENTS.md, use that; (2) else develop if it exists locally; (3) else development if it exists locally; (4) else stop and ask the user: no develop/development integration branch found - use main (falling back to master), or set up a develop-based workflow? Offer main as the recommended default; (5) on decline / main preference, resolve main (fall back to master). Do not auto-create a branch. Once resolved, print: BASE_BRANCH resolved to: [value].
QUALITY_CMD — the full quality gate command to run from repo root
DEBUGGER_ON_FAILURE — read from $AE_PROJECT_DIR/.agentic/config.json key debugger_on_failure (boolean, default false). When true and the path is Elevated, a Debugger diagnosis step is interposed between a failed quality gate and the next engineer fix pass in Phase 7 - see Phase 7 for the full flow.
AUTO_MERGE_ON_CI_GREEN — read from $AE_PROJECT_DIR/.agentic/config.json key auto_merge_on_ci_green (boolean, default false). When true, Phase 12 squash-merges the PR after CI passes, the PR is ready, and no reviewer has requested changes. Default false leaves the PR open for human review.
PR_WORKFLOW_REVIEWERS — read from AGENTS.md ## PR Workflow section, Reviewers: field (comma-separated GitHub usernames). Default: empty string. Section absence = empty. Used in Phase 10b as fallback reviewer assignment when no CODEOWNERS file is found.
REWORK_DETECTION — read from $AE_PROJECT_DIR/.agentic/config.json key rework_detection (boolean, default true; absent key resolves to true). When false, the ticket-rework alert goes fully dark: the Phase 9 ledger write, the Phase 1 detection read, the REWORK notice, and the escalation (Elevated risk floor, architect/Skeptic callouts, Tier-3 bump) are all disabled. See $AE_REPO_DIR/content/references/ticket-rework.md.
TRACKER_STATE_DIAGNOSTIC — read from $AE_PROJECT_DIR/.agentic/config.json key tracker_state_diagnostic (boolean, default true). When false, the writeback subagent's diagnostic-enrichment sub-step (see ## Tracker Writeback Helper step 5) never runs; the subagent behaves exactly as it did before this plan (a plain transition attempt, generic soft-fail on error only, no extra operator-visible line naming available states). Set false for a project that has deliberately decided not to model a given TRACKER_STATE_* column and does not want a recurring diagnostic line about it.
Tracker resolution — read tracker config using this fallback chain:
- If a
## Tracker section exists in AGENTS.md and contains TRACKER: jira: set TRACKER=jira. Extract TICKET_PREFIX, JIRA_BASE_URL, JIRA_QA_ASSIGNEE_ACCOUNT_ID (optional), JIRA_QA_TRANSITION (optional — no default). Also extract optional state-name overrides: JIRA_STATE_IN_PROGRESS → TRACKER_STATE_IN_PROGRESS (default "In Progress"), JIRA_STATE_IN_REVIEW → TRACKER_STATE_IN_REVIEW (default "In Review"), JIRA_STATE_QA → TRACKER_STATE_QA (default "QA"), JIRA_STATE_BLOCKED → TRACKER_STATE_BLOCKED (default "Blocked"), JIRA_STATE_DONE → TRACKER_STATE_DONE (default "Done"). All five fields are optional; absence = use default. Also extract an optional pipeline-order override: JIRA_PIPELINE_ORDER → TRACKER_PIPELINE_ORDER (comma-separated permutation of exactly IN_PROGRESS, IN_REVIEW, QA, case-insensitive token match; default IN_PROGRESS, IN_REVIEW, QA when absent). On a malformed value (wrong token set, duplicate, unknown token): print WARNING: JIRA_PIPELINE_ORDER '<value>' is not a valid permutation of IN_PROGRESS/IN_REVIEW/QA - using the default order. and fall back to the default; do not abort Setup.
- Else if a
## Tracker section exists with TRACKER: linear (future-proofing): treat as Linear and read Linear fields from ## Tracker instead of ## Linear. Apply the same state-name override fields AND the same Pipeline order: override field as the Linear path below.
- Else if a
## Linear section exists: set TRACKER=linear. Extract Team → TICKET_PREFIX, Workspace → LINEAR_WORKSPACE, QA assignee ID → LINEAR_QA_ASSIGNEE_ID (optional). Also extract optional state-name overrides: State In Progress: → TRACKER_STATE_IN_PROGRESS (default "In Progress"), State In Review: → TRACKER_STATE_IN_REVIEW (default "In Review"), State QA: → TRACKER_STATE_QA (default "Testing"), State Blocked: → TRACKER_STATE_BLOCKED (default "Blocked"), State Done: → TRACKER_STATE_DONE (default "Done"). All five fields are optional; absence = use default. (Note: Linear TRACKER_STATE_QA defaults to "Testing" while Jira defaults to "QA" — reflects common workspace conventions for each tracker.) Also extract an optional pipeline-order override: Pipeline order: → TRACKER_PIPELINE_ORDER, same syntax, validation, and default as the Jira JIRA_PIPELINE_ORDER field above.
- Else: set
TRACKER=none. Set all TRACKER_STATE_* variables to their defaults: TRACKER_STATE_IN_PROGRESS="In Progress", TRACKER_STATE_IN_REVIEW="In Review", TRACKER_STATE_QA="Testing", TRACKER_STATE_BLOCKED="Blocked", TRACKER_STATE_DONE="Done". Set TRACKER_PIPELINE_ORDER to its default IN_PROGRESS, IN_REVIEW, QA.
Dual-shape note: Linear projects canonically store tracker config under ## Linear; Jira projects use ## Tracker. This is intentional — it preserves zero-migration compatibility for every existing Linear project that already has a ## Linear section.
Legacy ## Linear shape guard — if TRACKER=linear was resolved from a ## Linear section AND the section is missing the Workspace: field (required for URL generation), stop immediately and print:
Your tracker config is missing fields $implement-ticket needs. Run manual workflow 'ds-init-project' via `$AE_REPO_DIR/bin/agentic-codex-dispatch command ds-init-project` to update it —
discovery will fill in most fields automatically.
Do not continue. Do not attempt to write the migration. All config-mutation logic lives in manual workflow 'ds-init-project' via $AE_REPO_DIR/bin/agentic-codex-dispatch command ds-init-project.
$AE_PROJECT_DIR/.agentic/tracker.yml local overlay. After the fallback chain above resolves a base result (steps 1-4), check for a project-local, gitignored <repo>/.agentic/tracker.yml overlay and merge it in: the overlay wins field-by-field over the AGENTS.md result, and any changed field is disclosed. This lets a repo whose tracker cannot be declared in a tracked, universally-inherited AGENTS.md still resolve TRACKER at runtime, without baking one operator's workspace or account ID into a public file.
- Merge rule. If no
AGENTS.md section resolved (step 4 landed) OR the overlay's tracker: differs from the AGENTS.md TRACKER, the overlay is sole source and every AGENTS.md-derived field is discarded - a type switch is a replacement, never a merge. Otherwise, unset overlay fields fall through to the AGENTS.md value, and fields the overlay does set win, field-by-field.
- Three-state diagnostic. The overlay resolves to one of
ok (fields honored), absent (no file - falls through to the AGENTS.md result unchanged), or unusable (present but rejected - falls through to the AGENTS.md result, or to TRACKER=none if there was no AGENTS.md section either, plus a distinct, actionable reason naming what IS accepted).
- Required-field rule. When the overlay is sole source, a resolved tracker with no
TICKET_PREFIX (plus JIRA_BASE_URL for jira / LINEAR_WORKSPACE for linear) after defaults is invalid - the overlay is demoted to unusable rather than producing an impossible half-configured tracker.
- Parse boundary. The overlay is a flat
key: value line format, not full YAML: comment lines (#) and blank lines are skipped before any other processing; a line with no colon is ignored with a warning; keys are case-insensitive; an empty value is treated as unset; duplicate keys - last occurrence wins; values are capped at 256 characters.
- Credential guard. Any parsed key matching a credential-shaped pattern (
token, secret, password, api_key, credential, cookie, bearer, pat, etc.) rejects the entire file, degrading to the AGENTS.md result - this file must never hold secrets; credentials resolve from the harness/MCP layer, never from this overlay.
- Data-only.
$AE_PROJECT_DIR/.agentic/tracker.yml is data-only and executes nothing - unlike $AE_PROJECT_DIR/.agentic/phase0-classifiers.yml, which runs with full conductor privileges (see the Trigger-based Phase 0 section below).
- Tracked-file warning. If the overlay file is git-tracked (rather than ignored), print one warning before proceeding - it may hold another operator's tracker config committed by mistake or hand-authored outside the write-path guard.
- Guard interaction: the legacy
## Linear shape guard is evaluated before this overlay and is never suppressed by it.
- Prefer
agentic-tracker resolve --json for this whole step (it implements the merge rule, diagnostics, and guards above deterministically); when the binary is unavailable, apply the rule as written here.
Print a summary of resolved values before Phase 1:
Tracker: [linear | jira | none, or "none ($AE_PROJECT_DIR/.agentic/tracker.yml present but unusable: <reason>)" when the overlay is unusable]
Tracker config source: [$AE_PROJECT_DIR/.agentic/tracker.yml (overrides: <fields>) | (n/a - AGENTS.md only)]
TICKET_PREFIX: [value or "n/a"]
BASE_BRANCH: [value]
AUTO_MERGE_ON_CI_GREEN: [true | false]
PR_WORKFLOW_REVIEWERS: [comma-separated usernames or "(none)"]
TRACKER_STATE_IN_PROGRESS: [value]
TRACKER_STATE_IN_REVIEW: [value]
TRACKER_STATE_QA: [value]
TRACKER_STATE_BLOCKED: [value]
TRACKER_STATE_DONE: [value]
TRACKER_PIPELINE_ORDER: [value]
All work lives in $REPO.
Tracker Writeback Helper
Reusable subagent invocation pattern. Used by Phase 11 (existing), 7 new sites below, and awaiting callers - 3 modes of manual workflow 'ds-ticket-status-sync' via $AE_REPO_DIR/bin/agentic-codex-dispatch command ds-ticket-status-sync (single-ticket, --all, --pending-merge) plus $wrap Part F. Gated on TRACKER != none; no-op otherwise.
Invocation contract:
When the conductor reaches a writeback boundary:
- Skip entirely if
TRACKER == none.
- Spawn the tracker-writeback subagent (Tier 1,
general-purpose) in background (fire-and-forget; do NOT wait for return before continuing the phase). Fire-and-forget applies at W1-W7 and Phase 11; awaiting callers - 3 modes of manual workflow 'ds-ticket-status-sync' via $AE_REPO_DIR/bin/agentic-codex-dispatch command ds-ticket-status-sync (single-ticket, --all, --pending-merge) plus $wrap Part F - are enumerated in the guard's step 4.d.iv below.
- Pass to the subagent:
tracker: linear | jira
ticket_id: from current task context
target_state: one of the resolved TRACKER_STATE_* variables
forward_only_guard: true for every writeback caller - the 7 new sites, Phase 11 (preserving its prior hardcoded Testing behavior), and the awaiting callers - 3 modes of manual workflow 'ds-ticket-status-sync' via $AE_REPO_DIR/bin/agentic-codex-dispatch command ds-ticket-status-sync (single-ticket, --all, --pending-merge) plus $wrap Part F
tracker_state_values: { "IN_PROGRESS": "$TRACKER_STATE_IN_PROGRESS", "IN_REVIEW": "$TRACKER_STATE_IN_REVIEW", "QA": "$TRACKER_STATE_QA", "BLOCKED": "$TRACKER_STATE_BLOCKED", "DONE": "$TRACKER_STATE_DONE" } - the 5 values resolved once in Setup; required by the forward-only guard's same-category pipeline sub-rank
diagnostic_enabled: $TRACKER_STATE_DIAGNOSTIC (boolean, resolved once in Setup; gates the diagnostic-enrichment sub-step of step 5 below)
linear_team_key: $TICKET_PREFIX (Linear only; the team key already resolved in Setup from the ## Linear Team: field - scopes the live list_workflow_states call in step 5's diagnostic-enrichment sub-step to the correct team, exactly as Phase 2c's own Fetch step already does for its advisory-only call)
pipeline_order: the 3-element ordered permutation of IN_PROGRESS/IN_REVIEW/QA resolved once in Setup as TRACKER_PIPELINE_ORDER, defaulting to ["IN_PROGRESS","IN_REVIEW","QA"]; rank = index within this list, consumed by step 4.d.iv's pipeline sub-rank.
- Tracker-specific config:
LINEAR_WORKSPACE, LINEAR_QA_ASSIGNEE_ID for Linear; equivalent for Jira
Subagent responsibilities (extended for forward_only_guard):
-
Pre-read current state:
- Linear: call
mcp__linear__get_issue to read the ticket's current state, capturing both state.type and state.name (e.g. "In Review") from the response.
- Jira: call
mcp__mcp-atlassian__jira_get_issue to read the ticket's current status, capturing both fields.status.statusCategory.key and fields.status.name (e.g. "In Review") from the response.
-
Field-absence guard. If the pre-read call succeeds (no MCP/API error) but the returned object omits state.type (Linear) or fields.status.statusCategory.key (Jira) - a successful call with an incomplete response, not a call failure - treat it identically to the pre-read failure in step 5: skip the transition, do not compute any rank, and emit one stderr line: tracker-writeback: <ticket_id> pre-read succeeded but response omitted the state-type field ('state.type' / 'statusCategory.key') - skipping, no rank assumed. Absence always routes to skip, never to permit - a missing category must never be read as "already past every target" or "not yet at any target."
-
Compute category rank (governs cross-category comparisons only):
- Linear:
backlog < unstarted < started < completed < canceled < duplicate; canceled and duplicate are both terminal (never overwritten by any automatic transition).
- Linear defensive fallback (separate from the primary enum above): if a state's
type is instead spelled cancelled (double L) - e.g. a differently-shaped MCP response or a stale cached row - treat it as terminal too. The canonical/primary spelling the Linear API emits is single-L canceled; this fallback exists only for robustness against a non-conforming response shape.
- Jira:
new < indeterminate < done (via statusCategory.key); a status whose category or name matches cancellation semantics (Won't Do, Cancelled, Duplicate, Will Not Fix) is terminal (never overwritten).
-
Apply the guard - category rank first, then a same-category pipeline sub-rank:
a. If current state is terminal (Linear canceled / duplicate / defensive cancelled (double L), or Jira cancellation-semantic): skip unconditionally.
b. If category_rank(current) < category_rank(target): permit (forward move across categories).
c. If category_rank(current) > category_rank(target): skip (backward move across categories - this is what prevents Blocked or In Review from ever overwriting Done).
d. If category_rank(current) == category_rank(target) (the same-category band that holds In Progress / In Review / QA / Blocked on both trackers), apply the pipeline sub-rank by case-insensitive exact-name match against the 5 values in tracker_state_values:
- i. If
target_state's name case-insensitive-exact-matches the CURRENT state's name: skip (idempotent no-op - already there).
- ii. Else if
target_state matches BLOCKED: permit unconditionally. Blocked is always a permitted same-category target on both trackers - a genuine problem signal that must never be silently dropped, regardless of where the tracker's columns happen to sit.
- iii. Else if the CURRENT state's name matches
BLOCKED: permit unconditionally. Resuming or unblocking a ticket must always be able to move it forward into In Progress, In Review, or QA - Blocked never blocks a later forward transition.
- iv. Else, look up current and target against
pipeline_order (a 3-element ordered list of the tokens IN_PROGRESS/IN_REVIEW/QA, each resolved against tracker_state_values the same way): rank = index within pipeline_order. pipeline_order defaults to the ordered sequence IN_PROGRESS (rank 0) < IN_REVIEW (rank 1) < QA (rank 2) - the historical order in which AE's own writeback sites fire (W1 < W2 < W3) - unless the project declares JIRA_PIPELINE_ORDER / Pipeline order: in AGENTS.md (see Setup), in which case the declared order governs instead.
- If BOTH names resolve to a pipeline rank: permit iff
pipeline_rank(current) < pipeline_rank(target); otherwise skip.
- Otherwise (at least one name does not resolve to a pipeline rank - either because it does not match any of the 5 known
tracker_state_values at all, or because it matches one of the 5 values that has no pipeline rank, e.g. DONE or BLOCKED reached here only on a misconfigured tracker where that value's category coincides with this same-category band): skip unconditionally. Set the return payload's unmatched_state_name to that name only when it does not resolve to any of the 5 known tracker_state_values at all - a name that resolves to a configured value but simply lacks a pipeline rank is not "unmatched." Fire-and-forget call sites (W1-W7, Phase 11 - these never read the subagent's return value) additionally emit ONE stderr line directly here, bounded to at most one line per fire because each fire covers exactly one ticket: tracker-writeback: <ticket_id> current state '<name>' did not match any configured TRACKER_STATE_* value - skipping same-category comparison. Callers that await the result - 3 modes of manual workflow 'ds-ticket-status-sync' via $AE_REPO_DIR/bin/agentic-codex-dispatch command ds-ticket-status-sync (single-ticket, --all, --pending-merge) plus $wrap Part F - do NOT get a per-ticket stderr line for this branch; they read unmatched_state_name from each ticket's return, accumulate across their sweep, and print exactly ONE aggregate line at the end.
-
Soft-fail: any transition error logged to stderr; subagent returns { "status": "failed", "errors": [...] }. Conductor logs and continues; never blocks the phase. A state pre-read failure (MCP/API error) is also a skip: log a one-line warning to stderr and do not proceed. Do not assume any rank when the pre-read fails.
Diagnostic enrichment (new, gated on diagnostic_enabled; runs strictly AFTER a transition attempt, never before, and can never change whether the write happens). When step 4 permits a transition, the subagent attempts it using the EXISTING mechanism, completely unchanged from today - Linear: a single mcp__linear__save_issue call with state: target_state; Jira: discover available transitions via mcp__mcp-atlassian__jira_get_transitions on this ticket, then call mcp__mcp-atlassian__jira_transition_issue for the matching transition id. Nothing runs before this attempt - there is no new round-trip on the happy path on either tracker. (Jira's discovery call is not new API surface introduced by this plan - it is already required to obtain a transition id before any Jira transition can be attempted at all; Linear's save_issue remains the single direct call it is today.)
Only when that attempt does NOT succeed, and only when diagnostic_enabled is true, does the subagent attempt - best-effort - to enrich the outcome with a diagnostic string, using ONLY a data source positively established as sound for the claim being made on that tracker. Any failure of this enrichment step itself is swallowed: it degrades the message (diagnostic stays null), it never changes status, transitioned, or any other part of the original outcome.
- Jira - reuse the
jira_get_transitions result already fetched during the attempt above (no new call). If target_state did not match any available transition's target name (this was already known before jira_transition_issue was ever called): relabel the outcome status: "skipped_unconfigured_state" and set diagnostic to: "'<target_state>' not among the transitions currently available for this ticket (currently in '<current_status>') - available right now: [<comma-separated available transition target names, or "(none)">]. This is a per-ticket snapshot, not the project's full workflow - if '<target_state>' is reachable via a different path, this ticket just isn't there yet. Verify the name in AGENTS.md, or check the tracker directly.". If instead a matching transition WAS found but the jira_transition_issue call itself errored (a genuine API/transient failure - the configured name was fine), leave status: "failed" exactly as today; there is nothing meaningful to enrich.
- Linear - make ONE best-effort call to
mcp__linear__list_workflow_states filtered to linear_team_key (team-scoped, genuinely global for this team, confirmed via the @linear/sdk WorkflowState type). If this call itself fails: swallow it per the rule above - leave status: "failed" and diagnostic: null. If it succeeds: check whether target_state case-insensitive-exact-matches any returned state name. If NOT found: relabel the outcome status: "skipped_unconfigured_state" and set diagnostic to: "'<target_state>' not found among <linear_team_key>'s live workflow states - available: [<comma-separated live state names>].". If target_state WAS found among the live states (the save_issue failure had some other cause - transient error, permissions, etc. - the configured name was fine), leave status: "failed" with diagnostic still attached as informational context, since a live list was already fetched successfully.
This step can only relabel a "failed" outcome to "skipped_unconfigured_state" when live data positively confirms the configured name is not currently usable; it can never convert "failed" into "ok", and it can never prevent, delay, or retry the original transition attempt.
Fire-and-forget call sites (W1-W7, Phase 11) emit, for a "skipped_unconfigured_state" outcome only, the diagnostic text as ONE stderr line: tracker-writeback: <ticket_id> -> '<target_state>' SKIPPED: <diagnostic>. A plain "failed" outcome (enriched with diagnostic or not) continues to use the existing FAILED: line format (see "Failure logging" below, extended for this case). Callers that await the result (3 modes of manual workflow 'ds-ticket-status-sync' via $AE_REPO_DIR/bin/agentic-codex-dispatch command ds-ticket-status-sync, $wrap Part F) read status and diagnostic from the return payload and format them per their own operator-visible-line conventions (see the edits to those files below).
Rejected: fully tracker-derived pipeline order. A live-fetched global ordering was considered instead of a declarable default. Jira's only available state-enumeration call (jira_get_transitions on a probe ticket) returns transitions available from that ticket's CURRENT status only - an edge-local view of the workflow graph, not a global ordering of all states - so no cross-tracker-symmetric live-derived order can be built that works the same way for both currently-supported trackers. A mechanism that only works for one tracker breaks universality; the explicit-declaration-with-fixed-default design above is the soundest project-level alternative.
This ranking never reads $AE_PROJECT_DIR/.agentic/tracker-states.json. It uses only the live pre-read of the ticket's own current state (step 1) and the 5 tracker_state_values strings resolved once in Setup. The Phase 2c cache remains Phase 2c-only and purely advisory; no writeback subagent reads or writes it.
Failure logging: subagent stderr is captured by the conductor's agentic-emit event; one operator-visible line per failure of the form: tracker-writeback: <ticket_id> -> '<target_state>' FAILED: <error>. A status: "skipped_unconfigured_state" outcome uses the distinct SKIPPED form defined in step 5's diagnostic-enrichment sub-step instead: tracker-writeback: <ticket_id> -> '<target_state>' SKIPPED: <diagnostic>. No block, either form.
For full details of the Phase 11 writeback subagent brief shape, see the Phase 11 block below — the brief is unchanged except for the addition of target_state, forward_only_guard, tracker_state_values, and pipeline_order parameters. Phase 11's own Jira JIRA_QA_TRANSITION-gated transition mechanism (see "Behavior" above - unaffected, unedited by this plan) and its Linear path both additionally receive the diagnostic-enrichment behavior from ## Tracker Writeback Helper step 5 when a transition attempt does not succeed; this plan does not change what Phase 11 writes or when, only what it reports when it does not write.
Tracker Create Helper
Reusable SYNCHRONOUS pattern - the conductor waits for the new ticket ID before routing to $implement-ticket. Called by the ticket-offer gate (cross-ref $AE_REPO_DIR/content/sections/02-delegation.md §Ticket-offer gate).
Invocation contract:
Caller supplies:
TICKET_TITLE - one-line summary of the work
TICKET_BODY - markdown description; include Problem + Acceptance Criteria when known
TICKET_TYPE - feature | bug | task
Helper returns:
CREATED_TICKET_ID - e.g. DS-42; empty string on failure
CREATED_TICKET_URL - empty string on failure
CREATE_STATUS - created | skipped | failed
CREATE_ERROR - error message string, or null on success
Collision pre-check (runs BEFORE the create branches):
Before calling any tracker create API, scan in-flight tickets in the same tracker project/team for overlapping output surfaces. Overlap surface = same source files, same exported symbols, same DB tables/migrations, or same shared utility/config that the proposed TICKET_BODY scope touches. This is the cross-ticket boundary analysis that prevents two parallel sessions from colliding on the same file - the failure mode where a boundary gets retrofitted AFTER the ticket already exists instead of at creation time.
Scan target: open AND in-progress tickets in the same project/team. For Linear: mcp__linear__list_issues filtered by team, excluding state types completed, canceled, and duplicate. For Jira: mcp__mcp-atlassian__jira_search with project JQL scoped to statusCategory != Done. For trackers with no query branch: skip silently (fail-safe - the boundary-in-body rule below still applies but relies on the conductor's own scope knowledge rather than a scan).
Decision:
-
No overlap, OR tracker has no query branch: proceed to the create branches with TICKET_BODY unchanged.
-
Overlap found: append a ## Scope boundary section to TICKET_BODY BEFORE the create call. The section names the overlapping ticket(s) and the file/symbol/table each side owns. Worked example (AUT-301 vs AUT-300, both touching the operator-list surface):
## Scope boundary
- AUT-300 owns: packages/qa-auth/src/adapters/admin.ts (prod-DB guard), admin/scripts/seed-qa-operator.ts, and any isTestAccount schema migration if that route is chosen.
- This ticket owns: backend/src/operators/index.ts GET /operators WHERE-clause filter only.
- Merge order: this ticket is the symptom-fix; AUT-300 is root-cause. If AUT-300 adds an isTestAccount flag, that migration is AUT-300's to own.
The boundary section is binding, not advisory: it travels with the ticket into the tracker so the other session sees it on its next pull. If the conductor cannot determine a clean boundary (the two tickets genuinely own the same lines with no split), STOP before creating and surface the conflict to the operator for a manual scope-split.
The scan is a single tracker query (one API roundtrip, paginated to the project/team). It is cheap and runs only at create time - it does not run on every phase transition.
Branch on TRACKER:
-
TRACKER == linear: call mcp__linear__save_issue with NO id field (save_issue creates when no id is supplied - this matches the repo's existing Linear convention; do NOT use a createIssue tool, it does not exist). Pass title=TICKET_TITLE, description=TICKET_BODY, and the Linear team. IMPORTANT team-source note: the ## Linear section's Team: field resolves to TICKET_PREFIX (a prefix string like "DS"), but save_issue needs the Linear team key/id - if only a prefix is available, resolve the actual team via the Linear team-list tool. Do NOT invent a ## Linear Team heading; use the existing ## Linear Team: resolution that the rest of this command uses. On success read issue.identifier -> CREATED_TICKET_ID, issue.url -> CREATED_TICKET_URL, CREATE_STATUS=created. On MCP error: CREATE_STATUS=failed, CREATE_ERROR=<msg>.
-
TRACKER == jira: call mcp__mcp-atlassian__jira_create_issue (naming-consistent with the existing mcp__mcp-atlassian__jira_* family used elsewhere in this file). Pass project_key=TICKET_PREFIX, summary=TICKET_TITLE, description=TICKET_BODY, issue_type mapped from TICKET_TYPE (feature -> "Story", bug -> "Bug", task -> "Task"; omit to accept project default if uncertain). On success read the returned issue key -> CREATED_TICKET_ID, construct CREATED_TICKET_URL as <JIRA_BASE_URL>/browse/<CREATED_TICKET_ID>, CREATE_STATUS=created. On MCP error: CREATE_STATUS=failed.
-
TRACKER has no built-in create branch (forward-looking fall-through): CREATE_STATUS=skipped. Emit one operator line: ticket_driven: create not supported for this tracker - proceeding ad-hoc. Do NOT run any shell command from $AE_PROJECT_DIR/.agentic/phase0-classifiers.yml as a create operation - the classifier contract is read-only; creation is a write operation outside that contract. This branch is the extension point for trackers not yet integrated: adding a new tracker means adding a create branch above; until then it falls through here. Adding a project-local classifier does NOT constitute a create integration.
LOUD failure (NOT silent): on CREATE_STATUS=failed, emit an operator-visible line mirroring the Writeback Helper's failure line format: tracker-create: '<TICKET_TITLE>' FAILED: <CREATE_ERROR>. Do not block the caller; the caller (the gate) decides: offer mode proceeds ad-hoc AFTER emitting the warning; require mode surfaces and waits.
Phase 0: Input normalization
Run this phase BEFORE Phase 0a-pre. Output is the in-memory normalized_input structure consumed by every later phase. No disk side-effects.
Goal: convert any form of <input> into a deterministic ordered list of {ticket_id, source} entries, an optional freeform_task, and an optional additional_operator_context. Confirm only when classification is ambiguous or destructive.
Fast paths (no confirmation, no operator-visible output beyond the resolution itself).
| Condition | Action |
|---|
Invocation is a single token matching ^[A-Z][A-Z0-9_]+-\d+$ AND matches TICKET_PREFIX (when TRACKER ≠ none) | entries=[{ticket_id, source: "literal"}], proceed to Phase 0a-pre. Zero new operator output. |
TRACKER == none AND input is freeform text only (no tickets, no URLs, no images) | entries=[], freeform_task=<input>, proceed. No confirmation prompt. (TRACKER=none has zero ambiguity for freeform — Phase 1's prior freeform prompt is now redundant.) |
Otherwise, classify the input. Built-in classifiers run first, in this order; project-local classifiers (see "Extension point" below) run after for inputs that fall through.
| Input shape | Detection | Resolution |
|---|
| Bare ticket ID | matches ^[A-Z][A-Z0-9_]+-\d+$ | append {ticket_id, source: "literal"} |
| Comma/space-separated list | tokenize on [,\s]+, each token matches bare-ID regex | append each as source: "list" |
| Jira issue URL | ^https?://[^/]+/browse/([A-Z][A-Z0-9_]+-\d+) | extract group 1, append source: "url:jira-issue" |