/**
 * commands/task.mjs — per-task isolated directories for multi-agent concurrency (C29).
 *
 * task subcommand: create | list | get | close | update | move | complete-subtask | narration-drain | prune
 *
 * Layer 1 (post-CLI-layering refactor, subtask 9). cmdTask returns
 * { envelope, exitCode } — it MUST NOT call process.exit, output(), or fail()
 * directly. The Layer 2 wrapper (dispatch.mjs `wrap()`) writes stdout and exits.
 *
 * D-03 fix: re-spawning apt-tools (the worktree create hook) goes through
 * resolveAptToolsBin(targetDir) instead of process.argv[1].
 */

import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
	getTaskIsolationConfig,
	loadMergedProjectConfig,
	loadProjectConfig,
} from '../config/load.mjs'
import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
import { createCoordinationStore } from '../coordination/store.mjs'
import { getGitIdentity } from '../git/identity.mjs'
import { detectWorktree } from '../git/worktree-detect.mjs'
import {
	buildDependsOnMap,
	collectBlockedByTaskIds,
	detectDependsOnCycle,
} from '../graph/depends-on.mjs'
import { parseTaskRef } from '../graph/task-ref.mjs'
import { detectHost } from '../host/detect.mjs'
import { applyLifecyclePhaseSideEffect } from '../roadmap/lifecycle.mjs'
import { completeSubtask } from '../task/build-progress-sync.mjs'
import { executionModesList, isValidExecutionMode } from '../task/execution-mode.mjs'
import { generateTaskId, getTaskDir } from '../task/ids.mjs'
import { regenerateTaskIndex } from '../task/index-md.mjs'
import { resolveDefaultOutputs } from '../task/outputs-guard.mjs'
import { computeWorktreeCleanup } from '../task/worktree-cleanup.mjs'
import { isTeamEnabled, teamSync, updateTeamMember } from '../team/team.mjs'
import { parseFlags } from '../util/args.mjs'
import { resolveAptToolsBin } from '../util/bin-path.mjs'
import { parseDuration } from '../util/duration.mjs'
import { withFileLock } from '../util/fs-lock.mjs'
import { atomicWriteJson } from '../util/io.mjs'
import { err, exitWith, ok } from '../util/result.mjs'
import { cmdCiWatch } from './ci-watch.mjs'

