#!/usr/bin/env node

"use strict";

/**
 * update-lifecycle.js — Feature lifecycle state manager
 * Manages .claude/lifecycle.json per-project state tracking.
 *
 * Subcommands:
 *   register <feature> [--branch <b>] [--issue <n>] [--description <d>]  Register a feature
 *   start <feature> <step>                  Mark step active (sets first_started_at)
 *   done <feature> <step>                   Mark step done
 *   fail <feature> <step>                   Mark step failed
 *   activate <feature>                      Set active feature
 *   deactivate [feature]                    Park feature, clear active
 *   next <feature>                          Output next step command
 *   (bridge removed — lifecycle read directly from lifecycle.json by statusline)
 *   complete <feature>                      Mark feature completed, clear active
 *   archive <feature>                       Archive a feature (any non-archived state)
 *   restore <feature>                       Restore an archived feature
 *   backlog                                 List backlog features (never started) as JSON
 *   queue                                   List incomplete features as JSON
 *   list                                    List all features with status as JSON
 *   read                                    Output lifecycle JSON
 *   require-prior <feature> <step> [--force] [--reason "..."]  Check prior step gate
 *   gate-verdict <feature> <step> <verdict> <artifact-path>    Record gate verdict
 *   plan-mode <feature> on|off|check        Manage planning session marker
 *   artifact-status <file> <status> [--superseded-by <path>]  Update frontmatter status
 */

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const STEPS = ["spec", "design", "develop", "validate", "deploy"];
const VALID_STATUSES = [
  "pending",
  "active",
  "done",
  "failed",
  "skipped",
  "gated",
];

// ─── State Machine Transition Table ─────────────────────────────────────────
// Maps current step status → allowed events.
// Events not listed for a status require --force to proceed.
const VALID_STEP_TRANSITIONS = {
  pending: ["start", "skip", "fail"],
  active: ["start", "done", "fail"], // idempotent start allowed (resume/retry)
  done: ["done", "fail"], // idempotent done allowed; fail allowed (e.g., post-gate-verdict override)
  failed: ["start"], // retry allowed
  skipped: ["start"], // un-skip allowed
  gated: [], // must re-run gate-verdict first; start requires --force
};

/**
 * Validate a state machine transition.
 * Exits with code 1 if the transition is invalid and --force is not set.
 * @param {string} currentStatus - Current step status
 * @param {string} event - The transition event (start, done, fail, skip)
 * @param {{force?: boolean}} opts - Options
 */
function assertTransition(currentStatus, event, opts = {}) {
  const allowed = VALID_STEP_TRANSITIONS[currentStatus] || [];
  if (allowed.includes(event)) return;
  if (opts.force) return;
  console.error(
    `Invalid transition: cannot '${event}' a step in '${currentStatus}' status. ` +
      `Use --force to override.`,
  );
  process.exit(1);
}

const ARTIFACT_STATUSES = ["draft", "accepted", "superseded"];
const VALID_ARTIFACT_TRANSITIONS = {
  draft: ["accepted"],
  accepted: ["superseded", "draft"],
  superseded: [],
};

// ─── Lifecycle File ─────────────────────────────────────────────────────────

function resolveLifecyclePath() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  return path.join(cwd, ".claude", "lifecycle.json");
}

function resolveRegistryPath() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  return path.join(cwd, ".claude", "lifecycle-registry.json");
}

function resolveStatePath() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  return path.join(cwd, ".claude", "lifecycle-state.json");
}

// ─── File Locking ───────────────────────────────────────────────────────────

const LOCK_RETRY_MS = 50;
const LOCK_DEADLINE_MS = 5000;
const LOCK_STALE_MS = 10000;

function resolveLockPath() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  return path.join(cwd, ".claude", "lifecycle.lock");
}

/**
 * Acquire an exclusive file lock using O_CREAT|O_EXCL (wx).
 * Spin-retries with jitter up to a 5s deadline. Detects stale locks via mtime.
 * Writes PID to lock file for diagnostics.
 * Returns a release function.
 */
function acquireLock() {
  const lockPath = resolveLockPath();
  const dir = path.dirname(lockPath);
  fs.mkdirSync(dir, { recursive: true });

  const start = Date.now();
  while (true) {
    try {
      const fd = fs.openSync(lockPath, "wx");
      fs.writeSync(fd, String(process.pid));
      fs.closeSync(fd);
      return function release() {
        try {
          fs.unlinkSync(lockPath);
        } catch {
          /* ignore — already released */
        }
      };
    } catch (err) {
      if (err.code !== "EEXIST") throw err;

      // Check for stale lock
      try {
        const stat = fs.statSync(lockPath);
        if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
          // Stale lock — break it
          try {
            fs.unlinkSync(lockPath);
          } catch {
            /* race — another process may have already broken it */
          }
          continue;
        }
      } catch {
        // Lock disappeared between open and stat — retry
        continue;
      }

      if (Date.now() - start >= LOCK_DEADLINE_MS) {
        process.stderr.write(
          JSON.stringify({
            error: "LOCK_TIMEOUT",
            code: "E005",
            message: `Lifecycle lock timeout after ${LOCK_DEADLINE_MS}ms`,
            lockPath,
          }) + "\n",
        );
        process.exit(1);
      }

      // Spin-wait with jitter — intentional busy wait since Node.js has no
      // synchronous sleep. Pins a CPU core for up to ~100ms per iteration, which
      // is acceptable for a CLI tool. Same pattern in manage-features.js and
      // sync-tracker.js. (#623)
      const jitter = Math.random() * LOCK_RETRY_MS;
      const waitUntil = Date.now() + LOCK_RETRY_MS + jitter;
      while (Date.now() < waitUntil) {
        /* busy wait */
      }
    }
  }
}

/**
 * Execute fn() while holding the lifecycle lock.
 * Lock is released in the finally block AND via a process exit handler
 * (process.exit() bypasses finally blocks in Node.js).
 */
function withLifecycleLock(fn) {
  const release = acquireLock();
  const onExit = () => release();
  process.on("exit", onExit);
  try {
    return fn();
  } finally {
    process.removeListener("exit", onExit);
    release();
  }
}

// ─── Session ID ─────────────────────────────────────────────────────────────

/**
 * Derive a deterministic 8-char hex session ID from cwd + ppid.
 * Override via CLAUDE_SESSION_ID env var.
 */
function resolveSessionId() {
  if (process.env.CLAUDE_SESSION_ID) {
    return process.env.CLAUDE_SESSION_ID;
  }
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  const raw = `${cwd}${process.ppid}`;
  return crypto.createHash("sha256").update(raw).digest("hex").slice(0, 8);
}

// ─── Sessions Directory ─────────────────────────────────────────────────────

function resolveSessionsDir() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  return path.join(cwd, ".claude", "sessions");
}

// ─── Schema Migrations ─────────────────────────────────────────────────────

function migrateToV4(data) {
  if (data.schema_version >= 4) return data;
  // V4 adds session registry support — no structural changes to existing data,
  // but bumps version to indicate session-aware lifecycle
  data.schema_version = 4;
  return data;
}

