/**
 * commands/init.mjs — multi-runtime framework install (C29 + multi-runtime
 * rewrite, 2026-04).
 *
 * Replaces the previous Claude-only `cp -r` installer with the per-runtime
 * transform pipeline (src/cli/install/pipeline.mjs). Registered runtime
 * targets are supported via `--<runtime>` flags or `--all`. Default with no
 * flags in
 * non-TTY mode → `--claude` (backward compat — existing skills invoke
 * `apt-tools init <dir>` with no flags). Default in TTY → interactive prompt
 * with auto-detection markers.
 *
 * The .aperant/ project-state setup (config, state, seeds, gitignore,
 * workflows/templates/prompts copies, public-remote warning) is preserved
 * byte-for-byte from the previous implementation. Only the runtime-install
 * half is replaced.
 */

import { execSync } from 'node:child_process'
import {
	copyFileSync,
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	writeFileSync,
} from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { detectMode } from '../bootstrap/detect-mode.mjs'
import { deriveSidecar } from '../bootstrap/product-context-sidecar.mjs'
import { assertConfigWriteAllowed } from '../config/guard.mjs'
import {
	AUDIT_001_LOCAL_FIELDS,
	migrateDeferredFieldsToLocalConfig,
	writeLocalConfigField,
} from '../config/load.mjs'
import { migrateConfigSchema, setAt } from '../config/schema-migrate.mjs'
import { resolveSharePolicy } from '../config/share-policy.mjs'
import { buildAperantGitignoreTemplate } from '../config/upgrade-gitignore.mjs'
import { detectPublicRemote } from '../git/remote.mjs'
import { detectArchetype } from '../install/archetype.mjs'
import { installKernel } from '../install/install-kernel.mjs'
import { MANIFEST_FILENAME } from '../install/manifest.mjs'
import {
	discoverCanonicalFiles,
	runInstall,
	writeLastInstallSummary,
} from '../install/pipeline.mjs'
import { getRuntime, RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
import { runLegacyCleanup } from '../install/runtime-migrate.mjs'
import {
	CLASS_A_SETTINGS,
	CLASS_B_SETTINGS,
	findClassDescriptor,
	parseClassFlags,
} from '../install/setting-class.mjs'
import { detectAgentRuntime } from '../util/agent-runtime.mjs'
import { buildAptSection } from '../util/aperant-section.mjs'
import { atomicWriteJson } from '../util/atomic-write.mjs'
import { aperantBanner } from '../util/banner.mjs'
import { checkInstructionFile, copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
import { withFileLock } from '../util/fs-lock.mjs'
import { emitInitConfigHint } from '../util/init-hints.mjs'
import { err, exitWith, ok, rawText } from '../util/result.mjs'
import { runCheck } from './check.mjs'
import { installPrecompactHook, installSessionStartHook } from './precompact-hook.mjs'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

/** Map of runtime id → dynamic-import loader. */
const TRANSFORM_LOADERS = new Map(
	RUNTIMES.map((r) => [r.id, () => import(`../install/transforms/${r.id}.mjs`)]),
)

/**
 * Parse the runtime selection from CLI flags.
 *
 * Exported (in addition to being used internally) so the parser can be unit
 * tested without spawning the apt-tools CLI subprocess.
 *
 * Returns the legacy four-field shape PLUS the new Class A/B flags
 * (`visibility`, `autonomy`, `accept_new_defaults`, `yes`) merged in
 * from `parseClassFlags`. Existing fields are unchanged so all
 * pre-existing call sites (and the 12 init-flag-parser cases) still pass.
 *
 * Whether the runtime selection was EXPLICIT (a per-runtime flag was
 * passed) vs IMPLICIT (the non-TTY default kicked in) is captured in
 * `runtimes_explicit` so the Class A gate downstream can decide whether
 * a missing runtimes flag should block (no flag, no `--yes`) or not
 * (flag passed, or `--yes`).
 *
 * 0.6.7 — `--force-runtimes` (Bug 2 fix) bypasses the `upgrade_noop`
 * short-circuit so `/apt:update` can re-stamp runtime manifests even
 * when `.aperant/config.json` is at the current schema. Strictly
 * additive: callers without the flag still get the no-op short-circuit
 * (preserves `init-upgrade-drift.test.ts` case (a)).
 *
 * 0.7.0 — `--force-overwrite-catalog` (FRAMEWORK-BUG-023) bypasses the
 * foreign-row defensive check in `injectInstructionFile`. Power-user
 * flag only — no interview prompt. Without it, an instruction file
 * carrying rows the generator didn't produce causes init to exit 2 with
 * `code: 'instruction-file.foreign-rows'` so unattended upgrade flows
 * surface the would-be data loss instead of silently clobbering it.
 *
 * @param {string[]} args
 * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean, forceOverwriteCatalog: boolean, keepLegacy: boolean }}
 */
export function parseInstallFlags(args) {
	// C58 (0.7.2): `--kilocode` is a deprecated alias of `--kilo`. Matches
	// the upstream package + app branding so users typing what they see on
	// kilocode.ai get the same install as the canonical `--kilo` flag.
	// Internal runtime id stays `kilo` (no migration — per spec ID-01).
	args = args.map((a) => (a === '--kilocode' ? '--kilo' : a))
	const flagSet = new Set(args.filter((a) => a.startsWith('--')))
	let target = null
	for (let i = 0; i < args.length; i++) {
		if (args[i] === '--target' && args[i + 1] && !args[i + 1].startsWith('--')) {
			target = resolve(args[i + 1])
		}
	}
	// C47 D-04 — opt out of the PROJECT.md auto-chain. Forwarded as
	// `answers.skipBootstrap` into both interview paths.
	const skipBootstrap = flagSet.has('--skip-bootstrap')
	// 0.6.7 (Bug 2 fix) — opt-in bypass of the upgrade_noop short-circuit
	// so /apt:update can re-stamp runtime files on a no-drift upgrade.
	const forceRuntimes = flagSet.has('--force-runtimes')
	// 0.7.0 (FRAMEWORK-BUG-023) — opt-in bypass of the foreign-row check
	// in injectInstructionFile. Power-user flag only.
	const forceOverwriteCatalog = flagSet.has('--force-overwrite-catalog')
	// 0.7.0 (FRAMEWORK-BUG-021) — opt out of the codex legacy-tree cleanup
	// that fires when `codex` is in the runtime set. Defaults to false so
	// the cleanup runs by default; pass `--keep-legacy` to preserve the
	// orphaned `.agents/skills/` tree on upgrades.
	const keepLegacy = flagSet.has('--keep-legacy')

	const classFlags = parseClassFlags(args)

	const base = {
		target,
		skipBootstrap,
		forceRuntimes,
		forceOverwriteCatalog,
		keepLegacy,
		visibility: classFlags.visibility,
		autonomy: classFlags.autonomy,
		accept_new_defaults: classFlags.accept_new_defaults,
		yes: classFlags.yes,
	}

	if (flagSet.has('--all')) {
		return {
			...base,
			runtimes: RUNTIMES.map((r) => r.id),
			runtimes_explicit: true,
			interactive: false,
		}
	}

	const requested = []
	for (const r of RUNTIMES) {
		if (flagSet.has(r.flag)) requested.push(r.id)
	}
	if (requested.length > 0)
		return { ...base, runtimes: requested, runtimes_explicit: true, interactive: false }

	if (flagSet.has('--interactive') || (process.stdin.isTTY && !flagSet.has('--no-interactive'))) {
		return { ...base, runtimes: [], runtimes_explicit: false, interactive: true }
	}
	// Backward compat: non-TTY default = claude only. runtimes_explicit:false
	// signals to the Class A gate that this is the implicit fallback, not a
	// user-stated choice.
	return { ...base, runtimes: ['claude'], runtimes_explicit: false, interactive: false }
}

/**
 * @typedef {Object} InstallAnswers
 * @property {string[]} runtimes                      Selected runtime ids.
 * @property {0|1|2|3} autonomyDefault                 Per-task default.
 * @property {'solo'|'team'} visibility                Solo vs team mode.
 * @property {string[]} crossVerificationTools         Runtimes to cross-verify (subset of `runtimes`).
 * @property {boolean} scanNow                         Whether to prompt for /apt:scan in outro.
 * @property {boolean} skipBootstrap                   C47 — when true, init skips the PROJECT.md auto-chain (D-04 escape hatch).
 * @property {import('../install/archetype.mjs').ArchetypeResult} archetype
 * @property {string} [agent_runtime]                  0.6.1 — detected agent CLI label (`'claude-code' | 'cursor' | 'aider' | 'unknown'`). Messaging-only.
 * @property {Array<{ key: string, value: unknown, why?: string, source?: string }>} [applied_defaults]
 *                                                    0.6.1 — Class B + Class A-collapsed defaults the install applied without user input.
 * @property {Array<{ key: string, value: unknown, source: string }>} [explicit_inputs]
 *                                                    0.6.1 — Class A values the user passed via flag.
 * @property {{ migrated: Record<string, unknown>, applied_defaults: unknown[], requires_explicit: unknown[], auto_added: unknown[] }} [migration]
 *                                                    0.6.1 — schema-drift plan when an existing config was reconciled. Only set on the upgrade path.
 */

/**
 * Run the full interactive onboarding interview or, in non-TTY mode,
 * derive answers from flags + sensible defaults.
 *
 * @param {string} targetDir
 * @param {string} version
 * @param {string} canonicalRoot       Used to read `templates/config.json` for schema-drift checks.
 * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
 * @returns {Promise<InstallAnswers | { requiresInput: true, requires_explicit, applied_defaults, agent_runtime, rerun_with } | { upgrade_noop: true } | { cancelled: true }>}
 */
async function runInterview(targetDir, version, canonicalRoot, parsedFlags) {
	const archetype = detectArchetype(targetDir)

	if (!process.stdin.isTTY) {
		return nonInteractiveAnswers(targetDir, canonicalRoot, archetype, parsedFlags)
	}

	return interactiveInterview(targetDir, version, canonicalRoot, archetype, parsedFlags)
}

/**
 * Read the existing `.aperant/config.json` if present. Returns null on a
 * fresh install. Catches and returns null on parse failure so a corrupt
 * config doesn't crash the installer (the user will see the file in
 * `git status` and can repair it).
 */
function readExistingConfig(targetDir) {
	const path = join(targetDir, '.aperant', 'config.json')
	if (!existsSync(path)) return null
	try {
		return JSON.parse(readFileSync(path, 'utf-8'))
	} catch {
		return null
	}
}

/**
 * Read the canonical template config that ships with this framework
 * version. Used by the schema-migrate path; failure is fatal because we
 * cannot proceed without knowing the target schema.
 */
function readTemplateConfig(canonicalRoot) {
	const path = resolve(canonicalRoot, 'templates', 'config.json')
	return JSON.parse(readFileSync(path, 'utf-8'))
}

/**
 * Build the `rerun_with` hint shown in the requires_input envelope.
 * Includes the project dir + every Class A flag the user still needs to
 * pass + every per-runtime flag they originally passed (so they don't
 * have to remember).
 */
function buildRerunWithHint(projectDir, parsedFlags, requiresExplicit) {
	const flags = []
	const addedKeys = new Set(requiresExplicit.map((e) => e.key))
	if (addedKeys.has('visibility')) {
		// Placeholder — user must pick one; do not assume team-mode default.
		flags.push('--team|--solo')
	}
	if (addedKeys.has('runtimes')) {
		flags.push('--claude')
	}
	// Re-include the runtime flags the user already passed (so the hint is
	// copy-pasteable end-to-end).
	for (const r of RUNTIMES) {
		if (parsedFlags.runtimes_explicit && parsedFlags.runtimes.includes(r.id)) {
			if (!flags.includes(r.flag)) flags.push(r.flag)
		}
	}
	return `apt-tools init ${projectDir} ${flags.join(' ')}`.trim()
}

/**
 * Infer `share.visibility` from the target's git tracking state.
 *
 * Repos that already track files inside `.aperant/` or any runtime root
 * (`.claude/`, `.codex/`, …) are *behaviourally* team-mode: contributors
 * pull the framework's installed files via git, not via local
 * `apt-tools init`. Treating them as solo on an upgrade (the old `--yes`
 * default did exactly this — bumped the schema, silently wrote
 * `visibility: 'solo'`, and the project-gitignore allow-list flipped)
 * is a destructive, non-obvious surprise. The 0.6.0/0.6.1 dogfood
 * incidents both hit this rake.
 *
 * Returns:
 *   - `'team'` when any tracked file lives under `.aperant/` or a runtime
 *     root.
 *   - `'solo'` when the dir is a git repo but no such tracked file
 *     exists.
 *   - `undefined` when the target isn't a git repo (caller falls back to
 *     the descriptor default, which preserves greenfield behaviour).
 *
 * @param {string} targetDir
 * @returns {'team'|'solo'|undefined}
 */
function inferVisibilityFromGit(targetDir) {
	try {
		execSync('git rev-parse --is-inside-work-tree 2>/dev/null', {
			cwd: targetDir,
			stdio: ['ignore', 'ignore', 'ignore'],
		})
	} catch {
		return undefined
	}
	const probePaths = ['.aperant', ...RUNTIMES.map((r) => r.installRoot)]
	try {
		const out = execSync(`git ls-files -- ${probePaths.map((p) => `'${p}'`).join(' ')}`, {
			cwd: targetDir,
			encoding: 'utf8',
			stdio: ['ignore', 'pipe', 'ignore'],
		}).trim()
		return out.length > 0 ? 'team' : 'solo'
	} catch {
		return 'solo'
	}
}

/**
 * Apply Class A defaults that were collapsed under --yes /
 * --accept-new-defaults back into the migrated config so finishInit
 * actually writes them to disk (otherwise the next install detects the
 * same drift again, AND injectProjectGitignore reads a stale
 * `share.visibility` and silently re-introduces the dogfood bug the
 * 0.6.1 work was meant to fix).
 *
 * Mutates `migration.migrated` in place + appends to
 * `migration.applied_defaults` so finishInit's upgrade-write trigger
 * (which checks `applied_defaults.length > 0`) fires.
 *
 * Bumps `migration.migrated.version` to `templateVersion` only when the
 * caller passes a defined version (it's `undefined` when blockers remain
 * post-collapse — see the `--accept-new-defaults` branch in
 * `nonInteractiveAnswers`).
 *
 * Uses the shared `setAt` helper from schema-migrate.mjs for dot-path
 * writes — keeps the prototype-pollution guard and dot-path semantics in
 * one place.
 *
 * `overrides` lets callers substitute the descriptor default for a
 * specific shortKey — used in the upgrade path to honour
 * `inferVisibilityFromGit`, so a team repo that ran `--yes` doesn't get
 * silently flipped to solo.
 *
 * @param {{ migrated: Record<string, unknown>, applied_defaults: Array<{ key: string, value: unknown, why: string, source?: string }> }} migration
 * @param {Array<{ key: string, why: string, default?: unknown }>} collapsedEntries
 * @param {string} source              `--yes` or `--accept-new-defaults`.
 * @param {unknown} templateVersion    Template's version, applied to migrated.
 * @param {Record<string, unknown>} [overrides]  shortKey → inferred value.
 */
function applyCollapsedClassAToMigration(
	migration,
	collapsedEntries,
	source,
	templateVersion,
	overrides = {},
) {
	for (const r of collapsedEntries) {
		const desc = findClassDescriptor(r.key)
		if (!desc) continue
		const hasOverride = Object.hasOwn(overrides, desc.shortKey)
		const value = hasOverride ? overrides[desc.shortKey] : desc.default
		const recordedSource = hasOverride ? `${source} (inferred from git tracking)` : source
		setAt(migration.migrated, desc.key, value)
		migration.applied_defaults.push({
			key: desc.shortKey,
			value,
			why: r.why,
			source: recordedSource,
		})
	}
	if (templateVersion !== undefined) {
		migration.migrated.version = templateVersion
	}
}

/**
 * Resolve the effective runtimes list from flags, with fallback to
 * `suggestRuntimes` and a final fallback to `['claude']`.
 *
 * Shared by the `nonInteractiveAnswers` upgrade path and the
 * `--force-runtimes` synthesis block in `cmdInit` to avoid duplication.
 *
 * Emits a one-line stderr warning when the final fallback fires (no
 * explicit flags and `suggestRuntimes` returned empty) so the choice is
 * visible without breaking automated callers.
 *
 * @param {string} targetDir
 * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
 * @returns {string[]}
 */
function selectRuntimes(targetDir, parsedFlags) {
	if (parsedFlags.runtimes_explicit) return parsedFlags.runtimes
	const suggested = new Set(suggestRuntimes(targetDir))
	if (suggested.size === 0) {
		process.stderr.write(
			'[apt-tools] No runtime specified and config has no suggestRuntimes; defaulting to claude.\n',
		)
		suggested.add('claude')
	}
	return [...suggested]
}

/**
 * Return the runtime ids that should be installed during a no-schema-drift
 * init. A current config is not enough to prove runtime files exist on this
 * machine: team repos often carry `.aperant/config.json` but not local
 * runtime roots/manifests.
 *
 * @param {string} targetDir
 * @param {string[]} runtimeIds
 * @returns {string[]}
 */
function runtimesMissingManifest(targetDir, runtimeIds) {
	const missing = []
	for (const id of runtimeIds) {
		const runtime = getRuntime(id)
		if (!runtime) continue
		const manifestPath = join(targetDir, runtime.installRoot, MANIFEST_FILENAME)
		if (!existsSync(manifestPath)) missing.push(id)
	}
	return missing
}

/**
 * Current-schema init should still run the runtime installer when the user
 * explicitly requested runtimes, when --force-runtimes is present, or when
 * local runtime roots are suggested but have no apt manifest.
 *
 * @param {string} targetDir
 * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
 * @returns {string[]}
 */
function selectNoDriftRuntimeInstallIds(targetDir, parsedFlags) {
	if (parsedFlags.forceRuntimes) return selectRuntimes(targetDir, parsedFlags)
	if (parsedFlags.runtimes_explicit) return parsedFlags.runtimes
	return runtimesMissingManifest(targetDir, suggestRuntimes(targetDir))
}

/**
 * Non-TTY / scripted path — derive answers from flags + sensible defaults,
 * gated by the Class A/B classifier (spec R2 + R3).
 *
 * Returns one of three sentinel shapes:
 *   - `InstallAnswers`            — install proceeds.
 *   - `{ requiresInput: true, ... }` — Class A blocker; cmdInit emits
 *     a `requires_input` envelope at exit code 2.
 *   - `{ upgrade_noop: true, ... }` — existing config matches template
 *     schema; cmdInit emits an `up_to_date` envelope.
 *
 * Never throws (the only throw site, parseClassFlags, runs in cmdInit
 * before this is called).
 *
 * @param {string} targetDir
 * @param {string} canonicalRoot
 * @param {import('../install/archetype.mjs').ArchetypeResult} archetype
 * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
 * @returns {InstallAnswers | { requiresInput: true, requires_explicit, applied_defaults, agent_runtime, rerun_with } | { upgrade_noop: true, applied_defaults, auto_added, migration }}
 */
function nonInteractiveAnswers(targetDir, canonicalRoot, archetype, parsedFlags) {
	const agentRuntime = detectAgentRuntime()
	const existingConfig = readExistingConfig(targetDir)
	const templateConfig = readTemplateConfig(canonicalRoot)

	if (existingConfig) {
		// === UPGRADE PATH ===
		// Drift detection takes precedence; a no-drift run short-circuits.
		const migration = migrateConfigSchema(existingConfig, templateConfig)
		const noDrift =
			migration.requires_explicit.length === 0 &&
			migration.applied_defaults.length === 0 &&
			migration.auto_added.length === 0
		if (noDrift) {
			return {
				upgrade_noop: true,
				applied_defaults: [],
				auto_added: [],
				migration,
				skipBootstrap: parsedFlags.skipBootstrap,
			}
		}

		// Class A blockers under upgrade — collapse via --yes /
		// --accept-new-defaults if requested, otherwise return the gate.
		// Collapsing means: take the descriptor's `default`, push it into
		// migration.migrated AT THE FULL DOT-PATH, AND record under
		// migration.applied_defaults so finishInit's upgrade-write branch
		// fires. Without the disk write the next run would detect the same
		// drift AND injectProjectGitignore would silently default to
		// solo-mode — re-introducing the 0.6.0 dogfood bug.
		let requires = migration.requires_explicit
		if (parsedFlags.yes && requires.length > 0) {
			// Prefer evidence over descriptor defaults: a repo that already
			// tracks `.aperant/` or any runtime root is team-mode by
			// behaviour. Without this, `--yes` flips long-tracked repos to
			// solo on the v3→v4 schema bump (0.6.0/0.6.1 dogfood incidents).
			// Explicit --solo/--team in parsedFlags wins; only collapse-time
			// inference runs here.
			const overrides = {}
			if (!parsedFlags.visibility && requires.some((r) => r.key === 'visibility')) {
				const inferred = inferVisibilityFromGit(targetDir)
				if (inferred !== undefined) overrides.visibility = inferred
			}
			applyCollapsedClassAToMigration(
				migration,
				requires,
				'--yes',
				templateConfig.version,
				overrides,
			)
			requires = []
		} else if (parsedFlags.accept_new_defaults && requires.length > 0) {
			// --accept-new-defaults applies template defaults for new Class A
			// keys but still BLOCKS on invalid existing values (spec R3 matrix).
			const stillBlocked = requires.filter((r) => r.why === 'value invalid under new schema')
			const acceptable = requires.filter((r) => r.why !== 'value invalid under new schema')
			applyCollapsedClassAToMigration(
				migration,
				acceptable,
				'--accept-new-defaults',
				stillBlocked.length === 0 ? templateConfig.version : undefined,
			)
			requires = stillBlocked
		}

		if (requires.length > 0) {
			return {
				requiresInput: true,
				requires_explicit: requires,
				applied_defaults: migration.applied_defaults,
				agent_runtime: agentRuntime,
				rerun_with: buildRerunWithHint(targetDir, parsedFlags, requires),
			}
		}

		// Drift handled — derive answers from migrated config so finishInit
		// can rewrite the config with applied defaults.
		const visibility = migration.migrated?.share?.visibility === 'team' ? 'team' : 'solo'
		const autonomyDefault = migration.migrated?.autonomy?.default ?? 1
		const xverify = migration.migrated?.multi_model?.cross_verification?.tools ?? []
		const runtimes = selectRuntimes(targetDir, parsedFlags)
		return {
			runtimes,
			autonomyDefault,
			visibility,
			crossVerificationTools: xverify,
			scanNow: false,
			skipBootstrap: parsedFlags.skipBootstrap,
			archetype,
			agent_runtime: agentRuntime,
			applied_defaults: migration.applied_defaults,
			explicit_inputs: collectExplicitInputs(parsedFlags),
			migration,
		}
	}

	// === FRESH INSTALL PATH ===
	// Class A gate: visibility must be explicit (or --yes), runtimes must
	// be explicit (or --yes — the implicit `['claude']` default counts as
	// missing).
	const requires = []
	const appliedDefaults = []

	const visibilityDesc = CLASS_A_SETTINGS.find((d) => d.shortKey === 'visibility')
	if (parsedFlags.visibility) {
		// explicit — no default needed.
	} else if (parsedFlags.yes) {
		appliedDefaults.push({
			key: 'visibility',
			value: visibilityDesc?.default,
			why: visibilityDesc?.why,
			source: '--yes',
		})
	} else {
		requires.push({
			key: visibilityDesc?.shortKey,
			why: visibilityDesc?.why,
			options: visibilityDesc?.options,
			default: visibilityDesc?.default,
		})
	}

	const runtimesDesc = CLASS_A_SETTINGS.find((d) => d.shortKey === 'runtimes')
	if (parsedFlags.runtimes_explicit) {
		// explicit — no default needed.
	} else if (parsedFlags.yes) {
		appliedDefaults.push({
			key: 'runtimes',
			value: parsedFlags.runtimes,
			why: runtimesDesc?.why,
			source: '--yes',
		})
	} else {
		requires.push({
			key: runtimesDesc?.shortKey,
			why: runtimesDesc?.why,
			options: runtimesDesc?.options,
			default: runtimesDesc?.default,
		})
	}

	// Class B always applies a default (spec R2: never blocks).
	const autonomyDesc = CLASS_B_SETTINGS.find((d) => d.shortKey === 'autonomyDefault')
	const autonomyValue =
		parsedFlags.autonomy !== undefined ? parsedFlags.autonomy : autonomyDesc?.default
	if (parsedFlags.autonomy === undefined) {
		appliedDefaults.push({
			key: 'autonomyDefault',
			value: autonomyValue,
			why: autonomyDesc?.why,
			source: parsedFlags.yes ? '--yes' : 'default',
		})
	}

	if (requires.length > 0) {
		return {
			requiresInput: true,
			requires_explicit: requires,
			applied_defaults: appliedDefaults,
			agent_runtime: agentRuntime,
			rerun_with: buildRerunWithHint(targetDir, parsedFlags, requires),
		}
	}

	// All Class A satisfied (explicit or --yes) → install proceeds.
	const suggested = new Set(suggestRuntimes(targetDir))
	if (suggested.size === 0) suggested.add('claude')
	const runtimes = parsedFlags.runtimes_explicit ? parsedFlags.runtimes : [...suggested]
	const visibility = parsedFlags.visibility ?? /** @type {'solo'} */ ('solo')

	return {
		runtimes,
		autonomyDefault: autonomyValue,
		visibility,
		crossVerificationTools: [],
		scanNow: false,
		skipBootstrap: parsedFlags.skipBootstrap,
		archetype,
		agent_runtime: agentRuntime,
		applied_defaults: appliedDefaults,
		explicit_inputs: collectExplicitInputs(parsedFlags),
	}
}

/**
 * Build the `explicit_inputs` envelope field — every Class A value the
 * user explicitly passed via flag, with the originating flag name as
 * `source` (spec R2's success-envelope shape).
 */
function collectExplicitInputs(parsedFlags) {
	const out = []
	if (parsedFlags.visibility) {
		out.push({
			key: 'visibility',
			value: parsedFlags.visibility,
			source: parsedFlags.visibility === 'team' ? '--team' : '--solo',
		})
	}
	if (parsedFlags.runtimes_explicit) {
		out.push({
			key: 'runtimes',
			value: parsedFlags.runtimes,
			source: parsedFlags.runtimes.length === RUNTIMES.length ? '--all' : '--<runtime>',
		})
	}
	if (parsedFlags.autonomy !== undefined) {
		out.push({
			key: 'autonomyDefault',
			value: parsedFlags.autonomy,
			source: '--autonomy',
		})
	}
	return out
}

/**
 * TTY path — the full clack interview that expands the install flow beyond
 * runtime selection into autonomy + team + cross-model verification +
 * scan-now. Every question has a typed default so users can accept the
 * interview by mashing ENTER.
 *
 * On a fresh install (no existing config) this remains byte-identical to
 * the pre-change behavior (spec R5 / acceptance criterion 7). The
 * upgrade-TTY gate added in subtask 7 wraps THIS function with a
 * pre-flight schema-drift check.
 *
 * @param {string} targetDir
 * @param {string} version
 * @param {string} canonicalRoot       Absolute path to the framework root; used to read `templates/config.json` for the upgrade-TTY schema-drift check.
 * @param {import('../install/archetype.mjs').ArchetypeResult} archetype
 * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
 * @returns {Promise<InstallAnswers>}
 */
async function interactiveInterview(targetDir, version, canonicalRoot, archetype, parsedFlags) {
	const skipBootstrap = parsedFlags.skipBootstrap
	const clack = await import('@clack/prompts')

	// Upgrade gate (spec R5). Existing config → run schema-migrate first
	// and either short-circuit (no-op / Class B-only drift) or prompt
	// only for the new Class A keys. Fresh install (no existing config)
	// falls through to the byte-identical pre-change interview below.
	const existingConfig = readExistingConfig(targetDir)
	if (existingConfig) {
		const templateConfig = readTemplateConfig(canonicalRoot)
		const migration = migrateConfigSchema(existingConfig, templateConfig)
		const noDrift =
			migration.requires_explicit.length === 0 &&
			migration.applied_defaults.length === 0 &&
			migration.auto_added.length === 0
		if (noDrift) {
			const runtimeInstallIds = selectNoDriftRuntimeInstallIds(targetDir, parsedFlags)
			// Runtime repair bypasses the TTY "Nothing to do." outro so the
			// runtime walker can re-stamp missing manifests first.
			if (runtimeInstallIds.length > 0) {
				return {
					upgrade_noop: true,
					skipBootstrap,
					migration,
					runtime_reinstall_runtimes: runtimeInstallIds,
				}
			}
			process.stdout.write(`\n${aperantBanner(version)}\n\n`)
			clack.intro('install')
			clack.note(`Already up to date — v${version}`, 'No drift')
			clack.outro('Nothing to do.')
			return { upgrade_noop: true, skipBootstrap }
		}
		// Class B / auto_added only → apply silently, render summary, skip
		// the rest of the interview. The user did not opt in to a re-tour.
		if (migration.requires_explicit.length === 0) {
			process.stdout.write(`\n${aperantBanner(version)}\n\n`)
			clack.intro('install')
			const summary = [
				...migration.applied_defaults.map((d) => `  + ${d.key} = ${JSON.stringify(d.value)}`),
				...migration.auto_added.map((d) => `  + ${d.key} (template addition)`),
			].join('\n')
			clack.note(summary || '(no changes)', 'Schema migration — applied defaults')
			clack.outro(`v${version} — config refreshed.`)
			const merged = migration.migrated
			const suggestedUpgrade = suggestRuntimes(targetDir)
			return {
				runtimes: parsedFlags.runtimes_explicit
					? parsedFlags.runtimes
					: suggestedUpgrade.length > 0
						? suggestedUpgrade
						: ['claude'],
				autonomyDefault: merged?.autonomy?.default ?? 1,
				visibility: merged?.share?.visibility === 'team' ? 'team' : 'solo',
				crossVerificationTools: merged?.multi_model?.cross_verification?.tools ?? [],
				scanNow: false,
				skipBootstrap,
				archetype,
				agent_runtime: detectAgentRuntime(),
				applied_defaults: migration.applied_defaults,
				explicit_inputs: collectExplicitInputs(parsedFlags),
				migration,
			}
		}

		// Class A drift → prompt ONLY for the missing keys, then merge.
		process.stdout.write(`\n${aperantBanner(version)}\n\n`)
		clack.intro('install — schema migration')
		const missingKeys = new Set(migration.requires_explicit.map((e) => e.key))
		const collected = {}

		if (missingKeys.has('visibility')) {
			const sel = await clack.confirm({
				message:
					'Are you part of a team? (enables shared roadmap + task board + team-status via git)',
				initialValue: false,
			})
			if (clack.isCancel(sel)) {
				clack.cancel('Migration cancelled — config unchanged.')
				return { cancelled: true }
			}
			collected.visibility = sel ? 'team' : 'solo'
		}

		if (missingKeys.has('runtimes')) {
			const suggested = new Set(suggestRuntimes(targetDir))
			if (suggested.size === 0) suggested.add('claude')
			const sel = await clack.multiselect({
				message: 'Pick the runtimes to install Aperant into',
				options: RUNTIMES.map((r) => ({
					value: r.id,
					label: r.displayLabel ?? r.id,
					hint: r.flag,
				})),
				initialValues: [...suggested],
				required: true,
			})
			if (clack.isCancel(sel)) {
				clack.cancel('Migration cancelled — config unchanged.')
				return { cancelled: true }
			}
			collected.runtimes = /** @type {string[]} */ (sel)
		}

		if (missingKeys.has('scanNow') && archetype.type === 'brownfield') {
			const sel = await clack.confirm({
				message: 'Scan existing code to build the feature registry after install? (runs /apt:scan)',
				initialValue: true,
			})
			if (clack.isCancel(sel)) {
				clack.cancel('Migration cancelled — config unchanged.')
				return { cancelled: true }
			}
			collected.scanNow = sel === true
		}

		// Apply collected values back into the migrated config so finishInit
		// rewrites them to disk. Also push them into migration.applied_defaults
		// so finishInit's upgrade-write trigger
		// (`migration.applied_defaults.length > 0 || migration.auto_added.length > 0`)
		// fires when a user prompted-only-for-Class-A-drift run lands — without
		// this push the migrated config would never reach disk, the version
		// would not bump on the next install, and the same drift would be
		// detected again.
		const mergedConfig = migration.migrated
		if (collected.visibility) {
			mergedConfig.share = mergedConfig.share ?? {}
			mergedConfig.share.visibility = collected.visibility
			migration.applied_defaults.push({
				key: 'visibility',
				value: collected.visibility,
				why: 'collected via clack prompt during upgrade-TTY migration',
				source: 'tty-prompt',
			})
		}
		if (collected.runtimes) {
			mergedConfig.runtimes = collected.runtimes
			migration.applied_defaults.push({
				key: 'runtimes',
				value: collected.runtimes,
				why: 'collected via clack prompt during upgrade-TTY migration',
				source: 'tty-prompt',
			})
		}
		if (collected.scanNow !== undefined) {
			mergedConfig.scanNow = collected.scanNow
			migration.applied_defaults.push({
				key: 'scanNow',
				value: collected.scanNow,
				why: 'collected via clack prompt during upgrade-TTY migration',
				source: 'tty-prompt',
			})
		}
		// Bump version since requires_explicit is now empty post-prompts.
		mergedConfig.version = templateConfig.version

		clack.outro(`v${version} — config migrated.`)

		const finalRuntimes =
			collected.runtimes ?? (parsedFlags.runtimes_explicit ? parsedFlags.runtimes : ['claude'])

		return {
			runtimes: finalRuntimes,
			autonomyDefault: mergedConfig?.autonomy?.default ?? 1,
			visibility: collected.visibility ?? mergedConfig?.share?.visibility ?? 'solo',
			crossVerificationTools: mergedConfig?.multi_model?.cross_verification?.tools ?? [],
			scanNow: collected.scanNow ?? false,
			skipBootstrap,
			archetype,
			agent_runtime: detectAgentRuntime(),
			applied_defaults: migration.applied_defaults,
			explicit_inputs: collectExplicitInputs(parsedFlags),
			migration: { ...migration, requires_explicit: [], migrated: mergedConfig },
		}
	}

	// Fresh install — byte-identical to the pre-change behavior below.
	// Layer 1 escape hatch — banner is written directly here because clack
	// (a TTY library) already writes to process.stdout on its own. The
	// function is only reachable in interactive TTY mode (guarded by the
	// `interactiveTty` check in cmdInit); the Layer 1 "no direct stdout"
	// rule is effectively N/A for this TTY-only path. cmdInit returns
	// rawText('', 0) for TTY mode to prevent wrap() from doubling the output.
	process.stdout.write(`\n${aperantBanner(version)}\n\n`)
	clack.intro('install')

	// Keep notes ≤70 chars wide so clack's frame never blows past 80 cols
	// (observed clipping on standard terminals in 0.2.1). We truncate the
	// signals list to the two strongest so the line stays on one row even
	// on deeply-established repos.
	const archetypeCopy =
		archetype.type === 'brownfield'
			? `Brownfield (${archetype.signals.slice(0, 2).join(', ')}).`
			: 'Greenfield — no existing code detected.'
	clack.note(archetypeCopy, 'Project type')

	// Runtimes — one-line teaching hint. clack's multiselect renders its own
	// inline key hints, but users still missed them in 0.1.3, so a short
	// note before the prompt makes the mechanics obvious.
	clack.note('↑/↓ move · SPACE toggle · ENTER confirm', 'Step 1 · Runtimes')
	const suggested = new Set(suggestRuntimes(targetDir))
	if (suggested.size === 0) suggested.add('claude')
	const runtimesSelection = await clack.multiselect({
		message: 'Pick the runtimes to install Aperant into',
		options: RUNTIMES.map((r) => ({ value: r.id, label: r.id, hint: r.flag })),
		initialValues: [...suggested],
		required: true,
	})
	if (clack.isCancel(runtimesSelection)) {
		clack.cancel('Installation cancelled — no files written.')
		return { cancelled: true }
	}
	const runtimes = /** @type {string[]} */ (runtimesSelection)

	// Autonomy default — still prompted per-task; this is just the pre-filled choice.
	const autonomySelection = await clack.select({
		message: 'Default autonomy level for new tasks (you can always override per-task)',
		options: [
			{
				value: 0,
				label: 'Supervised',
				hint: 'confirm every step — safest, slowest',
			},
			{
				value: 1,
				label: 'Guided',
				hint: 'confirm before commits — recommended for humans-in-the-loop',
			},
			{
				value: 2,
				label: 'Autonomous',
				hint: 'plan → execute end-to-end, only stop for critical risks',
			},
			{
				value: 3,
				label: 'YOLO',
				hint: 'full auto including destructive ops — use with worktrees',
			},
		],
		initialValue: 1,
	})
	if (clack.isCancel(autonomySelection)) {
		clack.cancel('Installation cancelled — no files written.')
		return { cancelled: true }
	}
	const autonomyDefault = /** @type {0|1|2|3} */ (autonomySelection)

	// Team mode — controls visibility preset (solo = everything gitignored, team = share roadmap/tasks/roster).
	const teamSelection = await clack.confirm({
		message: 'Are you part of a team? (enables shared roadmap + task board + team-status via git)',
		initialValue: false,
	})
	if (clack.isCancel(teamSelection)) {
		clack.cancel('Installation cancelled — no files written.')
		return { cancelled: true }
	}
	const visibility = teamSelection ? 'team' : 'solo'

	// Cross-model verification — only meaningful when ≥2 runtimes are installed.
	let crossVerificationTools = []
	if (runtimes.length >= 2) {
		const enableXVerify = await clack.confirm({
			message:
				'Enable cross-model verification during reviews? (invokes other installed CLIs for a second opinion — no API keys)',
			initialValue: true,
		})
		if (clack.isCancel(enableXVerify)) {
			clack.cancel('Installation cancelled — no files written.')
			return { cancelled: true }
		}
		if (enableXVerify) {
			const toolsSelection = await clack.multiselect({
				message: 'Which installed runtimes should participate in cross-verification?',
				options: runtimes.map((id) => ({ value: id, label: id })),
				initialValues: runtimes,
				required: true,
			})
			if (clack.isCancel(toolsSelection)) {
				clack.cancel('Installation cancelled — no files written.')
				return { cancelled: true }
			}
			crossVerificationTools = /** @type {string[]} */ (toolsSelection)
		}
	}

	// Scan-now — brownfield only. LLM-driven, so we can only surface the command in the outro;
	// store the intent here so the outro knows to show it prominently.
	let scanNow = false
	if (archetype.type === 'brownfield') {
		const scanSelection = await clack.confirm({
			message: 'Scan existing code to build the feature registry after install? (runs /apt:scan)',
			initialValue: true,
		})
		if (clack.isCancel(scanSelection)) {
			clack.cancel('Installation cancelled — no files written.')
			return { cancelled: true }
		}
		scanNow = scanSelection === true
	}

	return {
		runtimes,
		autonomyDefault,
		visibility,
		crossVerificationTools,
		scanNow,
		skipBootstrap,
		archetype,
	}
}

/**
 * Patch the template config in place with the interview answers. Leaves
 * every key the user did NOT touch at its template default so the shape
 * matches `/apt:setup`'s review-mode expectations.
 *
 * @param {Record<string, any>} config
 * @param {InstallAnswers} answers
 */
export function applyInterviewAnswers(config, answers, targetDir = null) {
	if (config.autonomy) {
		config.autonomy.default = answers.autonomyDefault
	}
	if (config.share) {
		config.share.visibility = answers.visibility
		if (answers.visibility === 'team') {
			// Team preset — share roadmap + task board + roster (gitignored personal state stays local).
			config.share.roadmap = true
			config.share.tasks = true
			if (config.share.team) config.share.team.roster = true
		}
	}
	if (config.multi_model?.cross_verification) {
		const xverify = answers.crossVerificationTools
		// `enabled` is project policy (is cross-verification on for this
		// project?) — stays in shared `config.json`.
		config.multi_model.cross_verification.enabled = xverify.length > 0
		// AUDIT-001 (0.7.2 S1) — `tools` is per-device (which CLIs are installed
		// HERE) and MUST land in `config.local.json`, NOT shared. Strip from
		// the template to keep the shared file clean. When `targetDir` is null
		// (legacy callers / tests that don't go through finishInit), we leave
		// the tools array on the shared template — backward-compatible fallback
		// — but the writer path always passes targetDir so production lands
		// the value in local.
		if (targetDir) {
			delete config.multi_model.cross_verification.tools
			writeLocalConfigField(targetDir, 'multi_model.cross_verification.tools', xverify)
		} else {
			config.multi_model.cross_verification.tools = xverify
		}
	}
	config.project_type =
		answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'

	// Onboarding marker (task 15-05-26_apt-setup-onboarding-marker-7q9c).
	// `/apt:setup` dispatches on this marker to decide review-mode vs full
	// Batch 1-8 flow — file existence alone cannot disambiguate "fresh seed
	// from template" from "fully tuned by setup" because templates/config.json
	// ships with every key populated. Only the fresh-seed branch reaches this
	// function (finishInit:1331); the upgrade branch (finishInit:1338-1357)
	// writes answers.migration.migrated directly and never calls
	// applyInterviewAnswers, so legacy unmarked configs correctly stay
	// unmarked on upgrade. --yes / --accept-new-defaults walk this same fresh
	// path but do NOT walk Batch 1-8 questions, so completed stays false.
	config.onboarding = {
		completed: false,
		seeded_from_template: true,
		seeded_at: new Date().toISOString(),
	}
}

/**
 * Read the framework version from package.json.
 *
 * @returns {string}
 */
function readFrameworkVersion() {
	const pkgPath = resolve(__dirname, '..', '..', '..', 'package.json')
	return JSON.parse(readFileSync(pkgPath, 'utf-8')).version
}

/**
 * Install the requested runtimes via the pipeline.
 *
 * @param {string[]} runtimeIds
 * @param {string} canonicalRoot
 * @param {string} targetDir
 * @param {string} version
 */
async function installRuntimes(runtimeIds, canonicalRoot, targetDir, version) {
	const results = []
	// router-0.3.0 — fail fast if any canonical SKILL.md has malformed
	// frontmatter or an unknown gate ID. Prevents malformed skills from
	// leaking into runtime install roots where they'd break the router
	// envelope scan.
	const skillsRoot = join(canonicalRoot, 'skills')
	const checkResult = runCheck({ skillsRoot })
	if (checkResult.status !== 'ok') {
		throw new Error(
			`apt-tools init aborted — canonical skill set failed validation:\n${checkResult.errors
				.map((e) => `  ${e.file.replace(skillsRoot + '/', '')}: ${e.field}: ${e.message}`)
				.join('\n')}\n` + `Run 'apt-tools check' for full details.`,
		)
	}
	// Discover canonical files once and share across all runtime installs to
	// avoid redundant disk reads (14× readFileSync per file in --all mode).
	const canonicalFiles = discoverCanonicalFiles(canonicalRoot)
	for (const id of runtimeIds) {
		const loader = TRANSFORM_LOADERS.get(id)
		if (!loader) {
			results.push({
				runtime: id,
				installRoot: null,
				files: [],
				skipped: [`unknown runtime: ${id}`],
			})
			continue
		}
		const mod = await loader()
		const r = runInstall({
			runtime: id,
			transform: mod.transform,
			canonicalRoot,
			targetDir,
			version,
			canonicalFiles,
		})
		results.push({
			runtime: r.runtime,
			installRoot: r.installRoot,
			files: r.files,
			skipped: r.skipped,
		})
	}
	return results
}

/**
 * Main entry — invoked from bin/apt-tools.mjs.
 *
 * @param {string} projectDir
 * @param {string[]} extraArgs
 * @returns {Promise<{ envelope: object, exitCode: number }>}
 */
export async function cmdInit(projectDir, extraArgs = []) {
	// Default to the current working directory when no <project-dir> is given —
	// matches the `npx @aperant/framework init` one-liner users expect from
	// other scaffolders (create-next-app, shadcn, get-shit-done-cc).
	const resolvedProjectDir = projectDir && projectDir.length > 0 ? projectDir : '.'

	const targetDir = resolve(resolvedProjectDir)
	const canonicalRoot = resolve(__dirname, '..', '..', '..')
	const version = readFrameworkVersion()

	let parsedFlags
	try {
		parsedFlags = parseInstallFlags(extraArgs)
	} catch (e) {
		// parseClassFlags throws on --team --solo conflict / out-of-range
		// --autonomy. Surface as a usage error envelope, exit 1.
		return err(`init failed: ${e instanceof Error ? e.message : String(e)}`)
	}
	const { target: targetOverride, interactive } = parsedFlags
	const installTarget = targetOverride ?? targetDir

	if (!existsSync(join(canonicalRoot, 'skills'))) {
		return err(`Skills directory not found: ${join(canonicalRoot, 'skills')}`)
	}

	// Compute interactiveTty early — needed for the cancel path below as well
	// as the success path. TTY mode suppresses the JSON envelope in both cases
	// because clack already rendered the user-facing output to stdout.
	const interactiveTty = interactive && process.stdin.isTTY

	try {
		const answers = interactive
			? await runInterview(installTarget, version, canonicalRoot, parsedFlags)
			: nonInteractiveAnswers(
					installTarget,
					canonicalRoot,
					detectArchetype(installTarget),
					parsedFlags,
				)

		// Interactive cancel — interview returned the sentinel { cancelled: true }
		// answer object. In TTY mode clack.cancel() already rendered the styled
		// cancel block to stdout, so return rawText('', 0) to suppress the JSON
		// envelope. In non-TTY mode emit the structured envelope so scripts
		// piping apt-tools can observe the cancellation signal.
		if (answers && answers.cancelled) {
			if (interactiveTty) return rawText('', 0)
			return ok({
				status: 'cancelled',
				command: 'init',
				projectDir: targetDir,
				message: 'Installation cancelled — no files written.',
			})
		}

		// Non-TTY Class A blocker — return the requires_input envelope at
		// exit code 2 (mirrors config/upgrade-layout.mjs's exitWith pattern).
		if (answers?.requiresInput) {
			return exitWith(
				{
					status: 'requires_input',
					command: 'init',
					projectDir: targetDir,
					requires_explicit: answers.requires_explicit,
					applied_defaults: answers.applied_defaults,
					agent_runtime: answers.agent_runtime,
					rerun_with: answers.rerun_with,
				},
				2,
			)
		}

		const noDriftRuntimeInstallIds = answers?.upgrade_noop
			? (answers.runtime_reinstall_runtimes ??
				selectNoDriftRuntimeInstallIds(installTarget, parsedFlags))
			: []

		// Upgrade no-drift with no runtime work — short-circuit before
		// installRuntimes. /apt:update remains the right surface for refreshing
		// already-manifested skills; init only repairs selected or suggested
		// runtime roots that lack manifests.
		// The bootstrap auto-chain still runs (it's deterministic and
		// idempotent — preserves PROJECT.md, refreshes sidecar from it).
		// In TTY mode clack already rendered "Nothing to do." to stdout,
		// so suppress the JSON envelope (matches the cancelled path).
		if (answers?.upgrade_noop && noDriftRuntimeInstallIds.length === 0) {
			if (interactiveTty) return rawText('', 0)
			const bootstrap = answers.skipBootstrap
				? { status: 'skipped', reason: '--skip-bootstrap flag' }
				: runBootstrapAutoChain(targetDir)
			return ok({
				status: 'up_to_date',
				command: 'init',
				projectDir: targetDir,
				framework_version: version,
				agent_runtime: detectAgentRuntime(),
				bootstrap,
				message: `Already up to date — v${version}`,
			})
		}

		// Current-schema runtime install — synthesize a real InstallAnswers
		// shape from the existing config so installRuntimes + finishInit run
		// their normal upgrade path.
		let effectiveAnswers = answers
		if (answers?.upgrade_noop && noDriftRuntimeInstallIds.length > 0) {
			const existing = answers.migration?.migrated || {}
			effectiveAnswers = {
				runtimes: noDriftRuntimeInstallIds,
				autonomyDefault: existing?.autonomy?.default ?? 1,
				visibility: existing?.share?.visibility === 'team' ? 'team' : 'solo',
				crossVerificationTools: existing?.multi_model?.cross_verification?.tools ?? [],
				scanNow: false,
				skipBootstrap: answers.skipBootstrap,
				archetype: detectArchetype(installTarget),
				agent_runtime: detectAgentRuntime(),
				applied_defaults: [],
				explicit_inputs: collectExplicitInputs(parsedFlags),
			}
		}

		// FRAMEWORK-BUG-021 — sweep the legacy `.codex/` codex tree
		// BEFORE the install pipeline runs so the install can re-stamp
		// `.agents/skills/<folder>/SKILL.md` (Codex CLI's documented
		// surface, per https://developers.openai.com/codex/skills) against
		// a clean slate. Only fires when codex is in the requested runtime
		// set and `--keep-legacy` was not passed; the helper itself is
		// conservative (no-op on managed installs, no-op when no legacy
		// framework files are present). The v0.8.2 inversion is
		// documented in `runtime-migrate.mjs:7-12`.
		let legacyCleanup = null
		if (effectiveAnswers.runtimes.includes('codex')) {
			if (parsedFlags.keepLegacy) {
				legacyCleanup = { runtime: 'codex', ran: false, reason: '--keep-legacy' }
			} else {
				const cleanup = runLegacyCleanup({ targetDir: installTarget })
				legacyCleanup = { runtime: 'codex', ...cleanup }
				if (cleanup.ran && cleanup.retained.length > 0) {
					process.stderr.write(
						`[apt-tools] legacy_cleanup retained ${cleanup.retained.length} user-added path(s) under .codex/: ${cleanup.retained.join(', ')}\n`,
					)
				}
			}
		}

		const results = await installRuntimes(
			effectiveAnswers.runtimes,
			canonicalRoot,
			installTarget,
			version,
		)

		// Provision the project-local kernel BEFORE finishInit runs its JSON
		// output — that way the "structure" summary can reflect the kernel
		// path. We show a spinner in TTY mode because npm install is slow.
		const kernelResult = await runKernelInstall(installTarget, version, interactiveTty)

		const envelope = finishInit(
			targetDir,
			installTarget,
			canonicalRoot,
			version,
			effectiveAnswers.runtimes,
			results,
			effectiveAnswers,
			interactiveTty,
			kernelResult,
			parsedFlags.forceOverwriteCatalog === true,
			legacyCleanup,
		)
		// FRAMEWORK-BUG-023 — finishInit signals the foreign-row defensive
		// abort by returning an early-exit sentinel envelope.
		if (envelope?.__earlyExit) {
			return envelope.__earlyExit
		}
		if (interactiveTty) {
			await renderOutro(effectiveAnswers, results, version, kernelResult)
			// TTY mode: envelope was persisted to disk + suppressed from stdout
			// because the clack outro already rendered the user-facing summary.
			// Return the rawText shape with empty payload so wrap() routes us
			// through process.stdout.write('') — a no-op — instead of writing
			// the JSON envelope on top of the clack outro. (Subtask 20: replaces
			// the legacy `return undefined` pattern.)
			return rawText('', 0)
		}
		return ok(envelope)
	} catch (e) {
		return err(`init failed: ${e instanceof Error ? e.message : String(e)}`)
	}
}

/**
 * Spinner-wrapped kernel install. Keeps clack happy in interactive mode
 * (user sees "Installing Aperant kernel…" while npm churns) and stays
 * silent in non-TTY so piped output remains parseable.
 *
 * @param {string} installTarget
 * @param {string} version
 * @param {boolean} interactiveTty
 * @returns {Promise<import('../install/install-kernel.mjs').InstallKernelResult>}
 */
/**
 * Add project-root `.gitignore` entries for every directory we installed
 * files into, so the user doesn't get ~300 untracked files the first time
 * they run `git status`. Wrapped in an APT-marked block so a future
 * `apt-tools uninstall` can strip it cleanly.
 *
 * When `teamMode` is true we install the `.aperant/` ignore lighter —
 * the internal `.aperant/.gitignore` (deny-by-default) is what actually
 * governs which sub-paths get shared. Without `.aperant/` at the project
 * level, git will descend and the internal allow-list applies.
 *
 * @param {string} targetDir
 * @param {Array<{ runtime: string, installRoot: string|null }>} runtimeResults
 * @param {boolean} teamMode
 */
function injectProjectGitignore(targetDir, runtimeResults, teamMode) {
	const gitignorePath = join(targetDir, '.gitignore')
	const START = '# APT:gitignore-start'
	const END = '# APT:gitignore-end'

	const dirs = new Set()
	for (const r of runtimeResults) {
		if (!r.installRoot) continue
		// Store a path relative to targetDir, normalised with a trailing slash
		// so gitignore treats it as a directory.
		const rel = r.installRoot.replace(`${targetDir}/`, '')
		if (rel.length > 0 && rel !== targetDir) dirs.add(`${rel.replace(/\/$/, '')}/`)
	}

	const block = [
		START,
		'# Auto-managed by `apt-tools init`. Reversed by `apt-tools uninstall`.',
		'# Aperant Framework runtime roots — installed files, not your code.',
		...[...dirs].sort().map((d) => d),
		teamMode
			? '# .aperant/ intentionally NOT ignored in team mode — its internal .gitignore'
			: '.aperant/',
		teamMode ? '# (deny-by-default) governs which sub-paths are shared with the team.' : '',
		END,
	]
		.filter(Boolean)
		.join('\n')

	let existing = ''
	try {
		existing = readFileSync(gitignorePath, 'utf-8')
	} catch {
		// file doesn't exist — we'll create it
	}

	if (existing.includes(START)) {
		// Replace the existing block in place (idempotent on re-install).
		const re = new RegExp(`${START}[\\s\\S]*?${END}\\n?`)
		writeFileSync(gitignorePath, existing.replace(re, `${block}\n`), 'utf-8')
	} else {
		// Append (with a separating blank line if the file had prior content).
		const sep = existing.length > 0 && !existing.endsWith('\n\n') ? '\n\n' : ''
		writeFileSync(gitignorePath, existing + sep + block + '\n', 'utf-8')
	}
}

async function runKernelInstall(installTarget, version, interactiveTty) {
	if (!interactiveTty) {
		return installKernel(installTarget, version)
	}
	const clack = await import('@clack/prompts')
	const spinner = clack.spinner()
	spinner.start('Installing Aperant kernel into .aperant/deps/ (one-time, ~10-30s)')
	const result = installKernel(installTarget, version)
	if (result.status === 'installed') {
		spinner.stop(`Kernel ready (${Math.round((result.durationMs ?? 0) / 100) / 10}s)`)
	} else {
		spinner.stop(`Kernel install ${result.status} — ${result.reason ?? 'unknown'}`, 1)
	}
	return result
}

/**
 * Final-screen framed "next steps" box, tailored to the user's answers.
 * Only rendered in interactive TTY mode — the JSON output from finishInit
 * is always the machine-readable source of truth.
 *
 * @param {InstallAnswers} answers
 * @param {Array<{ runtime: string, installRoot: string|null, files: string[], skipped: string[] }>} results
 * @param {string} version
 */
async function renderOutro(answers, results, version, kernelResult) {
	const clack = await import('@clack/prompts')
	const fileCount = results.reduce((n, r) => n + r.files.length, 0)

	// Next-steps: lead with `/apt "..."`, not `/apt plan "..."`. The whole
	// premise of the framework is that `/apt` routes automatically — asking
	// users to memorize subcommands defeats that. Sub-skills are surfaced
	// only when they're the unambiguous right answer (e.g. `/apt:scan` for
	// brownfield feature-registry bootstrap) or optional polish
	// (`/apt:setup` for knobs we didn't cover during install).
	const lines = []
	let step = 1
	if (answers.scanNow || answers.archetype.type === 'brownfield') {
		lines.push(`${step++}. Build feature map:   /apt:scan`)
	}
	lines.push(`${step++}. Start a task:        /apt "describe what to build"`)
	if (answers.visibility === 'team') {
		lines.push(`${step++}. Team roster:         /apt:setup   (teammates + sharing)`)
	}
	// /apt:setup still covers real ground we don't touch during install —
	// be explicit so users understand why they might want to run it.
	lines.push(`${step++}. Advanced tuning:     /apt:setup   (docs, design, orchestration)`)

	clack.note(lines.join('\n'), 'Next steps')

	if (kernelResult && kernelResult.status !== 'installed') {
		clack.note(
			`Kernel install ${kernelResult.status}: ${kernelResult.reason ?? 'unknown'}\nRun manually: npm install --prefix .aperant/deps @aperant/framework@${version}`,
			'Kernel warning',
		)
	}

	const xverifyCopy =
		answers.crossVerificationTools.length > 0
			? `x-verify: ${answers.crossVerificationTools.join(', ')}`
			: 'x-verify off'
	clack.note(
		'Relaunch Claude Code / Codex / your AI CLI in this directory\nso the new .claude/ + .codex/ surfaces are picked up.',
		'Activate',
	)
	clack.outro(`Installed ${fileCount} files · v${version} · ${xverifyCopy}`)
}

/**
 * Resolve the deduplicated set of root instruction-file paths the selected
 * runtimes read. Each chosen runtime contributes its `nativeInstructionFile`
 * (claude→CLAUDE.md, codex/opencode→AGENTS.md, gemini→GEMINI.md); runtimes
 * mapped to `null` install to their own surface and contribute nothing.
 * Filenames are deduplicated (codex + opencode both map to AGENTS.md → one
 * target).
 *
 * Resolution rules:
 *   - Multiple distinct targets (e.g. `--claude --codex`): write all of them
 *     verbatim — that is the whole point of the fix (AC1, AC2, AC5).
 *   - Exactly one resolved target that does NOT exist AND it is CLAUDE.md, but
 *     AGENTS.md already exists (the `--claude` with a pre-seeded AGENTS.md and
 *     no CLAUDE.md case, Risk 4 / AC9): redirect to the existing AGENTS.md
 *     rather than creating an orphan CLAUDE.md. This is safe ONLY for the
 *     claude runtime because native Claude Code reads AGENTS.md as well as
 *     CLAUDE.md. The redirect is NOT applied for gemini (reads only GEMINI.md)
 *     or codex/opencode (read only AGENTS.md) — redirecting those to an
 *     unrelated existing file would reintroduce the very visibility gap this
 *     fix closes, so their absent target is simply created.
 *   - Zero resolved targets (no selected runtime maps to an instruction file):
 *     fall back to the legacy heuristic — CLAUDE.md if present, else AGENTS.md
 *     if present, else a new CLAUDE.md.
 *
 * @param {string[]} chosenIds
 * @param {string} targetDir
 * @returns {string[]} absolute instruction-file paths, deduplicated
 */
function resolveInstructionTargets(chosenIds, targetDir) {
	const filenames = []
	for (const id of chosenIds) {
		const runtime = getRuntime(id)
		const file = runtime?.nativeInstructionFile
		if (file && !filenames.includes(file)) filenames.push(file)
	}
	const claudeMdPath = join(targetDir, 'CLAUDE.md')
	const agentsMdPath = join(targetDir, 'AGENTS.md')
	const resolved = filenames.map((name) => join(targetDir, name))

	if (resolved.length === 0) {
		// No selected runtime maps to an instruction file — legacy heuristic.
		if (existsSync(claudeMdPath)) return [claudeMdPath]
		if (existsSync(agentsMdPath)) return [agentsMdPath]
		return [claudeMdPath]
	}

	if (resolved.length === 1 && !existsSync(resolved[0])) {
		// Single registry target that doesn't exist yet. The ONLY safe redirect
		// is claude→AGENTS.md (Risk 4 / AC9): native Claude Code reads AGENTS.md
		// too, so when CLAUDE.md is absent but AGENTS.md exists (e.g. a prior
		// codex install), writing AGENTS.md still serves claude and avoids an
		// orphan CLAUDE.md. gemini/codex/opencode read only their own native
		// file — redirecting them to an unrelated existing file would hide the
		// catalog from their CLI, so we just create their target.
		if (resolved[0] === claudeMdPath && existsSync(agentsMdPath)) return [agentsMdPath]
		return resolved
	}

	// One or more registry targets, at least one exists OR multiple distinct
	// targets — write the registry-resolved set verbatim.
	return resolved
}

/**
 * Synchronous tail of init — runs after the runtime-install promise
 * resolves. Performs all .aperant/ + Claude-hook + remote-warning work
 * unchanged from the legacy implementation.
 *
 * @param {string} targetDir
 * @param {string} installTarget
 * @param {string} canonicalRoot
 * @param {string} version
 * @param {string[]} chosenIds
 * @param {Array<{ runtime: string, installRoot: string|null, files: string[], skipped: string[] }>} runtimeResults
 * @param {InstallAnswers} answers        Interview answers merged into config.json.
 * @param {boolean} suppressStdout         When true (interactive TTY), the
 *                                         structured envelope is written to
 *                                         `.aperant/install/last-init.json`
 *                                         instead of stdout, so the clack UI
 *                                         stays clean. Non-TTY callers still
 *                                         get the envelope on stdout.
 * @param {import('../install/install-kernel.mjs').InstallKernelResult} [kernelResult]
 *                                         Result of provisioning `.aperant/
 *                                         deps/` — surfaced in the envelope so
 *                                         downstream tooling can detect a
 *                                         degraded install.
 * @param {boolean} [forceOverwriteCatalog=false]
 *                                         FRAMEWORK-BUG-023 — bypass the
 *                                         foreign-row check in
 *                                         injectInstructionFile when the user
 *                                         explicitly opted in via
 *                                         --force-overwrite-catalog.
 * @param {object|null} [legacyCleanup=null]
 *                                         FRAMEWORK-BUG-021 — outcome of the
 *                                         codex `.agents/skills/` cleanup
 *                                         step. `null` when codex was not in
 *                                         the runtime set; otherwise the
 *                                         shape `{ runtime, ran, … }` from
 *                                         `runLegacyCleanup`.
 */
function finishInit(
	targetDir,
	installTarget,
	canonicalRoot,
	version,
	chosenIds,
	runtimeResults,
	answers,
	suppressStdout = false,
	kernelResult = null,
	forceOverwriteCatalog = false,
	legacyCleanup = null,
) {
	writeLastInstallSummary(targetDir, runtimeResults, version)

	const aperantDir = join(targetDir, '.aperant')
	mkdirSync(join(aperantDir, 'proof'), { recursive: true })

	for (const subdir of [
		'context/installed',
		'context/notes',
		'standards',
		'features',
		'locks',
		'events',
		'team-status',
		'roundtables',
		'stress-tests',
		'tasks',
		'paused',
	]) {
		mkdirSync(join(aperantDir, subdir), { recursive: true })
	}

	const rationalizationsSource = resolve(canonicalRoot, 'templates', 'rationalizations')
	const rationalizationsDest = join(aperantDir, 'rationalizations')
	if (existsSync(rationalizationsSource)) {
		mkdirSync(rationalizationsDest, { recursive: true })
		for (const f of readdirSync(rationalizationsSource)) {
			const dest = join(rationalizationsDest, f)
			if (!existsSync(dest)) copyFileSync(join(rationalizationsSource, f), dest)
		}
	}

	const configTemplate = resolve(canonicalRoot, 'templates', 'config.json')
	const configDest = join(aperantDir, 'config.json')
	const configLockPath = join(aperantDir, 'config.lock-init')
	let configCreated = false
	let configMigrated = false
	if (existsSync(configTemplate) && !existsSync(configDest)) {
		assertConfigWriteAllowed('init')
		withFileLock(
			configLockPath,
			() => {
				if (!existsSync(configDest)) {
					const template = JSON.parse(readFileSync(configTemplate, 'utf-8'))
					// AUDIT-001 (0.7.2 S1) — pass targetDir so the cross_verification.tools
					// per-device field lands in `config.local.json`, not the shared
					// `config.json`. Matches the upgrade-path migrator which already
					// moves the same field via migrateDeferredFieldsToLocalConfig.
					applyInterviewAnswers(template, answers, targetDir)
					atomicWriteJson(configDest, template)
					configCreated = true
				}
			},
			{ projectRoot: targetDir },
		)
	} else if (
		answers.migration &&
		(answers.migration.applied_defaults.length > 0 || answers.migration.auto_added.length > 0) &&
		existsSync(configDest)
	) {
		// Upgrade path — apply the migration plan back to disk so the new
		// keys + bumped version land. requires_explicit was either resolved
		// (TTY prompts / --yes / --accept-new-defaults) or empty; if it's
		// non-empty we never reach finishInit (cmdInit short-circuits on
		// the requires_input envelope).
		assertConfigWriteAllowed('init')
		withFileLock(
			configLockPath,
			() => {
				atomicWriteJson(configDest, answers.migration.migrated)
				configMigrated = true
			},
			{ projectRoot: targetDir },
		)
	}

	// AUDIT-001 — UPGRADE-only migrator. Moves the 5 deferred fields out
	// of `config.json` into `config.local.json` on real upgrade paths
	// (configMigrated === true), NOT on fresh seeds — the freshly-seeded
	// template config.json IS the post-migration state, so the migrator
	// would just round-trip with no observable benefit AND would trip
	// the next init invocation's no-drift detection. Idempotent on
	// re-run. Per-field independence per ID-08 — one failure does NOT
	// block the others.
	if (configMigrated && existsSync(configDest)) {
		try {
			migrateDeferredFieldsToLocalConfig(targetDir, AUDIT_001_LOCAL_FIELDS)
		} catch {
			/* never block init on telemetry-only migrator */
		}
	}

	const stateTemplate = resolve(canonicalRoot, 'templates', 'state.json')
	const stateDest = join(aperantDir, 'state.json')
	let stateCreated = false
	if (existsSync(stateTemplate) && !existsSync(stateDest)) {
		const stateData = JSON.parse(readFileSync(stateTemplate, 'utf-8'))
		stateData.initialized_at = new Date().toISOString()
		stateData.last_activity = new Date().toISOString()
		atomicWriteJson(stateDest, stateData)
		stateCreated = true
	}

	const seedsDest = join(aperantDir, 'roadmap', 'seeds.json')
	let seedsCreated = false
	if (!existsSync(seedsDest)) {
		try {
			mkdirSync(dirname(seedsDest), { recursive: true })
			const now = new Date().toISOString()
			atomicWriteJson(seedsDest, { version: 1, updated_at: now, seeds: [] })
			seedsCreated = true
		} catch {
			/* non-fatal */
		}
	}

	const effectiveConfig = existsSync(configDest)
		? JSON.parse(readFileSync(configDest, 'utf-8'))
		: {}
	const sharePolicy = resolveSharePolicy(effectiveConfig)
	const aperantGitignorePath = join(aperantDir, '.gitignore')
	if (!existsSync(aperantGitignorePath)) {
		writeFileSync(aperantGitignorePath, buildAperantGitignoreTemplate(sharePolicy), 'utf-8')
	}

	// Project-root .gitignore — without these entries the user sees every
	// installed file (.claude/**, .codex/**, .aperant/**) as an untracked
	// change in `git status`. The per-.aperant/.gitignore above is a deny-
	// by-default template that only matters when `.aperant/` is tracked at
	// the project level; for solo users we simply ignore the whole tree.
	injectProjectGitignore(targetDir, runtimeResults, answers.visibility === 'team')

	for (const dir of ['workflows', 'templates', 'prompts']) {
		const src = resolve(canonicalRoot, dir)
		if (existsSync(src)) {
			copyDirRecursive(src, join(aperantDir, dir))
		}
	}

	const installedContextSrc = resolve(canonicalRoot, 'context', 'installed')
	if (existsSync(installedContextSrc)) {
		copyDirRecursive(installedContextSrc, join(aperantDir, 'context', 'installed'))
	}

	// Resolve every distinct instruction file the selected runtimes read, then
	// preflight ALL of them before writing ANY — so a single foreign-row abort
	// leaves no file partially written (US-03 / AC3). Each chosen runtime maps
	// to its `nativeInstructionFile` (claude→CLAUDE.md, codex/opencode→AGENTS.md,
	// gemini→GEMINI.md); null-mapped runtimes install to their own surface and
	// contribute no target. When the selected runtimes resolve to zero targets,
	// or none of the resolved targets exist on disk, fall back to the legacy
	// "pick the existing file" heuristic so e.g. `--claude` with a pre-seeded
	// AGENTS.md (and no CLAUDE.md) still writes AGENTS.md (Risk 4 / AC9).
	const instructionTargets = resolveInstructionTargets(chosenIds, targetDir)
	// FRAMEWORK-BUG-023 — generate the section from discoverSkills before
	// handing it to the writer. The writer also runs a foreign-row defensive
	// check; if rows present in any file aren't in the generated set we
	// signal exit-2 via the __earlyExit sentinel so cmdInit can route the
	// envelope through exitWith() without writing a malformed install.
	const aptSection = buildAptSection({
		canonicalRoot,
		targetDir,
		cli: detectAgentRuntime(),
	})
	// Preflight pass — check every target with the write-free helper; collect
	// all aborting targets so the error lists every offender, not just the first.
	const abortingTargets = []
	for (const filePath of instructionTargets) {
		const check = checkInstructionFile(filePath, { aptSection, force: forceOverwriteCatalog })
		if (check.status === 'aborted-foreign-rows') {
			abortingTargets.push({ path: basename(filePath), foreignRowIds: check.foreignRowIds })
		}
	}
	if (abortingTargets.length > 0) {
		const allForeignRowIds = [...new Set(abortingTargets.flatMap((t) => t.foreignRowIds))]
		const fileList = abortingTargets.map((t) => t.path).join(', ')
		return {
			__earlyExit: exitWith(
				{
					ok: false,
					status: 'error',
					command: 'init',
					code: 'instruction-file.foreign-rows',
					message:
						`${fileList} contains skill rows not present in the installed framework: ` +
						`${allForeignRowIds.join(', ')}. ` +
						'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
					foreignRowIds: allForeignRowIds,
					instructionFile: { path: abortingTargets[0].path, status: 'aborted-foreign-rows' },
					instructionFiles: abortingTargets.map((t) => ({
						path: t.path,
						status: 'aborted-foreign-rows',
					})),
				},
				2,
			),
		}
	}
	// Write pass — no target aborted in the preflight, so write all.
	const instructionFiles = instructionTargets.map((filePath) => {
		const outcome = injectInstructionFile(filePath, {
			aptSection,
			force: forceOverwriteCatalog,
		})
		return {
			path: basename(filePath),
			status: outcome.status,
			foreignRowIds: outcome.foreignRowIds,
		}
	})
	// TOCTOU guard: if a file gained foreign rows BETWEEN the preflight check and
	// the write (e.g. another process wrote to it), surface the same error envelope
	// the preflight uses rather than silently accepting partial writes.
	const writtenAborts = instructionFiles.filter((f) => f.status === 'aborted-foreign-rows')
	if (writtenAborts.length > 0) {
		const allForeignRowIds = [...new Set(writtenAborts.flatMap((f) => f.foreignRowIds ?? []))]
		const fileList = writtenAborts.map((f) => f.path).join(', ')
		return {
			__earlyExit: exitWith(
				{
					ok: false,
					status: 'error',
					command: 'init',
					code: 'instruction-file.foreign-rows',
					message:
						`${fileList} contains skill rows not present in the installed framework: ` +
						`${allForeignRowIds.join(', ')}. ` +
						'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
					foreignRowIds: allForeignRowIds,
					instructionFile: { path: writtenAborts[0].path, status: 'aborted-foreign-rows' },
					instructionFiles: writtenAborts.map((f) => ({
						path: f.path,
						status: 'aborted-foreign-rows',
					})),
				},
				2,
			),
		}
	}
	// Primary entry drives the backwards-compat singular alias + structure key.
	const primaryInstructionFile = instructionFiles[0]

	const installedClaude = chosenIds.includes('claude') || existsSync(join(targetDir, '.claude'))
	const precompactResult = installedClaude
		? installPrecompactHook(targetDir)
		: 'skipped (no .claude/)'
	const sessionStartResult = installedClaude
		? installSessionStartHook(targetDir)
		: 'skipped (no .claude/)'

	const enabledToggles = collectEnabledToggles(sharePolicy)
	const remoteInfo = detectPublicRemote(targetDir)
	let publicRemoteWarning = null
	if (remoteInfo.isPublic && enabledToggles.length > 0) {
		publicRemoteWarning = {
			remote: remoteInfo.url,
			host: remoteInfo.host,
			enabledToggles,
			instruction:
				'Run /apt:setup and choose a visibility preset, or set share.* = false in .aperant/config.json, before committing .aperant/*',
		}
	}

	const totalFiles = runtimeResults.reduce((acc, r) => acc + r.files.length, 0)
	const skipped = runtimeResults.reduce((acc, r) => acc + r.skipped.length, 0)

	const structure = {
		'.aperant/config.json': configCreated ? 'created' : 'already exists',
		'.aperant/state.json': stateCreated ? 'created' : 'already exists',
		'.aperant/roadmap/seeds.json': seedsCreated ? 'created' : 'already exists',
		'.aperant/proof/': 'evidence output directory',
		'.aperant/context/installed/': 'framework-shipped context (durable-source)',
		'.aperant/context/notes/': 'runtime decision ledgers (local-private)',
		'.aperant/standards/': 'discovered conventions',
		'.aperant/features/': 'feature registry',
		'.aperant/locks/': 'coordination locks (git-ignored)',
		'.aperant/events/': 'event log (git-ignored)',
		'.aperant/prompts/': 'agent prompt templates',
		'.aperant/install/last-install.json': 'cross-runtime install summary',
		'.claude/settings.local.json': `PreCompact hook ${precompactResult}; SessionStart hook ${sessionStartResult}`,
	}
	for (const f of instructionFiles) {
		structure[f.path] = `APT section ${f.status}`
	}
	if (kernelResult) {
		structure['.aperant/deps/'] =
			kernelResult.status === 'installed'
				? `local kernel v${version} ready`
				: `kernel ${kernelResult.status} — ${kernelResult.reason ?? 'unknown'}`
	}
	for (const r of runtimeResults) {
		if (!r.installRoot) continue
		structure[r.installRoot.replace(installTarget + '/', '')] =
			`${r.files.length} files (${r.runtime})`
	}

	// C47 D-04 — auto-chain apt:bootstrap so the user has a PROJECT.md +
	// product-context.json ready immediately. The LLM-synthesized prose only
	// lands when the user runs /apt:bootstrap interactively; this auto-chain
	// is the deterministic half (template scaffold + sidecar derive) so
	// downstream skills always have the files they look for.
	const bootstrapResult = answers.skipBootstrap
		? { status: 'skipped', reason: '--skip-bootstrap flag' }
		: runBootstrapAutoChain(targetDir)
	if (bootstrapResult.status === 'ok') {
		structure['PROJECT.md'] = bootstrapResult.projectMdStatus
		structure['.aperant/product-context.json'] = bootstrapResult.sidecarStatus
	}

	const envelope = {
		status: 'ok',
		command: 'init',
		projectDir: targetDir,
		installTarget,
		runtimes: runtimeResults.map((r) => ({
			runtime: r.runtime,
			install_root: r.installRoot,
			file_count: r.files.length,
			skipped_count: r.skipped.length,
		})),
		framework_version: version,
		configCreated,
		configMigrated,
		// Plural contract (ID-03): one entry per instruction file written.
		// `instructionFile` (singular) is a backwards-compat alias = first entry.
		instructionFiles,
		instructionFile: primaryInstructionFile,
		stateCreated,
		seedsCreated,
		publicRemoteWarning,
		structure,
		bootstrap: bootstrapResult,
		// Non-TTY agent-aware install fields (spec R2 + R4). Always emitted
		// (even when empty) so downstream tooling can rely on the shape.
		applied_defaults: answers.applied_defaults ?? [],
		explicit_inputs: answers.explicit_inputs ?? [],
		agent_runtime: answers.agent_runtime ?? detectAgentRuntime(),
		migration: answers.migration
			? {
					applied_defaults: answers.migration.applied_defaults,
					requires_explicit: answers.migration.requires_explicit,
					auto_added: answers.migration.auto_added,
				}
			: null,
		message: `Installed ${totalFiles} files across ${runtimeResults.length} runtime(s)${
			skipped > 0 ? ` (${skipped} skipped)` : ''
		}`,
		next: 'Run /apt in your AI CLI — the router picks the right skill automatically.',
		kernel: kernelResult
			? {
					status: kernelResult.status,
					path: kernelResult.kernelPath,
					durationMs: kernelResult.durationMs,
					...(kernelResult.reason ? { reason: kernelResult.reason } : {}),
				}
			: null,
		// FRAMEWORK-BUG-021 — always-present envelope field. `null` when
		// codex was not in the runtime set; otherwise the cleanup outcome
		// (uses `{ ran: false, reason }` for every skip case so parsers
		// see a uniform shape).
		legacy_cleanup: legacyCleanup,
	}

	if (suppressStdout) {
		// Interactive TTY path — persist the envelope to disk for tooling and
		// post-hoc inspection, but keep it out of stdout so the clack UI
		// renders cleanly. cmdInit itself returns undefined in this case so
		// wrap() lands on legacy-tolerance (no JSON envelope on top of clack).
		atomicWriteJson(join(aperantDir, 'install', 'last-init.json'), envelope)
	}

	// One-time UX hint: explain that .aperant/config.json is intentionally
	// committed (team-shared prefs) so users don't mistake the M-status of
	// it after `/apt:setup` for a missing gitignore entry. Only fires on
	// genuine first-install (configCreated === true) — subsequent re-install
	// noops MUST stay silent. Suppressible via APT_SUPPRESS_INIT_HINTS=1.
	// Stderr only (matches the gitignore-drift warning channel) so the
	// JSON envelope on stdout stays parseable.
	if (configCreated) {
		emitInitConfigHint()
	}

	// Non-TTY path: caller (cmdInit) wraps this envelope in ok() and returns
	// it through wrap()'s standard JSON output path.
	return envelope
}

/**
 * @param {ReturnType<typeof resolveSharePolicy>} policy
 * @returns {string[]}
 */
function collectEnabledToggles(policy) {
	const toggles = []
	if (policy.roadmap) toggles.push('share.roadmap')
	if (policy.tasks) toggles.push('share.tasks')
	if (policy.team?.roster) toggles.push('share.team.roster')
	if (policy.decisions) toggles.push('share.decisions')
	if (policy.digests) toggles.push('share.digests')
	return toggles
}

/**
 * C47 D-04 — deterministic bootstrap auto-chain run at the tail of init.
 *
 * Runs the safe, non-interactive half of apt:bootstrap:
 *   1. Mode detect (greenfield / brownfield via D-03).
 *   2. Scaffold PROJECT.md from templates/project-md.md if missing — never
 *      overwrites an existing PROJECT.md (that's the user's prose).
 *   3. Derive `.aperant/product-context.json` from whatever PROJECT.md is
 *      on disk (the freshly scaffolded template OR the pre-existing one).
 *
 * The LLM-synthesized prose interview is left to interactive invocations of
 * /apt:bootstrap. This auto-chain just guarantees the files downstream
 * skills look for are present after init, so the user doesn't hit a
 * "no product context" footgun on the very next `/apt` call.
 *
 * Never throws. On any error (template missing, malformed PROJECT.md, I/O
 * failure), returns `{ status: 'error', reason }` so the init envelope
 * surfaces the problem instead of aborting the whole install.
 *
 * @param {string} projectDir
 * @returns {{ status: 'ok' | 'error' | 'skipped', mode?: string, projectMdStatus?: string, sidecarStatus?: string, reason?: string }}
 */
function runBootstrapAutoChain(projectDir) {
	try {
		const projectMdPath = join(projectDir, 'PROJECT.md')
		const sidecarPath = join(projectDir, '.aperant', 'product-context.json')
		const templatePath = resolve(__dirname, '..', '..', '..', 'templates', 'project-md.md')

		const detected = detectMode(projectDir)

		let projectMdStatus
		if (existsSync(projectMdPath)) {
			projectMdStatus = 'existing (preserved)'
		} else if (!existsSync(templatePath)) {
			return { status: 'error', reason: `template missing at ${templatePath}` }
		} else {
			const body = readFileSync(templatePath, 'utf-8')
			writeFileSync(projectMdPath, body, 'utf-8')
			projectMdStatus = 'scaffolded from template'
		}

		const markdown = readFileSync(projectMdPath, 'utf-8')
		let packageName = null
		let packageDescription = null
		const pkgPath = join(projectDir, 'package.json')
		if (existsSync(pkgPath)) {
			try {
				const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
				packageName = typeof pkg?.name === 'string' ? pkg.name : null
				packageDescription = typeof pkg?.description === 'string' ? pkg.description : null
			} catch {
				/* best-effort */
			}
		}
		const sidecar = deriveSidecar(markdown, { packageName, packageDescription })
		atomicWriteJson(sidecarPath, sidecar)

		return {
			status: 'ok',
			mode: detected.mode,
			projectMdStatus,
			sidecarStatus: 'derived from PROJECT.md',
		}
	} catch (err) {
		const reason = err instanceof Error ? err.message : String(err)
		return { status: 'error', reason }
	}
}
