/**
 * commands/roadmap.mjs — per-scope roadmap CRUD (C29).
 *
 * roadmap subcommand: init | render | add-milestone | add-phase |
 * set-phase-status | rollup | delete-phase | delete-milestone |
 * validate-refs | sync
 */
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { selfStageCommittableWrites } from '../artifacts/self-stage.mjs';
import { getDefaultOwner, getGitIdentity } from '../git/identity.mjs';
import { runConvexMirror } from '../roadmap/convex-mirror.mjs';
import { loadRoadmapForScope as ioLoadRoadmapForScope, saveRoadmapForScope as ioSaveRoadmapForScope, } from '../roadmap/io.mjs';
import { deletePhaseFile } from '../roadmap/phases-io.mjs';
import { renderRoadmapMd } from '../roadmap/render.mjs';
import { applyRollup, writeRollup } from '../roadmap/rollup.mjs';
import { findMilestoneScopes } from '../roadmap/scope-lookup.mjs';
import { syncRoadmapInProcess } from '../roadmap/sync.mjs';
import { loadSeeds, seedsJsonPath } from '../seeds/io.mjs';
import { renderSeedsMd } from '../seeds/render.mjs';
import { regenerateTaskIndex } from '../task/index-md.mjs';
import { updateTeamMember } from '../team/team.mjs';
import { parseFlags } from '../util/args.mjs';
import { withFileLock } from '../util/fs-lock.mjs';
import { atomicWriteJson } from '../util/io.mjs';
import { err, exitWith, ok, UsageError } from '../util/result.mjs';
/**
 * Sentinel thrown by inner helpers (`loadRoadmap`, `resolveScope`, etc.) so
 * the top-level dispatcher can catch and convert to a structured `err()`
 * return without each helper threading errors back through return values.
 */