function migrateToV5(data) {
  if (data.schema_version >= 5) return data;

  // Read features.json for slug → <KEY>-XXX mapping
  const featuresPath = path.join(
    path.dirname(resolveLifecyclePath()),
    "features.json",
  );
  let slugToId = {};

  if (fs.existsSync(featuresPath)) {
    try {
      const features = JSON.parse(fs.readFileSync(featuresPath, "utf-8"));
      for (const [id, feat] of Object.entries(features.features || {})) {
        if (feat.slug) slugToId[feat.slug] = id;
      }
    } catch {
      /* ignore unreadable features.json — graceful degradation */
    }
  }

  // Self-bootstrap: if no features.json (or empty mapping), generate <KEY>-N
  // IDs from lifecycle.json data so upgrades from v0.7.1 work without manual steps.
  // Reads PROJECT_KEY from project-constants.md; falls back to FEAT- prefix.
  if (Object.keys(slugToId).length === 0) {
    const slugEntries = Object.entries(data.features)
      .filter(([key]) => !isFeatureId(key))
      .sort((a, b) =>
        (a[1].created_at || "").localeCompare(b[1].created_at || ""),
      );

    if (slugEntries.length > 0) {
      const prefix = readProjectKeyFromConstants() || "FEAT";
      let nextId = 1;
      const generatedFeatures = {};

      for (const [slug, feature] of slugEntries) {
        const featId = `${prefix}-${nextId}`;
        slugToId[slug] = featId;
        generatedFeatures[featId] = {
          id: featId,
          slug,
          display_name: feature.display_name || slug,
          description: feature.description || null,
          priority: feature.priority || null,
          created_at: feature.created_at || new Date().toISOString(),
          updated_at: feature.updated_at || new Date().toISOString(),
          remote: null,
          archived_at: feature.archived_at || null,
        };
        nextId++;
      }

      // Write generated features.json as side-effect (atomic tmp+rename)
      try {
        const newFeaturesData = {
          schema_version: 1,
          next_id: nextId,
          project_key: "",
          features: generatedFeatures,
        };
        const tmp = featuresPath + ".tmp";
        fs.writeFileSync(
          tmp,
          JSON.stringify(newFeaturesData, null, 2) + "\n",
          "utf-8",
        );
        fs.renameSync(tmp, featuresPath);
        process.stderr.write(
          `[update-lifecycle] migrateToV5: bootstrapped features.json with ${slugEntries.length} feature(s)\n`,
        );
      } catch {
        /* best effort — migration continues even if write fails */
      }
    }
  }

  // Re-key features from slug to <KEY>-XXX
  const newFeatures = {};
  for (const [slug, feature] of Object.entries(data.features)) {
    const featId = slugToId[slug] || null;
    if (featId) {
      newFeatures[featId] = feature;
    } else {
      // No mapping — keep slug key (graceful degradation)
      process.stderr.write(
        `[update-lifecycle] migrateToV5: no feature ID mapping for slug "${slug}" — keeping as-is\n`,
      );
      newFeatures[slug] = feature;
    }
  }

  // Deduplication: if both a slug key and its corresponding <KEY>-XXX key exist
  // (can happen when slug was kept as-is above but <KEY>-XXX was also present),
  // merge slug entry into <KEY>-XXX entry, preferring <KEY>-XXX data.
  for (const [id, feat] of Object.entries(newFeatures)) {
    if (!isFeatureId(id)) continue;
    // Find any slugToId reverse entry that maps to this id
    for (const [slug, mappedId] of Object.entries(slugToId)) {
      if (mappedId === id && newFeatures[slug]) {
        process.stderr.write(
          `[update-lifecycle] migrateToV5: deduplicating ghost slug "${slug}" into "${id}"\n`,
        );
        // Prefer <KEY>-XXX data; remove the slug ghost
        delete newFeatures[slug];
      }
    }
  }

  data.features = newFeatures;

  // Update active_feature from slug to <KEY>-XXX (AC-5)
  if (data.active_feature && slugToId[data.active_feature]) {
    data.active_feature = slugToId[data.active_feature];
  }

  data.schema_version = 5;
  return data;
}

function migrateToV3(data) {
  if (data.schema_version >= 3) return data;
  for (const feature of Object.values(data.features)) {
    if (!feature.steps) continue;
    for (const step of STEPS) {
      const s = feature.steps[step];
      if (!s) continue;
      if (s.gate_verdict === undefined) {
        // Grandfather clause: steps already done passed without formal gating
        s.gate_verdict = s.status === "done" ? "PASS" : null;
      }
      if (s.gate_artifact === undefined) s.gate_artifact = null;
      if (s.forced_at === undefined) s.forced_at = null;
      if (s.forced_reason === undefined) s.forced_reason = null;
    }
  }
  data.schema_version = 3;
  return data;
}

function migrateToV2(data) {
  if (data.schema_version >= 2) return data;
  for (const feature of Object.values(data.features)) {
    // Infer first_started_at from earliest step started_at
    let earliest = null;
    if (feature.steps) {
      for (const step of STEPS) {
        const s = feature.steps[step];
        if (s && s.started_at && (!earliest || s.started_at < earliest)) {
          earliest = s.started_at;
        }
      }
    }
    if (feature.first_started_at === undefined) {
      feature.first_started_at = earliest || null;
    }
    if (feature.archived_at === undefined) feature.archived_at = null;
    if (feature.description === undefined) feature.description = null;
    if (feature.priority === undefined) feature.priority = null;
  }
  data.schema_version = 2;
  return data;
}

// ─── Registry / State CRUD ─────────────────────────────────────────────────

/**
 * Full field set for a step object. Used by migration and cmdRegister.
 */
function makeDefaultStep(status) {
  return {
    status: status || "pending",
    started_at: null,
    completed_at: null,
    gate_verdict: null,
    gate_verdict_at: null,
    gate_artifact: null,
    forced_at: null,
    forced_reason: null,
    started_by_session: null,
  };
}

/**
 * Normalize a step object to the full v6 field set, preserving existing values.
 */
function normalizeStep(step) {
  const base = makeDefaultStep();
  return { ...base, ...step };
}

function readRegistry() {
  const regPath = resolveRegistryPath();
  try {
    if (fs.existsSync(regPath)) {
      return JSON.parse(fs.readFileSync(regPath, "utf-8"));
    }
  } catch {
    /* ignore */
  }
  return null;
}

function writeRegistry(data) {
  const regPath = resolveRegistryPath();
  const dir = path.dirname(regPath);
  fs.mkdirSync(dir, { recursive: true });
  const tmpPath = regPath + ".tmp";
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
  fs.renameSync(tmpPath, regPath);
}

function readState() {
  const statePath = resolveStatePath();
  try {
    if (fs.existsSync(statePath)) {
      return JSON.parse(fs.readFileSync(statePath, "utf-8"));
    }
  } catch {
    /* ignore */
  }
  return { schema_version: 7, active_feature: null, updated_at: null };
}

function writeState(data) {
  const statePath = resolveStatePath();
  const dir = path.dirname(statePath);
  fs.mkdirSync(dir, { recursive: true });
  const tmpPath = statePath + ".tmp";
  const payload = {
    schema_version: data.schema_version || 7,
    active_feature:
      data.active_feature !== undefined ? data.active_feature : null,
    updated_at: new Date().toISOString(),
  };
  fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
  fs.renameSync(tmpPath, statePath);
}

// ─── v5 → v6 Migration ────────────────────────────────────────────────────

/**
 * Migrate from lifecycle.json (v5) to lifecycle-registry.json + lifecycle-state.json (v6).
 * Resumable and idempotent. Must be called inside withLifecycleLock().
 */
