| name | ds-implement-ticket |
| description | 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 bac |
| user-invocable | true |
Implement Ticket
Run the Activation preflight from 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 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 /ds-wrap automatically to preserve state via context.md and MEMORY.md updates (or instruct the operator to run /ds-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 AskUserQuestion 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. /ds-wrap - save session state and generate a hand-off summary
2. Start a new session (on Claude Code, /clear also works)
3. /ds-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
/ds-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 AskUserQuestion 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 /ds-implement-ticket now risks exhausting your token budget
before the architect-plan-review phase completes.
Recommended safe pattern:
1. /ds-wrap - save session state and generate a hand-off summary
2. Start a new session (on Claude Code, /clear also works)
3. /ds-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
/ds-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
.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 METHODOLOGY.md §Risk Classification).
- Promotion-gate check + Brief/Plan authoring. Comprehension artifacts that the conductor must produce itself (per METHODOLOGY.md §Planning Artifacts).
- Stop-and-ask decisions. The user-facing surface; subagents do not interact with the user.
- All
.agentic/*.json[l] writes. Sole-writer rule for loop-state.json, tasks.jsonl, and any other state file under .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 .agentic/batch-state.json and .agentic/loop-state.json. Phases that write to either file (Phase 0a, Phase 0a-pre, Phase 6/6b, Phase 7, Phase 12, Phase 12a) MUST apply the contracts below.
Contract A — Per-write session_id gate (applies to BOTH batch-state.json and loop-state.json).
Before every conductor write to either file:
- Read the current on-disk file (if present).
- If the file exists and its
session_id field is a non-empty string AND does not match the current session, AND its last_updated is within the last 10 minutes: ABORT the write. Print the verbatim warning:
WARNING: write to .agentic/<file> aborted - another session (session_id=<X>, last_updated=<Y>) appears to own this file. Identify the live session via .agentic/*.json last_updated. 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.
- 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 updated_at in the new payload.
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 but not for read-only resume prompts (those follow the Phase 0a-pre decision table).
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 last_updated 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>, last_updated=<Y>). Wait for it to finish, or kill it and re-invoke.
Concurrent batches per project root are not supported. Operators wanting parallel batches use separate worktrees with separate .agentic/.
N=1 foreign-batch warning. If Phase 0 produced exactly 1 entry (single-ticket) AND .agentic/batch-state.json exists with status=active + different session_id + last_updated within the last 10 minutes: print the verbatim warning:
NOTE: a batch session is active for this project root (session_id=<X>, last_updated=<Y>). Single-ticket invocations are not refused, but loop-state.json writes will collide if the same ticket is touched. Identify the live session via .agentic/loop-state.json last_updated. 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 mirror.
The Stop hook (hooks/stop-context.js) mirrors its loop-state.json interrupted-mark write to batch-state.json via the helper writeBatchState(cwd, sessionId). The mirror applies an ownership check: if the file's session_id is a non-empty string and does not match the Stop hook's session uuid, the write is aborted silently (the hook does not steal another session's batch state). Best-effort silent-fail throughout. The mirror sets status=interrupted, interrupted_at=now, interrupt_reason="unknown", updated_at=now; all other fields including last_updated_phase, tickets[], and replan_log[] are preserved.
.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": "/ds-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.
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 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 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)
Before reading AGENTS.md or doing any setup, check for .agentic/loop-state.json:
If the file 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 the file. 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.
If the file exists and status == "active" with last_updated more than 10 minutes ago: treat as implicitly interrupted (the Stop hook may not have fired). Print: "Found an active loop state last written [elapsed] ago — treating as interrupted." Then follow the "interrupted" path above.
If the file exists and status == "complete" or "stalled":
- Print: "A completed/stalled loop state file exists for ticket [ticket_id]. Clearing it."
- Delete the file. Proceed normally.
If no file exists: proceed normally.
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 .agentic/loop-state.json exists but cannot be parsed as JSON, print a warning, offer to delete the file and start fresh. 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 loop-state.json and batch-state.json. See Phase 0a-pre and the "Batch state contracts" section above for the full contract. Every conductor write to loop-state.json includes a top-level session_id: <current session> field; readers tolerate absence for back-compat with state files written by prior versions.
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 .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 .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 .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.
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.
- 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 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.)
- 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".
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 /ds-implement-ticket needs. Run /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 /ds-init-project.
Print a summary of resolved values before Phase 1:
Tracker: [linear | jira | none]
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]
All work lives in $REPO.
Tracker Writeback Helper
Reusable subagent invocation pattern. Used by Phase 11 (existing) and 7 new sites below. 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).
- 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 all 7 new sites; preserves existing Phase 11 behavior (which used hardcoded Testing)
- 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: call mcp__linear__get_issue (or Jira mcp__mcp-atlassian__jira_get_issue) to read the ticket's current state including state.type (Linear: backlog, unstarted, started, completed, cancelled; Jira: map via status category).
-
Forward-only guard: compute rank of current state and target state.
Linear ranking (uses state.type directly):
backlog < unstarted < started < completed; cancelled is terminal (never overwritten by any automatic transition).
Jira ranking (map via status category, available on every Jira status via statusCategory.key):
new (To Do, Open, Backlog) → rank unstarted
indeterminate (In Progress, In Review, Testing) → rank started
done (Done, Closed, Resolved) → rank completed
- Custom categories or names matching cancellation semantics (Won't Do, Cancelled, Will Not Fix) → terminal (never overwritten)
Apply the same rank-comparison rule for both trackers: if current rank >= target rank, skip the transition.
-
Skip semantics:
- If current state read fails (MCP/API error): skip transition silently. Do NOT assume position; do NOT proceed with the transition. Log a one-line warning to stderr.
- If current rank >= target rank (already there or past it): skip transition. No notification noise.
- If current state is
cancelled: skip transition unconditionally.
- Otherwise: perform the transition via
mcp__linear__save_issue (or Jira equivalent).
-
Soft-fail: any transition error logged to stderr; subagent returns { "status": "failed", "errors": [...] }. Conductor logs and continues; never blocks the phase.
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>. No block.
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 and forward_only_guard parameters.
Tracker Create Helper
Reusable SYNCHRONOUS pattern - the conductor waits for the new ticket ID before routing to /ds-implement-ticket. Called by the ticket-offer gate (cross-ref 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 and state not in (Done, Cancelled). 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 .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" |
| Jira JQL/search URL | host matches JIRA_BASE_URL host AND path is /issues (or /jira/.../issues) AND query contains jql= | URL-decode jql, call mcp__mcp-atlassian__jira_search, paginate up to cap, append each as source: "url:jira-jql" with title |
| Linear issue URL | ^https?://linear\.app/[^/]+/issue/([A-Z][A-Z0-9_]+-\d+) | extract group 1, append source: "url:linear-issue" |
| Linear filter URL | linear.app/<workspace>/view/... or filter query string | call mcp__linear__list_issues with decoded filter, paginate to cap, append source: "url:linear-filter" with title |
| Pasted screenshot | Any image attachment present in the operator's user-message payload (image MIME type or attachment marker indicating an image was uploaded with the invocation) | conductor reads the image directly (Tier 2, multimodal). Extract every distinct [A-Z][A-Z0-9_]+-\d+ substring. Append each as source: "screenshot". Do not spawn an OCR subagent. |
| Freeform residue | any non-matching text after all classifiers consumed their inputs | held aside; see Freeform handling below |
Extension point (project-local classifiers).
If .agentic/phase0-classifiers.yml exists at the project root, load it after Setup and before built-in classifiers run. Built-in classifiers run FIRST; project-local classifiers run only against inputs that fell through (residue not matched by any built-in). Schema:
classifiers:
- source_label: "github-issue"
detect: "^https?://github\\.com/[^/]+/[^/]+/issues/(\\d+)"
resolver: "gh issue view $1 --json number,title --jq '{ticket_id: \"GH-\\(.number)\", title: .title}'"
- source_label: "asana-task"
detect: "^https?://app\\.asana\\.com/0/\\d+/(\\d+)"
resolver:
mcp_tool: "mcp__asana__get_task"
args: { gid: "$1" }
response_path: "$.data"
Resolution rules:
detect is a regex applied to each fall-through input token/URL.
resolver is either a shell command (string) or an MCP tool spec (object with mcp_tool, args, optional response_path). The resolver MUST yield (directly or via response_path extraction) at minimum ticket_id (and optionally title).
- Resolver failures are treated like "Unparseable URL" — appended to
resolution_notes, no entry produced.
- Each matched input contributes one entry with
source: "extension:<source_label>".
Shell-command resolver contract (binding).
- Output channel: resolver MUST emit JSON on stdout. Stderr is captured and logged to
resolution_notes but is NOT parsed.
- Exit code: zero exit = success; non-zero exit = treat as "no entries from this resolver" (log stderr, continue Phase 0; do NOT abort).
- JSON shape: stdout MUST be either a single object
{ticket_id: string, title?: string} OR a JSON array of such objects. Any other shape (non-JSON, missing ticket_id, wrong types) is a resolver failure.
- Capture-group substitution:
$1 through $9 correspond to regex capture groups from detect. Substituted values MUST be shell-escaped by wrapping the value in single quotes and replacing every embedded single quote ' with the four-character sequence '\''. Example: a capture value O'Brien's repo is substituted as 'O'\''Brien'\''s repo'. The engineer MUST NOT use unquoted $1 substitution under any circumstance — raw URLs and tracker IDs may contain shell metacharacters (;, &, `, $(), |, newlines) that would otherwise inject commands into the conductor shell.
- Timeout: 10 seconds per resolver invocation. On timeout: kill the process, treat as zero entries, append a
"resolver timeout: <source_label>" warning to resolution_notes.
MCP-tool resolver contract (binding).
- Invocation: the conductor calls the named MCP tool with
args as the input dict. Capture-group substitution $1-$9 applies to string-typed values inside args by literal string replacement. Shell-escaping does NOT apply (these are tool-call arguments, not shell tokens). The conductor MUST type-check each substituted value against the schema the MCP tool advertises — if the tool expects an integer and substitution produces a non-numeric string, treat as resolver failure and log; do NOT silently coerce.
- Response parsing: the resolver entry MAY specify
response_path: — a JSONPath-like expression (root $, dot-traversal, optional array index e.g. $.data.items[0]) telling the conductor which sub-object of the tool response carries ticket_id and title. If response_path is omitted, the conductor reads ticket_id/title directly from the top-level response object. If response_path is present but does not resolve (key missing, type mismatch), treat as resolver failure.
- Failure & timeout: MCP tool errors and tool-side timeouts are treated identically to shell-command non-zero exit — log and continue.
Security model.
.agentic/phase0-classifiers.yml runs with full conductor privileges: shell-command resolvers execute as the operator's shell user, and MCP-tool resolvers can invoke any MCP server the conductor has access to. Trust level is therefore equivalent to executable code committed to the repository — anyone who can land a change to this file can execute arbitrary commands in any session that runs /ds-implement-ticket against the affected branch. Operators MUST review changes to .agentic/phase0-classifiers.yml whenever pulling an untrusted or unfamiliar branch (collaborator PR, fork, dependabot, agent-authored branch) before invoking /ds-implement-ticket on that branch. The file is project-local by convention and is not signed, sandboxed, or sandbox-enforced. This trust posture matches the rest of the .agentic/ umbrella but is called out explicitly here because Phase 0 runs before any other phase and is therefore the first execution surface a malicious classifier file could exploit.
Rationale for .agentic/phase0-classifiers.yml (over the AGENTS.md ## Tracker extension): the project-local YAML keeps the classifier registry decoupled from tracker config (which is single-tracker by design); supports multiple un-enumerated trackers simultaneously (a project may use Jira primary + GitHub Issues secondary); and matches the .agentic/ convention for project-local agentic state. AGENTS.md ## Tracker remains the single-tracker config; new trackers don't replace it.
Pagination cap. Default 50 issues per URL/filter (combined across pagination). On overflow, prompt: "JQL/filter returned >50 issues; capped at 50. Narrow the query or proceed with the first 50? (narrow / proceed)". On narrow: abort Phase 0. On proceed: keep first 50, log to resolution_notes.
Sanity ceiling. Hard refuse if len(entries) > 200 after all classifiers and pagination. Print: "Phase 0 resolved >200 tickets; refusing as a sanity ceiling. Narrow your input." Exit. This is the ONLY hard refusal in Phase 0.
Deduplication. Dedupe entries[] by ticket_id preserving first-seen order. Record dropped duplicates in resolution_notes.
Freeform handling (mixed-input residue).
| Condition | Action |
|---|
entries non-empty AND freeform residue present | Default: route residue to additional_operator_context (attach to every entry's downstream brief). Print residue + entries summary, prompt: "Mixed input detected. Residue: '<first 200 chars>'. Entries: <list>. Attach residue as additional context to all entries, drop, or abort? (attach-to-all / drop / abort) [default: attach-to-all]". On attach-to-all: set additional_operator_context=<residue>. On drop: set additional_operator_context=null, log to resolution_notes. On abort: exit. |
entries empty AND residue AND TRACKER=none | Fast path above already caught this case. |
entries empty AND residue AND TRACKER ≠ none | Confirm: "No tracker IDs detected and TRACKER=<tracker>. Treat input as freeform task (no tracker fetch), or abort? (freeform / abort)". On freeform: set freeform_task=<residue>, entries=[]. On abort: exit. |
entries empty AND no residue | Print: "Phase 0 produced no entries and no freeform task. Re-invoke with a ticket reference or description." Exit. |
Failure handling per classifier.
| Failure | Action |
|---|
| Unparseable URL | Treat as freeform residue. Log to resolution_notes. |
| JQL/filter returns 0 results | Print "JQL/filter returned 0 issues." Continue with other inputs. |
| JQL/filter auth failure | Print verbatim error. Abort Phase 0 (no silent freeform fallback — masks credential issues). |
| Screenshot has no detectable IDs | Print "Screenshot contained no <PREFIX>-NNN matches." Continue. |
| Screenshot ID prefix ≠ TICKET_PREFIX | Append anyway with resolution_notes warning. Phase 1 fetch is authoritative. |
| Mixed input where some IDs don't exist in tracker | Phase 0 validates shape, not existence. Phase 1's per-ticket fetch is authoritative. |
| Project-local classifier resolver failure | Treat as Unparseable URL. Log to resolution_notes. |
Confirmation policy. Confirmation runs ONLY in the cases below. All other resolutions proceed silently with a one-line [phase: input-normalization | entries=<N> | freeform=<bool> | extra_context=<bool>] breadcrumb.
| Trigger | Confirmation |
|---|
| JQL/filter URL → any N entries | Soft warn + auto-proceed — print resolved IDs + titles in a one-per-line list, do NOT prompt; emit resolution_notes entry. The operator wrote the JQL deliberately; Phase 0a batch triage already presents a per-ticket summary downstream; "as autonomously as possible" is the stated goal. (Aligned with the operator-enumerated >5 row below.) |
| Screenshot → any N entries | Yes — OCR is approximate, print extracted IDs, (proceed / abort) |
| Mixed input with freeform residue | Yes — (attach-to-all / drop / abort), default attach-to-all |
| Cap hit (>50 from JQL/filter) | Yes — (narrow / proceed) |
| No IDs + TRACKER ≠ none + freeform residue | Yes — (freeform / abort) |
| Operator-enumerated sources (literal IDs, comma/space lists, single issue URLs, mixed bare-IDs+issue-URLs) producing >5 entries | Soft warn + auto-proceed — print loud warning enumerating all resolved IDs in a one-per-line list, do NOT prompt; emit resolution_notes entry. (Threshold of 5 is chosen because a single visual scan can verify ≤5 IDs; >5 deserves an explicit list so the operator catches typos, but the operator already enumerated each one — confirming would violate "as autonomously as possible".) |
| All other operator-enumerated cases (≤5 IDs, single URL, fully unambiguous) | No confirmation. Proceed silently. |
| Sanity ceiling (>200) | Refused (no prompt; hard exit). |
Tier: Tier 2 (conductor-direct, including screenshot read and resolver execution).
Collision-awareness backstop (consume-time). When Phase 0 resolves ticket entries, the conductor reads each ticket body for a ## Scope boundary section (written by the Create Helper collision pre-check). If present, carry it into the architect brief and engineer execution contract. If absent AND the tracker supports queries, run the same in-flight overlap scan the Create Helper runs before the architect spawns; on overlap, surface the boundary to the operator and append it to the architect brief. Best-effort, never blocks Phase 0 - this catches human-filed tickets that skipped the create-time pre-check.
Phase 0a-open-goal: Open-goal loop init or resume (conditional)
Trigger: invocation carries goal_mode=open_goal. Mutually exclusive with Phase 0a-pre/Phase 0a - skip both when this fires; fall through unmodified when it does not.
Step 0 - resume-vs-fresh classification (before any write). Provably complete partition.
Read .agentic/batch-state.json if present.
-
Absent, or mode != "open_goal": → Fresh init.
-
mode == "open_goal": classify via termination_reason first (authoritative when non-null, regardless of status); fall back to status sub-partition only when termination_reason == null.
A. termination_reason non-null (6 of 7 buckets; resolved without consulting status):
cap_reached_iterations | cap_reached_wallclock → TERMINAL; print:
A prior open-goal loop already terminated (reason: <termination_reason>, iteration <N>/<max_iterations>). Starting fresh clears this state. To continue this goal instead, re-invoke with max_iterations and/or max_wallclock_min set HIGHER than the current values (max_iterations=<X>, max_wallclock_min=<Y>). Confirm: fresh-start (delete + reinit) or raise-caps (continue from iteration <N+1>)? (fresh / raise-caps)
Offer both fresh and raise-caps.
blocked → TERMINAL, fresh ONLY (raise-caps can't unblock a stuck Skeptic loop); prompt adds: "To resume the specific blocked iteration's stuck work instead, use the ordinary per-ticket Resume check against that iteration's own branch - this phase does not do that."
goal_met → TERMINAL, fresh ONLY (nothing to raise).
paused_stale_pace | paused_operator_request → RESUMABLE.
B. termination_reason == null (7th bucket; sub-partitioned on status, exhaustively):
status in {paused, interrupted} → RESUMABLE.
status == "active" → apply Contract A's existing per-write session_id-mismatch determination to this READ (same 4-way logic Contract A uses to gate every batch-state.json WRITE, applied here as read-time classification):
session_id non-empty and matches current session → RESUMABLE (same-session continuation; covers crash-mid-advance re-invoked same session).
session_id non-empty, differs, AND last_updated older than 10 min → RESUMABLE, treated as implicitly interrupted (mirrors Phase 0a-pre "status=active AND last_updated>10min → implicit interrupt"). Covers crash-mid-advance surviving into a later session.
session_id non-empty, differs, AND last_updated within last 10 min → REFUSE, verbatim Contract C message: "Another batch session is active for this project root (session_id=<X>, last_updated=<Y>). Wait for it to finish, or kill it and re-invoke." Exit. (live-foreign-session; closes the null+active Contract-C bypass.)
session_id null/absent (legacy) → force-takeover prompt verbatim (Phase 0a-pre): "WARNING: another session (session_id=<X>, last_updated=<Y>) may still be active. Force takeover? (yes/no). Identify the live session via .agentic/loop-state.json last_updated." yes → RESUMABLE; no → exit/wait.
status == "complete" → TERMINAL, fresh ONLY (safe default; unexpected/legacy combo).
status == "stalled" → TERMINAL, fresh ONLY (same rationale).
- any other/unrecognized
status → TERMINAL, fresh ONLY (safe default).
Completeness statement: every (termination_reason, status) pair lands in exactly one of {Fresh init, RESUMABLE, raise-caps-or-fresh (terminal-cap bucket), refuse}. Bucket A resolves 6 of 7 termination_reason values without consulting status. Bucket B exhaustively covers the 7th value (null) across all 5 named status enum values + explicit catch-all; status==active within bucket B is itself exhaustively partitioned by Contract A's 4-way session_id/staleness logic. No pair unclassified.
- On fresh (any TERMINAL branch): delete
batch-state.json → Fresh init. (force-takeover "no" exits/waits, does NOT go fresh.)
- On raise-caps: refuse unless declared
max_iterations/max_wallclock_min strictly greater than on-disk: "raise-caps requires re-invoking with max_iterations and/or max_wallclock_min set higher than the existing values (current: max_iterations=<X>, max_wallclock_min=<Y>). Re-invoke with a higher value, or choose fresh." On success: Contract A write (update, NO Contract C - update not create) setting raised cap(s), termination_reason:null, status:"active" → Advance to next iteration (idempotency-checked).
- On RESUMABLE (any sub-case incl. force-takeover yes): Contract A write
status:"active"; do NOT reset iteration → Advance to next iteration (idempotency-checked).
goal_condition divergence (unchanged): read from disk on any resume, never re-parsed; differing invocation value prints one-line "on-disk value governs" warning (mirrors Phase 0a-pre GENUINE-divergence pattern), continues.
Advance to next iteration (idempotency-checked)
Read the last entry in tickets[] (if any):
tickets[] empty OR last entry status == "complete": no next-iteration entry yet → apply the IDENTICAL Contract A+B write Phase 12a "On no trigger, GOAL_MET false" performs (increment open_goal.iteration, append pending entry) - reused by reference.
- last entry
status is "pending" or "in_progress": a next-iteration entry was ALREADY appended (most likely by Phase 12a's advance-write interrupted before Phase 1 - the crash-mid-advance scenario). Do NOT increment iteration or append again - use the existing entry as-is. Prevents double-advance (silent skip/dupe of an iteration).
Either way, fall through to Phase 1 for the iteration corresponding to the last tickets[] entry.
Fresh init (only via the branches above - never unconditional)
Validation (refuse before Phase 1 on failure):
goal_condition missing → refuse: "goal_mode=open_goal requires goal_condition to be declared. Re-invoke with goal_condition set." Exit.
max_iterations or max_wallclock_min missing/non-positive → refuse verbatim (trigger-catalog.md Hard-stop rule 5): "goal_mode=open_goal requires max_iterations and max_wallclock_min to be declared - no unbounded default is permitted. Re-invoke with both fields set." Exit.
dry_run, if present, must be literal true/false; absent defaults to false. No refusal on absence.
Contract C check (before any write): apply the broadened Contract C check above (Fresh init is one of the three triggering create-paths) - refuse verbatim if batch-state.json is active/foreign-session/recent. Exit on refusal.
On successful validation (Contract A fresh write):
- Initialize
.agentic/batch-state.json: mode:"open_goal", batch_id:"open-goal-<ISO8601>-<4hex>", wallclock_cap_min:<max_wallclock_min>, wallclock_started_at:now, tickets:[], open_goal:{active:true, goal_condition:<string>, iteration:1, max_iterations:<int>, risk_declared:null, termination_reason:null, dry_run:<bool>}.
loop-state.json NOT touched here - initialized normally at Phase 6 loop init exactly as any ordinary iteration (no open-goal fields).
- Breadcrumb:
[phase: open-goal-init | goal_condition="<condition>" | max_iterations=<N> | max_wallclock_min=<M> | dry_run=<bool>].
- Fall through to Phase 1 for iteration 1.
Off-by-one note (Minor): at init, iteration=1 but tickets=[] (len 0) - SOLE momentary exception to iteration==len(tickets[]), closed the moment Phase 1 of iteration 1 appends the first synthetic entry. From Phase 1 of iteration 1 onward, the invariant holds continuously.
Per-iteration ticket lifecycle. Each iteration's synthetic tickets[] entry (ticket_id:"<goal-slug>-iter-N", cluster_id:"open-goal") follows the SAME transition-write pattern ordinary batch tickets use: pending → in_progress at Phase 1 start, in_progress → complete at Phase 12, or → blocked via "Batch-mode escalation routing" (Phase 6). Every transition applies Contract A + Contract B.
Interaction with top-level "Resume check (before setup)". That check reads loop-state.json before Phase 0, independent of this phase. A mid-iteration interrupted resume jumps to that iteration's resume entry point; Phase 0a-open-goal is never re-entered that session. Step 0 fires only on a normal Phase 0 pass reaching goal_mode=open_goal.
Phase 0a-pre: Batch resume check
Run this phase BEFORE the per-ticket Resume check below. This is the composition anchor: batch-level resume picks the ticket cursor first; the per-ticket Resume check then runs unmodified scoped to that ticket's branch and loop-state.json.
Trigger: Phase 0 normalization produced ≥ 2 entries (same trigger as Phase 0a). Skip otherwise.
Single-ticket wallclock carve-out. Single-entry invocations bypass this phase entirely - no .agentic/batch-state.json is read or created - EXCEPT when max_wallclock_min is declared on a single-entry invocation with no goal_mode. In that case: apply the Contract C refusal check (broadened per the Contract C definition above) ONLY - do NOT also run the separate N=1 foreign-batch warning first; both checks share the identical trigger (active + different session + ≤10min), so the softer warning first would be dead code (Contract C refusal always overrides any yes/proceed). This differs from the ordinary N=1 path, which never creates batch-state and never needs Contract C. On success (Contract A fresh write): create .agentic/batch-state.json{mode:"single_ticket_capped", tickets:[{ticket_id:<the one entry>, status:"pending", cluster_id:null, depends_on:[]}], wallclock_cap_min:<max_wallclock_min>, wallclock_started_at:now}. This is the ONLY N=1 path that creates batch-state.json.
Read .agentic/batch-state.json if present. Apply the decision table below.
batch-state.json state | Action |
|---|
| absent | Skip Phase 0a-pre. Fall through to the existing per-ticket Resume check, then Setup, then Phase 0a (which initializes batch-state.json). |
status=complete | Print: "Prior batch complete; clearing." Delete the file. Fall through to the existing per-ticket Resume check. |
status=stalled | Print stalled summary (tickets + reasons). Prompt: resume / fresh / abandon. On abandon: delete file and exit. On fresh: delete file and fall through. On resume: apply re-plan migration (below) and pick next pending ticket. |
status=paused | Print: "Batch paused at operator request: [last_summary]." Prompt: resume / fresh. On fresh: delete file and fall through. On resume: apply re-plan migration and pick next pending ticket. |
status=interrupted | Print: "Batch interrupted (reason: [interrupt_reason]). N completed, M pending/blocked." Prompt: resume / fresh. On fresh: delete file and fall through. On resume: apply re-plan migration and pick next pending ticket. |
status=active AND last_updated > 10 min ago | Treat as implicit interrupt. Same prompt as interrupted row. |
status=active AND last_updated ≤ 10 min AND session_id matches current | Silent re-entry resume (rare; e.g. /ds-implement-ticket re-invoked within the same session). Pick next pending ticket from tickets[]. |
status=active AND last_updated ≤ 10 min AND (session_id differs OR session_id is null/absent) | If Phase 0 produced ≥ 2 entries: refuse with the verbatim Contract C message. If Phase 0 produced exactly 1 entry: see "N=1 foreign-batch warning" below; this row does not apply (Phase 0a-pre runs only when Phase 0 produced ≥ 2 entries). For N≥2 force-takeover prompts: print "WARNING: another session (session_id=<X>, last_updated=<Y>) may still be active. Force takeover? (yes/no). Identify the live session via .agentic/loop-state.json last_updated." and require explicit operator confirmation. |
| Parse failure | Print warning. Prompt: delete-and-fresh / abort. On abort: exit. On delete-and-fresh: delete file and fall through. |
Inconsistent pair (batch-state.json says active, loop-state.json says interrupted) | Trust the non-active file. If both are stale-active (>10 min), treat as implicit interrupt for both. |
Move ordering hazard (resume case). On resume, batch-state.json.tickets[] is the authoritative ticket cursor and supersedes any Phase 0 output produced in the resuming session. If the operator re-supplied input, compare Phase 0 entries[] against on-disk tickets[]:
- SUBSET match (all tickets[] IDs are present in entries[], but entries[] has extras): this is NOT a hazard. The extras were deferred or excluded at original triage time. Note: extras beyond the original deferred set that were not part of the original input are not auto-added on resume - surface them to the operator or run them separately; they are never mis-run. Proceed silently with
batch-state.json.tickets[] as the cursor.
- GENUINE divergence (tickets[] contains IDs NOT present in entries[]): surface the warning below.
WARNING: resumed batch tickets[] = [<list>] contain ticket IDs not present in this invocation's Phase 0 entries[] = [<list>].
The on-disk batch state takes precedence on resume. Continue resuming the prior batch, or abandon resume and use the new input?
(continue-resume / abandon-resume-and-use-new-input)
On continue-resume: discard Phase 0 output, use batch-state.json.tickets[]. On abandon-resume-and-use-new-input: delete batch-state.json and re-run Phase 0a from the new entries.
Resume composition rule (binding). If Phase 0a-pre confirms resume of an active batch, it sets the in-memory ticket cursor to the next pending ticket from tickets[] BEFORE falling through to the existing per-ticket Resume check. The per-ticket Resume check then runs UNMODIFIED but scoped to the picked ticket's branch and loop-state.json. The two state mechanisms compose: batch resume picks the ticket; per-ticket resume picks the phase within that ticket. They have non-overlapping scopes.
Re-plan migration on resume. When the operator confirms resume of any non-active batch state (stalled, paused, interrupted, or stale-active treated as interrupted):
git fetch origin.
- For each ticket in
tickets[] with status pending or blocked: re-fetch the tracker record. If the ticket has been merged elsewhere (per tracker status, or per gh pr list --state merged --head <branch> returning a non-empty result), append a replan_log entry {ts, action: "drop_merged", ticket_id, detail} and set the ticket's status to skipped_already_merged.
- Run /ds-ticket-triage Phases 1-3 over the surviving pending/blocked tickets to re-sequence. Level 2 investigator (Phase 2b) is gated on
replan_count >= 2: count replan_log entries with action: "investigator_rerun"; if the count is >= 2, spawn a real background investigator (including the functional-duplicate brief); otherwise run Level 1 only (conductor-direct). Append the replan_log entry {ts, action: "investigator_rerun", ticket_id: null, detail: "replan #N"} BEFORE spawning the investigator when it fires. Map the resulting lanes back to the surviving tickets' cluster_id and depends_on fields (array order); deferred or in-progress-excluded tickets discovered during re-plan are surfaced to the operator and excluded from tickets[].
- All writes apply Contract A (per-write
session_id gate) and Contract B (replan_log[] read-merge-write preservation). See "Batch state contracts" below.
- Bump
status back to active. Preserve wallclock_started_at from the prior batch (the wallclock cap is per-batch lifetime, not per-session - a batch resumed in a later session continues counting against the original wallclock_started_at).
Emit breadcrumb: [phase: batch-resume | tickets_remaining=K].
Phase 0a: Batch triage (Phase 0 produced ≥ 2 entries)
Trigger: Phase 0 normalization produced ≥ 2 entries.
Skip: Phase 0 produced exactly 1 entry. Mixed-form inputs that Phase 0 normalized down to a single entry count as single-entry and skip Phase 0a.
Flow:
-
Run the /ds-ticket-triage planning algorithm (Phases 1-3) conductor-orchestrated. Phase 0a feeds its OWN already-normalized entries[] directly into triage Phase 1 - triage Phase 0 is NOT re-run (entries are already normalized).
-
Phase 1 (metadata fetch, conductor-direct, soft-fail): for each entry, fetch priority, status, story_points, labels, components, assignee, and issuelinks from the tracker (same per-ticket fetch as /ds-ticket-triage Phase 1). Soft-fail per ticket (mark fetch_failed: true and proceed). Detect terminal: true (Done/Cancelled) and in_progress: true (active workflow state) per the /ds-ticket-triage Phase 1 rules.
-
Phase 2a (DAG + cycle handling, conductor-direct): build the dependency graph from blocks/is-blocked-by links, detect cycles (break at lowest-confidence link, defer both with cycle_warning: true). External deps noted but not used for lane assignment.
-
Phase 2b conflict-surface analysis: Level 1 is always conductor-direct (shared component/label overlap check). Level 2 applies when len(entries) <= 20: spawn ONE real background investigator with the full /ds-ticket-triage Phase 2b brief, including the functional-duplicate detection task (bar: "a reasonable engineer would implement them with exactly the same change"). When len(entries) > 20: set HEURISTIC_ONLY=true and proceed WITHOUT prompting - rationale: the batch was already committed via Phase 0, and prompting mid-initialization wastes operator context.
-
Phase 3 (Rules 1-4, conductor-direct): distribute surviving tickets across lanes using the /ds-ticket-triage Phase 3 consume-and-remainder pipeline. Lane cap is fixed at 3 on this path (--lanes override is not available for the /ds-implement-ticket integration path).
The result is an in-memory triage_result containing:
{lanes[], deferred[], in_progress_excluded[], functional_duplicates[], conflict_warnings[], heuristic_only}.
-
Surface triage findings to the operator BEFORE building tickets[]. Present a structured summary covering:
-
Functional duplicate warnings (when functional_duplicates[] is non-empty): for each pair, print the ticket IDs and the one-sentence reason. Prompt the operator PER PAIR (after all lane assignments are known, since Phase 3 has already run):
Functional duplicate detected: <A> + <B> - <summary>
Both tickets appear to describe the same functional work.
Action: (defer-first / defer-second / keep-both)
On defer-first: add ticket A to deferred[], remove it from its lane. On defer-second: add ticket B to deferred[], remove it from its lane. On keep-both: no change. Do NOT recompute the lane distribution after a defer choice - surgically remove the deferred ticket from its lane only.
-
Deferred tickets (from Phase 3 Rule 1 + any operator-deferred duplicates): list each with its reason.
-
In-progress tickets (from Phase 1): list each. These are excluded from tickets[] and kickoff.
-
HEURISTIC_ONLY notice (when HEURISTIC_ONLY=true): "Conflict analysis: Level 1 only (component/label overlap; >20 tickets, investigator pass skipped). Functional-duplicate detection was also skipped."
After surfacing, map ONLY the lane-assigned tickets to tickets[] (deferred and in-progress-excluded tickets are NOT written to tickets[]).
Map in ARRAY ORDER: lane 1 first, lane 2 next, lane 3 last. Within each lane, chains are topo-sorted (blockers first); parallel tickets within the same lane are sorted priority-descending then ticket_id-ascending. Each entry:
status: "pending"
cluster_id: "lane-N" (where N is the lane number)
depends_on: ["<prev-ticket-id>"] (chain) or [] (parallel lane head or independent)
No merge_order field. Array position is the execution cursor.
Emit breadcrumb: [phase: batch-triage | triage_algorithm=ticket-triage-phases-1-3 | N tickets | lanes=K | lane_cap=3 | deferred=P | excluded_in_progress=Q | heuristic_only=<bool>].
-
Initialize .agentic/batch-state.json (persistent batch cursor). First apply the Contract C concurrent-batch refusal: if the file already exists with status=active, a different session_id, and last_updated within the last 10 minutes, REFUSE with the verbatim Contract C message and exit. Otherwise, write the initial skeleton:
schema_version: 1
session_id: <current>
batch_id: "<first ticket's TICKET_PREFIX>-batch-<ISO8601>-<4hex>"
status: "active"
tickets[]: triage-derived executable cursor; contains only lane-assigned tickets (deferred and in-progress-excluded tickets are not included); in array order as described in step 2 above
wallclock_started_at: now, wallclock_cap_min: <env AGENTIC_BATCH_MAX_WALLCLOCK_MIN or 90>
replan_log: []
created_at: now, updated_at: now
Atomic tmp+rename. Apply Contract A on the write (this is a fresh write so no prior session_id; the gate effectively passes).
-
Conductor iterates through tickets[] in array order (tickets[] contains only lane-assigned executable tickets; deferred and in-progress-excluded tickets were surfaced in step 2 and are not present), running existing per-ticket phases (1 → 12) for each ticket. Per-ticket transition writes to batch-state.json (each via Contract A + Contract B):
- At ticket start:
status: "pending" → "in_progress", set started_at, update updated_at.
- At ticket complete:
status: "in_progress" → "complete", set ended_at, last_summary, pr_number, branch.
- At ticket block:
status → "blocked" with detail in last_summary.
- At ticket merged-elsewhere skip:
status → "skipped_already_merged" with replan_log append.
Persistent batch state lives in .agentic/batch-state.json. See Phase 0a-pre for the resume protocol.
Phase 0b: Brief check + qa.md snapshot + on-resume Brief migration
Before any architect spawn, check for an existing Brief, snapshot qa.md for Elevated tickets, and handle the on-resume Brief migration for tickets predating the qa_criteria requirement.
Brief check
Slug derivation: convert the ticket title to kebab-case and strip any ticket-ID prefix
(e.g. AE-123 Add user login becomes add-user-login).
Check (either condition satisfies):
- A file exists at
docs/planning/<slug>.md, OR
.agentic/brief-session.json exists with status: complete AND brief_path matching
the ticket slug.
If found:
- Set
brief_path = docs/planning/<slug>.md in the architect execution contract (Phase 3).
- At the promotion gate in Phase 3b: skip the conductor-authored Brief step - the Brief is
pre-existing and operator-confirmed.
- Pass
brief_source: operator to the Skeptic-on-Brief gate; use the operator-confirmed
Skeptic variant (completeness-only review per content/commands/ds-brief.md Section 6).
- If
.agentic/brief-session.json confirms brief_source: operator, set operator_brief_injectionable: true to signal Phase 3 that the Brief's committed constraints should be injected into the architect spawn brief (see Phase 3 "Pre-authored Brief injection").
If not found: proceed normally. The promotion gate in Phase 3b determines whether a
Brief is required based on the unit count from the orchestration-planner.
qa.md snapshot (Elevated only)
After risk has been classified, if the current ticket is Elevated, snapshot any existing .agentic/qa.md to a per-ticket snapshot file. Trivial invocations skip this step entirely (preserves bit-for-bit-identical guarantee for Trivial single-ticket invocations - no .agentic/qa.md.snapshot-* file is produced).
Snapshot rules:
- If risk is Trivial: skip this entire subsection. Do not create or touch any snapshot file.
- If risk is Elevated and
.agentic/qa.md does not exist: skip silently (nothing to snapshot).
- If risk is Elevated and
.agentic/qa.md exists and .agentic/qa.md.snapshot-<ticket_id> does NOT already exist: copy .agentic/qa.md to .agentic/qa.md.snapshot-<ticket_id> via atomic write (write to .agentic/qa.md.snapshot-<ticket_id>.tmp, then rename).
- If risk is Elevated and
.agentic/qa.md.snapshot-<ticket_id> already exists (e.g., on resume of a paused or interrupted ticket): preserve the existing snapshot. Do not overwrite. The original snapshot represents the qa.md state at the start of this ticket's first run.
The snapshot is consumed at Phase 11b by wrap-ticket to compute the diff between the snapshot and the working-tree .agentic/qa.md, surfacing qa.md additions made during this ticket. Phase 12 cleanup removes the snapshot file. The snapshot path is gitignored under the existing .agentic/ umbrella; no .gitignore change is needed.
On-resume Brief migration (qa_criteria backfill)
When Phase 0a-pre or the per-ticket Resume check detects an in-flight ticket whose Brief lacks the qa_criteria field (because the ticket was started before the qa_criteria requirement was rolled out), apply this migration before spawning any worker:
-
Probe architect plan. If the architect plan (referenced from the Brief or stored alongside it) contains a qa_criteria block, the conductor authors a retroactive Brief amendment appending the architect's qa_criteria block verbatim into the Brief. Proceed normally.
-
If neither has qa_criteria (legitimate transition ticket), surface the operator prompt verbatim:
WARNING: this ticket's Brief and architect plan predate the qa_criteria requirement. Options:
(a) provide a qa_criteria block now (paste YAML)
(b) one-time bypass for this transition ticket (skip QA for this ticket only)
Choose (a/b).
On (a): the operator pastes the YAML; conductor injects it into the Brief and proceeds.
On (b): conductor records a one-time bypass marker for THIS ticket only (in-context, scoped to this resume) and proceeds with QA skipped. The bypass does NOT extend to future tickets.
-
New invocations (no in-flight state) hard-fail per architect plan. Fresh /ds-implement-ticket invocations on Elevated tickets without a qa_criteria block in the Brief or architect plan emit a Critical Skeptic finding on the architect plan; the conductor does not proceed past Phase 3 until the architect plan supplies the block. The on-resume bypass option is exclusively for tickets that started before this requirement existed.
Phase 1: Understand the ticket
(Setup has already resolved TRACKER. Execute exactly one of the sub-sections below.)
Iteration: Phase 1 runs once per entry in normalized_input.entries. The current [TICKET_ID] refers to entry.ticket_id. When normalized_input.entries is empty AND normalized_input.freeform_task is set, only the TRACKER is none sub-section executes, with freeform_task as the description. When entries is non-empty, the TRACKER is none sub-section is skipped regardless of TRACKER value.
When normalized_input.additional_operator_context is non-null, append it verbatim to every entry's downstream architect (Phase 3) and engineer (Phase 5) brief, prefixed with "Additional operator context (applied to all entries):". This routes mixed-input residue into the per-entry brief without dropping operator intent.
If TRACKER is linear
- Call
mcp__linear__get_issue with the ticket ID and includeRelations: true.
- Read the full description — specifically the Implementation, Files, and QA sections.
- Note any blocking tickets (
blockedBy) — confirm they are done before proceeding.
- Note the ticket type (feature vs bug) — this drives branch naming.
- Comment thread fetch. Call
mcp__linear__list_comments with the UUID issueId of the issue (graceful-skip if the tool name differs or the call fails). Collect all returned comment bodies. Scan each comment: if the body contains the string "QA" AND at least one of FAIL, PARTIAL, BLOCKED, failed, re-work, flag that comment as a prior-QA-failure comment. Accumulate flagged comments in PRIOR_QA_COMMENTS (array of comment bodies). Build COMMENT_THREAD_SUMMARY as the concatenation of all comment bodies, truncated to 2000 characters. If the call is not available or returns an error, set both to empty (graceful no-op).
If TRACKER is jira
- Call
mcp__mcp-atlassian__jira_get_issue with issue_key: "[TICKET_PREFIX]-NNN" and fields: "*all" to get the full issue including description and current status.
- Read the full description — note any Acceptance Criteria, Implementation Notes, and QA content in the description or sub-tasks.
- Note any blocking issues — confirm they are resolved before proceeding.
- Note the issue type (Story, Bug, Task) — this drives branch naming.