export function cmdTask(subcommand, projectDir, extraArgs) {
	if (!subcommand)
		return err(
			'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]',
		)
	if (!projectDir) return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`)

	const targetDir = resolve(projectDir)
	const statePath = join(targetDir, '.aperant', 'state.json')
	const flags = parseFlags(extraArgs)

	// R8 — @internal CoordinationStore seam. V1 is a thin wrapper over the
	// existing filesystem helpers so behavior is unchanged; subtask 4 (R10)
	// and subtask 5 (R2) swap in the real team-status + event-log impls.
	const store = createCoordinationStore(targetDir)

	switch (subcommand) {
		case 'create': {
			const description = flags.get('description') || flags.get('desc')
			if (!description) return err('Required: --description "task description"')

			const track = flags.get('track') || 'STANDARD'
			const autonomy = parseInt(flags.get('autonomy') ?? '1', 10)

			// C21 — first-class execution_mode field. Accepts the same values as
			// the router's `--execution-mode` flag (validation lives in
			// task/execution-mode.mjs — single source of truth). Unset is allowed
			// and signals no human pre-pick (planner falls back to autonomy
			// heuristics).
			let executionMode = null
			if (flags.has('execution-mode')) {
				const raw = flags.get('execution-mode')
				if (!isValidExecutionMode(raw)) {
					return err(`Invalid --execution-mode: "${raw}". Expected one of: ${executionModesList()}`)
				}
				executionMode = raw
			}

			// C28 — scope + linkage fields (D-02, D-03).
			const scope = (flags.get('scope') || 'project').trim() || 'project'
			const milestoneId = flags.get('milestone') || null
			const phaseIdForRoadmap = flags.get('phase') || null
			const roadmapRefUrl = flags.get('roadmap-ref-url') || null
			const dependsOnRaw = flags.get('depends-on') || ''
			const dependsOn = dependsOnRaw
				? dependsOnRaw
						.split(',')
						.map((s) => s.trim())
						.filter(Boolean)
				: []

			// Scope is project-agnostic: any freeform scope is allowed, and the
			// `roadmap_ref_url` pointer is optional for every scope. Tasks may
			// link into `milestones.json` via --milestone/--phase, or to an
			// external document via --roadmap-ref-url, or neither.

			// Validate depends_on refs parse cleanly before we touch state.
			for (const ref of dependsOn) {
				const resolved = parseTaskRef(ref, scope)
				if (!resolved) return err(`Invalid --depends-on ref: "${ref}"`)
			}

			// When both milestone + phase are given, they must exist in the
			// scoped roadmap (.aperant/roadmap/{scope}/milestones.json). We do
			// NOT auto-create the milestone/phase.
			if (milestoneId && phaseIdForRoadmap) {
				const scopedRoadmapPath = join(targetDir, '.aperant', 'roadmap', scope, 'milestones.json')
				if (existsSync(scopedRoadmapPath)) {
					try {
						const roadmap = JSON.parse(readFileSync(scopedRoadmapPath, 'utf-8'))
						const milestones = Array.isArray(roadmap?.milestones) ? roadmap.milestones : []
						const m = milestones.find((x) => x?.id === milestoneId)
						if (!m) return err(`Milestone "${milestoneId}" not found in roadmap scope "${scope}"`)
						const phases = Array.isArray(m.phases) ? m.phases : []
						const p = phases.find((x) => x?.id === phaseIdForRoadmap)
						if (!p)
							return err(`Phase "${phaseIdForRoadmap}" not found in milestone "${milestoneId}"`)
					} catch (e) {
						return err(`Failed to validate roadmap linkage: ${e.message}`)
					}
				}
				// If the scope's roadmap doesn't exist yet, we defer — cutover or
				// a later roadmap add-milestone/add-phase may populate it.
			}

			// AUDIT-001: read through the merged loader so local-only
			// overrides for preferences.date_format / preferences.time_format
			// win over the shared config.json fallback.
			// Guard: malformed config.local.json must not crash task creation.
			let config
			try {
				config = loadMergedProjectConfig(targetDir) ?? {}
			} catch {
				config = {}
			}
			// User-supplied --id bypasses the collision guard (caller owns the
			// uniqueness contract). Generated ids run through a numeric-counter
			// disambiguator that appends -2, -3, … when the candidate already
			// exists in state or on disk. Bounded at 100 iterations as a
			// defensive ceiling; same-day same-slug collisions are vanishingly
			// rare so the loop should almost always terminate on the first try.
			//
			// BUG-2 fix: the candidate-selection scan, taskDir mkdir, and
			// state.active_tasks write MUST all happen inside a single
			// withFileLock critical section. Two concurrent task create calls
			// in the multi-agent environment can otherwise both compute the
			// same candidate against the same pre-lock snapshot of state and
			// race into a duplicate-id write that silently overwrites the
			// first task's record.
			const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/
			const explicitIdRaw = flags.get('id')
			if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
				return err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
					command: 'task-create',
				})
			}
			// Secondary guard: ensure the resolved task directory stays inside the
			// tasks base. The SAFE_ID_PATTERN above blocks `/` and most traversal
			// but allows `.` and `..` as valid characters. A lone `..` id would
			// escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
			if (explicitIdRaw) {
				const tasksBase = resolve(join(targetDir, '.aperant', 'tasks'))
				const candidateDir = resolve(getTaskDir(targetDir, explicitIdRaw))
				if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
					return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
						command: 'task-create',
					})
				}
			}

			let taskId
			let taskDir
			let collisionOverflowErr = null

			withFileLock(
				statePath,
				() => {
					// Read state under the lock so the collision scan, taskDir
					// mkdir, and state.active_tasks write all observe the same
					// snapshot. withFileLock seeds an empty file if statePath
					// doesn't exist, so the JSON.parse fallback handles the
					// first-ever task create cleanly.
					const raw = readFileSync(statePath, 'utf-8').trim()
					const state = raw ? JSON.parse(raw) : {}

					// Migrate: if old format (active_task singular), convert
					if (state.active_task !== undefined && state.active_tasks === undefined) {
						state.active_tasks = {}
						if (state.active_task) {
							const oldId = state.active_task.id || 'legacy-task'
							state.active_tasks[oldId] = state.active_task
						}
						delete state.active_task
					}
					if (!state.active_tasks) state.active_tasks = {}

					// Resolve taskId against the locked-in state snapshot.
					if (explicitIdRaw) {
						taskId = explicitIdRaw
					} else {
						const base = generateTaskId(description, config)
						const activeIds = Object.keys(state.active_tasks)
						const completedIds = Array.isArray(state.completed_tasks)
							? state.completed_tasks.map((row) => row?.id).filter(Boolean)
							: []
						const known = new Set([...activeIds, ...completedIds])
						const isTaken = (candidate) =>
							known.has(candidate) || existsSync(getTaskDir(targetDir, candidate))
						let candidate = base
						let counter = 2
						while (isTaken(candidate)) {
							if (counter > 100) {
								collisionOverflowErr = err(
									`task create: collision counter exceeded 100 for base id "${base}" — manual --id required`,
								)
								return
							}
							candidate = `${base}-${counter}`
							counter++
						}
						taskId = candidate
					}

					// Create task directory under the lock so a concurrent
					// run can't observe (and then re-pick) a half-created id.
					taskDir = getTaskDir(targetDir, taskId)
					mkdirSync(taskDir, { recursive: true })
					for (const subdir of ['events', 'locks']) {
						mkdirSync(join(taskDir, subdir), { recursive: true })
					}

					// Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
					// C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
					// R11 — resolve the outputs allowlist now so it is pinned at
					// task creation time. If the planner edits the task record
					// later (e.g. narrows the allowlist), it overwrites this.
					const outputs = resolveDefaultOutputs({
						id: taskId,
						scope,
						milestone_id: milestoneId,
						phase_id: phaseIdForRoadmap,
					})

					state.active_tasks[taskId] = {
						id: taskId,
						description,
						track,
						autonomy,
						// C21 — execution_mode is omitted entirely when unset so legacy
						// readers (apt-plan SKILL.md 9a) continue to see `undefined`
						// rather than `null` and fall through to the autonomy heuristic.
						...(executionMode ? { execution_mode: executionMode } : {}),
						scope,
						milestone_id: milestoneId,
						phase_id: phaseIdForRoadmap,
						roadmap_ref_url: roadmapRefUrl,
						depends_on: dependsOn,
						outputs,
						// FRAMEWORK-BUG-033 defect 2 — durable task→agent ownership.
						// Defaults to null at create time; fan-out workers (or the
						// `task update --assigned-to <agent-id>` path) populate it
						// when they claim the task. Persists across short-lived CLI
						// invocations unlike team-status/{agentId}.json (pid-keyed).
						assigned_to: flags.has('assigned-to') ? flags.get('assigned-to') : null,
						lifecycle_phase: 'planning',
						history: [],
						created_at: new Date().toISOString(),
						progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
					}

					// ── Task-level worktree hook ──────────────────────────────
					// When task_isolation.worktree_per_task is enabled AND we're
					// not already inside a worktree AND the host supports them,
					// fork a branch + worktree and record its location on the
					// task record. Any failure is downgraded to a warning so
					// task-create never aborts.
					const isolation = getTaskIsolationConfig(targetDir)
					const host = detectHost()
					if (
						isolation.worktree_per_task === true &&
						host.capabilities.worktrees === true &&
						!detectWorktree(targetDir).is_worktree
					) {
						try {
							const wtArgs = ['--task', taskId, '--scope', scope]
							if (isolation.push_on_create) wtArgs.push('--push')
							if (isolation.from_branch && isolation.from_branch !== 'current') {
								wtArgs.push('--from', isolation.from_branch)
							}
							// Invoke inline so we capture the structured payload
							// without shelling back out. We cheat by calling the
							// worktree creator directly as a subprocess to keep
							// its output discipline intact, then parse its JSON.
							//
							// D-03 — single source of truth for the apt-tools binary
							// path (replaces the legacy process.argv[1] heuristic).
							const aptToolsBin = resolveAptToolsBin(targetDir)
							const createRes = spawnSync(
								process.execPath,
								[aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
								{ encoding: 'utf-8' },
							)
							if (createRes.status === 0 && createRes.stdout) {
								const wtOut = JSON.parse(createRes.stdout)
								state.active_tasks[taskId].worktree_path = wtOut.worktree_path
								state.active_tasks[taskId].branch = wtOut.branch
								state.active_tasks[taskId].base_branch = wtOut.base_branch
								state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
								if (wtOut.scope) {
									state.active_tasks[taskId].worktree_scope = wtOut.scope
								}
								if (wtOut.push_warning) {
									state.active_tasks[taskId].push_warning = wtOut.push_warning
								}
								// C54 — merge install_* fields surfaced by the
								// worktree-install hook in worktree.mjs. These fields are
								// absent on pre-C54 default-config paths, so readers that
								// don't know about install_* continue to work unchanged.
								if (wtOut.install && typeof wtOut.install === 'object') {
									const inst = wtOut.install
									if (inst.install_status)
										state.active_tasks[taskId].install_status = inst.install_status
									if (inst.install_log_path)
										state.active_tasks[taskId].install_log_path = inst.install_log_path
									if (inst.install_pid !== undefined && inst.install_pid !== null)
										state.active_tasks[taskId].install_pid = inst.install_pid
									if (Array.isArray(inst.install_ecosystems))
										state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
									if (inst.install_started_at)
										state.active_tasks[taskId].install_started_at = inst.install_started_at
									if (inst.install_finished_at)
										state.active_tasks[taskId].install_finished_at = inst.install_finished_at
									if (inst.install_warning)
										state.active_tasks[taskId].install_warning = inst.install_warning
									// C54 review: persist the per-ecosystem breakdown and
									// pre-spawn scanner rejections. worktree-install already
									// wrote these directly via updateState; forwarding them
									// through the stdout merge keeps the exposed fields
									// consistent with the install envelope.
									if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
										state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
									if (Array.isArray(inst.install_rejections))
										state.active_tasks[taskId].install_rejections = inst.install_rejections
								}
								if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
									state.active_tasks[taskId].install_warning = wtOut.install_warning
								}
							} else {
								state.active_tasks[taskId].worktree_warning =
									'worktree create failed: ' +
									(createRes.stderr || createRes.stdout || 'unknown')
										.toString()
										.trim()
										.split('\n')
										.pop()
							}
						} catch (e) {
							state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`
						}
					}

					state.last_activity = new Date().toISOString()
					atomicWriteJson(statePath, state)
				},
				{ projectRoot: targetDir },
			)

			if (collisionOverflowErr) return collisionOverflowErr

			regenerateTaskIndex(targetDir)

			// Team collaboration: register user + add to active board
			updateTeamMember(targetDir)
			store.upsertTeamStatus(taskId, {
				description,
				lifecycle_phase: 'planning',
				scope,
			})

			// R10 — forensic trail: emit task.created to the append-only
			// JSONL log so operators can reconstruct history even when
			// ephemeral state.json / team-status have been rotated.
			store.appendEvent({
				op: 'task.created',
				task: taskId,
				data: {
					description,
					track,
					scope,
					milestone_id: milestoneId,
					phase_id: phaseIdForRoadmap,
				},
			})

			// Re-read task record so we can surface the worktree block to the
			// router skill without duplicating state.
			let createdTask = null
			if (existsSync(statePath)) {
				try {
					const s = JSON.parse(readFileSync(statePath, 'utf-8'))
					createdTask = s.active_tasks?.[taskId] || null
				} catch {
					/* best-effort */
				}
			}

			const createOut = {
				status: 'ok',
				command: 'task-create',
				task_id: taskId,
				task_dir: taskDir,
				description,
				track,
				autonomy,
			}
			if (createdTask?.worktree_path) {
				createOut.worktree = {
					worktree_path: createdTask.worktree_path,
					branch: createdTask.branch,
					base_branch: createdTask.base_branch,
					remote_tracking: !!createdTask.remote_tracking,
				}
				if (createdTask.push_warning) createOut.worktree.push_warning = createdTask.push_warning
			}
			if (createdTask?.worktree_warning) {
				createOut.worktree_warning = createdTask.worktree_warning
			}
			return ok(createOut)
		}

		case 'list': {
			if (!existsSync(statePath)) {
				return ok({ status: 'ok', command: 'task-list', tasks: [], count: 0 })
			}

			const state = JSON.parse(readFileSync(statePath, 'utf-8'))

			// Handle both old and new format
			let tasks = []
			if (state.active_tasks && typeof state.active_tasks === 'object') {
				tasks = Object.values(state.active_tasks)
			} else if (state.active_task) {
				tasks = [state.active_task]
			}

			// C28 — scope filter + transitive blocked-by walk.
			const scopeFilter = flags.get('scope')
			if (scopeFilter) {
				tasks = tasks.filter((t) => (t?.scope || 'project') === scopeFilter)
			}
			const blockedBy = flags.get('blocked-by')
			if (blockedBy) {
				const depMap = buildDependsOnMap(state)
				const blockedIds = new Set(collectBlockedByTaskIds(depMap, blockedBy))
				tasks = tasks.filter((t) => t?.id && blockedIds.has(t.id))
			}

			return ok({ status: 'ok', command: 'task-list', tasks, count: tasks.length })
		}

		case 'get': {
			const taskId = flags.get('id')
			if (!taskId) return err('Required: --id <task-id>')

			if (!existsSync(statePath)) return err('No state.json found')
			const state = JSON.parse(readFileSync(statePath, 'utf-8'))

			// Handle both formats
			let task = null
			if (state.active_tasks?.[taskId]) {
				task = state.active_tasks[taskId]
			} else if (state.active_task?.id === taskId) {
				task = state.active_task
			}

			if (!task) return err(`Task "${taskId}" not found in active tasks`)

			// C54 R6 — dead-PID liveness check. If install_status is "running"
			// but the recorded pid is no longer alive, rewrite state as
			// `failed` with a clear warning. Prevents a child crash from
			// leaving the task wedged in perpetual "running".
			if (
				task.install_status === 'running' &&
				typeof task.install_pid === 'number' &&
				task.install_pid > 0
			) {
				let alive = true
				try {
					process.kill(task.install_pid, 0)
				} catch (e) {
					if (e && e.code === 'ESRCH') alive = false
					// EPERM → process exists but we can't signal it; treat as alive.
				}
				if (!alive) {
					task.install_status = 'failed'
					task.install_warning = 'child process died without writing final status'
					task.install_finished_at = new Date().toISOString()
					try {
						withFileLock(
							statePath,
							() => {
								const freshState = JSON.parse(readFileSync(statePath, 'utf-8'))
								if (freshState.active_tasks?.[taskId]) {
									freshState.active_tasks[taskId].install_status = 'failed'
									freshState.active_tasks[taskId].install_warning =
										'child process died without writing final status'
									freshState.active_tasks[taskId].install_finished_at = new Date().toISOString()
									freshState.last_activity = new Date().toISOString()
									atomicWriteJson(statePath, freshState)
								}
							},
							{ projectRoot: targetDir },
						)
					} catch {
						/* best-effort — the in-memory `task` is already rewritten */
					}
				}
			}

			const taskDir = getTaskDir(targetDir, taskId)
			const hasSpec = existsSync(join(taskDir, 'spec.md'))
			const hasPlan = existsSync(join(taskDir, 'implementation_plan.json'))
			const hasProgress = existsSync(join(taskDir, 'build-progress.json'))

			return ok({
				status: 'ok',
				command: 'task-get',
				task,
				task_dir: taskDir,
				artifacts: { spec: hasSpec, plan: hasPlan, progress: hasProgress },
			})
		}

		case 'close': {
			const taskId = flags.get('id')
			if (!taskId) return err('Required: --id <task-id>')
			const verdict = flags.get('verdict') || 'completed'
			// Internal flag set by `task close-merged` when
			// GitHub confirmed state === 'MERGED'. Threads through to
			// computeWorktreeCleanup so cleanup skips local merge-back and
			// runs fetch + ff-only instead.
			const prMerged = flags.has('pr-merged-remotely')

			if (!existsSync(statePath)) return err('No state.json found')

			// withFileLock callback can produce either an error envelope (early
			// return shape) or a final ok envelope; capture both via outer state.
			let closeResult = null
			let closeError = null
			withFileLock(
				statePath,
				() => {
					const state = JSON.parse(readFileSync(statePath, 'utf-8'))

					// Handle both formats
					let task = null
					if (state.active_tasks?.[taskId]) {
						task = state.active_tasks[taskId]
						delete state.active_tasks[taskId]
					} else if (state.active_task?.id === taskId) {
						task = state.active_task
						state.active_task = null
					}

					if (!task) {
						closeError = err(`Task "${taskId}" not found in active tasks`)
						return
					}

					if (!state.completed_tasks) state.completed_tasks = []
					// C28 — preserve scope + lifecycle_phase on the completed row so
					// INDEX.md can group completed tasks by scope and CompletedTaskRecord
					// stays aligned with the TaskRecord shape.
					state.completed_tasks.push({
						id: taskId,
						description: task.description,
						scope: task.scope || 'project',
						completed_at: new Date().toISOString(),
						qa_verdict: verdict,
						lifecycle_phase: 'completed',
						commits: [],
					})
					state.last_activity = new Date().toISOString()
					atomicWriteJson(statePath, state)

					regenerateTaskIndex(targetDir)

					// Team collaboration: remove from active board
					updateTeamMember(targetDir)
					store.removeTeamStatus(taskId)

					// R10 — emit task.closed so the append-only log captures the
					// verdict before teamSync / worktree cleanup potentially fail.
					store.appendEvent({
						op: 'task.closed',
						task: taskId,
						data: {
							verdict,
							scope: task.scope || 'project',
							milestone_id: task.milestone_id ?? null,
							phase_id: task.phase_id ?? null,
						},
					})

					// Worktree cleanup hook — honors task_isolation.cleanup_on_close
					// (never | ask | auto). See src/cli/task/worktree-cleanup.mjs.
					// prMerged threads from `task close-merged`
					// via --pr-merged-remotely; when true, cleanup runs fetch +
					// ff-only instead of local merge-back.
					let cleanup = null
					if (task.worktree_path) {
						const isolation = getTaskIsolationConfig(targetDir)
						const mode = isolation.cleanup_on_close || 'ask'
						cleanup = computeWorktreeCleanup(task, mode, targetDir, { prMerged })
					}

					// Best-effort team sync: commit shared bookkeeping so teammates
					// don't have to pick up orphaned diffs. Never fails task close.
					//
					// R7 — when the task has a worktree, run git from there so the
					// bookkeeping commit lands on the worktree's feature branch, not
					// the main repo's current branch (fixes the C29 leak).
					let syncResult = null
					if (isTeamEnabled(targetDir)) {
						try {
							syncResult = teamSync(targetDir, {
								push: false,
								cwd: task.worktree_path || targetDir,
								// R11 — declare the task's outputs so team sync refuses
								// to stage any file outside the allowlist.
								outputs: Array.isArray(task.outputs) ? task.outputs : undefined,
							})
						} catch (e) {
							syncResult = { status: 'error', error: e.message }
						}
					}

					// Completion side effect: on approved verdicts, transition the
					// linked phase from `in-progress` → `completed`. Rejected
					// verdicts leave the phase state alone (Q1 — review failure is
					// not a status change, the phase is still the right place).
					// Quick tasks (no linkage) are a silent no-op.
					//
					// C56 A2 — opt-in `task_isolation.auto_close_phase: true` gates
					// the flip behind PR merge confirmation. When enabled AND the
					// task has `pr_url`, we shell out to `gh pr view --json state`
					// and only flip when `state === 'MERGED'`. Default (flag unset
					// or false) preserves today's behavior (immediate flip).
					//
					// R7 — forward task.worktree_path so lifecycle can route the
					// roadmap-sync commit to the worktree branch as well.
					const isolationCfg = getTaskIsolationConfig(targetDir)
					const autoClosePhase = isolationCfg.auto_close_phase === true
					let prCheck = null
					let phaseFlipGated = false
					if (verdict === 'approved' && task.milestone_id && task.phase_id && autoClosePhase) {
						if (task.pr_url) {
							try {
								// Test hook: APT_GH_FIXTURE_JSON lets tests inject a
								// deterministic gh response without shelling to gh. The
								// env var holds the JSON body (as if from `gh pr view`).
								let rawJson = null
								if (process.env.APT_GH_FIXTURE_JSON) {
									rawJson = process.env.APT_GH_FIXTURE_JSON
								} else {
									const res = spawnSync(
										'gh',
										['pr', 'view', task.pr_url, '--json', 'state,mergedAt'],
										{ cwd: targetDir, encoding: 'utf-8' },
									)
									if (res.status === 0 && res.stdout) rawJson = res.stdout
									else {
										prCheck = {
											state: null,
											warning: `gh pr view failed: ${(res.stderr || '').toString().slice(0, 160)}`,
										}
										// gh failure → fail-open (fall through to today's flip) per spec.
									}
								}
								if (rawJson) {
									const parsed = JSON.parse(rawJson)
									prCheck = { state: parsed.state, mergedAt: parsed.mergedAt ?? null }
									if (parsed.state !== 'MERGED') phaseFlipGated = true
								}
							} catch (e) {
								prCheck = {
									state: null,
									warning: `gh pr view error: ${e.message ?? String(e)}`,
								}
							}
						} else {
							prCheck = {
								state: null,
								warning:
									'auto_close_phase enabled but task has no pr_url — falling back to default (flipping phase)',
							}
						}
					}

					let completionTransition = null
					if (verdict === 'approved' && task.milestone_id && task.phase_id && !phaseFlipGated) {
						const result = applyLifecyclePhaseSideEffect(
							targetDir,
							{
								id: task.id,
								scope: task.scope || 'project',
								milestone_id: task.milestone_id,
								phase_id: task.phase_id,
								worktree_path: task.worktree_path || null,
								outputs: Array.isArray(task.outputs) ? task.outputs : null,
							},
							{ from: 'in-progress', to: 'completed' },
							'task.close.approved',
						)
						if (result) {
							completionTransition = { ...result.transition, commit: result.commit }
						}
					}

					const closeOut = { status: 'ok', command: 'task-close', task_id: taskId, verdict }
					if (cleanup) closeOut.cleanup = cleanup
					if (syncResult) closeOut.team_sync = syncResult
					if (completionTransition) closeOut.transition = completionTransition
					if (prCheck) {
						closeOut.pr_check = prCheck
						if (prCheck.state && prCheck.state !== 'MERGED') {
							closeOut.pr_state =
								prCheck.state === 'OPEN'
									? 'open'
									: prCheck.state === 'CLOSED'
										? 'closed_not_merged'
										: prCheck.state.toLowerCase()
						}
					}
					closeResult = ok(closeOut)
				},
				{ projectRoot: targetDir },
			)
			return closeError ?? closeResult
		}

		case 'update': {
			const taskId = flags.get('id')
			if (!taskId) return err('Required: --id <task-id>')

			if (!existsSync(statePath)) return err('No state.json found')

			let updateResult = null
			let updateError = null
			withFileLock(
				statePath,
				() => {
					const state = JSON.parse(readFileSync(statePath, 'utf-8'))

					const task = state.active_tasks?.[taskId]
					if (!task) {
						updateError = err(`Task "${taskId}" not found in active tasks`)
						return
					}

					// C28 — linkage fields are reassigned only via `task move`.
					// Plain `task update` rejects attempts to change scope /
					// milestone_id / phase_id / roadmap_ref_url outright.
					const bannedOnUpdate = [
						'scope',
						'milestone',
						'milestone-id',
						'phase-id',
						'roadmap-ref-url',
					]
					for (const f of bannedOnUpdate) {
						if (flags.has(f)) {
							updateError = exitWith(
								{
									status: 'error',
									command: 'task-update',
									task_id: taskId,
									reason: 'use task move to change linkage fields',
									field: f,
								},
								1,
							)
							return
						}
					}

					// C28 — `--phase` is gone; task lifecycle moves via --lifecycle-phase.
					// We still fail loudly when the old flag appears so callers find out
					// immediately rather than getting silent no-ops.
					if (flags.has('phase')) {
						updateError = exitWith(
							{
								status: 'error',
								command: 'task-update',
								task_id: taskId,
								reason: '--phase was renamed to --lifecycle-phase (C28)',
							},
							1,
						)
						return
					}
					// Capture the lifecycle transition spec BEFORE mutating the task
					// record. The map below is the canonical lifecycle → phase-status
					// contract. Only `executing` moves the phase today; verifying /
					// reviewing remain no-ops because they don't carry a status
					// meaning beyond "agent is working in that lane". Completion is
					// handled by `task close --verdict approved`, not here.
					let lifecycleTransition = null
					if (flags.has('lifecycle-phase')) {
						task.lifecycle_phase = flags.get('lifecycle-phase')
						// R6 (roundtable 2026-05-14) — compute elapsed_ms since the
						// previous LIFECYCLE event (or task.created) for phase-boundary
						// telemetry. The 'task.lifecycle.' opPrefix is critical: without
						// it, the most-recent event lookup picks up high-frequency events
						// like task.tokens.recorded, which would silently corrupt
						// elapsed_ms attribution (PR #115 review finding LOG-001).
						const prev = findMostRecentEventForTask(targetDir, taskId, 'task.lifecycle.')
						let prevTime = prev ? new Date(prev.t).getTime() : null
						// LOG-003: when no prior lifecycle event exists (first transition),
						// fall back to task.created_at so planning phase duration is captured.
						if (!prevTime && task.created_at) {
							const createdMs = new Date(task.created_at).getTime()
							if (Number.isFinite(createdMs)) prevTime = createdMs
						}
						const elapsed_ms = prevTime ? Date.now() - prevTime : null
						// R10 — emit the lifecycle transition event even when the
						// side-effect helper decides the phase spec is a no-op; the
						// caller still changed task.lifecycle_phase.
						store.appendEvent({
							op: `task.lifecycle.${task.lifecycle_phase}`,
							task: taskId,
							data: {
								lifecycle_phase: task.lifecycle_phase,
								scope: task.scope || 'project',
								milestone_id: task.milestone_id ?? null,
								phase_id: task.phase_id ?? null,
								elapsed_ms,
							},
						})
						// C56 B5 — exhaustive lifecycle→roadmap-phase map. `null`
						// entries mean "no roadmap-phase side effect on this
						// lifecycle transition" (the flip happens elsewhere):
						//   - shipped-pending-merge: apt:ship sets it; roadmap
						//     phase stays `in-progress` until /apt:close-task
						//     confirms PR merge and flips to `completed`.
						const LIFECYCLE_TO_PHASE = {
							executing: { from: 'planned', to: 'in-progress' },
							'shipped-pending-merge': null,
						}
						const spec = LIFECYCLE_TO_PHASE[task.lifecycle_phase]
						if (spec) {
							const result = applyLifecyclePhaseSideEffect(
								targetDir,
								task,
								spec,
								`task.update.${task.lifecycle_phase}`,
							)
							if (result) {
								lifecycleTransition = { ...result.transition, commit: result.commit }
							}
						}
					}
					// C21 — mutable execution_mode. Not a linkage field, so update
					// (not move) is the right surface. Validation centralized in
					// task/execution-mode.mjs.
					if (flags.has('execution-mode')) {
						const raw = flags.get('execution-mode')
						if (!isValidExecutionMode(raw)) {
							updateError = exitWith(
								{
									status: 'error',
									command: 'task-update',
									task_id: taskId,
									reason: `Invalid --execution-mode: "${raw}". Expected one of: ${executionModesList()}`,
								},
								1,
							)
							return
						}
						task.execution_mode = raw
					}
					// C56 A2 — `--pr-url <url>` records the GitHub PR URL opened by
					// apt:ship. Downstream consumers: task close (auto_close_phase
					// gate) and `task close-merged` (/apt:close-task).
					//
					// Defect fix (12-05-26): when the caller does NOT also pass
					// `--lifecycle-phase` and the task is currently in `reviewing`,
					// auto-flip lifecycle to `shipped-pending-merge` atomically.
					// This collapses the two-call apt:ship contract into a single
					// load-bearing call so orchestrators can't drop the lifecycle
					// transition. Idempotent / no-op on any other phase.
					let ciWatchResult = {
						ci_watch_armed: false,
						ci_watch_skip_reason: null,
						ci_watch: null,
					}
					let prUrlForCiWatch = null
					if (flags.has('pr-url')) {
						const url = flags.get('pr-url')
						if (!url || typeof url !== 'string') {
							updateError = exitWith(
								{
									status: 'error',
									command: 'task-update',
									task_id: taskId,
									reason: `Invalid --pr-url: "${url}"`,
								},
								1,
							)
							return
						}
						task.pr_url = url
						if (!flags.has('lifecycle-phase') && task.lifecycle_phase === 'reviewing') {
							task.lifecycle_phase = 'shipped-pending-merge'
							if (!Array.isArray(task.lifecycle_history)) task.lifecycle_history = []
							task.lifecycle_history.push({
								phase: 'shipped-pending-merge',
								at: new Date().toISOString(),
								reason: 'auto-flip on --pr-url',
							})
							store.appendEvent({
								op: 'task.lifecycle.shipped-pending-merge',
								task: taskId,
								data: {
									lifecycle_phase: 'shipped-pending-merge',
									scope: task.scope || 'project',
									milestone_id: task.milestone_id ?? null,
									phase_id: task.phase_id ?? null,
									reason: 'auto-flip on --pr-url',
								},
							})
						}
						prUrlForCiWatch = url
					}
					// FRAMEWORK-BUG-033 defect 2 — durable task→agent ownership.
					// Mutable owner field; fan-out workers write
					// `fanout-${batch}-${task}` on claim, conductor reads to
					// resolve crash recovery (which worker owned T1 when the
					// conductor died?).
					if (flags.has('assigned-to')) {
						task.assigned_to = flags.get('assigned-to')
					}
					if (flags.has('subtasks-total'))
						task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10)
					if (flags.has('subtasks-completed'))
						task.progress.subtasks_completed = parseInt(flags.get('subtasks-completed'), 10)
					if (flags.has('current-subtask'))
						task.progress.current_subtask = flags.get('current-subtask')
					if (flags.has('depends-on')) {
						const raw = flags.get('depends-on')
						const proposed = raw
							? raw
									.split(',')
									.map((s) => s.trim())
									.filter(Boolean)
							: []
						// Validate each ref parses.
						for (const ref of proposed) {
							const parsed = parseTaskRef(ref, task.scope || 'project')
							if (!parsed) {
								updateError = exitWith(
									{
										status: 'error',
										command: 'task-update',
										task_id: taskId,
										reason: `Invalid --depends-on ref: "${ref}"`,
									},
									1,
								)
								return
							}
						}
						// Build a working adjacency that REPLACES task.depends_on with
						// the proposed edges, then test each new edge for cycles.
						const workingState = JSON.parse(JSON.stringify(state))
						if (workingState.active_tasks?.[taskId]) {
							workingState.active_tasks[taskId].depends_on = proposed
						}
						const depMap = buildDependsOnMap(workingState)
						for (const ref of proposed) {
							const parsed = parseTaskRef(ref, task.scope || 'project')
							if (!parsed) continue
							const cycle = detectDependsOnCycle(depMap, { from: taskId, to: parsed.task_id })
							if (cycle) {
								updateError = exitWith(
									{
										status: 'error',
										command: 'task-update',
										task_id: taskId,
										reason: `cycle detected in depends_on: ${cycle.join(' -> ')}`,
									},
									1,
								)
								return
							}
						}
						task.depends_on = proposed
					}

					state.last_activity = new Date().toISOString()
					atomicWriteJson(statePath, state)
					if (prUrlForCiWatch) {
						// BUG-027 (a): pre-arm gh check. If the PR is already
						// MERGED/CLOSED, skip arming so we don't leak a new
						// watcher state file we'll immediately have to GC. gh
						// rate-limit / offline / ENOENT falls back to arm (mirror
						// the existing rate_limited/offline patterns at lines
						// 1322/1326) — non-fatal so a transient gh failure
						// doesn't prevent legitimate watcher arming.
						const ghFixture = process.env.APT_GH_FIXTURE_JSON
						let terminalState = null
						if (ghFixture) {
							try {
								const parsed = JSON.parse(ghFixture)
								if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
									terminalState = parsed.state
								}
							} catch {
								/* malformed fixture → fall through to arm */
							}
						} else {
							try {
								const res = spawnSync('gh', ['pr', 'view', prUrlForCiWatch, '--json', 'state'], {
									cwd: targetDir,
									encoding: 'utf-8',
								})
								if (res.status === 0 && res.stdout) {
									const parsed = JSON.parse(res.stdout)
									if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
										terminalState = parsed.state
									}
								}
								/* non-zero exit / ENOENT / parse error → fall through to arm */
							} catch {
								/* never block arming on gh transport error */
							}
						}
						if (terminalState) {
							ciWatchResult = {
								ci_watch_armed: false,
								ci_watch_skip_reason: 'pr-terminal-state',
								ci_watch: null,
							}
						} else {
							ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
						}
					}

					regenerateTaskIndex(targetDir)

					// Team collaboration: sync active board with updated state
					updateTeamMember(targetDir)
					const progressFraction =
						task.progress.subtasks_total > 0
							? task.progress.subtasks_completed / task.progress.subtasks_total
							: null
					store.upsertTeamStatus(taskId, {
						description: task.description,
						lifecycle_phase: task.lifecycle_phase,
						progress: progressFraction,
						current_subtask: task.progress.current_subtask,
					})

					const updateOut = { status: 'ok', command: 'task-update', task_id: taskId, task }
					if (lifecycleTransition) updateOut.transition = lifecycleTransition
					if (flags.has('pr-url')) {
						updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed
						updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason
						if (ciWatchResult.ci_watch) updateOut.ci_watch = ciWatchResult.ci_watch
						// FRAMEWORK-BUG-018 Track 1: surface `scheduler_handle` at the top
						// level so `apt-ship/SKILL.md` §7.2 can read it via
						// `.scheduler_handle // empty` without dotting through `.ci_watch.*`.
						// `null` today (no production path populates it); Track 2 lands
						// non-null values without re-plumbing the envelope shape.
						updateOut.scheduler_handle = ciWatchResult.scheduler_handle ?? null
					}
					updateResult = ok(updateOut)
				},
				{ projectRoot: targetDir },
			)
			return updateError ?? updateResult
		}

		case 'move': {
			// C28 D-06 — reassign linkage fields (scope/milestone_id/phase_id/
			// roadmap_ref_url) with an auditable history entry. Cross-scope
			// moves require --force --reason; --force without --reason always
			// fails. Destructive changes go through this path (NOT `task update`).
			const taskId = flags.get('id')
			if (!taskId) return err('Required: --id <task-id>')

			if (!existsSync(statePath)) return err('No state.json found')

			const force = extraArgs.includes('--force')
			const reason = flags.get('reason') || null

			if (force && !reason) {
				return exitWith(
					{
						status: 'error',
						command: 'task-move',
						task_id: taskId,
						reason: '--reason required when --force',
					},
					1,
				)
			}

			const toScope = flags.get('to-scope') || null
			const toMilestoneRaw = flags.has('to-milestone') ? flags.get('to-milestone') : undefined
			const toPhaseRaw = flags.has('to-phase') ? flags.get('to-phase') : undefined
			const toRoadmapRefUrlRaw = flags.has('to-roadmap-ref-url')
				? flags.get('to-roadmap-ref-url')
				: undefined

			let moveResult = null
			let moveError = null
			withFileLock(
				statePath,
				() => {
					const state = JSON.parse(readFileSync(statePath, 'utf-8'))
					const task = state.active_tasks?.[taskId]
					if (!task) {
						moveError = err(`Task "${taskId}" not found in active tasks`)
						return
					}

					const from = {
						scope: task.scope || 'project',
						milestone_id: task.milestone_id ?? null,
						phase_id: task.phase_id ?? null,
						roadmap_ref_url: task.roadmap_ref_url ?? null,
					}

					// Empty-string flag values clear the linkage; omitted flags preserve.
					const emptyToNull = (v) => (v === '' ? null : v)
					const to = {
						scope: toScope || from.scope,
						milestone_id:
							toMilestoneRaw === undefined ? from.milestone_id : emptyToNull(toMilestoneRaw),
						phase_id: toPhaseRaw === undefined ? from.phase_id : emptyToNull(toPhaseRaw),
						roadmap_ref_url:
							toRoadmapRefUrlRaw === undefined
								? from.roadmap_ref_url
								: emptyToNull(toRoadmapRefUrlRaw),
					}

					// Cross-scope moves are explicit — require --force --reason.
					if (to.scope !== from.scope && !force) {
						moveError = exitWith(
							{
								status: 'error',
								command: 'task-move',
								task_id: taskId,
								reason: 'cross-scope move requires --force --reason',
							},
							1,
						)
						return
					}

					// When moving into a milestone+phase, validate the target exists in
					// the per-scope roadmap unless --force is set.
					if (to.milestone_id && to.phase_id && !force) {
						const roadmapPath = join(targetDir, '.aperant', 'roadmap', to.scope, 'milestones.json')
						if (existsSync(roadmapPath)) {
							try {
								const roadmap = JSON.parse(readFileSync(roadmapPath, 'utf-8'))
								const milestones = Array.isArray(roadmap?.milestones) ? roadmap.milestones : []
								const m = milestones.find((x) => x?.id === to.milestone_id)
								if (!m) {
									moveError = exitWith(
										{
											status: 'error',
											command: 'task-move',
											task_id: taskId,
											reason: `milestone "${to.milestone_id}" not found`,
										},
										1,
									)
									return
								}
								const phases = Array.isArray(m.phases) ? m.phases : []
								if (!phases.find((x) => x?.id === to.phase_id)) {
									moveError = exitWith(
										{
											status: 'error',
											command: 'task-move',
											task_id: taskId,
											reason: `phase "${to.phase_id}" not found in milestone "${to.milestone_id}"`,
										},
										1,
									)
									return
								}
							} catch (e) {
								moveError = exitWith(
									{
										status: 'error',
										command: 'task-move',
										task_id: taskId,
										reason: `failed to validate roadmap: ${e.message}`,
									},
									1,
								)
								return
							}
						}
					}

					// Append to history.
					if (!Array.isArray(task.history)) task.history = []
					const identity = getGitIdentity(targetDir)
					task.history.push({
						from,
						to,
						at: new Date().toISOString(),
						actor: identity?.name || 'unknown',
						reason: reason || null,
					})

					// Apply changes.
					task.scope = to.scope
					task.milestone_id = to.milestone_id
					task.phase_id = to.phase_id
					task.roadmap_ref_url = to.roadmap_ref_url

					state.last_activity = new Date().toISOString()
					atomicWriteJson(statePath, state)

					regenerateTaskIndex(targetDir)
					updateTeamMember(targetDir)
					store.upsertTeamStatus(taskId, {
						description: task.description,
						lifecycle_phase: task.lifecycle_phase,
						scope: task.scope,
					})

					moveResult = ok({
						status: 'ok',
						command: 'task-move',
						task_id: taskId,
						from,
						to,
						task,
					})
				},
				{ projectRoot: targetDir },
			)
			return moveError ?? moveResult
		}

		case 'complete-subtask': {
			// C33 — atomic build-progress ↔ plan sync. Closes the gap where
			// executors appended to build-progress.json but left
			// implementation_plan.json.subtasks[].status at "pending", causing
			// the `all-subtasks-complete` hard gate to fail even after every
			// commit landed.
			const taskId = flags.get('task-id')
			if (!taskId) return err('Required: --task-id <task-id>')
			const subtaskIdFlag = flags.get('subtask-id')
			if (!subtaskIdFlag) return err('Required: --subtask-id <id-or-id+id>')
			const commit = flags.get('commit') || null
			const notes = flags.get('notes') || null
			try {
				const result = completeSubtask({
					projectDir: targetDir,
					taskId,
					subtaskId: subtaskIdFlag,
					commit,
					notes,
				})
				return ok({
					command: 'task-complete-subtask',
					...result,
				})
			} catch (e) {
				return err(`task complete-subtask: ${e.message}`)
			}
		}

		case 'close-merged': {
			// C56 B1 — enumerate shipped-pending-merge tasks, confirm merge via
			// `gh pr view`, and close the ones GitHub says merged. Per spec §B1.
			const onlyTaskId = flags.get('task') || null
			const dryRun = flags.has('dry-run')
			const narrateOnly = flags.has('narrate-only')

			// FRAMEWORK-BUG-020 — `--narrate-only` is read-only enumeration of
			// the pending_narration[] ledger. No `gh pr view`, no `task close`
			// spawn, no ledger writes. The skill body (apt-close-task §4) drains
			// per row via `task narration-drain` AFTER a successful narrator
			// spawn — at-least-once semantics is safe because the narrator is
			// anchor-replace idempotent (agents/apt-team-docs-narrator.md §6/§2).
			if (narrateOnly) {
				if (!existsSync(statePath)) {
					return ok({
						status: 'ok',
						command: 'task-close-merged',
						mode: 'narrate-only',
						closed: [],
						skipped: [],
						errors: [],
					})
				}
				const stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
				const ledger = Array.isArray(stateBlob.pending_narration) ? stateBlob.pending_narration : []
				const filtered = onlyTaskId
					? ledger.filter((row) => row && row.task_id === onlyTaskId)
					: ledger
				return ok({
					status: 'ok',
					command: 'task-close-merged',
					mode: 'narrate-only',
					closed: filtered.map((row) => ({
						task_id: row.task_id,
						scope: row.scope ?? null,
						phase_id: row.phase_id ?? null,
						closed_at: row.closed_at ?? null,
						pr_number: row.pr_number ?? null,
						narration_pending: true,
					})),
					skipped: [],
					errors: [],
				})
			}

			if (!existsSync(statePath)) {
				return ok({
					status: 'ok',
					command: 'task-close-merged',
					closed: [],
					skipped: [],
					errors: [],
				})
			}

			const stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
			const candidates = []
			for (const [id, t] of Object.entries(stateBlob.active_tasks || {})) {
				if (t?.lifecycle_phase !== 'shipped-pending-merge') continue
				if (onlyTaskId && id !== onlyTaskId) continue
				candidates.push({ id, task: t })
			}

			const closed = []
			const skipped = []
			const errors = []

			for (const { id, task } of candidates) {
				if (!task.pr_url) {
					skipped.push({ id, reason: 'no-pr-url' })
					continue
				}
				// gh pr view with test hook + rate-limit retry.
				let ghJson = null
				let ghError = null
				const ghFixture = process.env.APT_GH_FIXTURE_JSON
				if (ghFixture) {
					ghJson = ghFixture
				} else {
					const attempt = () =>
						spawnSync('gh', ['pr', 'view', task.pr_url, '--json', 'state,mergedAt,number'], {
							cwd: targetDir,
							encoding: 'utf-8',
						})
					let res = attempt()
					if (res.status !== 0) {
						const e = (res.stderr || '').toString()
						if (/rate limit|403/i.test(e)) {
							// Sleep 30s then retry once — per spec §B1 step 2.
							const end = Date.now() + 30_000
							while (Date.now() < end) {
								// busy-wait-ish: Node's sync sleep requires Atomics.wait on a SAB.
								// A sync sleep is acceptable here — close-merged is not latency-critical.
								// Use Atomics.wait on a dummy SharedArrayBuffer for a real sleep.
								const sab = new SharedArrayBuffer(4)
								const view = new Int32Array(sab)
								Atomics.wait(view, 0, 0, Math.min(1000, end - Date.now()))
							}
							res = attempt()
							if (res.status !== 0) {
								skipped.push({ id, reason: 'rate_limited' })
								continue
							}
						} else if (res.error && res.error.code === 'ENOENT') {
							skipped.push({ id, reason: 'offline' })
							continue
						} else if (!res.stdout) {
							skipped.push({ id, reason: 'offline' })
							continue
						}
					}
					if (res.status === 0 && res.stdout) {
						ghJson = res.stdout
					} else if (!ghJson) {
						ghError = (res.stderr || '').toString().slice(0, 160) || 'gh failed'
						skipped.push({ id, reason: `gh_error: ${ghError}` })
						continue
					}
				}

				let parsed
				try {
					parsed = JSON.parse(ghJson)
				} catch {
					skipped.push({ id, reason: 'gh_parse_error' })
					continue
				}

				// C57 — symmetric pattern with pr-review.maybeRunCloseRoundCleanup:
				// only MERGED and CLOSED are terminal. Anything else (OPEN, draft,
				// ambiguous) stays in shipped-pending-merge for the next sweep.
				if (parsed.state !== 'MERGED' && parsed.state !== 'CLOSED') {
					skipped.push({
						id,
						reason: `pr_${String(parsed.state || 'unknown').toLowerCase()}`,
						pr_state: parsed.state,
					})
					continue
				}

				// C57 — verdict is approved on MERGED, rejected on CLOSED-not-merged.
				// CLOSED PRs are abandoned/superseded; tasks for them must stop
				// rotting in shipped-pending-merge or the sweeper infinite-polls.
				const closeVerdict = parsed.state === 'MERGED' ? 'approved' : 'rejected'

				if (dryRun) {
					closed.push({
						id,
						pr_number: parsed.number ?? null,
						pr_state: parsed.state,
						verdict: closeVerdict,
						dry_run: true,
					})
					continue
				}

				// C57 (Piece 4 guardrail) — defend against task-mutated races.
				// Re-read state under lock and verify pr_url is unchanged before
				// firing the destructive close child. If the task's pr_url
				// shifted (e.g. user re-shipped to a new PR), skip with reason
				// pr_url_mutated rather than closing the wrong PR.
				let prUrlMutated = false
				try {
					if (existsSync(statePath)) {
						withFileLock(
							statePath,
							() => {
								const fresh = JSON.parse(readFileSync(statePath, 'utf-8'))
								const live = fresh.active_tasks?.[id]
								if (!live || live.pr_url !== task.pr_url) {
									prUrlMutated = true
								}
							},
							{ projectRoot: targetDir },
						)
					}
				} catch {
					// Read/lock failure is non-fatal; treat as not-mutated and
					// let task close handle the canonical state read.
				}
				if (prUrlMutated) {
					skipped.push({ id, reason: 'pr_url_mutated' })
					continue
				}

				// Drive the full close path via an in-process CLI call so all of
				// task close's side effects (team-sync, worktree cleanup, phase flip
				// when auto_close_phase is on, event emission, INDEX regen) run.
				const closeArgs = [
					resolve(import.meta.dirname ?? '.', '..', '..', '..', 'bin', 'apt-tools.mjs'),
					'task',
					'close',
					targetDir,
					'--id',
					id,
					'--verdict',
					closeVerdict,
				]
				// Only signal pr_merged_remotely when GitHub
				// confirmed MERGED. CLOSED-not-merged is a rejection path; leaving
				// cleanup behavior unchanged there is intentional (spec §11 R3).
				if (parsed.state === 'MERGED') {
					closeArgs.push('--pr-merged-remotely')
				}
				const closeRes = spawnSync('node', closeArgs, {
					cwd: targetDir,
					encoding: 'utf-8',
					env: { ...process.env },
				})
				if (closeRes.status !== 0) {
					errors.push({
						id,
						reason: `task-close-failed: ${(closeRes.stderr || '').toString().slice(0, 160)}`,
					})
					continue
				}

				// C56 A3 — strip stale per-PR authorship override now that the PR
				// is terminal (MERGED or CLOSED). Best-effort; failure is a warning.
				try {
					const prNumber = parsed.number ?? extractPrNumber(task.pr_url)
					if (prNumber !== null) {
						stripAuthorshipOverride(targetDir, prNumber)
					}
				} catch (e) {
					errors.push({
						id,
						reason: `config-strip-failed: ${e.message ?? String(e)}`,
					})
				}

				// Emit task.closed.merged event (distinct from task.closed emitted
				// inside the `task close` call above — that one has verdict only).
				// Verdict in the event payload distinguishes MERGED (approved) from
				// CLOSED-not-merged (rejected) for log replay consumers.
				try {
					store.appendEvent({
						op: 'task.closed.merged',
						task: id,
						data: {
							pr_url: task.pr_url,
							pr_number: parsed.number ?? null,
							pr_state: parsed.state,
							verdict: closeVerdict,
							mergedAt: parsed.mergedAt ?? null,
							scope: task.scope || 'project',
						},
					})
				} catch {
					// Non-fatal
				}

				closed.push({
					id,
					pr_number: parsed.number ?? null,
					pr_state: parsed.state,
					verdict: closeVerdict,
					mergedAt: parsed.mergedAt ?? null,
				})

				// BUG-027 (b): tear down any armed ci-watch state for this PR.
				// `doStop` is idempotent (returns `not-found` for missing state
				// files), so calling unconditionally here is safe. Without this
				// the watcher state file leaks past PR-terminal-state and
				// downstream operations (ci-watch sweep, health-check) flag it
				// as orphaned. Failure is logged into errors[] as a warning;
				// the merge close itself has already succeeded.
				try {
					const prNumber = parsed.number ?? extractPrNumber(task.pr_url)
					if (prNumber !== null) {
						cmdCiWatch('stop', targetDir, [
							'--pr',
							String(prNumber),
							'--reason',
							'task-closed-merged',
						])
					}
				} catch (e) {
					errors.push({
						id,
						reason: `ci-watch-stop-failed: ${e?.message ?? String(e)}`,
					})
				}

				// Append a pending_narration[] ledger entry on successful merged close
				// so /apt:close-task --narrate-only can drain it later. The ledger is
				// additive-only; failure to write must never undo the close above
				// (R4 ledger leak — caller can manually invoke the narrator).
				if (closeVerdict === 'approved') {
					try {
						withFileLock(
							statePath,
							() => {
								const fresh = JSON.parse(readFileSync(statePath, 'utf-8'))
								if (!Array.isArray(fresh.pending_narration)) fresh.pending_narration = []
								fresh.pending_narration.push({
									task_id: id,
									scope: task.scope || 'project',
									phase_id: task.phase_id ?? null,
									closed_at: new Date().toISOString(),
									pr_number: parsed.number ?? null,
								})
								atomicWriteJson(statePath, fresh)
							},
							{ projectRoot: targetDir },
						)
					} catch (e) {
						errors.push({
							id,
							reason: `ledger-write-failed: ${e.message ?? String(e)}`,
						})
					}
				}
			}

			return ok({
				status: 'ok',
				command: 'task-close-merged',
				dry_run: dryRun,
				closed,
				skipped,
				errors,
			})
		}

		case 'narration-drain': {
			// Remove a single pending_narration[] row under the same withFileLock
			// guard task close uses. Caller (apt:close-task --narrate-only) invokes
			// this once per drained entry AFTER its background narrator spawn
			// returns — so a spawn failure leaves the row in place for retry.
			const taskId = flags.get('task')
			if (!taskId) return err('Required: --task <task-id>')

			if (!existsSync(statePath)) {
				return ok({
					status: 'ok',
					command: 'task-narration-drain',
					removed: null,
					remaining: 0,
					reason: 'no-state',
				})
			}

			let removed = null
			let remaining = 0
			withFileLock(
				statePath,
				() => {
					const state = JSON.parse(readFileSync(statePath, 'utf-8'))
					const ledger = Array.isArray(state.pending_narration) ? state.pending_narration : []
					const idx = ledger.findIndex((row) => row && row.task_id === taskId)
					if (idx >= 0) {
						removed = ledger[idx]
						ledger.splice(idx, 1)
						state.pending_narration = ledger
						atomicWriteJson(statePath, state)
					}
					remaining = ledger.length
				},
				{ projectRoot: targetDir },
			)

			return ok({
				status: 'ok',
				command: 'task-narration-drain',
				removed,
				remaining,
			})
		}

		case 'prune': {
			// C56 A4 — retention verb. Removes `.aperant/tasks/{id}/` directories
			// whose task row is in `state.completed_tasks[]` AND whose completion
			// timestamp is older than --older-than. Never removes a task whose
			// worktree_path still exists on disk. Does NOT mutate state.json.
			const olderThanRaw = flags.get('older-than')
			if (!olderThanRaw) return err('Required: --older-than <duration> (e.g. 30d, 2w, 6m)')
			let thresholdMs
			try {
				thresholdMs = parseDuration(olderThanRaw)
			} catch (e) {
				return err(`task prune: invalid --older-than: ${e.message}`)
			}
			const dryRun = flags.has('dry-run')
			const scopeFilter = flags.get('scope') || null
			const now = Date.now()

			const state = existsSync(statePath)
				? JSON.parse(readFileSync(statePath, 'utf-8'))
				: { completed_tasks: [], active_tasks: {} }

			const pruned = []
			const skipped = []

			for (const row of state.completed_tasks || []) {
				if (!row?.id) continue
				const rowScope = row.scope || 'project'
				if (scopeFilter && rowScope !== scopeFilter) {
					skipped.push({ id: row.id, reason: 'scope-mismatch' })
					continue
				}
				const completedAt = row.completed_at ? Date.parse(row.completed_at) : null
				if (!completedAt || Number.isNaN(completedAt)) {
					skipped.push({ id: row.id, reason: 'no-completed-at' })
					continue
				}
				const age = now - completedAt
				if (age < thresholdMs) {
					skipped.push({
						id: row.id,
						reason: 'too-young',
						age_days: Math.floor(age / 86_400_000),
					})
					continue
				}
				// Worktree-alive guard — refuse to prune if any worktree_path is
				// still on disk (could be from this task OR another task row
				// sharing the dir name, which is fine: the on-disk presence is
				// the signal).
				const worktreePath = row.worktree_path
				if (worktreePath && existsSync(worktreePath)) {
					skipped.push({ id: row.id, reason: 'worktree-active' })
					continue
				}
				const taskDir = getTaskDir(targetDir, row.id)
				if (!existsSync(taskDir)) {
					skipped.push({ id: row.id, reason: 'dir-missing' })
					continue
				}
				if (dryRun) {
					pruned.push({
						id: row.id,
						path: taskDir,
						age_days: Math.floor(age / 86_400_000),
						dry_run: true,
					})
					continue
				}
				try {
					rmSync(taskDir, { recursive: true, force: true })
					pruned.push({
						id: row.id,
						path: taskDir,
						age_days: Math.floor(age / 86_400_000),
					})
				} catch (e) {
					skipped.push({
						id: row.id,
						reason: `rm-failed: ${e.message ?? String(e)}`,
					})
				}
			}

			return ok({
				status: 'ok',
				command: 'task-prune',
				older_than: olderThanRaw,
				threshold_ms: thresholdMs,
				dry_run: dryRun,
				scope: scopeFilter,
				pruned,
				skipped,
			})
		}

		default:
			return err(
				`Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, narration-drain, prune`,
			)
	}
}