function migrateToV6() {
  const regPath = resolveRegistryPath();
  const oldPath = resolveLifecyclePath();
  const claudeDir = path.dirname(oldPath);
  const featuresPath = path.join(claudeDir, "features.json");

  // If registry exists with migration_complete: true, skip
  try {
    if (fs.existsSync(regPath)) {
      const existing = JSON.parse(fs.readFileSync(regPath, "utf-8"));
      if (existing.migration_complete === true) return;
      // migration_complete: false — re-run from scratch
    }
  } catch {
    /* re-run */
  }

  // Need lifecycle.json to migrate from
  if (!fs.existsSync(oldPath)) return;

  // Read and apply legacy migrations
  let data;
  try {
    data = JSON.parse(fs.readFileSync(oldPath, "utf-8"));
  } catch {
    return;
  }
  data = migrateToV5(migrateToV4(migrateToV3(migrateToV2(data))));

  // Read features.json for unique fields
  let featuresData = null;
  if (fs.existsSync(featuresPath)) {
    try {
      featuresData = JSON.parse(fs.readFileSync(featuresPath, "utf-8"));
    } catch {
      /* ignore */
    }
  }

  // Build registry
  const registry = {
    schema_version: 7,
    migration_complete: false,
    project_key: (featuresData && featuresData.project_key) || null,
    next_id: (featuresData && featuresData.next_id) || null,
    features: {},
  };

  // Merge feature data
  for (const [key, feature] of Object.entries(data.features || {})) {
    const feat = { ...feature };
    // Merge features.json unique fields (slug, remote)
    if (featuresData && featuresData.features) {
      const fEntry = featuresData.features[key];
      if (fEntry) {
        if (fEntry.slug !== undefined && feat.slug === undefined)
          feat.slug = fEntry.slug;
        if (fEntry.remote !== undefined && feat.remote === undefined)
          feat.remote = fEntry.remote;
      }
    }
    // Ensure slug exists
    if (feat.slug === undefined) feat.slug = null;
    if (feat.remote === undefined) feat.remote = null;
    // Normalize all steps to full field set
    if (feat.steps) {
      for (const step of STEPS) {
        feat.steps[step] = normalizeStep(feat.steps[step] || {});
      }
    }
    registry.features[key] = feat;
  }

  // Build state
  const stateData = {
    schema_version: 7,
    active_feature: data.active_feature || null,
  };

  // Step 5: Write registry with migration_complete: false
  writeRegistry(registry);

  // Step 6: Write state
  writeState(stateData);

  // Step 7: Set migration_complete: true
  registry.migration_complete = true;
  writeRegistry(registry);

  // Step 8: Rename lifecycle.json
  try {
    fs.renameSync(oldPath, oldPath + ".migrated");
  } catch {
    /* best effort */
  }

  // Step 9: Rename features.json
  if (fs.existsSync(featuresPath)) {
    try {
      fs.renameSync(featuresPath, featuresPath + ".migrated");
    } catch {
      /* best effort */
    }
  }

  // Step 10: Clean up old lock file
  const oldLockPath = path.join(claudeDir, "lifecycle.json.lock");
  try {
    if (fs.existsSync(oldLockPath)) fs.unlinkSync(oldLockPath);
  } catch {
    /* ignore */
  }

  process.stderr.write(
    `[update-lifecycle] migrateToV6: migrated to lifecycle-registry.json + lifecycle-state.json\n`,
  );
}

// ─── Lifecycle Read/Write (transparent wrappers) ───────────────────────────

function migrateToV7(data) {
  if ((data.schema_version || 0) >= 7) return data;

  let stripped = 0;
  for (const id of Object.keys(data.features || {})) {
    const f = data.features[id];
    if (!f) continue;
    if (Object.prototype.hasOwnProperty.call(f, "display_name")) {
      delete f.display_name;
      stripped += 1;
    }
    if (Object.prototype.hasOwnProperty.call(f, "description")) {
      delete f.description;
      stripped += 1;
    }
  }

  data.schema_version = 7;
  if (stripped > 0) {
    process.stderr.write(
      "[update-lifecycle] migrateToV7: stripped " +
        stripped +
        " field(s) from " +
        Object.keys(data.features || {}).length +
        " feature(s)\n",
    );
  }
  return data;
}

function readLifecycle() {
  const regPath = resolveRegistryPath();

  // If registry exists, read from split files
  if (fs.existsSync(regPath)) {
    try {
      const registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
      // If migration incomplete, run it
      if (registry.migration_complete === false) {
        migrateToV6();
        return readLifecycle();
      }
      const state = readState();
      const merged = {
        schema_version: registry.schema_version,
        active_feature: state.active_feature,
        features: registry.features,
        project_key: registry.project_key || null,
        next_id: registry.next_id || null,
      };
      const prevVersion = merged.schema_version;
      migrateToV7(merged);
      if (merged.schema_version !== prevVersion) {
        writeRegistry({
          schema_version: merged.schema_version,
          migration_complete: true,
          project_key: merged.project_key,
          next_id: merged.next_id,
          features: merged.features,
        });
      }
      return merged;
    } catch {
      /* fall through to legacy */
    }
  }

  // Legacy fallback: read lifecycle.json
  const filePath = resolveLifecyclePath();
  try {
    if (fs.existsSync(filePath)) {
      const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
      const migrated = migrateToV5(migrateToV4(migrateToV3(migrateToV2(data))));
      // Trigger migration to v6 (will write split files)
      migrateToV6();
      // Re-read from split files if migration succeeded
      if (fs.existsSync(regPath)) {
        return readLifecycle();
      }
      return migrated;
    }
  } catch {
    /* ignore */
  }
  return { schema_version: 7, active_feature: null, features: {} };
}

function writeLifecycle(data) {
  writeRegistry({
    schema_version: data.schema_version || 7,
    migration_complete: true,
    project_key: data.project_key || null,
    next_id: data.next_id || null,
    features: data.features,
  });
  writeState({
    schema_version: data.schema_version || 7,
    active_feature:
      data.active_feature !== undefined ? data.active_feature : null,
  });
}

/**
 * @deprecated Use readLifecycle() directly. Removal target: v0.9.0.
 * Backward-compatible alias that reads split files and returns merged shape.
 */
const readMerged = readLifecycle;

// ─── Feature Slug ───────────────────────────────────────────────────────────

function toSlug(name) {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

/**
 * Read PROJECT_KEY from project-constants.md if it exists.
 * Returns empty string if not found or file doesn't exist.
 */
function readProjectKeyFromConstants() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  const constantsPath = path.join(cwd, ".claude", "project-constants.md");
  if (!fs.existsSync(constantsPath)) return "";
  try {
    const content = fs.readFileSync(constantsPath, "utf-8");
    const m = content.match(/PROJECT_KEY\s*\|\s*([^\s|]+)/);
    return m ? m[1].trim() : "";
  } catch {
    return "";
  }
}

/**
 * Returns true if str matches a feature ID format: <KEY>-N (e.g. WA-1, ECK-74, FEAT-7, ECK-DEV-1).
 * KEY is one or more uppercase-alphanumeric segments separated by dashes; followed by -N digits.
 * Case-insensitive. Also matches legacy zero-padded IDs (e.g. FEAT-001).
 * @param {string} str
 * @returns {boolean}
 */
function isFeatureId(str) {
  return /^[A-Z][A-Z0-9]{0,9}(?:-[A-Z0-9]+)*-\d+$/i.test(str);
}

