
Swain Do
- 128 installs
- 2 repo stars
- Updated July 24, 2026
- cristoslc/swain
Execute Swain-managed agent work: run planned tasks, apply repo changes, and advance a coding session through the Swain do workflow.
About
swain-do from cristoslc/swain is the execution entrypoint for the Swain agent workflow. It runs planned coding tasks, applies changes in the repository, and advances autonomous sessions so Claude Code can complete multi-step build work through a structured do command rather than ad hoc prompting.
- Runs Swain task execution workflow
- Turns agent plans into concrete repo actions
- Coordinates multi-step coding sessions
- Keeps Swain-driven work moving without manual glue
Swain Do by the numbers
- 128 all-time installs (skills.sh)
- Ranked #3,719 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cristoslc/swain --skill swain-doAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 128 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 24, 2026 |
| Repository | cristoslc/swain ↗ |
What it does
Execute Swain-managed agent work: run planned tasks, apply repo changes, and advance a coding session through the Swain do workflow.
Files
<!-- swain-model-hint: sonnet, effort: low — default for task management; see per-section overrides below -->
Execution Tracking
<!-- session-check: SPEC-121 --> Before proceeding with any state-changing operation, check for an active session:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-check.sh" 2>/dev/nullIf the JSON output has "status" other than "active", inform the operator: "No active session — start one with /swain-init?" Proceed if they dismiss.
Abstraction layer for agent execution tracking. Other skills (e.g., swain-design) express intent using abstract terms; this skill translates that intent into concrete CLI commands.
Before first use: Read references/tk-cheatsheet.md for complete command syntax, flags, ID formats, and anti-patterns.
Artifact handoff protocol
This skill receives handoffs from swain-design based on a four-tier tracking model:
| Tier | Artifacts | This skill's role |
|---|---|---|
| Implementation | SPEC | Create a tracked implementation plan and task breakdown before any code is written |
| Coordination | EPIC, VISION, JOURNEY | Do not track directly — swain-design decomposes these into children first, then hands off the children |
| Research | SPIKE | Create a tracked plan when the research is complex enough to benefit from task breakdown |
| Reference | ADR, PERSONA, RUNBOOK | No tracking expected |
If invoked directly on a coordination-tier artifact (EPIC, VISION, JOURNEY) without prior decomposition, defer to swain-design to create child SPECs first, then create plans for those children.
Term mapping
Other skills use these abstract terms. This skill maps them to the current backend (tk):
| Abstract term | Meaning | tk command |
|---|---|---|
| implementation plan | Top-level container grouping all tasks for a spec artifact | tk create "Title" -t epic --external-ref <SPEC-ID> |
| task | An individual unit of work within a plan | tk create "Title" -t task --parent <epic-id> |
| origin ref | Immutable link from a plan to the spec that seeded it | --external-ref <ID> flag on epic creation |
| spec tag | Mutable tag linking a task to every spec it affects | --tags spec:<ID> on create |
| dependency | Ordering constraint between tasks | tk dep <child> <parent> (child depends on parent) |
| ready work | Unblocked tasks available for pickup | tk ready |
| claim | Atomically take ownership of a task | tk claim <id> |
| complete | Mark a task as done | tk add-note <id> "reason" then tk close <id> |
| abandon | Close a task that will not be completed | tk add-note <id> "Abandoned: <why>" then tk close <id> |
| escalate | Abandon + invoke swain-design to update upstream artifacts | Abandon, then invoke swain-design skill |
Configuration and bootstrap
Config stored in .agents/execution-tracking.vars.json (created on first run). Read references/configuration.md for first-run setup questions, config keys, and the 6-step bootstrap workflow.
Statuses
tk accepts exactly three status values: open, in_progress, closed. Use the status command to set arbitrary statuses, but the dependency graph (ready, blocked) only evaluates these three.
To express abandonment, use tk add-note <id> "Abandoned: ..." then tk close <id> — see Escalation.
Ticket lifecycle (ADR-015)
Tickets are ephemeral execution scaffolding — they exist to help agents track and resume work during SPEC implementation. Once the parent SPEC transitions to a terminal state (Complete, Abandoned), its tickets may be discarded. Tickets are not committed to trunk, not used as retro evidence, and should not block worktree cleanup. The session log (.agents/session.json JSONL) is the archival record of what happened; tickets are the live dashboard of what's in progress.
Operating rules
1. Always include `--description` (or -d) when creating issues — a title alone loses the "why" behind a task. Future agents (or your future self) picking up this work need enough context to act without re-researching. 2. Create/update tasks at the start of work, after each major milestone, and before final response — this keeps the tracker useful as a live dashboard rather than a post-hoc record. 3. Keep task titles short and action-oriented — they appear in tk ready output, tree views, and notifications where space is limited. 4. Store handoff notes using tk add-note <id> "context" rather than ephemeral chat context — chat history is lost between sessions, but task notes persist and are visible to any agent or observer. 5. Include references to related artifact IDs in tags (e.g., spec:SPEC-003) — this enables querying all work touching a given spec. 6. Prefix abandonment reasons with `Abandoned:` when closing incomplete tasks — this convention makes abandoned work findable so nothing silently disappears. 7. Use `ticket-query` for structured output — when you need JSON for programmatic use, pipe through ticket-query (available in the vendored bin/ directory) instead of parsing human-readable output. Example: ticket-query '.status == "open"'
<!-- swain-model-hint: opus, effort: high — plan creation and code implementation require deep reasoning -->
TDD enforcement
Strict RED-GREEN-REFACTOR with anti-rationalization safeguards and completion verification. Read references/tdd-enforcement.md for the anti-rationalization table, task ordering rules, and evidence requirements.
Spec lineage tagging
Use --external-ref SPEC-NNN on plan epics (immutable origin) and --tags spec:SPEC-NNN on child tasks (mutable). Query: ticket-query '.tags and (.tags | contains("spec:SPEC-003"))'. Cross-plan links: tk link <task-a> <task-b>.
Escalation
When work cannot proceed as designed, abandon tasks and escalate to swain-design. Read references/escalation.md for the triage table, abandonment commands, escalation workflow, and cross-spec handling.
"What's next?" flow
Run tk ready for unblocked tasks and ticket-query '.status == "in_progress"' for in-flight work. If .tickets/ is empty or missing, defer to bash "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents/bin/chart.sh" ready for artifact-level guidance.
Context on claim
When claiming a task tagged with spec:<ID>, show the Vision ancestry breadcrumb to provide strategic context. Run bash "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents/bin/chart.sh" scope <SPEC-ID> 2>/dev/null | head -5 to display the parent chain. This tells the agent/operator how the current task connects to project strategy.
Artifact/tk reconciliation
When specwatch detects mismatches (TK_SYNC, TK_ORPHAN in .agents/specwatch.log), read references/reconciliation.md for the mismatch types, resolution commands, and reconciliation workflow.
Drift resolution (SPEC-307)
On ticket create, edit, or close — if the ticket has a spec: tag — run drift resolution against the tagged SPEC. Read both the ticket and the SPEC. If the ticket's scope drifts from the SPEC's acceptance criteria or problem statement, apply a fix: either re-align the child (edit the ticket) or update the parent (edit the SPEC).
Fix direction uses signals and content judgment: count prior drift decisions against the parent (more means parent is likely stale), plus assess which direction produces the better outcome. Apply the fix, then present the result for operator review: accept, modify, or revert. All outcomes are recorded as drift decisions via swain-session-state.sh record-decision.
Session bookmark
After state-changing operations, update the bookmark: bash "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents/bin/swain-bookmark.sh" "<action> <task-description>"
Superpowers skill chaining
When superpowers is installed, swain-do invokes these skills at specific points. Skipping them or inlining the work undermines the guarantees they provide — TDD catches regressions before they compound, and verification prevents false completion claims that waste downstream effort:
1. Before writing code for any task: Invoke the test-driven-development skill. Write a failing test first (RED), then make it pass (GREEN), then refactor. This applies to every task, not just the first one.
2. When dispatching parallel work: Invoke subagent-driven-development (if subagents are available and tasks are independent) or executing-plans (if serial). Read references/execution-strategy.md for the decision tree.
3. Before claiming any task or plan is complete: Invoke verification-before-completion. Run the verification commands, read the output, and only then assert success. No completion claims without fresh evidence.
Detection: ls .agents/skills/test-driven-development/SKILL.md .claude/skills/test-driven-development/SKILL.md 2>/dev/null — if at least one path exists, superpowers is available. Cache the result for the session.
When superpowers is NOT installed, swain-do uses its built-in TDD enforcement (see references/tdd-enforcement.md) and serial execution.
Plan ingestion (superpowers integration)
When a superpowers plan file exists, use the ingestion script (scripts/ingest-plan.py) instead of manual task decomposition. Read references/plan-ingestion.md for usage, format requirements, and when NOT to use it.
Execution strategy
Selects serial vs. subagent-driven execution based on superpowers availability and task complexity. Read references/execution-strategy.md for the decision tree, detection commands, and worktree-artifact mapping.
Pre-plan implementation detection
Before creating a plan for a SPEC, scan for evidence that it's already implemented. This avoids re-implementing work that exists on unmerged branches or was done in a prior session. Run these checks in parallel — they're independent signals that feed a single decision.
Signal scan
| Signal | Check | Why it matters |
|---|---|---|
| Unmerged branches | `git for-each-ref --format='%(refname:short) %(upstream:trackshort)' refs/heads/ \ | grep -i "<SPEC-ID>" then verify not merged: git merge-base --is-ancestor <branch> HEAD` |
| Git history | `git log --oneline --all \ | grep -i "<SPEC-ID>"` |
| Deliverable files | Read the spec to identify described outputs (scripts, modules, configs). Check whether they exist on HEAD via ls or Glob. | Files on disk without matching commits may indicate partial or uncommitted work. |
| Tests pass | Re-run the spec's tests now and read the output. Prior results are not evidence — only fresh execution counts. | This is the critical gate. Agents are prone to rationalizing that "tests passed before" without re-running. The reason this matters: code changes between sessions can silently break previously-passing tests. |
Decision
- 2+ signals → take the retroactive-close path (below)
- 1 signal → proceed with normal plan creation; note the signal in the first task's description
- 0 signals → proceed normally
Retroactive-close path
When evidence confirms prior implementation, skip full task decomposition:
1. Create a single tracking task: tk create "Retroactive verification: <SPEC-ID>" -t task --external-ref <SPEC-ID> -d "Verify prior implementation before closing SPEC." 2. Claim it: tk claim <id> 3. Run verification-before-completion (if superpowers installed) or re-run the spec's tests manually. 4. If verification passes: add a note with the evidence, close the task, then invoke swain-design to transition the spec to Complete. 5. If verification fails: fall back to normal plan creation — the prior implementation was incomplete.
Worktree isolation preamble
All mutating work tracked by swain-do happens in a worktree — regardless of whether it touches source code, artifacts, skill files, or data. This prevents half-finished changes from polluting trunk and avoids collisions between parallel agents. Before any operation that will produce file changes (plan creation, task claim, code writing, artifact editing, skill file changes, spec transitions, execution handoff), run this detection:
Read-only operations skip this preamble entirely — proceed in the current context. The explicit read-only allowlist:
tk ready,tk show,tk status,tk listticket-query(structured queries)- Plan inspection (reading plan files without modifying them)
- Status checks and task queries
Step 0 — Commit any dirty files
This step ALWAYS runs, regardless of whether you're in a worktree or on trunk. Uncommitted changes from earlier in the session (newly created artifacts, edited specs, etc.) must be on HEAD before proceeding — worktrees check out from HEAD, and even on trunk, dirty files risk being overwritten or lost.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
UNTRACKED=$(git -C "$REPO_ROOT" ls-files --others --exclude-standard 2>/dev/null)
MODIFIED=$(git -C "$REPO_ROOT" diff --name-only 2>/dev/null)
if [ -n "$UNTRACKED" ] || [ -n "$MODIFIED" ]; then
[ -n "$UNTRACKED" ] && echo "$UNTRACKED" | xargs -d '\n' git -C "$REPO_ROOT" add --
[ -n "$MODIFIED" ] && echo "$MODIFIED" | xargs -d '\n' git -C "$REPO_ROOT" add --
git -C "$REPO_ROOT" commit -m "chore: stage dirty tree before worktree creation" || {
echo "ERROR: pre-commit step failed — aborting worktree creation"
exit 1
}
fiIf the commit fails (e.g., pre-commit hook rejection), surface the error and stop. Do not proceed to worktree detection or any other step.
Step 1 — Detect worktree context
# SPEC-250: Check env var first (set by bin/swain), then git plumbing
if [ -n "${SWAIN_WORKTREE_PATH:-}" ]; then
IN_WORKTREE=yes
else
GIT_COMMON=$(git rev-parse --git-common-dir 2>/dev/null)
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
[ "$GIT_COMMON" != "$GIT_DIR" ] && IN_WORKTREE=yes || IN_WORKTREE=no
fiIf `IN_WORKTREE=yes`: already isolated. Proceed to step 5 (worktree bookmark).
If `IN_WORKTREE=no` (main worktree) and the operation will produce file changes:
2. Check for existing worktrees matching the target spec/work:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-worktree-overlap.sh" "<SPEC-ID>"If the JSON output has "found": true, offer to reuse: "Worktree for <SPEC-ID> already exists at <path>. Reuse it?" If yes, inform the operator to restart the session with swain --resume <name>. If no, inform the operator to start a new session with the purpose text.
3. Worktree creation is handled by bin/swain pre-launch (SPEC-245, EPIC-056). Most runtimes (Gemini CLI, Codex, Copilot, Crush) cannot change their working directory mid-session — only Claude Code can via EnterWorktree, and that is a runtime-specific crutch, not a universal pattern. The correct universal approach is pre-launch isolation via bin/swain.
If SWAIN_WORKTREE_PATH is set, the agent is already in a managed worktree. If IN_WORKTREE=yes via git plumbing, the agent is in a worktree (possibly entered manually). In both cases, proceed with work.
If IN_WORKTREE=no and the operation requires isolation, inform the operator: "Not in a worktree. Start a new session with swain \"<purpose>\" to get worktree isolation."
4. After entering (if worktree was just created by bin/swain), re-run tab naming to reflect the new branch:
bash "$REPO_ROOT/.agents/bin/swain-tab-name.sh" --path "$(pwd)" --auto5. Record the worktree bookmark (SPEC-235). After entering a worktree, call swain-bookmark.sh to register it in session.json.
WT_PATH="$(pwd)"
WT_BRANCH="$(git branch --show-current 2>/dev/null || echo 'unknown')"
bash "$REPO_ROOT/.agents/bin/swain-bookmark.sh" worktree add "$WT_PATH" "$WT_BRANCH"Operator override: If the operator explicitly says "work on trunk" or "don't isolate," respect the override and proceed on trunk. Log a warning: "Proceeding on trunk at operator request — changes will land directly on the development branch."
Note (SPEC-195): swain-init does not create worktrees at startup — worktree creation is deferred to this preamble, which runs when swain-do dispatches actual work. This ensures worktree names reflect the work context and allows overlap detection.
When all tasks in the plan complete, or when the operator requests, run the plan completion handoff (see below) before exiting the worktree.
Plan completion and handoff
When all tasks under a plan epic are closed (or the operator declares the work done), execute this chain before exiting the worktree. This ensures retros, SPEC transitions, and EPIC cascades fire consistently.
Step 1 — Detect plan completion
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
export PATH="$REPO_ROOT/.agents/bin:$PATH"
# Check if any tasks under the plan epic are still open
OPEN_COUNT=$(ticket-query ".parent == \"<epic-id>\" and .status != \"closed\"" 2>/dev/null | wc -l | tr -d ' ')If OPEN_COUNT > 0, the plan is not complete — continue working or ask the operator. If OPEN_COUNT == 0, proceed.
Step 2 — Run completion pipeline (SPEC-257)
After detecting plan completion, run the quality gate pipeline before transitioning the SPEC. This ensures BDD tests, smoke tests, and retro all fire automatically.
2a — Create completion state file
Identify the SPEC ID from the plan epic's --external-ref:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SPEC_ID=$(tk show <epic-id> 2>/dev/null | grep -i 'external_ref' | awk '{print $NF}')
if [ -z "$SPEC_ID" ]; then
echo "WARNING: No SPEC linked to plan epic — skipping completion pipeline."
fiIf SPEC_ID is empty, skip Step 2 entirely and proceed to Step 3. Log the warning.
Create the state file per DESIGN-018:
mkdir -p "$REPO_ROOT/.agents"
jq -n --arg spec "$SPEC_ID" --arg branch "$(git branch --show-current)" \
'{spec_id: $spec, branch: $branch, pipeline_started: (now | todate), steps: {bdd_tests: {status: "pending", timestamp: null, detail: null}, smoke_test: {status: "pending", timestamp: null, detail: null}, retro: {status: "pending", timestamp: null, detail: null}}}' \
> "$REPO_ROOT/.agents/completion-state.json.tmp" \
&& mv "$REPO_ROOT/.agents/completion-state.json.tmp" "$REPO_ROOT/.agents/completion-state.json"2b — Run BDD tests
Check if swain-test.sh is available:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SWAIN_TEST="$REPO_ROOT/.agents/bin/swain-test.sh"If `swain-test.sh` exists: Run it once, capture output and exit code:
BDD_OUTPUT=$(bash "$SWAIN_TEST" --artifacts "$SPEC_ID" 2>&1)
BDD_EXIT=$?- If exit code is 0 → update state to
passed:
BDD_DETAIL=$(echo "$BDD_OUTPUT" | head -5 | tr '\n' ' ')
jq --arg status "passed" --arg detail "$BDD_DETAIL" \
'.steps.bdd_tests.status = $status | .steps.bdd_tests.timestamp = (now | todate) | .steps.bdd_tests.detail = $detail' \
"$REPO_ROOT/.agents/completion-state.json" > "$REPO_ROOT/.agents/completion-state.json.tmp" \
&& mv "$REPO_ROOT/.agents/completion-state.json.tmp" "$REPO_ROOT/.agents/completion-state.json"- If exit code is non-zero → update state to
failed:
BDD_DETAIL=$(echo "$BDD_OUTPUT" | tail -10 | tr '\n' ' ')
jq --arg status "failed" --arg detail "$BDD_DETAIL" \
'.steps.bdd_tests.status = $status | .steps.bdd_tests.timestamp = (now | todate) | .steps.bdd_tests.detail = $detail' \
"$REPO_ROOT/.agents/completion-state.json" > "$REPO_ROOT/.agents/completion-state.json.tmp" \
&& mv "$REPO_ROOT/.agents/completion-state.json.tmp" "$REPO_ROOT/.agents/completion-state.json"Stop the pipeline. Report the failure to the operator: "BDD tests failed. Details: {detail}. Say retry to re-run, or skip BDD to continue without."
If `swain-test.sh` does not exist: Warn and mark skipped:
jq --arg status "skipped" --arg detail "swain-test.sh not available" \
'.steps.bdd_tests.status = $status | .steps.bdd_tests.timestamp = (now | todate) | .steps.bdd_tests.detail = $detail' \
"$REPO_ROOT/.agents/completion-state.json" > "$REPO_ROOT/.agents/completion-state.json.tmp" \
&& mv "$REPO_ROOT/.agents/completion-state.json.tmp" "$REPO_ROOT/.agents/completion-state.json"Display: "swain-test not available — skipping BDD gate."
2c — Run smoke tests
Only proceed if bdd_tests is passed or skipped. If bdd_tests is failed, the pipeline is paused — do not run smoke.
BDD_STATUS=$(jq -r '.steps.bdd_tests.status' "$REPO_ROOT/.agents/completion-state.json")If BDD_STATUS is passed or skipped:
If `swain-test.sh` exists: The smoke test output from swain-test.sh includes a ## SMOKE section with manual verification steps. Extract and present these to the operator:
SMOKE_SECTION=$(bash "$SWAIN_TEST" --artifacts "$SPEC_ID" 2>&1 | sed -n '/^## SMOKE/,/^## /p' | head -20)Present the smoke instructions and ask for confirmation:
Smoke test instructions:
{smoke section content}
>
Did the smoke test pass? (yes / no / skip)
- yes → update
smoke_testtopassedwith detail "operator confirmed" - no → update
smoke_testtofailedwith detail from operator. Stop pipeline: "Smoke test failed. Say retry to re-check, or skip smoke to continue." - skip → update
smoke_testtoskippedwith detail "operator chose to skip"
Use the same jq update pattern from 2b to write state.
If `swain-test.sh` does not exist: Mark skipped with detail "swain-test.sh not available", same as BDD fallback.
2d — Run retrospective
Only proceed if both bdd_tests and smoke_test are passed or skipped. Retro cannot be skipped — if the operator says "skip retro", refuse: "Retro always runs. It captures learning even from imperfect work."
Invoke the swain-retro skill using the agent's Skill tool (this is a tool invocation, not a bash command):
Use the Skill tool: invokeswain-retrowith args:"SPEC completion — run retro for <SPEC-ID> before phase transition."
After swain-retro completes, update the state:
jq --arg status "passed" --arg detail "retro captured" \
'.steps.retro.status = $status | .steps.retro.timestamp = (now | todate) | .steps.retro.detail = $detail' \
"$REPO_ROOT/.agents/completion-state.json" > "$REPO_ROOT/.agents/completion-state.json.tmp" \
&& mv "$REPO_ROOT/.agents/completion-state.json.tmp" "$REPO_ROOT/.agents/completion-state.json"If swain-retro fails (skill invocation error), update state to failed and report. The operator can say retry to re-run.
2e — Skip and resume handling
Skip handling: At any point during the pipeline, the operator can say:
- "skip BDD" → set
bdd_teststoskippedwith detail "operator requested skip", continue to 2c - "skip smoke" → set
smoke_testtoskippedwith detail "operator requested skip", continue to 2d - "skip retro" → refuse. Retro always runs. Say: "Retro captures learning even from imperfect work — it cannot be skipped."
Resume handling: If a step failed and the operator says "retry" or "continue": 1. Read .agents/completion-state.json 2. Find the first step with status pending or failed 3. Resume the pipeline from that step — do not re-run steps that already passed or were skipped
Pipeline gate: After all three steps, verify completion:
PENDING=$(jq -r '.steps | to_entries[] | select(.value.status == "pending" or .value.status == "failed") | .key' "$REPO_ROOT/.agents/completion-state.json")If PENDING is empty (all steps passed or skipped), proceed to Step 3 (SPEC transition). If not, the pipeline is blocked — report which steps remain.
Step 3 — Invoke swain-design for SPEC transition
Pipeline gate (SPEC-257): Only proceed to SPEC transition if the completion pipeline passed. Check: jq -r '.steps | to_entries[] | select(.value.status == "pending" or .value.status == "failed") | .key' "$REPO_ROOT/.agents/completion-state.json". If any steps are pending or failed, do not transition — return to Step 2 to resolve.
Identify the SPEC linked to the plan epic (via --external-ref):
tk show <epic-id> 2>/dev/null # external_ref field contains the SPEC IDInvoke swain-design to transition the SPEC forward. The target phase depends on the spec's current state and whether verification is complete:
- If all acceptance criteria have evidence → transition to
Complete - If acceptance criteria need manual verification → transition to
Needs Manual Test - If implementation is done but untested → transition to
In Progress(if not already)
swain-design handles the downstream chain automatically:
- Checks whether the parent EPIC should also transition (all child SPECs complete → EPIC Complete)
- If the EPIC reaches a terminal state → invokes swain-retro for the EPIC-level retrospective (the SPEC-level retro already ran in Step 2d)
Step 4 — Offer merge and cleanup
After the SPEC transition completes, offer to merge and clean up:
All tasks closed. SPEC-NNN transitioned to {phase}. Merge this branch into {base-branch} and clean up the worktree?
If the operator accepts: 1. Ensure all changes are committed 2. Run /swain-sync to merge and push (marks lockfile ready_for_cleanup) 3. Worktree cleanup is handled by bin/swain after the runtime exits (SPEC-245)
If the operator declines, the branch is preserved. bin/swain will show it in the menu next launch.
Note (ADR-015): .tickets/ files in the worktree are ephemeral scaffolding and should not block cleanup. Tickets have no archival value after SPEC completion.
Skipping the chain
The operator can say "just exit" or "skip the handoff" to bypass Steps 2–4 and go directly to ExitWorktree. Log a note on the plan epic: tk add-note <epic-id> "Exited without completion handoff". The worktree remains for the next session.
Note: Skipping the chain means the completion pipeline did not run. If .agents/completion-state.json was already created (Step 2a ran), any pending steps remain pending. swain-teardown will detect this and invoke the missing steps before sync.
Bookmark management (ADR-023)
Bookmarks track what the operator is working on. They persist across sessions so the next session can pick up where this one left off.
Set bookmark
When the operator says "bookmark this", "remember where I am", or after state-changing operations:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-bookmark.sh" "<context note>"Infer the note from conversation context or the operator's explicit text. Do not prompt for a note if the context is clear.
Worktree bookmarks
When entering a worktree (already handled in the worktree isolation preamble, Step 5), the worktree is registered:
bash "$REPO_ROOT/.agents/bin/swain-bookmark.sh" worktree add "$WT_PATH" "$WT_BRANCH"Clear bookmark
When the operator says "clear bookmark" or "fresh start":
bash "$REPO_ROOT/.agents/bin/swain-bookmark.sh" --clearDecision recording (ADR-023)
When the operator or agent makes a significant decision (approves a spec, chooses an approach, sets direction), record it:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-state.sh" record-decision --note "Approved SPEC-119 implementation approach"Decisions are tracked against the session's decision budget. When the budget is reached, inform the operator and suggest running /swain-teardown.
Progress log (ADR-023)
After completing tasks or reaching milestones, update the progress log:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-progress-log.sh" --digest "$REPO_ROOT/.agents/session-log.jsonl"This updates each touched EPIC/Initiative's progress.md and ## Progress section.
Fallback
If tk cannot be found or is unavailable:
1. Log the failure reason. 2. Fall back to a neutral text task ledger (JSONL or Markdown checklist) in the working directory. 3. Use the same status model (open, in_progress, blocked, closed) and keep updates externally visible.
#!/usr/bin/env bash
# tk-plugin: Import tickets from .beads/issues.jsonl
# tk-plugin-version: 1.0.0
set -euo pipefail
jsonl=".beads/issues.jsonl"
if [[ ! -f "$jsonl" ]]; then
echo "Error: $jsonl not found" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for migration" >&2
exit 1
fi
# Default to .tickets in current directory if not set
TICKETS_DIR="${TICKETS_DIR:-.tickets}"
mkdir -p "$TICKETS_DIR"
# Single jq call generates all markdown with <<<FILE:id>>> delimiters
# Then awk splits into individual files (much faster than per-line jq calls)
# Beads dependency types map to: blocks->deps, parent-child->parent, related->links
jq -r '
def by_type(t): [.dependencies[]? | select(.type == t) | .depends_on_id];
def to_array: if length == 0 then "[]" else "[" + (map("\(.)") | join(", ")) + "]" end;
(by_type("blocks") | to_array) as $deps |
(by_type("related") | to_array) as $links |
(by_type("parent-child") | first // null) as $parent |
"<<<FILE:\(.id)>>>\n" +
"---\n" +
"id: \(.id)\n" +
"status: \(.status // "open")\n" +
"deps: \($deps)\n" +
"links: \($links)\n" +
"created: \(.created_at // "")\n" +
"type: \(.issue_type // "task")\n" +
"priority: \(.priority // 2)\n" +
(if .assignee and .assignee != "" then "assignee: \(.assignee)\n" else "" end) +
(if .external_ref and .external_ref != "" then "external-ref: \(.external_ref)\n" else "" end) +
(if $parent then "parent: \($parent)\n" else "" end) +
"---\n" +
"# \(.title // "Untitled")\n\n" +
(if .description and .description != "" then "\(.description)\n\n" else "" end) +
(if .design and .design != "" then "## Design\n\n\(.design)\n\n" else "" end) +
(if .acceptance_criteria and .acceptance_criteria != "" then "## Acceptance Criteria\n\n\(.acceptance_criteria)\n\n" else "" end) +
(if .notes and .notes != "" then "## Notes\n\n\(.notes)\n\n" else "" end)
' "$jsonl" | awk -v dir="$TICKETS_DIR" '
/^<<<FILE:.*>>>$/ {
if (file) close(file)
id = substr($0, 9, length($0) - 11)
file = dir "/" id ".md"
count++
print "Migrated: " id
next
}
file { print > file }
END { if (file) close(file); print "Migrated " count " tickets from beads" }
'
#!/usr/bin/env bash
# tk-plugin: Output tickets as JSON, optionally filtered with jq
# tk-plugin-version: 1.1.0
set -euo pipefail
filter="${1:-}"
# Auto-detect TICKETS_DIR if not already set (walk up from $PWD, same logic as tk)
if [[ -z "${TICKETS_DIR:-}" ]]; then
_dir="$PWD"
while [[ "$_dir" != "/" ]]; do
if [[ -d "$_dir/.tickets" ]]; then
TICKETS_DIR="$_dir/.tickets"
break
fi
_dir=$(dirname "$_dir")
done
if [[ -z "${TICKETS_DIR:-}" ]]; then
[[ -d "/.tickets" ]] && TICKETS_DIR="/.tickets"
fi
if [[ -z "${TICKETS_DIR:-}" ]]; then
echo "ticket-query: no .tickets directory found (searched from $PWD up)" >&2
exit 1
fi
fi
# Generate all JSON in one awk pass
json_output=$(awk '
BEGIN { FS=": "; in_front=0 }
FNR==1 {
if (prev_file) emit()
field_count=0; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^[a-zA-Z]/ {
key = $1
val = substr($0, length($1) + 3)
gsub(/^ +| +$/, "", val)
field_count++
field_keys[field_count] = key
field_vals[field_count] = val
}
function emit() {
if (field_count > 0) {
printf "{"
for (i = 1; i <= field_count; i++) {
if (i > 1) printf ","
key = field_keys[i]
val = field_vals[i]
# Handle arrays
if (val ~ /^\[.*\]$/) {
gsub(/^\[|\]$/, "", val)
n = split(val, items, ", *")
printf "\"%s\":[", key
for (j = 1; j <= n; j++) {
if (j > 1) printf ","
gsub(/^ +| +$/, "", items[j])
if (items[j] != "") printf "\"%s\"", items[j]
}
printf "]"
} else {
printf "\"%s\":\"%s\"", key, val
}
}
printf "}\n"
}
}
END { if (prev_file) emit() }
' "$TICKETS_DIR"/*.md 2>/dev/null)
if [[ -n "$filter" ]]; then
echo "$json_output" | jq -c "select($filter)"
else
echo "$json_output"
fi
#!/usr/bin/env bash
set -euo pipefail
# ticket - minimal ticket system with dependency tracking
# Stores markdown files with YAML frontmatter in .tickets/
#
# Originally from: https://github.com/wedow/ticket
# Copyright (c) 2025 wedow — MIT License
# Vendored and modified for swain (https://github.com/cristoslc/swain)
# Find .tickets directory by walking parent directories
find_tickets_dir() {
# Explicit env var takes priority
[[ -n "${TICKETS_DIR:-}" ]] && { echo "$TICKETS_DIR"; return 0; }
# Walk parents looking for .tickets
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
if [[ -d "$dir/.tickets" ]]; then
echo "$dir/.tickets"
return 0
fi
dir=$(dirname "$dir")
done
# Check root too
[[ -d "/.tickets" ]] && { echo "/.tickets"; return 0; }
# Not found
return 1
}
# Commands that can create .tickets if not found
WRITE_COMMANDS="create claim"
# Initialize TICKETS_DIR based on command type
init_tickets_dir() {
local cmd="$1"
local is_write_cmd=0
[[ " $WRITE_COMMANDS " == *" $cmd "* ]] && is_write_cmd=1
if TICKETS_DIR=$(find_tickets_dir); then
# For read commands, verify the directory exists
if [[ $is_write_cmd -eq 0 ]] && [[ ! -d "$TICKETS_DIR" ]]; then
echo "Error: tickets directory '$TICKETS_DIR' does not exist" >&2
return 1
fi
return 0
fi
# Not found - write commands can initialize in current directory
if [[ $is_write_cmd -eq 1 ]]; then
TICKETS_DIR=".tickets"
return 0
fi
echo "Error: no .tickets directory found (searched parent directories)" >&2
echo "Run 'tk create' to initialize, or set TICKETS_DIR env var" >&2
return 1
}
TICKET_PAGER="${TICKET_PAGER:-${PAGER:-}}"
# Prefer ripgrep if available, fall back to grep
if command -v rg &>/dev/null; then
_grep() { rg "$@"; }
else
_grep() { grep "$@"; }
fi
# Portable ISO date (GNU date supports -Iseconds, BSD date does not)
_iso_date() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
# Portable sed -i (BSD requires -i '', GNU uses -i)
_sed_i() {
local file="$1"
shift
local tmp="${file}.tmp.$$"
sed "$@" "$file" > "$tmp" && mv "$tmp" "$file"
}
# Generate ticket ID from directory name + random string
generate_id() {
local dir_name
dir_name=$(basename "$(pwd)")
# Extract first letter of each hyphenated/underscored segment
local prefix
prefix=$(echo "$dir_name" | sed 's/[-_]/ /g' | awk '{for(i=1;i<=NF;i++) printf substr($i,1,1)}')
# Fallback to first 3 chars if single segment (prefix too short)
[[ ${#prefix} -lt 2 ]] && prefix="${dir_name:0:3}"
# 4-char random lower case alphanumeric string
local hash
hash=$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom | head -c 4)
echo "${prefix}-${hash}"
}
# Ensure tickets directory exists
ensure_dir() {
mkdir -p "$TICKETS_DIR"
}
# Get ticket file path (supports partial ID matching)
ticket_path() {
local id="$1"
# Trim leading/trailing whitespace (handles Claude/agent quirks)
read -r id <<< "$id"
local exact="$TICKETS_DIR/${id}.md"
if [[ -f "$exact" ]]; then
echo "$exact"
return 0
fi
# Try partial match (anywhere in filename)
local matches
matches=$(find "$TICKETS_DIR" -maxdepth 1 -name "*${id}*.md" 2>/dev/null | head -2)
local count
count=$(echo "$matches" | _grep -c . || true)
if [[ "$count" -eq 1 ]]; then
echo "$matches"
return 0
elif [[ "$count" -gt 1 ]]; then
echo "Error: ambiguous ID '$id' matches multiple tickets" >&2
return 1
else
echo "Error: ticket '$id' not found" >&2
return 1
fi
}
# Extract YAML field value
yaml_field() {
local file="$1"
local field="$2"
sed -n '/^---$/,/^---$/p' "$file" | _grep "^${field}:" | sed "s/^${field}: *//"
}
# Update YAML field
update_yaml_field() {
local file="$1"
local field="$2"
local value="$3"
if _grep -q "^${field}:" "$file"; then
_sed_i "$file" "s/^${field}:.*/${field}: ${value}/"
else
# Insert after first --- (beginning of frontmatter)
_sed_i "$file" "0,/^---$/ { /^---$/a\\
${field}: ${value}
}"
fi
}
cmd_create() {
ensure_dir
local title="" description="" design="" acceptance=""
local priority=2 issue_type="task" assignee="" external_ref="" parent="" tags=""
# Default assignee to git user.name if available
assignee=$(git config user.name 2>/dev/null || true)
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--description) description="$2"; shift 2 ;;
--design) design="$2"; shift 2 ;;
--acceptance) acceptance="$2"; shift 2 ;;
-p|--priority) priority="$2"; shift 2 ;;
-t|--type) issue_type="$2"; shift 2 ;;
-a|--assignee) assignee="$2"; shift 2 ;;
--external-ref) external_ref="$2"; shift 2 ;;
--parent) parent="$2"; shift 2 ;;
--tags) tags="$2"; shift 2 ;;
-*) echo "Unknown option: $1" >&2; return 1 ;;
*) title="$1"; shift ;;
esac
done
# Validate and resolve parent if specified
if [[ -n "$parent" ]]; then
local parent_file
parent_file=$(ticket_path "$parent") || return 1
parent=$(basename "$parent_file" .md)
fi
title="${title:-Untitled}"
local id
id=$(generate_id)
local file="$TICKETS_DIR/${id}.md"
local now
now=$(_iso_date)
{
echo "---"
echo "id: $id"
echo "status: open"
echo "deps: []"
echo "links: []"
echo "created: $now"
echo "type: $issue_type"
echo "priority: $priority"
[[ -n "$assignee" ]] && echo "assignee: $assignee"
[[ -n "$external_ref" ]] && echo "external-ref: $external_ref"
[[ -n "$parent" ]] && echo "parent: $parent"
if [[ -n "$tags" ]]; then
echo "tags: [${tags//,/, }]"
fi
echo "---"
echo "# $title"
echo ""
if [[ -n "$description" ]]; then
echo "$description"
echo ""
fi
if [[ -n "$design" ]]; then
echo "## Design"
echo ""
echo "$design"
echo ""
fi
if [[ -n "$acceptance" ]]; then
echo "## Acceptance Criteria"
echo ""
echo "$acceptance"
echo ""
fi
} > "$file"
echo "$id"
}
# Valid statuses
VALID_STATUSES="open in_progress closed"
validate_status() {
local status="$1"
for valid in $VALID_STATUSES; do
[[ "$status" == "$valid" ]] && return 0
done
echo "Error: invalid status '$status'. Must be one of: $VALID_STATUSES" >&2
return 1
}
cmd_status() {
if [[ $# -lt 2 ]]; then
echo "Usage: $(basename "$0") status <id> <status>" >&2
echo "Valid statuses: $VALID_STATUSES" >&2
return 1
fi
local id="$1"
local status="$2"
validate_status "$status" || return 1
local file
file=$(ticket_path "$id") || return 1
update_yaml_field "$file" "status" "$status"
echo "Updated $(basename "$file" .md) -> $status"
}
cmd_start() {
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") start <id>" >&2
return 1
fi
cmd_status "$1" "in_progress"
}
cmd_close() {
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") close <id>" >&2
return 1
fi
cmd_status "$1" "closed"
# Release claim lock if held (prevents stale lock accumulation)
local file
file=$(ticket_path "$1") || return 0
local real_id
real_id=$(basename "$file" .md)
local lockdir="$TICKETS_DIR/.locks/${real_id}"
[[ -d "$lockdir" ]] && rm -rf "$lockdir"
}
cmd_reopen() {
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") reopen <id>" >&2
return 1
fi
cmd_status "$1" "open"
}
cmd_claim() {
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") claim <id> [actor]" >&2
return 1
fi
local id="$1"
local actor="${2:-${USER:-agent}}"
local file
file=$(ticket_path "$id") || return 1
local real_id
real_id=$(basename "$file" .md)
local lockdir="$TICKETS_DIR/.locks/${real_id}"
mkdir -p "$TICKETS_DIR/.locks"
if mkdir "$lockdir" 2>/dev/null; then
echo "$actor" > "$lockdir/owner"
date -u +%Y-%m-%dT%H:%M:%SZ > "$lockdir/claimed"
cmd_status "$real_id" "in_progress"
update_yaml_field "$file" "assignee" "$actor"
echo "Claimed $real_id (locked by $actor)"
else
local owner
owner=$(cat "$lockdir/owner" 2>/dev/null || echo "unknown")
echo "Error: $real_id already claimed by $owner" >&2
return 1
fi
}
cmd_release() {
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") release <id>" >&2
return 1
fi
local id="$1"
local file
file=$(ticket_path "$id") || return 1
local real_id
real_id=$(basename "$file" .md)
local lockdir="$TICKETS_DIR/.locks/${real_id}"
if [[ -d "$lockdir" ]]; then
rm -rf "$lockdir"
echo "Released lock on $real_id"
else
echo "No lock found for $real_id"
fi
}
cmd_dep_tree() {
local full_mode=0
local root_id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--full) full_mode=1; shift ;;
*) root_id="$1"; shift ;;
esac
done
if [[ -z "$root_id" ]]; then
echo "Usage: ticket dep tree [--full] <id>" >&2
return 1
fi
awk -v root_pattern="$root_id" -v full_mode="$full_mode" '
BEGIN { FS=": "; in_front=0 }
FNR==1 {
if (prev_file) store()
id=""; status=""; title=""; deps=""; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^deps:/ {
deps = $2
gsub(/[\[\] ]/, "", deps)
}
!in_front && /^# / && title == "" { title = substr($0, 3) }
function store() {
if (id != "") {
statuses[id] = status
titles[id] = title
deps_str[id] = deps
n = split(deps, arr, ",")
for (i = 1; i <= n; i++) if (arr[i] != "") {
dep_count[id]++
dep_list[id, dep_count[id]] = arr[i]
}
}
}
END {
if (prev_file) store()
# Resolve partial ID
root = ""
for (id in statuses) {
if (index(id, root_pattern) > 0) {
if (root != "") {
print "Error: ambiguous ID " root_pattern > "/dev/stderr"
exit 1
}
root = id
}
}
if (root == "") {
print "Error: ticket " root_pattern " not found" > "/dev/stderr"
exit 1
}
# Find max depths using iterative approach with stack
stack[1] = root; stack_depth[1] = 0; stack_path[1] = ":"
sp = 1
while (sp > 0) {
id = stack[sp]; depth = stack_depth[sp]; path = stack_path[sp]
sp--
if (!(id in statuses)) continue
if (index(path, ":" id ":") > 0) continue
if (!(id in max_depth) || depth > max_depth[id]) {
max_depth[id] = depth
}
new_path = path id ":"
for (i = dep_count[id]; i >= 1; i--) {
child = dep_list[id, i]
if (child != "") {
sp++
stack[sp] = child
stack_depth[sp] = depth + 1
stack_path[sp] = new_path
}
}
}
# Compute subtree depths (iterative post-order)
delete stack; delete stack_depth; delete stack_path
delete visited
stack[1] = root; stack_path[1] = ":"; stack_phase[1] = 0
sp = 1
while (sp > 0) {
id = stack[sp]; path = stack_path[sp]; phase = stack_phase[sp]
if (!(id in statuses) || index(path, ":" id ":") > 0) { sp--; continue }
if (phase == 0) {
# First visit: push children
stack_phase[sp] = 1
new_path = path id ":"
for (i = dep_count[id]; i >= 1; i--) {
child = dep_list[id, i]
if (child != "" && !(child in subtree_depth)) {
sp++
stack[sp] = child
stack_path[sp] = new_path
stack_phase[sp] = 0
}
}
} else {
# Second visit: compute subtree depth
max_sub = max_depth[id]
for (i = 1; i <= dep_count[id]; i++) {
child = dep_list[id, i]
if (child in subtree_depth && subtree_depth[child] > max_sub) {
max_sub = subtree_depth[child]
}
}
subtree_depth[id] = max_sub
sp--
}
}
# Print tree (iterative with stack)
print root " [" statuses[root] "] " titles[root]
printed[root] = 1
delete stack
# Stack entries: id|depth|prefix|connector|path
# Start with root children
build_children(root, 0, "", "", ":" root ":")
while (print_sp > 0) {
id = print_stack_id[print_sp]
depth = print_stack_depth[print_sp]
prefix = print_stack_prefix[print_sp]
connector = print_stack_conn[print_sp]
path = print_stack_path[print_sp]
print_sp--
if (!(id in statuses)) continue
if (!full_mode && (id in printed)) continue
if (index(path, ":" id ":") > 0) continue
if (!full_mode && depth != max_depth[id]) continue
print prefix connector id " [" statuses[id] "] " titles[id]
if (!full_mode) printed[id] = 1
if (connector == "└── ") new_prefix = prefix " "
else new_prefix = prefix "│ "
build_children(id, depth, new_prefix, connector, path id ":")
}
}
function build_children(id, depth, prefix, connector, path, i, child, n, arr, sorted, j, tmp, min_idx) {
# Collect printable children
n = 0
for (i = 1; i <= dep_count[id]; i++) {
child = dep_list[id, i]
if (child == "") continue
if (!full_mode && (child in printed)) continue
if (!(child in max_depth)) continue
if (!full_mode && depth + 1 != max_depth[child]) continue
if (index(path, ":" child ":") > 0) continue
n++
arr[n] = child
}
if (n == 0) return
# Sort by subtree_depth, then by ticket ID (insertion sort)
for (i = 2; i <= n; i++) {
tmp = arr[i]
j = i - 1
while (j >= 1 && (subtree_depth[arr[j]] > subtree_depth[tmp] || \
(subtree_depth[arr[j]] == subtree_depth[tmp] && arr[j] > tmp))) {
arr[j + 1] = arr[j]
j--
}
arr[j + 1] = tmp
}
# Push to stack in reverse order (so first prints first)
for (i = n; i >= 1; i--) {
child = arr[i]
print_sp++
print_stack_id[print_sp] = child
print_stack_depth[print_sp] = depth + 1
print_stack_prefix[print_sp] = prefix
if (i == n) print_stack_conn[print_sp] = "└── "
else print_stack_conn[print_sp] = "├── "
print_stack_path[print_sp] = path
}
}
' "$TICKETS_DIR"/*.md 2>/dev/null
}
cmd_dep_cycle() {
awk '
BEGIN { FS=": "; in_front=0 }
FNR==1 {
if (prev_file) store()
id=""; status=""; title=""; deps=""; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^deps:/ {
deps = $2
gsub(/[\[\] ]/, "", deps)
}
!in_front && /^# / && title == "" { title = substr($0, 3) }
function store() {
if (id != "" && status != "closed") {
statuses[id] = status
titles[id] = title
deps_str[id] = deps
n = split(deps, arr, ",")
for (i = 1; i <= n; i++) if (arr[i] != "") {
dep_count[id]++
dep_list[id, dep_count[id]] = arr[i]
}
}
}
# DFS cycle detection
function dfs(node, path, path_len, i, child, result) {
if (!(node in statuses)) return ""
if (state[node] == 2) return "" # black - fully visited
if (state[node] == 1) {
# gray - found cycle, extract it
cycle = node
for (i = path_len; i >= 1; i--) {
cycle = path[i] " -> " cycle
if (path[i] == node) break
}
return cycle
}
state[node] = 1 # gray - visiting
path[path_len + 1] = node
for (i = 1; i <= dep_count[node]; i++) {
child = dep_list[node, i]
result = dfs(child, path, path_len + 1)
if (result != "") return result
}
state[node] = 2 # black - done
return ""
}
END {
if (prev_file) store()
cycle_count = 0
for (id in statuses) {
if (state[id] == 0) {
delete path
result = dfs(id, path, 0)
if (result != "") {
# Check if this cycle is already found (normalized)
# Extract cycle members
n = split(result, parts, " -> ")
# Normalize: find smallest ID as starting point
min_id = parts[1]
min_idx = 1
for (i = 2; i < n; i++) { # skip last (duplicate of first)
if (parts[i] < min_id) {
min_id = parts[i]
min_idx = i
}
}
# Build normalized cycle string
norm = ""
for (i = 0; i < n - 1; i++) {
idx = ((min_idx - 1 + i) % (n - 1)) + 1
norm = (norm == "") ? parts[idx] : norm "," parts[idx]
}
# Check if already seen
if (!(norm in seen_cycles)) {
seen_cycles[norm] = 1
cycle_count++
cycles[cycle_count] = result
cycle_members[cycle_count] = norm
}
}
}
}
if (cycle_count == 0) {
print "No dependency cycles found"
} else {
for (c = 1; c <= cycle_count; c++) {
if (c > 1) print ""
print "Cycle " c ": " cycles[c]
n = split(cycle_members[c], members, ",")
for (i = 1; i <= n; i++) {
id = members[i]
printf " %-8s [%s] %s\n", id, statuses[id], titles[id]
}
}
}
}
' "$TICKETS_DIR"/*.md 2>/dev/null
}
cmd_dep() {
# Handle subcommands
if [[ "${1:-}" == "tree" ]]; then
shift
cmd_dep_tree "$@"
return
fi
if [[ "${1:-}" == "cycle" ]]; then
shift
cmd_dep_cycle "$@"
return
fi
if [[ $# -lt 2 ]]; then
echo "Usage: ticket dep <id> <dependency-id>" >&2
echo " ticket dep tree <id> - show dependency tree" >&2
echo " ticket dep cycle - find dependency cycles" >&2
return 1
fi
local id="$1"
local dep_id="$2"
local file
file=$(ticket_path "$id") || return 1
# Verify dependency exists and resolve to full ID
local dep_file
dep_file=$(ticket_path "$dep_id") || return 1
dep_id=$(basename "$dep_file" .md)
# Get current deps
local current_deps
current_deps=$(yaml_field "$file" "deps")
# Add dep if not already present
if echo "$current_deps" | _grep -q "$dep_id"; then
echo "Dependency already exists"
return 0
fi
# Update deps array
if [[ "$current_deps" == "[]" ]]; then
update_yaml_field "$file" "deps" "[$dep_id]"
else
local new_deps
new_deps=$(echo "$current_deps" | sed "s/\]/, $dep_id]/")
update_yaml_field "$file" "deps" "$new_deps"
fi
echo "Added dependency: $(basename "$file" .md) -> $dep_id"
}
cmd_ready() {
local assignee_filter="" tag_filter=""
while [[ $# -gt 0 ]]; do
case "$1" in
-a) assignee_filter="$2"; shift 2 ;;
--assignee=*) assignee_filter="${1#--assignee=}"; shift ;;
-T) tag_filter="$2"; shift 2 ;;
--tag=*) tag_filter="${1#--tag=}"; shift ;;
*) shift ;;
esac
done
awk -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" '
BEGIN { FS=": "; in_front=0 }
FNR==1 {
if (prev_file) store()
id=""; status=""; title=""; deps=""; priority=""; assignee=""; tags=""; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^priority:/ { priority = $2 }
in_front && /^assignee:/ { assignee = $2 }
in_front && /^tags:/ { tags = $2; gsub(/[\[\] ]/, "", tags) }
in_front && /^deps:/ {
deps = $2
gsub(/[\[\] ]/, "", deps)
}
!in_front && /^# / && title == "" { title = substr($0, 3) }
function has_tag(tags_str, tag, i, n, arr) {
n = split(tags_str, arr, ",")
for (i = 1; i <= n; i++) if (arr[i] == tag) return 1
return 0
}
function store() {
if (id != "") {
statuses[id] = status
titles[id] = title
deps_raw[id] = deps
priorities[id] = (priority != "") ? priority : 2
assignees[id] = assignee
all_tags[id] = tags
}
}
END {
if (prev_file) store()
# Find ready tickets: active with all deps closed
for (id in statuses) {
status = statuses[id]
if (status != "open" && status != "in_progress") continue
if (assignee_filter != "" && assignees[id] != assignee_filter) continue
if (tag_filter != "" && !has_tag(all_tags[id], tag_filter)) continue
deps = deps_raw[id]
ready = 1
if (deps != "") {
n = split(deps, arr, ",")
for (i = 1; i <= n; i++) {
dep = arr[i]
if (dep != "" && statuses[dep] != "closed") {
ready = 0
break
}
}
}
if (ready) {
output[++count] = sprintf("%s|%s|%s|%s", priorities[id], id, status, titles[id])
}
}
# Sort by priority, then by id
for (i = 1; i <= count; i++) {
for (j = i + 1; j <= count; j++) {
split(output[i], a, "|")
split(output[j], b, "|")
if (a[1] > b[1] || (a[1] == b[1] && a[2] > b[2])) {
tmp = output[i]; output[i] = output[j]; output[j] = tmp
}
}
}
for (i = 1; i <= count; i++) {
split(output[i], f, "|")
printf "%-8s [P%s][%s] - %s\n", f[2], f[1], f[3], f[4]
}
}
' "$TICKETS_DIR"/*.md 2>/dev/null
}
cmd_closed() {
local limit=20 assignee_filter="" tag_filter=""
while [[ $# -gt 0 ]]; do
case "$1" in
--limit=*) limit="${1#--limit=}"; shift ;;
-a) assignee_filter="$2"; shift 2 ;;
--assignee=*) assignee_filter="${1#--assignee=}"; shift ;;
-T) tag_filter="$2"; shift 2 ;;
--tag=*) tag_filter="${1#--tag=}"; shift ;;
*) shift ;;
esac
done
# List files by mtime (most recent first), filter closed, limit output
local files
files=$(ls -t "$TICKETS_DIR"/*.md 2>/dev/null | head -n 100)
[[ -z "$files" ]] && return 0
echo "$files" | xargs awk -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" '
BEGIN { FS=": "; in_front=0 }
FNR==1 {
if (prev_file) emit()
id=""; status=""; title=""; assignee=""; tags=""; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^assignee:/ { assignee = $2 }
in_front && /^tags:/ { tags = $2; gsub(/[\[\] ]/, "", tags) }
!in_front && /^# / && title == "" { title = substr($0, 3) }
function has_tag(tags_str, tag, i, n, arr) {
n = split(tags_str, arr, ",")
for (i = 1; i <= n; i++) if (arr[i] == tag) return 1
return 0
}
function emit() {
if (id != "" && (status == "closed" || status == "done") && (assignee_filter == "" || assignee == assignee_filter) && (tag_filter == "" || has_tag(tags, tag_filter))) {
output[++count] = sprintf("%-8s [%s] - %s", id, status, title)
}
}
END {
if (prev_file) emit()
for (i = 1; i <= count; i++) print output[i]
}
' | head -n "$limit"
}
cmd_blocked() {
local assignee_filter="" tag_filter=""
while [[ $# -gt 0 ]]; do
case "$1" in
-a) assignee_filter="$2"; shift 2 ;;
--assignee=*) assignee_filter="${1#--assignee=}"; shift ;;
-T) tag_filter="$2"; shift 2 ;;
--tag=*) tag_filter="${1#--tag=}"; shift ;;
*) shift ;;
esac
done
awk -v assignee_filter="$assignee_filter" -v tag_filter="$tag_filter" '
BEGIN { FS=": "; in_front=0 }
FNR==1 {
if (prev_file) store()
id=""; status=""; title=""; deps=""; priority=""; assignee=""; tags=""; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^priority:/ { priority = $2 }
in_front && /^assignee:/ { assignee = $2 }
in_front && /^tags:/ { tags = $2; gsub(/[\[\] ]/, "", tags) }
in_front && /^deps:/ {
deps = $2
gsub(/[\[\] ]/, "", deps)
}
!in_front && /^# / && title == "" { title = substr($0, 3) }
function has_tag(tags_str, tag, i, n, arr) {
n = split(tags_str, arr, ",")
for (i = 1; i <= n; i++) if (arr[i] == tag) return 1
return 0
}
function store() {
if (id != "") {
statuses[id] = status
titles[id] = title
deps_raw[id] = deps
priorities[id] = (priority != "") ? priority : 2
assignees[id] = assignee
all_tags[id] = tags
}
}
END {
if (prev_file) store()
# Find blocked tickets: active with at least one dep not closed
for (id in statuses) {
status = statuses[id]
if (status != "open" && status != "in_progress") continue
if (assignee_filter != "" && assignees[id] != assignee_filter) continue
if (tag_filter != "" && !has_tag(all_tags[id], tag_filter)) continue
deps = deps_raw[id]
if (deps == "") continue
blocked = 0
n = split(deps, arr, ",")
for (i = 1; i <= n; i++) {
dep = arr[i]
if (dep != "" && statuses[dep] != "closed") {
blocked = 1
break
}
}
if (blocked) {
# Build list of only open blockers
blockers = ""
n = split(deps, arr, ",")
for (i = 1; i <= n; i++) {
dep = arr[i]
if (dep != "" && statuses[dep] != "closed") {
blockers = (blockers == "") ? dep : blockers ", " dep
}
}
output[++count] = sprintf("%s|%s|%s|%s|[%s]", priorities[id], id, status, titles[id], blockers)
}
}
# Sort by priority, then by id
for (i = 1; i <= count; i++) {
for (j = i + 1; j <= count; j++) {
split(output[i], a, "|")
split(output[j], b, "|")
if (a[1] > b[1] || (a[1] == b[1] && a[2] > b[2])) {
tmp = output[i]; output[i] = output[j]; output[j] = tmp
}
}
}
for (i = 1; i <= count; i++) {
split(output[i], f, "|")
printf "%-8s [P%s][%s] - %s <- %s\n", f[2], f[1], f[3], f[4], f[5]
}
}
' "$TICKETS_DIR"/*.md 2>/dev/null
}
cmd_undep() {
if [[ $# -lt 2 ]]; then
echo "Usage: ticket undep <id> <dependency-id>" >&2
return 1
fi
local id="$1"
local dep_id="$2"
local file
file=$(ticket_path "$id") || return 1
# Resolve dep_id to full ID
local dep_file
dep_file=$(ticket_path "$dep_id") || return 1
dep_id=$(basename "$dep_file" .md)
local current_deps
current_deps=$(yaml_field "$file" "deps")
if ! echo "$current_deps" | _grep -q "$dep_id"; then
echo "Dependency not found"
return 1
fi
# Remove dep from array
local new_deps
new_deps=$(echo "$current_deps" | sed "s/, *$dep_id//g; s/$dep_id, *//g; s/$dep_id//g")
# Clean up empty array case
[[ "$new_deps" == "[]" || "$new_deps" == "[, ]" || "$new_deps" == "[ ]" ]] && new_deps="[]"
update_yaml_field "$file" "deps" "$new_deps"
echo "Removed dependency: $(basename "$file" .md) -/-> $dep_id"
}
add_link_to_file() {
local file="$1"
local target_id="$2"
local current_links
current_links=$(yaml_field "$file" "links" || true)
[[ -z "$current_links" ]] && current_links="[]"
# Skip if already present
if echo "$current_links" | _grep -q "$target_id"; then
return 0
fi
if [[ "$current_links" == "[]" ]]; then
update_yaml_field "$file" "links" "[$target_id]"
else
local new_links
new_links=$(echo "$current_links" | sed "s/\]/, $target_id]/")
update_yaml_field "$file" "links" "$new_links"
fi
}
cmd_link() {
if [[ $# -lt 2 ]]; then
echo "Usage: ticket link <id> <id> [id...]" >&2
return 1
fi
# Resolve all ticket paths first
local -a ids=() files=()
for arg in "$@"; do
local file
file=$(ticket_path "$arg") || return 1
ids+=("$(basename "$file" .md)")
files+=("$file")
done
# Use awk to update all files in one pass per file
local id_list
id_list=$(printf '%s\n' "${ids[@]}")
local count=0
for ((i=0; i<${#ids[@]}; i++)); do
local file="${files[$i]}"
local self="${ids[$i]}"
# Build list of other IDs to link
local others=""
for ((j=0; j<${#ids[@]}; j++)); do
[[ $i -ne $j ]] && others="$others ${ids[$j]}"
done
# Update file with awk - add missing links
local result
result=$(awk -v self="$self" -v others="$others" '
BEGIN {
n = split(others, other_arr, " ")
for (i = 1; i <= n; i++) need[other_arr[i]] = 1
}
/^links:/ {
# Parse existing links
gsub(/[\[\]]/, "", $0)
sub(/^links: */, "", $0)
m = split($0, existing, ", *")
for (i = 1; i <= m; i++) {
if (existing[i] != "") {
have[existing[i]] = 1
delete need[existing[i]]
}
}
# Build new links array
out = ""
for (i = 1; i <= m; i++) {
if (existing[i] != "") {
out = (out == "") ? existing[i] : out ", " existing[i]
}
}
for (id in need) {
out = (out == "") ? id : out ", " id
added++
}
print "links: [" out "]"
found = 1
next
}
{ print }
END { printf "%d", added > "/dev/stderr" }
' "$file" 2>&1 >"${file}.tmp")
mv "${file}.tmp" "$file"
((count += result)) || true
done
if [[ $count -eq 0 ]]; then
echo "All links already exist"
else
echo "Added $count link(s) between ${#ids[@]} tickets"
fi
}
remove_link_from_file() {
local file="$1"
local target_id="$2"
local current_links
current_links=$(yaml_field "$file" "links" || true)
# Skip if not present
if [[ -z "$current_links" ]] || ! echo "$current_links" | _grep -q "$target_id"; then
return 0
fi
local new_links
new_links=$(echo "$current_links" | sed "s/, *$target_id//g; s/$target_id, *//g; s/$target_id//g")
[[ "$new_links" == "[]" || "$new_links" == "[, ]" || "$new_links" == "[ ]" ]] && new_links="[]"
update_yaml_field "$file" "links" "$new_links"
}
cmd_unlink() {
if [[ $# -lt 2 ]]; then
echo "Usage: ticket unlink <id> <target-id>" >&2
return 1
fi
# Resolve both IDs to full IDs
local file target_file
file=$(ticket_path "$1") || return 1
target_file=$(ticket_path "$2") || return 1
local id target_id
id=$(basename "$file" .md)
target_id=$(basename "$target_file" .md)
local current_links
current_links=$(yaml_field "$file" "links" || true)
if [[ -z "$current_links" ]] || ! echo "$current_links" | _grep -q "$target_id"; then
echo "Link not found"
return 1
fi
# Remove from both files
remove_link_from_file "$file" "$target_id"
remove_link_from_file "$target_file" "$id"
echo "Removed link: $(basename "$file" .md) <-> $target_id"
}
cmd_show() {
if [[ $# -lt 1 ]]; then
echo "Usage: ticket show <id>" >&2
return 1
fi
local file
file=$(ticket_path "$1") || return 1
local target_id
target_id=$(basename "$file" .md)
_show_output() {
awk -v target="$target_id" -v target_file="$file" '
BEGIN { FS=": "; in_front=0 }
# First pass: collect all ticket metadata
FNR==1 {
if (prev_file) store()
id=""; status=""; title=""; deps=""; links=""; parent=""; in_front=0
prev_file=FILENAME
}
/^---$/ { in_front = !in_front; next }
in_front && /^id:/ { id = $2 }
in_front && /^status:/ { status = $2 }
in_front && /^deps:/ { deps = $2; gsub(/[\[\] ]/, "", deps) }
in_front && /^links:/ { links = $2; gsub(/[\[\] ]/, "", links) }
in_front && /^parent:/ { parent = $2 }
!in_front && /^# / && title == "" { title = substr($0, 3) }
function store() {
if (id != "") {
statuses[id] = status
titles[id] = title
all_deps[id] = deps
all_links[id] = links
parents[id] = parent
}
}
END {
if (prev_file) store()
# Build inverse relationships
for (id in statuses) {
# Children: tickets where parent == target
if (parents[id] == target) {
children_count++
children[children_count] = id
}
# Blocking: tickets where target is in their deps
deps = all_deps[id]
if (deps != "") {
n = split(deps, arr, ",")
for (i = 1; i <= n; i++) {
if (arr[i] == target && statuses[id] != "closed") {
blocking_count++
blocking[blocking_count] = id
}
}
}
}
# Now output the target file with enhancements
in_front = 0
while ((getline line < target_file) > 0) {
if (line == "---") {
in_front = !in_front
print line
} else if (in_front && line ~ /^parent:/) {
# Add parent title as comment
p = line
sub(/^parent: */, "", p)
if (p in titles) {
print line " # " titles[p]
} else {
print line
}
} else {
print line
}
}
close(target_file)
# Collect blockers (unclosed deps)
deps = all_deps[target]
if (deps != "") {
n = split(deps, arr, ",")
blocker_count = 0
for (i = 1; i <= n; i++) {
d = arr[i]
if (d != "" && statuses[d] != "closed") {
blocker_count++
blockers[blocker_count] = d
}
}
if (blocker_count > 0) {
print ""
print "## Blockers"
print ""
for (i = 1; i <= blocker_count; i++) {
d = blockers[i]
printf "- %s [%s] %s\n", d, statuses[d], titles[d]
}
}
}
# Show tickets this is blocking
if (blocking_count > 0) {
print ""
print "## Blocking"
print ""
for (i = 1; i <= blocking_count; i++) {
b = blocking[i]
printf "- %s [%s] %s\n", b, statuses[b], titles[b]
}
}
# Show children
if (children_count > 0) {
print ""
print "## Children"
print ""
for (i = 1; i <= children_count; i++) {
c = children[i]
printf "- %s [%s] %s\n", c, statuses[c], titles[c]
}
}
# Collect linked tickets
links = all_links[target]
if (links != "") {
n = split(links, arr, ",")
print ""
print "## Linked"
print ""
for (i = 1; i <= n; i++) {
l = arr[i]
if (l != "") {
printf "- %s [%s] %s\n", l, statuses[l], titles[l]
}
}
}
}
' "$TICKETS_DIR"/*.md 2>/dev/null
}
if [[ -t 1 && -n "$TICKET_PAGER" ]]; then
read -r -a pager_cmd <<<"$TICKET_PAGER"
_show_output | "${pager_cmd[@]}"
else
_show_output
fi
}
cmd_add_note() {
if [[ $# -lt 1 ]]; then
echo "Usage: ticket add-note <id> [note text]" >&2
return 1
fi
local file
file=$(ticket_path "$1") || return 1
shift
local note
if [[ $# -gt 0 ]]; then
note="$*"
elif [[ ! -t 0 ]]; then
note=$(cat)
else
echo "Error: no note provided" >&2
return 1
fi
local timestamp
timestamp=$(_iso_date)
# Add Notes section if missing, then append timestamped note
if ! grep -q '^## Notes' "$file"; then
printf '\n## Notes\n' >> "$file"
fi
printf '\n**%s**\n\n%s\n' "$timestamp" "$note" >> "$file"
echo "Note added to $(basename "$file" .md)"
}
# List installed plugins with descriptions
# Scripts: # tk-plugin: description (comment in first 10 lines)
# Binaries: --tk-describe flag outputs description
_list_plugins() {
local seen="" plugin cmd desc path out
for prefix in tk ticket; do
while IFS= read -r plugin; do
[[ -z "$plugin" ]] && continue
cmd="${plugin#${prefix}-}"
# Skip if already seen (tk- takes precedence over ticket-)
case " $seen " in *" $cmd "*) continue ;; esac
seen="$seen $cmd"
path=$(command -v "$plugin" 2>/dev/null) || continue
[[ -f "$path" ]] || continue
desc=""
# Try comment first (fast, no execution)
desc=$(head -10 "$path" 2>/dev/null | grep -m1 '^# tk-plugin:' | sed 's/^# tk-plugin: *//')
# Fall back to --tk-describe for binaries (requires timeout command)
if [[ -z "$desc" ]] && command -v timeout &>/dev/null; then
if out=$(timeout 1 "$path" --tk-describe 2>/dev/null); then
out=$(printf '%s' "$out" | head -1)
[[ "$out" == "tk-plugin:"* ]] && desc="${out#tk-plugin: }"
fi
fi
printf " %-22s %s\n" "$cmd" "${desc:-(no description)}"
done < <(compgen -c "${prefix}-" 2>/dev/null | sort -u)
done
}
cmd_help() {
local cmd
cmd=$(basename "$0")
cat << EOF
$cmd - minimal ticket system with dependency tracking
Usage: $cmd <command> [args]
Commands:
create [title] [options] Create ticket, prints ID
-d, --description Description text
--design Design notes
--acceptance Acceptance criteria
-t, --type Type (bug|feature|task|epic|chore) [default: task]
-p, --priority Priority 0-4, 0=highest [default: 2]
-a, --assignee Assignee
--external-ref External reference (e.g., gh-123, JIRA-456)
--parent Parent ticket ID
--tags Comma-separated tags (e.g., --tags ui,backend,urgent)
start <id> Set status to in_progress
close <id> Set status to closed
reopen <id> Set status to open
claim <id> [actor] Atomic claim: mkdir lock + start + assign
release <id> Remove claim lock
status <id> <status> Update status (open|in_progress|closed)
dep <id> <dep-id> Add dependency (id depends on dep-id)
dep tree [--full] <id> Show dependency tree (--full disables dedup)
dep cycle Find dependency cycles in open tickets
undep <id> <dep-id> Remove dependency
link <id> <id> [id...] Link tickets together (symmetric)
unlink <id> <target-id> Remove link between tickets
ready [-a X] [-T X] List open/in-progress tickets with deps resolved
blocked [-a X] [-T X] List open/in-progress tickets with unresolved deps
closed [--limit=N] [-a X] [-T X] List recently closed tickets (default 20, by mtime)
show <id> Display ticket
add-note <id> [text] Append timestamped note (or pipe via stdin)
super <cmd> [args] Bypass plugins, run built-in command directly
EOF
# List installed plugins
local plugins
plugins=$(_list_plugins 2>/dev/null)
if [[ -n "$plugins" ]]; then
cat << EOF
Plugins (tk-<cmd> or ticket-<cmd> in PATH):
$plugins
EOF
fi
cat << EOF
Use 'super' to bypass plugins. Plugins receive TICKETS_DIR and TK_SCRIPT
env vars; use '\$TK_SCRIPT super <cmd>' to call built-ins.
Plugin descriptions: comment '# tk-plugin: text' or --tk-describe flag
Tickets stored as markdown files in .tickets/
Supports partial ID matching (e.g., '$cmd show 5c4' matches 'nw-5c46')
EOF
}
# Main dispatch
# Handle 'super' to bypass plugins
_tk_super=0
if [[ "${1:-}" == "super" ]]; then
_tk_super=1
shift
fi
# Check for plugin commands first (unless super mode)
# Plugins are responsible for their own TICKETS_DIR handling
if [[ $_tk_super -eq 0 && -n "${1:-}" && "${1:-}" != "help" && "${1:-}" != "--help" && "${1:-}" != "-h" ]]; then
for _prefix in tk ticket; do
_plugin="${_prefix}-$1"
if command -v "$_plugin" &>/dev/null; then
# Export context for plugins
export TICKETS_DIR="${TICKETS_DIR:-$(find_tickets_dir 2>/dev/null || echo "")}"
export TK_SCRIPT="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
shift
exec "$_plugin" "$@"
fi
done
fi
# Initialize tickets dir for built-in commands (skip for help)
case "${1:-help}" in
help|--help|-h) ;;
*) init_tickets_dir "${1:-}" || exit 1 ;;
esac
# Built-in commands
case "${1:-help}" in
create) shift; cmd_create "$@" ;;
start) shift; cmd_start "$@" ;;
close) shift; cmd_close "$@" ;;
reopen) shift; cmd_reopen "$@" ;;
claim) shift; cmd_claim "$@" ;;
release) shift; cmd_release "$@" ;;
status) shift; cmd_status "$@" ;;
dep) shift; cmd_dep "$@" ;;
undep) shift; cmd_undep "$@" ;;
link) shift; cmd_link "$@" ;;
unlink) shift; cmd_unlink "$@" ;;
ready) shift; cmd_ready "$@" ;;
blocked) shift; cmd_blocked "$@" ;;
closed) shift; cmd_closed "$@" ;;
show) shift; cmd_show "$@" ;;
add-note) shift; cmd_add_note "$@" ;;
help|--help|-h) cmd_help ;;
*)
echo "Unknown command: $1" >&2
cmd_help >&2
exit 1
;;
esac
Configuration
The skill stores persistent project-level configuration in .agents/execution-tracking.vars.json. This file is created on first run and checked on every subsequent invocation.
First-run setup
If .agents/execution-tracking.vars.json does not exist, create it by asking the user the questions below (use sensible defaults if the user says "just use defaults"):
| Key | Type | Default | Question |
|---|---|---|---|
tk_path | string | "bin/tk" | "Path to the vendored tk script (relative to project root)" |
fallback_format | "jsonl" \ | "markdown" | "jsonl" |
Write the file as pretty-printed JSON:
{
"tk_path": "bin/tk",
"fallback_format": "jsonl"
}On subsequent runs, read the file and apply its values — don't re-ask.
Applying config
- `tk_path`: Resolve this path relative to the project root to find the vendored tk script. Add its directory to PATH for plugin resolution.
- `fallback_format`: Controls the format used by the Fallback section.
Bootstrap workflow
1. Load config: Read .agents/execution-tracking.vars.json. If missing, run first-run setup above. 2. Resolve tk: The vendored tk script lives at the configured tk_path (default: bin/tk). Verify it exists and is executable. 3. Set up PATH: Export PATH with tk's directory prepended so plugins (ticket-query, ticket-migrate-beads) are found:
TK_BIN="$(cd "$(dirname "$tk_path")" && pwd)"
export PATH="$TK_BIN:$PATH"4. Check for existing data: look for .tickets/ directory. 5. If no `.tickets/`, first use: tk creates .tickets/ automatically on first tk create. 6. Verify: tk ready should run without error.
Escalation
When work cannot proceed as designed, use this protocol to abandon tasks and flow control back to swain-design for upstream changes before re-planning.
Triage table
| Scope | Situation | Action |
|---|---|---|
| Single task | Alternative approach exists | Abandon task, create replacement under same plan |
| Single task | Spec assumption is wrong | Abandon task, invoke swain-design to update SPEC, create replacement task |
| Multiple tasks | Direction change needed | Abandon affected tasks, create ADR + update SPEC via swain-design, seed new tasks |
| Entire plan | Fundamental rethink required | Abandon all tasks, abandon SPEC (and possibly EPIC) via swain-design, create new SPEC if needed |
Abandoning tasks
# Single task
tk add-note <id> "Abandoned: <why>"
tk close <id>
# Batch — close all open tasks under an epic (use ticket-query to find them)
for id in $(ticket-query '.parent == "<epic-id>" and .status == "open"' | jq -r '.id'); do
tk add-note "$id" "Abandoned: <why>"
tk close "$id"
done
# Preserve in-progress notes before closing
tk add-note <id> "Abandoning: <context about partial work>"
tk close <id>Escalation workflow
1. Record the blocker. Append notes to the plan epic explaining why work cannot proceed:
tk add-note <epic-id> "Blocked: <description of blocker>"2. Invoke swain-design. Choose the appropriate scope:
- Spec tweak — update the SPEC's assumptions or requirements, then return here.
- Design pivot — create an ADR documenting the decision change, update affected SPECs, then return here.
- Full abandon — transition the SPEC (and possibly EPIC) to Abandoned phase via swain-design.
3. Seed replacement plan from the updated spec. Create a new implementation plan linked to the same (or new) SPEC via origin ref:
tk create "Implement <updated approach>" -t epic --external-ref <SPEC-ID>4. Link lineage. Preserve traceability between abandoned and replacement work:
- Use the same
spec:<SPEC-ID>tags on new tasks. - Reference abandoned task IDs in the new epic's notes:
tk add-note <new-epic-id> "Replaces abandoned tasks: <old-id-1>, <old-id-2>"Cross-spec escalation
When abandoned tasks carry multiple spec: tags, each referenced spec may need upstream changes. Check every spec tag on the abandoned tasks and invoke swain-design for each affected spec before re-planning.
# List spec tags on a task
tk show <id> # tags are visible in the YAML frontmatterExecution Strategy
When dispatching implementation work, swain-do selects the execution strategy based on environment and task characteristics.
Strategy selection
superpowers installed?
├── YES → prefer subagent-driven development
│ ├── Complex task (multi-file, >5 min) → dispatch subagent with worktree
│ ├── Simple task (<5 min, single file) → serial execution (subagent overhead not worth it)
│ └── Research task → dispatch parallel investigation agents
└── NO → tk-tracked serial execution (current default)Detection: Check whether superpowers' execution skills exist:
ls .claude/skills/subagent-driven-development/SKILL.md .agents/skills/subagent-driven-development/SKILL.md \
.claude/skills/using-git-worktrees/SKILL.md .agents/skills/using-git-worktrees/SKILL.md 2>/dev/nullIf at least one path exists for each skill, subagent-driven development is available.
Worktree-artifact mapping
When a spec is implemented via a git worktree (superpowers' using-git-worktrees skill), swain-do records the mapping in the tk epic's notes:
tk add-note <epic-id> "Worktree: branch <branch-name> implements <SPEC-ID>"This enables:
- Status queries to show which worktrees are active for which specs
- Cleanup checks after spec completion (orphaned worktrees)
- Traceability between the spec artifact and its implementation branch
When the spec transitions to Implemented, verify the worktree has been cleaned up or merged.
Plan Ingestion (superpowers integration)
When a superpowers plan file exists (produced by the writing-plans skill), use the ingestion script instead of manually decomposing tasks. The script parses the plan's ### Task N: blocks and registers them in tk with full spec lineage.
The ingest helper lives at scripts/ingest-plan.py.
When to use
- A superpowers plan file exists at
docs/plans/YYYY-MM-DD-<name>.md - The plan follows the
writing-plansformat (header +### Task N:blocks) - You have an origin-ref artifact ID to link the plan to
Usage
# Parse and register in tk
uv run python3 scripts/ingest-plan.py <plan-file> <origin-ref>
# Parse only (preview without creating tk tasks)
uv run python3 scripts/ingest-plan.py <plan-file> <origin-ref> --dry-run
# With additional tags
uv run python3 scripts/ingest-plan.py <plan-file> <origin-ref> --tags epic:EPIC-009What it does
1. Parses the plan header (title, goal, architecture, tech stack) 2. Splits on ### Task N: boundaries 3. Creates a tk epic with --external-ref <origin-ref> 4. Creates child tasks with --tags spec:<origin-ref> and full task body as description 5. Wires sequential dependencies (Task N+1 depends on Task N)
When NOT to use
- The plan file doesn't follow superpowers format — fall back to manual task breakdown
- You need non-sequential dependencies — use the script, then adjust deps manually with
tk dep - The plan is very short (1-2 tasks) — manual creation is faster
Artifact/tk Reconciliation
When specwatch detects mismatches between artifact status and tk item state (via specwatch.sh tk-sync or specwatch.sh scan), this skill is responsible for cleanup. The specwatch log (.agents/specwatch.log) contains TK_SYNC and TK_ORPHAN entries identifying the mismatches.
Mismatch types and resolution
| Log entry | Meaning | Resolution |
|---|---|---|
TK_SYNC artifact Implemented, tk open | Spec is done but tasks linger | Close open tk items: tk add-note <id> "Reconciled: artifact already Implemented" then tk close <id> |
TK_SYNC artifact Abandoned, tk open | Spec was killed but tasks linger | Abandon open tk items: tk add-note <id> "Abandoned: parent artifact Abandoned" then tk close <id> |
TK_SYNC all tk closed, artifact active | All work done but spec not transitioned | Invoke swain-design to transition the artifact forward (e.g., Approved → Implemented) |
TK_ORPHAN tk refs non-existent artifact | tk items reference an artifact ID not found in docs/ | Investigate: artifact may have been renamed/deleted. Close or re-tag the tk items |
Reconciliation workflow
1. Read the log: grep '^TK_SYNC\|^TK_ORPHAN' .agents/specwatch.log 2. For each mismatch, apply the resolution from the table above. 3. Re-run sync check: specwatch.sh tk-sync to confirm all mismatches resolved.
Automated invocation
Specwatch runs tk-sync as part of specwatch.sh scan and during watch-mode event processing (when tk is available). When mismatches are found, the output directs the user to invoke swain-do for reconciliation.
TDD Enforcement
Implementation tasks follow strict RED-GREEN-REFACTOR methodology with anti-rationalization safeguards. These rules apply regardless of whether superpowers is installed — they are baked into swain-do's methodology.
Anti-rationalization table
When creating implementation plans, every task that involves writing code must follow this discipline:
| Rationalization | Why it's wrong | Rule |
|---|---|---|
| "I'll write the test after the code since I know what I'm building" | Tests written after confirm what was built, not what was specified. They miss edge cases the spec intended. | Write the failing test FIRST. The test is derived from the acceptance criterion, not the implementation. |
| "This is too simple to need a test" | Simplicity today becomes complexity tomorrow. Untested code is unverified code. | Every behavioral change gets a test. If it's truly simple, the test is also simple. |
| "I'll refactor first to make testing easier" | Refactoring without tests means refactoring without a safety net. | RED first. Write the test against the current interface, then refactor under test coverage. |
| "The integration test covers this" | Integration tests are slow and don't isolate failures. A unit test failing tells you exactly what broke. | Unit tests for logic, integration tests for wiring. Both are needed. |
| "I need to see the implementation to know what to test" | This means the spec is unclear, not that you should skip TDD. | If you can't write the test, the acceptance criterion needs clarification — escalate to swain-design. |
Task ordering
1. Test first. For each functional unit, create a test task before its implementation task. The test task writes a failing test derived from the artifact's acceptance criteria. 2. Small cycles. Prefer many small red-green pairs over a single "write all tests" → "write all code" split. 3. Refactor explicitly. Include a refactor task after green when the implementation warrants cleanup. 4. Integration tests bookend the plan. Start with a skeleton integration test (it will fail). The final task verifies it passes.
Post-GREEN coverage self-critique
After tests pass (GREEN) and before REFACTOR, pause and enumerate what you didn't test. This step is mandatory — do not skip it or defer it to a later task.
Dimensions to check
For each dimension, ask: "Did I write a test for this?" If not, note it.
| Dimension | What to look for |
|---|---|
| Flags and arguments | Every CLI flag, function parameter, and configuration option has at least one test exercising it. |
| Write side-effects | If the code writes to disk, updates state, or mutates external systems, a test verifies the write happened and the content is correct. |
| Fallback / degraded paths | If the code has fallback behavior (missing dependency, broken input, unavailable service), a test exercises the fallback — not just the happy path. |
| Error cases | Invalid input, missing files, permission failures, network errors — whatever can go wrong at the system boundary. |
| Idempotency | If the spec says "safe to call multiple times," a test runs it twice and asserts consistent output. |
| Integration | Do the tests verify the user's actual goal, or just the contract? If the spec exists to reduce visible tool calls from 5 to 1, is there a test that counts tool calls? |
Output format
If any dimension has no coverage, surface it before proceeding:
Tests pass (N/N). Untested dimensions:
- --skip-worktree flag (no test exercises it)- lastBranch write side-effect (no test checks the file was updated)>
Add tests for these before REFACTOR?
If the operator says proceed without additional tests, note which dimensions were skipped and move on. Do not block indefinitely — the self-critique is a gate, not a trap.
Anti-rationalization
| Rationalization | Rule |
|---|---|
| "The coverage is good enough" | Good enough for what? Name the untested dimension. If you can name it, you can test it. |
| "That edge case won't happen in practice" | If it can't happen, the test is trivial. If it can happen, it needs a test. |
| "I'll add more tests in the refactor phase" | REFACTOR changes structure, not behavior. New behavior = new RED-GREEN cycle, not a refactor task. |
Completion verification
No task may be claimed as complete without fresh verification evidence. This applies universally — not just to SPEC acceptance criteria, but to any tk task.
What counts as evidence
| Task type | Acceptable evidence |
|---|---|
| Code implementation | Test passes, manual verification output, screenshot |
| Documentation | Content review, link check, rendered preview |
| Configuration | Applied and tested in target environment |
| Research | Findings documented with sources |
Enforcement
When closing a task, add a note with evidence before closing:
# Good — includes evidence
tk add-note <id> "JWT middleware added; test_jwt_validation passes"
tk close <id>
# Bad — no evidence
tk close <id>If a task is closed without evidence, it should be reopened and completed properly. The verification discipline prevents "completion drift" where tasks are marked done based on intent rather than observed behavior.
tk (ticket) CLI cheatsheet
Quick reference for the vendored tk script at bin/tk.
Prerequisites
- tk is a single bash script — no runtime dependencies beyond bash + coreutils
- Vendored in the project at
bin/tk - Stores tickets as markdown files with YAML frontmatter in
.tickets/ - Plugins (
ticket-query,ticket-migrate-beads) live in the samebin/directory
Setup
tk requires no explicit initialization. The .tickets/ directory is created automatically on first tk create.
To use tk from anywhere in the project:
TK_BIN="$(cd bin && pwd)" # relative to the project root
export PATH="$TK_BIN:$PATH"ID format
- Hash-based:
nw-5c46,ab-3f2e - Prefixed with project abbreviation (auto-detected from directory name)
- Partial matching:
tk show 5c4matchesnw-5c46 - Never fabricate IDs — capture from
tk createoutput
Ticket format
Each ticket is a markdown file in .tickets/:
---
id: nw-5c46
status: open
type: task
priority: 2
deps: [nw-a1b2]
tags: [spec:SPEC-003, backend]
parent: nw-f3e9
external-ref: SPEC-003
assignee: agent
created: 2026-03-12T10:30:00Z
---
# Fix login redirect
Description of the task...
## Notes
**2026-03-12T10:30:00Z**
Added JWT middleware; test passes.Issue types & priority
Types: bug, feature, task, epic, chore
Priority: 0–4 (numeric only)
| Priority | Meaning |
|---|---|
| 0 | Critical (security, data loss, broken builds) |
| 1 | High (major features, important bugs) |
| 2 | Medium (default) |
| 3 | Low (polish, optimization) |
| 4 | Backlog (future ideas) |
Creating tickets
# Basic task
tk create "Fix login redirect" -t task -p 2
# With description
tk create "Add export endpoint" -t feature -p 1 -d "REST endpoint for CSV export"
# Epic with external reference
tk create "Implement auth system" -t epic --external-ref SPEC-003
# Child task with tags
tk create "Add JWT middleware" -t task --parent nw-a1b2 -p 1 --tags spec:SPEC-003
# All create flags
# -t, --type Type (bug|feature|task|epic|chore) [default: task]
# -p, --priority Priority 0-4, 0=highest [default: 2]
# -d, --description Description text
# --design Design notes
# --acceptance Acceptance criteria
# -a, --assignee Assignee
# --external-ref External reference (e.g., SPEC-003, gh-123)
# --parent Parent ticket ID
# --tags Comma-separated tags (e.g., --tags ui,backend,spec:SPEC-003)Finding work
# Unblocked work (dependency-aware)
tk ready
# Blocked tickets (have unresolved dependencies)
tk blocked
# Filter ready by assignee or type
tk ready -a agent
tk ready -T epic
# Recently closed
tk closed
tk closed --limit=10WARNING: tk ready evaluates the full dependency graph. It only shows tickets whose deps are all closed.
Viewing tickets
# Full details
tk show nw-a1b2
# JSON output (via ticket-query plugin)
ticket-query # All tickets as JSON
ticket-query '.status == "open"' # Filter open
ticket-query '.type == "epic"' # Filter epics
ticket-query '.tags and (.tags | contains("spec:SPEC-003"))' # By tagClaiming & updating
# Atomic claim (mkdir lock + start + assign)
tk claim nw-a1b2
tk claim nw-a1b2 myname # Specify actor
# Release lock without changing status
tk release nw-a1b2
# Set status directly
tk start nw-a1b2 # → in_progress
tk status nw-a1b2 open # → open
tk status nw-a1b2 closed # → closedValid statuses: open, in_progress, closed
Closing tickets
# Close
tk close nw-a1b2
# Close with evidence (add note first)
tk add-note nw-a1b2 "JWT middleware added; test_jwt_validation passes"
tk close nw-a1b2
# Abandon (convention: prefix with "Abandoned:")
tk add-note nw-a1b2 "Abandoned: approach infeasible after testing"
tk close nw-a1b2
# Reopen
tk reopen nw-a1b2Dependencies
# Add dependency (child depends on parent)
tk dep nw-child nw-parent
# Remove dependency
tk undep nw-child nw-parent
# View dependency tree
tk dep tree nw-a1b2
tk dep tree --full nw-a1b2 # Disable dedup
# Find cycles
tk dep cycle
# Symmetric links (non-blocking)
tk link nw-a1b2 nw-c3d4
tk unlink nw-a1b2 nw-c3d4Notes
# Add timestamped note
tk add-note nw-a1b2 "Discovered edge case in auth flow"
# Pipe note from stdin
echo "Long note content here" | tk add-note nw-a1b2ticket-query (JSON output)
The ticket-query plugin reads all .tickets/*.md files and outputs JSON. Pipe through jq for filtering:
# All tickets
ticket-query
# Open tickets
ticket-query '.status == "open"'
# In-progress tasks
ticket-query '.status == "in_progress"'
# Tickets with specific tag
ticket-query '.tags and (.tags | contains("spec:SPEC-003"))'
# Count open tickets
ticket-query '.status == "open"' | wc -lticket-migrate-beads
Converts .beads/issues.jsonl to .tickets/ markdown files:
ticket-migrate-beadsRequires jq. Reads from .beads/issues.jsonl, writes to .tickets/.
Anti-patterns
| Wrong | Right | Why |
|---|---|---|
| Guessing ticket IDs | Capture from tk create output | IDs are hash-based |
Parsing tk ready output programmatically | Use ticket-query | Human-readable output may change |
Omitting --description on create | Always use -d | Future agents need context |
| Closing without evidence | tk add-note then tk close | Prevents completion drift |
Using tk start for claiming | Use tk claim | claim is atomic (mkdir lock) |
Checking .beads/ directory | Check .tickets/ directory | beads is deprecated |
Plugins
Plugins are scripts named tk-* or ticket-* in PATH. They receive TICKETS_DIR and TK_SCRIPT env vars.
Vendored plugins in bin/ (relative to this skill's directory):
ticket-query— JSON output with jq filteringticket-migrate-beads— Import from.beads/issues.jsonl
Use tk super <cmd> to bypass plugins and run built-in commands directly.
#!/usr/bin/env -S uv run python3
"""Ingest a superpowers plan file into tk (ticket) as an epic with child tasks.
Parses the writing-plans format (### Task N: Title blocks) and registers
each task in tk with spec lineage tagging and sequential dependencies.
Usage:
ingest-plan.py <plan-file> <origin-ref> [--dry-run] [--tags TAG,...]
Examples:
ingest-plan.py docs/plans/2026-03-06-auth-system.md SPEC-003
ingest-plan.py docs/plans/2026-03-06-auth-system.md SPEC-003 --dry-run
ingest-plan.py docs/plans/2026-03-06-auth-system.md SPEC-003 --tags epic:EPIC-009
"""
import argparse
import json
import os
import re
import subprocess
import sys
def parse_header(content: str) -> dict:
"""Extract plan header: title, goal, architecture, tech stack."""
header = {}
# Title from first H1
m = re.search(r'^# (.+)$', content, re.MULTILINE)
if m:
header['title'] = m.group(1).strip()
# Goal, Architecture, Tech Stack from **Key:** Value lines
for key in ('Goal', 'Architecture', 'Tech Stack'):
m = re.search(rf'^\*\*{key}:\*\*\s*(.+)$', content, re.MULTILINE)
if m:
header[key.lower().replace(' ', '_')] = m.group(1).strip()
return header
def parse_tasks(content: str) -> list[dict]:
"""Split content on ### Task N: boundaries, extract title and body."""
# Find all ### Task headings
pattern = r'^### Task (\d+):\s*(.+)$'
matches = list(re.finditer(pattern, content, re.MULTILINE))
if not matches:
return []
tasks = []
for i, match in enumerate(matches):
task_num = int(match.group(1))
title = match.group(2).strip()
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
body = content[start:end].strip()
# Extract file paths from **Files:** section
files = []
files_match = re.search(r'^\*\*Files:\*\*\s*\n((?:- .+\n)+)', body, re.MULTILINE)
if files_match:
for line in files_match.group(1).strip().split('\n'):
line = line.strip().lstrip('- ')
if line:
files.append(line)
tasks.append({
'number': task_num,
'title': title,
'body': body,
'files': files,
})
return tasks
def parse_plan(path: str) -> dict:
"""Parse a superpowers plan file into structured data."""
with open(path) as f:
content = f.read()
header = parse_header(content)
tasks = parse_tasks(content)
if not tasks:
print(f"Error: no '### Task N:' headings found in {path}", file=sys.stderr)
sys.exit(1)
return {'header': header, 'tasks': tasks, 'source': path}
def tk_create(args: list[str]) -> str:
"""Run a tk create command and return the created ticket ID from stdout."""
cmd = ['tk'] + args
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"tk error: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
# tk create prints "Created <id>" — extract the ID
output = result.stdout.strip()
m = re.search(r'Created\s+(\S+)', output)
if m:
return m.group(1)
# Fallback: return the last word on the first line
return output.split()[-1] if output else ''
def tk_dep(child_id: str, parent_id: str):
"""Add a dependency: child depends on parent."""
subprocess.run(
['tk', 'dep', child_id, parent_id],
capture_output=True, text=True,
)
def register_in_tk(plan: dict, origin_ref: str, extra_tags=None):
"""Create tk epic + child tasks from parsed plan."""
header = plan['header']
tasks = plan['tasks']
title = header.get('title', os.path.basename(plan['source']))
# Create epic
epic_args = [
'create', title,
'-t', 'epic',
'--external-ref', origin_ref,
'-d', f"Ingested from {plan['source']}. "
f"Goal: {header.get('goal', 'N/A')}. "
f"Architecture: {header.get('architecture', 'N/A')}.",
]
epic_id = tk_create(epic_args)
print(f"Created epic: {epic_id} — {title}")
# Create child tasks
tags = [f'spec:{origin_ref}']
if extra_tags:
tags.extend(extra_tags)
tag_str = ','.join(tags)
task_ids = []
for task in tasks:
# Truncate body for description (keep reasonable)
desc = task['body']
if len(desc) > 4000:
desc = desc[:3997] + '...'
task_args = [
'create', f"Task {task['number']}: {task['title']}",
'-t', 'task',
'--parent', epic_id,
'-p', '1',
'-d', desc,
'--tags', tag_str,
]
task_id = tk_create(task_args)
task_ids.append(task_id)
print(f" Created task: {task_id} — {task['title']}")
# Wire sequential dependencies
for i in range(1, len(task_ids)):
tk_dep(task_ids[i], task_ids[i - 1])
print(f" Dep: {task_ids[i]} depends on {task_ids[i - 1]}")
return {'epic_id': epic_id, 'task_ids': task_ids}
def main():
parser = argparse.ArgumentParser(description='Ingest a superpowers plan file into tk')
parser.add_argument('plan_file', help='Path to superpowers plan markdown file')
parser.add_argument('origin_ref', help='Origin artifact ID (e.g., SPEC-003)')
parser.add_argument('--dry-run', action='store_true',
help='Parse only — output JSON without creating tk tasks')
parser.add_argument('--tags', default='',
help='Additional comma-separated tags for all tasks')
args = parser.parse_args()
if not os.path.isfile(args.plan_file):
print(f"Error: file not found: {args.plan_file}", file=sys.stderr)
sys.exit(1)
plan = parse_plan(args.plan_file)
if args.dry_run:
json.dump(plan, sys.stdout, indent=2)
print()
return
extra_tags = [t.strip() for t in args.tags.split(',') if t.strip()] if args.tags else None
result = register_in_tk(plan, args.origin_ref, extra_tags)
print(f"\nIngestion complete: {len(result['task_ids'])} tasks under epic {result['epic_id']}")
if __name__ == '__main__':
main()
#!/usr/bin/env bash
# swain-worktree-overlap.sh — SPEC-195: Check for existing worktrees matching a spec/context
#
# Usage:
# swain-worktree-overlap.sh SPEC-194 # check for worktrees matching SPEC-194
# swain-worktree-overlap.sh "fast greeting" # check for worktrees matching text
#
# Output (JSON):
# { "found": true, "worktrees": [{ "path": "...", "branch": "..." }] }
# { "found": false, "worktrees": [] }
set +e
SEARCH_TERM="${1:-}"
if [[ -z "$SEARCH_TERM" ]]; then
echo '{"found":false,"worktrees":[],"error":"no search term provided"}'
exit 1
fi
# Normalize search term for case-insensitive matching
SEARCH_LOWER=$(echo "$SEARCH_TERM" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
MATCHES=()
# Parse git worktree list --porcelain
while IFS= read -r line; do
case "$line" in
"worktree "*)
current_path="${line#worktree }"
current_branch=""
;;
"branch "*)
current_branch="${line#branch refs/heads/}"
# Check if branch name contains the search term
branch_lower=$(echo "$current_branch" | tr '[:upper:]' '[:lower:]')
if echo "$branch_lower" | grep -q "$SEARCH_LOWER"; then
MATCHES+=("{\"path\":\"$current_path\",\"branch\":\"$current_branch\"}")
fi
;;
esac
done < <(git worktree list --porcelain 2>/dev/null)
# Build JSON output
if [[ ${#MATCHES[@]} -eq 0 ]]; then
echo '{"found":false,"worktrees":[]}'
else
echo -n '{"found":true,"worktrees":['
for ((i=0; i<${#MATCHES[@]}; i++)); do
[[ $i -gt 0 ]] && echo -n ","
echo -n "${MATCHES[$i]}"
done
echo ']}'
fi
#!/usr/bin/env bash
# test-worktree-overlap.sh — SPEC-195: Test worktree overlap detection
#
# Usage: bash test-worktree-overlap.sh [--verbose]
set -euo pipefail
VERBOSE=0
[[ "${1:-}" == "--verbose" ]] && VERBOSE=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OVERLAP_SCRIPT="$SCRIPT_DIR/swain-worktree-overlap.sh"
PASS=0
FAIL=0
TOTAL=0
assert_eq() {
local test_name="$1" expected="$2" actual="$3"
TOTAL=$((TOTAL + 1))
if [[ "$expected" == "$actual" ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: $test_name"
else
FAIL=$((FAIL + 1))
echo " FAIL: $test_name (expected: '$expected', got: '$actual')"
fi
}
echo "=== SPEC-195: Worktree overlap detection tests ==="
# Test 1: Script exists
TOTAL=$((TOTAL + 1))
if [[ -x "$OVERLAP_SCRIPT" ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: overlap script exists and is executable"
else
FAIL=$((FAIL + 1))
echo " FAIL: overlap script not found at $OVERLAP_SCRIPT"
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
exit 1
fi
# Test 2: No search term → error
echo "Test 2: No search term returns error"
result=$(bash "$OVERLAP_SCRIPT" 2>/dev/null || true)
found=$(echo "$result" | jq -r '.found' 2>/dev/null || echo "")
assert_eq "no search term returns found=false" "false" "$found"
# Test 3: Search for current worktree (should find this one)
echo "Test 3: Search for current worktree branch"
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
result=$(bash "$OVERLAP_SCRIPT" "$current_branch" 2>/dev/null)
found=$(echo "$result" | jq -r '.found' 2>/dev/null)
assert_eq "current branch found" "true" "$found"
# Test 4: Search for nonexistent spec
echo "Test 4: Search for nonexistent spec"
result=$(bash "$OVERLAP_SCRIPT" "SPEC-99999" 2>/dev/null)
found=$(echo "$result" | jq -r '.found' 2>/dev/null)
assert_eq "nonexistent spec not found" "false" "$found"
# Test 5: Case-insensitive search
echo "Test 5: Case-insensitive search"
result_upper=$(bash "$OVERLAP_SCRIPT" "$(echo "$current_branch" | tr '[:lower:]' '[:upper:]')" 2>/dev/null)
found_upper=$(echo "$result_upper" | jq -r '.found' 2>/dev/null)
assert_eq "case-insensitive search works" "true" "$found_upper"
# Test 6: Performance (<100ms)
echo "Test 6: Performance (<500ms)"
start_ms=$(python3 -c "import time; print(int(time.time()*1000))")
bash "$OVERLAP_SCRIPT" "SPEC-195" >/dev/null 2>&1
end_ms=$(python3 -c "import time; print(int(time.time()*1000))")
elapsed=$((end_ms - start_ms))
TOTAL=$((TOTAL + 1))
if [[ $elapsed -lt 500 ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: performance (${elapsed}ms)"
else
FAIL=$((FAIL + 1))
echo " FAIL: performance (${elapsed}ms, expected <100ms)"
fi
# Test 7: JSON output is valid
echo "Test 7: Valid JSON output"
result=$(bash "$OVERLAP_SCRIPT" "test" 2>/dev/null)
TOTAL=$((TOTAL + 1))
if echo "$result" | jq . >/dev/null 2>&1; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: valid JSON output"
else
FAIL=$((FAIL + 1))
echo " FAIL: invalid JSON output: $result"
fi
echo ""
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
[[ $FAIL -eq 0 ]] && exit 0 || exit 1