class RoadmapUsageError extends UsageError {
    constructor(msg) {
        super(msg);
        this.name = 'RoadmapUsageError';
    }
}
export async function cmdRoadmap(subcommand, projectDir, extraArgs) {
    if (!subcommand)
        return err('Usage: apt-tools roadmap <init|render|add-milestone|add-phase|set-phase-status|rollup|delete-phase|delete-milestone|validate-refs|sync> <project-dir>');
    if (!projectDir)
        return err('Usage: apt-tools roadmap ' + subcommand + ' <project-dir>');
    try {
        return await cmdRoadmapImpl(subcommand, projectDir, extraArgs);
    }
    catch (e) {
        if (e instanceof RoadmapUsageError)
            return err(e.message);
        throw e;
    }
}
async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
    const targetDir = resolve(projectDir);
    updateTeamMember(targetDir);
    const roadmapRoot = join(targetDir, '.aperant', 'roadmap');
    const flags = parseFlags(extraArgs);
    // Per-scope roadmap layout:
    //   .aperant/roadmap/{scope}/milestones.json   ← structured
    //   .aperant/roadmap/{scope}/ROADMAP.md        ← rendered
    // Each scope is fully independent — its own milestone/phase namespace,
    // its own lock, its own rendered markdown. There is no top-level
    // milestones.json or ROADMAP.md.
    function scopePaths(scope) {
        const scopeDir = join(roadmapRoot, scope);
        return {
            scopeDir,
            jsonPath: join(scopeDir, 'milestones.json'),
            mdPath: join(scopeDir, 'ROADMAP.md'),
        };
    }
    function listScopes() {
        if (!existsSync(roadmapRoot))
            return [];
        const out = [];
        for (const name of readdirSync(roadmapRoot)) {
            const sub = join(roadmapRoot, name);
            try {
                if (!statSync(sub).isDirectory())
                    continue;
            }
            catch {
                continue;
            }
            if (existsSync(join(sub, 'milestones.json')))
                out.push(name);
        }
        // Stable ordering: project first, others alphabetical.
        out.sort((a, b) => {
            if (a === b)
                return 0;
            if (a === 'project')
                return -1;
            if (b === 'project')
                return 1;
            return a.localeCompare(b);
        });
        return out;
    }
    // R5 (subtask 10): delegate load/save to roadmap/io.mjs so the new per-phase
    // dual-write + phases/-preferred-read semantics apply uniformly to every
    // CRUD + lifecycle code path. Local helpers remain so the CLI still emits
    // the same "no roadmap for scope X" failure message when the scope is
    // genuinely uninitialized.
    function loadRoadmap(scope) {
        const rm = ioLoadRoadmapForScope(targetDir, scope);
        if (!rm)
            throw new RoadmapUsageError(`No roadmap for scope "${scope}". Run: apt-tools roadmap init --scope ${scope}`);
        return rm;
    }
    function saveRoadmap(scope, roadmap) {
        ioSaveRoadmapForScope(targetDir, scope, roadmap);
    }
    // C56 B4 — capture the repo-relative paths a roadmap save just wrote so
    // the caller can self-stage them via selfStageCommittableWrites. Mirrors
    // the set of sinks inside saveRoadmapForScope (milestones.json + ROADMAP.md
    // + phases/<id>.json). Paths are .aperant/-relative so the classifier can
    // bucket them cleanly.
    function writtenPathsForScope(scope, roadmap) {
        const paths = [
            `.aperant/roadmap/${scope}/milestones.json`,
            `.aperant/roadmap/${scope}/ROADMAP.md`,
        ];
        for (const milestone of roadmap?.milestones || []) {
            for (const phase of milestone?.phases || []) {
                if (phase?.id)
                    paths.push(`.aperant/roadmap/${scope}/phases/${phase.id}.json`);
            }
        }
        return paths;
    }
    // C56 B4 — best-effort self-stage helper. Intersects `paths` with the
    // committable classification and `git add -f`s them so the caller's next
    // commit picks them up. Returns the result envelope fragment the callers
    // surface as `self_staged`.
    function selfStageAfterWrite(paths) {
        const res = selfStageCommittableWrites(targetDir, paths, { outputs: null });
        return {
            staged: res.staged,
            skipped: res.skipped,
            warnings: res.warnings,
        };
    }
    // Find which scope owns a given milestone id. Returns null if not found,
    // the scope name if unique, or throws on ambiguity (same id in >1 scope).
    //
    // Delegates to roadmap/scope-lookup.mjs so seeds CLI shares the same
    // lookup. Preserves the original error-on-ambiguity behavior by calling
    // fail() here — seeds CLI uses the lower-level findMilestoneScopes() and
    // crafts its own error envelope.
    function findMilestoneScope(milestoneId) {
        const hits = findMilestoneScopes(targetDir, milestoneId);
        if (hits.length === 0)
            return null;
        if (hits.length > 1)
            throw new RoadmapUsageError(`Milestone "${milestoneId}" exists in multiple scopes (${hits.join(', ')}); pass --scope`);
        return hits[0];
    }
    // Resolve the scope for a command that may or may not have --scope explicitly.
    // - If --scope passed: use it (validating the roadmap file if required).
    // - Otherwise: look up via --milestone <id> across all scopes.
    // - Fallback: 'project' (for add-milestone etc. where scope is create-time).
    function resolveScope({ requireRoadmap = true, milestoneId = null, defaultScope = 'project', } = {}) {
        const explicit = flags.get('scope');
        if (explicit) {
            const s = explicit.trim() || defaultScope;
            if (requireRoadmap) {
                const { jsonPath } = scopePaths(s);
                if (!existsSync(jsonPath))
                    throw new RoadmapUsageError(`No roadmap for scope "${s}". Run: apt-tools roadmap init --scope ${s}`);
            }
            return s;
        }
        if (milestoneId) {
            const found = findMilestoneScope(milestoneId);
            if (found)
                return found;
            throw new RoadmapUsageError(`Milestone "${milestoneId}" not found in any scope (looked in: ${listScopes().join(', ') || '∅'})`);
        }
        return defaultScope;
    }
    // C28 D-07 — cascade-null helper. The scanning of affected tasks is
    // inlined at each destructive roadmap path so the scan + mutate can run
    // under a single `withFileLock(state.json)` and close the TOCTOU window.
    // Apply cascade-null to every task in `affected`. Appends a history entry
    // with cascade_nulled:true. Callers must hold a lock on state.json.
    function cascadeNullLinkage(state, affected, reason, at, actor) {
        for (const entry of affected) {
            const t = entry.task;
            if (!Array.isArray(t.history))
                t.history = [];
            const from = {
                scope: t.scope || 'project',
                milestone_id: t.milestone_id ?? null,
                phase_id: t.phase_id ?? null,
            };
            t.history.push({
                cascade_nulled: true,
                from,
                to: { scope: t.scope || 'project', milestone_id: null, phase_id: null },
                at,
                actor,
                reason,
            });
            t.milestone_id = null;
            t.phase_id = null;
        }
        // state is already the in-memory object that aliases active/completed tasks.
    }
    switch (subcommand) {
        case 'init': {
            // Initialize a per-scope roadmap directory. Defaults to scope=project.
            // Multiple scopes can coexist — call init once per scope.
            const scope = (flags.get('scope') || 'project').trim() || 'project';
            const { scopeDir, jsonPath } = scopePaths(scope);
            mkdirSync(scopeDir, { recursive: true });
            if (existsSync(jsonPath) && !flags.has('force')) {
                return ok({
                    status: 'ok',
                    command: 'roadmap-init',
                    scope,
                    path: jsonPath,
                    note: 'already exists (use --force to reset)',
                });
            }
            const project = flags.get('project') || basename(targetDir);
            const roadmap = {
                version: 1,
                project,
                scope,
                updated_at: new Date().toISOString(),
                milestones: [],
            };
            saveRoadmap(scope, roadmap);
            return ok({ status: 'ok', command: 'roadmap-init', project, scope, path: jsonPath });
        }
        case 'render': {
            // Render one scope (when --scope passed) or every scope that exists.
            // When --seeds is passed (or when no --scope filter is set and
            // seeds.json has at least one seed), also render SEEDS.md at
            // .aperant/roadmap/SEEDS.md — the cross-scope companion to each
            // scope's ROADMAP.md.
            const explicit = flags.get('scope');
            const seedsOnly = flags.has('seeds-only');
            const seedsRequested = flags.has('seeds') || seedsOnly;
            const scopes = seedsOnly ? [] : explicit ? [explicit] : listScopes();
            if (!seedsRequested && scopes.length === 0) {
                throw new RoadmapUsageError('No roadmaps found. Run: apt-tools roadmap init');
            }
            const rendered = [];
            for (const scope of scopes) {
                const roadmap = loadRoadmap(scope);
                const { jsonPath, mdPath } = scopePaths(scope);
                withFileLock(jsonPath, () => {
                    writeFileSync(mdPath, renderRoadmapMd(roadmap), 'utf-8');
                }, { projectRoot: targetDir });
                rendered.push({ scope, path: mdPath });
            }
            // Seeds rendering — opt-in via --seeds, or auto when no --scope
            // filter AND seeds.json has entries. Missing seeds.json is never
            // fatal: emit a structured reply so the caller knows we skipped.
            let seedsRendered = null;
            const seedsPath = seedsJsonPath(targetDir);
            const seedsFile = loadSeeds(targetDir);
            const seedsHasEntries = !!(seedsFile &&
                Array.isArray(seedsFile.seeds) &&
                seedsFile.seeds.length > 0);
            const shouldRenderSeeds = seedsRequested || (!explicit && !seedsOnly && seedsHasEntries && seedsFile);
            if (shouldRenderSeeds && seedsFile) {
                // Preload every scope's roadmap so cross-scope seed rows resolve.
                const roadmapsByScope = new Map();
                for (const scope of listScopes()) {
                    const rm = ioLoadRoadmapForScope(targetDir, scope);
                    if (rm)
                        roadmapsByScope.set(scope, rm);
                }
                let state = null;
                try {
                    const statePath = join(targetDir, '.aperant', 'state.json');
                    state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf-8')) : null;
                }
                catch {
                    /* state.json malformed — render with zero task counts */
                }
                const md = renderSeedsMd(seedsFile, roadmapsByScope, state);
                const seedsMdPath = join(roadmapRoot, 'SEEDS.md');
                mkdirSync(roadmapRoot, { recursive: true });
                withFileLock(seedsPath, () => {
                    writeFileSync(seedsMdPath, md, 'utf-8');
                }, { projectRoot: targetDir });
                seedsRendered = { path: seedsMdPath, seed_count: seedsFile.seeds.length };
            }
            else if (seedsRequested) {
                // Operator asked for seeds but seeds.json is missing. Report it
                // explicitly so they know to run `seed init`.
                seedsRendered = { path: null, note: 'no seeds.json — run `apt-tools seed init`' };
            }
            // C56 B4 — self-stage the rendered markdown (and seeds summary when
            // present). ROADMAP.md + SEEDS.md are committable; staging here
            // ensures the caller's next commit captures the render.
            const renderPaths = [];
            for (const r of rendered) {
                // Build .aperant/-relative path from the absolute mdPath.
                const rel = r.path.startsWith(targetDir) ? r.path.slice(targetDir.length + 1) : r.path;
                renderPaths.push(rel);
            }
            if (seedsRendered?.path) {
                const rel = seedsRendered.path.startsWith(targetDir)
                    ? seedsRendered.path.slice(targetDir.length + 1)
                    : seedsRendered.path;
                renderPaths.push(rel);
            }
            const selfStagedRender = selfStageAfterWrite(renderPaths);
            return ok({
                status: 'ok',
                command: 'roadmap-render',
                rendered,
                seeds: seedsRendered,
                self_staged: selfStagedRender,
            });
        }
        case 'add-milestone': {
            const id = flags.get('id');
            const title = flags.get('title');
            const goal = flags.get('goal') || '';
            const scope = (flags.get('scope') || 'project').trim() || 'project';
            if (!id || !title)
                throw new RoadmapUsageError('Required: --id <id> --title <title>');
            // Auto-init the scope if its roadmap file doesn't exist yet.
            const { jsonPath } = scopePaths(scope);
            if (!existsSync(jsonPath)) {
                const project = flags.get('project') || basename(targetDir);
                saveRoadmap(scope, {
                    version: 1,
                    project,
                    scope,
                    updated_at: new Date().toISOString(),
                    milestones: [],
                });
            }
            const roadmap = loadRoadmap(scope);
            if (roadmap.milestones.some((m) => m.id === id))
                throw new RoadmapUsageError(`Milestone "${id}" already exists in scope "${scope}"`);
            // Milestone id must be unique across scopes (task linkage uses id alone
            // by default; ambiguity would force --scope on every task create).
            for (const otherScope of listScopes()) {
                if (otherScope === scope)
                    continue;
                const other = loadRoadmap(otherScope);
                if (other.milestones.some((m) => m.id === id))
                    throw new RoadmapUsageError(`Milestone "${id}" already exists in scope "${otherScope}"; pick a unique id`);
            }
            roadmap.milestones.push({ id, title, goal, scope, phases: [] });
            saveRoadmap(scope, roadmap);
            const selfStagedAdd = selfStageAfterWrite(writtenPathsForScope(scope, roadmap));
            return ok({
                status: 'ok',
                command: 'roadmap-add-milestone',
                milestone_id: id,
                scope,
                self_staged: selfStagedAdd,
            });
        }
        case 'add-phase': {
            const milestoneId = flags.get('milestone');
            const id = flags.get('id');
            const title = flags.get('title');
            if (!milestoneId || !id || !title)
                throw new RoadmapUsageError('Required: --milestone <id> --id <id> --title <title>');
            const scope = resolveScope({ milestoneId });
            const roadmap = loadRoadmap(scope);
            const milestone = roadmap.milestones.find((m) => m.id === milestoneId);
            if (!milestone)
                throw new RoadmapUsageError(`Milestone "${milestoneId}" not found in scope "${scope}"`);
            if (milestone.phases.some((p) => p.id === id))
                throw new RoadmapUsageError(`Phase "${id}" already exists in ${milestoneId}`);
            const blockedBy = flags.get('blocked-by') ? flags.get('blocked-by').split(',') : [];
            const deliverables = flags.get('deliverables') ? flags.get('deliverables').split('|') : [];
            // Phase inherits scope from its owning milestone (scope is per-file now).
            const phaseScope = milestone.scope || scope;
            milestone.phases.push({
                id,
                title,
                description: flags.get('description') || '',
                status: flags.get('status') || 'planned',
                owner: flags.get('owner') || getDefaultOwner(targetDir),
                scope: phaseScope,
                blocked_by: blockedBy,
                deliverables,
                completed_at: null,
            });
            saveRoadmap(scope, roadmap);
            const selfStagedAddPhase = selfStageAfterWrite(writtenPathsForScope(scope, roadmap));
            return ok({
                status: 'ok',
                command: 'roadmap-add-phase',
                milestone_id: milestoneId,
                phase_id: id,
                scope: phaseScope,
                self_staged: selfStagedAddPhase,
            });
        }
        case 'set-phase-status': {
            const milestoneId = flags.get('milestone');
            const phaseId = flags.get('phase');
            const status = flags.get('status');
            if (!milestoneId || !phaseId || !status)
                throw new RoadmapUsageError('Required: --milestone <id> --phase <id> --status <status>');
            // C28 D-07 — `deleted` is the destructive path that triggers orphan
            // block + cascade-null. All other statuses are non-destructive.
            const validStatuses = ['planned', 'in-progress', 'completed', 'blocked', 'skipped', 'deleted'];
            if (!validStatuses.includes(status))
                throw new RoadmapUsageError(`Invalid status. Expected one of: ${validStatuses.join(', ')}`);
            const force = extraArgs.includes('--force');
            const reason = flags.get('reason') || null;
            if (force && !reason) {
                return exitWith({
                    status: 'error',
                    command: 'roadmap-set-phase-status',
                    reason: '--reason required when --force',
                }, 1);
            }
            const scope = resolveScope({ milestoneId });
            const roadmap = loadRoadmap(scope);
            const milestone = roadmap.milestones.find((m) => m.id === milestoneId);
            if (!milestone)
                throw new RoadmapUsageError(`Milestone "${milestoneId}" not found in scope "${scope}"`);
            const phase = milestone.phases.find((p) => p.id === phaseId);
            if (!phase)
                throw new RoadmapUsageError(`Phase "${phaseId}" not found in ${milestoneId}`);
            // Destructive? Check for orphaned tasks before touching roadmap.
            // The orphan-block check AND the cascade-null mutation run under the
            // same state.json lock so a concurrent task create can't slip a new
            // reference in between the check and the roadmap mutation.
            if (status === 'deleted') {
                const statePathLocal = join(targetDir, '.aperant', 'state.json');
                let cascadedCount = 0;
                let blockedAffected = null;
                if (existsSync(statePathLocal)) {
                    withFileLock(statePathLocal, () => {
                        const freshState = JSON.parse(readFileSync(statePathLocal, 'utf-8'));
                        const freshAffected = [];
                        for (const [id, t] of Object.entries(freshState.active_tasks || {})) {
                            if (t?.milestone_id === milestoneId && t?.phase_id === phaseId) {
                                freshAffected.push({ source: 'active', id, task: t });
                            }
                        }
                        for (const t of freshState.completed_tasks || []) {
                            if (t?.id && t?.milestone_id === milestoneId && t?.phase_id === phaseId) {
                                freshAffected.push({ source: 'completed', id: t.id, task: t });
                            }
                        }
                        if (freshAffected.length > 0 && !force) {
                            blockedAffected = freshAffected.map((a) => ({ source: a.source, id: a.id }));
                            return;
                        }
                        if (freshAffected.length > 0 && force) {
                            const identity = getGitIdentity(targetDir);
                            cascadeNullLinkage(freshState, freshAffected, reason, new Date().toISOString(), identity?.name || 'unknown');
                            freshState.last_activity = new Date().toISOString();
                            atomicWriteJson(statePathLocal, freshState);
                            cascadedCount = freshAffected.length;
                        }
                    }, { projectRoot: targetDir });
                }
                if (blockedAffected) {
                    return exitWith({
                        status: 'error',
                        command: 'roadmap-set-phase-status',
                        reason: 'orphan-block',
                        affected: blockedAffected,
                    }, 1);
                }
                if (cascadedCount > 0)
                    regenerateTaskIndex(targetDir);
                // Remove the phase entirely when status=deleted.
                milestone.phases = milestone.phases.filter((p) => p.id !== phaseId);
                saveRoadmap(scope, roadmap);
                // C34c — unlink the per-phase JSON file so the phases/-preferred
                // reader cannot resurrect the just-deleted phase on the next load.
                deletePhaseFile(targetDir, scope, phaseId);
                const selfStagedDel = selfStageAfterWrite(writtenPathsForScope(scope, roadmap));
                return ok({
                    status: 'ok',
                    command: 'roadmap-set-phase-status',
                    milestone_id: milestoneId,
                    phase_id: phaseId,
                    phase_status: status,
                    cascade_nulled: cascadedCount,
                    reason,
                    self_staged: selfStagedDel,
                });
            }
            phase.status = status;
            if (status === 'completed' && !phase.completed_at) {
                phase.completed_at = new Date().toISOString();
            }
            else if (status !== 'completed') {
                phase.completed_at = null;
            }
            if (flags.has('owner'))
                phase.owner = flags.get('owner');
            // C33 — recompute milestone rollup in the same write so the
            // milestone's `status` field stays in sync with its phases.
            const rollup = applyRollup(roadmap, milestoneId);
            saveRoadmap(scope, roadmap);
            const selfStagedPhase = selfStageAfterWrite(writtenPathsForScope(scope, roadmap));
            return ok({
                status: 'ok',
                command: 'roadmap-set-phase-status',
                milestone_id: milestoneId,
                phase_id: phaseId,
                phase_status: status,
                rollup: {
                    before: rollup.before,
                    after: rollup.after,
                    changed: rollup.changed,
                },
                self_staged: selfStagedPhase,
            });
        }
        case 'rollup': {
            // C33 — manual milestone-status rollup. Useful for repair or for
            // verifying lifecycle-driven rollups land the same shape.
            const milestoneId = flags.get('milestone');
            if (!milestoneId)
                throw new RoadmapUsageError('Required: --milestone <id>');
            const scope = resolveScope({ milestoneId });
            const result = writeRollup(targetDir, scope, milestoneId);
            return ok({
                status: 'ok',
                command: 'roadmap-rollup',
                milestone_id: milestoneId,
                scope,
                before: result.before,
                after: result.after,
                changed: result.changed,
                committed: result.committed,
            });
        }
        case 'delete-phase': {
            const milestoneId = flags.get('milestone');
            const phaseId = flags.get('phase');
            if (!milestoneId || !phaseId)
                throw new RoadmapUsageError('Required: --milestone <id> --phase <id>');
            const force = extraArgs.includes('--force');
            const reason = flags.get('reason') || null;
            if (force && !reason) {
                return exitWith({
                    status: 'error',
                    command: 'roadmap-delete-phase',
                    reason: '--reason required when --force',
                }, 1);
            }
            const scope = resolveScope({ milestoneId });
            const roadmap = loadRoadmap(scope);
            const milestone = roadmap.milestones.find((m) => m.id === milestoneId);
            if (!milestone)
                throw new RoadmapUsageError(`Milestone "${milestoneId}" not found in scope "${scope}"`);
            const phase = milestone.phases.find((p) => p.id === phaseId);
            if (!phase)
                throw new RoadmapUsageError(`Phase "${phaseId}" not found in ${milestoneId}`);
            // Atomic orphan-block check + cascade under the state.json lock to
            // close the TOCTOU window between the check and the roadmap write.
            const stPath = join(targetDir, '.aperant', 'state.json');
            let cascadedCount = 0;
            let blockedAffected = null;
            if (existsSync(stPath)) {
                withFileLock(stPath, () => {
                    const freshState = JSON.parse(readFileSync(stPath, 'utf-8'));
                    const freshAffected = [];
                    for (const [id, t] of Object.entries(freshState.active_tasks || {})) {
                        if (t?.milestone_id === milestoneId && t?.phase_id === phaseId) {
                            freshAffected.push({ source: 'active', id, task: t });
                        }
                    }
                    for (const t of freshState.completed_tasks || []) {
                        if (t?.id && t?.milestone_id === milestoneId && t?.phase_id === phaseId) {
                            freshAffected.push({ source: 'completed', id: t.id, task: t });
                        }
                    }
                    if (freshAffected.length > 0 && !force) {
                        blockedAffected = freshAffected.map((a) => ({ source: a.source, id: a.id }));
                        return;
                    }
                    if (freshAffected.length > 0 && force) {
                        const identity = getGitIdentity(targetDir);
                        cascadeNullLinkage(freshState, freshAffected, reason, new Date().toISOString(), identity?.name || 'unknown');
                        freshState.last_activity = new Date().toISOString();
                        atomicWriteJson(stPath, freshState);
                        cascadedCount = freshAffected.length;
                    }
                }, { projectRoot: targetDir });
            }
            if (blockedAffected) {
                return exitWith({
                    status: 'error',
                    command: 'roadmap-delete-phase',
                    reason: 'orphan-block',
                    affected: blockedAffected,
                }, 1);
            }
            if (cascadedCount > 0)
                regenerateTaskIndex(targetDir);
            milestone.phases = milestone.phases.filter((p) => p.id !== phaseId);
            saveRoadmap(scope, roadmap);
            // C34c — unlink the per-phase JSON file so the phases/-preferred
            // reader cannot resurrect the just-deleted phase on the next load.
            deletePhaseFile(targetDir, scope, phaseId);
            const selfStagedDelPhase = selfStageAfterWrite(writtenPathsForScope(scope, roadmap));
            return ok({
                status: 'ok',
                command: 'roadmap-delete-phase',
                milestone_id: milestoneId,
                phase_id: phaseId,
                cascade_nulled: cascadedCount,
                reason,
                self_staged: selfStagedDelPhase,
            });
        }
        case 'delete-milestone': {
            const milestoneId = flags.get('id') || flags.get('milestone');
            if (!milestoneId)
                throw new RoadmapUsageError('Required: --id <milestone-id>');
            const force = extraArgs.includes('--force');
            const reason = flags.get('reason') || null;
            if (force && !reason) {
                return exitWith({
                    status: 'error',
                    command: 'roadmap-delete-milestone',
                    reason: '--reason required when --force',
                }, 1);
            }
            const scope = resolveScope({ milestoneId });
            const roadmap = loadRoadmap(scope);
            const milestone = roadmap.milestones.find((m) => m.id === milestoneId);
            if (!milestone)
                throw new RoadmapUsageError(`Milestone "${milestoneId}" not found in scope "${scope}"`);
            // Atomic orphan-block check + cascade under the state.json lock.
            const stPath = join(targetDir, '.aperant', 'state.json');
            let cascadedCount = 0;
            let blockedAffected = null;
            if (existsSync(stPath)) {
                withFileLock(stPath, () => {
                    const freshState = JSON.parse(readFileSync(stPath, 'utf-8'));
                    const freshAffected = [];
                    for (const [id, t] of Object.entries(freshState.active_tasks || {})) {
                        if (t?.milestone_id === milestoneId) {
                            freshAffected.push({ source: 'active', id, task: t });
                        }
                    }
                    for (const t of freshState.completed_tasks || []) {
                        if (t?.id && t?.milestone_id === milestoneId) {
                            freshAffected.push({ source: 'completed', id: t.id, task: t });
                        }
                    }
                    if (freshAffected.length > 0 && !force) {
                        blockedAffected = freshAffected.map((a) => ({ source: a.source, id: a.id }));
                        return;
                    }
                    if (freshAffected.length > 0 && force) {
                        const identity = getGitIdentity(targetDir);
                        cascadeNullLinkage(freshState, freshAffected, reason, new Date().toISOString(), identity?.name || 'unknown');
                        freshState.last_activity = new Date().toISOString();
                        atomicWriteJson(stPath, freshState);
                        cascadedCount = freshAffected.length;
                    }
                }, { projectRoot: targetDir });
            }
            if (blockedAffected) {
                return exitWith({
                    status: 'error',
                    command: 'roadmap-delete-milestone',
                    reason: 'orphan-block',
                    affected: blockedAffected,
                }, 1);
            }
            if (cascadedCount > 0)
                regenerateTaskIndex(targetDir);
            // C34c — capture the phase ids before filtering so we can unlink each
            // `.aperant/roadmap/{scope}/phases/{id}.json` and prevent the
            // phases/-preferred reader from resurrecting the deleted milestone.
            const doomedPhaseIds = (milestone.phases || []).map((p) => p.id).filter(Boolean);
            roadmap.milestones = roadmap.milestones.filter((m) => m.id !== milestoneId);
            saveRoadmap(scope, roadmap);
            for (const phaseId of doomedPhaseIds) {
                deletePhaseFile(targetDir, scope, phaseId);
            }
            const selfStagedDelMs = selfStageAfterWrite(writtenPathsForScope(scope, roadmap));
            return ok({
                status: 'ok',
                command: 'roadmap-delete-milestone',
                milestone_id: milestoneId,
                cascade_nulled: cascadedCount,
                reason,
                self_staged: selfStagedDelMs,
            });
        }
        case 'validate-refs': {
            // C28 D-08 — scan state.json active_tasks + completed_tasks for
            // roadmap_ref_url entries. For path#anchor: resolve relative to
            // project root and grep for `<a id="<anchor>">`. For URL: unless
            // --offline, HEAD via curl with a 5s timeout. Emits {status, checked, broken}.
            const offline = extraArgs.includes('--offline');
            const statePathLocal = join(targetDir, '.aperant', 'state.json');
            const broken = [];
            let checked = 0;
            const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            const httpHead = (urlStr) => {
                try {
                    const out = execFileSync('curl', ['-sI', '-m', '5', urlStr], {
                        encoding: 'utf-8',
                        stdio: 'pipe',
                    });
                    const firstLine = (out || '').split('\n')[0] || '';
                    const m = firstLine.match(/\s(\d{3})\s/);
                    const code = m ? parseInt(m[1], 10) : 0;
                    if (code >= 200 && code < 400)
                        return { ok: true };
                    return { ok: false, reason: `http ${code || 'no-response'}` };
                }
                catch (err) {
                    return { ok: false, reason: err.message || 'curl failed' };
                }
            };
            const walk = (entries) => {
                for (const [id, task] of entries) {
                    const ref = task?.roadmap_ref_url;
                    if (!ref || typeof ref !== 'string')
                        continue;
                    checked++;
                    if (/^https?:\/\//i.test(ref)) {
                        if (offline)
                            continue;
                        const result = httpHead(ref);
                        if (!result.ok)
                            broken.push({ task_id: id, ref, reason: result.reason });
                        continue;
                    }
                    // Local path#anchor.
                    const hashIdx = ref.indexOf('#');
                    const relPath = hashIdx === -1 ? ref : ref.slice(0, hashIdx);
                    const anchor = hashIdx === -1 ? null : ref.slice(hashIdx + 1);
                    const absPath = join(targetDir, relPath);
                    if (!existsSync(absPath)) {
                        broken.push({ task_id: id, ref, reason: `file not found: ${relPath}` });
                        continue;
                    }
                    if (!anchor)
                        continue;
                    const content = readFileSync(absPath, 'utf-8');
                    const re = new RegExp(`<a\\s+id=["']${escapeRegex(anchor)}["']\\s*>`, 'i');
                    if (!re.test(content)) {
                        broken.push({ task_id: id, ref, reason: `anchor "${anchor}" not found in ${relPath}` });
                    }
                }
            };
            if (existsSync(statePathLocal)) {
                const state = JSON.parse(readFileSync(statePathLocal, 'utf-8'));
                walk(Object.entries(state.active_tasks || {}));
                walk((state.completed_tasks || []).filter((t) => t?.id).map((t) => [t.id, t]));
            }
            if (broken.length > 0) {
                return exitWith({ status: 'error', command: 'roadmap-validate-refs', checked, broken }, 1);
            }
            return ok({ status: 'ok', command: 'roadmap-validate-refs', checked, broken: [] });
        }
        case 'sync': {
            // Commit dirty tracked roadmap files (milestones.json + ROADMAP.md).
            // Delegates to the shared `syncRoadmapInProcess` helper so the
            // CLI subcommand and the in-process `effects drain` path share
            // identical behavior. The helper uses `git commit --only -- <paths>`
            // to perform a partial commit, which leaves the rest of the index
            // untouched (fixes the old unstaging regression).
            const syncReason = flags.get('reason') || 'roadmap status transition';
            let result;
            try {
                result = syncRoadmapInProcess(targetDir, syncReason);
            }
            catch (e) {
                return err(e.message);
            }
            if (result.skipped === 'team mode disabled') {
                return ok({
                    status: 'skipped',
                    command: 'roadmap-sync',
                    reason: 'team mode disabled',
                    committed: false,
                });
            }
            if (!result.committed) {
                // Preserve the old JSON shape: status:ok, committed:false,
                // files:[], reason populated with the skip reason when known.
                return ok({
                    status: 'ok',
                    command: 'roadmap-sync',
                    committed: false,
                    files: [],
                    reason: result.skipped || syncReason,
                });
            }
            return ok({
                status: 'ok',
                command: 'roadmap-sync',
                committed: true,
                files: result.files,
                sha: result.sha,
                reason: syncReason,
            });
        }
        case 'convex-mirror': {
            // Slice 5 ST14 / AC10 / ID-06. POSTs the rendered narrator prose
            // to the Next.js /api/roadmap/narrator-mirror endpoint. Markdown
            // is canonical (ADR-0006); this call is the Convex mirror.
            const scope = flags.get('scope');
            const phaseId = flags.get('phase');
            const anchorId = flags.get('anchor-id');
            const prosePath = flags.get('prose-path');
            const mergedPrRaw = flags.get('merged-pr');
            if (!scope || !phaseId || !anchorId || !prosePath) {
                return err('Usage: apt-tools roadmap convex-mirror <project-dir> --scope <s> --phase <p> --anchor-id <a> --prose-path <file> [--merged-pr <n>]');
            }
            let proseBody = '';
            try {
                proseBody = readFileSync(prosePath, 'utf-8');
            }
            catch (e) {
                return err(`prose-path read failed: ${e.message}`);
            }
            const mergedPr = mergedPrRaw ? Number(mergedPrRaw) : undefined;
            let result;
            try {
                result = await runConvexMirror(targetDir, {
                    scope,
                    phaseId,
                    anchorId,
                    proseBody,
                    mergedPr,
                });
            }
            catch (e) {
                return err(e.message);
            }
            if (result.status === 'error') {
                return err(result.message ?? 'convex-mirror error');
            }
            return ok({
                status: result.status,
                command: 'roadmap-convex-mirror',
                message: result.message,
            });
        }
        default:
            return err(`Unknown roadmap subcommand: ${subcommand}. Expected: init, render, add-milestone, add-phase, set-phase-status, delete-phase, delete-milestone, validate-refs, sync, convex-mirror`);
    }
}
//# sourceMappingURL=roadmap.mjs.map