/**
 * Resolve the key used to look up a feature in data.features.
 * - If featureArg matches <KEY>-N format (e.g. WA-1, FEAT-74): returns it uppercased (direct key).
 * - Otherwise: falls back to slug-based lookup for backward compatibility.
 *
 * Options:
 *   allowNew {boolean} — when true, returns the derived slug/id even if it
 *     does not yet exist in features (used by cmdRegister to create new entries).
 *     When false (default), slug-derived keys that don't exist in features are
 *     returned as-is so callers can produce a correct "not found" error.
 *
 * Returns the resolved key. Never returns null — callers check features[key].
 * @param {object} features - data.features map
 * @param {string} featureArg - raw argument from CLI
 * @param {{allowNew?: boolean}} [opts]
 * @returns {string}
 */
function resolveFeatureKey(features, featureArg, opts) {
  if (isFeatureId(featureArg)) {
    const id = featureArg.toUpperCase();
    // For <KEY>-XXX format, always return uppercased id (found or not)
    return id;
  }
  // Slug fallback
  const slug = toSlug(featureArg);
  // If the derived slug exists in the map, return it
  if (features[slug] !== undefined) return slug;
  // Not found. If allowNew is set (register path), return the slug so the
  // caller can create a new entry. Otherwise return the slug too — callers
  // already check features[key] and produce their own "not found" error.
  return slug;
}

// ─── Subcommands ────────────────────────────────────────────────────────────

function parseFlag(args, flag) {
  const idx = args.indexOf(flag);
  return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
}

// ─── Frontmatter Helpers ────────────────────────────────────────────────────

function parseFrontmatter(content) {
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?(?:\n|$)/);
  if (!match) return null;
  return { raw: match[1], fullMatch: match[0] };
}

function getStatusFromFrontmatter(raw) {
  const m = raw.match(/^status:\s*"?([^"\n]*)"?\s*$/m);
  return m ? m[1].trim() : null;
}

function setStatusInFrontmatter(content, fm, newStatus, supersededBy) {
  let updatedRaw = fm.raw;
  const statusRegex = /^status:\s*"?[^"\n]*"?\s*$/m;

  if (statusRegex.test(updatedRaw)) {
    updatedRaw = updatedRaw.replace(statusRegex, `status: "${newStatus}"`);
  } else {
    // No status field — insert before closing ---
    updatedRaw = updatedRaw + `\nstatus: "${newStatus}"`;
  }

  if (supersededBy) {
    const sbRegex = /^superseded_by:\s*"?[^"\n]*"?\s*$/m;
    if (sbRegex.test(updatedRaw)) {
      updatedRaw = updatedRaw.replace(
        sbRegex,
        `superseded_by: "${supersededBy}"`,
      );
    } else {
      updatedRaw = updatedRaw + `\nsuperseded_by: "${supersededBy}"`;
    }
  }

  const updatedFm = `---\n${updatedRaw}\n---`;
  return content.replace(fm.fullMatch, () => updatedFm);
}

// ─── Subcommands ────────────────────────────────────────────────────────────

function cmdRegister(args) {
  const featureName = args[0];
  if (!featureName) {
    console.error(
      "Usage: register <feature> [--branch <branch>] [--issue <n>] [--description <d>] [--priority <n>]",
    );
    process.exit(1);
  }

  const branch = parseFlag(args, "--branch");
  const issueRaw = parseFlag(args, "--issue");
  const issueNumber = issueRaw ? parseInt(issueRaw, 10) || null : null;
  const description = parseFlag(args, "--description");
  const priorityRaw = parseFlag(args, "--priority");
  const priority = priorityRaw ? parseInt(priorityRaw, 10) : null;
  if (priorityRaw && (priority === null || isNaN(priority))) {
    console.error(`Invalid priority value: ${priorityRaw} (must be a number)`);
    process.exit(1);
  }
  const featureKey = isFeatureId(featureName)
    ? featureName.toUpperCase()
    : toSlug(featureName);

  withLifecycleLock(() => {
    const data = readLifecycle();
    if (data.features[featureKey]) {
      // Already registered — update mutable fields if provided
      if (branch) data.features[featureKey].branch = branch;
      if (issueNumber) data.features[featureKey].issue_number = issueNumber;
      if (priority !== null) data.features[featureKey].priority = priority;
      data.features[featureKey].updated_at = new Date().toISOString();
      writeLifecycle(data);
      return;
    }

    const steps = {};
    for (const step of STEPS) {
      steps[step] = makeDefaultStep("pending");
    }

    data.features[featureKey] = {
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
      branch: branch,
      issue_number: issueNumber,
      first_started_at: null,
      archived_at: null,
      priority: priority,
      steps,
    };

    writeLifecycle(data);
  });
}

function cmdStart(args) {
  const [featureName, step] = args;
  if (!featureName || !step) {
    console.error('Usage: start <feature> <step> [--force] [--reason "..."]');
    process.exit(1);
  }
  if (!STEPS.includes(step)) {
    console.error(`Invalid step: ${step}. Valid: ${STEPS.join(", ")}`);
    process.exit(1);
  }

  const flagArgs = args.slice(2);
  const forceFlag = flagArgs.includes("--force");
  const reason = parseFlag(flagArgs, "--reason");

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    const existing = data.features[featureKey].steps[step] || {};
    const currentStatus = existing.status || "pending";

    // Session conflict detection (AC-9): if step is already active in another session
    const currentSessionId = resolveSessionId();
    if (
      currentStatus === "active" &&
      existing.started_by_session &&
      existing.started_by_session !== currentSessionId
    ) {
      if (!forceFlag) {
        console.error(
          `Error: Step '${step}' is already active in session ${existing.started_by_session}\n` +
            `       (started ${existing.started_at}).\n` +
            `       Use --force --reason "..." to take over.`,
        );
        process.exit(1);
      }
    }

    assertTransition(currentStatus, "start", { force: forceFlag });

    const now = new Date().toISOString();
    const stepData = {
      ...existing,
      status: "active",
      started_at: now,
      started_by_session: currentSessionId,
    };
    // Record forced override with audit trail
    if (
      forceFlag &&
      !VALID_STEP_TRANSITIONS[currentStatus]?.includes("start")
    ) {
      stepData.forced_at = now;
      stepData.forced_reason = reason || null;
    }
    data.features[featureKey].steps[step] = stepData;
    // Set first_started_at on first step activation
    if (!data.features[featureKey].first_started_at) {
      data.features[featureKey].first_started_at = now;
    }
    data.features[featureKey].updated_at = now;
    writeLifecycle(data);
  });
}

function cmdDone(args) {
  const [featureName, step] = args;
  if (!featureName || !step) {
    console.error(
      'Usage: done <feature> <step> [--no-guard] [--force] [--reason "..."]',
    );
    process.exit(1);
  }
  if (!STEPS.includes(step)) {
    console.error(`Invalid step: ${step}. Valid: ${STEPS.join(", ")}`);
    process.exit(1);
  }

  const flagArgs = args.slice(2);
  const forceFlag = flagArgs.includes("--force");
  const noGuard = flagArgs.includes("--no-guard");
  const reason = parseFlag(flagArgs, "--reason");

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    const existing = data.features[featureKey].steps[step] || {};
    const currentStatus = existing.status || "pending";
    assertTransition(currentStatus, "done", { force: forceFlag });

    // Require-prior guard: verify prior step is done with passing gate verdict
    const stepIndex = STEPS.indexOf(step);
    if (!noGuard && stepIndex > 0) {
      const priorStep = STEPS[stepIndex - 1];
      const priorData = data.features[featureKey].steps[priorStep] || {};
      const priorStatus = priorData.status || "pending";
      const priorVerdict = priorData.gate_verdict || null;
      const statusOk = priorStatus === "done";
      const verdictOk =
        priorVerdict === "PASS" || priorVerdict === "CONDITIONAL";

      if (!(statusOk && verdictOk)) {
        if (forceFlag) {
          // Record forced override
          existing.forced_at = new Date().toISOString();
          existing.forced_reason = reason || null;
        } else {
          const parts = [];
          if (!statusOk)
            parts.push(
              `prior step '${priorStep}' is not done (status: ${priorStatus})`,
            );
          if (!verdictOk)
            parts.push(
              `gate verdict is not PASS or CONDITIONAL (verdict: ${priorVerdict})`,
            );
          console.error(
            `Cannot complete '${step}': ${parts.join("; ")}. ` +
              `Use --force --reason "..." to override, or --no-guard to skip.`,
          );
          process.exit(1);
        }
      }
    }

    const now = new Date().toISOString();
    data.features[featureKey].steps[step] = {
      ...existing,
      status: "done",
      started_at: existing.started_at || null,
      completed_at: existing.completed_at || now,
    };
    data.features[featureKey].updated_at = now;
    writeLifecycle(data);
  });
}

