start-ticket

star 289

Declare an active ticket so the ticket-first hook lets code edits through. Accepts `<N>` or `<owner>/<repo>#<N>`.

me2resh By me2resh schedule Updated 6/6/2026

name: start-ticket description: Declare an active ticket so the ticket-first hook lets code edits through. Accepts <N> or <owner>/<repo>#<N>. disable-model-invocation: false argument-hint: " | <owner/repo>#" effort: low

/start-ticket - Declare the Active Ticket

Writes a session marker so the require-active-ticket.sh PreToolUse hook permits Edit/Write on code paths. Without it, the hook blocks edits to anything outside .claude/, docs/, projects/*/docs/, and *.md.

Marker layout (apexyard#41 + #513):

Path When the hook uses it
<ops_root>/.claude/session/tickets/<project>/<safe-branch> Tier 0 (#513). Edit is under <ops_root>/workspace/<project>/ AND the file's repo is on a git-worktree branch (or CLAUDE_WORKTREE_BRANCH is set). Lets parallel agents on the same project hold independent tickets. safe-branch = branch with /__.
<ops_root>/.claude/session/tickets/<project> Tier 1. Edit is under <ops_root>/workspace/<project>/ AND this per-project marker exists (as a FILE). Single-agent default.
<ops_root>/.claude/session/current-ticket Tier 2. Fallback. Checked if neither above matched. Also the marker for ops-repo framework edits (no workspace/<name>/ prefix).

All markers live in the ops fork (gitignored). No more .claude/session/ inside each managed-project clone. tickets/<project> is a FILE (tier 1) or a DIRECTORY holding <safe-branch> markers (tier 0) — the hook's -f tests keep the two from colliding.

This is the mechanical enforcement of the Pre-Build Gate in .claude/rules/workflow-gates.md — "do not start coding until the ticket exists".

Path resolution

Read the registry path via portfolio_registry, the per-project docs dir via portfolio_projects_dir, and the ideas backlog via portfolio_ideas_backlog — all from .claude/hooks/_lib-portfolio-paths.sh. Source the helper at the top of any bash block that touches those paths:

source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"
registry=$(portfolio_registry)

Defaults match today's single-fork layout (./apexyard.projects.yaml, ./projects, ./projects/ideas-backlog.md). Adopters in split-portfolio mode override the portfolio.{registry, projects_dir, ideas_backlog} keys in .claude/project-config.json. Don't hardcode literal apexyard.projects.yaml or projects/ paths in bash blocks — the helper resolves whichever mode the adopter is in. See docs/multi-project.md.

Process

1. Parse Arguments

Expected forms:

  • 42 — plain number, resolves against the current repo. Read git remote get-url origin and extract <owner>/<repo>. If there's no origin, stop and ask for a fully-qualified reference.
  • me2resh/flat-mate#128 — fully-qualified reference.
  • apexyard#42 — owner defaults to the current org (parsed from the origin URL).

If $ARGUMENTS is empty, stop and ask the user which issue they're starting.

Cross-repo note: ApexYard governs a portfolio of repos. If the user is in the ops repo (the apexyard fork) but the ticket lives in a managed project's own repo, they should pass the fully-qualified form so the marker records the correct tracker. Each managed project's tickets live in that project's own GitHub repo — tickets do not cross project boundaries.

2. Verify the Issue Exists

Source the tracker library and call tracker_view. The library dispatches the right CLI based on .tracker.kind in .claude/project-config.{defaults,}.jsongh (default), linear, jira, asana, custom, or none. See .claude/hooks/_lib-tracker.sh and AgDR-0033.

source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-tracker.sh"

issue_json=$(tracker_view "<number>" "<owner/repo>")
state=$(echo "$issue_json" | jq -r '.state // empty')
title=$(echo "$issue_json" | jq -r '.title // empty')
url=$(echo "$issue_json" | jq -r '.url // empty')

The lib emits normalised JSON: {state, title, url, labels}. Each tracker adapter parses the underlying CLI's JSON into this common shape, so the skill doesn't need to branch per-CLI.

If the lib exits non-zero with empty stdout, the issue does not exist (or the CLI isn't installed / authenticated). Stop and report the error — do not write the marker.

If state indicates the ticket is closed (gh: CLOSED; linear/jira/asana: Done / Closed / Resolved / Cancelled), warn the user and confirm before continuing (sometimes you do want to resume work on a re-opened issue).

tracker.kind = none adopters: the lib returns no data. Skip the existence check entirely; trust the user's input. Re-verify the shape against tracker_id_pattern so obvious typos still block.

3. Derive a Branch Suggestion

From the issue title and number, generate: <type>/<TICKET-ID>-<slug> where:

  • <type> guessed from title prefix: [Feat]feature, [Fix]fix, [Docs]docs, [Chore]chore, default feature
  • <TICKET-ID> is GH-<number> for GitHub Issues, or matches the project's configured ticket_prefix from apexyard.projects.yaml if set
  • <slug> = lowercase title, kebab-case, max 40 chars, stopwords trimmed from the edges

Match the convention in .claude/rules/git-conventions.md.

4. Resolve the target marker

Per apexyard#41, the marker path depends on whether the ticket's tracker repo matches a registered managed project.

4a. Locate the ops root

The ops root is the apexyard fork root — the directory containing BOTH onboarding.yaml and apexyard.projects.yaml. Walk up from CWD / the nearest git toplevel until you find it:

ops_root=""
r=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
while [ -n "$r" ] && [ "$r" != "/" ]; do
  if [ -f "$r/onboarding.yaml" ] && [ -f "$r/apexyard.projects.yaml" ]; then
    ops_root="$r"
    break
  fi
  r=$(dirname "$r")
done

If not found (user is outside an apexyard fork), tell the user and stop. Starting a ticket without the fork doesn't make sense.

4b. Look the tracker repo up in the registry

Given the ticket's owner/repo (from step 1), grep apexyard.projects.yaml for a project whose repo: field matches. One registry-safe way (uses yq when available, falls back to a greppy read):

if command -v yq >/dev/null 2>&1; then
  project=$(yq eval ".projects[] | select(.repo == \"${OWNER_REPO}\") | .name" "$ops_root/apexyard.projects.yaml")
else
  # Greppy fallback: find the `name:` whose sibling `repo:` matches.
  # Strips surrounding quotes from both `name:` and `repo:` values so the
  # comparison works whether the registry uses bare scalars
  # (`repo: me2resh/curios-dog`) or quoted scalars (`repo: "me2resh/…"`).
  project=$(awk -v r="$OWNER_REPO" '
    function unquote(s) { gsub(/^["\x27]|["\x27]$/, "", s); return s }
    /^[[:space:]]*- name:/ { name = unquote($3) }
    /^[[:space:]]*repo:/   { if (unquote($2) == r) { print name; exit } }
  ' "$ops_root/apexyard.projects.yaml")
fi

Notes on the fallback:

  • Handles both repo: me2resh/curios-dog and repo: "me2resh/curios-dog" (and single-quoted).
  • Assumes - name: is the FIRST key in each project entry — that matches the shape in apexyard.projects.yaml.example and every entry produced by /handover. If your registry reorders keys so repo: appears before name: in an entry, the lookup misses. Fix: move name: to the top, or install yq (the preferred path).
  • Leading whitespace is tolerated via ^[[:space:]]* — nested entries under projects: parse fine at any indent level, so long as the indent is consistent within the entry.

$project is now either a registered project name (e.g. curios-dog, sharppick) or empty (ticket's tracker repo isn't registered — typically because the ticket is on the ops fork itself, or a repo that's not under management).

4c. Pick the marker path

Three tiers (apexyard#41 + #513). When the ticket maps to a registered project AND this session is running inside a git worktree (parallel agents fanned out on the same project), write a per-worktree marker so two agents on the same project don't overwrite each other's ticket (last-writer-wins). Otherwise write the per-project file (single-agent — unchanged), or the ops fallback.

if [ -n "$project" ]; then
  # Detect a worktree: prefer the harness-set env var, else check whether the
  # current checkout is a LINKED worktree (not the main working tree). Compare
  # the ABSOLUTE git-dir against the ABSOLUTE common-dir — they differ only in a
  # linked worktree. The absolute forms matter: a plain --git-dir vs
  # --git-common-dir comparison false-positives in the main checkout (one comes
  # back absolute, the other relative). This is the SAME detection the
  # require-active-ticket.sh / require-migration-ticket.sh read side uses.
  wt_branch="${CLAUDE_WORKTREE_BRANCH:-}"
  if [ -z "$wt_branch" ]; then
    gd=$(git rev-parse --absolute-git-dir 2>/dev/null)
    gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
    if [ -n "$gd" ] && [ "$gd" != "$gcd" ]; then
      wt_branch=$(git branch --show-current 2>/dev/null)
    fi
  fi

  if [ -n "$wt_branch" ]; then
    safe_branch="${wt_branch//\//__}"          # '/' → '__' filesystem-safe
    marker="$ops_root/.claude/session/tickets/$project/$safe_branch"
  else
    marker="$ops_root/.claude/session/tickets/$project"
  fi
  mkdir -p "$(dirname "$marker")"
else
  marker="$ops_root/.claude/session/current-ticket"
  mkdir -p "$(dirname "$marker")"
fi

Note: tickets/$project is a FILE in single-agent mode and a DIRECTORY in worktree mode (it holds the per-branch markers). If you're switching a project from single-agent to worktree mode and tickets/$project already exists as a file, remove it first (rm "$ops_root/.claude/session/tickets/$project") so the directory can be created.

5. Write the marker

Write these key=value lines to the path resolved in step 4c:

repo=<owner/repo>
number=<number>
title=<title>
url=<url>
suggested_branch=<branch>
started_at=<ISO-8601>

6. Confirm to the User

Output a two-line confirmation that names the marker path so the user sees which scope this ticket is active on:

Active ticket: <owner/repo>#<number> — <title>
Marker: <marker>  (per-project / ops fallback)
Suggested branch: <branch>

Do NOT create the branch automatically. The user may already be on a branch, or may want to confirm the branch name first.

Notes

  • .claude/session/ (including .claude/session/tickets/) is gitignored — markers are per-machine, per-clone of the ops fork.
  • Running /start-ticket again overwrites the marker at whichever path resolved in step 4c (per-project or fallback). That's how you switch tickets — including jumping between projects (each project's marker lives in its own file, so switching between curios-dog and sharppick doesn't lose either one's context).
  • To clear a specific project's marker: rm <ops_root>/.claude/session/tickets/<project>.
  • To clear the ops-level fallback: rm <ops_root>/.claude/session/current-ticket.
  • Exempt paths (.claude/, docs/, projects/*/docs/, any *.md) don't need a ticket — the skill is only required before touching source / config / infra.
  • Migration from pre-#41 layout: if your workflow still has a .claude/session/current-ticket inside a managed-project clone (workspace/<name>/.claude/session/current-ticket), it's harmless but no longer read by the hook. Delete it or re-run /start-ticket to have the new marker written under the ops fork's .claude/session/tickets/<name>.

Part of ApexYard — multi-project SDLC framework for Claude Code · MIT.

Install via CLI
npx skills add https://github.com/me2resh/apexyard --skill start-ticket
Repository Details
star Stars 289
call_split Forks 174
navigation Branch main
article Path SKILL.md
More from Creator