function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
	const pr = extractPrNumber(prUrl)
	if (!pr) {
		return {
			ci_watch_armed: false,
			ci_watch_skip_reason: 'invalid-pr-url',
			ci_watch: null,
		}
	}

	const host = detectHost()
	const defaultMode = host.capabilities?.cron ? 'auto' : 'never'
	const config = loadProjectConfig(targetDir)
	const configuredMode = config?.ci_watch?.after_ship || defaultMode
	const mode = configuredMode === 'ask' && Number(task.autonomy) === 3 ? 'auto' : configuredMode

	if (mode === 'never') {
		return {
			ci_watch_armed: false,
			ci_watch_skip_reason: 'disabled',
			ci_watch: null,
		}
	}

	if (mode === 'ask') {
		return {
			ci_watch_armed: false,
			ci_watch_skip_reason: 'ask-deferred',
			ci_watch: null,
		}
	}

	if (mode !== 'auto') {
		return {
			ci_watch_armed: false,
			ci_watch_skip_reason: `invalid-config:${mode}`,
			ci_watch: null,
		}
	}

	const result = cmdCiWatch('start', targetDir, [
		'--pr',
		String(pr),
		'--task-id',
		task.id,
		'--host',
		host.cli || 'unknown',
	])
	const envelope = result?.envelope || {}
	if (envelope.status === 'ok') {
		return {
			ci_watch_armed: true,
			ci_watch_skip_reason: null,
			ci_watch: envelope,
			scheduler_handle: envelope.scheduler_handle ?? null,
		}
	}
	if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
		return {
			ci_watch_armed: true,
			ci_watch_skip_reason: 'already-armed',
			ci_watch: envelope,
			scheduler_handle: envelope.scheduler_handle ?? null,
		}
	}
	return {
		ci_watch_armed: false,
		ci_watch_skip_reason: envelope.reason || 'start-failed',
		ci_watch: envelope,
		scheduler_handle: envelope.scheduler_handle ?? null,
	}
}