function cmdFail(args) {
  const [featureName, step] = args;
  if (!featureName || !step) {
    console.error('Usage: fail <feature> <step> [--force] [--reason "..."]');
    process.exit(1);
  }
  if (!STEPS.includes(step)) {
    console.error(`Invalid step: ${step}. Valid: ${STEPS.join(", ")}`);
    process.exit(1);
  }

  const flagArgs = args.slice(2);
  const forceFlag = flagArgs.includes("--force");
  const reason = parseFlag(flagArgs, "--reason");

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    const existing = data.features[featureKey].steps[step] || {};
    const currentStatus = existing.status || "pending";
    assertTransition(currentStatus, "fail", { force: forceFlag });

    const now = new Date().toISOString();
    const stepData = {
      ...existing,
      status: "failed",
      started_at: existing.started_at || null,
      failed_at: now,
    };
    // Record forced override with audit trail
    if (forceFlag && !VALID_STEP_TRANSITIONS[currentStatus]?.includes("fail")) {
      stepData.forced_at = now;
      stepData.forced_reason = reason || null;
    }
    data.features[featureKey].steps[step] = stepData;
    data.features[featureKey].updated_at = now;
    writeLifecycle(data);
  });
}

function cmdActivate(args) {
  const featureName = args[0];
  if (!featureName) {
    console.error("Usage: activate <feature> [--session <id>]");
    process.exit(1);
  }

  const sessionId = parseFlag(args, "--session") || resolveSessionId();
  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    // Write active_feature to lifecycle.json root (AC-5: store feature ID directly)
    data.active_feature = featureKey;
    writeLifecycle(data);

    // Write active_feature to session file (session-scoped)
    const claudeDir = path.join(
      process.env.CLAUDE_PROJECT_DIR || process.cwd(),
      ".claude",
    );
    writeSessionFile(
      sessionId,
      {
        active_feature: featureKey,
        plan_mode: false,
        stage_description: null,
        git_branch: null,
      },
      claudeDir,
    );
  });
}

function cmdDeactivate(args) {
  // Parse --session from the full args array BEFORE extracting feature name,
  // so `deactivate --session abc123` works correctly without a feature name.
  const sessionId = parseFlag(args, "--session") || resolveSessionId();
  withLifecycleLock(() => {
    const data = readLifecycle();

    // Resolve key: explicit arg (non-flag) or current active_feature
    let featureKey;
    if (args[0] && !args[0].startsWith("--")) {
      featureKey = resolveFeatureKey(data.features, args[0]);
    } else if (data.active_feature) {
      featureKey = data.active_feature;
    } else {
      console.error("No active feature to deactivate.");
      process.exit(1);
    }

    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    // Revert any "active" step back to "pending" (preserve done/skipped/failed)
    const steps = data.features[featureKey].steps;
    for (const step of STEPS) {
      if (steps[step] && steps[step].status === "active") {
        steps[step].status = "pending";
      }
    }

    data.features[featureKey].updated_at = new Date().toISOString();

    // Clear active_feature if it matches
    if (data.active_feature === featureKey) {
      data.active_feature = null;
    }

    writeLifecycle(data);

    // Clear active_feature in session file
    const claudeDir = path.join(
      process.env.CLAUDE_PROJECT_DIR || process.cwd(),
      ".claude",
    );
    writeSessionFile(
      sessionId,
      {
        active_feature: null,
        plan_mode: false,
        stage_description: null,
        git_branch: null,
      },
      claudeDir,
    );
  });
}

function cmdNext(args) {
  const featureName = args[0];
  if (!featureName) {
    console.error("Usage: next <feature>");
    process.exit(1);
  }

  const data = readLifecycle();
  const featureKey = resolveFeatureKey(data.features, featureName);
  if (!data.features[featureKey]) {
    console.error(`Feature not found: ${featureKey}`);
    process.exit(1);
  }

  const steps = data.features[featureKey].steps;
  for (const step of STEPS) {
    const status = steps[step] ? steps[step].status : "pending";
    if (status === "pending" || status === "failed") {
      console.log(`/eck:${step} ${featureKey}`);
      return;
    }
  }
  console.log("Feature complete.");
}

function cmdComplete(args) {
  const featureName = args[0];
  if (!featureName) {
    console.error("Usage: complete <feature>");
    process.exit(1);
  }

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    data.features[featureKey].completed_at = new Date().toISOString();
    data.features[featureKey].updated_at = new Date().toISOString();

    // Clear active_feature if it matches the completed feature
    if (data.active_feature === featureKey) {
      data.active_feature = null;
    }

    writeLifecycle(data);
  });
}

function cmdQueue() {
  const data = readLifecycle();
  const queue = [];

  for (const [featureKey, feature] of Object.entries(data.features)) {
    // Skip completed and archived features
    if (feature.completed_at) continue;
    if (feature.archived_at) continue;

    // Find first non-done/non-skipped step
    let nextStep = null;
    for (const step of STEPS) {
      const status = feature.steps[step]
        ? feature.steps[step].status
        : "pending";
      if (status !== "done" && status !== "skipped") {
        nextStep = step;
        break;
      }
    }

    // If all steps done/skipped, skip this feature
    if (!nextStep) continue;

    queue.push({
      slug: featureKey,
      display_name: feature.display_name,
      issue_number: feature.issue_number || null,
      next_step: nextStep,
      created_at: feature.created_at,
    });
  }

  console.log(JSON.stringify(queue, null, 2));
}

function cmdList() {
  const data = readLifecycle();
  const list = [];

  for (const [featureKey, feature] of Object.entries(data.features)) {
    // Determine status: archived > done > active > parked > backlog
    let status;
    if (feature.archived_at) {
      status = "archived";
    } else if (feature.completed_at) {
      status = "done";
    } else if (data.active_feature === featureKey) {
      status = "active";
    } else if (feature.first_started_at) {
      status = "parked";
    } else {
      status = "backlog";
    }

    // Find next incomplete step
    let nextStep = null;
    for (const step of STEPS) {
      const stepStatus = feature.steps[step]
        ? feature.steps[step].status
        : "pending";
      if (stepStatus !== "done" && stepStatus !== "skipped") {
        nextStep = step;
        break;
      }
    }

    // Build progress map
    const progress = {};
    for (const step of STEPS) {
      progress[step] = feature.steps[step]
        ? feature.steps[step].status
        : "pending";
    }

    list.push({
      slug: featureKey,
      display_name: feature.display_name,
      status,
      next_step: nextStep,
      progress,
      updated_at: feature.updated_at,
    });
  }

  // Sort by updated_at descending
  list.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));

  console.log(JSON.stringify(list, null, 2));
}

function cmdArchive(args) {
  const featureName = args[0];
  if (!featureName) {
    console.error("Usage: archive <feature>");
    process.exit(1);
  }

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    if (data.features[featureKey].archived_at) {
      console.error(`Feature already archived: ${featureKey}`);
      process.exit(1);
    }

    // If feature is active, clear active_feature and revert active steps
    if (data.active_feature === featureKey) {
      const steps = data.features[featureKey].steps;
      for (const step of STEPS) {
        if (steps[step] && steps[step].status === "active") {
          steps[step].status = "pending";
        }
      }
      data.active_feature = null;
    }

    data.features[featureKey].archived_at = new Date().toISOString();
    data.features[featureKey].updated_at = new Date().toISOString();
    writeLifecycle(data);
  });
}

function cmdRestore(args) {
  const featureName = args[0];
  if (!featureName) {
    console.error("Usage: restore <feature>");
    process.exit(1);
  }

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    if (!data.features[featureKey].archived_at) {
      console.error(`Feature is not archived: ${featureKey}`);
      process.exit(1);
    }

    data.features[featureKey].archived_at = null;
    data.features[featureKey].updated_at = new Date().toISOString();
    writeLifecycle(data);
  });
}

function cmdBacklog() {
  const data = readLifecycle();
  const backlog = [];

  for (const [slug, feature] of Object.entries(data.features)) {
    // Backlog: not completed, not archived, never started, not active
    if (feature.completed_at) continue;
    if (feature.archived_at) continue;
    if (feature.first_started_at) continue;
    if (slug === data.active_feature) continue;

    backlog.push({
      slug,
      display_name: feature.display_name,
      description: feature.description || null,
      issue_number: feature.issue_number || null,
      priority: feature.priority,
      created_at: feature.created_at,
    });
  }

  // Sort by priority (nulls last), then created_at ascending
  backlog.sort((a, b) => {
    if (a.priority !== null && b.priority !== null)
      return a.priority - b.priority;
    if (a.priority !== null) return -1;
    if (b.priority !== null) return 1;
    return (a.created_at || "").localeCompare(b.created_at || "");
  });

  console.log(JSON.stringify(backlog, null, 2));
}

function cmdRead() {
  const data = readLifecycle();
  console.log(JSON.stringify(data, null, 2));
}

function cmdRequirePrior(args) {
  const featureName = args[0];
  const step = args[1];
  if (!featureName || !step) {
    console.error(
      'Usage: require-prior <feature> <step> [--force] [--reason "..."]',
    );
    process.exit(1);
  }
  if (!STEPS.includes(step)) {
    console.error(`Invalid step: ${step}. Valid: ${STEPS.join(", ")}`);
    process.exit(1);
  }

  // Parse flags from remaining args (after the two positionals)
  const flagArgs = args.slice(2);
  const forceFlag = flagArgs.includes("--force");
  const reason = parseFlag(flagArgs, "--reason");

  const stepIndex = STEPS.indexOf(step);

  // No prerequisite for the first step
  if (stepIndex === 0) {
    console.log(
      JSON.stringify({
        passed: true,
        reason: "no prerequisite for first step",
      }),
    );
    process.exit(0);
  }

  const priorStep = STEPS[stepIndex - 1];

  // Lock needed because --force path writes to lifecycle.json
  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    const priorStepData = data.features[featureKey].steps[priorStep] || {};
    const priorStatus = priorStepData.status || "pending";
    const priorVerdict = priorStepData.gate_verdict || null;

    const statusOk = priorStatus === "done";
    const verdictOk = priorVerdict === "PASS" || priorVerdict === "CONDITIONAL";

    if (statusOk && verdictOk) {
      console.log(
        JSON.stringify({
          passed: true,
          status: priorStatus,
          verdict: priorVerdict,
        }),
      );
      process.exit(0);
    }

    if (forceFlag) {
      // Record the forced override in the current step being started
      const currentStepData = data.features[featureKey].steps[step] || {
        status: "pending",
      };
      currentStepData.forced_at = new Date().toISOString();
      currentStepData.forced_reason = reason || null;
      data.features[featureKey].steps[step] = currentStepData;
      data.features[featureKey].updated_at = new Date().toISOString();
      writeLifecycle(data);

      console.log(
        JSON.stringify({
          passed: false,
          forced: true,
          status: priorStatus,
          verdict: priorVerdict,
          reason: reason || null,
        }),
      );
      process.exit(0);
    }

    const blockingParts = [];
    if (!statusOk)
      blockingParts.push(
        `prior step '${priorStep}' is not done (status: ${priorStatus})`,
      );
    if (!verdictOk)
      blockingParts.push(
        `gate verdict is not PASS or CONDITIONAL (verdict: ${priorVerdict})`,
      );

    console.log(
      JSON.stringify({
        passed: false,
        forced: false,
        status: priorStatus,
        verdict: priorVerdict,
        blocking_reason: blockingParts.join("; "),
      }),
    );
    process.exit(1);
  });
}

function cmdGateVerdict(args) {
  const [featureName, step, verdict, artifactPath] = args;
  if (!featureName || !step || !verdict || !artifactPath) {
    console.error(
      "Usage: gate-verdict <feature> <step> <verdict> <artifact-path>",
    );
    process.exit(1);
  }
  if (!STEPS.includes(step)) {
    console.error(`Invalid step: ${step}. Valid: ${STEPS.join(", ")}`);
    process.exit(1);
  }

  const VALID_VERDICTS = ["PASS", "CONDITIONAL", "FAIL"];
  if (!VALID_VERDICTS.includes(verdict)) {
    console.error(
      `Invalid verdict: ${verdict}. Valid: ${VALID_VERDICTS.join(", ")}`,
    );
    process.exit(1);
  }

  withLifecycleLock(() => {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    if (!data.features[featureKey]) {
      console.error(`Feature not found: ${featureKey}`);
      process.exit(1);
    }

    const stepObj = data.features[featureKey].steps[step] || {
      status: "pending",
    };
    stepObj.gate_verdict = verdict;
    stepObj.gate_verdict_at = new Date().toISOString();
    stepObj.gate_artifact = artifactPath;

    if (verdict === "FAIL") {
      stepObj.status = "gated";
    } else {
      // PASS or CONDITIONAL — mark step done and record completion
      stepObj.status = "done";
      stepObj.completed_at = stepObj.completed_at || new Date().toISOString();
    }

    data.features[featureKey].steps[step] = stepObj;
    data.features[featureKey].updated_at = new Date().toISOString();
    writeLifecycle(data);

    console.log(
      JSON.stringify({
        recorded: true,
        step,
        verdict,
        status: stepObj.status,
      }),
    );
    process.exit(0);
  });
}