/**
 * Extract a PR number from a GitHub URL like https://github.com/owner/repo/pull/123
 * or a short form like `owner/repo#123`. Returns null if not parseable.
 *
 * @param {string} url
 * @returns {number | null}
 * @internal
 */
function extractPrNumber(url) {
	if (typeof url !== 'string') return null
	const m1 = url.match(/\/pull\/(\d+)(?:[?#/]|$)/)
	if (m1) return Number.parseInt(m1[1], 10)
	const m2 = url.match(/#(\d+)$/)
	if (m2) return Number.parseInt(m2[1], 10)
	const m3 = url.match(/^(\d+)$/)
	if (m3) return Number.parseInt(m3[1], 10)
	return null
}

/**
 * C56 A3 / B1 — strip `.aperant/config.json.pr_review.authorship_overrides['pr-{N}']`
 * after PR merge is confirmed. Uses withFileLock + atomicWriteJson. Silent
 * no-op if the config or the key doesn't exist.
 *
 * @param {string} projectDir
 * @param {number} prNumber
 * @internal
 */
function stripAuthorshipOverride(projectDir, prNumber) {
	const configPath = join(resolve(projectDir), '.aperant', 'config.json')
	if (!existsSync(configPath)) return
	withFileLock(
		configPath,
		() => {
			let cfg
			try {
				cfg = JSON.parse(readFileSync(configPath, 'utf-8'))
			} catch {
				return
			}
			const key = `pr-${prNumber}`
			if (cfg?.pr_review?.authorship_overrides && key in cfg.pr_review.authorship_overrides) {
				delete cfg.pr_review.authorship_overrides[key]
				// Clean the parent object if it's now empty.
				if (Object.keys(cfg.pr_review.authorship_overrides).length === 0) {
					delete cfg.pr_review.authorship_overrides
				}
				atomicWriteJson(configPath, cfg)
			}
		},
		{ projectRoot: projectDir },
	)
}