function cmdPlanMode(args) {
  const featureName = args[0];
  const mode = args[1];
  if (!featureName || !mode) {
    console.error("Usage: plan-mode <feature> on|off|check [--session <id>]");
    process.exit(1);
  }
  if (!["on", "off", "check"].includes(mode)) {
    console.error(`Invalid mode: ${mode}. Valid: on, off, check`);
    process.exit(1);
  }

  const sessionId = parseFlag(args, "--session") || resolveSessionId();
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  const markerPath = path.join(cwd, ".planning-session");

  if (mode === "check") {
    const data = readLifecycle();
    const featureKey = resolveFeatureKey(data.features, featureName);
    const markerExists = fs.existsSync(markerPath);
    const planModeAt = data.features[featureKey]
      ? data.features[featureKey].plan_mode_at || null
      : null;
    // Read marker content for session ownership info
    let ownerSession = null;
    if (markerExists) {
      try {
        const content = fs.readFileSync(markerPath, "utf-8").trim();
        if (content) ownerSession = content;
      } catch {
        /* ignore */
      }
    }
    console.log(
      JSON.stringify({
        plan_mode: markerExists ? "on" : "off",
        marker_exists: markerExists,
        plan_mode_at: planModeAt,
        owner_session: ownerSession,
        current_session: sessionId,
        owned_by_current: ownerSession === sessionId,
      }),
    );
    process.exit(0);
  }

  if (mode === "on") {
    withLifecycleLock(() => {
      const data = readLifecycle();
      const featureKey = resolveFeatureKey(data.features, featureName);
      if (!data.features[featureKey]) {
        console.error(`Feature not found: ${featureKey}`);
        process.exit(1);
      }
      // Write session ID to marker for ownership attribution
      fs.writeFileSync(markerPath, sessionId + "\n", "utf-8");
      data.features[featureKey].plan_mode_at = new Date().toISOString();
      data.features[featureKey].updated_at = new Date().toISOString();
      writeLifecycle(data);

      // Update session file
      const claudeDir = path.join(cwd, ".claude");
      const existing = readSessionFile(sessionId, claudeDir) || {};
      writeSessionFile(
        sessionId,
        {
          ...existing,
          plan_mode: true,
        },
        claudeDir,
      );

      console.log(
        JSON.stringify({
          plan_mode: "on",
          marker: markerPath,
          session: sessionId,
        }),
      );
      process.exit(0);
    });
  }

  if (mode === "off") {
    withLifecycleLock(() => {
      const data = readLifecycle();
      const featureKey = resolveFeatureKey(data.features, featureName);
      if (!data.features[featureKey]) {
        console.error(`Feature not found: ${featureKey}`);
        process.exit(1);
      }

      // Check ownership before deletion
      let ownerSession = null;
      if (fs.existsSync(markerPath)) {
        try {
          const content = fs.readFileSync(markerPath, "utf-8").trim();
          if (content) ownerSession = content;
        } catch {
          /* ignore */
        }
      }

      if (ownerSession && ownerSession !== sessionId) {
        console.error(
          `Plan mode owned by session ${ownerSession}, not ${sessionId}. Use --force to override.`,
        );
        if (!args.includes("--force")) {
          process.exit(1);
        }
      }

      try {
        fs.unlinkSync(markerPath);
      } catch {
        /* ignore — marker may not exist */
      }
      data.features[featureKey].plan_mode_at = null;
      data.features[featureKey].updated_at = new Date().toISOString();
      writeLifecycle(data);

      // Update session file
      const claudeDir = path.join(cwd, ".claude");
      const existing = readSessionFile(sessionId, claudeDir) || {};
      writeSessionFile(
        sessionId,
        {
          ...existing,
          plan_mode: false,
        },
        claudeDir,
      );

      console.log(JSON.stringify({ plan_mode: "off" }));
      process.exit(0);
    });
  }
}

function cmdArtifactStatus(args) {
  const filePath = args[0];
  const newStatus = args[1];
  if (!filePath || !newStatus) {
    console.error(
      "Usage: artifact-status <file> <status> [--superseded-by <path>]",
    );
    process.exit(1);
  }

  if (!ARTIFACT_STATUSES.includes(newStatus)) {
    console.error(
      `Invalid artifact status: ${newStatus}. Valid: ${ARTIFACT_STATUSES.join(", ")}`,
    );
    process.exit(1);
  }

  const supersededBy = parseFlag(args.slice(2), "--superseded-by");

  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  const resolved = path.resolve(cwd, filePath);

  // Guard against path traversal (#616)
  if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
    console.error(
      `Path traversal rejected: ${filePath} resolves outside project root`,
    );
    process.exit(1);
  }

  if (!fs.existsSync(resolved)) {
    console.error(`File not found: ${resolved}`);
    process.exit(1);
  }

  const content = fs.readFileSync(resolved, "utf-8");
  const fm = parseFrontmatter(content);
  if (!fm) {
    console.error(`No frontmatter found in: ${resolved}`);
    process.exit(1);
  }

  const currentStatus = getStatusFromFrontmatter(fm.raw) || "draft";

  // No-op check
  if (currentStatus === newStatus) {
    console.log(
      JSON.stringify({
        updated: false,
        reason: "already at target status",
        file: filePath,
        status: currentStatus,
      }),
    );
    process.exit(0);
  }

  // Validate transition
  const allowed = VALID_ARTIFACT_TRANSITIONS[currentStatus];
  if (!allowed || !allowed.includes(newStatus)) {
    console.error(
      `Invalid transition: ${currentStatus} → ${newStatus}. Allowed from ${currentStatus}: ${(allowed || []).join(", ")}`,
    );
    process.exit(1);
  }

  const updated = setStatusInFrontmatter(content, fm, newStatus, supersededBy);

  // Atomic write
  const tmpPath = resolved + ".tmp";
  fs.writeFileSync(tmpPath, updated, "utf-8");
  fs.renameSync(tmpPath, resolved);

  console.log(
    JSON.stringify({
      updated: true,
      file: filePath,
      old_status: currentStatus,
      new_status: newStatus,
    }),
  );
  process.exit(0);
}

// ─── Session File CRUD ──────────────────────────────────────────────────────

/**
 * Write a per-session JSON file to .claude/sessions/{id}.json.
 * Atomic write via .tmp + renameSync.
 */
function writeSessionFile(id, data, claudeDir) {
  if (!id) throw new Error("session id required");
  const dir = claudeDir
    ? path.join(claudeDir, "sessions")
    : resolveSessionsDir();
  fs.mkdirSync(dir, { recursive: true });
  const filePath = path.join(dir, `${id}.json`);
  const tmpPath = filePath + ".tmp";
  const payload = {
    schema_version: 1,
    session_id: id,
    active_feature: data.active_feature || null,
    current_step: data.current_step || null,
    plan_mode: data.plan_mode || false,
    stage_description: data.stage_description || null,
    git_branch: data.git_branch || null,
    last_heartbeat: new Date().toISOString(),
  };
  fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
  fs.renameSync(tmpPath, filePath);
  return payload;
}

/**
 * Read a session file by ID.
 * Returns null if not found or unreadable.
 */
function readSessionFile(id, claudeDir) {
  if (!id) return null;
  const dir = claudeDir
    ? path.join(claudeDir, "sessions")
    : resolveSessionsDir();
  const filePath = path.join(dir, `${id}.json`);
  try {
    if (!fs.existsSync(filePath)) return null;
    return JSON.parse(fs.readFileSync(filePath, "utf-8"));
  } catch {
    return null;
  }
}

/**
 * List all active (non-stale) sessions.
 * Returns array of session data objects.
 */
function listActiveSessions(claudeDir, ttlHours) {
  if (ttlHours === undefined) ttlHours = 24;
  const dir = claudeDir
    ? path.join(claudeDir, "sessions")
    : resolveSessionsDir();
  if (!fs.existsSync(dir)) return [];
  const cutoff = Date.now() - ttlHours * 60 * 60 * 1000;
  const sessions = [];
  try {
    for (const entry of fs.readdirSync(dir)) {
      if (!entry.endsWith(".json")) continue;
      try {
        const filePath = path.join(dir, entry);
        const stat = fs.statSync(filePath);
        if (stat.mtimeMs < cutoff) continue;
        const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
        sessions.push(data);
      } catch {
        /* ignore unreadable files */
      }
    }
  } catch {
    /* ignore */
  }
  return sessions;
}

/**
 * Prune session files older than ttlHours.
 * Returns count of pruned files.
 */
function pruneDeadSessions(claudeDir, ttlHours) {
  if (ttlHours === undefined) ttlHours = 24;
  const dir = claudeDir
    ? path.join(claudeDir, "sessions")
    : resolveSessionsDir();
  if (!fs.existsSync(dir)) return 0;
  const cutoff = Date.now() - ttlHours * 60 * 60 * 1000;
  let pruned = 0;
  try {
    for (const entry of fs.readdirSync(dir)) {
      if (!entry.endsWith(".json")) continue;
      try {
        const filePath = path.join(dir, entry);
        const stat = fs.statSync(filePath);
        if (stat.mtimeMs < cutoff) {
          fs.unlinkSync(filePath);
          pruned++;
        }
      } catch {
        /* ignore */
      }
    }
  } catch {
    /* ignore */
  }
  return pruned;
}

/**
 * Count non-stale sessions active on a given feature slug.
 */
function countActiveSessions(claudeDir, featureSlug, staleMinutes) {
  if (!featureSlug) return 0;
  if (staleMinutes === undefined) staleMinutes = 5;
  const dir = claudeDir
    ? path.join(claudeDir, "sessions")
    : resolveSessionsDir();
  if (!fs.existsSync(dir)) return 0;
  const cutoff = Date.now() - staleMinutes * 60 * 1000;
  let count = 0;
  try {
    for (const entry of fs.readdirSync(dir)) {
      if (!entry.endsWith(".json")) continue;
      try {
        const filePath = path.join(dir, entry);
        const stat = fs.statSync(filePath);
        if (stat.mtimeMs < cutoff) continue;
        const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
        if (data.active_feature === featureSlug) count++;
      } catch {
        /* ignore */
      }
    }
  } catch {
    /* ignore */
  }
  return count;
}

// ─── Session Subcommands ────────────────────────────────────────────────────

function cmdSessionWrite(args) {
  const sessionId = args[0] || resolveSessionId();
  const activeFeature = parseFlag(args, "--feature") || null;
  const currentStep = parseFlag(args, "--step") || null;
  const planMode = args.includes("--plan-mode");
  const stageDesc = parseFlag(args, "--stage") || null;
  const gitBranch = parseFlag(args, "--branch") || null;

  const claudeDir = path.join(
    process.env.CLAUDE_PROJECT_DIR || process.cwd(),
    ".claude",
  );
  const payload = writeSessionFile(
    sessionId,
    {
      active_feature: activeFeature,
      current_step: currentStep,
      plan_mode: planMode,
      stage_description: stageDesc,
      git_branch: gitBranch,
    },
    claudeDir,
  );

  console.log(JSON.stringify(payload));
}

function cmdSessionRead(args) {
  const sessionId = args[0] || resolveSessionId();
  const claudeDir = path.join(
    process.env.CLAUDE_PROJECT_DIR || process.cwd(),
    ".claude",
  );
  const data = readSessionFile(sessionId, claudeDir);
  if (!data) {
    console.error(`Session not found: ${sessionId}`);
    process.exit(1);
  }
  console.log(JSON.stringify(data, null, 2));
}

function cmdSessionPrune(args) {
  const ttlRaw = parseFlag(args, "--ttl");
  const ttlHours = ttlRaw ? parseInt(ttlRaw, 10) : 24;
  const claudeDir = path.join(
    process.env.CLAUDE_PROJECT_DIR || process.cwd(),
    ".claude",
  );
  const pruned = pruneDeadSessions(claudeDir, ttlHours);
  console.log(JSON.stringify({ pruned }));
}

function cmdSessionCount(args) {
  const feature = args[0];
  if (!feature) {
    console.error("Usage: session-count <feature>");
    process.exit(1);
  }
  const staleRaw = parseFlag(args, "--stale-minutes");
  const staleMinutes = staleRaw ? parseInt(staleRaw, 10) : 5;
  const claudeDir = path.join(
    process.env.CLAUDE_PROJECT_DIR || process.cwd(),
    ".claude",
  );
  const count = countActiveSessions(claudeDir, feature, staleMinutes);
  console.log(JSON.stringify({ feature, count }));
}

// ─── Main ───────────────────────────────────────────────────────────────────

function main() {
  const args = process.argv.slice(2);
  const command = args[0];
  const rest = args.slice(1);

  switch (command) {
    case "register":
      cmdRegister(rest);
      break;
    case "start":
      cmdStart(rest);
      break;
    case "done":
      cmdDone(rest);
      break;
    case "fail":
      cmdFail(rest);
      break;
    case "activate":
      cmdActivate(rest);
      break;
    case "deactivate":
      cmdDeactivate(rest);
      break;
    case "next":
      cmdNext(rest);
      break;
    case "complete":
      cmdComplete(rest);
      break;
    case "archive":
      cmdArchive(rest);
      break;
    case "restore":
      cmdRestore(rest);
      break;
    case "backlog":
      cmdBacklog();
      break;
    case "queue":
      cmdQueue();
      break;
    case "list":
      cmdList();
      break;
    case "read":
      cmdRead();
      break;
    case "require-prior":
      cmdRequirePrior(rest);
      break;
    case "gate-verdict":
      cmdGateVerdict(rest);
      break;
    case "plan-mode":
      cmdPlanMode(rest);
      break;
    case "artifact-status":
      cmdArtifactStatus(rest);
      break;
    case "session-write":
      cmdSessionWrite(rest);
      break;
    case "session-read":
      cmdSessionRead(rest);
      break;
    case "session-prune":
      cmdSessionPrune(rest);
      break;
    case "session-count":
      cmdSessionCount(rest);
      break;
    default:
      console.error(
        "Usage: update-lifecycle.js <register|start|done|fail|activate|deactivate|next|complete|archive|restore|backlog|queue|list|read|require-prior|gate-verdict|plan-mode|artifact-status|session-write|session-read|session-prune|session-count> [args]",
      );
      process.exit(1);
  }
}

module.exports = {
  readLifecycle,
  writeLifecycle,
  resolveLifecyclePath,
  resolveRegistryPath,
  resolveStatePath,
  resolveLockPath,
  acquireLock,
  withLifecycleLock,
  resolveSessionId,
  resolveSessionsDir,
  writeSessionFile,
  readSessionFile,
  listActiveSessions,
  pruneDeadSessions,
  countActiveSessions,
  readRegistry,
  writeRegistry,
  readState,
  writeState,
  readMerged,
  migrateToV2,
  migrateToV3,
  migrateToV4,
  migrateToV5,
  migrateToV6,
  migrateToV7,
  makeDefaultStep,
  normalizeStep,
  toSlug,
  isFeatureId,
  resolveFeatureKey,
  STEPS,
  VALID_STATUSES,
  VALID_STEP_TRANSITIONS,
  assertTransition,
  ARTIFACT_STATUSES,
  VALID_ARTIFACT_TRANSITIONS,
  LOCK_RETRY_MS,
  LOCK_DEADLINE_MS,
  LOCK_STALE_MS,
};

if (require.main === module) {
  main();
}
